diff --git a/.gitignore b/.gitignore index 62209487c..7616e4628 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,9 @@ out/ build/ !gradle/wrapper/gradle-wrapper.jar +# Lock file the Room Gradle plugin drops in a module root when it copies schemas +*.lck + # Signing files .signing/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6aad6c6e2..0e8648d4d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -118,7 +118,8 @@ dependencies { implementation(projects.feature.autofill) implementation(projects.feature.settings) implementation(projects.feature.backup) - implementation(projects.migrationCreateAccess) + implementation(projects.migration.createAccess) + implementation(projects.migration.legacyData) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) diff --git a/build.gradle.kts b/build.gradle.kts index 2a6973f7b..3a2f4f628 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,6 +3,7 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.androidx.room) apply false + alias(libs.plugins.androidx.room3) apply false alias(libs.plugins.kotlin.parcelize) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.serialization) apply false diff --git a/core/item/schemas/de.davis.keygo.core.item.data.local.datasource.ItemDatabase/1.json b/core/item/schemas/de.davis.keygo.core.item.data.local.datasource.ItemDatabase/1.json index 7f75af623..6e3252f8a 100644 --- a/core/item/schemas/de.davis.keygo.core.item.data.local.datasource.ItemDatabase/1.json +++ b/core/item/schemas/de.davis.keygo.core.item.data.local.datasource.ItemDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "d827ca95e51c22ef1dc56c2eb260f41c", + "identityHash": "4f0b0d6a7ba5ff447e745fb0f08d8be3", "entities": [ { "tableName": "vault", @@ -54,7 +54,7 @@ }, { "tableName": "item", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `vault_id` BLOB NOT NULL, `name` TEXT NOT NULL, `note` TEXT, `item_type` TEXT NOT NULL, `pinned` INTEGER NOT NULL, `wrapped_key` BLOB NOT NULL, `key_nonce` BLOB NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`vault_id`) REFERENCES `vault`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `vault_id` BLOB NOT NULL, `name` TEXT NOT NULL, `note` TEXT, `item_type` TEXT NOT NULL, `pinned` INTEGER NOT NULL, `wrapped_key` BLOB NOT NULL, `key_nonce` BLOB NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`vault_id`) REFERENCES `vault`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", @@ -102,6 +102,17 @@ "columnName": "key_nonce", "affinity": "BLOB", "notNull": true + }, + { + "fieldPath": "timestamp.createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp.modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER" } ], "primaryKey": { @@ -597,7 +608,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd827ca95e51c22ef1dc56c2eb260f41c')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4f0b0d6a7ba5ff447e745fb0f08d8be3')" ] } } \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/datasource/ItemDatabase.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/datasource/ItemDatabase.kt index cbfe872d1..4c1f22574 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/datasource/ItemDatabase.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/datasource/ItemDatabase.kt @@ -72,7 +72,7 @@ internal class DatabaseModule { Room.databaseBuilder( context, ItemDatabase::class.java, - "secure_element_database", + "keygo_database", ).build() @Single diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/ItemEntity.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/ItemEntity.kt index 5404eddd2..e117fb391 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/ItemEntity.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/ItemEntity.kt @@ -35,4 +35,5 @@ internal data class ItemEntity( val pinned: Boolean, @Embedded val keyInformation: KeyInformation, + @Embedded val timestamp: Timestamp, ) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/Timestamp.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/Timestamp.kt new file mode 100644 index 000000000..d26345ff0 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/Timestamp.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.core.item.data.local.entity + +import androidx.room.ColumnInfo + +internal data class Timestamp( + @ColumnInfo("created_at") + val createdAt: Long, + @ColumnInfo("modified_at") + val modifiedAt: Long?, +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapper.kt index bc400155d..be71012de 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapper.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapper.kt @@ -18,6 +18,7 @@ internal fun CreditCardProjection.toDomain() = CreditCard( vaultId = item.itemEntity.vaultId, name = item.itemEntity.name, keyInformation = item.itemEntity.keyInformation.toDomain(), + timestamp = item.itemEntity.timestamp.toDomain(), tags = item.tags.map(TagEntity::toDomain).toSet(), note = item.itemEntity.note, pinned = item.itemEntity.pinned, diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapper.kt index 192e9299f..0b97f4c98 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapper.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapper.kt @@ -18,6 +18,7 @@ internal fun Item.toData() = ItemEntity( pinned = pinned, keyInformation = keyInformation.toEntity(), + timestamp = timestamp.toEntity(), ) internal fun LightweightItem.toDomain() = LiteItem.Concrete( diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapper.kt index cee8d9457..a5ac86f6a 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapper.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapper.kt @@ -41,6 +41,7 @@ internal fun LoginProjection.toDomain(): Login = Login( name = item.itemEntity.name, note = item.itemEntity.note, keyInformation = item.itemEntity.keyInformation.toDomain(), + timestamp = item.itemEntity.timestamp.toDomain(), tags = item.tags.map(TagEntity::toDomain).toSet(), pinned = item.itemEntity.pinned, ) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/TimestampMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/TimestampMapper.kt new file mode 100644 index 000000000..3047f778e --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/TimestampMapper.kt @@ -0,0 +1,15 @@ +package de.davis.keygo.core.item.data.mapper + +import de.davis.keygo.core.item.data.local.entity.Timestamp +import kotlin.time.Instant +import de.davis.keygo.core.item.domain.model.Timestamp as DomainTimestamp + +internal fun DomainTimestamp.toEntity(): Timestamp = Timestamp( + createdAt = createdAt.toEpochMilliseconds(), + modifiedAt = modifiedAt?.toEpochMilliseconds(), +) + +internal fun Timestamp.toDomain(): DomainTimestamp = DomainTimestamp( + createdAt = Instant.fromEpochMilliseconds(createdAt), + modifiedAt = modifiedAt?.let { Instant.fromEpochMilliseconds(it) }, +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/RoomTransactionRunner.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/TransactionRunnerImpl.kt similarity index 78% rename from core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/RoomTransactionRunner.kt rename to core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/TransactionRunnerImpl.kt index 9747c52ae..f228be101 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/RoomTransactionRunner.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/TransactionRunnerImpl.kt @@ -2,11 +2,11 @@ package de.davis.keygo.core.item.data.repository import androidx.room.withTransaction import de.davis.keygo.core.item.data.local.datasource.ItemDatabase -import de.davis.keygo.core.item.domain.TransactionRunner +import de.davis.keygo.core.item.domain.repository.TransactionRunner import org.koin.core.annotation.Single @Single -internal class RoomTransactionRunner( +internal class TransactionRunnerImpl( private val database: ItemDatabase, ) : TransactionRunner { override suspend fun runInTransaction(block: suspend () -> R): R = diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/TransactionRunner.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/TransactionRunner.kt deleted file mode 100644 index 8ba9acb9f..000000000 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/TransactionRunner.kt +++ /dev/null @@ -1,13 +0,0 @@ -package de.davis.keygo.core.item.domain - -/** - * Runs a block of persistence work as a single atomic unit: either every write inside [block] - * commits, or none of them do. If the calling coroutine is cancelled or [block] throws, the - * transaction rolls back. - * - * Nested calls join the outermost transaction rather than opening a new one, so callers can safely - * compose operations that already run their own transactions internally. - */ -interface TransactionRunner { - suspend fun runInTransaction(block: suspend () -> R): R -} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CreditCard.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CreditCard.kt index 2b0be44a4..9d7849085 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CreditCard.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CreditCard.kt @@ -12,6 +12,7 @@ data class CreditCard( override val vaultId: VaultId, override val name: String, override val keyInformation: KeyInformation, + override val timestamp: Timestamp, override val tags: Set, override val note: String?, override val pinned: Boolean, diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Item.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Item.kt index a3a620441..6c1c1c481 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Item.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Item.kt @@ -13,4 +13,5 @@ sealed interface Item : LiteItem { val keyInformation: KeyInformation val tags: Set val note: String? + val timestamp: Timestamp } diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Login.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Login.kt index 196943279..88f76bd90 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Login.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Login.kt @@ -17,6 +17,7 @@ data class Login( override val vaultId: VaultId, override val name: String, override val keyInformation: KeyInformation, + override val timestamp: Timestamp, override val tags: Set = emptySet(), override val note: String?, override val pinned: Boolean, @@ -27,7 +28,7 @@ data class Login( val hasAnyContent: Boolean get() = !username.isNullOrBlank() - || passwordCredential != null - || totp != null - || passkeyRPs.isNotEmpty() + || passwordCredential != null + || totp != null + || passkeyRPs.isNotEmpty() } diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Timestamp.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Timestamp.kt new file mode 100644 index 000000000..1ade3e2bf --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Timestamp.kt @@ -0,0 +1,9 @@ +package de.davis.keygo.core.item.domain.model + +import kotlin.time.Clock +import kotlin.time.Instant + +data class Timestamp( + val createdAt: Instant = Clock.System.now(), + val modifiedAt: Instant? = null, +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/YearMonthExt.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/YearMonthExt.kt new file mode 100644 index 000000000..f134974fc --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/YearMonthExt.kt @@ -0,0 +1,14 @@ +package de.davis.keygo.core.item.domain.model + +import java.time.YearMonth +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException + +// "yy" parses into the 2000-2099 range, which is correct for card expirations. +private val EXPIRATION_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("MM/yy") + +fun String.toYearMonthOrNull(): YearMonth? = try { + YearMonth.parse(this, EXPIRATION_FORMATTER) +} catch (_: DateTimeParseException) { + null +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/TransactionRunner.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/TransactionRunner.kt new file mode 100644 index 000000000..e5dbf379f --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/TransactionRunner.kt @@ -0,0 +1,5 @@ +package de.davis.keygo.core.item.domain.repository + +interface TransactionRunner { + suspend fun runInTransaction(block: suspend () -> R): R +} \ No newline at end of file diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/ItemDaoSearchTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/ItemDaoSearchTest.kt index 9d3fe715b..3522574bf 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/ItemDaoSearchTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/ItemDaoSearchTest.kt @@ -5,6 +5,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver import de.davis.keygo.core.item.data.local.datasource.ItemDatabase import de.davis.keygo.core.item.data.local.entity.ItemEntity import de.davis.keygo.core.item.data.local.entity.TagEntity +import de.davis.keygo.core.item.data.local.entity.Timestamp import de.davis.keygo.core.item.data.local.entity.VaultEntity import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.alias.VaultId @@ -67,6 +68,7 @@ internal class ItemDaoSearchTest { itemType = VaultItemType.Login, pinned = false, keyInformation = EntityKeyInformation(byteArrayOf(), byteArrayOf()), + timestamp = Timestamp(createdAt = 0L, modifiedAt = null), ) ) if (tags.isNotEmpty()) diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/TagDaoTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/TagDaoTest.kt index 796aaa4d7..662fa8ada 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/TagDaoTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/TagDaoTest.kt @@ -5,6 +5,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver import de.davis.keygo.core.item.data.local.datasource.ItemDatabase import de.davis.keygo.core.item.data.local.entity.ItemEntity import de.davis.keygo.core.item.data.local.entity.TagEntity +import de.davis.keygo.core.item.data.local.entity.Timestamp import de.davis.keygo.core.item.data.local.entity.VaultEntity import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.alias.VaultId @@ -64,6 +65,7 @@ internal class TagDaoTest { itemType = VaultItemType.Login, pinned = false, keyInformation = EntityKeyInformation(byteArrayOf(), byteArrayOf()), + timestamp = Timestamp(createdAt = 0L, modifiedAt = null), ) ) return id diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapperTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapperTest.kt index a714737df..957f08fcd 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapperTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapperTest.kt @@ -12,12 +12,14 @@ import de.davis.keygo.core.item.domain.model.CreditCard import de.davis.keygo.core.item.domain.model.EncryptedPayload import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.Tag +import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.generated.domain.model.VaultItemType import java.time.YearMonth import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull import de.davis.keygo.core.item.data.local.entity.KeyInformation as EntityKeyInformation +import de.davis.keygo.core.item.data.local.entity.Timestamp as EntityTimestamp class CreditCardMapperTest { @@ -128,6 +130,7 @@ class CreditCardMapperTest { cardNumber = cardNumber, cvv = cvv, expirationDate = expirationDate, + timestamp = Timestamp(), ) private fun baseProjection( @@ -157,6 +160,7 @@ class CreditCardMapperTest { wrappedKey = byteArrayOf(), keyNonce = byteArrayOf(), ), + timestamp = EntityTimestamp(createdAt = 0L, modifiedAt = null), ), tags = tags, ), diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/DomainMapperTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/DomainMapperTest.kt index 648fa2b01..69102d4cf 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/DomainMapperTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/DomainMapperTest.kt @@ -10,6 +10,7 @@ import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.model.PasswordCredential import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.PasswordSecret +import de.davis.keygo.core.item.domain.model.Timestamp import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -34,6 +35,7 @@ class DomainMapperTest { keyInformation = KeyInformation(wrappedKey = byteArrayOf(), keyNonce = byteArrayOf()), note = null, pinned = false, + timestamp = Timestamp(), ) // toData diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapperTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapperTest.kt index 69cc97104..3e495ed65 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapperTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapperTest.kt @@ -12,6 +12,7 @@ import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.model.PasswordCredential import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.PasswordSecret +import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.generated.domain.model.VaultItemType import kotlin.test.Test import kotlin.test.assertContentEquals @@ -40,6 +41,7 @@ class ItemMapperTest { pinned = pinned, vaultId = newVaultId(), keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + timestamp = Timestamp(), ) // Item.toData() -> ItemEntity diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapperTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapperTest.kt index 4c1065fe4..c088dacb0 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapperTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapperTest.kt @@ -16,7 +16,9 @@ import de.davis.keygo.core.item.domain.model.PasswordCredential import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.PasswordSecret import de.davis.keygo.core.item.domain.model.Tag +import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.core.item.data.local.entity.Timestamp as EntityTimestamp import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -103,6 +105,7 @@ class LoginMapperTest { wrappedKey = byteArrayOf(), keyNonce = byteArrayOf(), ), + timestamp = EntityTimestamp(createdAt = 0L, modifiedAt = null), ), tags = tags, ), @@ -125,5 +128,6 @@ class LoginMapperTest { keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), note = null, pinned = false, + timestamp = Timestamp(), ) } diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImplTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImplTest.kt index e6062adde..b5a866883 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImplTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImplTest.kt @@ -14,6 +14,7 @@ import de.davis.keygo.core.item.domain.alias.newVaultId import de.davis.keygo.core.item.domain.model.CreditCard import de.davis.keygo.core.item.domain.model.EncryptedPayload import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.util.isFailure import de.davis.keygo.core.util.isSuccess @@ -34,6 +35,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue import de.davis.keygo.core.item.data.local.entity.KeyInformation as EntityKeyInformation +import de.davis.keygo.core.item.data.local.entity.Timestamp as EntityTimestamp class CreditCardRepositoryImplTest { @@ -146,6 +148,7 @@ class CreditCardRepositoryImplTest { cardNumber = CreditCard.CardNumber(EncryptedPayload.EMPTY), cvv = CreditCard.CVV(EncryptedPayload.EMPTY), expirationDate = YearMonth.of(2030, 12), + timestamp = Timestamp(), ) private fun projection(id: ItemId) = CreditCardProjection( @@ -168,6 +171,7 @@ class CreditCardRepositoryImplTest { wrappedKey = byteArrayOf(), keyNonce = byteArrayOf(), ), + timestamp = EntityTimestamp(createdAt = 0L, modifiedAt = null), ), tags = emptySet(), ), diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImplTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImplTest.kt index 76e72f354..b9b6123f9 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImplTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImplTest.kt @@ -21,6 +21,7 @@ import de.davis.keygo.core.item.domain.model.PasswordCredential import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.PasswordSecret import de.davis.keygo.core.item.domain.model.Tag +import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.domain.model.Totp import de.davis.keygo.core.util.isFailure import de.davis.keygo.core.util.isSuccess @@ -228,6 +229,7 @@ class LoginRepositoryImplTest { vaultId = newVaultId(), keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), tags = tags, + timestamp = Timestamp(), ) } diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/TransactionRunnerImplTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/TransactionRunnerImplTest.kt new file mode 100644 index 000000000..e20535410 --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/TransactionRunnerImplTest.kt @@ -0,0 +1,68 @@ +package de.davis.keygo.core.item.data.repository + +import androidx.room.withTransaction +import de.davis.keygo.core.item.data.local.datasource.ItemDatabase +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** + * Verifies that [TransactionRunnerImpl] delegates to [ItemDatabase.withTransaction] + * transparently: it returns whatever the block returns, propagates whatever the block throws, and + * invokes the block exactly once inside the transaction. + * + * This does not cover real SQLite commit/rollback. That is Room's own behaviour, and this project + * has no way to exercise it in a JVM unit test without Robolectric, which is not used here. + */ +internal class TransactionRunnerImplTest { + + private val database = mockk() + private val runner = TransactionRunnerImpl(database) + + @BeforeTest + fun setUp() { + mockkStatic("androidx.room.RoomDatabaseKt") + coEvery { database.withTransaction(any Any?>()) } coAnswers { + secondArg Any?>().invoke() + } + } + + @AfterTest + fun tearDown() { + unmockkStatic("androidx.room.RoomDatabaseKt") + } + + @Test + fun `returns the block result`() = runTest { + assertEquals(42, runner.runInTransaction { 42 }) + } + + @Test + fun `propagates an exception thrown inside the block`() = runTest { + val error = IllegalStateException("boom") + + val thrown = assertFailsWith { + runner.runInTransaction { throw error } + } + + assertEquals(error, thrown) + } + + @Test + fun `invokes the block exactly once inside the transaction`() = runTest { + var invocations = 0 + + runner.runInTransaction { invocations++ } + + assertEquals(1, invocations) + coVerify(exactly = 1) { database.withTransaction(any Any?>()) } + } +} diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/CreditCardTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/CreditCardTest.kt index b8d8b608d..9e59088b9 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/CreditCardTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/CreditCardTest.kt @@ -61,5 +61,6 @@ class CreditCardTest { cardNumber = CreditCard.CardNumber(EncryptedPayload.EMPTY), cvv = CreditCard.CVV(EncryptedPayload.EMPTY), expirationDate = YearMonth.of(2030, 12), + timestamp = Timestamp(), ) } diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/LoginTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/LoginTest.kt index 271d3505b..f0395d38b 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/LoginTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/LoginTest.kt @@ -73,5 +73,6 @@ class LoginTest { keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), note = null, pinned = false, + timestamp = Timestamp(), ) } diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCaseTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCaseTest.kt index 38c059181..7dad88e28 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCaseTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCaseTest.kt @@ -6,6 +6,7 @@ import de.davis.keygo.core.item.domain.alias.newVaultId import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.model.Tag +import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.util.domain.usecase.SortUseCase import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest @@ -34,6 +35,7 @@ class ObserveAllTagsSortedUseCaseTest { tags = tags, note = null, pinned = false, + timestamp = Timestamp(), ) @Test diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertItemUseCaseTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertItemUseCaseTest.kt index 0aa1cd592..eb303355a 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertItemUseCaseTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertItemUseCaseTest.kt @@ -11,6 +11,7 @@ import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.model.PasswordCredential import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.PasswordSecret +import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.util.isFailure import de.davis.keygo.core.util.isSuccess import kotlinx.coroutines.test.runTest @@ -43,6 +44,7 @@ class UpsertItemUseCaseTest { wrappedKey = byteArrayOf(), keyNonce = byteArrayOf(), ), + timestamp = Timestamp(), ) private fun testCreditCard(name: String = "Test Card") = CreditCard( @@ -60,6 +62,7 @@ class UpsertItemUseCaseTest { cardNumber = CreditCard.CardNumber(EncryptedPayload.EMPTY), cvv = CreditCard.CVV(EncryptedPayload.EMPTY), expirationDate = YearMonth.of(2030, 12), + timestamp = Timestamp(), ) @Test diff --git a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeCreditCardRepository.kt b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeCreditCardRepository.kt index 1717bc56a..cada680d0 100644 --- a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeCreditCardRepository.kt +++ b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeCreditCardRepository.kt @@ -20,7 +20,7 @@ import kotlinx.coroutines.flow.update */ class FakeCreditCardRepository : CreditCardRepository { - internal val store = MutableStateFlow>(emptyMap()) + val store = MutableStateFlow>(emptyMap()) /** Error returned by the next [createOrUpdateCreditCard] call (cleared after use). */ var createOrUpdateError: Throwable? = null diff --git a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeTransactionRunner.kt b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeTransactionRunner.kt index fa75d66c6..48cf1f900 100644 --- a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeTransactionRunner.kt +++ b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeTransactionRunner.kt @@ -1,6 +1,6 @@ package de.davis.keygo.core.item -import de.davis.keygo.core.item.domain.TransactionRunner +import de.davis.keygo.core.item.domain.repository.TransactionRunner import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -12,7 +12,7 @@ import kotlinx.coroutines.withContext * verify a caller wraps all of its writes in a single atomic unit rather than one per item. * - Nested calls increment the count like any other; the fakes it wraps do not model rollback, so * tests using it verify wrapping structure, not the rollback guarantee (that is Room's, covered by - * the real [de.davis.keygo.core.item.domain.TransactionRunner] implementation). + * the real [TransactionRunner] implementation). * - [block] runs in a *separate coroutine*, mirroring Room's `withTransaction`, which dispatches * onto its own transaction thread. This is not incidental: a pass-through fake that ran [block] * inline let a `Flow` invariant violation reach production, because callers emitting progress @@ -24,7 +24,10 @@ class FakeTransactionRunner : TransactionRunner { var enteredCount = 0 private set + var failWith: Throwable? = null + override suspend fun runInTransaction(block: suspend () -> R): R { + failWith?.let { failWith = null; throw it } enteredCount++ return withContext(Dispatchers.Default) { block() } } diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/SessionImpl.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/SessionImpl.kt index 48b9b6138..f2b5c0fdd 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/SessionImpl.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/SessionImpl.kt @@ -1,6 +1,9 @@ package de.davis.keygo.core.security.data import de.davis.keygo.core.security.domain.Session +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import org.koin.core.annotation.Single import javax.security.auth.DestroyFailedException @@ -9,12 +12,22 @@ internal class SessionImpl : Session { private var _ark: ByteArray? = null + // Replayed so a collector wired up after an unlock still sees it, and buffered with + // DROP_OLDEST so emitting can never suspend or fail inside startSession. + private val _sessionStarts = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + override val sessionStarts: Flow = _sessionStarts + override val ark: ByteArray get() = _ark ?: throw IllegalStateException("No active session") override fun startSession(ark: ByteArray) { endSession() _ark = ark + _sessionStarts.tryEmit(Unit) } override fun endSession() { @@ -25,4 +38,4 @@ internal class SessionImpl : Session { } _ark = null } -} \ No newline at end of file +} diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/Session.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/Session.kt index 1b095e202..338732356 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/Session.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/Session.kt @@ -1,9 +1,21 @@ package de.davis.keygo.core.security.domain +import kotlinx.coroutines.flow.Flow + interface Session { val ark: ByteArray + /** + * Emits once per [startSession], for work that has to happen on every unlock however the user + * got there. + * + * A session is started from the auth screen, from the autofill service and from both passkey + * activities. A caller that enumerated those instead would go stale the next time a fifth way + * in is added, and would do so silently. + */ + val sessionStarts: Flow + fun startSession(ark: ByteArray) fun endSession() -} \ No newline at end of file +} diff --git a/core/security/src/test/kotlin/de/davis/keygo/core/security/data/SessionImplTest.kt b/core/security/src/test/kotlin/de/davis/keygo/core/security/data/SessionImplTest.kt index b0e3d106e..bed79086b 100644 --- a/core/security/src/test/kotlin/de/davis/keygo/core/security/data/SessionImplTest.kt +++ b/core/security/src/test/kotlin/de/davis/keygo/core/security/data/SessionImplTest.kt @@ -1,5 +1,10 @@ package de.davis.keygo.core.security.data +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -58,4 +63,33 @@ class SessionImplTest { session.endSession() session.endSession() // should not throw } + + /** + * What the v1 import's retry rests on: it is not enough for the first unlock to be announced, + * because the run it starts is the one that may have left work behind. + */ + @Test + fun `a later start reaches a collector that already handled an earlier one`() = runTest { + val starts = mutableListOf() + session.sessionStarts.onEach { starts += it }.launchIn(backgroundScope) + + session.startSession(generateArk()) + runCurrent() + session.startSession(generateArk()) + runCurrent() + + assertEquals(2, starts.size) + } + + /** + * Replayed so the work bound to an unlock still runs when its collector attaches after the + * fact. Nothing collecting must not be able to swallow the one signal that a v1 import, or + * anything wired here later, gets to hear. + */ + @Test + fun `a start is replayed to a collector that arrives afterwards`() = runTest { + session.startSession(generateArk()) + + session.sessionStarts.first() + } } diff --git a/core/security/src/test/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCaseTest.kt b/core/security/src/test/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCaseTest.kt index 21de887f8..dc73f1ced 100644 --- a/core/security/src/test/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCaseTest.kt +++ b/core/security/src/test/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCaseTest.kt @@ -9,6 +9,7 @@ import de.davis.keygo.core.item.domain.alias.newVaultId import de.davis.keygo.core.item.domain.model.CreditCard import de.davis.keygo.core.item.domain.model.EncryptedPayload import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.domain.model.Vault import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider import de.davis.keygo.core.security.domain.model.CryptoScopeError @@ -52,6 +53,7 @@ class ItemWithCryptoScopeUseCaseTest { cardNumber = CreditCard.CardNumber(EncryptedPayload.EMPTY), cvv = CreditCard.CVV(EncryptedPayload.EMPTY), expirationDate = YearMonth.of(2030, 12), + timestamp = Timestamp(), ) @BeforeTest diff --git a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProvider.kt b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProvider.kt index aa3635085..4df7fa738 100644 --- a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProvider.kt +++ b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProvider.kt @@ -41,6 +41,9 @@ class FakeCryptographicScopeProvider( var rewrapResult: Result = Result.Success(KeyInformation(byteArrayOf(), byteArrayOf())) + /** When set, [itemScope] fails with this error instead of running its block. */ + var itemScopeFailure: CryptoScopeError? = null + override suspend fun itemScope( itemId: ItemId, block: suspend CryptographicScope.() -> R @@ -66,28 +69,32 @@ class FakeCryptographicScopeProvider( wrappedVaultKeyInformation: WrappedVaultKeyInformation, wrappedItemKeyInformation: WrappedItemKeyInformation, block: suspend CryptographicScope.() -> R, - ): Result = block( - object : CryptographicScope { - override suspend fun ByteArray.encrypt( - label: String, - context: CoroutineContext, - ): CryptographicData { - callHistory += CallHistory.EncryptCall(label, this.copyOf()) - return CryptographicData(data = transform(this), iv = IV) - } + ): Result { + itemScopeFailure?.let { return Result.Failure(it) } - override suspend fun CryptographicData.decrypt( - label: String, - context: CoroutineContext, - ): ByteArray { - callHistory += CallHistory.DecryptCall(label, this.data.copyOf()) - return transform(data) - } + return block( + object : CryptographicScope { + override suspend fun ByteArray.encrypt( + label: String, + context: CoroutineContext, + ): CryptographicData { + callHistory += CallHistory.EncryptCall(label, this.copyOf()) + return CryptographicData(data = transform(this), iv = IV) + } - override suspend fun wrapCurrentItemKey(context: CoroutineContext): KeyInformation = - KeyInformation(byteArrayOf(), byteArrayOf()) - } - ).let(Result::Success) + override suspend fun CryptographicData.decrypt( + label: String, + context: CoroutineContext, + ): ByteArray { + callHistory += CallHistory.DecryptCall(label, this.data.copyOf()) + return transform(data) + } + + override suspend fun wrapCurrentItemKey(context: CoroutineContext): KeyInformation = + KeyInformation(byteArrayOf(), byteArrayOf()) + }, + ).let(Result::Success) + } override suspend fun rewrapItemKey( sourceVault: WrappedVaultKeyInformation, diff --git a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeSession.kt b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeSession.kt index 49ff11216..84c831d87 100644 --- a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeSession.kt +++ b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeSession.kt @@ -1,6 +1,9 @@ package de.davis.keygo.core.security.crypto import de.davis.keygo.core.security.domain.Session +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow /** * A fake implementation of [Session] that provides a fixed DEK for testing purposes. @@ -15,13 +18,24 @@ class FakeSession( override val ark: ByteArray get() = _ark ?: throw IllegalStateException("FakeSession not started") + private val _sessionStarts = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + override val sessionStarts: Flow = _sessionStarts + init { - if (startOnConstruct) _ark = ByteArray(32) { it.toByte() } + if (startOnConstruct) { + _ark = ByteArray(32) { it.toByte() } + _sessionStarts.tryEmit(Unit) + } } override fun startSession(ark: ByteArray) { _ark = ark startSessionCalled = true + _sessionStarts.tryEmit(Unit) } override fun endSession() { diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts index 0122bec5f..e421f02a6 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -11,7 +11,7 @@ dependencies { implementation(projects.core.identity) implementation(projects.core.item) implementation(projects.core.ui) - implementation(projects.migrationCreateAccess) + implementation(projects.migration.createAccess) implementation(libs.androidx.navigation.compose) } diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModel.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModel.kt index 1896882f5..a25da324d 100644 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModel.kt +++ b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModel.kt @@ -21,7 +21,7 @@ import de.davis.keygo.feature.auth.presentation.model.BiometricRequest import de.davis.keygo.feature.auth.presentation.model.UIPasswordError import de.davis.keygo.migration.create_access.domain.usecase.ClearMainPasswordUseCase import de.davis.keygo.migration.create_access.domain.usecase.HasMainPasswordUseCase -import de.davis.keygo.migration.create_access.domain.usecase.ValidateMainPassword +import de.davis.keygo.migration.create_access.domain.usecase.ValidateMainPasswordUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview @@ -50,7 +50,7 @@ internal class AuthViewModel( // ---- Migration ---- hasV1MainPassword: HasMainPasswordUseCase, - private val validateMainPassword: ValidateMainPassword, + private val validateMainPassword: ValidateMainPasswordUseCase, private val clearMainPasswordUseCase: ClearMainPasswordUseCase, // ------------------- diff --git a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/AddRegistrableDomainsToLoginUseCaseTest.kt b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/AddRegistrableDomainsToLoginUseCaseTest.kt index be7421d0d..df94c45c6 100644 --- a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/AddRegistrableDomainsToLoginUseCaseTest.kt +++ b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/AddRegistrableDomainsToLoginUseCaseTest.kt @@ -8,6 +8,7 @@ import de.davis.keygo.core.item.domain.alias.newVaultId import de.davis.keygo.core.item.domain.model.DomainInfo import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.item.domain.model.Timestamp import kotlinx.coroutines.test.runTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -42,6 +43,7 @@ class AddRegistrableDomainsToLoginUseCaseTest { pinned = false, vaultId = vaultId, keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + timestamp = Timestamp(), ) @Test diff --git a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/DoesItemHaveDomainReferencesUseCaseTest.kt b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/DoesItemHaveDomainReferencesUseCaseTest.kt index e02afc09c..3b5ede7cd 100644 --- a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/DoesItemHaveDomainReferencesUseCaseTest.kt +++ b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/DoesItemHaveDomainReferencesUseCaseTest.kt @@ -8,6 +8,7 @@ import de.davis.keygo.core.item.domain.alias.newVaultId import de.davis.keygo.core.item.domain.model.DomainInfo import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.item.domain.model.Timestamp import kotlinx.coroutines.test.runTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -43,6 +44,7 @@ class DoesItemHaveDomainReferencesUseCaseTest { pinned = false, vaultId = vaultId, keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + timestamp = Timestamp(), ) @Test diff --git a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt index bb6b8ca8c..cbf700ff0 100644 --- a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt +++ b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt @@ -18,6 +18,7 @@ import de.davis.keygo.core.item.domain.model.DomainInfo import de.davis.keygo.core.item.domain.model.EncryptedPayload import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.domain.model.Totp import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider import de.davis.keygo.core.util.Result @@ -147,6 +148,7 @@ internal class AutofillViewModelTest { keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), note = null, pinned = false, + timestamp = Timestamp(), ) private fun minimalTotp(loginId: ItemId) = Totp( diff --git a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/dataset/SuggestionFinderTest.kt b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/dataset/SuggestionFinderTest.kt index 5d3c2e5c7..b15a21b7e 100644 --- a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/dataset/SuggestionFinderTest.kt +++ b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/dataset/SuggestionFinderTest.kt @@ -12,6 +12,7 @@ import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.model.PasswordCredential import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.PasswordSecret +import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.domain.model.Totp import de.davis.keygo.core.security.domain.usecase.GetTdlMatchedLoginsUseCase import de.davis.keygo.feature.autofill.presentation.model.FieldType @@ -58,6 +59,7 @@ internal class SuggestionFinderTest { keyInformation = keyInfo, note = null, pinned = false, + timestamp = Timestamp(), ) private val lPassOnly = Login( id = newItemId(), @@ -70,6 +72,7 @@ internal class SuggestionFinderTest { keyInformation = keyInfo, note = null, pinned = false, + timestamp = Timestamp(), ) private val lBoth = Login( id = newItemId(), @@ -82,6 +85,7 @@ internal class SuggestionFinderTest { keyInformation = keyInfo, note = null, pinned = false, + timestamp = Timestamp(), ) private val lNeither = Login( id = newItemId(), @@ -94,6 +98,7 @@ internal class SuggestionFinderTest { keyInformation = keyInfo, note = null, pinned = false, + timestamp = Timestamp(), ) @Before @@ -196,6 +201,7 @@ internal class SuggestionFinderTest { keyInformation = keyInfo, note = null, pinned = false, + timestamp = Timestamp(), ) val extra2 = Login( id = newItemId(), @@ -208,6 +214,7 @@ internal class SuggestionFinderTest { keyInformation = keyInfo, note = null, pinned = false, + timestamp = Timestamp(), ) val extra3 = Login( id = newItemId(), @@ -220,6 +227,7 @@ internal class SuggestionFinderTest { keyInformation = keyInfo, note = null, pinned = false, + timestamp = Timestamp(), ) loginRepo.seed(extra1, extra2, extra3) val form = credentialsForm(listOf(field(FieldType.Credentials.Username))) diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupSession.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupSession.kt index 9c98fd429..b109b8db8 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupSession.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupSession.kt @@ -1,6 +1,8 @@ package de.davis.keygo.feature.backup.data import de.davis.keygo.core.security.domain.Session +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow /** * A read-only [Session] holding a recovered ARK for the duration of a single backup. It never @@ -10,6 +12,8 @@ internal class BackupSession(private val backupArk: ByteArray) : Session { override val ark: ByteArray get() = backupArk + override val sessionStarts: Flow = emptyFlow() + override fun startSession(ark: ByteArray) = error("BackupSession is read-only") diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupRestorer.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupRestorer.kt index 60278827c..eee5c8f53 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupRestorer.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupRestorer.kt @@ -1,9 +1,8 @@ package de.davis.keygo.feature.backup.domain -import de.davis.keygo.core.item.domain.TransactionRunner -import de.davis.keygo.core.item.domain.model.Vault import de.davis.keygo.core.item.domain.repository.CreditCardRepository import de.davis.keygo.core.item.domain.repository.LoginRepository +import de.davis.keygo.core.item.domain.repository.TransactionRunner import de.davis.keygo.core.item.domain.repository.VaultRepository import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.getOrNull diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/BackupTestData.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/BackupTestData.kt index 2e8fe2d68..3dc1c5b72 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/BackupTestData.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/BackupTestData.kt @@ -15,6 +15,7 @@ import de.davis.keygo.core.item.domain.model.PasswordCredential import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.PasswordSecret import de.davis.keygo.core.item.domain.model.Tag +import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.domain.model.Totp import de.davis.keygo.core.item.domain.model.Vault import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider @@ -76,6 +77,7 @@ fun testLogin( tags = tags.mapNotNull { Tag.of(it) }.toSet(), note = note, pinned = false, + timestamp = Timestamp(), ) fun testPasskey( @@ -114,4 +116,5 @@ fun testCard( cardNumber = number?.let { CreditCard.CardNumber(secretPayload(it)) }, cvv = cvv?.let { CreditCard.CVV(secretPayload(it)) }, expirationDate = expiration, + timestamp = Timestamp(), ) diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCase.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCase.kt index cb1d266f1..a89d66a55 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCase.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCase.kt @@ -4,6 +4,8 @@ import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.alias.VaultId import de.davis.keygo.core.item.domain.model.CreditCard import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Timestamp +import de.davis.keygo.core.item.domain.model.toYearMonthOrNull import de.davis.keygo.core.item.domain.repository.CreditCardRepository import de.davis.keygo.core.item.domain.repository.VaultRepository import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase @@ -23,9 +25,6 @@ import de.davis.keygo.rust.card.CardFormatter import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import org.koin.core.annotation.Single -import java.time.YearMonth -import java.time.format.DateTimeFormatter -import java.time.format.DateTimeParseException @Single class CreateNewOrUpdateCreditCardUseCase( @@ -95,6 +94,9 @@ class CreateNewOrUpdateCreditCardUseCase( keyInformation: KeyInformation, ): CreditCard = item.copy(vaultId = vaultId, keyInformation = keyInformation) + override fun touch(item: CreditCard, timestamp: Timestamp): CreditCard = + item.copy(timestamp = timestamp) + override suspend fun CryptographicScope.buildCreate( upsert: UpsertCreditCard, itemId: ItemId, @@ -119,6 +121,7 @@ class CreateNewOrUpdateCreditCardUseCase( cvv = encryptedCvv?.await(), expirationDate = upsert.expirationDate.getValue() ?.toYearMonthOrNull(), // toYearMonthOrNull will not return null, since isValidExpiration ensures the date to be correct + timestamp = Timestamp(), ) } @@ -146,14 +149,4 @@ class CreateNewOrUpdateCreditCardUseCase( ) } - private fun String.toYearMonthOrNull(): YearMonth? = try { - YearMonth.parse(this, EXPIRATION_FORMATTER) - } catch (_: DateTimeParseException) { - null - } - - companion object { - // "yy" parses into the 2000-2099 range, which is correct for card expirations. - private val EXPIRATION_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("MM/yy") - } } diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCase.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCase.kt index 0105f5952..f26ceb94a 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCase.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCase.kt @@ -7,6 +7,7 @@ import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.model.PasswordCredential import de.davis.keygo.core.item.domain.model.PasswordSecret +import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.domain.model.Totp import de.davis.keygo.core.item.domain.repository.LoginRepository import de.davis.keygo.core.item.domain.repository.VaultRepository @@ -68,6 +69,8 @@ class CreateNewOrUpdateLoginUseCase( override fun relocate(item: Login, vaultId: VaultId, keyInformation: KeyInformation): Login = item.copy(vaultId = vaultId, keyInformation = keyInformation) + override fun touch(item: Login, timestamp: Timestamp): Login = item.copy(timestamp = timestamp) + override suspend fun CryptographicScope.buildCreate( upsert: UpsertLogin, itemId: ItemId, @@ -100,6 +103,7 @@ class CreateNewOrUpdateLoginUseCase( pinned = false, keyInformation = keyInformation, vaultId = vaultId, + timestamp = Timestamp(), ) } diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateOrUpdateItemUseCase.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateOrUpdateItemUseCase.kt index 64497ade4..8433370a4 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateOrUpdateItemUseCase.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateOrUpdateItemUseCase.kt @@ -5,6 +5,7 @@ import de.davis.keygo.core.item.domain.alias.VaultId import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.item.domain.model.Item import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.domain.repository.VaultRepository import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase import de.davis.keygo.core.security.domain.crypto.CryptographicScope @@ -19,6 +20,7 @@ import de.davis.keygo.feature.item.core.domain.model.ItemUpsertError import de.davis.keygo.feature.item.core.domain.model.UpsertItem import de.davis.keygo.feature.item.core.domain.model.UpsertType import de.davisalessandro.keygo.rust.ItemAad +import kotlin.time.Clock /** * Shared create/update orchestration for every vault item type. Owns the scaffolding that is @@ -61,6 +63,9 @@ abstract class CreateOrUpdateItemUseCase( /** Returns a copy of [item] moved to [vaultId] with the re-wrapped [keyInformation]. */ protected abstract fun relocate(item: I, vaultId: VaultId, keyInformation: KeyInformation): I + /** Returns a copy of [item] with [timestamp] applied. */ + protected abstract fun touch(item: I, timestamp: Timestamp): I + suspend operator fun invoke(upsert: U): Result> { val errors = validate(upsert) if (errors.isNotEmpty()) return Result.Failure(errors) @@ -118,13 +123,15 @@ abstract class CreateOrUpdateItemUseCase( vaultId = existing.vaultId, ) - val item = cryptographicScopeProvider.itemScope( + val built = cryptographicScopeProvider.itemScope( wrappedVaultKeyInformation = sourceVault, wrappedItemKeyInformation = existing.wrappedItemKeyInformation(), ) { buildUpdate(upsert, existing) }.bind(ItemUpsertError::CryptoError) + val item = touch(built, built.timestamp.copy(modifiedAt = Clock.System.now())) + if (isEmpty(item, upsert)) return Result.Failure(ItemUpsertError.Empty) if (targetVaultId == null || targetVaultId == existing.vaultId) diff --git a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCaseTest.kt b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCaseTest.kt index bab7cfaa5..0085dc495 100644 --- a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCaseTest.kt +++ b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCaseTest.kt @@ -11,6 +11,7 @@ import de.davis.keygo.core.item.domain.alias.newVaultId import de.davis.keygo.core.item.domain.model.CreditCard import de.davis.keygo.core.item.domain.model.EncryptedPayload import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.domain.model.Vault import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider @@ -26,6 +27,7 @@ import de.davis.keygo.feature.item.core.domain.model.clear import de.davis.keygo.feature.item.core.domain.model.set import kotlinx.coroutines.test.runTest import java.time.YearMonth +import kotlin.time.Clock import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertContains @@ -414,6 +416,40 @@ class CreateNewOrUpdateCreditCardUseCaseTest { assertContentEquals(rewrapped.wrappedKey, stored.keyInformation.wrappedKey) } + // Timestamps + + @Test + fun `create sets createdAt to now and leaves modifiedAt null`() = runTest { + val before = Clock.System.now() + + val result = useCase( + UpsertCreditCard.create(vaultId = defaultVault.id, name = "My card", holder = "Holder") + ) + + val after = Clock.System.now() + val stored = storedById(result.getOrNull()) + assertNotNull(stored) + assertTrue(stored.timestamp.createdAt >= before && stored.timestamp.createdAt <= after) + assertNull(stored.timestamp.modifiedAt) + } + + @Test + fun `update sets modifiedAt to now and preserves createdAt`() = runTest { + val existing = testCard() + creditCardRepository.seed(existing) + + val before = Clock.System.now() + useCase(UpsertCreditCard.update(itemId = existing.id, name = set("New name"))) + val after = Clock.System.now() + + val updated = creditCardRepository.getCreditCardById(existing.id) + assertNotNull(updated) + assertEquals(existing.timestamp.createdAt, updated.timestamp.createdAt) + val modifiedAt = updated.timestamp.modifiedAt + assertNotNull(modifiedAt) + assertTrue(modifiedAt >= before && modifiedAt <= after) + } + private fun makeUseCase( cryptographicScopeProvider: CryptographicScopeProvider = cryptoProvider, cardFormatter: FakeCardFormatter = this.cardFormatter, @@ -428,6 +464,7 @@ class CreateNewOrUpdateCreditCardUseCaseTest { private fun testCard( name: String = "Test card", cvv: CreditCard.CVV? = null, + timestamp: Timestamp = Timestamp(), ) = CreditCard( id = newItemId(), vaultId = defaultVault.id, @@ -440,6 +477,7 @@ class CreateNewOrUpdateCreditCardUseCaseTest { cardNumber = CreditCard.CardNumber(EncryptedPayload.EMPTY), cvv = cvv, expirationDate = YearMonth.of(2030, 5), + timestamp = timestamp, ) private suspend fun storedById(id: ItemId?): CreditCard? { diff --git a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt index d3b07f9b7..133dc9221 100644 --- a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt +++ b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt @@ -14,6 +14,7 @@ import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.model.PasswordCredential import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.PasswordSecret +import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.domain.model.Totp import de.davis.keygo.core.item.domain.model.Vault import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase @@ -32,6 +33,7 @@ import de.davis.keygo.rust.totp.TotpService import de.davisalessandro.keygo.rust.Algorithm import de.davisalessandro.keygo.rust.TotpInfo import kotlinx.coroutines.test.runTest +import kotlin.time.Clock import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertContains @@ -745,6 +747,40 @@ class CreateNewOrUpdateLoginUseCaseTest { assertEquals("alice@github.com", totp.accountName) } + // Timestamps + + @Test + fun `create sets createdAt to now and leaves modifiedAt null`() = runTest { + val before = Clock.System.now() + + val result = useCase( + UpsertLogin.create(vaultId = defaultVault.id, name = "My site", password = "s3cr3t") + ) + + val after = Clock.System.now() + val stored = storedById(result.getOrNull()) + assertNotNull(stored) + assertTrue(stored.timestamp.createdAt >= before && stored.timestamp.createdAt <= after) + assertNull(stored.timestamp.modifiedAt) + } + + @Test + fun `update sets modifiedAt to now and preserves createdAt`() = runTest { + val existing = testLogin() + loginRepository.seed(existing) + + val before = Clock.System.now() + useCase(UpsertLogin.update(itemId = existing.id, name = set("New name"))) + val after = Clock.System.now() + + val updated = loginRepository.getLoginById(existing.id) + assertNotNull(updated) + assertEquals(existing.timestamp.createdAt, updated.timestamp.createdAt) + val modifiedAt = updated.timestamp.modifiedAt + assertNotNull(modifiedAt) + assertTrue(modifiedAt >= before && modifiedAt <= after) + } + // Helpers private fun makeUseCase( @@ -771,6 +807,7 @@ class CreateNewOrUpdateLoginUseCaseTest { score = passwordScore, ), totp: Totp? = null, + timestamp: Timestamp = Timestamp(), ) = Login( id = newItemId(), name = name, @@ -782,6 +819,7 @@ class CreateNewOrUpdateLoginUseCaseTest { pinned = false, vaultId = defaultVault.id, keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + timestamp = timestamp, ) private suspend fun storedById(id: ItemId?): Login? { diff --git a/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt b/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt index 21f2f81a6..41d59d7f8 100644 --- a/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt +++ b/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt @@ -10,6 +10,7 @@ import de.davis.keygo.core.item.domain.alias.newVaultId import de.davis.keygo.core.item.domain.model.CreditCard import de.davis.keygo.core.item.domain.model.EncryptedPayload import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.domain.model.Vault import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase @@ -68,6 +69,7 @@ class ViewCreditCardViewModelTest { cardNumber = encryptedCardNumber, cvv = null, expirationDate = null, + timestamp = Timestamp(), ) private val vaultRepository = FakeVaultRepository() diff --git a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt index fd6ab2c94..756aa955b 100644 --- a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt +++ b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt @@ -14,6 +14,7 @@ import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.model.PasswordCredential import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.PasswordSecret +import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.domain.model.Totp import de.davis.keygo.core.item.domain.model.Vault import de.davis.keygo.core.security.crypto.BindingCryptographicScopeProvider @@ -370,6 +371,7 @@ class MoveItemsToVaultUseCaseTest { pinned = pinned, vaultId = vault.id, keyInformation = wrapCurrentItemKey(), + timestamp = Timestamp(), ) }.assertSuccess() loginRepository.seed(login) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1e865096e..e9115ff64 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ semver = "0.2.5" koinBOM = "4.2.1" koin-plugin = "1.0.0" room = "2.8.4" +room3 = "3.0.0" sqlite = "2.6.2" ksp = "2.3.6" datastore = "1.2.1" @@ -92,8 +93,11 @@ koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compos koin-androidx-workmanager = { group = "io.insert-koin", name = "koin-androidx-workmanager" } androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room3-runtime = { group = "androidx.room3", name = "room3-runtime", version.ref = "room3" } +androidx-room3-testing = { group = "androidx.room3", name = "room3-testing", version.ref = "room3" } androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-room3-compiler = { group = "androidx.room3", name = "room3-compiler", version.ref = "room3" } androidx-sqlite-bundled = { group = "androidx.sqlite", name = "sqlite-bundled-jvm", version.ref = "sqlite" } androidx-datastore = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" } @@ -136,6 +140,7 @@ google-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } google-protobuf = { id = "com.google.protobuf", version.ref = "protobuf" } android-application = { id = "com.android.application", version.ref = "agp" } androidx-room = { id = "androidx.room", version.ref = "room" } +androidx-room3 = { id = "androidx.room3", version.ref = "room3" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/migration-create-access/src/main/AndroidManifest.xml b/migration-create-access/src/main/AndroidManifest.xml deleted file mode 100644 index a5918e68a..000000000 --- a/migration-create-access/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/migration-create-access/.gitignore b/migration/create-access/.gitignore similarity index 100% rename from migration-create-access/.gitignore rename to migration/create-access/.gitignore diff --git a/migration-create-access/build.gradle.kts b/migration/create-access/build.gradle.kts similarity index 100% rename from migration-create-access/build.gradle.kts rename to migration/create-access/build.gradle.kts diff --git a/migration-create-access/consumer-rules.pro b/migration/create-access/consumer-rules.pro similarity index 100% rename from migration-create-access/consumer-rules.pro rename to migration/create-access/consumer-rules.pro diff --git a/migration-create-access/proguard-rules.pro b/migration/create-access/proguard-rules.pro similarity index 100% rename from migration-create-access/proguard-rules.pro rename to migration/create-access/proguard-rules.pro diff --git a/migration/create-access/src/main/AndroidManifest.xml b/migration/create-access/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/migration/create-access/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/local/datasource/datastore/MainPasswordSerializer.kt b/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/local/datasource/datastore/MainPasswordSerializer.kt similarity index 99% rename from migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/local/datasource/datastore/MainPasswordSerializer.kt rename to migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/local/datasource/datastore/MainPasswordSerializer.kt index e7814311b..cebf6bf3f 100644 --- a/migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/local/datasource/datastore/MainPasswordSerializer.kt +++ b/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/local/datasource/datastore/MainPasswordSerializer.kt @@ -15,4 +15,4 @@ internal object MainPasswordSerializer : Serializer { override suspend fun writeTo(t: ProtoMainPassword, output: OutputStream) { t.writeTo(output) } -} \ No newline at end of file +} diff --git a/migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/mapper/MainPasswordMapper.kt b/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/mapper/MainPasswordMapper.kt similarity index 100% rename from migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/mapper/MainPasswordMapper.kt rename to migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/mapper/MainPasswordMapper.kt diff --git a/migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/repository/HashValidatorImpl.kt b/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/repository/HashValidatorImpl.kt similarity index 99% rename from migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/repository/HashValidatorImpl.kt rename to migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/repository/HashValidatorImpl.kt index 9f3304842..19bc32054 100644 --- a/migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/repository/HashValidatorImpl.kt +++ b/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/repository/HashValidatorImpl.kt @@ -13,4 +13,4 @@ internal class HashValidatorImpl : HashValidator { BCrypt.Version.VERSION_2A, LongPasswordStrategies.hashSha512(BCrypt.Version.VERSION_2A) ).verify(plainText, hash).verified -} \ No newline at end of file +} diff --git a/migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/repository/MainPasswordRepositoryImpl.kt b/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/repository/MainPasswordRepositoryImpl.kt similarity index 97% rename from migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/repository/MainPasswordRepositoryImpl.kt rename to migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/repository/MainPasswordRepositoryImpl.kt index a4c0cf166..613725556 100644 --- a/migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/repository/MainPasswordRepositoryImpl.kt +++ b/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/repository/MainPasswordRepositoryImpl.kt @@ -13,7 +13,7 @@ import org.koin.core.annotation.Single @Single internal class MainPasswordRepositoryImpl( - @MainPasswordQualifier + @param:MainPasswordQualifier private val dataStore: DataStore, ) : MainPasswordRepository { @@ -30,4 +30,4 @@ internal class MainPasswordRepositoryImpl( } } } -} \ No newline at end of file +} diff --git a/migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/di/MigrationCreateAccessModule.kt b/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/di/MigrationCreateAccessModule.kt similarity index 99% rename from migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/di/MigrationCreateAccessModule.kt rename to migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/di/MigrationCreateAccessModule.kt index 16cadd63d..bb87b8664 100644 --- a/migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/di/MigrationCreateAccessModule.kt +++ b/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/di/MigrationCreateAccessModule.kt @@ -25,4 +25,4 @@ object MigrationCreateAccessModule { @MainPasswordQualifier internal fun provideProtoMainPasswordDataStore(context: Context) = context.protoMainPasswordDataStore -} \ No newline at end of file +} diff --git a/migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/di/annotation/MainPasswordQualifier.kt b/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/di/annotation/MainPasswordQualifier.kt similarity index 100% rename from migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/di/annotation/MainPasswordQualifier.kt rename to migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/di/annotation/MainPasswordQualifier.kt diff --git a/migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/model/MainPassword.kt b/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/model/MainPassword.kt similarity index 93% rename from migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/model/MainPassword.kt rename to migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/model/MainPassword.kt index f7ecebf25..5f785bdf3 100644 --- a/migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/model/MainPassword.kt +++ b/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/model/MainPassword.kt @@ -2,4 +2,4 @@ package de.davis.keygo.migration.create_access.domain.model import java.time.Instant -internal data class MainPassword(val hash: String, val createdAt: Instant) \ No newline at end of file +internal data class MainPassword(val hash: String, val createdAt: Instant) diff --git a/migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/repository/HashValidator.kt b/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/repository/HashValidator.kt similarity index 99% rename from migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/repository/HashValidator.kt rename to migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/repository/HashValidator.kt index a7c9e62e3..c37cd9297 100644 --- a/migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/repository/HashValidator.kt +++ b/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/repository/HashValidator.kt @@ -6,4 +6,4 @@ internal interface HashValidator { plainText: ByteArray, hash: ByteArray ): Boolean -} \ No newline at end of file +} diff --git a/migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/repository/MainPasswordRepository.kt b/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/repository/MainPasswordRepository.kt similarity index 99% rename from migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/repository/MainPasswordRepository.kt rename to migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/repository/MainPasswordRepository.kt index 50a19ade3..bb5ed527e 100644 --- a/migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/repository/MainPasswordRepository.kt +++ b/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/repository/MainPasswordRepository.kt @@ -7,4 +7,4 @@ internal interface MainPasswordRepository { suspend fun getMainPassword(): MainPassword suspend fun clearMainPassword() -} \ No newline at end of file +} diff --git a/migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/ClearMainPasswordUseCase.kt b/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/ClearMainPasswordUseCase.kt similarity index 99% rename from migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/ClearMainPasswordUseCase.kt rename to migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/ClearMainPasswordUseCase.kt index 5e084e96a..583759ee3 100644 --- a/migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/ClearMainPasswordUseCase.kt +++ b/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/ClearMainPasswordUseCase.kt @@ -8,4 +8,4 @@ class ClearMainPasswordUseCase internal constructor( private val mainPasswordRepository: MainPasswordRepository ) { suspend operator fun invoke() = mainPasswordRepository.clearMainPassword() -} \ No newline at end of file +} diff --git a/migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/HasMainPasswordUseCase.kt b/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/HasMainPasswordUseCase.kt similarity index 99% rename from migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/HasMainPasswordUseCase.kt rename to migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/HasMainPasswordUseCase.kt index a78fdc849..7f2dc54c6 100644 --- a/migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/HasMainPasswordUseCase.kt +++ b/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/HasMainPasswordUseCase.kt @@ -10,4 +10,4 @@ class HasMainPasswordUseCase internal constructor( suspend operator fun invoke(): Boolean = mainPasswordRepository.getMainPassword().hash.isNotEmpty() -} \ No newline at end of file +} diff --git a/migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/ValidateMainPassword.kt b/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/ValidateMainPasswordUseCase.kt similarity index 78% rename from migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/ValidateMainPassword.kt rename to migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/ValidateMainPasswordUseCase.kt index a95ebb405..456bd896b 100644 --- a/migration-create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/ValidateMainPassword.kt +++ b/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/ValidateMainPasswordUseCase.kt @@ -5,9 +5,9 @@ import de.davis.keygo.migration.create_access.domain.repository.MainPasswordRepo import org.koin.core.annotation.Single @Single -class ValidateMainPassword internal constructor( +class ValidateMainPasswordUseCase internal constructor( private val hashValidator: HashValidator, - private val mainPasswordRepository: MainPasswordRepository + private val mainPasswordRepository: MainPasswordRepository, ) { @OptIn(ExperimentalStdlibApi::class) @@ -15,7 +15,7 @@ class ValidateMainPassword internal constructor( mainPasswordRepository.getMainPassword().hash.let { hashValidator.validateHash( plainText = password.toByteArray(), - hash = it.hexToByteArray() + hash = it.hexToByteArray(), ) } -} \ No newline at end of file +} diff --git a/migration-create-access/src/main/proto/main_password.proto b/migration/create-access/src/main/proto/main_password.proto similarity index 100% rename from migration-create-access/src/main/proto/main_password.proto rename to migration/create-access/src/main/proto/main_password.proto diff --git a/migration/legacy-data/build.gradle.kts b/migration/legacy-data/build.gradle.kts new file mode 100644 index 000000000..e6e1aafa3 --- /dev/null +++ b/migration/legacy-data/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + alias(libs.plugins.keygo.android.library) + alias(libs.plugins.androidx.room3) + alias(libs.plugins.google.ksp) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "de.davis.keygo.migration.legacy_data" + + testFixtures { + enable = true + } +} + +dependencies { + implementation(libs.androidx.room3.runtime) + ksp(libs.androidx.room3.compiler) + + implementation(libs.kotlinx.serialization.json) + + implementation(projects.core.item) + implementation(projects.core.security) + implementation(projects.core.util) + + testImplementation(libs.io.mockk) + testImplementation(libs.androidx.room3.testing) + testImplementation(libs.androidx.sqlite.bundled) + testImplementation(testFixtures(projects.core.item)) + testImplementation(testFixtures(projects.core.security)) + testImplementation(testFixtures(projects.core.util)) + + testFixturesImplementation(projects.core.util) + testFixturesImplementation(libs.androidx.room3.runtime) +} + +room3 { + schemaDirectory("$projectDir/schemas") +} + +// The tests seed from v1's exported schema JSON and so need to know where it lives. Handed over as +// a system property rather than resolved from a relative path inside the test, which would only +// work while the working directory happens to be the module directory. +tasks.withType().configureEach { + systemProperty("legacySchemaDir", "$projectDir/schemas") +} diff --git a/migration/legacy-data/consumer-rules.pro b/migration/legacy-data/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/migration/legacy-data/schemas/de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase/1.json b/migration/legacy-data/schemas/de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase/1.json new file mode 100644 index 000000000..293766b88 --- /dev/null +++ b/migration/legacy-data/schemas/de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase/1.json @@ -0,0 +1,78 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "6fb485e6e64b1e9cc5ceeb0440c58e23", + "entities": [ + { + "tableName": "SecureElement", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`data` BLOB, `title` TEXT, `type` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "detail", + "columnName": "data", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MasterPassword", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `hash` BLOB)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "BLOB", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6fb485e6e64b1e9cc5ceeb0440c58e23')" + ] + } +} \ No newline at end of file diff --git a/migration/legacy-data/schemas/de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase/2.json b/migration/legacy-data/schemas/de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase/2.json new file mode 100644 index 000000000..4793a226b --- /dev/null +++ b/migration/legacy-data/schemas/de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase/2.json @@ -0,0 +1,97 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "d5dd1a0120d5842be17e8ea9a19ee039", + "entities": [ + { + "tableName": "SecureElement", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`data` BLOB, `title` TEXT, `type` INTEGER NOT NULL, `favorite` INTEGER NOT NULL DEFAULT false, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `created_at` INTEGER, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "detail", + "columnName": "data", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MasterPassword", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `hash` BLOB)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "BLOB", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd5dd1a0120d5842be17e8ea9a19ee039')" + ] + } +} \ No newline at end of file diff --git a/migration/legacy-data/schemas/de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase/3.json b/migration/legacy-data/schemas/de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase/3.json new file mode 100644 index 000000000..8c5a9d886 --- /dev/null +++ b/migration/legacy-data/schemas/de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase/3.json @@ -0,0 +1,170 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "0a97d13a94575bc2ed2ab009853b0086", + "entities": [ + { + "tableName": "SecureElement", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`title` TEXT NOT NULL, `data` BLOB NOT NULL, `favorite` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `created_at` INTEGER DEFAULT CURRENT_TIMESTAMP, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamps.createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "defaultValue": "CURRENT_TIMESTAMP" + }, + { + "fieldPath": "timestamps.modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "Tag", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `tagId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tagId", + "columnName": "tagId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "tagId" + ] + }, + "indices": [ + { + "name": "index_Tag_name", + "unique": true, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Tag_name` ON `${TABLE_NAME}` (`name`)" + } + ] + }, + { + "tableName": "SecureElementTagCrossRef", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `tagId` INTEGER NOT NULL, PRIMARY KEY(`id`, `tagId`), FOREIGN KEY(`id`) REFERENCES `SecureElement`(`id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`tagId`) REFERENCES `Tag`(`tagId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tagId", + "columnName": "tagId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "tagId" + ] + }, + "indices": [ + { + "name": "index_SecureElementTagCrossRef_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SecureElementTagCrossRef_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_SecureElementTagCrossRef_tagId", + "unique": false, + "columnNames": [ + "tagId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SecureElementTagCrossRef_tagId` ON `${TABLE_NAME}` (`tagId`)" + } + ], + "foreignKeys": [ + { + "table": "SecureElement", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Tag", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "tagId" + ], + "referencedColumns": [ + "tagId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0a97d13a94575bc2ed2ab009853b0086')" + ] + } +} \ No newline at end of file diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyAesGcmCipher.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyAesGcmCipher.kt new file mode 100644 index 000000000..5b378bafc --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyAesGcmCipher.kt @@ -0,0 +1,33 @@ +package de.davis.keygo.migration.legacy_data.data.crypto + +import de.davis.keygo.migration.legacy_data.domain.crypto.LegacyCipher +import org.koin.core.annotation.Single +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Reverses v1's `Cryptography.encryptAES`, which wrote `IV(12) || AES-256-GCM ciphertext` with a + * 128 bit tag under the `password_manager_skey` Keystore alias. + */ +@Single +internal class LegacyAesGcmCipher : LegacyCipher { + + override fun decrypt(blob: ByteArray, key: SecretKey): ByteArray? { + if (blob.size <= IV_SIZE) return null + + return runCatching { + Cipher.getInstance(TRANSFORMATION) + .apply { + init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, blob, 0, IV_SIZE)) + } + .doFinal(blob, IV_SIZE, blob.size - IV_SIZE) + }.getOrNull() + } + + private companion object { + const val IV_SIZE = 12 + const val TAG_BITS = 128 + const val TRANSFORMATION = "AES/GCM/NoPadding" + } +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/json/LegacyDetailJson.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/json/LegacyDetailJson.kt new file mode 100644 index 000000000..c500a47f8 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/json/LegacyDetailJson.kt @@ -0,0 +1,30 @@ +package de.davis.keygo.migration.legacy_data.data.json + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** + * Mirrors what GSON wrote for `PasswordDetails` and `CreditCardDetails`. Every field is optional + * because the two shapes share one object and because v1 omitted nulls. + * + * `strength` stays a raw [JsonElement] so the parser can accept both the enum-name string and the + * older `{"type": ordinal}` object without a custom serializer. + */ +@Serializable +internal class LegacyDetailJson( + val type: Int? = null, + val username: String? = null, + val origin: String? = null, + val password: ByteArray? = null, + val strength: JsonElement? = null, + val cardholder: LegacyNameJson? = null, + val expirationDate: String? = null, + val cardNumber: String? = null, + val cvv: String? = null, +) + +@Serializable +internal data class LegacyNameJson( + val firstName: String? = null, + val lastName: String? = null, +) diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/json/LegacyDetailParser.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/json/LegacyDetailParser.kt new file mode 100644 index 000000000..8a4bb4198 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/json/LegacyDetailParser.kt @@ -0,0 +1,64 @@ +package de.davis.keygo.migration.legacy_data.data.json + +import de.davis.keygo.migration.legacy_data.domain.model.LEGACY_TYPE_CREDIT_CARD +import de.davis.keygo.migration.legacy_data.domain.model.LEGACY_TYPE_PASSWORD +import de.davis.keygo.migration.legacy_data.domain.model.LegacyDetail +import de.davis.keygo.migration.legacy_data.domain.model.LegacyStrength +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.intOrNull +import org.koin.core.annotation.Single + +@Single +internal class LegacyDetailParser { + + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + coerceInputValues = true + } + + /** Returns null for anything we cannot make sense of; the caller records that as a row failure. */ + fun parse(decrypted: ByteArray): LegacyDetail? { + if (decrypted.isEmpty()) return null + + val parsed = runCatching { + json.decodeFromString(decrypted.decodeToString()) + }.getOrNull() ?: return null + + return when (parsed.type) { + LEGACY_TYPE_PASSWORD -> LegacyDetail.Password( + username = parsed.username, + origin = parsed.origin, + password = parsed.password, + strength = parseStrength(parsed.strength), + ) + + LEGACY_TYPE_CREDIT_CARD -> LegacyDetail.CreditCard( + firstName = parsed.cardholder?.firstName, + lastName = parsed.cardholder?.lastName, + cardNumber = parsed.cardNumber, + cvv = parsed.cvv, + expirationDate = parsed.expirationDate, + ) + + else -> null + } + } + + private fun parseStrength(element: JsonElement?): LegacyStrength? = + when (element) { + null -> null + // A safe cast rather than `jsonPrimitive`, which throws when `type` holds an object or + // an array. Nothing v1 wrote looks like that, but a throw here would escape `parse` and + // turn one odd row into a crash instead of a skipped row. + is JsonObject -> (element["type"] as? JsonPrimitive) + ?.intOrNull + ?.let(LegacyStrength.entries::getOrNull) + + is JsonPrimitive -> LegacyStrength.entries.firstOrNull { it.name == element.content } + else -> null + } +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/dao/LegacyElementDao.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/dao/LegacyElementDao.kt new file mode 100644 index 000000000..d018bec94 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/dao/LegacyElementDao.kt @@ -0,0 +1,35 @@ +package de.davis.keygo.migration.legacy_data.data.local.dao + +import androidx.room3.Dao +import androidx.room3.Insert +import androidx.room3.Query +import androidx.room3.Transaction +import de.davis.keygo.migration.legacy_data.data.local.entity.LegacySecureElementEntity +import de.davis.keygo.migration.legacy_data.data.local.entity.LegacySecureElementTagCrossRef +import de.davis.keygo.migration.legacy_data.data.local.entity.LegacyTagEntity +import de.davis.keygo.migration.legacy_data.data.local.pojo.LegacyElementWithTags + +@Dao +internal interface LegacyElementDao { + + @Transaction + @Query("SELECT * FROM SecureElement") + suspend fun getAllWithTags(): List + + @Query("SELECT COUNT(*) FROM SecureElement") + suspend fun count(): Int + + @Query("DELETE FROM SecureElement WHERE id IN (:ids)") + suspend fun deleteByIds(ids: List) + + // Insert methods exist for tests only. Production code never writes elements or tags; it + // reads them and deletes the ones it has imported. + @Insert + suspend fun insertElement(element: LegacySecureElementEntity): Long + + @Insert + suspend fun insertTag(tag: LegacyTagEntity): Long + + @Insert + suspend fun insertCrossRef(crossRef: LegacySecureElementTagCrossRef) +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/AndroidLegacyDatabaseProvider.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/AndroidLegacyDatabaseProvider.kt new file mode 100644 index 000000000..b47aeb6d1 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/AndroidLegacyDatabaseProvider.kt @@ -0,0 +1,58 @@ +package de.davis.keygo.migration.legacy_data.data.local.datasource + +import android.content.Context +import androidx.room3.Room +import androidx.sqlite.SQLiteDriver +import de.davis.keygo.migration.legacy_data.data.local.migration.LegacyMigration2To3 + +/** + * Opens the inherited file. Never creates one. + * + * The rows that would abort the 2-to-3 recreate are dealt with inside [LegacyMigration2To3] rather + * than by a pass over the closed file, so the only thing left for this class to guard is the path. + * + * @param driver left null in production so Room keeps the framework helper it has always used here. + * A JVM test has no framework helper to use, so it passes a bundled driver; without one, Room + * cannot create a database file at all and a test asserting that no file appears could not fail. + */ +internal class AndroidLegacyDatabaseProvider( + private val context: Context, + private val driver: SQLiteDriver? = null, +) : LegacyDatabaseProvider { + + private var database: LegacyDatabase? = null + + /** + * Synchronized because a second caller racing in while the first is still building would get a + * second handle on the same file and leak the one it displaced. + */ + @Synchronized + override fun get(): LegacyDatabase? { + database?.let { return it } + + val path = context.getDatabasePath(LEGACY_DATABASE_NAME) + // Room creates any file it is asked to open. Handing it a path that is not there would + // leave an empty `secure_element_database` behind on an install that never ran v1, and + // every later run would find that file and treat it as inherited data. + if (!path.exists()) return null + + return Room.databaseBuilder(context, LEGACY_DATABASE_NAME) + .addMigrations(LegacyMigration2To3) + .apply { driver?.let(::setDriver) } + .build() + .also { database = it } + } + + @Synchronized + override fun close() { + // A failure to close must not stop the file from being deleted; a handle we cannot close is + // exactly the case where leaving the file behind serves the user least. + runCatching { database?.close() } + database = null + } + + override fun delete(): Boolean { + close() + return context.deleteDatabase(LEGACY_DATABASE_NAME) + } +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabase.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabase.kt new file mode 100644 index 000000000..20d176836 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabase.kt @@ -0,0 +1,46 @@ +package de.davis.keygo.migration.legacy_data.data.local.datasource + +import androidx.room3.AutoMigration +import androidx.room3.Database +import androidx.room3.RoomDatabase +import de.davis.keygo.migration.legacy_data.data.local.dao.LegacyElementDao +import de.davis.keygo.migration.legacy_data.data.local.entity.LegacySecureElementEntity +import de.davis.keygo.migration.legacy_data.data.local.entity.LegacySecureElementTagCrossRef +import de.davis.keygo.migration.legacy_data.data.local.entity.LegacyTagEntity + +internal const val LEGACY_DATABASE_NAME = "secure_element_database" + +/** + * KeyGo v1's database, ported entity for entity so the inherited file can be read through Room. + * + * Version 3 is final; v1 will never ship again. Versions 1 and 2 are still reachable because a user + * can update to v2 directly from an early v1 build whose file never auto-migrated. + * + * 1-to-2 only adds columns, so Room generates it from v1's own exported `1.json`. 2-to-3 is a table + * recreate that a row with a NULL `title` or `data` would abort, so it is hand written instead and + * registered on the builder rather than here: see + * [de.davis.keygo.migration.legacy_data.data.local.migration.LegacyMigration2To3]. + * + * Opening this over an inherited file is a one-way door, but only once the open succeeds. The first + * query runs the migrations, which permanently rewrite the file to version 3 and drop its + * `MasterPassword` table. An import that gets past that point and then fails is retried against a + * file already at version 3, never at the version it started from. + * + * A migration that fails is the other case and behaves the opposite way. Room runs each one in a + * transaction, so an abort rolls the file back to the version it came in at, which is what lets a + * retry start from exactly where the last attempt did. + */ +@Database( + version = 3, + entities = [ + LegacySecureElementEntity::class, + LegacyTagEntity::class, + LegacySecureElementTagCrossRef::class, + ], + autoMigrations = [AutoMigration(from = 1, to = 2)], + exportSchema = true, +) +internal abstract class LegacyDatabase : RoomDatabase() { + + abstract fun legacyElementDao(): LegacyElementDao +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabaseProvider.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabaseProvider.kt new file mode 100644 index 000000000..37e7ea0cb --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabaseProvider.kt @@ -0,0 +1,18 @@ +package de.davis.keygo.migration.legacy_data.data.local.datasource + +internal interface LegacyDatabaseProvider { + + /** + * Returns null when there is no file to open. + * + * An implementation must never bring the file into existence. This is a reader of an inherited + * database, and a database it created itself would be indistinguishable from one it inherited. + */ + fun get(): LegacyDatabase? + + /** Closes the open handle, if there is one. The next [get] opens a fresh one. */ + fun close() + + /** Closes and deletes the database and its `-wal`, `-shm` and `-journal` siblings. */ + fun delete(): Boolean +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/entity/LegacySecureElementEntity.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/entity/LegacySecureElementEntity.kt new file mode 100644 index 000000000..b58ccb5c6 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/entity/LegacySecureElementEntity.kt @@ -0,0 +1,61 @@ +package de.davis.keygo.migration.legacy_data.data.local.entity + +import androidx.room3.ColumnInfo +import androidx.room3.Embedded +import androidx.room3.Entity +import androidx.room3.PrimaryKey + +/** + * v1's `SecureElement`, ported so the legacy database can be read through Room instead of raw SQL. + * + * The class shape is deliberately copied from v1 rather than tidied up. Room emits direct fields + * before embedded ones, so v1's declaration order (title, data, favorite, embedded timestamps, id, + * then the body property type) is what produces its column order of + * `title, data, favorite, id, type, created_at, modified_at`. + * + * Room derives its identity hash from sorted fields and matches columns by name, so that order is + * runtime-inert and kept identical to v1 purely for byte-fidelity of the exported schema. What the + * hash does pin is column names, affinities, nullability, defaults, the primary key, indices and + * foreign keys; drifting on any of those is what makes Room refuse the file. + * `LegacySchemaIdentityTest` guards both properties with separate assertions. + * + * `data` stays a raw `ByteArray` here. v1 decrypted it inside a Room `@TypeConverter`; keeping + * decryption out of the DAO keeps the crypto injectable and the DAO fakeable. + */ +@Entity(tableName = "SecureElement") +internal data class LegacySecureElementEntity( + val title: String, + @ColumnInfo(name = "data") val data: ByteArray, + val favorite: Boolean = false, + @Embedded val timestamps: LegacyTimestamps = LegacyTimestamps(), + @PrimaryKey(autoGenerate = true) var id: Long = 0, +) { + var type: Int = 0 + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is LegacySecureElementEntity) return false + + if (title != other.title) return false + if (!data.contentEquals(other.data)) return false + if (favorite != other.favorite) return false + if (timestamps != other.timestamps) return false + if (id != other.id) return false + return type == other.type + } + + override fun hashCode(): Int { + var result = title.hashCode() + result = 31 * result + data.contentHashCode() + result = 31 * result + favorite.hashCode() + result = 31 * result + timestamps.hashCode() + result = 31 * result + id.hashCode() + result = 31 * result + type + return result + } +} + +internal data class LegacyTimestamps( + @ColumnInfo(name = "created_at", defaultValue = "CURRENT_TIMESTAMP") val createdAt: Long? = null, + @ColumnInfo(name = "modified_at") val modifiedAt: Long? = null, +) diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/entity/LegacySecureElementTagCrossRef.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/entity/LegacySecureElementTagCrossRef.kt new file mode 100644 index 000000000..09b1cbd77 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/entity/LegacySecureElementTagCrossRef.kt @@ -0,0 +1,31 @@ +package de.davis.keygo.migration.legacy_data.data.local.entity + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index + +@Entity( + tableName = "SecureElementTagCrossRef", + primaryKeys = ["id", "tagId"], + foreignKeys = [ + ForeignKey( + entity = LegacySecureElementEntity::class, + parentColumns = ["id"], + childColumns = ["id"], + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = LegacyTagEntity::class, + parentColumns = ["tagId"], + childColumns = ["tagId"], + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("id"), Index("tagId")], +) +internal data class LegacySecureElementTagCrossRef( + val id: Long, + val tagId: Long, +) diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/entity/LegacyTagEntity.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/entity/LegacyTagEntity.kt new file mode 100644 index 000000000..f9a7d433a --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/entity/LegacyTagEntity.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.migration.legacy_data.data.local.entity + +import androidx.room3.Entity +import androidx.room3.Index +import androidx.room3.PrimaryKey + +@Entity(tableName = "Tag", indices = [Index(value = ["name"], unique = true)]) +internal data class LegacyTagEntity( + val name: String, + @PrimaryKey(autoGenerate = true) val tagId: Long = 0, +) diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/migration/LegacyMigration2To3.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/migration/LegacyMigration2To3.kt new file mode 100644 index 000000000..eee520c9d --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/migration/LegacyMigration2To3.kt @@ -0,0 +1,90 @@ +package de.davis.keygo.migration.legacy_data.data.local.migration + +import androidx.room3.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL + +/** + * v1's 2-to-3 step, hand written so the rows that would abort a generated recreate can be dealt + * with inside the same transaction that recreates the table. + * + * Versions 1 and 2 declare `title` and `data` nullable while version 3 declares both NOT NULL, and + * a generated recreate copies rows straight across with `INSERT ... SELECT`. One row carrying a real + * NULL therefore aborts the whole migration and costs the user every other row in the file. Room + * offers no hook that runs before a generated recreate (`AutoMigrationSpec` has only + * `onPostMigrate`), so the repair has to live in a migration written out by hand. + * + * The two columns get opposite treatment, because a NULL in one costs the user everything in that + * row and a NULL in the other costs them nothing. + * + * A NULL `data` is reachable: v1's `Cryptography.encryptAES` swallowed every + * `GeneralSecurityException` and returned null, and its `@Nullable` `Converters.convertDetails` + * handed that straight to Room, so a Keystore cipher that failed at save time wrote a NULL blob and + * the insert succeeded. That is also why the column is nullable in versions 1 and 2 at all. Such a + * row is **deleted**: there is no ciphertext in it, so there is no secret to recover, and v1 could + * not read it either, its converter called `decryptAES(null)` and threw an NPE its own catch did + * not cover. Coalescing it to an empty blob instead would manufacture a row that can never decrypt, + * and one of those is enough to keep the import reporting a failure forever, which would leave the + * legacy file and v1's Keystore alias on disk for good. + * + * A NULL `title` is the opposite and must **not** be deleted. The blob beside it is intact, so the + * row is a fully recoverable credential that happens to have no display name. It is coalesced to an + * empty string and imported; the user gets their password back under a blank name, which is the + * whole of what was actually missing. + * + * Every statement below is copied from v1's own exported `3.json`, which is the schema this has to + * land on exactly: Room derives its identity hash from the declared entities and refuses a file + * whose tables do not match. `LegacyMigrationTest` runs this through + * `MigrationTestHelper.runMigrationsAndValidate`, which is what proves the copy is faithful. + */ +internal object LegacyMigration2To3 : Migration(2, 3) { + + override suspend fun migrate(connection: SQLiteConnection) { + // Both before the copy, so no row can abort it on a NOT NULL column. + connection.execSQL("UPDATE `SecureElement` SET `title` = '' WHERE `title` IS NULL") + connection.execSQL("DELETE FROM `SecureElement` WHERE `data` IS NULL") + + // The recreate itself. Room's own generated form, kept statement for statement. + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `_new_SecureElement` (`title` TEXT NOT NULL, " + + "`data` BLOB NOT NULL, `favorite` INTEGER NOT NULL, " + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, " + + "`created_at` INTEGER DEFAULT CURRENT_TIMESTAMP, `modified_at` INTEGER)", + ) + connection.execSQL( + "INSERT INTO `_new_SecureElement` " + + "(`title`,`data`,`favorite`,`id`,`type`,`created_at`,`modified_at`) " + + "SELECT `title`,`data`,`favorite`,`id`,`type`,`created_at`,`modified_at` " + + "FROM `SecureElement`", + ) + connection.execSQL("DROP TABLE `SecureElement`") + connection.execSQL("ALTER TABLE `_new_SecureElement` RENAME TO `SecureElement`") + + // Version 3 has no MasterPassword table. v2's copy holds a bcrypt hash v2 never reads. + connection.execSQL("DROP TABLE IF EXISTS `MasterPassword`") + + // Tag and the junction are new in version 3. Created after the recreate above, so the + // foreign keys below point at the table that survives rather than the one that was dropped. + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `Tag` (`name` TEXT NOT NULL, " + + "`tagId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + ) + connection.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_Tag_name` ON `Tag` (`name`)") + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `SecureElementTagCrossRef` (`id` INTEGER NOT NULL, " + + "`tagId` INTEGER NOT NULL, PRIMARY KEY(`id`, `tagId`), " + + "FOREIGN KEY(`id`) REFERENCES `SecureElement`(`id`) " + + "ON UPDATE CASCADE ON DELETE CASCADE , " + + "FOREIGN KEY(`tagId`) REFERENCES `Tag`(`tagId`) " + + "ON UPDATE CASCADE ON DELETE CASCADE )", + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_SecureElementTagCrossRef_id` " + + "ON `SecureElementTagCrossRef` (`id`)", + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_SecureElementTagCrossRef_tagId` " + + "ON `SecureElementTagCrossRef` (`tagId`)", + ) + } +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/pojo/LegacyElementWithTags.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/pojo/LegacyElementWithTags.kt new file mode 100644 index 000000000..afd09f08c --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/pojo/LegacyElementWithTags.kt @@ -0,0 +1,23 @@ +package de.davis.keygo.migration.legacy_data.data.local.pojo + +import androidx.room3.Embedded +import androidx.room3.Junction +import androidx.room3.Relation +import de.davis.keygo.migration.legacy_data.data.local.entity.LegacySecureElementEntity +import de.davis.keygo.migration.legacy_data.data.local.entity.LegacySecureElementTagCrossRef +import de.davis.keygo.migration.legacy_data.data.local.entity.LegacyTagEntity + +internal data class LegacyElementWithTags( + @Embedded val element: LegacySecureElementEntity, + // room3 takes these as arrays; Room 2.x spelled the same thing in the singular. + @Relation( + parentColumns = ["id"], + entityColumns = ["tagId"], + associateBy = Junction( + value = LegacySecureElementTagCrossRef::class, + parentColumns = ["id"], + entityColumns = ["tagId"], + ), + ) + val tags: List, +) diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyElementMapper.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyElementMapper.kt new file mode 100644 index 000000000..1bc81722e --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyElementMapper.kt @@ -0,0 +1,29 @@ +package de.davis.keygo.migration.legacy_data.data.mapper + +import de.davis.keygo.migration.legacy_data.data.local.pojo.LegacyElementWithTags +import de.davis.keygo.migration.legacy_data.domain.model.LEGACY_TAG_PREFIX +import de.davis.keygo.migration.legacy_data.domain.model.LegacyDetail +import de.davis.keygo.migration.legacy_data.domain.model.LegacyItem + +/** + * Turns a decrypted, parsed row into a [LegacyItem]. + * + * v1's `elementType:` tags were type discriminators rather than user data. v1's own UI hid them via + * `onlyCustoms()`, and v2 carries the same information in `item.item_type`, so they are dropped. + * + * `createdAt` is carried across exactly as it was found, null included. Schema version 1 had no + * timestamp columns, so a row that came up from it genuinely has none, and inventing one here would + * hide that from the fallback applied when the item is converted. + */ +internal fun LegacyElementWithTags.toLegacyItem(detail: LegacyDetail): LegacyItem = LegacyItem( + legacyId = element.id, + title = element.title, + favorite = element.favorite, + createdAt = element.timestamps.createdAt, + modifiedAt = element.timestamps.modifiedAt, + tags = tags.asSequence() + .map { it.name } + .filterNot { it.startsWith(LEGACY_TAG_PREFIX) } + .toSet(), + detail = detail, +) diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyItemRepositoryImpl.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyItemRepositoryImpl.kt new file mode 100644 index 000000000..b1d2d8977 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyItemRepositoryImpl.kt @@ -0,0 +1,111 @@ +package de.davis.keygo.migration.legacy_data.data.repository + +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.asResult +import de.davis.keygo.core.util.resultBinding +import de.davis.keygo.migration.legacy_data.data.json.LegacyDetailParser +import de.davis.keygo.migration.legacy_data.data.local.dao.LegacyElementDao +import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabaseProvider +import de.davis.keygo.migration.legacy_data.data.local.pojo.LegacyElementWithTags +import de.davis.keygo.migration.legacy_data.data.mapper.toLegacyItem +import de.davis.keygo.migration.legacy_data.domain.crypto.LegacyCipher +import de.davis.keygo.migration.legacy_data.domain.model.LegacyFailureReason +import de.davis.keygo.migration.legacy_data.domain.model.LegacyItem +import de.davis.keygo.migration.legacy_data.domain.model.LegacyReadFailure +import de.davis.keygo.migration.legacy_data.domain.model.LegacyRowFailure +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyItemRepository +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyKeyRepository +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyReadResult +import org.koin.core.annotation.Single +import kotlin.coroutines.cancellation.CancellationException + +/** + * SQLite's bound-parameter limit was 999 until 3.32.0, and that ceiling is what Android's system + * SQLite still enforces through API 30. A single `DELETE ... WHERE id IN (...)` over more than + * that many ids throws rather than deletes, so a v1 install with a four-figure vault would have + * every prune fail and reimport its whole vault on every unlock. Chunked well under the limit so + * the split holds regardless of which SQLite build a device is actually running. + */ +private const val PRUNE_CHUNK_SIZE = 500 + +@Single +internal class LegacyItemRepositoryImpl( + private val databaseProvider: LegacyDatabaseProvider, + private val keyRepository: LegacyKeyRepository, + private val cipher: LegacyCipher, + private val parser: LegacyDetailParser, +) : LegacyItemRepository { + + override suspend fun readAll(): Result = resultBinding { + // Asked first, and it is what decides whether the file is deleted. Zero is the destructive + // verdict, so it rests on a count that was actually taken: a file that will not open fails + // as DatabaseUnreadable here and never reaches the comparison. + (remainingCount().bind() > 0).asResult(LegacyReadFailure.DatabaseEmpty).bind() + + // Resolved once for the whole run rather than inferred from a run of nulls; see + // [LegacyReadFailure.KeyUnavailable]. This does not gate the 2-to-3 recreate, which the + // count above already triggered as the first query against the file. What it still buys is + // not putting the row loop through a key that is already known gone, and one Keystore round + // trip for the run instead of one per blob. + val key = keyRepository.secretKey().asResult(LegacyReadFailure.KeyUnavailable).bind() + + val rows = withDao { it.getAllWithTags() }.bind() + + val items = mutableListOf() + val failures = mutableListOf() + + for (row in rows) { + val detail = cipher.decrypt(row.element.data, key)?.let(parser::parse) + if (detail == null) { + failures += row.failure(LegacyFailureReason.Unreadable) + continue + } + + items += row.toLegacyItem(detail) + } + + LegacyReadResult(items = items, failures = failures, legacyKey = key) + } + + override suspend fun prune(legacyIds: List): Result { + if (legacyIds.isEmpty()) return Result.Success(Unit) + return withDao { dao -> + legacyIds.chunked(PRUNE_CHUNK_SIZE).forEach { dao.deleteByIds(it) } + } + } + + override suspend fun remainingCount(): Result = withDao { it.count() } + + override fun deleteDatabase(): Boolean = databaseProvider.delete() + + /** + * Runs [block] against the legacy DAO, turning the ways the file can refuse to be read into a + * failure rather than a raw throw. + * + * The two are not the same failure. No provider means no file to open at all, which is a clean + * install and holds nothing to lose, so it answers [LegacyReadFailure.DatabaseEmpty]. A file + * that is there but that Room could not validate on the first query answers + * [LegacyReadFailure.DatabaseUnreadable] and is left standing. + */ + private suspend fun withDao( + block: suspend (LegacyElementDao) -> T, + ): Result { + val dao = databaseProvider.get()?.legacyElementDao() + ?: return Result.Failure(LegacyReadFailure.DatabaseEmpty) + + return try { + Result.Success(block(dao)) + } catch (e: CancellationException) { + // Not swallowed into a failure the way the other repositories do it. There a swallowed + // cancellation is harmless; here it would become a statement about the user's file. A + // run cancelled because the unlock scope went away tells us nothing about what is in + // that file, and it must not be able to answer for it. + throw e + } catch (_: Exception) { + Result.Failure(LegacyReadFailure.DatabaseUnreadable) + } + } + + private fun LegacyElementWithTags.failure(reason: LegacyFailureReason) = + LegacyRowFailure(legacyId = element.id, title = element.title, reason = reason) +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyKeyRepositoryImpl.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyKeyRepositoryImpl.kt new file mode 100644 index 000000000..11112b162 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyKeyRepositoryImpl.kt @@ -0,0 +1,33 @@ +package de.davis.keygo.migration.legacy_data.data.repository + +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyKeyRepository +import org.koin.core.annotation.Single +import java.security.KeyStore +import javax.crypto.SecretKey + +@Single +internal class LegacyKeyRepositoryImpl : LegacyKeyRepository { + + override fun secretKey(): SecretKey? = + withAlias { getKey(LEGACY_KEY_ALIAS, null) as? SecretKey } + + override fun deleteLegacyKey() { + withAlias { deleteEntry(LEGACY_KEY_ALIAS) } + } + + /** + * Runs [block] against the Keystore only when v1's alias is actually there, and never throws. + * A Keystore that will not load, or an alias that is already gone, is the same answer to both + * callers: there is no legacy key. + */ + private fun withAlias(block: KeyStore.() -> T): T? = runCatching { + val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) } + if (keyStore.containsAlias(LEGACY_KEY_ALIAS)) keyStore.block() else null + }.getOrNull() + + private companion object { + const val ANDROID_KEY_STORE = "AndroidKeyStore" + + const val LEGACY_KEY_ALIAS = "password_manager_skey" + } +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/di/LegacyDatabaseModule.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/di/LegacyDatabaseModule.kt new file mode 100644 index 000000000..c13f594aa --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/di/LegacyDatabaseModule.kt @@ -0,0 +1,15 @@ +package de.davis.keygo.migration.legacy_data.di + +import android.content.Context +import de.davis.keygo.migration.legacy_data.data.local.datasource.AndroidLegacyDatabaseProvider +import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabaseProvider +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single + +@Module +internal class LegacyDatabaseModule { + + @Single + fun provideLegacyDatabaseProvider(context: Context): LegacyDatabaseProvider = + AndroidLegacyDatabaseProvider(context) +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/di/MigrationLegacyDataModule.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/di/MigrationLegacyDataModule.kt new file mode 100644 index 000000000..29fb92479 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/di/MigrationLegacyDataModule.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.migration.legacy_data.di + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module + +@Module(includes = [LegacyDatabaseModule::class]) +@Configuration +@ComponentScan("de.davis.keygo.migration.legacy_data") +object MigrationLegacyDataModule diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/crypto/LegacyCipher.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/crypto/LegacyCipher.kt new file mode 100644 index 000000000..bd25cb2eb --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/crypto/LegacyCipher.kt @@ -0,0 +1,15 @@ +package de.davis.keygo.migration.legacy_data.domain.crypto + +import javax.crypto.SecretKey + +internal fun interface LegacyCipher { + + /** + * Returns null when the blob cannot be decrypted for any reason. + * + * The key is handed in rather than looked up per blob: every blob in the file is under the one + * v1 alias, and a run resolves it once. Looking it up per blob costs a binder round trip to the + * Keystore for every row, twice over, to arrive back at the same key. + */ + fun decrypt(blob: ByteArray, key: SecretKey): ByteArray? +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/mapper/LegacyItemConverter.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/mapper/LegacyItemConverter.kt new file mode 100644 index 000000000..2dc65cea9 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/mapper/LegacyItemConverter.kt @@ -0,0 +1,156 @@ +package de.davis.keygo.migration.legacy_data.domain.mapper + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.CreditCard +import de.davis.keygo.core.item.domain.model.DomainInfo +import de.davis.keygo.core.item.domain.model.Item +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.item.domain.model.PasswordCredential +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.domain.model.PasswordSecret +import de.davis.keygo.core.item.domain.model.Tag +import de.davis.keygo.core.item.domain.model.Timestamp +import de.davis.keygo.core.item.domain.model.toYearMonthOrNull +import de.davis.keygo.core.security.domain.crypto.CryptographicScope +import de.davis.keygo.core.security.domain.crypto.encrypt +import de.davis.keygo.core.util.domain.resolver.RegistrableDomainResolver +import de.davis.keygo.migration.legacy_data.domain.crypto.LegacyCipher +import de.davis.keygo.migration.legacy_data.domain.model.LegacyDetail +import de.davis.keygo.migration.legacy_data.domain.model.LegacyItem +import de.davis.keygo.migration.legacy_data.domain.model.LegacyStrength +import org.koin.core.annotation.Single +import javax.crypto.SecretKey +import kotlin.time.Clock +import kotlin.time.Instant + +@Single +internal class LegacyItemConverter( + private val cipher: LegacyCipher, + private val registrableDomainResolver: RegistrableDomainResolver, +) { + + /** + * Builds the v2 item for one v1 row, encrypting each secret under the supplied scope's item key. + * + * Returns null only when v1's nested password blob fails to decrypt. Dropping the password and + * keeping the login would hand the user an entry that looks migrated but silently lost its + * secret, so the whole row is reported as a failure and left in the legacy database instead. + */ + context(scope: CryptographicScope) + suspend fun convert( + item: LegacyItem, + itemId: ItemId, + vaultId: VaultId, + keyInformation: KeyInformation, + legacyKey: SecretKey, + ): Item? = when (val detail = item.detail) { + is LegacyDetail.Password -> + convertPassword(item, detail, itemId, vaultId, keyInformation, legacyKey) + + is LegacyDetail.CreditCard -> convertCard(item, detail, itemId, vaultId, keyInformation) + } + + context(scope: CryptographicScope) + private suspend fun convertPassword( + item: LegacyItem, + detail: LegacyDetail.Password, + itemId: ItemId, + vaultId: VaultId, + keyInformation: KeyInformation, + legacyKey: SecretKey, + ): Login? { + val credential = detail.password?.let { encrypted -> + val plaintext = cipher.decrypt(encrypted, legacyKey)?.decodeToString() ?: return null + PasswordCredential( + secret = PasswordSecret.encrypt(plaintext), + score = detail.strength.toPasswordScore(), + ) + } + + return Login( + id = itemId, + username = detail.username?.trim()?.takeIf { it.isNotEmpty() }, + domainInfos = detail.origin + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { origin -> + setOf( + DomainInfo( + loginId = itemId, + value = origin, + eTLD1 = registrableDomainResolver.resolve(origin), + ), + ) + } + .orEmpty(), + passwordCredential = credential, + totp = null, + vaultId = vaultId, + name = item.title, + keyInformation = keyInformation, + tags = item.toTags(), + note = null, + pinned = item.favorite, + timestamp = item.toTimestamp(), + ) + } + + context(scope: CryptographicScope) + private suspend fun convertCard( + item: LegacyItem, + detail: LegacyDetail.CreditCard, + itemId: ItemId, + vaultId: VaultId, + keyInformation: KeyInformation, + ): CreditCard = CreditCard( + id = itemId, + vaultId = vaultId, + name = item.title, + keyInformation = keyInformation, + tags = item.toTags(), + note = null, + pinned = item.favorite, + holder = detail.fullName(), + // v1 stored whatever the user typed, separators included; v2's own entry form only ever + // stores digits (enforced by CardNumberInputTransformation), so migration normalizes here + // too rather than handing the rest of v2 a card number shape it never otherwise produces. + cardNumber = detail.cardNumber + ?.filter(Char::isDigit) + ?.takeIf { it.isNotEmpty() } + ?.let { CreditCard.CardNumber.encrypt(it) }, + cvv = detail.cvv + ?.takeIf { it.isNotBlank() } + ?.let { CreditCard.CVV.encrypt(it) }, + expirationDate = detail.expirationDate?.toYearMonthOrNull(), + timestamp = item.toTimestamp(), + ) + + /** + * v1 and v2 both score with nbvcxz over the same `basicScore`: v1 stored + * `Strength.entries[basicScore]`, v2 stores `PasswordScore(basicScore + 1)`. The mapping is + * therefore exact and needs no re-estimation, which also keeps a migration of hundreds of items + * from running nbvcxz hundreds of times. + */ + private fun LegacyStrength?.toPasswordScore(): PasswordScore = when (this) { + LegacyStrength.RIDICULOUS -> PasswordScore.Ridiculous + LegacyStrength.WEAK -> PasswordScore.Weak + LegacyStrength.MODERATE -> PasswordScore.Moderate + LegacyStrength.STRONG -> PasswordScore.Strong + LegacyStrength.VERY_STRONG -> PasswordScore.Excellent + null -> PasswordScore.None + } + + /** Mirrors v1's `Name.getFullName`, which returned null unless both halves were present. */ + private fun LegacyDetail.CreditCard.fullName(): String? = + if (firstName == null || lastName == null) null else "$firstName $lastName" + + private fun LegacyItem.toTags(): Set = tags.mapNotNull(Tag::of).toSet() + + private fun LegacyItem.toTimestamp(): Timestamp = Timestamp( + createdAt = createdAt?.let(Instant::fromEpochMilliseconds) ?: Clock.System.now(), + modifiedAt = modifiedAt?.let(Instant::fromEpochMilliseconds), + ) + +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyDetail.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyDetail.kt new file mode 100644 index 000000000..eced5bfad --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyDetail.kt @@ -0,0 +1,47 @@ +package de.davis.keygo.migration.legacy_data.domain.model + +/** The decoded contents of a v1 `SecureElement.data` blob, before any v2 types are involved. */ +internal sealed interface LegacyDetail { + + /** [password] is still encrypted: v1 nested a second layer of the same AES-GCM inside the JSON. */ + data class Password( + val username: String?, + val origin: String?, + val password: ByteArray?, + val strength: LegacyStrength?, + ) : LegacyDetail { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Password + + if (username != other.username) return false + if (origin != other.origin) return false + if (!password.contentEquals(other.password)) return false + if (strength != other.strength) return false + + return true + } + + override fun hashCode(): Int { + var result = username?.hashCode() ?: 0 + result = 31 * result + (origin?.hashCode() ?: 0) + result = 31 * result + (password?.contentHashCode() ?: 0) + result = 31 * result + (strength?.hashCode() ?: 0) + return result + } + } + + data class CreditCard( + val firstName: String?, + val lastName: String?, + val cardNumber: String?, + val cvv: String?, + val expirationDate: String?, + ) : LegacyDetail +} + +/** v1's `type` discriminator, which decides which [LegacyDetail] a decoded blob becomes. */ +internal const val LEGACY_TYPE_PASSWORD = 0x1 +internal const val LEGACY_TYPE_CREDIT_CARD = 0x11 diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyFailureReason.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyFailureReason.kt new file mode 100644 index 000000000..63396eaf5 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyFailureReason.kt @@ -0,0 +1,14 @@ +package de.davis.keygo.migration.legacy_data.domain.model + +/** Why one v1 row could not be imported. The row stays in the legacy database either way. */ +enum class LegacyFailureReason { + /** + * The row's blob never became a detail: it did not decrypt under v1's key, or the JSON inside + * it could not be read. A file whose key is gone does not arrive here at all, that is a + * whole-run KeyUnavailable, which stops the run instead of failing rows one at a time. + */ + Unreadable, + + /** The nested password blob did not decrypt, so the login would have been silently emptied. */ + UndecryptablePassword, +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyItem.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyItem.kt new file mode 100644 index 000000000..1301f70e0 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyItem.kt @@ -0,0 +1,15 @@ +package de.davis.keygo.migration.legacy_data.domain.model + +/** A single v1 row, decrypted and parsed, with its user tags already filtered. */ +internal data class LegacyItem( + val legacyId: Long, + val title: String, + val favorite: Boolean, + val createdAt: Long?, + val modifiedAt: Long?, + val tags: Set, + val detail: LegacyDetail, +) + +/** Marks v1's type-discriminator tags, which are dropped rather than carried across as user tags. */ +internal const val LEGACY_TAG_PREFIX = "elementType:" diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigrationOutcome.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigrationOutcome.kt new file mode 100644 index 000000000..5b96a191f --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigrationOutcome.kt @@ -0,0 +1,39 @@ +package de.davis.keygo.migration.legacy_data.domain.model + +sealed interface LegacyMigrationOutcome { + + /** + * Whether a later run would have nothing left to find. False is what keeps the retry alive on + * the next unlock, and it is the answer for every ending that leaves v1 rows on disk. + */ + val nothingLeftToImport: Boolean + + /** No legacy file, or one that opened and held no v1 rows. Either way it is gone now. */ + data object NothingToMigrate : LegacyMigrationOutcome { + override val nothingLeftToImport = true + } + + data class Migrated(val report: LegacyMigrationReport) : LegacyMigrationOutcome { + + // A retained file is a file with rows still in it: something was skipped, or the prune or + // the delete did not go through. All three are what the next unlock is for. + override val nothingLeftToImport get() = !report.fileRetained + } + + /** The run could not start or the batch write failed as a whole. Nothing was imported. */ + data class Failed(val cause: Throwable) : LegacyMigrationOutcome { + override val nothingLeftToImport = false + } +} + +/** + * Why a whole run stopped. Carried by [LegacyMigrationOutcome.Failed], which never reports a + * partial import: no row was written to v2. The legacy file itself may already have gone through + * Room's one-way 1/2-to-3 recreate by this point, since that runs on the first query against the + * file and the read issues one before anything else; that recreate only drops a table v2 never + * reads and carries every v1 row across, so it costs nothing this run would have protected anyway. + */ +class LegacyMigrationException internal constructor( + message: String, + cause: Throwable? = null, +) : Exception(message, cause) diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigrationReport.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigrationReport.kt new file mode 100644 index 000000000..88a0c7ff2 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigrationReport.kt @@ -0,0 +1,23 @@ +package de.davis.keygo.migration.legacy_data.domain.model + +data class LegacyRowFailure( + val legacyId: Long, + val title: String, + val reason: LegacyFailureReason, +) + +data class LegacyMigrationReport( + val migratedItems: Int, + val failures: List, + + /** + * True when the run ended with the legacy file still on disk despite every row it looked at + * having imported cleanly. [hasFailures] alone would miss this: a prune, a recount, or a delete + * that failed for reasons that have nothing to do with any one row still leaves a file behind + * that reimports the whole vault on the next unlock, and that has to be reported just as loudly + * as a row failure is. + */ + val fileRetained: Boolean = false, +) { + val hasFailures: Boolean get() = failures.isNotEmpty() +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyReadFailure.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyReadFailure.kt new file mode 100644 index 000000000..c41b68dc7 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyReadFailure.kt @@ -0,0 +1,37 @@ +package de.davis.keygo.migration.legacy_data.domain.model + +/** + * Why a whole read could not run. Nothing is imported, and the legacy file is left exactly as it + * was found. That is what separates these from [LegacyFailureReason], which is always about one row + * among many that were read fine. + */ +internal enum class LegacyReadFailure { + + /** + * v1's Keystore alias is gone, so no blob in the file can ever be decrypted. + * + * Probed once before any row is decrypted rather than inferred from a run of nulls. Reporting + * it as one [LegacyFailureReason.Unreadable] per row would tell the user that every entry + * was individually damaged, when in fact the entries are intact and only the key that opens + * them is gone. The two cases also call for opposite handling: this one stops the run. + */ + KeyUnavailable, + + /** + * The file exists but cannot be opened at all: corrupt, truncated, or not a SQLite database. A + * partially restored backup, or a foreign file sitting at `secure_element_database`, lands here. + */ + DatabaseUnreadable, + + /** + * There is no file, or the file opened and provably holds no v1 rows: either it was never + * written to, or an earlier run already imported and pruned everything in it. + * + * Kept apart from [DatabaseUnreadable] on purpose, and that distinction carries the whole + * safety of this path. This is the one failure that gets the file deleted, so it is only ever + * reached by counting the rows and getting zero. A file that will not open tells us nothing + * about what is inside it, and a partially restored backup deserves to be left where it is + * rather than thrown away on a guess. + */ + DatabaseEmpty, +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyStrength.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyStrength.kt new file mode 100644 index 000000000..377dca34a --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyStrength.kt @@ -0,0 +1,13 @@ +package de.davis.keygo.migration.legacy_data.domain.model + +/** + * v1's `Strength`, in v1's declaration order. The ordinal is load-bearing: the older JSON form + * stored it as `{"type": ordinal}`. + */ +internal enum class LegacyStrength { + RIDICULOUS, + WEAK, + MODERATE, + STRONG, + VERY_STRONG, +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/repository/LegacyItemRepository.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/repository/LegacyItemRepository.kt new file mode 100644 index 000000000..8ec4573cd --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/repository/LegacyItemRepository.kt @@ -0,0 +1,45 @@ +package de.davis.keygo.migration.legacy_data.domain.repository + +import de.davis.keygo.core.util.Result +import de.davis.keygo.migration.legacy_data.domain.model.LegacyItem +import de.davis.keygo.migration.legacy_data.domain.model.LegacyReadFailure +import de.davis.keygo.migration.legacy_data.domain.model.LegacyRowFailure +import javax.crypto.SecretKey + +internal data class LegacyReadResult( + val items: List, + val failures: List, + + /** + * v1's key, resolved once for the whole run. Carried here because the rows are handed over with + * their nested password blobs still sealed, and whatever opens those has to use the same key + * the rows themselves were read under. + */ + val legacyKey: SecretKey, +) + +/** Reads the inherited v1 database. */ +internal interface LegacyItemRepository { + + /** + * Reads, decrypts and parses every row. + * + * A row that fails is reported in [LegacyReadResult.failures] and never thrown. Only something + * that stops the whole run before any row can be judged, such as a gone Keystore alias, comes + * back as a failed [Result]. + * + * This is also the gate on the file's existence. A count is taken before anything else, and a + * file with no rows in it comes back as [LegacyReadFailure.DatabaseEmpty], which is the one + * failure the caller answers by deleting. Every other failure means the file was not understood, + * and a file nobody could read has to be left where it was found. + */ + suspend fun readAll(): Result + + /** Removes the rows that were successfully imported, so a retry cannot duplicate them. */ + suspend fun prune(legacyIds: List): Result + + suspend fun remainingCount(): Result + + /** Closes the database and deletes the file. */ + fun deleteDatabase(): Boolean +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/repository/LegacyKeyRepository.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/repository/LegacyKeyRepository.kt new file mode 100644 index 000000000..70c8da367 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/repository/LegacyKeyRepository.kt @@ -0,0 +1,16 @@ +package de.davis.keygo.migration.legacy_data.domain.repository + +import javax.crypto.SecretKey + +/** Reaches v1's Keystore alias, the key every blob in the inherited database was written under. */ +internal interface LegacyKeyRepository { + + /** Returns null when the alias is gone, which no blob in the file can survive. */ + fun secretKey(): SecretKey? + + /** + * Removes v1's alias. Only ever called once the file it protected has provably gone, because a + * key removed while encrypted rows are still on disk makes them unreadable for good. + */ + fun deleteLegacyKey() +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/LegacyDataImportStarter.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/LegacyDataImportStarter.kt new file mode 100644 index 000000000..7aed9dd18 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/LegacyDataImportStarter.kt @@ -0,0 +1,55 @@ +package de.davis.keygo.migration.legacy_data.domain.usecase + +import android.util.Log +import de.davis.keygo.core.security.domain.Session +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.koin.core.annotation.Single + +private const val TAG = "LegacyDataImport" + +/** + * Runs the v1 import in the background on every unlock. + * + * Driven by [Session.sessionStarts] rather than called from the auth screen, because the auth + * screen is only one of the doors: the autofill service and both passkey activities start sessions + * of their own. An import wired to some of them would leave a partial migration sitting until the + * user happened to come back through the right one, and would go quietly stale again the next time + * a door is added. + * + * The import is not work that can sit in front of the user: it opens the inherited file, decrypts + * every row through the Keystore and writes them all back under new keys, and how long that takes + * is a property of the user's old database rather than anything we can bound. It runs on its own + * application scope on [Dispatchers.IO], off the main thread and outside the lifetime of whatever + * unlocked, which for the auth screen is a ViewModel cleared moments later. + * + * There is no in-process lock today for a run to be interrupted by: the session lives for the + * process, and the app's only current "lock" is a process or activity restart, which ends the run + * along with everything else. A run interrupted by a future lock feature would fail instead, leave + * the legacy file exactly as it found it, and be retried on the next unlock. + * + * Created at Koin start so the collector is already listening when the first session begins. + */ +@Single(createdAtStart = true) +internal class LegacyDataImportStarter( + session: Session, + migrateLegacyData: MigrateLegacyDataUseCase, +) { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val runner = LegacyImportRunner( + scope = scope, + report = { message, cause -> + if (cause != null) Log.e(TAG, message, cause) else Log.e(TAG, message) + }, + import = { migrateLegacyData() }, + ) + + init { + session.sessionStarts.onEach { runner.start() }.launchIn(scope) + } +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/LegacyImportRunner.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/LegacyImportRunner.kt new file mode 100644 index 000000000..d96df5a79 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/LegacyImportRunner.kt @@ -0,0 +1,109 @@ +package de.davis.keygo.migration.legacy_data.domain.usecase + +import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationOutcome +import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationReport +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.cancellation.CancellationException + +/** + * Runs [import] on [scope], one run at a time, with nothing escaping into [scope]. + * + * One run at a time is what stops two unlocks from copying the same rows twice. The import mints a + * fresh item id for every row it takes across and has no key to recognise a row it already + * imported, so two runs over one legacy file would leave the user holding two of everything. A call + * made while a run is in flight is dropped rather than queued, and a call made after one has + * finished starts a new run, which is what makes the import retry on every unlock. + * + * That retry stops for good once a run reports [LegacyMigrationOutcome.nothingLeftToImport], which + * is the answer for the overwhelming majority of installs: no v1 file was ever there. Without the + * latch every unlock for the rest of the process would open the file, count it and sweep the + * filesystem to conclude the same nothing, and unlocks are not rare, since the autofill service and + * both passkey activities each start a session of their own. + * + * [import] returns its outcome rather than throwing for anything expected, and every ending that is + * not a clean success is turned into one call to [report]. + * + * Nothing thrown may reach [scope], where an uncaught throwable would take the process down. That + * covers [import] itself and also [report]: a throwing reporting implementation must not be able to + * do what a throwing import already cannot. [Throwable] and not [Exception] for [import]: + * [MigrateLegacyDataUseCase] catches [Exception] around its whole run, which leaves everything that + * is not one uncaught, and a module reaching Room, a native SQLite driver and the Keystore can raise + * a [LinkageError] or a [NoClassDefFoundError] on a device missing something it expected. + * + * Cancellation is rethrown rather than reported. See LegacyItemRepositoryImpl.withDao. + */ +internal class LegacyImportRunner( + private val scope: CoroutineScope, + private val report: (message: String, cause: Throwable?) -> Unit, + private val import: suspend () -> LegacyMigrationOutcome, +) { + + private val inFlight = AtomicBoolean(false) + + private val finished = AtomicBoolean(false) + + fun start() { + if (finished.get()) return + if (!inFlight.compareAndSet(false, true)) return + + // The flag is released on completion rather than in a finally, so a run whose scope died + // before its body ever ran still gives the next unlock its turn. + scope.launch { + try { + val outcome = import() + // Latched before the in-flight flag is released, so no start can slip between the + // two and win a run the verdict has already ruled out. + if (outcome.nothingLeftToImport) finished.set(true) + reportOutcome(outcome) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + reportSafely("v1 import threw", e) + } + }.invokeOnCompletion { inFlight.set(false) } + } + + private fun reportOutcome(outcome: LegacyMigrationOutcome) { + when (outcome) { + is LegacyMigrationOutcome.Failed -> + reportSafely("v1 import failed, retrying on the next unlock", outcome.cause) + + is LegacyMigrationOutcome.Migrated -> if ( + outcome.report.hasFailures || outcome.report.fileRetained + ) + reportSafely(migrationSummary(outcome.report), null) + + LegacyMigrationOutcome.NothingToMigrate -> Unit + } + } + + private fun migrationSummary(migrationReport: LegacyMigrationReport): String { + val parts = mutableListOf() + + if (migrationReport.hasFailures) { + // Grouped by reason rather than by row: a row's title is the user's own account name, + // and logcat is not the place for it. Counts and reasons are enough to work out what + // happened. + val byReason = migrationReport.failures.groupingBy { it.reason }.eachCount().entries + .joinToString { (reason, count) -> "$reason=$count" } + val total = migrationReport.migratedItems + migrationReport.failures.size + parts += "${migrationReport.failures.size} of $total row(s) skipped: $byReason" + } + + if (migrationReport.fileRetained) + parts += "the legacy file could not be cleared and will be retried on the next unlock" + + return "v1 import finished: ${parts.joinToString("; ")}" + } + + /** Calls [report] without letting a throw out of it reach [scope]. */ + private fun reportSafely(message: String, cause: Throwable?) { + try { + report(message, cause) + } catch (_: Throwable) { + // Nothing to escalate to: this is already the containment boundary. + } + } +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/MigrateLegacyDataUseCase.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/MigrateLegacyDataUseCase.kt new file mode 100644 index 000000000..f1a3463be --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/MigrateLegacyDataUseCase.kt @@ -0,0 +1,222 @@ +package de.davis.keygo.migration.legacy_data.domain.usecase + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.model.Item +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.repository.TransactionRunner +import de.davis.keygo.core.item.domain.repository.VaultContextRepository +import de.davis.keygo.core.item.domain.repository.VaultRepository +import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformation +import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation +import de.davis.keygo.core.security.domain.model.CryptoScopeError +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.core.util.isSuccess +import de.davis.keygo.core.util.onFailure +import de.davis.keygo.migration.legacy_data.domain.mapper.LegacyItemConverter +import de.davis.keygo.migration.legacy_data.domain.model.LegacyFailureReason +import de.davis.keygo.migration.legacy_data.domain.model.LegacyItem +import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationException +import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationOutcome +import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationReport +import de.davis.keygo.migration.legacy_data.domain.model.LegacyReadFailure +import de.davis.keygo.migration.legacy_data.domain.model.LegacyRowFailure +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyItemRepository +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyKeyRepository +import de.davisalessandro.keygo.rust.ItemAad +import kotlinx.coroutines.flow.first +import org.koin.core.annotation.Single +import javax.crypto.SecretKey +import kotlin.coroutines.cancellation.CancellationException + +/** + * Imports a KeyGo v1 install's items into the current vault. + * + * Runs after a session is live, because every secret is re-encrypted under an item key that is + * wrapped by the vault key, which is wrapped by the ARK. Rows that cannot be read are skipped and + * reported rather than aborting the run, and only the rows that were imported are pruned from the + * legacy database, so a later attempt cannot duplicate anything. + * + * Two acts here cannot be undone: deleting the inherited file, and deleting v1's Keystore alias. + * Both are gated on the user's data being provably in v2 already. Every other outcome leaves the + * file where it is, because a retry on the next unlock costs the user nothing and a wrong deletion + * costs them everything. + */ +@Single +class MigrateLegacyDataUseCase internal constructor( + private val legacyItemRepository: LegacyItemRepository, + private val legacyKeyRepository: LegacyKeyRepository, + private val converter: LegacyItemConverter, + private val cryptographicScopeProvider: CryptographicScopeProvider, + private val vaultRepository: VaultRepository, + private val vaultContextRepository: VaultContextRepository, + private val upsertVaultItem: UpsertVaultItemUseCase, + private val transactionRunner: TransactionRunner, +) { + + suspend operator fun invoke(): LegacyMigrationOutcome = try { + migrate() + } catch (e: CancellationException) { + // See LegacyItemRepositoryImpl.withDao. + throw e + } catch (e: Exception) { + LegacyMigrationOutcome.Failed(e) + } + + private suspend fun migrate(): LegacyMigrationOutcome { + // The read is the gate on everything below it. A failed read is not an empty file: it is + // not knowing what is in the file. Reading it as "no rows" would fall straight through to + // the deletion at the end and destroy data nobody ever looked at. + val read = when (val result = legacyItemRepository.readAll()) { + is Result.Success -> result.success + + // Exhaustive rather than an `else`, so a failure added later cannot default into the + // branch that deletes. + is Result.Failure -> return when (result.error) { + // The one branch that destroys the file, and only because it is the one that proves + // there is nothing to destroy: the rows were counted and there are none. + LegacyReadFailure.DatabaseEmpty -> { + deleteDatabaseAndKey() + LegacyMigrationOutcome.NothingToMigrate + } + + LegacyReadFailure.DatabaseUnreadable, LegacyReadFailure.KeyUnavailable -> + LegacyMigrationOutcome.Failed( + LegacyMigrationException( + "Could not read the legacy database: ${result.error}", + ), + ) + } + } + + val vaultId = targetVaultId() + ?: return LegacyMigrationOutcome.Failed( + LegacyMigrationException("No vault to import into"), + ) + val vaultKeyInformation = vaultRepository.getKeyInformation(vaultId) + ?: return LegacyMigrationOutcome.Failed( + LegacyMigrationException("Vault $vaultId has no key information"), + ) + + val failures = read.failures.toMutableList() + val converted = mutableListOf>() + + for (legacyItem in read.items) { + val itemId = newItemId() + val item = when ( + val scoped = + convert(legacyItem, itemId, vaultId, vaultKeyInformation, read.legacyKey) + ) { + is Result.Success -> scoped.success + + // The scope itself would not open: the vault key did not unwrap, or wrapping the + // new item key failed. That says nothing about this row and will hold for every + // other row too, so the run stops rather than reporting the user's intact entries + // as individually damaged and leaving a half-filled vault behind. + is Result.Failure -> return LegacyMigrationOutcome.Failed( + LegacyMigrationException( + "Could not open a cryptographic scope to import into: ${scoped.error}", + ), + ) + } + + if (item == null) { + failures += legacyItem.failure(LegacyFailureReason.UndecryptablePassword) + continue + } + converted += legacyItem.legacyId to item + } + + // One transaction for the batch, so a write that fails part way through leaves no items + // behind for a retry to duplicate. The throw is what rolls it back, and it also means + // everything in `converted` is written by the time the prune below runs. + if (converted.isNotEmpty()) + transactionRunner.runInTransaction { + for ((legacyId, item) in converted) + upsertVaultItem(item).onFailure { + throw LegacyMigrationException("Could not write legacy row $legacyId", it) + } + } + + // Only the rows now provably in v2. A prune that fails leaves the file as it was, which the + // next run re-imports; that is a duplicate at worst, and the deletion below is the only + // thing that could turn it into a loss. + val pruned = legacyItemRepository.prune(converted.map { it.first }).isSuccess() + + val fileDeleted = + deleteWhenFullyImported(hasFailures = failures.isNotEmpty(), pruned = pruned) + + return LegacyMigrationOutcome.Migrated( + LegacyMigrationReport( + migratedItems = converted.size, + failures = failures, + fileRetained = !fileDeleted, + ), + ) + } + + /** + * Deletes the file and v1's alias, and only once everything in the file is in v2. A count that + * cannot be taken is not a zero. Returns whether the file actually went. + */ + private suspend fun deleteWhenFullyImported(hasFailures: Boolean, pruned: Boolean): Boolean { + if (hasFailures || !pruned) return false + if (legacyItemRepository.remainingCount().getOrNull() != 0) return false + + return deleteDatabaseAndKey() + } + + /** + * Deletes the inherited file and v1's Keystore alias, in that order. + * + * The alias has to go with the file on every path that removes it, including an already-imported + * file that now counts zero rows: otherwise the key outlives the last run able to reach it. + * Deleting it is safe for the same reason the file is, there is no ciphertext left for it to + * open. Gated on the file actually going, so a key never outlives rows still on disk. + * + * A clean install reaches this too, by way of a provider with no file to hand out. There is + * nothing to delete, [LegacyItemRepository.deleteDatabase] says so, and the alias stays. + */ + private fun deleteDatabaseAndKey(): Boolean { + if (!legacyItemRepository.deleteDatabase()) return false + legacyKeyRepository.deleteLegacyKey() + return true + } + + private suspend fun convert( + legacyItem: LegacyItem, + itemId: ItemId, + vaultId: VaultId, + vaultKeyInformation: KeyInformation, + legacyKey: SecretKey, + ): Result = cryptographicScopeProvider.itemScope( + wrappedVaultKeyInformation = WrappedVaultKeyInformation( + wrappedVaultKey = vaultKeyInformation, + vaultId = vaultId, + ), + // No wrapped item key, so the scope mints a fresh one for this import. The AAD binds it to + // the new item id and the destination vault, which is what a v1 row has never had. + wrappedItemKeyInformation = WrappedItemKeyInformation( + itemAad = ItemAad(itemId = itemId, vaultId = vaultId), + ), + ) { + converter.convert( + item = legacyItem, + itemId = itemId, + vaultId = vaultId, + keyInformation = wrapCurrentItemKey(), + legacyKey = legacyKey, + ) + } + + private suspend fun targetVaultId(): VaultId? = + vaultContextRepository.getLastInteractedVaultId() + ?: vaultRepository.observeAllVaultMetadata().first().firstOrNull()?.vaultId + + private fun LegacyItem.failure(reason: LegacyFailureReason) = + LegacyRowFailure(legacyId = legacyId, title = title, reason = reason) +} diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/LegacyMigrationEndToEndTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/LegacyMigrationEndToEndTest.kt new file mode 100644 index 000000000..efb45f699 --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/LegacyMigrationEndToEndTest.kt @@ -0,0 +1,437 @@ +package de.davis.keygo.migration.legacy_data + +import androidx.room3.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.execSQL +import de.davis.keygo.core.item.FakeCreditCardRepository +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.FakeLoginRepository +import de.davis.keygo.core.item.FakeTransactionRunner +import de.davis.keygo.core.item.FakeVaultContextRepository +import de.davis.keygo.core.item.FakeVaultRepository +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.CreditCard +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.repository.CreditCardRepository +import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.decrypt +import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformation +import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.assertSuccess +import de.davis.keygo.migration.legacy_data.data.FakeLegacyDatabaseProvider +import de.davis.keygo.migration.legacy_data.data.FakeLegacyKeyRepository +import de.davis.keygo.migration.legacy_data.data.FakeRegistrableDomainResolver +import de.davis.keygo.migration.legacy_data.data.crypto.LegacyAesGcmCipher +import de.davis.keygo.migration.legacy_data.data.encryptLikeV1 +import de.davis.keygo.migration.legacy_data.data.json.LegacyDetailParser +import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase +import de.davis.keygo.migration.legacy_data.data.local.entity.LegacySecureElementEntity +import de.davis.keygo.migration.legacy_data.data.local.entity.LegacySecureElementTagCrossRef +import de.davis.keygo.migration.legacy_data.data.local.entity.LegacyTagEntity +import de.davis.keygo.migration.legacy_data.data.local.entity.LegacyTimestamps +import de.davis.keygo.migration.legacy_data.data.repository.LegacyItemRepositoryImpl +import de.davis.keygo.migration.legacy_data.domain.mapper.LegacyItemConverter +import de.davis.keygo.migration.legacy_data.domain.model.LegacyFailureReason +import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationOutcome +import de.davis.keygo.migration.legacy_data.domain.usecase.MigrateLegacyDataUseCase +import de.davisalessandro.keygo.rust.ItemAad +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import java.io.File +import java.nio.file.Files +import java.security.SecureRandom +import java.time.YearMonth +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Drives the whole import over a real database file: real Room entities, v1's real AES-GCM, the + * real JSON parser, the real converter and the real use case. + * + * Every earlier test in this module proved one link. Two properties only exist between links and so + * can only be proved here. The first is v1's double encryption: a password was encrypted once on its + * own and then again inside the row blob, so the plaintext only comes back if the repository, the + * parser and the converter each undo their own layer in the right order. The second is the order of + * prune and delete: the file is only destroyed once the rows it held are provably in v2. + * + * The one thing left standing in for production is the v2 side of the crypto. + * [FakeCryptographicScopeProvider] re-encrypts with a reversible XOR rather than the Rust wrap and + * unwrap, which need the native library that a JVM test does not have. The v1 side, which is what + * this module owns, is the real cipher throughout. + * + * The other known ceiling is how Room is opened. Production goes through + * `AndroidLegacyDatabaseProvider`, which opens in compat mode on the framework helper; a JVM test + * has no framework helper, so this class opens in driver mode on the bundled driver. That gap is + * accepted for the module rather than closed here. + */ +class LegacyMigrationEndToEndTest { + + private val tempDir: File = Files.createTempDirectory("legacy-e2e").toFile() + private val dbFile = File(tempDir, "secure_element_database") + + private val legacyKey: SecretKey = KeyGenerator.getInstance("AES") + .apply { init(256, SecureRandom()) } + .generateKey() + + private val vaultId = newVaultId() + + private val loginRepository = FakeLoginRepository() + private val creditCardRepository = FakeCreditCardRepository() + private val vaultRepository = FakeVaultRepository() + private val vaultContextRepository = FakeVaultContextRepository() + private val transactionRunner = FakeTransactionRunner() + private val cryptoProvider = FakeCryptographicScopeProvider(FakeItemRepository(loginRepository)) + + /** + * File backed, not in memory, and that is the point of this class rather than an incidental + * choice. The import's whole verdict is read off the bytes at [dbFile] through Room, and the + * provider deletes that same path afterwards, so an in-memory database would leave the file + * absent, the run answering `DatabaseEmpty` for the wrong reason and the prune-before-delete + * ordering with nothing to order. + * + * Room opens lazily, so building this up front still leaves each test free to write the file + * underneath it first. Built with the path-only overload rather than the one taking a Context, + * so no Android framework type has to be stood in for at all. + */ + private val database: LegacyDatabase = Room.databaseBuilder(dbFile.absolutePath) + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.IO) + .build() + + private val databaseProvider = FakeLegacyDatabaseProvider(file = dbFile, database = database) + + private val legacyKeyRepository = FakeLegacyKeyRepository(legacyKey) + private val legacyCipher = LegacyAesGcmCipher() + + /** Id the migration mints for the seeded card, learned through [cardIdCapturingRepository]. */ + private var migratedCardId: ItemId? = null + + /** + * `CreditCardRepository` only queries by id, and the id a migrated card gets is a fresh + * `newItemId()` minted deep inside the use case, not something this test can predict up front. + * Wrapping the write is how the test learns it, rather than adding a list query to production + * for the sake of this one assertion. + */ + private val cardIdCapturingRepository = object : CreditCardRepository by creditCardRepository { + override suspend fun createOrUpdateCreditCard(card: CreditCard): Result = + creditCardRepository.createOrUpdateCreditCard(card).also { result -> + if (result is Result.Success) migratedCardId = result.success + } + } + + private val legacyRepository = LegacyItemRepositoryImpl( + databaseProvider = databaseProvider, + keyRepository = legacyKeyRepository, + cipher = legacyCipher, + parser = LegacyDetailParser(), + ) + + private val useCase = MigrateLegacyDataUseCase( + legacyItemRepository = legacyRepository, + legacyKeyRepository = legacyKeyRepository, + converter = LegacyItemConverter( + cipher = legacyCipher, + registrableDomainResolver = FakeRegistrableDomainResolver(), + ), + cryptographicScopeProvider = cryptoProvider, + vaultRepository = vaultRepository, + vaultContextRepository = vaultContextRepository, + upsertVaultItem = UpsertVaultItemUseCase(loginRepository, cardIdCapturingRepository), + transactionRunner = transactionRunner, + ) + + @AfterTest + fun tearDown() { + database.close() + tempDir.deleteRecursively() + } + + private fun encryptLikeV1(plaintext: ByteArray): ByteArray = encryptLikeV1(plaintext, legacyKey) + + /** GSON wrote `byte[]` as a JSON array of signed ints. */ + private fun ByteArray.asJsonArray(): String = joinToString(",", "[", "]") + + private fun passwordJson(username: String, origin: String, password: String): ByteArray { + val nested = encryptLikeV1(password.encodeToByteArray()).asJsonArray() + return encryptLikeV1( + """{"type":1,"username":"$username","origin":"$origin","password":$nested,"strength":"STRONG"}""" + .encodeToByteArray(), + ) + } + + /** + * Long enough to clear the IV length check and nothing but garbage after it, so it reaches + * AES-GCM and fails the tag there rather than being rejected on its size. + */ + private fun undecryptableBlob(): ByteArray = ByteArray(13) { (it + 1).toByte() } + + private fun cardJson(): ByteArray = encryptLikeV1( + """{"type":17,"cardholder":{"firstName":"Ada","lastName":"Lovelace"}, + "expirationDate":"04/29","cardNumber":"4111111111111111","cvv":"123"}""" + .encodeToByteArray(), + ) + + private suspend fun seed( + title: String, + blob: ByteArray, + type: Int, + favorite: Boolean = false, + createdAt: Long? = 1_700_000_000_000L, + modifiedAt: Long? = null, + tags: List = emptyList(), + ) { + val dao = database.legacyElementDao() + val id = dao.insertElement( + LegacySecureElementEntity( + title = title, + data = blob, + favorite = favorite, + timestamps = LegacyTimestamps(createdAt, modifiedAt), + ).apply { this.type = type }, + ) + tags.forEach { name -> + // `Tag.name` is uniquely indexed, so a name another row already carries throws rather + // than returning an id. Reusing it is not worth the query; the cross ref is what these + // tests read back, and every one of them seeds distinct names. + val tagId = + runCatching { dao.insertTag(LegacyTagEntity(name = name)) }.getOrElse { -1L } + if (tagId > 0) dao.insertCrossRef(LegacySecureElementTagCrossRef(id, tagId)) + } + } + + private suspend fun seedVault() { + vaultRepository.seed( + Vault( + id = vaultId, + name = "Default Vault", + keyInformation = KeyInformation(byteArrayOf(1), byteArrayOf(2)), + icon = Vault.Icon.Default, + ), + ) + vaultContextRepository.setContextAndLastInteracted(vaultId) + } + + private suspend fun readBackPassword(loginId: ItemId): String = + cryptoProvider.itemScope( + wrappedVaultKeyInformation = WrappedVaultKeyInformation( + wrappedVaultKey = KeyInformation(byteArrayOf(1), byteArrayOf(2)), + vaultId = vaultId, + ), + // No wrappedItemKeyInformation, so this mints a fresh key rather than unwrapping the + // item's stored one. That is only safe because FakeCryptographicScopeProvider XORs + // against one fixed key no matter what it is handed; a key-aware fake would break this + // in a way that would be confusing to track down. + wrappedItemKeyInformation = WrappedItemKeyInformation( + itemAad = ItemAad(itemId = loginId, vaultId = vaultId), + ), + ) { + loginRepository.getLoginById(loginId)!!.passwordCredential!!.secret.decrypt() + }.assertSuccess() + + /** What a migrated card looks like once its secret fields are decrypted back to plaintext. */ + private data class DecryptedCard( + val holder: String?, + val cardNumber: String?, + val cvv: String?, + val expirationDate: YearMonth?, + ) + + /** + * The card path has no nested encryption the way v1's password does: `cardNumber` and `cvv` + * are encrypted once, directly, under the item key. Reading them back only has to undo that + * one layer. + */ + private suspend fun readBackCard(cardId: ItemId): DecryptedCard = + cryptoProvider.itemScope( + wrappedVaultKeyInformation = WrappedVaultKeyInformation( + wrappedVaultKey = KeyInformation(byteArrayOf(1), byteArrayOf(2)), + vaultId = vaultId, + ), + wrappedItemKeyInformation = WrappedItemKeyInformation( + itemAad = ItemAad(itemId = cardId, vaultId = vaultId), + ), + ) { + val card = creditCardRepository.getCreditCardById(cardId)!! + DecryptedCard( + holder = card.holder, + cardNumber = card.cardNumber?.decrypt(), + cvv = card.cvv?.decrypt(), + expirationDate = card.expirationDate, + ) + }.assertSuccess() + + @Test + fun `migrates a full v1 database and leaves nothing behind`() = runTest { + seedVault() + seed( + title = "Example", + blob = passwordJson("ada", "https://example.com", "hunter2"), + type = 1, + favorite = true, + modifiedAt = 1_700_000_999_000L, + tags = listOf("work", "elementType:password"), + ) + seed(title = "Card", blob = cardJson(), type = 17, tags = listOf("finance")) + + val outcome = assertIs(useCase()) + + assertEquals(2, outcome.report.migratedItems) + assertFalse(outcome.report.hasFailures) + + val login = loginRepository.observeLogins().first().single() + assertEquals("Example", login.name) + assertEquals("ada", login.username) + assertTrue(login.pinned) + assertEquals(setOf("work"), login.tags.map { it.display }.toSet()) + assertEquals(Instant.fromEpochMilliseconds(1_700_000_000_000L), login.timestamp.createdAt) + assertEquals( + Instant.fromEpochMilliseconds(1_700_000_999_000L), + login.timestamp.modifiedAt, + ) + assertEquals("hunter2", readBackPassword(login.id)) + + val card = readBackCard(migratedCardId!!) + assertEquals("Ada Lovelace", card.holder) + assertEquals("4111111111111111", card.cardNumber) + assertEquals("123", card.cvv) + assertEquals(YearMonth.of(2029, 4), card.expirationDate) + + assertTrue(legacyKeyRepository.deleted) + assertFalse(dbFile.exists()) + assertFalse(File(dbFile.absolutePath + "-wal").exists()) + assertFalse(File(dbFile.absolutePath + "-shm").exists()) + } + + @Test + fun `keeps the database and reports the row when a blob will not decrypt`() = runTest { + seedVault() + seed(title = "Fine", blob = passwordJson("ada", "https://example.com", "pw"), type = 1) + seed(title = "Broken", blob = undecryptableBlob(), type = 1) + + val outcome = assertIs(useCase()) + + assertEquals(1, outcome.report.migratedItems) + assertEquals("Broken", outcome.report.failures.single().title) + assertEquals(LegacyFailureReason.Unreadable, outcome.report.failures.single().reason) + assertFalse(legacyKeyRepository.deleted) + assertTrue(dbFile.exists()) + assertEquals(1, legacyRepository.remainingCount().assertSuccess()) + } + + @Test + fun `keeps the database and reports the row when the json is malformed`() = runTest { + seedVault() + seed(title = "Fine", blob = passwordJson("ada", "https://example.com", "pw"), type = 1) + seed(title = "Garbled", blob = encryptLikeV1("not json".encodeToByteArray()), type = 1) + + val outcome = assertIs(useCase()) + + assertEquals(1, outcome.report.migratedItems) + assertEquals(LegacyFailureReason.Unreadable, outcome.report.failures.single().reason) + assertTrue(dbFile.exists()) + } + + @Test + fun `a second run after a partial failure produces exactly one copy of everything`() = runTest { + seedVault() + seed(title = "Fine", blob = passwordJson("ada", "https://example.com", "pw"), type = 1) + seed(title = "Broken", blob = undecryptableBlob(), type = 1) + + assertIs(useCase()) + val afterFirst = loginRepository.observeLogins().first().map { it.name } + + val second = assertIs(useCase()) + + assertEquals(0, second.report.migratedItems) + assertEquals(1, second.report.failures.size) + assertEquals(afterFirst, loginRepository.observeLogins().first().map { it.name }) + assertEquals(listOf("Fine"), afterFirst) + } + + @Test + fun `migrates a five hundred item database completely`() = runTest { + seedVault() + repeat(500) { index -> + seed( + title = "Entry $index", + blob = passwordJson("user$index", "https://example.com", "pw$index"), + type = 1, + ) + } + + val outcome = assertIs(useCase()) + + assertEquals(500, outcome.report.migratedItems) + assertEquals(500, loginRepository.observeLogins().first().size) + assertEquals(1, transactionRunner.enteredCount) + assertFalse(dbFile.exists()) + } + + @Test + fun `deletes a stale v2 development database without importing anything`() = runTest { + seedVault() + // A file that exists but has no SecureElement table, the shape left behind by the + // ItemDatabase rename. It has to be a database that really opens and really has no such + // table, because that is the only evidence that earns the verdict that deletes it. A file of + // arbitrary bytes would be a corrupt file, which comes back Unreadable and which must be + // left exactly where it was found. Nothing has the path open yet: Room waits for its first + // query. + BundledSQLiteDriver().open(dbFile.absolutePath).use { connection -> + connection.execSQL("CREATE TABLE Leftover (id INTEGER PRIMARY KEY)") + } + + assertEquals(LegacyMigrationOutcome.NothingToMigrate, useCase()) + assertTrue(loginRepository.observeLogins().first().isEmpty()) + assertFalse(dbFile.exists()) + // The alias goes with the file on every path that deletes it. Nothing it could open is left + // on disk, and the run that finds no file at all can no longer reach it. + assertTrue(legacyKeyRepository.deleted) + } + + /** + * `Unreadable` is not a zero, and keeping the two apart is what makes the deletion safe. Only a + * count that was actually taken reaches `DatabaseEmpty`, the failure that deletes the file; a + * file that cannot be opened at all has to fall to `DatabaseUnreadable` and be left standing, + * because a corrupt page and a half-restored backup fail to open exactly the way a foreign file + * does, and answering "no v1 data here" on that guess would throw away a user's only copy of + * their data. + * + * Four arbitrary bytes are not a SQLite file, so Room throws on the first query and the read + * answers Unreadable, which is the one verdict nothing else in this suite drives. + */ + @Test + fun `keeps a file it could not inspect at all`() = runTest { + seedVault() + dbFile.writeBytes(byteArrayOf(0, 1, 2, 3)) + + assertIs(useCase()) + assertTrue(dbFile.exists()) + assertFalse(legacyKeyRepository.deleted) + } + + @Test + fun `does nothing at all when no legacy file exists`() = runTest { + seedVault() + databaseProvider.delete() + + assertEquals(LegacyMigrationOutcome.NothingToMigrate, useCase()) + assertNull(loginRepository.observeLogins().first().firstOrNull()) + // Nothing was deleted, because there was nothing to delete, so the alias has no reason to go + // and no file appeared at `dbFile` for the next run to find. + assertFalse(legacyKeyRepository.deleted) + assertFalse(dbFile.exists()) + } +} diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/LegacyMigrationRealDatabaseTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/LegacyMigrationRealDatabaseTest.kt new file mode 100644 index 000000000..6eee051c5 --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/LegacyMigrationRealDatabaseTest.kt @@ -0,0 +1,232 @@ +package de.davis.keygo.migration.legacy_data + +import androidx.room3.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import de.davis.keygo.core.item.FakeCreditCardRepository +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.FakeLoginRepository +import de.davis.keygo.core.item.FakeTransactionRunner +import de.davis.keygo.core.item.FakeVaultContextRepository +import de.davis.keygo.core.item.FakeVaultRepository +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.decrypt +import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformation +import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation +import de.davis.keygo.core.util.assertSuccess +import de.davis.keygo.migration.legacy_data.data.FakeLegacyDatabaseProvider +import de.davis.keygo.migration.legacy_data.data.FakeLegacyKeyRepository +import de.davis.keygo.migration.legacy_data.data.FakeRegistrableDomainResolver +import de.davis.keygo.migration.legacy_data.data.crypto.LegacyAesGcmCipher +import de.davis.keygo.migration.legacy_data.data.json.LegacyDetailParser +import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase +import de.davis.keygo.migration.legacy_data.data.repository.LegacyItemRepositoryImpl +import de.davis.keygo.migration.legacy_data.domain.mapper.LegacyItemConverter +import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationOutcome +import de.davis.keygo.migration.legacy_data.domain.usecase.MigrateLegacyDataUseCase +import de.davisalessandro.keygo.rust.ItemAad +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import java.io.File +import java.nio.file.Files +import java.time.YearMonth +import javax.crypto.spec.SecretKeySpec +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Runs the same import as [LegacyMigrationEndToEndTest], but over a `secure_element_database` (with + * its `-wal`/`-shm`) captured from an actual v1 build instead of rows this module wrote itself. + * + * Every other test in this module proves the migration handles what its authors believed v1 could + * produce: a row shape, a JSON shape, a failure mode. That is exactly the blind spot a hand-rolled + * fixture cannot see past, because the same belief that shaped the production code also shaped the + * test data. This class cannot prove the decision logic, only whether the crypto, the parser and the + * converter agree with something none of them produced. + * + * The Keystore-backed key never leaves hardware, so it cannot be part of any file that ends up on + * disk. The three rows below were captured after that alias was swapped for the static AES-256 key + * `keygo_v1_migrate_to_v2__data_key` (32 ASCII bytes), which is why that string is reconstructed + * here rather than read from anywhere production would use. + * + * Edge cases (corrupt files, malformed JSON, partial-failure retries, stale v2 leftovers, batch + * size) stay in [LegacyMigrationEndToEndTest], which can vary them freely; this class only ever + * has the one fixture and is not the place to grow more scenarios. + */ +class LegacyMigrationRealDatabaseTest { + + private val tempDir: File = Files.createTempDirectory("legacy-real-db").toFile() + private val dbFile = File(tempDir, "secure_element_database") + + init { + copyFixture("secure_element_database", dbFile) + copyFixture("secure_element_database-wal", File(tempDir, "secure_element_database-wal")) + copyFixture("secure_element_database-shm", File(tempDir, "secure_element_database-shm")) + } + + private val vaultId = newVaultId() + + private val loginRepository = FakeLoginRepository() + private val creditCardRepository = FakeCreditCardRepository() + private val vaultRepository = FakeVaultRepository() + private val vaultContextRepository = FakeVaultContextRepository() + private val transactionRunner = FakeTransactionRunner() + private val cryptoProvider = FakeCryptographicScopeProvider(FakeItemRepository(loginRepository)) + + private val database: LegacyDatabase = Room.databaseBuilder(dbFile.absolutePath) + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.IO) + .build() + + private val databaseProvider = FakeLegacyDatabaseProvider(file = dbFile, database = database) + + private val legacyKeyRepository = FakeLegacyKeyRepository(STATIC_KEY) + private val legacyCipher = LegacyAesGcmCipher() + + private val legacyRepository = LegacyItemRepositoryImpl( + databaseProvider = databaseProvider, + keyRepository = legacyKeyRepository, + cipher = legacyCipher, + parser = LegacyDetailParser(), + ) + + private val useCase = MigrateLegacyDataUseCase( + legacyItemRepository = legacyRepository, + legacyKeyRepository = legacyKeyRepository, + converter = LegacyItemConverter( + cipher = legacyCipher, + registrableDomainResolver = FakeRegistrableDomainResolver(), + ), + cryptographicScopeProvider = cryptoProvider, + vaultRepository = vaultRepository, + vaultContextRepository = vaultContextRepository, + upsertVaultItem = UpsertVaultItemUseCase(loginRepository, creditCardRepository), + transactionRunner = transactionRunner, + ) + + @AfterTest + fun tearDown() { + database.close() + tempDir.deleteRecursively() + } + + private fun copyFixture(name: String, destination: File) { + val resource = requireNotNull(javaClass.getResourceAsStream("/legacy-fixtures/$name")) { + "Missing test fixture /legacy-fixtures/$name on the test classpath" + } + resource.use { input -> destination.outputStream().use { input.copyTo(it) } } + } + + private suspend fun seedVault() { + vaultRepository.seed( + Vault( + id = vaultId, + name = "Default Vault", + keyInformation = KeyInformation(byteArrayOf(1), byteArrayOf(2)), + icon = Vault.Icon.Default, + ), + ) + vaultContextRepository.setContextAndLastInteracted(vaultId) + } + + private suspend fun readBackPassword(loginId: ItemId): String = + cryptoProvider.itemScope( + wrappedVaultKeyInformation = WrappedVaultKeyInformation( + wrappedVaultKey = KeyInformation(byteArrayOf(1), byteArrayOf(2)), + vaultId = vaultId, + ), + wrappedItemKeyInformation = WrappedItemKeyInformation( + itemAad = ItemAad(itemId = loginId, vaultId = vaultId), + ), + ) { + loginRepository.getLoginById(loginId)!!.passwordCredential!!.secret.decrypt() + }.assertSuccess() + + private data class DecryptedCard( + val holder: String?, + val cardNumber: String?, + val cvv: String?, + val expirationDate: YearMonth?, + ) + + private suspend fun readBackCard(cardId: ItemId): DecryptedCard = + cryptoProvider.itemScope( + wrappedVaultKeyInformation = WrappedVaultKeyInformation( + wrappedVaultKey = KeyInformation(byteArrayOf(1), byteArrayOf(2)), + vaultId = vaultId, + ), + wrappedItemKeyInformation = WrappedItemKeyInformation( + itemAad = ItemAad(itemId = cardId, vaultId = vaultId), + ), + ) { + val card = creditCardRepository.getCreditCardById(cardId)!! + DecryptedCard( + holder = card.holder, + cardNumber = card.cardNumber?.decrypt(), + cvv = card.cvv?.decrypt(), + expirationDate = card.expirationDate, + ) + }.assertSuccess() + + @Test + fun `migrates a v1 database captured from a real build without losing any of its data`() = + runTest { + seedVault() + + val outcome = assertIs(useCase()) + + assertEquals(3, outcome.report.migratedItems) + assertFalse(outcome.report.hasFailures) + + val logins = loginRepository.observeLogins().first() + assertEquals(2, logins.size) + + val withOrigin = logins.single { it.name == "Test Password Title" } + assertEquals("user@something.com", withOrigin.username) + assertEquals("web.website.com", withOrigin.domainInfos.single().value) + assertEquals(setOf("Tag1", "Tag2"), withOrigin.tags.map { it.display }.toSet()) + assertEquals("s3cret123@", readBackPassword(withOrigin.id)) + + // Seeded with no username or origin at all, which v1 stored as empty strings rather + // than nulls; the converter is what turns blank strings back into "not present". + val withoutOrigin = logins.single { it.name == "Random Password" } + assertNull(withoutOrigin.username) + assertTrue(withoutOrigin.domainInfos.isEmpty()) + assertTrue(withoutOrigin.tags.isEmpty()) + assertEquals("7'|MW_x5", readBackPassword(withoutOrigin.id)) + + val migratedCardId = creditCardRepository.store.first().values.first().id + val card = readBackCard(migratedCardId) + assertEquals("Holder Some Card", card.holder) + // v1 stored "4111 1111 1111 1111"; the converter strips separators to match what + // v2's own entry form would have stored for the same card. + assertEquals("4111111111111111", card.cardNumber) + assertEquals("123", card.cvv) + assertEquals(YearMonth.of(2026, 7), card.expirationDate) + assertEquals( + setOf("Tag1", "CardTag"), + creditCardRepository.getCreditCardById(migratedCardId)!!.tags.map { it.display } + .toSet(), + ) + + assertTrue(legacyKeyRepository.deleted) + assertFalse(dbFile.exists()) + assertFalse(File(dbFile.absolutePath + "-wal").exists()) + assertFalse(File(dbFile.absolutePath + "-shm").exists()) + } + + private companion object { + val STATIC_KEY = + SecretKeySpec("keygo_v1_migrate_to_v2__data_key".toByteArray(Charsets.UTF_8), "AES") + } +} diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyAesGcmCipherTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyAesGcmCipherTest.kt new file mode 100644 index 000000000..fcad2cf70 --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyAesGcmCipherTest.kt @@ -0,0 +1,73 @@ +package de.davis.keygo.migration.legacy_data.data.crypto + +import de.davis.keygo.migration.legacy_data.data.encryptLikeV1 +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertNull + +/** + * The framing here is v1's, not ours. See [encryptLikeV1], which is the transcription of it these + * tests are written against. + */ +class LegacyAesGcmCipherTest { + + private val cipher = LegacyAesGcmCipher() + + private val key: SecretKey = newKey() + + @Test + fun `decrypts a blob written by v1`() { + val plaintext = "{\"type\":1,\"username\":\"ada\"}".encodeToByteArray() + + val decrypted = cipher.decrypt(encryptLikeV1(plaintext, key), key) + + assertContentEquals(plaintext, decrypted) + } + + @Test + fun `decrypts an empty plaintext`() { + assertContentEquals( + byteArrayOf(), + cipher.decrypt(encryptLikeV1(byteArrayOf(), key), key), + ) + } + + @Test + fun `returns null for a blob encrypted under a different key`() { + val otherKey = newKey() + + assertNull(cipher.decrypt(encryptLikeV1(byteArrayOf(1, 2, 3), otherKey), key)) + } + + @Test + fun `returns null for a blob shorter than the iv`() { + assertNull(cipher.decrypt(byteArrayOf(1, 2, 3), key)) + } + + @Test + fun `returns null for a tampered blob`() { + val blob = encryptLikeV1("secret".encodeToByteArray(), key) + blob[blob.size - 1] = (blob[blob.size - 1] + 1).toByte() + + assertNull(cipher.decrypt(blob, key)) + } + + @Test + fun `uses a twelve byte iv prefix`() { + val blob = encryptLikeV1("x".encodeToByteArray(), key) + val manual = Cipher.getInstance("AES/GCM/NoPadding").apply { + init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, blob, 0, 12)) + }.doFinal(blob, 12, blob.size - 12) + + assertContentEquals(manual, cipher.decrypt(blob, key)) + } + + private fun newKey(): SecretKey = KeyGenerator.getInstance("AES") + .apply { init(256, SecureRandom()) } + .generateKey() +} diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/json/LegacyDetailParserTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/json/LegacyDetailParserTest.kt new file mode 100644 index 000000000..4cff958fa --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/json/LegacyDetailParserTest.kt @@ -0,0 +1,127 @@ +package de.davis.keygo.migration.legacy_data.data.json + +import de.davis.keygo.migration.legacy_data.domain.model.LegacyDetail +import de.davis.keygo.migration.legacy_data.domain.model.LegacyStrength +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +/** + * Every fixture here is shaped the way GSON wrote it in v1, including the details that look wrong: + * `password` is a JSON array of signed bytes because GSON serialises `byte[]` that way, and + * `strength` appears both as an enum name and as the older `{"type": n}` object because v1's + * `ElementDetailTypeAdapter` rewrote the object form on read and left already-migrated rows alone. + */ +class LegacyDetailParserTest { + + private val parser = LegacyDetailParser() + + private fun parse(json: String) = parser.parse(json.encodeToByteArray()) + + @Test + fun `parses a password with a string strength`() { + val detail = parse( + """{"type":1,"username":"ada","origin":"https://example.com", + "password":[1,-2,3],"strength":"STRONG"}""", + ) + + val password = assertIs(detail) + assertEquals("ada", password.username) + assertEquals("https://example.com", password.origin) + assertContentEquals(byteArrayOf(1, -2, 3), password.password) + assertEquals(LegacyStrength.STRONG, password.strength) + } + + @Test + fun `parses a password with the legacy object strength`() { + val detail = parse("""{"type":1,"username":"ada","strength":{"type":3}}""") + + assertEquals(LegacyStrength.STRONG, assertIs(detail).strength) + } + + @Test + fun `parses a password with no strength at all`() { + val detail = parse("""{"type":1,"username":"ada"}""") + + assertNull(assertIs(detail).strength) + } + + @Test + fun `maps every legacy strength ordinal`() { + val expected = listOf( + LegacyStrength.RIDICULOUS, + LegacyStrength.WEAK, + LegacyStrength.MODERATE, + LegacyStrength.STRONG, + LegacyStrength.VERY_STRONG, + ) + + expected.forEachIndexed { ordinal, strength -> + val detail = parse("""{"type":1,"strength":{"type":$ordinal}}""") + assertEquals(strength, assertIs(detail).strength) + } + } + + @Test + fun `keeps a password whose strength object is not shaped like v1 wrote it`() { + val detail = parse("""{"type":1,"username":"ada","strength":{"type":{"nested":1}}}""") + + val password = assertIs(detail) + assertEquals("ada", password.username) + assertNull(password.strength) + } + + @Test + fun `parses a credit card`() { + val detail = parse( + """{"type":17,"cardholder":{"firstName":"Ada","lastName":"Lovelace"}, + "expirationDate":"04/29","cardNumber":"4111111111111111","cvv":"123"}""", + ) + + val card = assertIs(detail) + assertEquals("Ada", card.firstName) + assertEquals("Lovelace", card.lastName) + assertEquals("04/29", card.expirationDate) + assertEquals("4111111111111111", card.cardNumber) + assertEquals("123", card.cvv) + } + + @Test + fun `parses a credit card with a null cardholder and blank cvv`() { + val detail = parse("""{"type":17,"cardholder":null,"cvv":"","cardNumber":"4111"}""") + + val card = assertIs(detail) + assertNull(card.firstName) + assertNull(card.lastName) + assertEquals("", card.cvv) + } + + @Test + fun `tolerates unknown keys that a newer v1 build might have written`() { + val detail = parse("""{"type":1,"username":"ada","somethingNew":{"a":1}}""") + + assertEquals("ada", assertIs(detail).username) + } + + @Test + fun `returns null for an unknown type id`() { + assertNull(parse("""{"type":99,"username":"ada"}""")) + } + + @Test + fun `returns null when the type is missing`() { + assertNull(parse("""{"username":"ada"}""")) + } + + @Test + fun `returns null for malformed json`() { + assertNull(parse("not json at all")) + } + + @Test + fun `returns null for empty input`() { + assertNull(parser.parse(byteArrayOf())) + } +} diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseOpenTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseOpenTest.kt new file mode 100644 index 000000000..3ca67f6b5 --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseOpenTest.kt @@ -0,0 +1,162 @@ +package de.davis.keygo.migration.legacy_data.data.local + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.execSQL +import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase +import kotlinx.coroutines.test.runTest +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Proves the ported entities open a database file that was created by a real v1 build, at every + * version v1 ever shipped, and that the migrations carry it forward to version 3. + * + * The seed files come from v1's own exported schema JSON, not from the ported entities under test, + * which is what keeps the proof honest. See [seedLegacyDatabase] for how and why. + * + * What the migrations do to rows that would abort the 2-to-3 recreate is a separate subject and + * lives in [LegacyMigrationTest], which can validate the resulting schema as well as the rows. + */ +class LegacyDatabaseOpenTest { + + private val tempDir: File = Files.createTempDirectory("legacy-db-test").toFile() + private val dbFile: File = File(tempDir, "secure_element_database") + + private fun openMigrated(): LegacyDatabase = openMigratedLegacyDatabase(dbFile) + + @AfterTest + fun tearDown() { + tempDir.deleteRecursively() + } + + private fun SQLiteConnection.columnsOf(table: String): List = + prepare("PRAGMA table_info($table)").use { stmt -> + buildList { + while (stmt.step()) add(stmt.getText(1)) + } + } + + private fun SQLiteConnection.insertElement(title: String, type: Int, blob: ByteArray) { + // Version 1 has no `favorite` column and version 3 declares it NOT NULL with no default, + // so writing it whenever it exists keeps one seed helper valid across all three schemas. + val hasFavorite = "favorite" in columnsOf("SecureElement") + val columns = if (hasFavorite) "title, data, type, favorite" else "title, data, type" + val values = if (hasFavorite) "?, ?, ?, 0" else "?, ?, ?" + + prepare("INSERT INTO SecureElement ($columns) VALUES ($values)").use { stmt -> + stmt.bindText(1, title) + stmt.bindBlob(2, blob) + stmt.bindLong(3, type.toLong()) + stmt.step() + } + } + + @Test + fun `opens and migrates a version 1 file`() = runTest { + seedLegacyDatabase(dbFile, version = 1).use { connection -> + connection.insertElement("Old entry", type = 1, blob = byteArrayOf(1, 2, 3)) + } + + val db = openMigrated() + val rows = db.legacyElementDao().getAllWithTags() + db.close() + + assertEquals(1, rows.size) + assertEquals("Old entry", rows.single().element.title) + assertEquals(1, rows.single().element.type) + // v1's 1-to-2 backfill is intentionally not ported; the mapper supplies the fallback. + assertNull(rows.single().element.timestamps.createdAt) + } + + @Test + fun `opens and migrates a version 2 file`() = runTest { + seedLegacyDatabase(dbFile, version = 2).use { connection -> + connection.insertElement("Mid entry", type = 17, blob = byteArrayOf(4, 5)) + connection.execSQL( + "UPDATE SecureElement SET created_at = 1700000000000, " + + "modified_at = 1700000009999, favorite = 1", + ) + } + + val db = openMigrated() + val rows = db.legacyElementDao().getAllWithTags() + val count = db.legacyElementDao().count() + db.close() + + assertEquals(1, rows.size) + assertEquals(1, count) + assertEquals(17, rows.single().element.type) + assertEquals(1_700_000_000_000L, rows.single().element.timestamps.createdAt) + // The mapper reads both of these, so the recreate has to carry them across intact. + assertEquals(1_700_000_009_999L, rows.single().element.timestamps.modifiedAt) + assertTrue(rows.single().element.favorite) + } + + @Test + fun `opens a version 3 file and reads tags through the junction`() = runTest { + seedLegacyDatabase(dbFile, version = 3).use { connection -> + connection.insertElement("Current entry", type = 1, blob = byteArrayOf(9)) + connection.execSQL("INSERT INTO Tag (name) VALUES ('work')") + connection.execSQL("INSERT INTO SecureElementTagCrossRef (id, tagId) VALUES (1, 1)") + } + + val db = openMigrated() + val rows = db.legacyElementDao().getAllWithTags() + db.close() + + assertEquals(1, rows.size) + assertEquals(listOf("work"), rows.single().tags.map { it.name }) + } + + @Test + fun `reads an empty database`() = runTest { + seedLegacyDatabase(dbFile, version = 3).close() + + val db = openMigrated() + val rows = db.legacyElementDao().getAllWithTags() + val count = db.legacyElementDao().count() + db.close() + + assertTrue(rows.isEmpty()) + assertEquals(0, count) + } + + @Test + fun `deletes only the requested rows and cascades their cross refs`() = runTest { + seedLegacyDatabase(dbFile, version = 3).use { connection -> + connection.insertElement("Keep", type = 1, blob = byteArrayOf(1)) + connection.insertElement("Drop", type = 1, blob = byteArrayOf(2)) + connection.execSQL("INSERT INTO Tag (name) VALUES ('shared')") + connection.execSQL("INSERT INTO SecureElementTagCrossRef (id, tagId) VALUES (2, 1)") + } + + assertEquals(1, crossRefCount(), "seed is wrong, the cascade assertion would be vacuous") + + val db = openMigrated() + db.legacyElementDao().deleteByIds(listOf(2L)) + val remaining = db.legacyElementDao().getAllWithTags() + db.close() + + assertEquals(listOf("Keep"), remaining.map { it.element.title }) + // The prune leans on the cascade, so an orphaned cross ref has to fail this test. + assertEquals(0, crossRefCount()) + } + + /** + * Counted straight off the file rather than through a DAO, so the assertion does not need a + * production query that nothing but this test would ever call. + */ + private fun crossRefCount(): Int = + BundledSQLiteDriver().open(dbFile.absolutePath).use { connection -> + connection.prepare("SELECT COUNT(*) FROM SecureElementTagCrossRef").use { stmt -> + stmt.step() + stmt.getLong(0).toInt() + } + } +} diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyMigrationTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyMigrationTest.kt new file mode 100644 index 000000000..4418117cd --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyMigrationTest.kt @@ -0,0 +1,173 @@ +package de.davis.keygo.migration.legacy_data.data.local + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.execSQL +import de.davis.keygo.migration.legacy_data.data.local.datasource.AndroidLegacyDatabaseProvider +import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase +import kotlinx.coroutines.test.runTest +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Proves the hand-written 2-to-3 lands on exactly the schema v1 shipped, and that it deals with the + * rows a generated recreate would have aborted on. + * + * Opening through real Room is the schema assertion, and it is a strong one. After running a + * migration Room compares the resulting tables against the schema it expects for that version and + * throws `Migration didn't properly handle ...` on any drift in a column name, affinity, + * nullability, default, primary key, index or foreign key. So every test here that reaches a row + * has already proved the DDL in + * [de.davis.keygo.migration.legacy_data.data.local.migration.LegacyMigration2To3] is a faithful copy + * of v1's. Opening through [AndroidLegacyDatabaseProvider] rather than a bare builder means the + * tests also fail if production ever forgets to register the migration. + */ +class LegacyMigrationTest { + + private val tempDir: File = Files.createTempDirectory("legacy-migration").toFile() + private val dbFile: File = File(tempDir, "secure_element_database") + + private fun openMigrated(): LegacyDatabase = openMigratedLegacyDatabase(dbFile) + + @AfterTest + fun tearDown() { + tempDir.deleteRecursively() + } + + private fun SQLiteConnection.insertV2( + title: String?, + data: ByteArray?, + type: Int = 1, + favorite: Int = 0, + ) { + prepare( + "INSERT INTO SecureElement (title, data, type, favorite) VALUES (?, ?, ?, ?)", + ).use { stmt -> + if (title == null) stmt.bindNull(1) else stmt.bindText(1, title) + if (data == null) stmt.bindNull(2) else stmt.bindBlob(2, data) + stmt.bindLong(3, type.toLong()) + stmt.bindLong(4, favorite.toLong()) + stmt.step() + } + } + + private fun tablesOnDisk(): List = + BundledSQLiteDriver().open(dbFile.absolutePath).use { connection -> + connection.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name", + ).use { stmt -> + buildList { + while (stmt.step()) add(stmt.getText(0)) + } + } + } + + @Test + fun `migrates a version 2 file onto v1's version 3 schema`() = runTest { + seedLegacyDatabase(dbFile, version = 2).use { connection -> + connection.insertV2("Kept", byteArrayOf(1, 2, 3), type = 17, favorite = 1) + connection.execSQL( + "UPDATE SecureElement SET created_at = 1700000000000, modified_at = 1700000009999", + ) + } + + // Reaching a row at all is the schema assertion: Room validates after migrating. + val db = openMigrated() + val rows = db.legacyElementDao().getAllWithTags() + db.close() + + assertEquals(1, rows.size) + val element = rows.single().element + assertEquals("Kept", element.title) + assertEquals(17, element.type) + assertTrue(element.favorite) + assertEquals(1_700_000_000_000L, element.timestamps.createdAt) + assertEquals(1_700_000_009_999L, element.timestamps.modifiedAt) + assertEquals(3, userVersionOf(dbFile)) + } + + @Test + fun `migrates a version 1 file, running the generated 1-to-2 first`() = runTest { + seedLegacyDatabase(dbFile, version = 1).use { connection -> + connection.prepare( + "INSERT INTO SecureElement (title, data, type) VALUES (?, ?, ?)", + ).use { stmt -> + stmt.bindText(1, "Old entry") + stmt.bindBlob(2, byteArrayOf(9)) + stmt.bindLong(3, 1) + stmt.step() + } + } + + val db = openMigrated() + val rows = db.legacyElementDao().getAllWithTags() + db.close() + + assertEquals(listOf("Old entry"), rows.map { it.element.title }) + assertEquals(3, userVersionOf(dbFile)) + } + + /** + * The row is deleted rather than repaired, because there is no ciphertext in it to recover. v1 + * wrote it when `Cryptography.encryptAES` swallowed a Keystore failure and returned null, and + * v1 could not read it back either. Coalescing it to an empty blob would leave a row that can + * never decrypt, and one of those keeps the import reporting a failure forever, which would + * leave the legacy file and v1's Keystore alias on disk for good. + */ + @Test + fun `drops a row whose blob is null and keeps every other row`() = runTest { + seedLegacyDatabase(dbFile, version = 2).use { connection -> + connection.insertV2("Has a blob", byteArrayOf(1)) + connection.insertV2("No blob at all", null) + connection.insertV2("Also has a blob", byteArrayOf(2)) + } + + val db = openMigrated() + val rows = db.legacyElementDao().getAllWithTags() + db.close() + + assertEquals(listOf("Has a blob", "Also has a blob"), rows.map { it.element.title }) + } + + /** + * The opposite call to the one above, and the reason the two columns cannot share a rule. The + * blob beside a null title is intact, so this row is a recoverable credential that happens to + * have no display name. Deleting it would throw away a password to save a string. + */ + @Test + fun `keeps a row whose title is null and empties the title instead`() = runTest { + seedLegacyDatabase(dbFile, version = 2).use { connection -> + connection.insertV2(null, byteArrayOf(4, 5, 6)) + } + + val db = openMigrated() + val rows = db.legacyElementDao().getAllWithTags() + db.close() + + assertEquals(1, rows.size) + assertEquals("", rows.single().element.title) + assertContentEquals(byteArrayOf(4, 5, 6), rows.single().element.data) + } + + @Test + fun `drops the MasterPassword table and creates the tag tables`() = runTest { + seedLegacyDatabase(dbFile, version = 2).use { connection -> + connection.insertV2("Anything", byteArrayOf(1)) + } + assertTrue("MasterPassword" in tablesOnDisk(), "seed is wrong") + + val db = openMigrated() + db.legacyElementDao().count() + db.close() + + val tables = tablesOnDisk() + assertTrue("MasterPassword" !in tables, "v2's MasterPassword must not survive") + assertTrue("Tag" in tables) + assertTrue("SecureElementTagCrossRef" in tables) + } +} diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacySchemaIdentityTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacySchemaIdentityTest.kt new file mode 100644 index 000000000..0d6e95290 --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacySchemaIdentityTest.kt @@ -0,0 +1,56 @@ +package de.davis.keygo.migration.legacy_data.data.local + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Guards the ported entities against drifting from v1's frozen schema. + * + * The identity hash is the whole assertion, because it is the whole of what Room itself checks: + * Room compares `room_master_table.identity_hash` against the hash derived from the declared + * entities and refuses to open the file on a mismatch. The hash is computed over sorted fields, so + * it pins column names, affinities, nullability, defaults, the primary key, indices and foreign + * keys. If it matches v1's, the ported entities describe v1's database, and every real v1 file on + * every device still opens. + * + * What the hash does not pin is the order the columns are declared in, and nothing else needs to. + * Room validates an opened file by matching columns by name, so declaration order is inert at + * runtime and there is nothing there for a test to protect. + * + * These hashes are v1's, read from `origin/v1:app/schemas/...KeyGoDatabase/`. They are frozen: v1 + * will never ship another version. A failure here means the port drifted, not that the expectation + * is stale. Versions 1 and 2 are not generated from anything, they *are* v1's own files, copied + * verbatim into the schema directory so Room can generate the 1-to-2 migration from them; asserting + * their hashes is what catches an edit to those copies. + */ +class LegacySchemaIdentityTest { + + private val json = Json { ignoreUnknownKeys = true } + + private fun identityHashOf(version: Int): String = + json.parseToJsonElement(LEGACY_SCHEMA_DIR.resolve("$version.json").toFile().readText()) + .jsonObject + .getValue("database") + .jsonObject + .getValue("identityHash") + .jsonPrimitive + .content + + @Test + fun `version 3 identity hash matches v1`() { + assertEquals( + "0a97d13a94575bc2ed2ab009853b0086", + identityHashOf(3), + "Ported entities no longer generate v1's version 3 schema.", + ) + } + + @Test + fun `copied version 1 and 2 schemas are v1's own`() { + assertEquals("6fb485e6e64b1e9cc5ceeb0440c58e23", identityHashOf(1)) + assertEquals("d5dd1a0120d5842be17e8ea9a19ee039", identityHashOf(2)) + } +} diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacySchemaSeed.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacySchemaSeed.kt new file mode 100644 index 000000000..fb82f8c93 --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacySchemaSeed.kt @@ -0,0 +1,81 @@ +package de.davis.keygo.migration.legacy_data.data.local + +import android.app.Instrumentation +import android.content.Context +import android.content.res.AssetManager +import androidx.room3.testing.MigrationTestHelper +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import java.io.File +import java.nio.file.Path +import java.nio.file.Paths + +/** + * Where v1's exported schema JSON lives, handed over by the `Test` task in `build.gradle.kts`. + * + * A system property rather than a relative path, because a relative path only resolves while the + * working directory happens to be the module directory, which is true under Gradle and not + * guaranteed in an IDE run configuration. + */ +internal val LEGACY_SCHEMA_DIR: Path = Paths.get( + checkNotNull(System.getProperty("legacySchemaDir")) { + "legacySchemaDir is unset; the Test task in build.gradle.kts is what supplies it" + }, +).resolve(LegacyDatabase::class.qualifiedName) + +private fun schemaInstrumentation(): Instrumentation { + val assets = mockk() + every { assets.open(any()) } answers { + LEGACY_SCHEMA_DIR.parent.resolve(firstArg()).toFile().inputStream() + } + + val context = object : LegacyStubContext() { + override fun getAssets(): AssetManager = assets + } + + return object : Instrumentation() { + override fun getContext(): Context = context + + override fun getTargetContext(): Context = context + } +} + +/** + * Writes a file at [file] that looks exactly like one a v1 build at [version] would have left + * behind, and hands back the open connection so the caller can seed rows into it. + * + * Room's own `MigrationTestHelper` does the writing, from v1's exported schema rather than from the + * ported entities under test, which is what keeps the proof honest. Versions 1 and 2 in that + * directory *are* v1's own files, copied in verbatim so Room can generate the 1-to-2 migration from + * them, which is why there is one copy of them rather than two. + * + * Only the helper's `createDatabase` is used. Its `runMigrationsAndValidate` is deliberately left + * alone, because it takes the migration list from the caller: the tests instead open the seeded file + * through [de.davis.keygo.migration.legacy_data.data.local.datasource.AndroidLegacyDatabaseProvider], + * which validates the post-migration schema through real Room *and* fails if production ever stops + * registering the hand-written 2-to-3. See [LegacyMigrationTest]. + * + * Not suspending, though `createDatabase` is, so that the handful of callers which have no reason to + * be coroutines do not have to become them. + */ +internal fun seedLegacyDatabase(file: File, version: Int): SQLiteConnection = runBlocking { + MigrationTestHelper( + instrumentation = schemaInstrumentation(), + file = file, + driver = BundledSQLiteDriver(), + databaseClass = LegacyDatabase::class, + ).createDatabase(version) +} + +/** The version stamp read straight off [file], which is how a test sees whether Room ran. */ +internal fun userVersionOf(file: File): Int = + BundledSQLiteDriver().open(file.absolutePath).use { connection -> + connection.prepare("PRAGMA user_version").use { stmt -> + stmt.step() + stmt.getLong(0).toInt() + } + } diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyTestContext.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyTestContext.kt new file mode 100644 index 000000000..26a806904 --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyTestContext.kt @@ -0,0 +1,49 @@ +package de.davis.keygo.migration.legacy_data.data.local + +import android.content.Context +import android.content.ContextWrapper +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import de.davis.keygo.migration.legacy_data.data.local.datasource.AndroidLegacyDatabaseProvider +import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase +import java.io.File +import kotlin.test.assertNotNull + +/** + * A [Context] that answers only what Room reaches for, and throws for everything else. + * + * A relaxed mock would answer *every* call, which is the opposite of what is wanted here: an + * unstubbed answer is how a test ends up quietly reading some other file and reporting an empty + * database. The two overrides are not guesses, they are what Room's android builder actually reaches + * for on the way to opening a file: + * - `getSystemService` returns null. Room asks for `ActivityManager` to decide whether it is on a + * low RAM device, and treats an absent service the same as a device that is not. + * - `getApplicationContext` returns this, because a null base context has none to delegate to. + * + * A `ContextWrapper` with a null base rather than a subclass of `Context`, because `Context` is + * abstract across dozens of members and none of the others are ever called. + */ +internal open class LegacyStubContext : ContextWrapper(null) { + + override fun getSystemService(name: String): Any? = null + + override fun getApplicationContext(): Context = this +} + +/** + * Answers `getDatabasePath` with [databaseFile]. Both production classes in this module that take a + * Context use it only to turn `secure_element_database` into a path, so pointing that one call at a + * temp file is the whole of what a test needs. + */ +internal fun legacyContext(databaseFile: File): Context = object : LegacyStubContext() { + + override fun getDatabasePath(name: String?): File = databaseFile +} + +/** + * Opened through the production provider rather than a bare `Room.databaseBuilder`, so a test cannot + * pass while the provider forgets to register the hand-written 2-to-3 migration. + */ +internal fun openMigratedLegacyDatabase(databaseFile: File): LegacyDatabase = + assertNotNull( + AndroidLegacyDatabaseProvider(legacyContext(databaseFile), BundledSQLiteDriver()).get(), + ) diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyItemRepositoryImplTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyItemRepositoryImplTest.kt new file mode 100644 index 000000000..b0567eca9 --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyItemRepositoryImplTest.kt @@ -0,0 +1,349 @@ +package de.davis.keygo.migration.legacy_data.data.repository + +import androidx.room3.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.execSQL +import de.davis.keygo.core.util.assertFailure +import de.davis.keygo.core.util.assertSuccess +import de.davis.keygo.migration.legacy_data.data.FakeLegacyCipher +import de.davis.keygo.migration.legacy_data.data.FakeLegacyDatabase +import de.davis.keygo.migration.legacy_data.data.FakeLegacyDatabaseProvider +import de.davis.keygo.migration.legacy_data.data.FakeLegacyElementDao +import de.davis.keygo.migration.legacy_data.data.FakeLegacyKeyRepository +import de.davis.keygo.migration.legacy_data.data.json.LegacyDetailParser +import de.davis.keygo.migration.legacy_data.data.local.datasource.AndroidLegacyDatabaseProvider +import de.davis.keygo.migration.legacy_data.data.local.datasource.LEGACY_DATABASE_NAME +import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase +import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabaseProvider +import de.davis.keygo.migration.legacy_data.data.local.entity.LegacySecureElementEntity +import de.davis.keygo.migration.legacy_data.data.local.entity.LegacySecureElementTagCrossRef +import de.davis.keygo.migration.legacy_data.data.local.entity.LegacyTagEntity +import de.davis.keygo.migration.legacy_data.data.local.entity.LegacyTimestamps +import de.davis.keygo.migration.legacy_data.data.local.legacyContext +import de.davis.keygo.migration.legacy_data.domain.model.LegacyDetail +import de.davis.keygo.migration.legacy_data.domain.model.LegacyFailureReason +import de.davis.keygo.migration.legacy_data.domain.model.LegacyReadFailure +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import java.io.File +import java.nio.file.Files +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class LegacyItemRepositoryImplTest { + + private lateinit var db: LegacyDatabase + private lateinit var keyRepository: FakeLegacyKeyRepository + private lateinit var databaseProvider: FakeLegacyDatabaseProvider + private lateinit var repository: LegacyItemRepositoryImpl + + /** + * A real path inside a directory that starts out empty, which is exactly what a clean v2 + * install looks like: no `secure_element_database`, and nothing allowed to bring one into + * being. Tests that need a file to be there seed one at this path themselves. + */ + private val tempDir: File = Files.createTempDirectory("legacy-on-disk").toFile() + private val legacyFile: File = File(tempDir, LEGACY_DATABASE_NAME) + + /** + * Wired over the real provider and a real path rather than the in-memory database, because + * these tests turn on what is actually on disk: whether a file appears that should not exist, + * and what the file that is there turns out to be. Fakes could not show that. + * + * Room gets a real driver here for the same reason. Left on its framework helper it cannot + * create a file under a JVM test at all, so the assertion that no file appears would hold + * whether or not the guard existed, and a test that cannot fail proves nothing. + */ + private fun fileBackedRepository() = repositoryOver( + AndroidLegacyDatabaseProvider( + context = legacyContext(legacyFile), + driver = BundledSQLiteDriver(), + ), + ) + + @BeforeTest + fun setUp() { + db = Room.inMemoryDatabaseBuilder() + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.IO) + .build() + keyRepository = FakeLegacyKeyRepository() + databaseProvider = FakeLegacyDatabaseProvider(legacyFile, db) + repository = newRepository() + } + + @AfterTest + fun tearDown() { + db.close() + tempDir.deleteRecursively() + } + + private fun newRepository() = repositoryOver(databaseProvider) + + private fun repositoryOver(provider: LegacyDatabaseProvider) = LegacyItemRepositoryImpl( + databaseProvider = provider, + keyRepository = keyRepository, + cipher = FakeLegacyCipher(), + parser = LegacyDetailParser(), + ) + + private suspend fun insert( + title: String, + json: String, + favorite: Boolean = false, + createdAt: Long? = 1_700_000_000_000L, + modifiedAt: Long? = null, + type: Int = 1, + tags: List = emptyList(), + ): Long { + val id = db.legacyElementDao().insertElement( + LegacySecureElementEntity( + title = title, + data = json.encodeToByteArray(), + favorite = favorite, + timestamps = LegacyTimestamps(createdAt = createdAt, modifiedAt = modifiedAt), + ).apply { this.type = type }, + ) + tags.forEach { name -> + val tagId = db.legacyElementDao().insertTag(LegacyTagEntity(name = name)) + db.legacyElementDao().insertCrossRef(LegacySecureElementTagCrossRef(id, tagId)) + } + return id + } + + @Test + fun `reads a password row with all of its fields`() = runTest { + insert( + title = "Example", + json = """{"type":1,"username":"ada","origin":"https://example.com","strength":"WEAK"}""", + favorite = true, + modifiedAt = 1_700_000_999_000L, + ) + + val result = repository.readAll().assertSuccess() + + assertTrue(result.failures.isEmpty()) + val item = result.items.single() + assertEquals("Example", item.title) + assertTrue(item.favorite) + assertEquals(1_700_000_000_000L, item.createdAt) + assertEquals(1_700_000_999_000L, item.modifiedAt) + assertEquals("ada", assertIs(item.detail).username) + } + + /** + * Schema version 1 had no timestamp columns, so a row that came up from it carries a real null. + * Substituting a value here would hide that from the fallback the converter applies later. + */ + @Test + fun `carries a missing created at through as null`() = runTest { + insert(title = "Old", json = """{"type":1}""", createdAt = null) + + assertNull(repository.readAll().assertSuccess().items.single().createdAt) + } + + @Test + fun `keeps user tags and drops element type tags`() = runTest { + insert( + title = "Example", + json = """{"type":1,"username":"ada"}""", + tags = listOf("work", "elementType:password", "personal"), + ) + + assertEquals( + setOf("work", "personal"), + repository.readAll().assertSuccess().items.single().tags, + ) + } + + @Test + fun `records an undecryptable row as a failure and keeps reading`() = runTest { + insert(title = "Broken", json = "${FakeLegacyCipher.FAIL}whatever") + insert(title = "Fine", json = """{"type":1,"username":"ada"}""") + + val result = repository.readAll().assertSuccess() + + assertEquals(listOf("Fine"), result.items.map { it.title }) + assertEquals(LegacyFailureReason.Unreadable, result.failures.single().reason) + assertEquals("Broken", result.failures.single().title) + } + + @Test + fun `records an unparseable row as a failure`() = runTest { + insert(title = "Garbled", json = "definitely not json") + + assertEquals( + LegacyFailureReason.Unreadable, + repository.readAll().assertSuccess().failures.single().reason, + ) + } + + @Test + fun `records an unknown type as a failure`() = runTest { + insert(title = "Alien", json = """{"type":42}""", type = 42) + + assertEquals( + LegacyFailureReason.Unreadable, + repository.readAll().assertSuccess().failures.single().reason, + ) + } + + @Test + fun `prune removes only the given rows`() = runTest { + insert(title = "Keep", json = """{"type":1}""") + val drop = insert(title = "Drop", json = """{"type":1}""") + + repository.prune(listOf(drop)).assertSuccess() + + assertEquals(listOf("Keep"), repository.readAll().assertSuccess().items.map { it.title }) + assertEquals(1, repository.remainingCount().assertSuccess()) + } + + /** + * SQLite's bound-parameter limit was 999 until 3.32.0, and Android's system SQLite still holds + * to it through API 30: a single `DELETE ... WHERE id IN (...)` over more ids than that throws + * instead of deleting. This repository runs on [BundledSQLiteDriver] in every JVM test, whose + * bundled SQLite has no such ceiling, so this test cannot reproduce the throw itself; what it + * proves instead is the invariant that makes the throw impossible regardless of which SQLite a + * device is actually running: no call to the DAO ever carries more than a safe number of ids. + */ + @Test + fun `prune splits large batches across several deletes`() = runTest { + val dao = FakeLegacyElementDao((1L..1200L).toSet()) + val repo = repositoryOver(FakeLegacyDatabaseProvider(legacyFile, FakeLegacyDatabase(dao))) + + repo.prune((1L..1200L).toList()).assertSuccess() + + assertTrue( + dao.deleteCalls.all { it.size <= 500 }, + "A v1 vault above SQLite's parameter limit must still prune in one call to prune(), " + + "just split across several deletes underneath. A single oversized call throws, the " + + "prune is reported as failed, and the whole vault reimports on the next unlock.", + ) + assertEquals(1200, dao.deleteCalls.sumOf { it.size }) + assertTrue(dao.remainingIds.isEmpty()) + } + + /** + * A file with nothing in it is a failure rather than an empty success, because the caller acts + * on it: `DatabaseEmpty` is what earns the file its deletion. An empty success would be read as + * "imported everything, which was nothing" and fall through the import instead. + */ + @Test + fun `reads an empty database as the failure that earns a delete`() = runTest { + assertEquals(LegacyReadFailure.DatabaseEmpty, repository.readAll().assertFailure()) + assertEquals(0, repository.remainingCount().assertSuccess()) + } + + /** + * A gone alias is one outcome for the whole run, not one failure per row. Both rows here would + * decrypt fine under a key that still existed, so reporting them as two `Unreadable` rows + * would claim the entries were damaged when only the key is missing. + */ + @Test + fun `reports a missing legacy key once instead of one failure per row`() = runTest { + insert(title = "First", json = """{"type":1}""") + insert(title = "Second", json = """{"type":1}""") + keyRepository = FakeLegacyKeyRepository(key = null) + + assertEquals(LegacyReadFailure.KeyUnavailable, newRepository().readAll().assertFailure()) + } + + @Test + fun `probes the legacy key once for the whole read`() = runTest { + repeat(3) { insert(title = "Row $it", json = """{"type":1}""") } + + repository.readAll().assertSuccess() + + assertEquals(1, keyRepository.probes) + } + + /** + * The provider hands back nothing when there is no file at the legacy path, which is a clean + * install and holds nothing to lose. That has to arrive as a failure the migration can report, + * never as an exception escaping into the unlock flow. + */ + @Test + fun `reports no provider as an empty database instead of throwing`() = runTest { + databaseProvider = FakeLegacyDatabaseProvider(legacyFile, database = null) + + assertEquals(LegacyReadFailure.DatabaseEmpty, newRepository().readAll().assertFailure()) + } + + /** + * The regression this guards is a file that should never have existed. This module only reads a + * database it inherited, and Room creates any file it is asked to open, so a read on an install + * that never ran v1 would leave an empty `secure_element_database` behind for every later run to + * find and treat as inherited data. + * + * Asserting the disk rather than the return value is the point: the return value would look the + * same whether or not the guard held. + */ + @Test + fun `reading on a clean install leaves no legacy file behind`() = runTest { + val result = fileBackedRepository().readAll() + + assertFalse( + legacyFile.exists(), + "reading must never bring a legacy database into existence", + ) + assertEquals(LegacyReadFailure.DatabaseEmpty, result.assertFailure()) + } + + /** + * The regression that matters most in this class. This file has the `SecureElement` table and a + * row in it, but Room refuses it on the first query. It must not come back as + * [LegacyReadFailure.DatabaseEmpty], which would delete a file holding a row nobody has ever + * read: a corrupt page, a disk that fills up during the 2-to-3 recreate and a cancelled run all + * arrive here by the same route. + * + * `DatabaseUnreadable` is the right shape. The count could not be taken at all, so nothing is + * known about what is in the file; that is reported, and the caller leaves the file standing. + * + * Seeded with v1's table name but not v1's schema, which is what Room's own integrity check + * rejects. A real inherited file that fails deeper down produces the same shape of failure. + */ + @Test + fun `a file Room refuses is unreadable, never empty`() = runTest { + BundledSQLiteDriver().open(legacyFile.absolutePath).use { connection -> + connection.execSQL("CREATE TABLE SecureElement (id INTEGER PRIMARY KEY)") + connection.execSQL("INSERT INTO SecureElement (id) VALUES (1)") + connection.execSQL("PRAGMA user_version = 3") + } + val repository = fileBackedRepository() + + assertEquals(LegacyReadFailure.DatabaseUnreadable, repository.readAll().assertFailure()) + assertTrue(legacyFile.exists(), "a file nobody could read must survive the run") + } + + /** + * Cancelling the unlock scope is not a statement about the user's file. `runCatching` here + * would fold it into `DatabaseUnreadable` and let a run that never finished answer for what is + * on disk, so this seam rethrows where the other repositories in the codebase do not. + */ + @Test + fun `lets a cancellation out instead of reporting the file as unreadable`() = runTest { + val dao = FakeLegacyElementDao().apply { + countFailure = CancellationException("the unlock scope went away") + } + databaseProvider = FakeLegacyDatabaseProvider(legacyFile, FakeLegacyDatabase(dao)) + + assertFailsWith { newRepository().remainingCount() } + } + + @Test + fun `deleteDatabase closes the open handle before removing the file`() = runTest { + legacyFile.createNewFile() + + assertTrue(repository.deleteDatabase(), "the file was there and had to go") + assertTrue(databaseProvider.closed, "the file cannot be deleted from under an open handle") + } +} diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/mapper/LegacyItemConverterTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/mapper/LegacyItemConverterTest.kt new file mode 100644 index 000000000..98124a9f1 --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/mapper/LegacyItemConverterTest.kt @@ -0,0 +1,298 @@ +package de.davis.keygo.migration.legacy_data.domain.mapper + +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.CreditCard +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.decrypt +import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformation +import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation +import de.davis.keygo.core.util.assertSuccess +import de.davis.keygo.migration.legacy_data.data.FAKE_LEGACY_KEY +import de.davis.keygo.migration.legacy_data.data.FakeLegacyCipher +import de.davis.keygo.migration.legacy_data.data.FakeRegistrableDomainResolver +import de.davis.keygo.migration.legacy_data.domain.crypto.LegacyCipher +import de.davis.keygo.migration.legacy_data.domain.model.LegacyDetail +import de.davis.keygo.migration.legacy_data.domain.model.LegacyItem +import de.davis.keygo.migration.legacy_data.domain.model.LegacyStrength +import de.davisalessandro.keygo.rust.ItemAad +import kotlinx.coroutines.test.runTest +import java.time.YearMonth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +class LegacyItemConverterTest { + + private val vaultId = newVaultId() + private val provider = FakeCryptographicScopeProvider(FakeItemRepository()) + + private fun legacyItem( + detail: LegacyDetail, + title: String = "Example", + favorite: Boolean = false, + createdAt: Long? = 1_700_000_000_000L, + modifiedAt: Long? = null, + tags: Set = emptySet(), + ) = LegacyItem( + legacyId = 7L, + title = title, + favorite = favorite, + createdAt = createdAt, + modifiedAt = modifiedAt, + tags = tags, + detail = detail, + ) + + private suspend fun convert( + item: LegacyItem, + cipher: LegacyCipher = FakeLegacyCipher(), + ) = provider.itemScope( + wrappedVaultKeyInformation = WrappedVaultKeyInformation( + wrappedVaultKey = KeyInformation(byteArrayOf(), byteArrayOf()), + vaultId = vaultId, + ), + wrappedItemKeyInformation = WrappedItemKeyInformation( + itemAad = ItemAad(itemId = newItemId(), vaultId = vaultId), + ), + ) { + LegacyItemConverter(cipher, FakeRegistrableDomainResolver()).convert( + item = item, + itemId = newItemId(), + vaultId = vaultId, + keyInformation = KeyInformation(byteArrayOf(1), byteArrayOf(2)), + legacyKey = FAKE_LEGACY_KEY, + ) + }.assertSuccess() + + @Test + fun `converts a password into a login with a decryptable secret`() = runTest { + val converted = convert( + legacyItem( + LegacyDetail.Password( + username = "ada", + origin = "https://example.com", + password = "hunter2".encodeToByteArray(), + strength = LegacyStrength.STRONG, + ), + ), + ) + + val login = assertIs(converted) + assertEquals("Example", login.name) + assertEquals("ada", login.username) + assertEquals(PasswordScore.Strong, login.passwordCredential?.score) + + val plaintext = provider.itemScope( + wrappedVaultKeyInformation = WrappedVaultKeyInformation( + wrappedVaultKey = KeyInformation(byteArrayOf(), byteArrayOf()), + vaultId = vaultId, + ), + wrappedItemKeyInformation = WrappedItemKeyInformation( + itemAad = ItemAad(itemId = login.id, vaultId = vaultId), + ), + ) { + login.passwordCredential!!.secret.decrypt() + }.assertSuccess() + + assertEquals("hunter2", plaintext) + } + + @Test + fun `maps every legacy strength onto its v2 score`() = runTest { + val expected = mapOf( + LegacyStrength.RIDICULOUS to PasswordScore.Ridiculous, + LegacyStrength.WEAK to PasswordScore.Weak, + LegacyStrength.MODERATE to PasswordScore.Moderate, + LegacyStrength.STRONG to PasswordScore.Strong, + LegacyStrength.VERY_STRONG to PasswordScore.Excellent, + ) + + expected.forEach { (legacy, score) -> + val login = assertIs( + convert( + legacyItem( + LegacyDetail.Password( + username = null, + origin = null, + password = "pw".encodeToByteArray(), + strength = legacy, + ), + ), + ), + ) + assertEquals(score, login.passwordCredential?.score) + } + } + + @Test + fun `maps a missing strength onto None`() = runTest { + val login = assertIs( + convert( + legacyItem( + LegacyDetail.Password( + username = null, + origin = null, + password = "pw".encodeToByteArray(), + strength = null, + ), + ), + ), + ) + + assertEquals(PasswordScore.None, login.passwordCredential?.score) + } + + @Test + fun `resolves the origin into a domain info`() = runTest { + val login = assertIs( + convert( + legacyItem( + LegacyDetail.Password("ada", "https://example.com", null, null), + ), + ), + ) + + val domain = login.domainInfos.single() + assertEquals("https://example.com", domain.value) + assertEquals("example.com", domain.eTLD1) + } + + @Test + fun `yields no domain info for a blank origin`() = runTest { + val login = assertIs( + convert(legacyItem(LegacyDetail.Password("ada", " ", null, null))), + ) + + assertTrue(login.domainInfos.isEmpty()) + } + + @Test + fun `maps a blank username onto null`() = runTest { + val login = assertIs( + convert(legacyItem(LegacyDetail.Password(" ", null, null, null))), + ) + + assertNull(login.username) + } + + @Test + fun `yields no password credential when v1 stored none`() = runTest { + val login = assertIs( + convert(legacyItem(LegacyDetail.Password("ada", null, null, null))), + ) + + assertNull(login.passwordCredential) + } + + @Test + fun `returns null when the nested password blob does not decrypt`() = runTest { + val blob = "hunter2".encodeToByteArray() + + val converted = convert( + item = legacyItem(LegacyDetail.Password("ada", null, blob, null)), + cipher = FakeLegacyCipher(failFor = blob), + ) + + assertNull(converted) + } + + @Test + fun `converts a credit card`() = runTest { + val converted = convert( + legacyItem( + LegacyDetail.CreditCard( + firstName = "Ada", + lastName = "Lovelace", + cardNumber = "4111111111111111", + cvv = "123", + expirationDate = "04/29", + ), + ), + ) + + val card = assertIs(converted) + assertEquals("Ada Lovelace", card.holder) + assertEquals(YearMonth.of(2029, 4), card.expirationDate) + } + + @Test + fun `maps a partial cardholder onto null, as v1 getFullName did`() = runTest { + val card = assertIs( + convert(legacyItem(LegacyDetail.CreditCard("Ada", null, "4111", null, null))), + ) + + assertNull(card.holder) + } + + @Test + fun `parses a two digit year into the 2000s`() = runTest { + val card = assertIs( + convert(legacyItem(LegacyDetail.CreditCard(null, null, null, null, "01/70"))), + ) + + assertEquals(YearMonth.of(2070, 1), card.expirationDate) + } + + @Test + fun `maps an unparseable expiration onto null`() = runTest { + val card = assertIs( + convert(legacyItem(LegacyDetail.CreditCard(null, null, "4111", null, "nonsense"))), + ) + + assertNull(card.expirationDate) + } + + @Test + fun `maps a blank cvv onto null`() = runTest { + val card = assertIs( + convert(legacyItem(LegacyDetail.CreditCard(null, null, "4111", "", null))), + ) + + assertNull(card.cvv) + } + + @Test + fun `carries favorite, tags and timestamps across`() = runTest { + val converted = convert( + legacyItem( + detail = LegacyDetail.Password("ada", null, null, null), + favorite = true, + createdAt = 1_700_000_000_000L, + modifiedAt = 1_700_000_999_000L, + tags = setOf("work", "personal"), + ), + )!! + + assertTrue(converted.pinned) + assertEquals(setOf("work", "personal"), converted.tags.map { it.display }.toSet()) + assertEquals(Instant.fromEpochMilliseconds(1_700_000_000_000L), converted.timestamp.createdAt) + assertEquals( + Instant.fromEpochMilliseconds(1_700_000_999_000L), + converted.timestamp.modifiedAt, + ) + assertNull(converted.note) + } + + @Test + fun `falls back to now for a null created_at`() = runTest { + val before = Instant.fromEpochMilliseconds(System.currentTimeMillis() - 1_000) + + val converted = convert( + legacyItem( + detail = LegacyDetail.Password("ada", null, null, null), + createdAt = null, + ), + )!! + + assertTrue(converted.timestamp.createdAt >= before) + assertNull(converted.timestamp.modifiedAt) + } +} diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/LegacyImportRunnerTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/LegacyImportRunnerTest.kt new file mode 100644 index 000000000..0fdd2e217 --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/LegacyImportRunnerTest.kt @@ -0,0 +1,286 @@ +package de.davis.keygo.migration.legacy_data.domain.usecase + +import de.davis.keygo.migration.legacy_data.domain.model.LegacyFailureReason +import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationOutcome +import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationReport +import de.davis.keygo.migration.legacy_data.domain.model.LegacyRowFailure +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Stands in for the import. Counts its calls and takes the call number, so a test can make the + * first run behave differently from the one that follows it. + */ +private class RecordingImport( + private val body: suspend (call: Int) -> LegacyMigrationOutcome = { + LegacyMigrationOutcome.NothingToMigrate + }, +) { + var invocations = 0 + private set + + suspend operator fun invoke(): LegacyMigrationOutcome { + invocations++ + return body(invocations) + } +} + +/** + * The runner is the only thing standing between a second unlock and a second copy of the user's + * vault, and the only thing standing between a throw in the import and a process the user watches + * die on the way in. + * + * Two runs over one legacy file would import every row twice under ids neither run can recognise as + * the other's, which is why a call during a run is dropped. A run that finished with rows still on + * disk must release, because retrying on the next unlock is what turns a partial import into a + * complete one; a run that finished with nothing left must not, because every later unlock would + * pay to conclude the same nothing. + * + * `runCurrent` and not `advanceUntilIdle` throughout. The runner launches into a background scope, + * which is what production does too, and `advanceUntilIdle` returns as soon as the foreground has + * nothing left rather than running the background work. It would leave every assertion below + * looking at an import that never started. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class LegacyImportRunnerTest { + + private val diagnostics = mutableListOf>() + + private val clearedFile = LegacyMigrationOutcome.Migrated( + LegacyMigrationReport(migratedItems = 3, failures = emptyList()), + ) + + private val retainedFile = LegacyMigrationOutcome.Migrated( + LegacyMigrationReport(migratedItems = 3, failures = emptyList(), fileRetained = true), + ) + + private fun TestScope.runnerFor(import: RecordingImport) = LegacyImportRunner( + scope = backgroundScope, + report = { message, cause -> diagnostics += message to cause }, + import = { import() }, + ) + + @Test + fun `a call made while a run is in flight is dropped`() = runTest { + val gate = CompletableDeferred() + val import = RecordingImport { + gate.await() + LegacyMigrationOutcome.NothingToMigrate + } + val runner = runnerFor(import) + + runner.start() + runCurrent() // the first run gets going and parks on the gate + + runner.start() + runCurrent() + + assertEquals(1, import.invocations, "a second unlock during a run must be dropped") + + gate.complete(Unit) + runCurrent() + + assertEquals(1, import.invocations, "the dropped call must not be queued behind the run") + } + + @Test + fun `a call made after a run that left rows behind starts a new run`() = runTest { + val import = RecordingImport { retainedFile } + val runner = runnerFor(import) + + runner.start() + runCurrent() + + runner.start() + runCurrent() + + assertEquals(2, import.invocations, "a finished run must leave the next unlock free") + } + + /** + * The verdict that ends the retry. Almost every install never had a v1 file, and without this + * each later unlock would re-open it, count it and sweep the filesystem to reach the same + * nothing. Unlocks are not rare either: the autofill service and both passkey activities start + * sessions of their own. + */ + @Test + fun `a run with nothing left to import is not repeated`() = runTest { + val import = RecordingImport { LegacyMigrationOutcome.NothingToMigrate } + val runner = runnerFor(import) + + runner.start() + runCurrent() + + runner.start() + runCurrent() + + assertEquals(1, import.invocations, "a verdict of nothing to import holds for the process") + } + + @Test + fun `a run that cleared the legacy file is not repeated either`() = runTest { + val import = RecordingImport { clearedFile } + val runner = runnerFor(import) + + runner.start() + runCurrent() + + runner.start() + runCurrent() + + assertEquals(1, import.invocations, "the file is gone; a retry has nothing left to find") + } + + @Test + fun `an import that throws is reported and leaves the next unlock free to retry`() = runTest { + val boom = IllegalStateException("probing the legacy file blew up") + val import = RecordingImport { throw boom } + val runner = runnerFor(import) + + runner.start() + runCurrent() + + assertEquals(listOf(boom), diagnostics.map { it.second }, "a throw must be reported") + + runner.start() + runCurrent() + + assertEquals(2, import.invocations, "a failed run must release the runner") + } + + @Test + fun `an import that fails with an Error is contained too`() = runTest { + val import = RecordingImport { throw NoClassDefFoundError("a driver this device lacks") } + val runner = runnerFor(import) + + runner.start() + runCurrent() + + // Not Exception. This runs on an application scope, where anything that gets out takes the + // process down, and a device missing a native library is not a reason to lose the vault. + assertIs(diagnostics.single().second) + } + + @Test + fun `cancellation is passed through rather than reported as a failure`() = runTest { + val import = RecordingImport { call -> + if (call == 1) throw CancellationException("the run was cancelled") + LegacyMigrationOutcome.NothingToMigrate + } + val runner = runnerFor(import) + + runner.start() + runCurrent() + + assertTrue( + diagnostics.isEmpty(), + "a cancelled run has learned nothing about the user's file and must not answer for it", + ) + + runner.start() + runCurrent() + + assertEquals(2, import.invocations, "a cancelled run must release the runner as well") + } + + @Test + fun `a Failed outcome reports its cause through the seam`() = runTest { + val cause = IllegalStateException("the legacy database exists but could not be opened") + val import = RecordingImport { LegacyMigrationOutcome.Failed(cause) } + val runner = runnerFor(import) + + runner.start() + runCurrent() + + assertEquals( + cause, + diagnostics.singleOrNull()?.second, + "Failed is this module's channel for an expected failure and must reach the seam", + ) + } + + @Test + fun `a Migrated outcome with row failures produces a diagnostic`() = runTest { + val report = LegacyMigrationReport( + migratedItems = 2, + failures = listOf( + LegacyRowFailure( + legacyId = 1L, + title = "Gmail", + reason = LegacyFailureReason.Unreadable, + ), + ), + ) + val import = RecordingImport { LegacyMigrationOutcome.Migrated(report) } + val runner = runnerFor(import) + + runner.start() + runCurrent() + + assertEquals(1, diagnostics.size, "a run that drops entries must leave a trace") + assertTrue( + diagnostics.single().first.contains("Unreadable"), + "the diagnostic must carry the reason, not just that something was skipped", + ) + assertTrue( + !diagnostics.single().first.contains("Gmail"), + "a row's title is the user's own account name; it must not end up in logcat", + ) + } + + @Test + fun `a Migrated outcome with no row failures but a retained file still reports`() = runTest { + val runner = runnerFor(RecordingImport { retainedFile }) + + runner.start() + runCurrent() + + // The ending that duplicates the whole vault on the next unlock, and `hasFailures` alone + // cannot see it: there were none. + assertEquals(1, diagnostics.size, "a retained file must report even with no row failures") + assertTrue(diagnostics.single().first.contains("retried")) + } + + /** A runner each, because both of these endings latch and so cannot follow one another. */ + @Test + fun `NothingToMigrate and a clean Migrated produce no diagnostic`() = runTest { + runnerFor(RecordingImport { LegacyMigrationOutcome.NothingToMigrate }).start() + runCurrent() + + runnerFor(RecordingImport { clearedFile }).start() + runCurrent() + + assertTrue(diagnostics.isEmpty(), "the normal endings must produce no diagnostic line") + } + + @Test + fun `a reporter that throws is contained, and the runner still releases`() = runTest { + val cause = IllegalStateException("the legacy database exists but could not be opened") + val import = RecordingImport { LegacyMigrationOutcome.Failed(cause) } + val runner = LegacyImportRunner( + scope = backgroundScope, + report = { _, _ -> throw RuntimeException("the reporter itself is broken") }, + import = { import() }, + ) + + runner.start() + runCurrent() + + runner.start() + runCurrent() + + assertEquals( + 2, + import.invocations, + "a throwing reporter must not take the application scope down", + ) + } +} diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/MigrateLegacyDataUseCaseTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/MigrateLegacyDataUseCaseTest.kt new file mode 100644 index 000000000..043517c3d --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/MigrateLegacyDataUseCaseTest.kt @@ -0,0 +1,422 @@ +package de.davis.keygo.migration.legacy_data.domain.usecase + +import de.davis.keygo.core.item.FakeCreditCardRepository +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.FakeLoginRepository +import de.davis.keygo.core.item.FakeTransactionRunner +import de.davis.keygo.core.item.FakeVaultContextRepository +import de.davis.keygo.core.item.FakeVaultRepository +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.security.domain.model.CryptoScopeError +import de.davis.keygo.core.util.Result +import de.davis.keygo.migration.legacy_data.data.FAKE_LEGACY_KEY +import de.davis.keygo.migration.legacy_data.data.FakeLegacyCipher +import de.davis.keygo.migration.legacy_data.data.FakeLegacyItemRepository +import de.davis.keygo.migration.legacy_data.data.FakeLegacyKeyRepository +import de.davis.keygo.migration.legacy_data.data.FakeRegistrableDomainResolver +import de.davis.keygo.migration.legacy_data.domain.crypto.LegacyCipher +import de.davis.keygo.migration.legacy_data.domain.mapper.LegacyItemConverter +import de.davis.keygo.migration.legacy_data.domain.model.LegacyDetail +import de.davis.keygo.migration.legacy_data.domain.model.LegacyFailureReason +import de.davis.keygo.migration.legacy_data.domain.model.LegacyItem +import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationOutcome +import de.davis.keygo.migration.legacy_data.domain.model.LegacyReadFailure +import de.davis.keygo.migration.legacy_data.domain.model.LegacyRowFailure +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyReadResult +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class MigrateLegacyDataUseCaseTest { + + private val vaultId: VaultId = newVaultId() + + private val legacyRepository = FakeLegacyItemRepository() + private val keyRepository = FakeLegacyKeyRepository() + private val transactionRunner = FakeTransactionRunner() + private val loginRepository = FakeLoginRepository() + private val creditCardRepository = FakeCreditCardRepository() + private val vaultRepository = FakeVaultRepository() + private val vaultContextRepository = FakeVaultContextRepository() + + private fun useCase( + cipher: LegacyCipher = FakeLegacyCipher(), + scopeProvider: CryptographicScopeProvider = + FakeCryptographicScopeProvider(FakeItemRepository()), + ) = MigrateLegacyDataUseCase( + legacyItemRepository = legacyRepository, + legacyKeyRepository = keyRepository, + converter = LegacyItemConverter(cipher, FakeRegistrableDomainResolver()), + cryptographicScopeProvider = scopeProvider, + vaultRepository = vaultRepository, + vaultContextRepository = vaultContextRepository, + upsertVaultItem = UpsertVaultItemUseCase(loginRepository, creditCardRepository), + transactionRunner = transactionRunner, + ) + + private suspend fun seedVault() { + vaultRepository.seed( + Vault( + id = vaultId, + name = "Default Vault", + keyInformation = KeyInformation(byteArrayOf(1), byteArrayOf(2)), + icon = Vault.Icon.Default, + ), + ) + vaultContextRepository.setContextAndLastInteracted(vaultId) + } + + private fun legacyItem(id: Long, title: String, detail: LegacyDetail) = LegacyItem( + legacyId = id, + title = title, + favorite = false, + createdAt = 1_700_000_000_000L, + modifiedAt = null, + tags = emptySet(), + detail = detail, + ) + + private fun password(id: Long, title: String) = legacyItem( + id = id, + title = title, + detail = LegacyDetail.Password( + username = "ada", + origin = "https://example.com", + password = "hunter2".encodeToByteArray(), + strength = null, + ), + ) + + private fun creditCard(id: Long, title: String) = legacyItem( + id = id, + title = title, + detail = LegacyDetail.CreditCard( + firstName = "Ada", + lastName = "Lovelace", + cardNumber = "4111111111111111", + cvv = "123", + expirationDate = "05/30", + ), + ) + + private fun seedRows( + items: List = emptyList(), + failures: List = emptyList(), + ) { + legacyRepository.readResult = + Result.Success(LegacyReadResult(items, failures, FAKE_LEGACY_KEY)) + } + + /** + * A clean install reaches the same branch an emptied file does, by way of a provider with no + * file to hand out. What keeps it apart is that there is nothing to delete: the deletion answers + * no, and the alias stays put rather than being cleaned up on an install that never ran v1. + */ + @Test + fun `reports nothing to migrate when the legacy file is absent`() = runTest { + legacyRepository.readResult = Result.Failure(LegacyReadFailure.DatabaseEmpty) + legacyRepository.deleteSucceeds = false + + assertEquals(LegacyMigrationOutcome.NothingToMigrate, useCase()()) + assertFalse(legacyRepository.databaseDeleted) + assertFalse(keyRepository.deleted) + } + + /** + * The alias goes with the file. An already-imported v1 file reaches this branch on the run + * after the one that emptied it, and leaving the key behind there would strand it in the + * Keystore with nothing left able to reach it. Safe on the same evidence the deletion is: the + * rows were counted and there are none for the key to have protected. + */ + @Test + fun `deletes an empty database and its legacy key, and reports nothing to migrate`() = runTest { + legacyRepository.readResult = Result.Failure(LegacyReadFailure.DatabaseEmpty) + + assertEquals(LegacyMigrationOutcome.NothingToMigrate, useCase()()) + assertTrue(legacyRepository.databaseDeleted) + assertTrue(keyRepository.deleted) + } + + /** + * Never folded into the branch above. A file that will not open says nothing about what is in + * it, and answering "nothing to migrate" would delete a copy nobody has ever read. + */ + @Test + fun `leaves an unreadable database exactly where it found it`() = runTest { + legacyRepository.readResult = Result.Failure(LegacyReadFailure.DatabaseUnreadable) + + assertIs(useCase()()) + assertFalse(legacyRepository.databaseDeleted) + assertFalse(keyRepository.deleted) + } + + @Test + fun `imports every row then deletes the database and the legacy key`() = runTest { + seedVault() + seedRows(items = listOf(password(1L, "One"), password(2L, "Two"))) + + val outcome = assertIs(useCase()()) + + assertEquals(2, outcome.report.migratedItems) + assertFalse(outcome.report.hasFailures) + assertFalse(outcome.report.fileRetained) + assertEquals(listOf(1L, 2L), legacyRepository.prunedIds) + assertTrue(legacyRepository.databaseDeleted) + assertTrue(keyRepository.deleted) + assertEquals(2, loginRepository.observeLogins().first().size) + } + + @Test + fun `imports both v1 types in one run`() = runTest { + seedVault() + seedRows(items = listOf(password(1L, "Login"), creditCard(2L, "Card"))) + + val outcome = assertIs(useCase()()) + + assertEquals(2, outcome.report.migratedItems) + assertEquals(1, loginRepository.observeLogins().first().size) + assertEquals(listOf(1L, 2L), legacyRepository.prunedIds) + } + + @Test + fun `commits the whole batch inside a single transaction`() = runTest { + seedVault() + seedRows(items = listOf(password(1L, "One"), password(2L, "Two"), password(3L, "Three"))) + + useCase()() + + assertEquals(1, transactionRunner.enteredCount) + } + + @Test + fun `keeps the database and the key when a row failed to read`() = runTest { + seedVault() + seedRows( + items = listOf(password(1L, "Fine")), + failures = listOf(LegacyRowFailure(2L, "Broken", LegacyFailureReason.Unreadable)), + ) + + val outcome = assertIs(useCase()()) + + assertEquals(1, outcome.report.migratedItems) + assertEquals(1, outcome.report.failures.size) + assertEquals(listOf(1L), legacyRepository.prunedIds) + assertFalse(legacyRepository.databaseDeleted) + assertFalse(keyRepository.deleted) + } + + @Test + fun `keeps the file when a row failed even if the file claims to be empty`() = runTest { + seedVault() + seedRows( + items = listOf(password(1L, "Fine")), + failures = listOf(LegacyRowFailure(2L, "Broken", LegacyFailureReason.Unreadable)), + ) + // The count is forced to claim the file is empty. A row we know did not come across + // outranks it: a file we could not read in full is not deleted on a row count's say-so. + legacyRepository.countResult = Result.Success(0) + + val outcome = assertIs(useCase()()) + + assertEquals(1, outcome.report.failures.size) + assertFalse(legacyRepository.databaseDeleted) + assertFalse(keyRepository.deleted) + } + + @Test + fun `reports a row whose nested password will not decrypt without importing it`() = runTest { + seedVault() + seedRows(items = listOf(password(1L, "Fine"))) + + val outcome = assertIs( + useCase(cipher = FakeLegacyCipher.Failing)(), + ) + + assertEquals(0, outcome.report.migratedItems) + assertEquals( + LegacyFailureReason.UndecryptablePassword, + outcome.report.failures.single().reason, + ) + assertTrue(legacyRepository.prunedIds.isEmpty()) + assertFalse(legacyRepository.databaseDeleted) + assertFalse(keyRepository.deleted) + } + + @Test + fun `fails without importing when the batch write throws`() = runTest { + seedVault() + seedRows(items = listOf(password(1L, "One"))) + transactionRunner.failWith = IllegalStateException("database is locked") + + val outcome = assertIs(useCase()()) + + assertEquals("database is locked", outcome.cause.message) + assertTrue(legacyRepository.prunedIds.isEmpty()) + assertFalse(legacyRepository.databaseDeleted) + assertFalse(keyRepository.deleted) + } + + @Test + fun `fails without pruning when one item cannot be written`() = runTest { + seedVault() + seedRows(items = listOf(password(1L, "One"), password(2L, "Two"))) + loginRepository.createOrUpdateError = IllegalStateException("constraint failed") + + assertIs(useCase()()) + + assertTrue(legacyRepository.prunedIds.isEmpty()) + assertFalse(legacyRepository.databaseDeleted) + assertFalse(keyRepository.deleted) + } + + @Test + fun `fails when no vault is available to import into`() = runTest { + seedRows(items = listOf(password(1L, "One"))) + + assertIs(useCase()()) + assertFalse(legacyRepository.databaseDeleted) + assertFalse(keyRepository.deleted) + } + + /** + * A scope that will not open stands in for a vault key that will not unwrap. That says nothing + * about any one row, so the run has to stop rather than blame the rows. + */ + @Test + fun `fails without importing when no cryptographic scope can be opened`() = runTest { + seedVault() + seedRows(items = listOf(password(1L, "One"), password(2L, "Two"))) + val unopenable = FakeCryptographicScopeProvider(FakeItemRepository()) + .apply { itemScopeFailure = CryptoScopeError.IdNotFound } + + assertIs(useCase(scopeProvider = unopenable)()) + + assertEquals(0, transactionRunner.enteredCount) + assertTrue(legacyRepository.prunedIds.isEmpty()) + assertFalse(legacyRepository.databaseDeleted) + assertFalse(keyRepository.deleted) + } + + @Test + fun `deletes the database when the legacy file held no rows at all`() = runTest { + seedVault() + seedRows() + + val outcome = assertIs(useCase()()) + + assertEquals(0, outcome.report.migratedItems) + assertTrue(legacyRepository.databaseDeleted) + assertTrue(keyRepository.deleted) + } + + @Test + fun `a second run after a partial failure imports nothing new`() = runTest { + seedVault() + seedRows( + failures = listOf(LegacyRowFailure(2L, "Broken", LegacyFailureReason.Unreadable)), + ) + + val outcome = assertIs(useCase()()) + + assertEquals(0, outcome.report.migratedItems) + assertTrue(loginRepository.observeLogins().first().isEmpty()) + assertFalse(legacyRepository.databaseDeleted) + assertFalse(keyRepository.deleted) + } + + @Test + fun `keeps the file when the whole read failed rather than treating it as empty`() = runTest { + seedVault() + legacyRepository.readResult = Result.Failure(LegacyReadFailure.KeyUnavailable) + + assertIs(useCase()()) + + assertFalse(legacyRepository.databaseDeleted) + assertFalse(keyRepository.deleted) + assertEquals(0, transactionRunner.enteredCount) + } + + @Test + fun `keeps the file when the read failed because it could not be opened`() = runTest { + seedVault() + legacyRepository.readResult = Result.Failure(LegacyReadFailure.DatabaseUnreadable) + + assertIs(useCase()()) + + assertFalse(legacyRepository.databaseDeleted) + assertFalse(keyRepository.deleted) + } + + @Test + fun `keeps the file when the imported rows could not be pruned`() = runTest { + seedVault() + seedRows(items = listOf(password(1L, "One"))) + legacyRepository.pruneResult = Result.Failure(LegacyReadFailure.DatabaseUnreadable) + // The count is forced to claim the file is empty, so the prune's own result is the only + // thing left holding the deletion back. The two disagreeing is exactly when we trust + // neither of them. + legacyRepository.countResult = Result.Success(0) + + val outcome = assertIs(useCase()()) + + assertEquals(1, outcome.report.migratedItems) + assertFalse(legacyRepository.databaseDeleted) + assertFalse(keyRepository.deleted) + assertTrue( + outcome.report.fileRetained, + "Every row imported, but the file survived. That has to be as loud as a row failure, " + + "or the next unlock reimports the whole vault with nothing in logcat to explain why.", + ) + } + + @Test + fun `keeps the file when rows are still in it after pruning`() = runTest { + seedVault() + seedRows(items = listOf(password(1L, "One"))) + legacyRepository.countResult = Result.Success(1) + + val outcome = assertIs(useCase()()) + + assertEquals(1, outcome.report.migratedItems) + assertFalse(legacyRepository.databaseDeleted) + assertFalse(keyRepository.deleted) + assertTrue(outcome.report.fileRetained) + } + + @Test + fun `keeps the file when the remaining rows cannot be counted`() = runTest { + seedVault() + seedRows(items = listOf(password(1L, "One"))) + legacyRepository.countResult = Result.Failure(LegacyReadFailure.DatabaseUnreadable) + + val outcome = assertIs(useCase()()) + + assertFalse(legacyRepository.databaseDeleted) + assertFalse(keyRepository.deleted) + assertTrue(outcome.report.fileRetained) + } + + @Test + fun `keeps the legacy key when the database file refused to be deleted`() = runTest { + seedVault() + seedRows(items = listOf(password(1L, "One"))) + legacyRepository.deleteSucceeds = false + + val outcome = assertIs(useCase()()) + + assertEquals(1, outcome.report.migratedItems) + assertFalse(legacyRepository.databaseDeleted) + assertFalse(keyRepository.deleted) + assertTrue(outcome.report.fileRetained) + } +} diff --git a/migration/legacy-data/src/test/resources/legacy-fixtures/secure_element_database b/migration/legacy-data/src/test/resources/legacy-fixtures/secure_element_database new file mode 100644 index 000000000..6b2d081ae Binary files /dev/null and b/migration/legacy-data/src/test/resources/legacy-fixtures/secure_element_database differ diff --git a/migration/legacy-data/src/test/resources/legacy-fixtures/secure_element_database-shm b/migration/legacy-data/src/test/resources/legacy-fixtures/secure_element_database-shm new file mode 100644 index 000000000..afede7515 Binary files /dev/null and b/migration/legacy-data/src/test/resources/legacy-fixtures/secure_element_database-shm differ diff --git a/migration/legacy-data/src/test/resources/legacy-fixtures/secure_element_database-wal b/migration/legacy-data/src/test/resources/legacy-fixtures/secure_element_database-wal new file mode 100644 index 000000000..6ead3c7b3 Binary files /dev/null and b/migration/legacy-data/src/test/resources/legacy-fixtures/secure_element_database-wal differ diff --git a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyCipher.kt b/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyCipher.kt new file mode 100644 index 000000000..79c8c6338 --- /dev/null +++ b/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyCipher.kt @@ -0,0 +1,30 @@ +package de.davis.keygo.migration.legacy_data.data + +import de.davis.keygo.migration.legacy_data.data.FakeLegacyCipher.Companion.FAIL +import de.davis.keygo.migration.legacy_data.domain.crypto.LegacyCipher +import javax.crypto.SecretKey + +/** + * Reversible stand-in for the Keystore cipher: a blob decrypts to itself, so a test seeds the + * plaintext it wants to read back and never needs a Keystore. + * + * Two ways to model a blob that will not open, because the two failures enter the import at + * different points. A blob prefixed with [FAIL] is a row the repository cannot decrypt at all, + * which it records as a row failure and keeps reading past. [failFor] names one exact blob, which + * is how the nested password inside an otherwise readable row is failed on its own. + */ +internal class FakeLegacyCipher(private val failFor: ByteArray? = null) : LegacyCipher { + + override fun decrypt(blob: ByteArray, key: SecretKey): ByteArray? = when { + failFor != null && blob.contentEquals(failFor) -> null + blob.decodeToString().startsWith(FAIL) -> null + else -> blob + } + + companion object { + const val FAIL = "!!UNDECRYPTABLE!!" + + /** Fails every blob, which is how a whole run under a gone v1 key is modelled. */ + val Failing = LegacyCipher { _, _ -> null } + } +} diff --git a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyDatabase.kt b/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyDatabase.kt new file mode 100644 index 000000000..5eb7611c9 --- /dev/null +++ b/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyDatabase.kt @@ -0,0 +1,24 @@ +package de.davis.keygo.migration.legacy_data.data + +import androidx.room3.InvalidationTracker +import de.davis.keygo.migration.legacy_data.data.local.dao.LegacyElementDao +import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase + +/** + * A plain subclass rather than Room's builder, for tests that only ever reach [legacyElementDao]. + * + * That is what lets a fake DAO be put underneath a real repository without a database file or a + * mock of the Room-generated container. Everything Room itself would use fails loudly, because this + * stands in for the container alone and not for a database. + */ +internal class FakeLegacyDatabase(private val dao: LegacyElementDao) : LegacyDatabase() { + + override fun legacyElementDao(): LegacyElementDao = dao + + override fun createInvalidationTracker(): InvalidationTracker = unmodelled() + + override suspend fun clearAllTables(): Unit = unmodelled() + + private fun unmodelled(): Nothing = + error("FakeLegacyDatabase is only ever read and pruned through") +} diff --git a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyDatabaseProvider.kt b/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyDatabaseProvider.kt new file mode 100644 index 000000000..aae83b26e --- /dev/null +++ b/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyDatabaseProvider.kt @@ -0,0 +1,42 @@ +package de.davis.keygo.migration.legacy_data.data + +import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase +import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabaseProvider +import java.io.File + +/** + * Hands out one already-open database, and nothing once the file behind it has gone, which is the + * rule production's provider enforces by checking the path before it builds anything. + */ +internal class FakeLegacyDatabaseProvider( + private val file: File, + private val database: LegacyDatabase?, +) : LegacyDatabaseProvider { + + var closed: Boolean = false + private set + + private var fileDeleted: Boolean = false + + override fun get(): LegacyDatabase? = database.takeUnless { fileDeleted } + + override fun close() { + closed = true + runCatching { database?.close() } + } + + /** + * Answers whether a file actually went, the way `Context.deleteDatabase` does. A flat `true` + * would report a deletion on a clean install that had nothing to delete, and v1's Keystore + * alias is gated on this answer. + */ + override fun delete(): Boolean { + close() + fileDeleted = true + return SUFFIXES.count { File(file.absolutePath + it).delete() } > 0 + } + + private companion object { + val SUFFIXES = listOf("", "-wal", "-shm", "-journal") + } +} diff --git a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyElementDao.kt b/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyElementDao.kt new file mode 100644 index 000000000..9a96c7e6c --- /dev/null +++ b/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyElementDao.kt @@ -0,0 +1,50 @@ +package de.davis.keygo.migration.legacy_data.data + +import de.davis.keygo.migration.legacy_data.data.local.dao.LegacyElementDao +import de.davis.keygo.migration.legacy_data.data.local.entity.LegacySecureElementEntity +import de.davis.keygo.migration.legacy_data.data.local.entity.LegacySecureElementTagCrossRef +import de.davis.keygo.migration.legacy_data.data.local.entity.LegacyTagEntity +import de.davis.keygo.migration.legacy_data.data.local.pojo.LegacyElementWithTags + +/** + * In-memory [LegacyElementDao] over a set of row ids, for the tests that are about counting and + * pruning rather than about what is in a row. + * + * Deletes really remove, so [remainingIds] and [count] answer what a real file would after a prune, + * and [deleteCalls] records how the prune was split up. Anything this fake does not model fails + * loudly rather than answering with a default, because a test that quietly reaches an unmodelled + * call is a test that proves nothing. + */ +internal class FakeLegacyElementDao(ids: Set = emptySet()) : LegacyElementDao { + + private val ids = ids.toMutableSet() + + /** Thrown by [count], so a read can fail the way a cancelled unlock scope does. */ + var countFailure: Throwable? = null + + /** The ids each [deleteByIds] call carried, in order. */ + val deleteCalls = mutableListOf>() + + val remainingIds: Set get() = ids + + override suspend fun count(): Int { + countFailure?.let { throw it } + return ids.size + } + + override suspend fun deleteByIds(ids: List) { + deleteCalls += ids + this.ids -= ids.toSet() + } + + override suspend fun getAllWithTags(): List = unmodelled() + + override suspend fun insertElement(element: LegacySecureElementEntity): Long = unmodelled() + + override suspend fun insertTag(tag: LegacyTagEntity): Long = unmodelled() + + override suspend fun insertCrossRef(crossRef: LegacySecureElementTagCrossRef): Unit = + unmodelled() + + private fun unmodelled(): Nothing = error("FakeLegacyElementDao does not model this call") +} diff --git a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyItemRepository.kt b/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyItemRepository.kt new file mode 100644 index 000000000..ff489573c --- /dev/null +++ b/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyItemRepository.kt @@ -0,0 +1,55 @@ +package de.davis.keygo.migration.legacy_data.data + +import de.davis.keygo.core.util.Result +import de.davis.keygo.migration.legacy_data.domain.model.LegacyReadFailure +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyItemRepository +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyReadResult + +/** + * In-memory [LegacyItemRepository] that keeps the real contract: every read, prune and count can + * fail, and a failure is a [Result] rather than a throw. + * + * The row count is derived from [readResult] and the ids pruned so far, which is what the real + * repository reports: the rows still in the file. Tests that need the count to disagree with that + * (a prune that silently did nothing, a count query that fell over) set [countResult] instead. + */ +internal class FakeLegacyItemRepository : LegacyItemRepository { + + var readResult: Result = + Result.Success(LegacyReadResult(emptyList(), emptyList(), FAKE_LEGACY_KEY)) + + var pruneResult: Result = Result.Success(Unit) + + /** Overrides the derived row count when non-null. */ + var countResult: Result? = null + + /** What [deleteDatabase] answers, so a file that refuses to go away can be modelled. */ + var deleteSucceeds: Boolean = true + + val prunedIds = mutableListOf() + + var databaseDeleted: Boolean = false + private set + + override suspend fun readAll(): Result = readResult + + override suspend fun prune(legacyIds: List): Result { + if (pruneResult is Result.Success) prunedIds += legacyIds + return pruneResult + } + + override suspend fun remainingCount(): Result = + countResult ?: Result.Success(rowsInFile()) + + override fun deleteDatabase(): Boolean { + databaseDeleted = deleteSucceeds + return deleteSucceeds + } + + private fun rowsInFile(): Int = when (val result = readResult) { + is Result.Success -> (result.success.items.size + result.success.failures.size - + prunedIds.size).coerceAtLeast(0) + + is Result.Failure -> 0 + } +} diff --git a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyKeyRepository.kt b/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyKeyRepository.kt new file mode 100644 index 000000000..4670bbe05 --- /dev/null +++ b/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyKeyRepository.kt @@ -0,0 +1,31 @@ +package de.davis.keygo.migration.legacy_data.data + +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyKeyRepository +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec + +/** + * A plain JCE key stands in for the Keystore one. Nothing here decrypts anything, so the bytes are + * irrelevant; what matters is whether the alias resolves and how often it is asked. + */ +internal val FAKE_LEGACY_KEY: SecretKey = SecretKeySpec(ByteArray(32), "AES") + +internal class FakeLegacyKeyRepository( + private val key: SecretKey? = FAKE_LEGACY_KEY, +) : LegacyKeyRepository { + + var probes: Int = 0 + private set + + var deleted: Boolean = false + private set + + override fun secretKey(): SecretKey? { + probes++ + return key + } + + override fun deleteLegacyKey() { + deleted = true + } +} diff --git a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeRegistrableDomainResolver.kt b/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeRegistrableDomainResolver.kt new file mode 100644 index 000000000..673f9f219 --- /dev/null +++ b/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeRegistrableDomainResolver.kt @@ -0,0 +1,17 @@ +package de.davis.keygo.migration.legacy_data.data + +import de.davis.keygo.core.util.domain.resolver.RegistrableDomainResolver + +/** + * Resolves anything under `example` to `example.com` and everything else to null. + * + * Every origin the migration tests seed is an example.com URL, so a fixed answer keeps eTLD+1 + * resolution from becoming a second thing that can fail in a test about the import. The null branch + * is still real: it is what a host the resolver cannot place answers with, and the converter has to + * carry that through as a domain info without an eTLD+1 rather than dropping the origin. + */ +internal class FakeRegistrableDomainResolver : RegistrableDomainResolver { + + override fun resolve(domain: String): String? = + if (domain.contains("example")) "example.com" else null +} diff --git a/scripts/new-module.sh b/scripts/new-module.sh old mode 100644 new mode 100755 index 80c879e39..531612585 --- a/scripts/new-module.sh +++ b/scripts/new-module.sh @@ -84,7 +84,7 @@ read_key() { case "$key" in $'\x1b') rest="" - IFS= read -rsn2 -t 0.05 rest || true + IFS= read -rsn2 -t 1 rest || true case "$rest" in '[A') KEY=up ;; '[B') KEY=down ;; @@ -394,10 +394,12 @@ PLUGIN_ACCESSOR="libs.plugins.${PLUGIN//-/.}" # DI class: PascalCase of location + name, e.g. feature/item + create -> FeatureItemCreateModule pascal_case() { - local out="" part + local out="" part first rest IFS='/-_' read -ra parts <<<"$1" for part in "${parts[@]}"; do - out+="${part^}" + first=$(echo "${part:0:1}" | tr '[:lower:]' '[:upper:]') + rest="${part:1}" + out+="${first}${rest}" done printf '%s' "$out" } diff --git a/settings.gradle.kts b/settings.gradle.kts index a74bb3192..29d162c2d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,7 +30,8 @@ rootProject.name = "KeyGoV2" include(":app") include(":automation-processor") include(":automation") -include(":migration-create-access") +include(":migration:create-access") +include(":migration:legacy-data") include(":core:item") include(":core:util") include(":core:security")