From 777b99b8fe0161f43ae8ec2a5153111a1acfbaa0 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 27 Jul 2026 19:49:03 +0200 Subject: [PATCH 01/31] refactor(db): rename db file --- .../davis/keygo/core/item/data/local/datasource/ItemDatabase.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 5f867b2659d3f374531eb77bef0d0e2eed08014f Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 27 Jul 2026 19:54:57 +0200 Subject: [PATCH 02/31] refactor(module): move create access migration --- app/build.gradle.kts | 2 +- feature/auth/build.gradle.kts | 2 +- migration-create-access/src/main/AndroidManifest.xml | 4 ---- .../create-access}/.gitignore | 0 .../create-access}/build.gradle.kts | 0 .../create-access}/consumer-rules.pro | 0 .../create-access}/proguard-rules.pro | 0 migration/create-access/src/main/AndroidManifest.xml | 4 ++++ .../data/local/datasource/datastore/MainPasswordSerializer.kt | 0 .../migration/create_access/data/mapper/MainPasswordMapper.kt | 0 .../create_access/data/repository/HashValidatorImpl.kt | 0 .../data/repository/MainPasswordRepositoryImpl.kt | 2 +- .../migration/create_access/di/MigrationCreateAccessModule.kt | 0 .../create_access/di/annotation/MainPasswordQualifier.kt | 0 .../migration/create_access/domain/model/MainPassword.kt | 0 .../create_access/domain/repository/HashValidator.kt | 0 .../create_access/domain/repository/MainPasswordRepository.kt | 0 .../create_access/domain/usecase/ClearMainPasswordUseCase.kt | 0 .../create_access/domain/usecase/HasMainPasswordUseCase.kt | 0 .../create_access/domain/usecase/ValidateMainPassword.kt | 0 .../create-access}/src/main/proto/main_password.proto | 0 settings.gradle.kts | 2 +- 22 files changed, 8 insertions(+), 8 deletions(-) delete mode 100644 migration-create-access/src/main/AndroidManifest.xml rename {migration-create-access => migration/create-access}/.gitignore (100%) rename {migration-create-access => migration/create-access}/build.gradle.kts (100%) rename {migration-create-access => migration/create-access}/consumer-rules.pro (100%) rename {migration-create-access => migration/create-access}/proguard-rules.pro (100%) create mode 100644 migration/create-access/src/main/AndroidManifest.xml rename {migration-create-access => migration/create-access}/src/main/kotlin/de/davis/keygo/migration/create_access/data/local/datasource/datastore/MainPasswordSerializer.kt (100%) rename {migration-create-access => migration/create-access}/src/main/kotlin/de/davis/keygo/migration/create_access/data/mapper/MainPasswordMapper.kt (100%) rename {migration-create-access => migration/create-access}/src/main/kotlin/de/davis/keygo/migration/create_access/data/repository/HashValidatorImpl.kt (100%) rename {migration-create-access => migration/create-access}/src/main/kotlin/de/davis/keygo/migration/create_access/data/repository/MainPasswordRepositoryImpl.kt (97%) rename {migration-create-access => migration/create-access}/src/main/kotlin/de/davis/keygo/migration/create_access/di/MigrationCreateAccessModule.kt (100%) rename {migration-create-access => migration/create-access}/src/main/kotlin/de/davis/keygo/migration/create_access/di/annotation/MainPasswordQualifier.kt (100%) rename {migration-create-access => migration/create-access}/src/main/kotlin/de/davis/keygo/migration/create_access/domain/model/MainPassword.kt (100%) rename {migration-create-access => migration/create-access}/src/main/kotlin/de/davis/keygo/migration/create_access/domain/repository/HashValidator.kt (100%) rename {migration-create-access => migration/create-access}/src/main/kotlin/de/davis/keygo/migration/create_access/domain/repository/MainPasswordRepository.kt (100%) rename {migration-create-access => migration/create-access}/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/ClearMainPasswordUseCase.kt (100%) rename {migration-create-access => migration/create-access}/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/HasMainPasswordUseCase.kt (100%) rename {migration-create-access => migration/create-access}/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/ValidateMainPassword.kt (100%) rename {migration-create-access => migration/create-access}/src/main/proto/main_password.proto (100%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6aad6c6e2..01b8bd074 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -118,7 +118,7 @@ dependencies { implementation(projects.feature.autofill) implementation(projects.feature.settings) implementation(projects.feature.backup) - implementation(projects.migrationCreateAccess) + implementation(projects.migration.createAccess) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) 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/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 100% 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 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 100% 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 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..c1aad8a37 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 { 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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/ValidateMainPassword.kt similarity index 100% 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/ValidateMainPassword.kt 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/settings.gradle.kts b/settings.gradle.kts index a74bb3192..a68a27e35 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,7 +30,7 @@ rootProject.name = "KeyGoV2" include(":app") include(":automation-processor") include(":automation") -include(":migration-create-access") +include(":migration:create-access") include(":core:item") include(":core:util") include(":core:security") From b8eb04852e7aa35a0badc3d4d1b943e39fe5c3cd Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 27 Jul 2026 20:14:36 +0200 Subject: [PATCH 03/31] fix(script): resolve timeout issue --- scripts/new-module.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) mode change 100644 => 100755 scripts/new-module.sh 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" } From 6969be6de1e3312150474c8961704f1fb65d4d93 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 27 Jul 2026 21:05:05 +0200 Subject: [PATCH 04/31] feat(item): introduce timestamp --- .../1.json | 17 +++++++-- .../core/item/data/local/entity/ItemEntity.kt | 1 + .../core/item/data/local/entity/Timestamp.kt | 10 +++++ .../core/item/data/mapper/CreditCardMapper.kt | 1 + .../keygo/core/item/data/mapper/ItemMapper.kt | 1 + .../core/item/data/mapper/LoginMapper.kt | 1 + .../core/item/data/mapper/TimestampMapper.kt | 15 ++++++++ .../core/item/domain/model/CreditCard.kt | 1 + .../keygo/core/item/domain/model/Item.kt | 1 + .../keygo/core/item/domain/model/Login.kt | 7 ++-- .../keygo/core/item/domain/model/Timestamp.kt | 9 +++++ .../item/data/local/dao/ItemDaoSearchTest.kt | 2 + .../core/item/data/local/dao/TagDaoTest.kt | 2 + .../item/data/mapper/CreditCardMapperTest.kt | 4 ++ .../core/item/data/mapper/DomainMapperTest.kt | 2 + .../core/item/data/mapper/ItemMapperTest.kt | 2 + .../core/item/data/mapper/LoginMapperTest.kt | 4 ++ .../CreditCardRepositoryImplTest.kt | 4 ++ .../repository/LoginRepositoryImplTest.kt | 2 + .../core/item/domain/model/CreditCardTest.kt | 1 + .../keygo/core/item/domain/model/LoginTest.kt | 1 + .../ObserveAllTagsSortedUseCaseTest.kt | 2 + .../domain/usecase/UpsertItemUseCaseTest.kt | 3 ++ .../usecase/ItemWithCryptoScopeUseCaseTest.kt | 2 + ...AddRegistrableDomainsToLoginUseCaseTest.kt | 2 + ...DoesItemHaveDomainReferencesUseCaseTest.kt | 2 + .../activity/AutofillViewModelTest.kt | 2 + .../dataset/SuggestionFinderTest.kt | 8 ++++ .../CreateNewOrUpdateCreditCardUseCase.kt | 5 +++ .../usecase/CreateNewOrUpdateLoginUseCase.kt | 4 ++ .../usecase/CreateOrUpdateItemUseCase.kt | 9 ++++- .../CreateNewOrUpdateCreditCardUseCaseTest.kt | 38 +++++++++++++++++++ .../CreateNewOrUpdateLoginUseCaseTest.kt | 38 +++++++++++++++++++ .../creditcard/ViewCreditCardViewModelTest.kt | 2 + .../usecase/MoveItemsToVaultUseCaseTest.kt | 2 + 35 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/Timestamp.kt create mode 100644 core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/TimestampMapper.kt create mode 100644 core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Timestamp.kt 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/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..25c54e3e9 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/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/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/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/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/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/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..61a1167f3 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,7 @@ 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.repository.CreditCardRepository import de.davis.keygo.core.item.domain.repository.VaultRepository import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase @@ -95,6 +96,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 +123,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(), ) } 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) From 4e73af4e59276718de55924657d1c6b3f8da1d26 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 27 Jul 2026 22:36:44 +0200 Subject: [PATCH 05/31] feat(core-item): add ItemTransactionRunner and guard the database file name Gives callers outside :core:item a way to commit a batch of item writes as one atomic transaction without reaching for the module-internal ItemDatabase. Also locks in the v2 database file name so it can never collide with the inherited v1 secure_element_database file. Co-Authored-By: Claude Opus 5 --- .../repository/ItemTransactionRunnerImpl.kt | 15 ++++ .../repository/ItemTransactionRunner.kt | 14 ++++ .../data/local/datasource/DatabaseNameTest.kt | 34 ++++++++++ .../ItemTransactionRunnerImplTest.kt | 68 +++++++++++++++++++ .../core/item/FakeItemTransactionRunner.kt | 25 +++++++ 5 files changed, 156 insertions(+) create mode 100644 core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/ItemTransactionRunnerImpl.kt create mode 100644 core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/ItemTransactionRunner.kt create mode 100644 core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/datasource/DatabaseNameTest.kt create mode 100644 core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/ItemTransactionRunnerImplTest.kt create mode 100644 core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeItemTransactionRunner.kt diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/ItemTransactionRunnerImpl.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/ItemTransactionRunnerImpl.kt new file mode 100644 index 000000000..92a05ae14 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/ItemTransactionRunnerImpl.kt @@ -0,0 +1,15 @@ +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.repository.ItemTransactionRunner +import org.koin.core.annotation.Single + +@Single +internal class ItemTransactionRunnerImpl( + private val database: ItemDatabase, +) : ItemTransactionRunner { + + override suspend fun inTransaction(block: suspend () -> R): R = + database.withTransaction { block() } +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/ItemTransactionRunner.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/ItemTransactionRunner.kt new file mode 100644 index 000000000..2f11c4d5b --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/ItemTransactionRunner.kt @@ -0,0 +1,14 @@ +package de.davis.keygo.core.item.domain.repository + +/** + * Runs a block of item writes as a single database transaction. + * + * Exists so callers outside `:core:item` can commit a batch atomically without reaching for + * `ItemDatabase`, which is internal to this module. Nesting is safe: Room's transaction support + * is reentrant on the same connection, so repository methods that already open their own + * transaction join the outer one rather than starting a second. + */ +interface ItemTransactionRunner { + + suspend fun inTransaction(block: suspend () -> R): R +} diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/datasource/DatabaseNameTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/datasource/DatabaseNameTest.kt new file mode 100644 index 000000000..e91bb1149 --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/datasource/DatabaseNameTest.kt @@ -0,0 +1,34 @@ +package de.davis.keygo.core.item.data.local.datasource + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * v1 shipped its Room database as `secure_element_database` under the same applicationId. If + * `ItemDatabase` ever takes that name back, Room reads version 3 out of the inherited v1 file, + * compares it against version 1 and throws, and `:migration:legacy-data` loses the file it reads + * from. Guarding the literal is cheaper than rediscovering that on a device. + */ +class DatabaseNameTest { + + private val source = File("src/main/kotlin/de/davis/keygo/core/item/data/local/datasource/ItemDatabase.kt") + .readText() + + @Test + fun `does not reuse the v1 database file name`() { + assertFalse( + source.contains("secure_element_database"), + "ItemDatabase must not use the v1 file name; :migration:legacy-data reads that file.", + ) + } + + @Test + fun `uses the expected database file name`() { + assertTrue( + source.contains("\"keygo_database\""), + "ItemDatabase file name changed; update :migration:legacy-data and this guard together.", + ) + } +} diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/ItemTransactionRunnerImplTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/ItemTransactionRunnerImplTest.kt new file mode 100644 index 000000000..567acc262 --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/ItemTransactionRunnerImplTest.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 [ItemTransactionRunnerImpl] 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 ItemTransactionRunnerImplTest { + + private val database = mockk() + private val runner = ItemTransactionRunnerImpl(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.inTransaction { 42 }) + } + + @Test + fun `propagates an exception thrown inside the block`() = runTest { + val error = IllegalStateException("boom") + + val thrown = assertFailsWith { + runner.inTransaction { throw error } + } + + assertEquals(error, thrown) + } + + @Test + fun `invokes the block exactly once inside the transaction`() = runTest { + var invocations = 0 + + runner.inTransaction { invocations++ } + + assertEquals(1, invocations) + coVerify(exactly = 1) { database.withTransaction(any Any?>()) } + } +} diff --git a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeItemTransactionRunner.kt b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeItemTransactionRunner.kt new file mode 100644 index 000000000..8b21f9ea4 --- /dev/null +++ b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeItemTransactionRunner.kt @@ -0,0 +1,25 @@ +package de.davis.keygo.core.item + +import de.davis.keygo.core.item.domain.repository.ItemTransactionRunner + +/** + * Runs the block inline with no real transaction. + * + * Records how many times a transaction was opened so tests can assert a batch was committed as + * one unit rather than per item. Set [failWith] to make the next call throw before running the + * block, which stands in for a transaction that could not be opened. + */ +class FakeItemTransactionRunner : ItemTransactionRunner { + + var transactionCount: Int = 0 + private set + + /** Thrown by the next [inTransaction] call, then cleared. */ + var failWith: Throwable? = null + + override suspend fun inTransaction(block: suspend () -> R): R { + failWith?.let { failWith = null; throw it } + transactionCount++ + return block() + } +} From 6d597d05ea32f119854caa7def240bc38f56e913 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 27 Jul 2026 23:44:33 +0200 Subject: [PATCH 06/31] feat(migration): port the v1 room entities and open legacy databases at versions 1-3 Co-Authored-By: Claude Opus 5 --- migration/legacy-data/build.gradle.kts | 32 +++ migration/legacy-data/consumer-rules.pro | 0 .../1.json | 78 ++++++++ .../2.json | 97 +++++++++ .../3.json | 170 ++++++++++++++++ .../data/local/dao/LegacyElementDao.kt | 35 ++++ .../data/local/datasource/LegacyDatabase.kt | 51 +++++ .../local/entity/LegacySecureElementEntity.kt | 57 ++++++ .../entity/LegacySecureElementTagCrossRef.kt | 31 +++ .../data/local/entity/LegacyTagEntity.kt | 11 + .../local/migration/LegacyMigrationSpecs.kt | 20 ++ .../data/local/pojo/LegacyElementWithTags.kt | 22 ++ .../di/MigrationLegacyDataModule.kt | 10 + .../data/local/LegacyDatabaseOpenTest.kt | 189 ++++++++++++++++++ .../data/local/LegacySchemaIdentityTest.kt | 76 +++++++ .../src/test/resources/legacy-schemas/1.json | 78 ++++++++ .../src/test/resources/legacy-schemas/2.json | 97 +++++++++ .../src/test/resources/legacy-schemas/3.json | 176 ++++++++++++++++ settings.gradle.kts | 1 + 19 files changed, 1231 insertions(+) create mode 100644 migration/legacy-data/build.gradle.kts create mode 100644 migration/legacy-data/consumer-rules.pro create mode 100644 migration/legacy-data/schemas/de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase/1.json create mode 100644 migration/legacy-data/schemas/de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase/2.json create mode 100644 migration/legacy-data/schemas/de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase/3.json create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/dao/LegacyElementDao.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabase.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/entity/LegacySecureElementEntity.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/entity/LegacySecureElementTagCrossRef.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/entity/LegacyTagEntity.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/migration/LegacyMigrationSpecs.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/pojo/LegacyElementWithTags.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/di/MigrationLegacyDataModule.kt create mode 100644 migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseOpenTest.kt create mode 100644 migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacySchemaIdentityTest.kt create mode 100644 migration/legacy-data/src/test/resources/legacy-schemas/1.json create mode 100644 migration/legacy-data/src/test/resources/legacy-schemas/2.json create mode 100644 migration/legacy-data/src/test/resources/legacy-schemas/3.json diff --git a/migration/legacy-data/build.gradle.kts b/migration/legacy-data/build.gradle.kts new file mode 100644 index 000000000..95c19f1ed --- /dev/null +++ b/migration/legacy-data/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + alias(libs.plugins.keygo.android.library) + alias(libs.plugins.androidx.room) + alias(libs.plugins.google.ksp) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "de.davis.keygo.migration.legacy_data" +} + +dependencies { + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.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.sqlite.bundled) + testImplementation(testFixtures(projects.core.item)) + testImplementation(testFixtures(projects.core.security)) + testImplementation(testFixtures(projects.core.util)) +} + +room { + schemaDirectory("$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/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..bf0248047 --- /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.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.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/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..4213c9c00 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabase.kt @@ -0,0 +1,51 @@ +package de.davis.keygo.migration.legacy_data.data.local.datasource + +import androidx.room.AutoMigration +import androidx.room.Database +import androidx.room.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 +import de.davis.keygo.migration.legacy_data.data.local.migration.LegacyMigrationSpec1To2 +import de.davis.keygo.migration.legacy_data.data.local.migration.LegacyMigrationSpec2To3 + +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. The two + * auto-migrations are generated from v1's own exported `1.json` and `2.json`, which is why 2-to-3 + * needs no hand-written SQL despite being a table recreate. + */ +@Database( + version = 3, + entities = [ + LegacySecureElementEntity::class, + LegacyTagEntity::class, + LegacySecureElementTagCrossRef::class, + ], + autoMigrations = [ + AutoMigration(from = 1, to = 2, spec = LegacyMigrationSpec1To2::class), + AutoMigration(from = 2, to = 3, spec = LegacyMigrationSpec2To3::class), + ], + exportSchema = true, +) +internal abstract class LegacyDatabase : RoomDatabase() { + + abstract fun legacyElementDao(): LegacyElementDao +} + +/** + * Opens the legacy database on demand. + * + * Indirection rather than an injected instance for two reasons: on a clean v2 install the file + * never exists and eagerly opening it would create an empty one, and the repository re-opens after + * `deleteDatabase` closes the previous handle. + */ +internal fun interface LegacyDatabaseProvider { + + fun get(): LegacyDatabase +} 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..f64d4ecae --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/entity/LegacySecureElementEntity.kt @@ -0,0 +1,57 @@ +package de.davis.keygo.migration.legacy_data.data.local.entity + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.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`. Reordering these declarations changes + * the generated DDL, which changes the identity hash, which makes Room refuse to open real v1 + * files. `LegacySchemaIdentityTest` is the guard. + * + * `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..dc71cd57c --- /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.room.Entity +import androidx.room.ForeignKey +import androidx.room.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..e75dd697b --- /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.room.Entity +import androidx.room.Index +import androidx.room.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/LegacyMigrationSpecs.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/migration/LegacyMigrationSpecs.kt new file mode 100644 index 000000000..235d4ea91 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/migration/LegacyMigrationSpecs.kt @@ -0,0 +1,20 @@ +package de.davis.keygo.migration.legacy_data.data.local.migration + +import androidx.room.DeleteTable +import androidx.room.migration.AutoMigrationSpec + +/** + * v1's 1-to-2 spec backfilled `created_at` where it was null. The importer does not need that: + * `LegacyElementMapper` already falls back to the current time for a null `created_at`, which it + * has to do anyway for rows that predate the column. Keeping this a no-op keeps hand-written SQL + * out of the module. + */ +internal class LegacyMigrationSpec1To2 : AutoMigrationSpec + +/** + * v1's 2-to-3 spec inserted `elementType:` discriminator tags, which the importer discards, so the + * body is dropped. The annotation is not optional: version 2 has a `MasterPassword` table that + * version 3 does not, and Room needs to be told the table was deleted rather than renamed. + */ +@DeleteTable(tableName = "MasterPassword") +internal class LegacyMigrationSpec2To3 : AutoMigrationSpec 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..f6a579359 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/pojo/LegacyElementWithTags.kt @@ -0,0 +1,22 @@ +package de.davis.keygo.migration.legacy_data.data.local.pojo + +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.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, + @Relation( + parentColumn = "id", + entityColumn = "tagId", + associateBy = Junction( + value = LegacySecureElementTagCrossRef::class, + parentColumn = "id", + entityColumn = "tagId", + ), + ) + val tags: List, +) 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..49108b627 --- /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 +@Configuration +@ComponentScan("de.davis.keygo.migration.legacy_data") +object MigrationLegacyDataModule 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..b54f62069 --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseOpenTest.kt @@ -0,0 +1,189 @@ +package de.davis.keygo.migration.legacy_data.data.local + +import android.content.Context +import androidx.room.Room +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 io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File +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 generated auto-migrations carry it forward to version 3. + * + * The seed files are built from v1's own exported schema JSON rather than with Room's + * `MigrationTestHelper`. Every constructor of the helper's Android artifact takes an + * `android.app.Instrumentation`, so it cannot run on a plain JVM unit test. Replaying `createSql` + * and `setupQueries` out of the pristine schema is what the helper does anyway, and it keeps the + * seed honest: it comes from v1's export, not from the ported entities under test. + */ +class LegacyDatabaseOpenTest { + + private val tempDir: File = createTempDir() + private val dbFile: File = File(tempDir, "secure_element_database") + + private val json = Json { ignoreUnknownKeys = true } + + /** + * Room turns a database name into a path with `Context.getDatabasePath`. A fully relaxed mock + * answers that with an empty path, which makes Room quietly open some other file and report an + * empty database, so this one has to resolve to the seeded file. + */ + private val context: Context = mockk(relaxed = true).apply { + every { getDatabasePath(any()) } returns dbFile + } + + /** Writes a file that looks exactly like one a v1 build at [version] would have left behind. */ + private fun createDatabase(version: Int): SQLiteConnection { + val schema = json + .parseToJsonElement(File("src/test/resources/legacy-schemas/$version.json").readText()) + .jsonObject + .getValue("database") + .jsonObject + + return BundledSQLiteDriver().open(dbFile.absolutePath).apply { + schema.getValue("entities").jsonArray.forEach { entity -> + val table = entity.jsonObject.getValue("tableName").jsonPrimitive.content + execSQL(entity.jsonObject.createSqlFor(table)) + entity.jsonObject.getValue("indices").jsonArray.forEach { index -> + execSQL(index.jsonObject.createSqlFor(table)) + } + } + schema.getValue("setupQueries").jsonArray.forEach { execSQL(it.jsonPrimitive.content) } + execSQL("PRAGMA user_version = $version") + } + } + + private fun JsonObject.createSqlFor(tableName: String): String = + getValue("createSql").jsonPrimitive.content.replace("\${TABLE_NAME}", tableName) + + private fun openMigrated(): LegacyDatabase = + Room.databaseBuilder(context, LegacyDatabase::class.java, dbFile.name) + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.IO) + .build() + + @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 { + createDatabase(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 { + createDatabase(2).use { connection -> + connection.insertElement("Mid entry", type = 17, blob = byteArrayOf(4, 5)) + connection.execSQL("UPDATE SecureElement SET created_at = 1700000000000") + } + + val db = openMigrated() + val rows = db.legacyElementDao().getAllWithTags() + db.close() + + assertEquals(1, rows.size) + assertEquals(17, rows.single().element.type) + assertEquals(1_700_000_000_000L, rows.single().element.timestamps.createdAt) + } + + @Test + fun `opens a version 3 file and reads tags through the junction`() = runTest { + createDatabase(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 { + createDatabase(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 { + createDatabase(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)") + } + + val db = openMigrated() + db.legacyElementDao().deleteByIds(listOf(2L)) + val remaining = db.legacyElementDao().getAllWithTags() + db.close() + + assertEquals(listOf("Keep"), remaining.map { it.element.title }) + } +} + +private fun createTempDir(): File = + java.nio.file.Files.createTempDirectory("legacy-db-test").toFile() 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..7833ea386 --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacySchemaIdentityTest.kt @@ -0,0 +1,76 @@ +package de.davis.keygo.migration.legacy_data.data.local + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Room compares `room_master_table.identity_hash` against the hash derived from the declared + * entities when it opens a file. If the ported entities drift from v1's schema by so much as a + * column order, every real v1 database on every device stops opening. + * + * 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. + */ +class LegacySchemaIdentityTest { + + private val json = Json { ignoreUnknownKeys = true } + + private val exportedDir = File( + "schemas/de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase", + ) + private val pristineDir = File("src/test/resources/legacy-schemas") + + private fun identityHashOf(file: File): String = + json.parseToJsonElement(file.readText()) + .jsonObject + .getValue("database") + .jsonObject + .getValue("identityHash") + .jsonPrimitive + .content + + private fun createSqlOf(file: File): List = + json.parseToJsonElement(file.readText()) + .jsonObject + .getValue("database") + .jsonObject + .getValue("entities") + .let { it as JsonArray } + .map { entity -> + val obj = entity as JsonObject + obj.getValue("createSql").jsonPrimitive.content + .replace("\${TABLE_NAME}", obj.getValue("tableName").jsonPrimitive.content) + } + .sorted() + + @Test + fun `version 3 identity hash matches v1`() { + assertEquals( + "0a97d13a94575bc2ed2ab009853b0086", + identityHashOf(File(exportedDir, "3.json")), + "Ported entities no longer generate v1's version 3 schema.", + ) + } + + @Test + fun `version 3 DDL matches v1 statement for statement`() { + assertEquals( + createSqlOf(File(pristineDir, "3.json")), + createSqlOf(File(exportedDir, "3.json")), + "Ported entity DDL drifted from v1. Column order counts.", + ) + } + + @Test + fun `copied version 1 and 2 schemas are v1's own`() { + assertEquals("6fb485e6e64b1e9cc5ceeb0440c58e23", identityHashOf(File(exportedDir, "1.json"))) + assertEquals("d5dd1a0120d5842be17e8ea9a19ee039", identityHashOf(File(exportedDir, "2.json"))) + } +} diff --git a/migration/legacy-data/src/test/resources/legacy-schemas/1.json b/migration/legacy-data/src/test/resources/legacy-schemas/1.json new file mode 100644 index 000000000..293766b88 --- /dev/null +++ b/migration/legacy-data/src/test/resources/legacy-schemas/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/src/test/resources/legacy-schemas/2.json b/migration/legacy-data/src/test/resources/legacy-schemas/2.json new file mode 100644 index 000000000..4793a226b --- /dev/null +++ b/migration/legacy-data/src/test/resources/legacy-schemas/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/src/test/resources/legacy-schemas/3.json b/migration/legacy-data/src/test/resources/legacy-schemas/3.json new file mode 100644 index 000000000..30ba2c0c5 --- /dev/null +++ b/migration/legacy-data/src/test/resources/legacy-schemas/3.json @@ -0,0 +1,176 @@ +{ + "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": "detail", + "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", + "notNull": false, + "defaultValue": "CURRENT_TIMESTAMP" + }, + { + "fieldPath": "timestamps.modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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`)" + } + ], + "foreignKeys": [] + }, + { + "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" + ] + } + ] + } + ], + "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, '0a97d13a94575bc2ed2ab009853b0086')" + ] + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index a68a27e35..29d162c2d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,6 +31,7 @@ include(":app") include(":automation-processor") include(":automation") include(":migration:create-access") +include(":migration:legacy-data") include(":core:item") include(":core:util") include(":core:security") From 1a63e8fe9dda9c846fcc583784450cea1b4a3bae Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 28 Jul 2026 00:04:15 +0200 Subject: [PATCH 07/31] test(migration): cover null columns across the 2-to-3 recreate and correct the schema guard docs The identity hash is computed over sorted fields and Room validates an opened file by column name, so column order is runtime-inert. Say that in the KDocs instead of the reverse, and state that the hash assertion and the DDL assertion cover different things so neither gets deleted as redundant. Record what actually happens when a version 2 file carries a NULL title or NULL data: the generated recreate aborts and the file cannot be opened at all. Also assert the cross ref cascade, count() on a populated database, and that favorite and modified_at survive the migrations. Co-Authored-By: Claude Opus 5 --- .gitignore | 3 + .../data/local/datasource/LegacyDatabase.kt | 5 ++ .../local/entity/LegacySecureElementEntity.kt | 13 +++- .../data/local/LegacyDatabaseOpenTest.kt | 71 ++++++++++++++++++- .../data/local/LegacySchemaIdentityTest.kt | 17 ++++- 5 files changed, 102 insertions(+), 7 deletions(-) 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/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 index 4213c9c00..72deb82ed 100644 --- 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 @@ -19,6 +19,11 @@ internal const val LEGACY_DATABASE_NAME = "secure_element_database" * can update to v2 directly from an early v1 build whose file never auto-migrated. The two * auto-migrations are generated from v1's own exported `1.json` and `2.json`, which is why 2-to-3 * needs no hand-written SQL despite being a table recreate. + * + * Opening this over an inherited file is a one-way door. The first query runs the auto-migrations, + * which permanently rewrite the file to version 3 and drop its `MasterPassword` table. An import + * that fails partway and is retried therefore finds the file already at version 3, never at the + * version it started from, so retry paths must be written against a file that is already migrated. */ @Database( version = 3, 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 index f64d4ecae..a4477769a 100644 --- 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 @@ -11,9 +11,16 @@ import androidx.room.PrimaryKey * 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`. Reordering these declarations changes - * the generated DDL, which changes the identity hash, which makes Room refuse to open real v1 - * files. `LegacySchemaIdentityTest` is the guard. + * `title, data, favorite, id, type, created_at, modified_at`. + * + * What that order does and does not buy is worth being precise about, because getting it wrong in + * either direction is expensive. Room derives its identity hash from sorted fields and validates an + * opened file by comparing columns by name, so column order is runtime-inert: reordering these + * declarations would neither change the hash nor stop a real v1 file from opening. What the hash + * does pin is column names, affinities, nullability, defaults, the primary key, indices and foreign + * keys, and drifting on any of those is what makes Room refuse the file. Column order is kept + * identical to v1 purely for byte-fidelity, so the schema we ship is the schema v1 shipped, and + * `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. 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 index b54f62069..d23ff7806 100644 --- 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 @@ -3,6 +3,7 @@ package de.davis.keygo.migration.legacy_data.data.local import android.content.Context import androidx.room.Room import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteException import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.execSQL import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase @@ -19,6 +20,7 @@ import java.io.File import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertTrue @@ -126,16 +128,24 @@ class LegacyDatabaseOpenTest { fun `opens and migrates a version 2 file`() = runTest { createDatabase(2).use { connection -> connection.insertElement("Mid entry", type = 17, blob = byteArrayOf(4, 5)) - connection.execSQL("UPDATE SecureElement SET created_at = 1700000000000") + 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) + // Task 5's 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 @@ -176,12 +186,71 @@ class LegacyDatabaseOpenTest { 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 }) + // Task 7's 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() + } + } + + /** + * Versions 1 and 2 declare `title` and `data` nullable, version 3 declares both NOT NULL, and + * Room's generated 2-to-3 recreate copies rows straight across with + * `INSERT INTO _new_SecureElement (...) SELECT ... FROM SecureElement`. A row carrying a real + * NULL therefore cannot survive the copy. + * + * These two tests record the observed behaviour, not a desired one: the migration aborts and + * the failure surfaces from the first query. Nothing is coerced to a default and no row is + * quietly skipped, so an affected file cannot be imported at all as things stand. The affected + * population is real. Anyone who went from an early v1 build straight to v2 never ran v1's own + * 2-to-3, which makes this port the first code that ever attempts the recreate for them. + * + * The assertion is on the type alone because the message is not observable here. AGP's mockable + * `android.jar` gives `android.database.SQLException`, which `androidx.sqlite.SQLiteException` + * aliases on Android, a generated empty constructor, so SQLite's text is dropped before the + * test can read it. On a device the message survives. Type is the only assertion true in both. + */ + @Test + fun `a null title aborts the 2 to 3 recreate`() = runTest { + createDatabase(2).use { connection -> + connection.execSQL( + "INSERT INTO SecureElement (title, data, type) VALUES (NULL, x'0102', 1)", + ) + } + + val db = openMigrated() + assertFailsWith { db.legacyElementDao().getAllWithTags() } + db.close() + } + + @Test + fun `null data aborts the 2 to 3 recreate`() = runTest { + createDatabase(2).use { connection -> + connection.execSQL( + "INSERT INTO SecureElement (title, data, type) VALUES ('Has title', NULL, 1)", + ) + } + + val db = openMigrated() + assertFailsWith { db.legacyElementDao().getAllWithTags() } + db.close() } } 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 index 7833ea386..c0541f8dc 100644 --- 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 @@ -10,9 +10,20 @@ import kotlin.test.Test import kotlin.test.assertEquals /** - * Room compares `room_master_table.identity_hash` against the hash derived from the declared - * entities when it opens a file. If the ported entities drift from v1's schema by so much as a - * column order, every real v1 database on every device stops opening. + * Guards the ported entities against drifting from v1's frozen schema, with two assertions that + * cover different things. Neither is redundant, and deleting either one leaves a real gap. + * + * The identity hash is what Room itself checks: it compares `room_master_table.identity_hash` + * against the hash derived from the declared entities and refuses to open the file on a mismatch. + * That hash is computed over sorted fields, so it pins column names, affinities, nullability, + * defaults, the primary key, indices and foreign keys, but it is blind to the order the columns are + * declared in. A drift on anything it covers stops every real v1 database on every device opening. + * + * The statement-for-statement DDL comparison is what pins column order. Order is runtime-inert, + * since Room validates an opened file by matching columns by name, so a drift here breaks nothing + * at runtime. It is asserted anyway so the schema we ship stays byte-identical to the one v1 + * shipped, which is what makes the exported JSON trustworthy as a record of v1 rather than a + * near-miss. * * 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 From 96be967c9a64cdb0462a325a023c88e5266fb750 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 28 Jul 2026 00:13:14 +0200 Subject: [PATCH 08/31] feat(migration): sanitize null legacy columns before Room opens the file A NULL title or data in a version 1 or 2 file aborts Room's generated 2-to-3 recreate and makes the whole database unopenable, costing the user every other row. Repair those two columns in place before Room ever sees the file, leaving the auto-migrations and the identity hash untouched. The repair is the smallest thing that clears the constraint: an empty title and an empty blob, never a deletion. An empty blob cannot decrypt, so the import reports that row as one failed item instead of losing the file. Guards against creating a missing file, touching an already-migrated version 3 file, or assuming SecureElement exists, and takes the driver as a parameter so tests run on the bundled driver. Co-Authored-By: Claude Opus 5 --- .../datasource/LegacyDatabaseSanitizer.kt | 66 +++++++ .../data/local/LegacyDatabaseOpenTest.kt | 19 +- .../data/local/LegacyDatabaseSanitizerTest.kt | 181 ++++++++++++++++++ 3 files changed, 259 insertions(+), 7 deletions(-) create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabaseSanitizer.kt create mode 100644 migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseSanitizerTest.kt diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabaseSanitizer.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabaseSanitizer.kt new file mode 100644 index 000000000..e3ab2d229 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabaseSanitizer.kt @@ -0,0 +1,66 @@ +package de.davis.keygo.migration.legacy_data.data.local.datasource + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import androidx.sqlite.driver.AndroidSQLiteDriver +import androidx.sqlite.execSQL +import java.io.File + +/** + * Repairs the rows in an inherited v1 file that would otherwise abort Room's 2-to-3 recreate. + * + * Versions 1 and 2 declare `title` and `data` nullable while version 3 declares both NOT NULL, and + * the generated recreate copies rows across with a plain `INSERT ... SELECT`. A single row carrying + * a real NULL therefore makes the whole file unopenable, which costs the user every other row as + * well. `LegacyDatabaseOpenTest` pins that hazard so it stays visible. + * + * The repair is deliberately the smallest thing that clears the constraint: an empty title and an + * empty blob, never a deletion and never an invented placeholder. An empty blob cannot decrypt, so + * the per-row skip policy reports such a row as one failed item instead of the whole import dying. + * + * Running before Room opens the file leaves the auto-migrations and the identity hash untouched. + */ +internal class LegacyDatabaseSanitizer( + private val driver: SQLiteDriver = AndroidSQLiteDriver(), +) { + + /** + * Repairs [path] and returns the number of rows it touched, so the migration can report it. + * Returns 0 and changes nothing when there is nothing to repair. + */ + fun sanitize(path: String): Int { + // Opening read-write creates the file, which would make a clean v2 install look like it + // inherited a legacy database. + if (!File(path).exists()) return 0 + + return driver.open(path).use { connection -> + // A version 3 file already survived the recreate, and its columns are NOT NULL anyway. + if (connection.userVersion() >= 3) return@use 0 + // No SecureElement means this is not a v1 file. Leave it alone rather than throw. + if (!connection.hasTable("SecureElement")) return@use 0 + + val repaired = connection.countRepairableRows() + connection.execSQL("UPDATE SecureElement SET title = '' WHERE title IS NULL") + connection.execSQL("UPDATE SecureElement SET data = x'' WHERE data IS NULL") + repaired + } + } + + private fun SQLiteConnection.userVersion(): Int = selectInt("PRAGMA user_version") + + private fun SQLiteConnection.hasTable(name: String): Boolean = + selectInt("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = '$name'") > 0 + + /** + * Counted before the updates rather than summed from their change counts, so a row that is null + * in both columns is reported once instead of twice. + */ + private fun SQLiteConnection.countRepairableRows(): Int = + selectInt("SELECT COUNT(*) FROM SecureElement WHERE title IS NULL OR data IS NULL") + + private fun SQLiteConnection.selectInt(sql: String): Int = + prepare(sql).use { statement -> + statement.step() + statement.getLong(0).toInt() + } +} 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 index d23ff7806..1a3430170 100644 --- 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 @@ -216,11 +216,16 @@ class LegacyDatabaseOpenTest { * `INSERT INTO _new_SecureElement (...) SELECT ... FROM SecureElement`. A row carrying a real * NULL therefore cannot survive the copy. * - * These two tests record the observed behaviour, not a desired one: the migration aborts and - * the failure surfaces from the first query. Nothing is coerced to a default and no row is - * quietly skipped, so an affected file cannot be imported at all as things stand. The affected - * population is real. Anyone who went from an early v1 build straight to v2 never ran v1's own - * 2-to-3, which makes this port the first code that ever attempts the recreate for them. + * These two tests record the raw hazard, not shipped behaviour: the migration aborts and the + * failure surfaces from the first query. Nothing is coerced to a default and no row is quietly + * skipped, so an unsanitized file cannot be imported at all. The affected population is real. + * Anyone who went from an early v1 build straight to v2 never ran v1's own 2-to-3, which makes + * this port the first code that ever attempts the recreate for them. + * + * The production path does not hit this, because `LegacyDatabaseSanitizer` runs first and + * rewrites those NULLs to an empty title and an empty blob. These tests stay as the regression + * guard proving the hazard the sanitizer exists to defend against is real, so deleting the + * sanitizer cannot go unnoticed. * * The assertion is on the type alone because the message is not observable here. AGP's mockable * `android.jar` gives `android.database.SQLException`, which `androidx.sqlite.SQLiteException` @@ -228,7 +233,7 @@ class LegacyDatabaseOpenTest { * test can read it. On a device the message survives. Type is the only assertion true in both. */ @Test - fun `a null title aborts the 2 to 3 recreate`() = runTest { + fun `an unsanitized null title makes the file unopenable`() = runTest { createDatabase(2).use { connection -> connection.execSQL( "INSERT INTO SecureElement (title, data, type) VALUES (NULL, x'0102', 1)", @@ -241,7 +246,7 @@ class LegacyDatabaseOpenTest { } @Test - fun `null data aborts the 2 to 3 recreate`() = runTest { + fun `unsanitized null data makes the file unopenable`() = runTest { createDatabase(2).use { connection -> connection.execSQL( "INSERT INTO SecureElement (title, data, type) VALUES ('Has title', NULL, 1)", diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseSanitizerTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseSanitizerTest.kt new file mode 100644 index 000000000..f6be8bdf9 --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseSanitizerTest.kt @@ -0,0 +1,181 @@ +package de.davis.keygo.migration.legacy_data.data.local + +import android.content.Context +import androidx.room.Room +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 de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabaseSanitizer +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The sanitizer exists because a NULL `title` or `data` in a version 1 or 2 file aborts Room's + * 2-to-3 recreate and takes the whole database with it. `LegacyDatabaseOpenTest` proves the hazard; + * these tests prove the repair clears it without disturbing anything else. + */ +class LegacyDatabaseSanitizerTest { + + private val tempDir: File = java.nio.file.Files.createTempDirectory("legacy-sanitize").toFile() + private val dbFile: File = File(tempDir, "secure_element_database") + + private val json = Json { ignoreUnknownKeys = true } + + private val sanitizer = LegacyDatabaseSanitizer(BundledSQLiteDriver()) + + private val context: Context = mockk(relaxed = true).apply { + every { getDatabasePath(any()) } returns dbFile + } + + @AfterTest + fun tearDown() { + tempDir.deleteRecursively() + } + + /** Writes a file that looks exactly like one a v1 build at [version] would have left behind. */ + private fun createDatabase(version: Int): SQLiteConnection { + val schema = json + .parseToJsonElement(File("src/test/resources/legacy-schemas/$version.json").readText()) + .jsonObject + .getValue("database") + .jsonObject + + return BundledSQLiteDriver().open(dbFile.absolutePath).apply { + schema.getValue("entities").jsonArray.forEach { entity -> + val table = entity.jsonObject.getValue("tableName").jsonPrimitive.content + execSQL(entity.jsonObject.createSqlFor(table)) + entity.jsonObject.getValue("indices").jsonArray.forEach { index -> + execSQL(index.jsonObject.createSqlFor(table)) + } + } + schema.getValue("setupQueries").jsonArray.forEach { execSQL(it.jsonPrimitive.content) } + execSQL("PRAGMA user_version = $version") + } + } + + private fun JsonObject.createSqlFor(tableName: String): String = + getValue("createSql").jsonPrimitive.content.replace("\${TABLE_NAME}", tableName) + + private fun openMigrated(): LegacyDatabase = + Room.databaseBuilder(context, LegacyDatabase::class.java, dbFile.name) + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.IO) + .build() + + /** Every row rendered as `title|data`, so an unchanged file compares equal with a clear diff. */ + private fun rowSnapshot(): List = + BundledSQLiteDriver().open(dbFile.absolutePath).use { connection -> + connection.prepare("SELECT title, data FROM SecureElement ORDER BY id").use { stmt -> + buildList { + while (stmt.step()) { + val title = if (stmt.isNull(0)) "" else stmt.getText(0) + val data = if (stmt.isNull(1)) "" else stmt.getBlob(1).joinToString() + add("$title|$data") + } + } + } + } + + @Test + fun `repairs a null title so the file opens with an empty one`() = runTest { + createDatabase(2).use { connection -> + connection.execSQL( + "INSERT INTO SecureElement (title, data, type) VALUES (NULL, x'0102', 1)", + ) + } + + assertEquals(1, sanitizer.sanitize(dbFile.absolutePath)) + + val db = openMigrated() + val rows = db.legacyElementDao().getAllWithTags() + db.close() + + assertEquals(1, rows.size) + assertEquals("", rows.single().element.title) + assertContentEquals(byteArrayOf(1, 2), rows.single().element.data) + } + + @Test + fun `repairs null data so the row survives with an empty blob`() = runTest { + createDatabase(2).use { connection -> + connection.execSQL( + "INSERT INTO SecureElement (title, data, type) VALUES ('Has title', NULL, 1)", + ) + } + + assertEquals(1, sanitizer.sanitize(dbFile.absolutePath)) + + val db = openMigrated() + val rows = db.legacyElementDao().getAllWithTags() + db.close() + + assertEquals(1, rows.size) + assertEquals("Has title", rows.single().element.title) + // Kept rather than deleted on purpose: an empty blob cannot decrypt, so the import reports + // this as one failed row instead of losing the whole file. + assertTrue(rows.single().element.data.isEmpty()) + } + + @Test + fun `returns zero and creates nothing when the file is missing`() { + assertFalse(dbFile.exists()) + + assertEquals(0, sanitizer.sanitize(dbFile.absolutePath)) + + assertFalse(dbFile.exists(), "sanitizing must never bring a legacy database into existence") + } + + @Test + fun `returns zero and leaves a version 3 file untouched`() { + createDatabase(3).use { connection -> + connection.execSQL( + "INSERT INTO SecureElement (title, data, favorite, type) " + + "VALUES ('Kept', x'0909', 0, 1)", + ) + } + val before = rowSnapshot() + + assertEquals(0, sanitizer.sanitize(dbFile.absolutePath)) + + assertEquals(before, rowSnapshot()) + } + + @Test + fun `returns zero when the file has no SecureElement table`() { + BundledSQLiteDriver().open(dbFile.absolutePath).use { connection -> + connection.execSQL("CREATE TABLE Leftover (id INTEGER PRIMARY KEY)") + connection.execSQL("PRAGMA user_version = 2") + } + + assertEquals(0, sanitizer.sanitize(dbFile.absolutePath)) + } + + @Test + fun `returns zero and changes nothing when no column is null`() { + createDatabase(2).use { connection -> + connection.execSQL( + "INSERT INTO SecureElement (title, data, type) VALUES ('Clean', x'0102', 1)", + ) + } + val before = rowSnapshot() + + assertEquals(0, sanitizer.sanitize(dbFile.absolutePath)) + + assertEquals(before, rowSnapshot()) + } +} From 7a2c0e11b5afcae83f07247179bf60a50c7fd8bd Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 28 Jul 2026 00:40:33 +0200 Subject: [PATCH 09/31] fix(migration): harden LegacyDatabaseSanitizer transaction and guard tests Wraps the sanitizer's count read and both UPDATEs in a single transaction so a crash mid-repair cannot leave the file half-fixed, inlines the hasTable table name to remove an unescaped-literal pattern, and makes selectInt fail loudly on a query with no row instead of returning a garbage 0. Adds a version-1 repair test (the population this module exists for) and rewrites the version-3 guard test so it actually fails if the guard is removed, rather than vacuously passing because a real v3 file can never hold the NULL it was checking for. Turns the stalled agent's debug println into a real assertion pinning that an aborted 2-to-3 recreate rolls the file back to version 2, and fixes a KDoc typo. --- .../data/local/datasource/LegacyDatabase.kt | 14 +++++--- .../datasource/LegacyDatabaseSanitizer.kt | 31 ++++++++++++----- .../data/local/LegacyDatabaseOpenTest.kt | 15 +++++++++ .../data/local/LegacyDatabaseSanitizerTest.kt | 33 +++++++++++++++++-- 4 files changed, 78 insertions(+), 15 deletions(-) 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 index 72deb82ed..0ed954521 100644 --- 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 @@ -20,10 +20,16 @@ internal const val LEGACY_DATABASE_NAME = "secure_element_database" * auto-migrations are generated from v1's own exported `1.json` and `2.json`, which is why 2-to-3 * needs no hand-written SQL despite being a table recreate. * - * Opening this over an inherited file is a one-way door. The first query runs the auto-migrations, - * which permanently rewrite the file to version 3 and drop its `MasterPassword` table. An import - * that fails partway and is retried therefore finds the file already at version 3, never at the - * version it started from, so retry paths must be written against a file that is already migrated. + * Opening this over an inherited file is a one-way door, but only once the open succeeds. The first + * query runs the auto-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, measured as 2 in + * `LegacyDatabaseOpenTest`. That is what makes `LegacyDatabaseSanitizer`'s "below version 3 only" + * guard useful on a retry: a file whose recreate aborted still reads as version 2, so the sanitizer + * still sees it and can repair it. */ @Database( version = 3, diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabaseSanitizer.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabaseSanitizer.kt index e3ab2d229..cd75262cb 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabaseSanitizer.kt +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabaseSanitizer.kt @@ -37,19 +37,33 @@ internal class LegacyDatabaseSanitizer( // A version 3 file already survived the recreate, and its columns are NOT NULL anyway. if (connection.userVersion() >= 3) return@use 0 // No SecureElement means this is not a v1 file. Leave it alone rather than throw. - if (!connection.hasTable("SecureElement")) return@use 0 + if (!connection.hasSecureElementTable()) return@use 0 - val repaired = connection.countRepairableRows() - connection.execSQL("UPDATE SecureElement SET title = '' WHERE title IS NULL") - connection.execSQL("UPDATE SecureElement SET data = x'' WHERE data IS NULL") - repaired + // The count read and both updates run as one transaction: this is the user's only copy + // of that data, and autocommit would let a crash between statements leave it half-repaired. + connection.execSQL("BEGIN") + try { + val repaired = connection.countRepairableRows() + connection.execSQL("UPDATE SecureElement SET title = '' WHERE title IS NULL") + connection.execSQL("UPDATE SecureElement SET data = x'' WHERE data IS NULL") + connection.execSQL("END") + repaired + } catch (e: Exception) { + connection.execSQL("ROLLBACK") + throw e + } } } private fun SQLiteConnection.userVersion(): Int = selectInt("PRAGMA user_version") - private fun SQLiteConnection.hasTable(name: String): Boolean = - selectInt("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = '$name'") > 0 + // Inlined rather than parameterized: the one call site is a compile-time constant, and a + // private helper that only ever checks for "SecureElement" does not need a String parameter + // that invites an unescaped literal later. + private fun SQLiteConnection.hasSecureElementTable(): Boolean = + selectInt( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'SecureElement'", + ) > 0 /** * Counted before the updates rather than summed from their change counts, so a row that is null @@ -58,9 +72,10 @@ internal class LegacyDatabaseSanitizer( private fun SQLiteConnection.countRepairableRows(): Int = selectInt("SELECT COUNT(*) FROM SecureElement WHERE title IS NULL OR data IS NULL") + /** Every call site is a COUNT(*) or a PRAGMA, both of which always return exactly one row. */ private fun SQLiteConnection.selectInt(sql: String): Int = prepare(sql).use { statement -> - statement.step() + check(statement.step()) { "expected a row from: $sql" } statement.getLong(0).toInt() } } 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 index 1a3430170..6dd52fcef 100644 --- 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 @@ -243,8 +243,23 @@ class LegacyDatabaseOpenTest { val db = openMigrated() assertFailsWith { db.legacyElementDao().getAllWithTags() } db.close() + + assertEquals( + 2, + userVersion(), + "the sanitizer's below-version-3 guard depends on this: a file whose recreate " + + "aborted must still read as version 2 for a retry to repair it", + ) } + private fun userVersion(): Int = + BundledSQLiteDriver().open(dbFile.absolutePath).use { connection -> + connection.prepare("PRAGMA user_version").use { stmt -> + stmt.step() + stmt.getLong(0).toInt() + } + } + @Test fun `unsanitized null data makes the file unopenable`() = runTest { createDatabase(2).use { connection -> diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseSanitizerTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseSanitizerTest.kt index f6be8bdf9..2b841d3d9 100644 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseSanitizerTest.kt +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseSanitizerTest.kt @@ -110,6 +110,25 @@ class LegacyDatabaseSanitizerTest { assertContentEquals(byteArrayOf(1, 2), rows.single().element.data) } + @Test + fun `repairs a null title in a version 1 file so it survives both auto-migrations`() = runTest { + createDatabase(1).use { connection -> + connection.execSQL( + "INSERT INTO SecureElement (title, data, type) VALUES (NULL, x'0102', 1)", + ) + } + + assertEquals(1, sanitizer.sanitize(dbFile.absolutePath)) + + val db = openMigrated() + val rows = db.legacyElementDao().getAllWithTags() + db.close() + + assertEquals(1, rows.size) + assertEquals("", rows.single().element.title) + assertContentEquals(byteArrayOf(1, 2), rows.single().element.data) + } + @Test fun `repairs null data so the row survives with an empty blob`() = runTest { createDatabase(2).use { connection -> @@ -140,13 +159,21 @@ class LegacyDatabaseSanitizerTest { assertFalse(dbFile.exists(), "sanitizing must never bring a legacy database into existence") } + /** + * Seeds the version 2 schema, which still allows a NULL title, then stamps the file as version + * 3 without going through the recreate. That is the only way to get a NULL title into a file the + * guard sees as version 3: a real version 3 file's NOT NULL column could never hold one, so + * seeding with `createDatabase(3)` would make the UPDATEs match nothing whether or not the guard + * exists. Snapshotting the row before and after `sanitize` catches the guard being removed, where + * asserting only the return value would not. + */ @Test fun `returns zero and leaves a version 3 file untouched`() { - createDatabase(3).use { connection -> + createDatabase(2).use { connection -> connection.execSQL( - "INSERT INTO SecureElement (title, data, favorite, type) " + - "VALUES ('Kept', x'0909', 0, 1)", + "INSERT INTO SecureElement (title, data, type) VALUES (NULL, x'0909', 1)", ) + connection.execSQL("PRAGMA user_version = 3") } val before = rowSnapshot() From f9d8c4395645732970c2afb600de3f6ead67029e Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 28 Jul 2026 06:11:54 +0200 Subject: [PATCH 10/31] feat(migration): decrypt v1 secure element blobs Co-Authored-By: Claude Opus 5 --- .../legacy_data/data/crypto/LegacyCipher.kt | 40 +++++++++ .../data/crypto/LegacyKeyProvider.kt | 30 +++++++ .../data/crypto/LegacyAesGcmCipherTest.kt | 86 +++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyCipher.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyKeyProvider.kt create mode 100644 migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyAesGcmCipherTest.kt diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyCipher.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyCipher.kt new file mode 100644 index 000000000..53d2268d3 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyCipher.kt @@ -0,0 +1,40 @@ +package de.davis.keygo.migration.legacy_data.data.crypto + +import org.koin.core.annotation.Single +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec + +internal interface LegacyCipher { + + /** Returns null when the blob cannot be decrypted for any reason. */ + fun decrypt(blob: ByteArray): ByteArray? +} + +/** + * 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( + private val keyProvider: LegacyKeyProvider, +) : LegacyCipher { + + override fun decrypt(blob: ByteArray): ByteArray? { + if (blob.size <= IV_SIZE) return null + val key = keyProvider.secretKey() ?: 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/crypto/LegacyKeyProvider.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyKeyProvider.kt new file mode 100644 index 000000000..56fbf80da --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyKeyProvider.kt @@ -0,0 +1,30 @@ +package de.davis.keygo.migration.legacy_data.data.crypto + +import org.koin.core.annotation.Single +import java.security.KeyStore +import javax.crypto.SecretKey + +internal const val LEGACY_KEY_ALIAS = "password_manager_skey" + +/** + * Supplies the AES key v1 used to encrypt every `SecureElement.data` blob. + * + * Returns null when the alias is gone. It must never create a key: v1's own `KeyUtil.getSecretKey` + * generated one on a miss, and doing the same here would silently turn "this row cannot be read" + * into "this row decrypts to garbage". A null means the legacy data is unrecoverable, which the + * caller reports rather than papers over. + */ +internal interface LegacyKeyProvider { + + fun secretKey(): SecretKey? +} + +@Single +internal class AndroidLegacyKeyProvider : LegacyKeyProvider { + + override fun secretKey(): SecretKey? = runCatching { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + if (!keyStore.containsAlias(LEGACY_KEY_ALIAS)) return null + keyStore.getKey(LEGACY_KEY_ALIAS, null) as? SecretKey + }.getOrNull() +} 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..187a76fa6 --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyAesGcmCipherTest.kt @@ -0,0 +1,86 @@ +package de.davis.keygo.migration.legacy_data.data.crypto + +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: `Cryptography.encryptWithIV` prefixed the 12 byte GCM IV to + * the ciphertext in one blob. `encryptLikeV1` below is a transcription of that method, so a passing + * test means wire compatibility with bytes real v1 installs wrote, not merely self-consistency. + */ +class LegacyAesGcmCipherTest { + + private val key: SecretKey = KeyGenerator.getInstance("AES") + .apply { init(256, SecureRandom()) } + .generateKey() + + private fun encryptLikeV1(plaintext: ByteArray, withKey: SecretKey = key): ByteArray { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, withKey) + val iv = cipher.iv + val encrypted = cipher.doFinal(plaintext) + return iv + encrypted + } + + private fun cipherWith(secretKey: SecretKey?) = + LegacyAesGcmCipher(object : LegacyKeyProvider { + override fun secretKey(): SecretKey? = secretKey + }) + + @Test + fun `decrypts a blob written by v1`() { + val plaintext = "{\"type\":1,\"username\":\"ada\"}".encodeToByteArray() + + val decrypted = cipherWith(key).decrypt(encryptLikeV1(plaintext)) + + assertContentEquals(plaintext, decrypted) + } + + @Test + fun `decrypts an empty plaintext`() { + assertContentEquals(byteArrayOf(), cipherWith(key).decrypt(encryptLikeV1(byteArrayOf()))) + } + + @Test + fun `returns null when the keystore has no legacy alias`() { + assertNull(cipherWith(null).decrypt(encryptLikeV1(byteArrayOf(1, 2, 3)))) + } + + @Test + fun `returns null for a blob encrypted under a different key`() { + val otherKey = KeyGenerator.getInstance("AES") + .apply { init(256, SecureRandom()) } + .generateKey() + + assertNull(cipherWith(key).decrypt(encryptLikeV1(byteArrayOf(1, 2, 3), otherKey))) + } + + @Test + fun `returns null for a blob shorter than the iv`() { + assertNull(cipherWith(key).decrypt(byteArrayOf(1, 2, 3))) + } + + @Test + fun `returns null for a tampered blob`() { + val blob = encryptLikeV1("secret".encodeToByteArray()) + blob[blob.size - 1] = (blob[blob.size - 1] + 1).toByte() + + assertNull(cipherWith(key).decrypt(blob)) + } + + @Test + fun `uses a twelve byte iv prefix`() { + val blob = encryptLikeV1("x".encodeToByteArray()) + 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, cipherWith(key).decrypt(blob)) + } +} From 81cec02f78d5dd2ba549bd951a0cd6da01c6d7b6 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 28 Jul 2026 06:21:17 +0200 Subject: [PATCH 11/31] feat(migration): parse v1 element detail json Co-Authored-By: Claude Opus 5 --- .../legacy_data/data/json/LegacyDetailJson.kt | 35 +++++ .../data/json/LegacyDetailParser.kt | 64 +++++++++ .../legacy_data/domain/model/LegacyItem.kt | 68 ++++++++++ .../data/json/LegacyDetailParserTest.kt | 127 ++++++++++++++++++ 4 files changed, 294 insertions(+) create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/json/LegacyDetailJson.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/json/LegacyDetailParser.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyItem.kt create mode 100644 migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/json/LegacyDetailParserTest.kt 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..be14f3b93 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/json/LegacyDetailJson.kt @@ -0,0 +1,35 @@ +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 data 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, +) { + + override fun equals(other: Any?): Boolean = this === other + + override fun hashCode(): Int = System.identityHashCode(this) +} + +@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/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..7ccf8379a --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyItem.kt @@ -0,0 +1,68 @@ +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, +} + +/** 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 (other !is Password) return false + + if (username != other.username) return false + if (origin != other.origin) return false + if (password != null) { + if (other.password == null) return false + if (!password.contentEquals(other.password)) return false + } else if (other.password != null) return false + return strength == other.strength + } + + 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 +} + +/** 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, +) + +internal const val LEGACY_TYPE_PASSWORD = 0x1 +internal const val LEGACY_TYPE_CREDIT_CARD = 0x11 +internal const val LEGACY_TAG_PREFIX = "elementType:" 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())) + } +} From aed19978fcc19916a0c316448129a9f1686609b1 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 28 Jul 2026 06:40:01 +0200 Subject: [PATCH 12/31] feat(migration): read and decode the legacy database into domain items Wires the ported v1 database, the Keystore cipher and the detail parser into a repository that reads every row, decrypts it, parses it, and returns typed LegacyItems plus the rows it could not import. LegacyDatabaseProvider gains its implementation. It sanitizes the inherited file before Room can open it, because Room runs the 2-to-3 recreate on the first query and one NULL title or blob aborts it and takes every other row down with it. The repaired-row count is surfaced so the migration can report it, and a file that cannot even be sanitized becomes an answer rather than an exception thrown across the unlock flow. The Keystore alias is probed once per run instead of once per row. A gone alias means nothing in the file is recoverable, which is a different outcome from a single row that will not decrypt, and the two call for opposite handling. Co-Authored-By: Claude Opus 5 --- .../data/local/datasource/LegacyDatabase.kt | 15 +- .../SanitizingLegacyDatabaseProvider.kt | 57 +++ .../data/mapper/LegacyElementMapper.kt | 29 ++ .../repository/LegacyItemRepositoryImpl.kt | 114 ++++++ .../legacy_data/di/LegacyDatabaseModule.kt | 30 ++ .../di/MigrationLegacyDataModule.kt | 2 +- .../domain/model/LegacyMigration.kt | 67 ++++ .../domain/repository/LegacyItemRepository.kt | 80 +++++ .../SanitizingLegacyDatabaseProviderTest.kt | 176 ++++++++++ .../LegacyItemRepositoryImplTest.kt | 332 ++++++++++++++++++ 10 files changed, 899 insertions(+), 3 deletions(-) create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/SanitizingLegacyDatabaseProvider.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyElementMapper.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyItemRepositoryImpl.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/di/LegacyDatabaseModule.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigration.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/repository/LegacyItemRepository.kt create mode 100644 migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/SanitizingLegacyDatabaseProviderTest.kt create mode 100644 migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyItemRepositoryImplTest.kt 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 index 0ed954521..099b9714e 100644 --- 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 @@ -56,7 +56,18 @@ internal abstract class LegacyDatabase : RoomDatabase() { * never exists and eagerly opening it would create an empty one, and the repository re-opens after * `deleteDatabase` closes the previous handle. */ -internal fun interface LegacyDatabaseProvider { +internal interface LegacyDatabaseProvider { - fun get(): LegacyDatabase + /** + * Returns null when the file cannot be made openable, which means it is corrupt, truncated, or + * not a SQLite database at all. Null rather than a throw, because a foreign file sitting where + * v1's used to be is an ordinary thing to find on a migration path and the caller reports it. + */ + fun get(): LegacyDatabase? + + /** Rows repaired to make the file openable, counted across every open so far. */ + val repairedRows: Int + + /** Closes the open handle, if there is one. The next [get] opens a fresh one. */ + fun close() } diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/SanitizingLegacyDatabaseProvider.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/SanitizingLegacyDatabaseProvider.kt new file mode 100644 index 000000000..cfec93c95 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/SanitizingLegacyDatabaseProvider.kt @@ -0,0 +1,57 @@ +package de.davis.keygo.migration.legacy_data.data.local.datasource + +import android.content.Context +import androidx.room.Room + +/** + * Repairs the inherited file, then opens it. + * + * The order is the whole point. Room runs the 2-to-3 recreate on the first query, and a single row + * carrying a NULL `title` or `data` aborts it and takes every other row in the file down with it. + * The sanitizer is the only thing standing between one bad row and the user losing all of them, and + * it can only do its work while the file is still closed, so it runs here rather than anywhere + * further in. It is cheap and self-guarding on a file that needs nothing, so every open pays for it. + */ +internal class SanitizingLegacyDatabaseProvider( + private val context: Context, + private val sanitizer: LegacyDatabaseSanitizer = LegacyDatabaseSanitizer(), +) : LegacyDatabaseProvider { + + private var database: LegacyDatabase? = null + + override var repairedRows: Int = 0 + private set + + /** + * 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 repaired = try { + sanitizer.sanitize(context.getDatabasePath(LEGACY_DATABASE_NAME).absolutePath) + } catch (_: Exception) { + // The sanitizer has no failure channel of its own: its guards cover a missing file, an + // already migrated one and a non-v1 one, and everything else it meets it opens. A + // corrupt, truncated or foreign file therefore throws out of the driver, the PRAGMA or + // the sqlite_master probe. Nothing can be read out of such a file, so it becomes an + // answer here instead of an exception thrown across the unlock flow. + return null + } + + repairedRows += repaired + return Room.databaseBuilder(context, LegacyDatabase::class.java, LEGACY_DATABASE_NAME) + .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 + } +} 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..b549313e2 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyItemRepositoryImpl.kt @@ -0,0 +1,114 @@ +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.fold +import de.davis.keygo.core.util.resultBinding +import de.davis.keygo.migration.legacy_data.data.crypto.LegacyCipher +import de.davis.keygo.migration.legacy_data.data.crypto.LegacyKeyProvider +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.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.LegacyDatabaseFiles +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyDatabaseState +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyItemRepository +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyReadResult +import org.koin.core.annotation.Single + +@Single +internal class LegacyItemRepositoryImpl( + private val databaseProvider: LegacyDatabaseProvider, + private val keyProvider: LegacyKeyProvider, + private val cipher: LegacyCipher, + private val parser: LegacyDetailParser, + private val databaseFiles: LegacyDatabaseFiles, +) : LegacyItemRepository { + + override val repairedRows: Int get() = databaseProvider.repairedRows + + override suspend fun state(): LegacyDatabaseState { + if (!databaseFiles.exists()) return LegacyDatabaseState.Absent + // The provider only comes back empty when the file could not even be repaired, so this is + // the one place `Unreadable` can be told apart from a file Room simply does not recognise. + if (databaseProvider.get() == null) return LegacyDatabaseState.Unreadable + + // Room opens the file on the first query rather than when the object is built, so querying + // is the probe. A leftover v2 database from before `ItemDatabase` was renamed has no + // SecureElement table, Room refuses to validate it, and that is what NotLegacy means. The + // null check above has already taken the unreadable case out of this branch. + return withDao { it.count() }.fold( + onSuccess = { LegacyDatabaseState.Present }, + onFailure = { LegacyDatabaseState.NotLegacy }, + ) + } + + override suspend fun readAll(): Result = resultBinding { + val rows = withDao { it.getAllWithTags() }.bind() + + // Probed once for the whole run, never once per row. `LegacyCipher.decrypt` folds four + // different failures into one null, and a gone alias is the only one of them that says + // nothing in this file is recoverable. Letting it arrive as one Undecryptable per row would + // tell the user every entry was individually damaged when the entries are intact, and it + // could not be told apart from a file that really is corrupt end to end. + keyProvider.secretKey().asResult(LegacyReadFailure.KeyUnavailable).bind() + + val items = mutableListOf() + val failures = mutableListOf() + + for (row in rows) { + val decrypted = cipher.decrypt(row.element.data) + if (decrypted == null) { + failures += row.failure(LegacyFailureReason.Undecryptable) + continue + } + + val detail = parser.parse(decrypted) + if (detail == null) { + failures += row.failure(LegacyFailureReason.Unparseable) + continue + } + + items += row.toLegacyItem(detail) + } + + LegacyReadResult(items = items, failures = failures) + } + + override suspend fun prune(legacyIds: List): Result { + if (legacyIds.isEmpty()) return Result.Success(Unit) + return withDao { it.deleteByIds(legacyIds) } + } + + override suspend fun remainingCount(): Result = withDao { it.count() } + + override fun deleteDatabase(): Boolean { + databaseProvider.close() + return databaseFiles.delete() + } + + /** + * Runs [block] against the legacy DAO, turning both ways the file can refuse to be read into + * the same failure: the provider could not repair it, or Room could not validate it on the + * first query. Neither leaves anything to import, and neither should surface as a raw throw. + */ + private suspend fun withDao( + block: suspend (LegacyElementDao) -> T, + ): Result { + val dao = databaseProvider.get()?.legacyElementDao() + ?: return Result.Failure(LegacyReadFailure.DatabaseUnreadable) + + return runCatching { block(dao) }.fold( + onSuccess = { Result.Success(it) }, + onFailure = { 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/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..95750dd2c --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/di/LegacyDatabaseModule.kt @@ -0,0 +1,30 @@ +package de.davis.keygo.migration.legacy_data.di + +import android.content.Context +import de.davis.keygo.migration.legacy_data.data.local.datasource.LEGACY_DATABASE_NAME +import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabaseProvider +import de.davis.keygo.migration.legacy_data.data.local.datasource.SanitizingLegacyDatabaseProvider +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyDatabaseFiles +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single + +@Module +internal class LegacyDatabaseModule { + + @Single + fun provideLegacyDatabaseProvider(context: Context): LegacyDatabaseProvider = + SanitizingLegacyDatabaseProvider(context) + + @Single + fun provideLegacyDatabaseFiles(context: Context): LegacyDatabaseFiles = + AndroidLegacyDatabaseFiles(context) +} + +private class AndroidLegacyDatabaseFiles( + private val context: Context, +) : LegacyDatabaseFiles { + + override fun exists(): Boolean = context.getDatabasePath(LEGACY_DATABASE_NAME).exists() + + override fun delete(): Boolean = context.deleteDatabase(LEGACY_DATABASE_NAME) +} 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 index 49108b627..29fb92479 100644 --- 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 @@ -4,7 +4,7 @@ import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Configuration import org.koin.core.annotation.Module -@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/model/LegacyMigration.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigration.kt new file mode 100644 index 000000000..e5a863944 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigration.kt @@ -0,0 +1,67 @@ +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 Keystore alias is gone, or the blob does not decrypt under it. */ + Undecryptable, + + /** The blob decrypted but the JSON inside it could not be read. */ + Unparseable, + + /** The JSON carried a `type` that v1 never shipped. */ + UnknownType, + + /** The nested password blob did not decrypt, so the login would have been silently emptied. */ + UndecryptablePassword, + + /** The row converted but the v2 write failed. */ + WriteFailed, +} + +/** + * 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. + */ +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.Undecryptable] 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, +} + +data class LegacyRowFailure( + val legacyId: Long, + val title: String, + val reason: LegacyFailureReason, +) + +data class LegacyMigrationReport( + val migratedItems: Int, + val failures: List, +) { + val hasFailures: Boolean get() = failures.isNotEmpty() +} + +sealed interface LegacyMigrationOutcome { + + /** No legacy file, or a file that turned out not to be v1's. */ + data object NothingToMigrate : LegacyMigrationOutcome + + data class Migrated(val report: LegacyMigrationReport) : LegacyMigrationOutcome + + /** The run could not start or the batch write failed as a whole. Nothing was imported. */ + data class Failed(val cause: Throwable) : LegacyMigrationOutcome +} 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..c87753554 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/repository/LegacyItemRepository.kt @@ -0,0 +1,80 @@ +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 + +/** What the inherited database file turned out to be. */ +internal sealed interface LegacyDatabaseState { + + /** No file at `secure_element_database`. */ + data object Absent : LegacyDatabaseState + + /** + * A file exists but has no `SecureElement` table. On a developer device this is a leftover v2 + * database from before `ItemDatabase` was renamed to `keygo_database`. It holds no v1 data, so + * it is deleted rather than read. + */ + data object NotLegacy : LegacyDatabaseState + + /** + * A file exists but cannot be opened at all: corrupt, truncated, or not a SQLite database. + * + * Kept apart from [NotLegacy] on purpose. A file that will not open tells us nothing about what + * is inside it, and [NotLegacy] is the state that gets the file deleted. A partially restored + * backup deserves to be left where it is, not thrown away on a guess. + */ + data object Unreadable : LegacyDatabaseState + + data object Present : LegacyDatabaseState +} + +internal data class LegacyReadResult( + val items: List, + val failures: List, +) + +/** Locates and removes the legacy database file. Keeps `Context` out of the reader. */ +internal interface LegacyDatabaseFiles { + + fun exists(): Boolean + + /** Deletes the database and its `-wal`, `-shm` and `-journal` siblings. */ + fun delete(): Boolean +} + +/** + * Reads the inherited v1 database. + * + * [state] is the gate and has to be asked first. Everything below it opens the file, and Room + * creates a file it is asked to open, so reading on an install that never had a v1 database would + * leave an empty `secure_element_database` behind for every later run to find. + */ +internal interface LegacyItemRepository { + + suspend fun state(): LegacyDatabaseState + + /** + * Rows the sanitizer repaired to make the inherited file openable at all, so the migration can + * tell the user that some of what it imported was patched up on the way in. + */ + val repairedRows: Int + + /** + * 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]. + */ + 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/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/SanitizingLegacyDatabaseProviderTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/SanitizingLegacyDatabaseProviderTest.kt new file mode 100644 index 000000000..4a0502a0c --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/SanitizingLegacyDatabaseProviderTest.kt @@ -0,0 +1,176 @@ +package de.davis.keygo.migration.legacy_data.data.local + +import android.content.Context +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.execSQL +import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabaseSanitizer +import de.davis.keygo.migration.legacy_data.data.local.datasource.SanitizingLegacyDatabaseProvider +import io.mockk.every +import io.mockk.mockk +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertSame + +/** + * The provider is the only place that can repair the inherited file, because it is the only place + * that still holds it closed. Room runs the 2-to-3 recreate on the first query, and one NULL title + * or blob aborts it and takes every other row down with it, so a repair that lands after the open + * lands too late to be worth anything. + * + * These tests therefore assert on the file itself rather than on what Room later reads out of it: + * that is the only way to see that the repair happened while the file was still at version 2. + */ +class SanitizingLegacyDatabaseProviderTest { + + private val tempDir: File = java.nio.file.Files.createTempDirectory("legacy-provider").toFile() + private val dbFile: File = File(tempDir, "secure_element_database") + + private val json = Json { ignoreUnknownKeys = true } + + /** + * Room and the sanitizer both turn a database name into a path with `Context.getDatabasePath`. + * A fully relaxed mock answers that with an empty path, which would quietly point both of them + * at some other file, so this one has to resolve to the seeded file. + */ + private val context: Context = mockk(relaxed = true).apply { + every { getDatabasePath(any()) } returns dbFile + } + + private fun newProvider() = SanitizingLegacyDatabaseProvider( + context = context, + sanitizer = LegacyDatabaseSanitizer(BundledSQLiteDriver()), + ) + + @AfterTest + fun tearDown() { + tempDir.deleteRecursively() + } + + /** Writes a file that looks exactly like one a v1 build at [version] would have left behind. */ + private fun createDatabase(version: Int): SQLiteConnection { + val schema = json + .parseToJsonElement(File("src/test/resources/legacy-schemas/$version.json").readText()) + .jsonObject + .getValue("database") + .jsonObject + + return BundledSQLiteDriver().open(dbFile.absolutePath).apply { + schema.getValue("entities").jsonArray.forEach { entity -> + val table = entity.jsonObject.getValue("tableName").jsonPrimitive.content + execSQL(entity.jsonObject.createSqlFor(table)) + entity.jsonObject.getValue("indices").jsonArray.forEach { index -> + execSQL(index.jsonObject.createSqlFor(table)) + } + } + schema.getValue("setupQueries").jsonArray.forEach { execSQL(it.jsonPrimitive.content) } + execSQL("PRAGMA user_version = $version") + } + } + + private fun JsonObject.createSqlFor(tableName: String): String = + getValue("createSql").jsonPrimitive.content.replace("\${TABLE_NAME}", tableName) + + private fun titlesOnDisk(): List = + BundledSQLiteDriver().open(dbFile.absolutePath).use { connection -> + connection.prepare("SELECT title FROM SecureElement ORDER BY id").use { stmt -> + buildList { + while (stmt.step()) add(if (stmt.isNull(0)) "" else stmt.getText(0)) + } + } + } + + private fun userVersion(): Int = + BundledSQLiteDriver().open(dbFile.absolutePath).use { connection -> + connection.prepare("PRAGMA user_version").use { stmt -> + stmt.step() + stmt.getLong(0).toInt() + } + } + + @Test + fun `repairs the file before the database is built`() { + createDatabase(2).use { connection -> + connection.execSQL( + "INSERT INTO SecureElement (title, data, type) VALUES (NULL, x'0102', 1)", + ) + } + + val provider = newProvider() + assertNotNull(provider.get()) + + assertEquals(1, provider.repairedRows) + assertEquals(listOf(""), titlesOnDisk()) + assertEquals( + 2, + userVersion(), + "the file must still be at version 2, which is what proves the repair ran before " + + "Room ever touched it", + ) + } + + @Test + fun `reports no repairs for a file that needs none`() { + createDatabase(2).use { connection -> + connection.execSQL( + "INSERT INTO SecureElement (title, data, type) VALUES ('Clean', x'0102', 1)", + ) + } + + val provider = newProvider() + assertNotNull(provider.get()) + + assertEquals(0, provider.repairedRows) + assertEquals(listOf("Clean"), titlesOnDisk()) + } + + /** + * A partially restored backup or a foreign file at this path is an ordinary thing to find on a + * migration path. The sanitizer has no failure channel of its own and throws, so the provider + * is where that has to stop being an exception and start being an answer. + */ + @Test + fun `returns nothing instead of throwing when the file is not a database`() { + dbFile.writeText("this is not a sqlite database, it is a text file that got restored here") + + assertNull(newProvider().get()) + } + + @Test + fun `opens nothing more than once until it is closed`() { + createDatabase(3).close() + val provider = newProvider() + + val first = assertNotNull(provider.get()) + assertSame(first, provider.get()) + + provider.close() + + assertNotSame(first, provider.get()) + } + + /** + * A clean v2 install has no legacy file, and bringing one into existence here would make the + * migration believe it inherited a database. + */ + @Test + fun `creates no file when there is nothing to migrate`() { + val provider = newProvider() + + assertNotNull(provider.get()) + + assertEquals(0, provider.repairedRows) + assertFalse(dbFile.exists(), "opening must never bring a legacy database into existence") + } +} 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..72fbb6529 --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyItemRepositoryImplTest.kt @@ -0,0 +1,332 @@ +package de.davis.keygo.migration.legacy_data.data.repository + +import androidx.room.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import de.davis.keygo.core.util.assertFailure +import de.davis.keygo.core.util.assertSuccess +import de.davis.keygo.migration.legacy_data.data.crypto.LegacyCipher +import de.davis.keygo.migration.legacy_data.data.crypto.LegacyKeyProvider +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.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.domain.model.LegacyDetail +import de.davis.keygo.migration.legacy_data.domain.model.LegacyFailureReason +import de.davis.keygo.migration.legacy_data.domain.model.LegacyReadFailure +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyDatabaseFiles +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyDatabaseState +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Reversible stand-in for the Keystore cipher. A blob prefixed with [FAIL] decrypts to null, which + * is how tests simulate a row whose key is gone without needing a Keystore. + */ +private class FakeLegacyCipher : LegacyCipher { + + override fun decrypt(blob: ByteArray): ByteArray? { + val text = blob.decodeToString() + return if (text.startsWith(FAIL)) null else text.encodeToByteArray() + } + + companion object { + const val FAIL = "!!UNDECRYPTABLE!!" + } +} + +/** + * 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. + */ +private class FakeLegacyKeyProvider( + private val key: SecretKey? = SecretKeySpec(ByteArray(32), "AES"), +) : LegacyKeyProvider { + + var probes: Int = 0 + private set + + override fun secretKey(): SecretKey? { + probes++ + return key + } +} + +/** Hands out one already-open in-memory database, or nothing when the file is unreadable. */ +private class FakeLegacyDatabaseProvider( + private val database: LegacyDatabase?, +) : LegacyDatabaseProvider { + + var closed: Boolean = false + private set + + override var repairedRows: Int = 0 + + override fun get(): LegacyDatabase? = database + + override fun close() { + closed = true + } +} + +private class FakeLegacyDatabaseFiles : LegacyDatabaseFiles { + + var present: Boolean = true + var deleted: Boolean = false + private set + + override fun exists(): Boolean = present + + override fun delete(): Boolean { + deleted = true + present = false + return true + } +} + +class LegacyItemRepositoryImplTest { + + private lateinit var db: LegacyDatabase + private lateinit var keyProvider: FakeLegacyKeyProvider + private lateinit var databaseProvider: FakeLegacyDatabaseProvider + private lateinit var databaseFiles: FakeLegacyDatabaseFiles + private lateinit var repository: LegacyItemRepositoryImpl + + @BeforeTest + fun setUp() { + db = Room.inMemoryDatabaseBuilder(mockk(relaxed = true), LegacyDatabase::class.java) + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.IO) + .build() + keyProvider = FakeLegacyKeyProvider() + databaseProvider = FakeLegacyDatabaseProvider(db) + databaseFiles = FakeLegacyDatabaseFiles() + repository = newRepository() + } + + @AfterTest + fun tearDown() { + db.close() + } + + private fun newRepository() = LegacyItemRepositoryImpl( + databaseProvider = databaseProvider, + keyProvider = keyProvider, + cipher = FakeLegacyCipher(), + parser = LegacyDetailParser(), + databaseFiles = databaseFiles, + ) + + 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.Undecryptable, 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.Unparseable, + 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.Unparseable, + repository.readAll().assertSuccess().failures.single().reason, + ) + } + + @Test + fun `prune removes only the given rows`() = runTest { + val keep = 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()) + assertTrue(keep > 0) + } + + @Test + fun `reads an empty database as no items and no failures`() = runTest { + val result = repository.readAll().assertSuccess() + + assertTrue(result.items.isEmpty()) + assertTrue(result.failures.isEmpty()) + 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 `Undecryptable` 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}""") + keyProvider = FakeLegacyKeyProvider(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, keyProvider.probes) + } + + /** + * The provider hands back nothing when the file is corrupt or is not a database at all. That + * has to arrive as a failure the migration can report, never as an exception escaping into the + * unlock flow. + */ + @Test + fun `reports an unreadable database instead of throwing`() = runTest { + databaseProvider = FakeLegacyDatabaseProvider(database = null) + + assertEquals( + LegacyReadFailure.DatabaseUnreadable, + newRepository().readAll().assertFailure(), + ) + } + + @Test + fun `state is absent when there is no legacy file`() = runTest { + databaseFiles.present = false + + assertEquals(LegacyDatabaseState.Absent, repository.state()) + } + + @Test + fun `state is present when the file opens as a v1 database`() = runTest { + insert(title = "Example", json = """{"type":1}""") + + assertEquals(LegacyDatabaseState.Present, repository.state()) + } + + /** + * Unreadable rather than [LegacyDatabaseState.NotLegacy] on purpose: a file that cannot be + * opened tells us nothing about what is inside it, and NotLegacy is the state that gets the + * file deleted. + */ + @Test + fun `state is unreadable when the file cannot be opened`() = runTest { + databaseProvider = FakeLegacyDatabaseProvider(database = null) + + assertEquals(LegacyDatabaseState.Unreadable, newRepository().state()) + } + + @Test + fun `deleteDatabase closes the open handle before removing the file`() = runTest { + assertTrue(repository.deleteDatabase()) + + assertTrue(databaseProvider.closed, "the file cannot be deleted from under an open handle") + assertTrue(databaseFiles.deleted) + } + + @Test + fun `reports the rows the sanitizer repaired`() = runTest { + databaseProvider.repairedRows = 4 + + assertEquals(4, repository.repairedRows) + } +} From d759c036ca663d0495a3928cc03d0ff310448a4f Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 28 Jul 2026 06:50:29 +0200 Subject: [PATCH 13/31] fix(migration): never create the legacy database file The legacy reader only ever reads a database it inherited, but 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 and every later run would find it and treat it as inherited data. This was a KDoc contract on LegacyItemRepository that callers had to remember. Make it true by construction instead: the provider returns null when the file is not there, next to the sanitizer call, since both guards are about the file's state before Room is allowed to touch it. state() keeps asking the file first so a clean install reads as Absent rather than Unreadable, which matters because those lead to opposite places. The provider gained a driver seam, left null in production so Room keeps the framework helper it has always used. Without it Room cannot create a file under a JVM test at all, and the test asserting no file appears would hold whether or not the guard existed. Co-Authored-By: Claude Opus 5 --- .../data/local/datasource/LegacyDatabase.kt | 10 ++- .../SanitizingLegacyDatabaseProvider.kt | 24 +++++- .../repository/LegacyItemRepositoryImpl.kt | 7 +- .../SanitizingLegacyDatabaseProviderTest.kt | 8 +- .../LegacyItemRepositoryImplTest.kt | 78 +++++++++++++++++++ 5 files changed, 116 insertions(+), 11 deletions(-) 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 index 099b9714e..5b47c3228 100644 --- 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 @@ -59,9 +59,13 @@ internal abstract class LegacyDatabase : RoomDatabase() { internal interface LegacyDatabaseProvider { /** - * Returns null when the file cannot be made openable, which means it is corrupt, truncated, or - * not a SQLite database at all. Null rather than a throw, because a foreign file sitting where - * v1's used to be is an ordinary thing to find on a migration path and the caller reports it. + * Returns null when there is no file to open, or when the file that is there cannot be made + * openable because it is corrupt, truncated, or not a SQLite database at all. Null rather than + * a throw, because a foreign file sitting where v1's used to be is an ordinary thing to find on + * a migration path and the caller reports it. + * + * 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? diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/SanitizingLegacyDatabaseProvider.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/SanitizingLegacyDatabaseProvider.kt index cfec93c95..941b27fa9 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/SanitizingLegacyDatabaseProvider.kt +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/SanitizingLegacyDatabaseProvider.kt @@ -2,19 +2,29 @@ package de.davis.keygo.migration.legacy_data.data.local.datasource import android.content.Context import androidx.room.Room +import androidx.sqlite.SQLiteDriver /** - * Repairs the inherited file, then opens it. + * Repairs the inherited file, then opens it. Never creates one. * * The order is the whole point. Room runs the 2-to-3 recreate on the first query, and a single row * carrying a NULL `title` or `data` aborts it and takes every other row in the file down with it. * The sanitizer is the only thing standing between one bad row and the user losing all of them, and * it can only do its work while the file is still closed, so it runs here rather than anywhere * further in. It is cheap and self-guarding on a file that needs nothing, so every open pays for it. + * + * Both of this class's guards are about the file's state before Room is allowed to touch it, which + * is why they sit together: one repairs what is there, the other refuses to invent what is not. + */ +/** + * @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 SanitizingLegacyDatabaseProvider( private val context: Context, private val sanitizer: LegacyDatabaseSanitizer = LegacyDatabaseSanitizer(), + private val driver: SQLiteDriver? = null, ) : LegacyDatabaseProvider { private var database: LegacyDatabase? = null @@ -30,8 +40,17 @@ internal class SanitizingLegacyDatabaseProvider( override fun get(): LegacyDatabase? { database?.let { return it } + val path = context.getDatabasePath(LEGACY_DATABASE_NAME) + // This module only ever reads a database it inherited, and 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. There is nothing to read here, so + // there is nothing to open. The sanitizer no-ops on a missing file rather than reporting + // one, so this is the guard that has to stop the builder. + if (!path.exists()) return null + val repaired = try { - sanitizer.sanitize(context.getDatabasePath(LEGACY_DATABASE_NAME).absolutePath) + sanitizer.sanitize(path.absolutePath) } catch (_: Exception) { // The sanitizer has no failure channel of its own: its guards cover a missing file, an // already migrated one and a non-v1 one, and everything else it meets it opens. A @@ -43,6 +62,7 @@ internal class SanitizingLegacyDatabaseProvider( repairedRows += repaired return Room.databaseBuilder(context, LegacyDatabase::class.java, LEGACY_DATABASE_NAME) + .apply { driver?.let(::setDriver) } .build() .also { database = it } } 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 index b549313e2..9e4e287bb 100644 --- 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 @@ -33,9 +33,12 @@ internal class LegacyItemRepositoryImpl( override val repairedRows: Int get() = databaseProvider.repairedRows override suspend fun state(): LegacyDatabaseState { + // Asked first, and of the file rather than of the provider. The provider also comes back + // empty for a file that is simply not there, and `Absent` and `Unreadable` lead to opposite + // places: one is a clean install with nothing to do, the other is a file we must not touch. if (!databaseFiles.exists()) return LegacyDatabaseState.Absent - // The provider only comes back empty when the file could not even be repaired, so this is - // the one place `Unreadable` can be told apart from a file Room simply does not recognise. + // Past that check the provider can only come back empty for a file it could not repair, so + // this is the one place `Unreadable` can be told apart from a file Room does not recognise. if (databaseProvider.get() == null) return LegacyDatabaseState.Unreadable // Room opens the file on the first query rather than when the object is built, so querying diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/SanitizingLegacyDatabaseProviderTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/SanitizingLegacyDatabaseProviderTest.kt index 4a0502a0c..68100d06b 100644 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/SanitizingLegacyDatabaseProviderTest.kt +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/SanitizingLegacyDatabaseProviderTest.kt @@ -161,14 +161,14 @@ class SanitizingLegacyDatabaseProviderTest { } /** - * A clean v2 install has no legacy file, and bringing one into existence here would make the - * migration believe it inherited a database. + * A clean v2 install has no legacy file. Room creates any file it is asked to open, so opening + * one here would make every later run believe it inherited a database from v1. */ @Test - fun `creates no file when there is nothing to migrate`() { + fun `opens nothing and creates nothing when there is no file`() { val provider = newProvider() - assertNotNull(provider.get()) + assertNull(provider.get()) assertEquals(0, provider.repairedRows) assertFalse(dbFile.exists(), "opening must never bring a legacy database into existence") 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 index 72fbb6529..d5ec6dbe6 100644 --- 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 @@ -1,5 +1,6 @@ package de.davis.keygo.migration.legacy_data.data.repository +import android.content.Context import androidx.room.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import de.davis.keygo.core.util.assertFailure @@ -7,8 +8,11 @@ import de.davis.keygo.core.util.assertSuccess import de.davis.keygo.migration.legacy_data.data.crypto.LegacyCipher import de.davis.keygo.migration.legacy_data.data.crypto.LegacyKeyProvider import de.davis.keygo.migration.legacy_data.data.json.LegacyDetailParser +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.datasource.LegacyDatabaseSanitizer +import de.davis.keygo.migration.legacy_data.data.local.datasource.SanitizingLegacyDatabaseProvider 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 @@ -18,15 +22,18 @@ import de.davis.keygo.migration.legacy_data.domain.model.LegacyFailureReason import de.davis.keygo.migration.legacy_data.domain.model.LegacyReadFailure import de.davis.keygo.migration.legacy_data.domain.repository.LegacyDatabaseFiles import de.davis.keygo.migration.legacy_data.domain.repository.LegacyDatabaseState +import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.runTest +import java.io.File import javax.crypto.SecretKey import javax.crypto.spec.SecretKeySpec import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue @@ -81,6 +88,14 @@ private class FakeLegacyDatabaseProvider( } } +/** Answers from the real filesystem, so an empty directory models a clean install faithfully. */ +private class FileBackedLegacyDatabaseFiles(private val file: File) : LegacyDatabaseFiles { + + override fun exists(): Boolean = file.exists() + + override fun delete(): Boolean = file.delete() +} + private class FakeLegacyDatabaseFiles : LegacyDatabaseFiles { var present: Boolean = true @@ -104,6 +119,37 @@ class LegacyItemRepositoryImplTest { private lateinit var databaseFiles: FakeLegacyDatabaseFiles private lateinit var repository: LegacyItemRepositoryImpl + /** + * A real path inside an empty directory, which is exactly what a clean v2 install looks like: + * no `secure_element_database`, and nothing allowed to bring one into being. + */ + private val tempDir: File = + java.nio.file.Files.createTempDirectory("legacy-clean-install").toFile() + private val cleanInstallFile: File = File(tempDir, LEGACY_DATABASE_NAME) + private val cleanInstallContext: Context = mockk(relaxed = true).apply { + every { getDatabasePath(any()) } returns cleanInstallFile + } + + /** + * Wired over the real provider and a real path rather than the in-memory database, because what + * is under test is whether a file turns up on disk. A fake provider 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 cleanInstallRepository() = LegacyItemRepositoryImpl( + databaseProvider = SanitizingLegacyDatabaseProvider( + context = cleanInstallContext, + sanitizer = LegacyDatabaseSanitizer(BundledSQLiteDriver()), + driver = BundledSQLiteDriver(), + ), + keyProvider = keyProvider, + cipher = FakeLegacyCipher(), + parser = LegacyDetailParser(), + databaseFiles = FileBackedLegacyDatabaseFiles(cleanInstallFile), + ) + @BeforeTest fun setUp() { db = Room.inMemoryDatabaseBuilder(mockk(relaxed = true), LegacyDatabase::class.java) @@ -119,6 +165,7 @@ class LegacyItemRepositoryImplTest { @AfterTest fun tearDown() { db.close() + tempDir.deleteRecursively() } private fun newRepository() = LegacyItemRepositoryImpl( @@ -296,6 +343,37 @@ class LegacyItemRepositoryImplTest { assertEquals(LegacyDatabaseState.Absent, repository.state()) } + /** + * `Absent` and not `NotLegacy`, because the two lead to opposite places: `NotLegacy` gets the + * file deleted, and deleting a file that was never there is at best confusing. Nor `Unreadable`, + * which means a file we must leave alone. + */ + @Test + fun `state is absent on a clean install`() = runTest { + assertEquals(LegacyDatabaseState.Absent, cleanInstallRepository().state()) + } + + /** + * 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. + * + * The read goes through `readAll` rather than `state`, because `state` is the caller's gate and + * a gate nobody is forced through is not a guarantee. Asserting the disk rather than the return + * value is the other half: the return value looks the same either way. + */ + @Test + fun `reading on a clean install leaves no legacy file behind`() = runTest { + val result = cleanInstallRepository().readAll() + + assertFalse( + cleanInstallFile.exists(), + "reading must never bring a legacy database into existence", + ) + assertEquals(LegacyReadFailure.DatabaseUnreadable, result.assertFailure()) + } + @Test fun `state is present when the file opens as a v1 database`() = runTest { insert(title = "Example", json = """{"type":1}""") From fb58ca96811b119e09855d4ee13ebc28e9eedfd0 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 28 Jul 2026 06:52:26 +0200 Subject: [PATCH 14/31] docs(migration): fold the driver param into the class KDoc Two stacked KDoc blocks orphaned the class documentation; only the @param block was attached to the declaration. Co-Authored-By: Claude Opus 5 --- .../data/local/datasource/SanitizingLegacyDatabaseProvider.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/SanitizingLegacyDatabaseProvider.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/SanitizingLegacyDatabaseProvider.kt index 941b27fa9..4d55bb8e8 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/SanitizingLegacyDatabaseProvider.kt +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/SanitizingLegacyDatabaseProvider.kt @@ -15,8 +15,7 @@ import androidx.sqlite.SQLiteDriver * * Both of this class's guards are about the file's state before Room is allowed to touch it, which * is why they sit together: one repairs what is there, the other refuses to invent what is not. - */ -/** + * * @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. From 0039da50fc9bbc5b0977d1a5df93259f5f4304d0 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 28 Jul 2026 11:21:12 +0200 Subject: [PATCH 15/31] fix(migration): base state() on the probe's answer, not a query failure state() no longer folds every withDao query failure into NotLegacy. NotLegacy now rests only on LegacySecureElementProbe positively answering false that the SecureElement table is absent; a query failure (corrupt page, a disk that fills mid-recreate, a cancelled run) reports Unreadable instead, so a file that was never shown to be free of v1 data can no longer be deleted on a guess. The probe's hasSecureElementTable()/selectInt() extensions are lifted out of LegacyDatabaseSanitizer into a shared LegacySecureElementProbe so the sanitizer and the repository can never drift into disagreeing about what a v1 file is. Also: withDao rethrows CancellationException instead of folding it into DatabaseUnreadable through runCatching, so a run cancelled by the unlock scope going away no longer answers for what is on disk. SanitizingLegacyDatabaseProvider.repairedRows is marked @Volatile since it is written under a lock but read through a plain getter from another thread. LegacyReadFailure is narrowed to internal. Deduplicates the schema-seeding test helper that was copy-pasted across LegacyDatabaseOpenTest, LegacyDatabaseSanitizerTest and SanitizingLegacyDatabaseProviderTest into one LegacySchemaSeed.kt, and adds LegacySecureElementProbeTest plus new LegacyItemRepositoryImplTest coverage for the state()/cancellation/deleteDatabase-ordering behaviour above. Co-Authored-By: Claude Opus 5 --- .../datasource/LegacyDatabaseSanitizer.kt | 15 -- .../datasource/LegacySecureElementProbe.kt | 76 +++++++++ .../SanitizingLegacyDatabaseProvider.kt | 3 + .../repository/LegacyItemRepositoryImpl.kt | 46 ++++-- .../legacy_data/di/LegacyDatabaseModule.kt | 6 + .../domain/model/LegacyMigration.kt | 2 +- .../domain/repository/LegacyItemRepository.kt | 10 +- .../data/local/LegacyDatabaseOpenTest.kt | 49 +----- .../data/local/LegacyDatabaseSanitizerTest.kt | 32 +--- .../data/local/LegacySchemaSeed.kt | 58 +++++++ .../local/LegacySecureElementProbeTest.kt | 110 +++++++++++++ .../SanitizingLegacyDatabaseProviderTest.kt | 42 +---- .../LegacyItemRepositoryImplTest.kt | 155 ++++++++++++++++-- 13 files changed, 442 insertions(+), 162 deletions(-) create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacySecureElementProbe.kt create mode 100644 migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacySchemaSeed.kt create mode 100644 migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacySecureElementProbeTest.kt diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabaseSanitizer.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabaseSanitizer.kt index cd75262cb..c09458505 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabaseSanitizer.kt +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabaseSanitizer.kt @@ -57,25 +57,10 @@ internal class LegacyDatabaseSanitizer( private fun SQLiteConnection.userVersion(): Int = selectInt("PRAGMA user_version") - // Inlined rather than parameterized: the one call site is a compile-time constant, and a - // private helper that only ever checks for "SecureElement" does not need a String parameter - // that invites an unescaped literal later. - private fun SQLiteConnection.hasSecureElementTable(): Boolean = - selectInt( - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'SecureElement'", - ) > 0 - /** * Counted before the updates rather than summed from their change counts, so a row that is null * in both columns is reported once instead of twice. */ private fun SQLiteConnection.countRepairableRows(): Int = selectInt("SELECT COUNT(*) FROM SecureElement WHERE title IS NULL OR data IS NULL") - - /** Every call site is a COUNT(*) or a PRAGMA, both of which always return exactly one row. */ - private fun SQLiteConnection.selectInt(sql: String): Int = - prepare(sql).use { statement -> - check(statement.step()) { "expected a row from: $sql" } - statement.getLong(0).toInt() - } } diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacySecureElementProbe.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacySecureElementProbe.kt new file mode 100644 index 000000000..49cdf24b0 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacySecureElementProbe.kt @@ -0,0 +1,76 @@ +package de.davis.keygo.migration.legacy_data.data.local.datasource + +import android.content.Context +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import androidx.sqlite.driver.AndroidSQLiteDriver + +/** + * Answers the one question that can prove an inherited file holds no v1 data: is v1's + * `SecureElement` table there? + * + * Named and kept apart because that answer is what gets the file deleted. It has to be asked of the + * closed file, before Room is allowed to run the 2-to-3 recreate, and it has to be asked directly. + * Inferring it from a failed query cannot work: a corrupt page, a disk that filled up during the + * recreate and a cancelled run all fail the same way a foreign file does, and reading any of those + * as "not v1's" throws the user's only copy of their data away on a guess. + */ +internal interface LegacySecureElementProbe { + + /** + * True when the table is there, false when the file opened and the table provably is not, and + * null when the file could not be inspected at all. + * + * Null is not a no. A file that will not open tells us nothing about what is inside it, so a + * caller deciding whether to delete has to treat null the way it treats true. + */ + fun hasSecureElementTable(): Boolean? +} + +/** + * Reads the answer off the file itself, with no Room in the way. + * + * @param driver left at the framework helper in production, which is what the rest of this module + * opens files with. A JVM test has no framework helper available, so it passes a bundled driver. + */ +internal class AndroidLegacySecureElementProbe( + private val context: Context, + private val driver: SQLiteDriver = AndroidSQLiteDriver(), +) : LegacySecureElementProbe { + + override fun hasSecureElementTable(): Boolean? { + val path = context.getDatabasePath(LEGACY_DATABASE_NAME) + // Opening read-write creates the file, which would make a clean v2 install look like it + // inherited a legacy database. Null rather than false, because a file that is not there + // says nothing about the file this probe was asked about. + if (!path.exists()) return null + + return try { + driver.open(path.absolutePath).use { it.hasSecureElementTable() } + } catch (_: Exception) { + // Corrupt, truncated, or not a SQLite database at all. Nothing was learned about what + // is inside it, and answering "no table" here is exactly the guess that costs a user + // with a half restored backup everything they had. + null + } + } +} + +/** + * Shared with [LegacyDatabaseSanitizer], which asks the same question of a connection it is already + * holding open. One copy, so the two can never drift into disagreeing about what a v1 file is. + * + * Not parameterized by table name: the callers are compile-time constants, and a String parameter + * here would invite an unescaped literal later. + */ +internal fun SQLiteConnection.hasSecureElementTable(): Boolean = + selectInt( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'SecureElement'", + ) > 0 + +/** Every call site is a COUNT(*) or a PRAGMA, both of which always return exactly one row. */ +internal fun SQLiteConnection.selectInt(sql: String): Int = + prepare(sql).use { statement -> + check(statement.step()) { "expected a row from: $sql" } + statement.getLong(0).toInt() + } diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/SanitizingLegacyDatabaseProvider.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/SanitizingLegacyDatabaseProvider.kt index 4d55bb8e8..c084f614d 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/SanitizingLegacyDatabaseProvider.kt +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/SanitizingLegacyDatabaseProvider.kt @@ -28,6 +28,9 @@ internal class SanitizingLegacyDatabaseProvider( private var database: LegacyDatabase? = null + // Written under the lock in `get` but read through a plain getter from whatever thread reports + // the migration, so the write needs to be visible without the reader taking the lock too. + @Volatile override var repairedRows: Int = 0 private set 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 index 9e4e287bb..6c1b880f9 100644 --- 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 @@ -9,6 +9,7 @@ import de.davis.keygo.migration.legacy_data.data.crypto.LegacyKeyProvider 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.datasource.LegacySecureElementProbe 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.model.LegacyFailureReason @@ -20,10 +21,12 @@ import de.davis.keygo.migration.legacy_data.domain.repository.LegacyDatabaseStat import de.davis.keygo.migration.legacy_data.domain.repository.LegacyItemRepository import de.davis.keygo.migration.legacy_data.domain.repository.LegacyReadResult import org.koin.core.annotation.Single +import kotlin.coroutines.cancellation.CancellationException @Single internal class LegacyItemRepositoryImpl( private val databaseProvider: LegacyDatabaseProvider, + private val secureElementProbe: LegacySecureElementProbe, private val keyProvider: LegacyKeyProvider, private val cipher: LegacyCipher, private val parser: LegacyDetailParser, @@ -37,30 +40,41 @@ internal class LegacyItemRepositoryImpl( // empty for a file that is simply not there, and `Absent` and `Unreadable` lead to opposite // places: one is a clean install with nothing to do, the other is a file we must not touch. if (!databaseFiles.exists()) return LegacyDatabaseState.Absent - // Past that check the provider can only come back empty for a file it could not repair, so - // this is the one place `Unreadable` can be told apart from a file Room does not recognise. + + // NotLegacy is the verdict that deletes the file, so it has to rest on evidence rather than + // on an inference from something going wrong. The probe reads the closed file and answers + // the only question that proves there is no v1 data in it. `== false` and not `!= true` on + // purpose: a file the probe could not read at all answers null, and null is not a no. + if (secureElementProbe.hasSecureElementTable() == false) + return LegacyDatabaseState.NotLegacy + + // Past that check the provider can only come back empty for a file it could not repair. if (databaseProvider.get() == null) return LegacyDatabaseState.Unreadable // Room opens the file on the first query rather than when the object is built, so querying - // is the probe. A leftover v2 database from before `ItemDatabase` was renamed has no - // SecureElement table, Room refuses to validate it, and that is what NotLegacy means. The - // null check above has already taken the unreadable case out of this branch. + // is what turns up everything else that can go wrong: a corrupt page, a disk that fills up + // during the 2-to-3 recreate, a table Room refuses to validate. None of those say the file + // holds no v1 data, so none of them may end up at NotLegacy. return withDao { it.count() }.fold( onSuccess = { LegacyDatabaseState.Present }, - onFailure = { LegacyDatabaseState.NotLegacy }, + onFailure = { LegacyDatabaseState.Unreadable }, ) } override suspend fun readAll(): Result = resultBinding { - val rows = withDao { it.getAllWithTags() }.bind() - // Probed once for the whole run, never once per row. `LegacyCipher.decrypt` folds four // different failures into one null, and a gone alias is the only one of them that says // nothing in this file is recoverable. Letting it arrive as one Undecryptable per row would // tell the user every entry was individually damaged when the entries are intact, and it // could not be told apart from a file that really is corrupt end to end. + // + // Asked before the rows are read, because reading them is the first query and the first + // query is what runs the 2-to-3 recreate. That rewrite is a one-way door, and a run that + // cannot decrypt a single blob has no business putting the user's file through it. keyProvider.secretKey().asResult(LegacyReadFailure.KeyUnavailable).bind() + val rows = withDao { it.getAllWithTags() }.bind() + val items = mutableListOf() val failures = mutableListOf() @@ -106,10 +120,18 @@ internal class LegacyItemRepositoryImpl( val dao = databaseProvider.get()?.legacyElementDao() ?: return Result.Failure(LegacyReadFailure.DatabaseUnreadable) - return runCatching { block(dao) }.fold( - onSuccess = { Result.Success(it) }, - onFailure = { Result.Failure(LegacyReadFailure.DatabaseUnreadable) }, - ) + return try { + Result.Success(block(dao)) + } catch (e: CancellationException) { + // Deliberately not `runCatching`, and deliberately unlike the other repositories in + // this codebase. There a swallowed cancellation becomes a harmless failure; here it + // becomes 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. Leave this rethrow where it is. + throw e + } catch (_: Exception) { + Result.Failure(LegacyReadFailure.DatabaseUnreadable) + } } private fun LegacyElementWithTags.failure(reason: LegacyFailureReason) = 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 index 95750dd2c..de98b429f 100644 --- 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 @@ -1,8 +1,10 @@ package de.davis.keygo.migration.legacy_data.di import android.content.Context +import de.davis.keygo.migration.legacy_data.data.local.datasource.AndroidLegacySecureElementProbe import de.davis.keygo.migration.legacy_data.data.local.datasource.LEGACY_DATABASE_NAME import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabaseProvider +import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacySecureElementProbe import de.davis.keygo.migration.legacy_data.data.local.datasource.SanitizingLegacyDatabaseProvider import de.davis.keygo.migration.legacy_data.domain.repository.LegacyDatabaseFiles import org.koin.core.annotation.Module @@ -15,6 +17,10 @@ internal class LegacyDatabaseModule { fun provideLegacyDatabaseProvider(context: Context): LegacyDatabaseProvider = SanitizingLegacyDatabaseProvider(context) + @Single + fun provideLegacySecureElementProbe(context: Context): LegacySecureElementProbe = + AndroidLegacySecureElementProbe(context) + @Single fun provideLegacyDatabaseFiles(context: Context): LegacyDatabaseFiles = AndroidLegacyDatabaseFiles(context) diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigration.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigration.kt index e5a863944..b8049da41 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigration.kt +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigration.kt @@ -23,7 +23,7 @@ enum class LegacyFailureReason { * was found. That is what separates these from [LegacyFailureReason], which is always about one row * among many that were read fine. */ -enum class LegacyReadFailure { +internal enum class LegacyReadFailure { /** * v1's Keystore alias is gone, so no blob in the file can ever be decrypted. 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 index c87753554..8c89761cf 100644 --- 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 @@ -12,9 +12,13 @@ internal sealed interface LegacyDatabaseState { data object Absent : LegacyDatabaseState /** - * A file exists but has no `SecureElement` table. On a developer device this is a leftover v2 - * database from before `ItemDatabase` was renamed to `keygo_database`. It holds no v1 data, so - * it is deleted rather than read. + * A file exists, opened, and provably has no `SecureElement` table. On a developer device this + * is a leftover v2 database from before `ItemDatabase` was renamed to `keygo_database`. It + * holds no v1 data, so it is deleted rather than read. + * + * "Provably" is the whole of it. This is the one state that destroys the file, so it is only + * ever reached by looking for the table and not finding it, never by reading a failure as if it + * meant the table was gone. */ data object NotLegacy : LegacyDatabaseState 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 index 6dd52fcef..97c46d785 100644 --- 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 @@ -11,11 +11,6 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import java.io.File import kotlin.test.AfterTest import kotlin.test.Test @@ -28,19 +23,14 @@ 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 generated auto-migrations carry it forward to version 3. * - * The seed files are built from v1's own exported schema JSON rather than with Room's - * `MigrationTestHelper`. Every constructor of the helper's Android artifact takes an - * `android.app.Instrumentation`, so it cannot run on a plain JVM unit test. Replaying `createSql` - * and `setupQueries` out of the pristine schema is what the helper does anyway, and it keeps the - * seed honest: it comes from v1's export, not from the ported entities under test. + * 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. */ class LegacyDatabaseOpenTest { private val tempDir: File = createTempDir() private val dbFile: File = File(tempDir, "secure_element_database") - private val json = Json { ignoreUnknownKeys = true } - /** * Room turns a database name into a path with `Context.getDatabasePath`. A fully relaxed mock * answers that with an empty path, which makes Room quietly open some other file and report an @@ -50,29 +40,8 @@ class LegacyDatabaseOpenTest { every { getDatabasePath(any()) } returns dbFile } - /** Writes a file that looks exactly like one a v1 build at [version] would have left behind. */ - private fun createDatabase(version: Int): SQLiteConnection { - val schema = json - .parseToJsonElement(File("src/test/resources/legacy-schemas/$version.json").readText()) - .jsonObject - .getValue("database") - .jsonObject - - return BundledSQLiteDriver().open(dbFile.absolutePath).apply { - schema.getValue("entities").jsonArray.forEach { entity -> - val table = entity.jsonObject.getValue("tableName").jsonPrimitive.content - execSQL(entity.jsonObject.createSqlFor(table)) - entity.jsonObject.getValue("indices").jsonArray.forEach { index -> - execSQL(index.jsonObject.createSqlFor(table)) - } - } - schema.getValue("setupQueries").jsonArray.forEach { execSQL(it.jsonPrimitive.content) } - execSQL("PRAGMA user_version = $version") - } - } - - private fun JsonObject.createSqlFor(tableName: String): String = - getValue("createSql").jsonPrimitive.content.replace("\${TABLE_NAME}", tableName) + private fun createDatabase(version: Int): SQLiteConnection = + seedLegacyDatabase(dbFile, version) private fun openMigrated(): LegacyDatabase = Room.databaseBuilder(context, LegacyDatabase::class.java, dbFile.name) @@ -246,20 +215,12 @@ class LegacyDatabaseOpenTest { assertEquals( 2, - userVersion(), + userVersionOf(dbFile), "the sanitizer's below-version-3 guard depends on this: a file whose recreate " + "aborted must still read as version 2 for a retry to repair it", ) } - private fun userVersion(): Int = - BundledSQLiteDriver().open(dbFile.absolutePath).use { connection -> - connection.prepare("PRAGMA user_version").use { stmt -> - stmt.step() - stmt.getLong(0).toInt() - } - } - @Test fun `unsanitized null data makes the file unopenable`() = runTest { createDatabase(2).use { connection -> diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseSanitizerTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseSanitizerTest.kt index 2b841d3d9..28b36b812 100644 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseSanitizerTest.kt +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseSanitizerTest.kt @@ -11,11 +11,6 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import java.io.File import kotlin.test.AfterTest import kotlin.test.Test @@ -34,8 +29,6 @@ class LegacyDatabaseSanitizerTest { private val tempDir: File = java.nio.file.Files.createTempDirectory("legacy-sanitize").toFile() private val dbFile: File = File(tempDir, "secure_element_database") - private val json = Json { ignoreUnknownKeys = true } - private val sanitizer = LegacyDatabaseSanitizer(BundledSQLiteDriver()) private val context: Context = mockk(relaxed = true).apply { @@ -47,29 +40,8 @@ class LegacyDatabaseSanitizerTest { tempDir.deleteRecursively() } - /** Writes a file that looks exactly like one a v1 build at [version] would have left behind. */ - private fun createDatabase(version: Int): SQLiteConnection { - val schema = json - .parseToJsonElement(File("src/test/resources/legacy-schemas/$version.json").readText()) - .jsonObject - .getValue("database") - .jsonObject - - return BundledSQLiteDriver().open(dbFile.absolutePath).apply { - schema.getValue("entities").jsonArray.forEach { entity -> - val table = entity.jsonObject.getValue("tableName").jsonPrimitive.content - execSQL(entity.jsonObject.createSqlFor(table)) - entity.jsonObject.getValue("indices").jsonArray.forEach { index -> - execSQL(index.jsonObject.createSqlFor(table)) - } - } - schema.getValue("setupQueries").jsonArray.forEach { execSQL(it.jsonPrimitive.content) } - execSQL("PRAGMA user_version = $version") - } - } - - private fun JsonObject.createSqlFor(tableName: String): String = - getValue("createSql").jsonPrimitive.content.replace("\${TABLE_NAME}", tableName) + private fun createDatabase(version: Int): SQLiteConnection = + seedLegacyDatabase(dbFile, version) private fun openMigrated(): LegacyDatabase = Room.databaseBuilder(context, LegacyDatabase::class.java, dbFile.name) 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..f0f45cbd7 --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacySchemaSeed.kt @@ -0,0 +1,58 @@ +package de.davis.keygo.migration.legacy_data.data.local + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.execSQL +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File + +private val json = Json { ignoreUnknownKeys = true } + +/** + * 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. + * + * Built by replaying v1's own exported schema JSON rather than with Room's `MigrationTestHelper`, + * whose Android artifact takes an `android.app.Instrumentation` in every constructor and so cannot + * run on a plain JVM test. Replaying `createSql` and `setupQueries` is what the helper does anyway, + * and it keeps the seed honest: it comes from v1's export, not from the ported entities under test. + * + * One copy for the whole test source set. The shape of that JSON is a detail every seeding test + * would otherwise have to know, and three copies of it are three places to update when one of them + * is wrong. + */ +internal fun seedLegacyDatabase(file: File, version: Int): SQLiteConnection { + val schema = json + .parseToJsonElement(File("src/test/resources/legacy-schemas/$version.json").readText()) + .jsonObject + .getValue("database") + .jsonObject + + return BundledSQLiteDriver().open(file.absolutePath).apply { + schema.getValue("entities").jsonArray.forEach { entity -> + val table = entity.jsonObject.getValue("tableName").jsonPrimitive.content + execSQL(entity.jsonObject.createSqlFor(table)) + entity.jsonObject.getValue("indices").jsonArray.forEach { index -> + execSQL(index.jsonObject.createSqlFor(table)) + } + } + schema.getValue("setupQueries").jsonArray.forEach { execSQL(it.jsonPrimitive.content) } + execSQL("PRAGMA user_version = $version") + } +} + +private fun JsonObject.createSqlFor(tableName: String): String = + getValue("createSql").jsonPrimitive.content.replace("\${TABLE_NAME}", tableName) + +/** 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/LegacySecureElementProbeTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacySecureElementProbeTest.kt new file mode 100644 index 000000000..11b22e486 --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacySecureElementProbeTest.kt @@ -0,0 +1,110 @@ +package de.davis.keygo.migration.legacy_data.data.local + +import android.content.Context +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.execSQL +import de.davis.keygo.migration.legacy_data.data.local.datasource.AndroidLegacySecureElementProbe +import io.mockk.every +import io.mockk.mockk +import java.io.File +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The probe is what stands between a file that cannot be read and a file that gets deleted, so what + * matters here is which answers it is willing to give. False is the destructive one, and only a + * file it actually opened and looked inside may produce it. + */ +class LegacySecureElementProbeTest { + + private val tempDir: File = java.nio.file.Files.createTempDirectory("legacy-probe").toFile() + private val dbFile: File = File(tempDir, "secure_element_database") + + /** + * The probe turns the database name into a path with `Context.getDatabasePath`. A fully relaxed + * mock answers that with an empty path, which would point it at some other file entirely. + */ + private val context: Context = mockk(relaxed = true).apply { + every { getDatabasePath(any()) } returns dbFile + } + + private val probe = AndroidLegacySecureElementProbe(context, BundledSQLiteDriver()) + + @AfterTest + fun tearDown() { + tempDir.deleteRecursively() + } + + @Test + fun `finds the table in a file a v1 build left behind`() { + seedLegacyDatabase(dbFile, version = 3).close() + + assertEquals(true, probe.hasSecureElementTable()) + } + + @Test + fun `finds the table in the oldest v1 file, before either auto-migration`() { + seedLegacyDatabase(dbFile, version = 1).close() + + assertEquals(true, probe.hasSecureElementTable()) + assertEquals(1, userVersionOf(dbFile), "the probe must not migrate the file it reads") + } + + /** + * The leftover v2 database from before `ItemDatabase` was renamed. This is the only answer that + * gets a file deleted, and this is the only shape of file allowed to produce it. + */ + @Test + fun `reports no table for a database that is not v1's`() { + BundledSQLiteDriver().open(dbFile.absolutePath).use { connection -> + connection.execSQL("CREATE TABLE Leftover (id INTEGER PRIMARY KEY)") + } + + assertEquals(false, probe.hasSecureElementTable()) + } + + /** + * Null, never false. A partially restored backup is an ordinary thing to find on a migration + * path, and it says nothing about whether v1's data is in there. Answering false would hand the + * caller a verdict that deletes it. + */ + @Test + fun `answers nothing for a file that is not a database at all`() { + dbFile.writeText("this is not a sqlite database, it is a text file that got restored here") + + assertNull(probe.hasSecureElementTable()) + } + + @Test + fun `answers nothing and creates nothing when there is no file`() { + assertFalse(dbFile.exists()) + + assertNull(probe.hasSecureElementTable()) + + assertFalse(dbFile.exists(), "probing must never bring a legacy database into existence") + } + + /** A probe that rewrites the file it is asked about would be a one-way door of its own. */ + @Test + fun `leaves the rows it reads over alone`() { + seedLegacyDatabase(dbFile, version = 2).use { connection -> + connection.execSQL( + "INSERT INTO SecureElement (title, data, type) VALUES (NULL, x'0102', 1)", + ) + } + + assertEquals(true, probe.hasSecureElementTable()) + + BundledSQLiteDriver().open(dbFile.absolutePath).use { connection -> + connection.prepare("SELECT title FROM SecureElement").use { stmt -> + assertTrue(stmt.step()) + assertTrue(stmt.isNull(0), "the probe repaired a row it was only asked to look at") + } + } + assertEquals(2, userVersionOf(dbFile)) + } +} diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/SanitizingLegacyDatabaseProviderTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/SanitizingLegacyDatabaseProviderTest.kt index 68100d06b..54dc11592 100644 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/SanitizingLegacyDatabaseProviderTest.kt +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/SanitizingLegacyDatabaseProviderTest.kt @@ -8,11 +8,6 @@ import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase import de.davis.keygo.migration.legacy_data.data.local.datasource.SanitizingLegacyDatabaseProvider import io.mockk.every import io.mockk.mockk -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import java.io.File import kotlin.test.AfterTest import kotlin.test.Test @@ -37,8 +32,6 @@ class SanitizingLegacyDatabaseProviderTest { private val tempDir: File = java.nio.file.Files.createTempDirectory("legacy-provider").toFile() private val dbFile: File = File(tempDir, "secure_element_database") - private val json = Json { ignoreUnknownKeys = true } - /** * Room and the sanitizer both turn a database name into a path with `Context.getDatabasePath`. * A fully relaxed mock answers that with an empty path, which would quietly point both of them @@ -58,29 +51,8 @@ class SanitizingLegacyDatabaseProviderTest { tempDir.deleteRecursively() } - /** Writes a file that looks exactly like one a v1 build at [version] would have left behind. */ - private fun createDatabase(version: Int): SQLiteConnection { - val schema = json - .parseToJsonElement(File("src/test/resources/legacy-schemas/$version.json").readText()) - .jsonObject - .getValue("database") - .jsonObject - - return BundledSQLiteDriver().open(dbFile.absolutePath).apply { - schema.getValue("entities").jsonArray.forEach { entity -> - val table = entity.jsonObject.getValue("tableName").jsonPrimitive.content - execSQL(entity.jsonObject.createSqlFor(table)) - entity.jsonObject.getValue("indices").jsonArray.forEach { index -> - execSQL(index.jsonObject.createSqlFor(table)) - } - } - schema.getValue("setupQueries").jsonArray.forEach { execSQL(it.jsonPrimitive.content) } - execSQL("PRAGMA user_version = $version") - } - } - - private fun JsonObject.createSqlFor(tableName: String): String = - getValue("createSql").jsonPrimitive.content.replace("\${TABLE_NAME}", tableName) + private fun createDatabase(version: Int): SQLiteConnection = + seedLegacyDatabase(dbFile, version) private fun titlesOnDisk(): List = BundledSQLiteDriver().open(dbFile.absolutePath).use { connection -> @@ -91,14 +63,6 @@ class SanitizingLegacyDatabaseProviderTest { } } - private fun userVersion(): Int = - BundledSQLiteDriver().open(dbFile.absolutePath).use { connection -> - connection.prepare("PRAGMA user_version").use { stmt -> - stmt.step() - stmt.getLong(0).toInt() - } - } - @Test fun `repairs the file before the database is built`() { createDatabase(2).use { connection -> @@ -114,7 +78,7 @@ class SanitizingLegacyDatabaseProviderTest { assertEquals(listOf(""), titlesOnDisk()) assertEquals( 2, - userVersion(), + userVersionOf(dbFile), "the file must still be at version 2, which is what proves the repair ran before " + "Room ever touched it", ) 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 index d5ec6dbe6..3e1a9a11c 100644 --- 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 @@ -3,20 +3,25 @@ package de.davis.keygo.migration.legacy_data.data.repository import android.content.Context import androidx.room.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.crypto.LegacyCipher import de.davis.keygo.migration.legacy_data.data.crypto.LegacyKeyProvider 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.AndroidLegacySecureElementProbe 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.datasource.LegacyDatabaseSanitizer +import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacySecureElementProbe import de.davis.keygo.migration.legacy_data.data.local.datasource.SanitizingLegacyDatabaseProvider 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.pojo.LegacyElementWithTags 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 @@ -29,10 +34,12 @@ import kotlinx.coroutines.test.runTest import java.io.File import javax.crypto.SecretKey import javax.crypto.spec.SecretKeySpec +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 @@ -71,6 +78,38 @@ private class FakeLegacyKeyProvider( } } +/** + * Answers the schema question without a file. [answer] carries the probe's three states: the table + * is there, the file opened and it provably is not, or nothing could be read at all. + */ +private class FakeLegacySecureElementProbe( + private val answer: Boolean? = true, +) : LegacySecureElementProbe { + + override fun hasSecureElementTable(): Boolean? = answer +} + +/** Fails every read the way a cancelled unlock scope does, and nothing else. */ +private class CancellingLegacyElementDao : LegacyElementDao { + + override suspend fun count(): Int = throw CancellationException("the unlock scope went away") + + override suspend fun getAllWithTags(): List = + throw CancellationException("the unlock scope went away") + + override suspend fun deleteByIds(ids: List): Unit = + throw CancellationException("the unlock scope went away") + + override suspend fun insertElement(element: LegacySecureElementEntity): Long = + error("this fake is only ever read from") + + override suspend fun insertTag(tag: LegacyTagEntity): Long = + error("this fake is only ever read from") + + override suspend fun insertCrossRef(crossRef: LegacySecureElementTagCrossRef): Unit = + error("this fake is only ever read from") +} + /** Hands out one already-open in-memory database, or nothing when the file is unreadable. */ private class FakeLegacyDatabaseProvider( private val database: LegacyDatabase?, @@ -96,15 +135,27 @@ private class FileBackedLegacyDatabaseFiles(private val file: File) : LegacyData override fun delete(): Boolean = file.delete() } -private class FakeLegacyDatabaseFiles : LegacyDatabaseFiles { +/** + * @param databaseClosed reports whether the provider's handle was already closed. Captured at the + * moment [delete] runs rather than read afterwards, because the order of the two is the behaviour: + * asserting after the fact holds just as well when the file is deleted first. + */ +private class FakeLegacyDatabaseFiles( + private val databaseClosed: () -> Boolean = { false }, +) : LegacyDatabaseFiles { var present: Boolean = true var deleted: Boolean = false private set + /** Null until [delete] runs, then whatever the provider's handle was at that instant. */ + var closedWhenDeleted: Boolean? = null + private set + override fun exists(): Boolean = present override fun delete(): Boolean { + closedWhenDeleted = databaseClosed() deleted = true present = false return true @@ -117,37 +168,44 @@ class LegacyItemRepositoryImplTest { private lateinit var keyProvider: FakeLegacyKeyProvider private lateinit var databaseProvider: FakeLegacyDatabaseProvider private lateinit var databaseFiles: FakeLegacyDatabaseFiles + private lateinit var secureElementProbe: LegacySecureElementProbe private lateinit var repository: LegacyItemRepositoryImpl /** - * A real path inside an empty directory, which is exactly what a clean v2 install looks like: - * no `secure_element_database`, and nothing allowed to bring one into being. + * 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 = - java.nio.file.Files.createTempDirectory("legacy-clean-install").toFile() - private val cleanInstallFile: File = File(tempDir, LEGACY_DATABASE_NAME) - private val cleanInstallContext: Context = mockk(relaxed = true).apply { - every { getDatabasePath(any()) } returns cleanInstallFile + java.nio.file.Files.createTempDirectory("legacy-on-disk").toFile() + private val legacyFile: File = File(tempDir, LEGACY_DATABASE_NAME) + private val legacyContext: Context = mockk(relaxed = true).apply { + every { getDatabasePath(any()) } returns legacyFile } /** - * Wired over the real provider and a real path rather than the in-memory database, because what - * is under test is whether a file turns up on disk. A fake provider could not show that. + * Wired over the real provider, the real probe 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 cleanInstallRepository() = LegacyItemRepositoryImpl( + private fun fileBackedRepository() = LegacyItemRepositoryImpl( databaseProvider = SanitizingLegacyDatabaseProvider( - context = cleanInstallContext, + context = legacyContext, sanitizer = LegacyDatabaseSanitizer(BundledSQLiteDriver()), driver = BundledSQLiteDriver(), ), + secureElementProbe = AndroidLegacySecureElementProbe( + context = legacyContext, + driver = BundledSQLiteDriver(), + ), keyProvider = keyProvider, cipher = FakeLegacyCipher(), parser = LegacyDetailParser(), - databaseFiles = FileBackedLegacyDatabaseFiles(cleanInstallFile), + databaseFiles = FileBackedLegacyDatabaseFiles(legacyFile), ) @BeforeTest @@ -158,7 +216,8 @@ class LegacyItemRepositoryImplTest { .build() keyProvider = FakeLegacyKeyProvider() databaseProvider = FakeLegacyDatabaseProvider(db) - databaseFiles = FakeLegacyDatabaseFiles() + databaseFiles = FakeLegacyDatabaseFiles(databaseClosed = { databaseProvider.closed }) + secureElementProbe = FakeLegacySecureElementProbe() repository = newRepository() } @@ -170,6 +229,7 @@ class LegacyItemRepositoryImplTest { private fun newRepository() = LegacyItemRepositoryImpl( databaseProvider = databaseProvider, + secureElementProbe = secureElementProbe, keyProvider = keyProvider, cipher = FakeLegacyCipher(), parser = LegacyDetailParser(), @@ -350,7 +410,7 @@ class LegacyItemRepositoryImplTest { */ @Test fun `state is absent on a clean install`() = runTest { - assertEquals(LegacyDatabaseState.Absent, cleanInstallRepository().state()) + assertEquals(LegacyDatabaseState.Absent, fileBackedRepository().state()) } /** @@ -365,10 +425,10 @@ class LegacyItemRepositoryImplTest { */ @Test fun `reading on a clean install leaves no legacy file behind`() = runTest { - val result = cleanInstallRepository().readAll() + val result = fileBackedRepository().readAll() assertFalse( - cleanInstallFile.exists(), + legacyFile.exists(), "reading must never bring a legacy database into existence", ) assertEquals(LegacyReadFailure.DatabaseUnreadable, result.assertFailure()) @@ -384,21 +444,80 @@ class LegacyItemRepositoryImplTest { /** * Unreadable rather than [LegacyDatabaseState.NotLegacy] on purpose: a file that cannot be * opened tells us nothing about what is inside it, and NotLegacy is the state that gets the - * file deleted. + * file deleted. The probe answers null here for the same reason it would in production, where + * a file the provider cannot open is a file the probe cannot read either. */ @Test fun `state is unreadable when the file cannot be opened`() = runTest { databaseProvider = FakeLegacyDatabaseProvider(database = null) + secureElementProbe = FakeLegacySecureElementProbe(answer = null) assertEquals(LegacyDatabaseState.Unreadable, newRepository().state()) } + /** + * The leftover v2 database from before `ItemDatabase` was renamed. No `SecureElement` table + * means no v1 data, and this is the one shape of file that earns the verdict that deletes it. + * + * Run over a real file rather than a fake probe, because the claim is about what is inside the + * file and a fake could only restate the expectation. + */ + @Test + fun `state is not legacy when the file has no SecureElement table`() = runTest { + BundledSQLiteDriver().open(legacyFile.absolutePath).use { connection -> + connection.execSQL("CREATE TABLE Leftover (id INTEGER PRIMARY KEY)") + } + + assertEquals(LegacyDatabaseState.NotLegacy, fileBackedRepository().state()) + } + + /** + * The regression that matters most in this class. This file has the `SecureElement` table, so + * it is not a leftover v2 database, but Room refuses it on the first query. Folding that into + * [LegacyDatabaseState.NotLegacy] deletes a file that was never shown to be free of v1 data, + * and 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. + * + * 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 `state is unreadable when the file has the table but the query fails`() = runTest { + BundledSQLiteDriver().open(legacyFile.absolutePath).use { connection -> + connection.execSQL("CREATE TABLE SecureElement (id INTEGER PRIMARY KEY)") + connection.execSQL("PRAGMA user_version = 3") + } + + assertEquals(LegacyDatabaseState.Unreadable, fileBackedRepository().state()) + } + + /** + * 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 { + // mockk stands in for the Room-generated database container alone, which has no fake to + // build from; the DAO underneath it, which is what the behaviour is about, is a real fake. + val database = mockk { + every { legacyElementDao() } returns CancellingLegacyElementDao() + } + databaseProvider = FakeLegacyDatabaseProvider(database) + + assertFailsWith { newRepository().remainingCount() } + } + @Test fun `deleteDatabase closes the open handle before removing the file`() = runTest { assertTrue(repository.deleteDatabase()) - assertTrue(databaseProvider.closed, "the file cannot be deleted from under an open handle") assertTrue(databaseFiles.deleted) + assertEquals( + true, + databaseFiles.closedWhenDeleted, + "the file cannot be deleted from under an open handle", + ) } @Test From c9a0bc0aacc2628bc3cd8ace289c8e42dd21946e Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 28 Jul 2026 11:39:55 +0200 Subject: [PATCH 16/31] feat(migration): convert legacy items into v2 logins and credit cards --- .../data/mapper/LegacyItemToDomainMapper.kt | 160 +++++++++ .../mapper/LegacyItemToDomainMapperTest.kt | 313 ++++++++++++++++++ 2 files changed, 473 insertions(+) create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemToDomainMapper.kt create mode 100644 migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemToDomainMapperTest.kt diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemToDomainMapper.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemToDomainMapper.kt new file mode 100644 index 000000000..3ca6f4b06 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemToDomainMapper.kt @@ -0,0 +1,160 @@ +package de.davis.keygo.migration.legacy_data.data.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.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.data.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 java.time.YearMonth +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException +import kotlin.time.Clock +import kotlin.time.Instant +import org.koin.core.annotation.Single + +@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, + ): Item? = when (val detail = item.detail) { + is LegacyDetail.Password -> convertPassword(item, detail, itemId, vaultId, keyInformation) + 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, + ): Login? { + val credential = detail.password?.let { encrypted -> + val plaintext = cipher.decrypt(encrypted)?.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(), + cardNumber = detail.cardNumber + ?.takeIf { it.isNotBlank() } + ?.let { CreditCard.CardNumber.encrypt(it) }, + cvv = detail.cvv + ?.takeIf { it.isNotBlank() } + ?.let { CreditCard.CVV.encrypt(it) }, + expirationDate = detail.expirationDate?.toYearMonthOrNull(), + timestamp = item.toTimestamp(), + ) + + /** 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), + ) + + private fun String.toYearMonthOrNull(): YearMonth? = try { + YearMonth.parse(this, EXPIRATION_FORMATTER) + } catch (_: DateTimeParseException) { + null + } + + private companion object { + // "yy" parses into the 2000-2099 range, matching v1's CreditCardUtil.isValidDateFormat + // and v2's CreateNewOrUpdateCreditCardUseCase. + val EXPIRATION_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("MM/yy") + } +} + +/** + * 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. + */ +internal 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 +} diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemToDomainMapperTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemToDomainMapperTest.kt new file mode 100644 index 000000000..e4183b367 --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemToDomainMapperTest.kt @@ -0,0 +1,313 @@ +package de.davis.keygo.migration.legacy_data.data.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.core.util.domain.resolver.RegistrableDomainResolver +import de.davis.keygo.migration.legacy_data.data.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 + +/** Passes the plaintext straight through, so tests control the nested password bytes directly. */ +private class PassthroughLegacyCipher( + private val failFor: ByteArray? = null, +) : LegacyCipher { + + override fun decrypt(blob: ByteArray): ByteArray? = + if (failFor != null && blob.contentEquals(failFor)) null else blob +} + +private class StubDomainResolver : RegistrableDomainResolver { + override fun resolve(domain: String): String? = + if (domain.contains("example")) "example.com" else null +} + +class LegacyItemToDomainMapperTest { + + private val vaultId = newVaultId() + private val provider = FakeCryptographicScopeProvider(FakeItemRepository()) + + private fun converter(cipher: LegacyCipher = PassthroughLegacyCipher()) = + LegacyItemConverter(cipher = cipher, registrableDomainResolver = StubDomainResolver()) + + 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 = PassthroughLegacyCipher(), + ) = provider.itemScope( + wrappedVaultKeyInformation = WrappedVaultKeyInformation( + wrappedVaultKey = KeyInformation(byteArrayOf(), byteArrayOf()), + vaultId = vaultId, + ), + wrappedItemKeyInformation = WrappedItemKeyInformation( + itemAad = ItemAad(itemId = newItemId(), vaultId = vaultId), + ), + ) { + // The itemScope receiver satisfies the converter's CryptographicScope context parameter. + converter(cipher).convert( + item = item, + itemId = newItemId(), + vaultId = vaultId, + keyInformation = KeyInformation(byteArrayOf(1), byteArrayOf(2)), + ) + }.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 = PassthroughLegacyCipher(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) + } +} From 3cd8065ee43e16af5c47afba1edc9ee930b7ed73 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 28 Jul 2026 12:12:48 +0200 Subject: [PATCH 17/31] feat(migration): orchestrate the legacy import with per-row failure isolation Gates the run on LegacyItemRepository.state(): Absent does nothing, NotLegacy deletes the stale file, Unreadable is left exactly where it was found, and only Present reads. Rows that fail are reported and left behind; the ones that made it are pruned, so a retry cannot duplicate anything. Deleting the inherited file and v1's Keystore alias only happens once the data is provably in v2: no failed row, a successful prune, and a legacy file that counts zero rows afterwards. The alias goes last and only if the file went. Corrects four defects in the plan's version of this step: - readAll/prune/remainingCount return Result, not raw values. Transcribing the planned call would have read a failed read as an empty file and deleted the user's data unseen. - LegacyDatabaseState.Unreadable had no branch at all. - The Keystore alias was deleted whether or not the file actually went, which would leave encrypted rows on disk with nothing left to open them. - A cryptographic scope that will not open was blamed on the row as UndecryptablePassword, once per row. It now stops the run. Co-Authored-By: Claude Opus 5 --- .../repository/LegacyKeyStoreCleanerImpl.kt | 31 ++ .../domain/model/LegacyMigration.kt | 16 + .../repository/LegacyKeyStoreCleaner.kt | 16 + .../domain/usecase/HasLegacyDataUseCase.kt | 30 ++ .../usecase/MigrateLegacyDataUseCase.kt | 220 +++++++++ .../usecase/FakeLegacyItemRepository.kt | 79 ++++ .../usecase/HasLegacyDataUseCaseTest.kt | 64 +++ .../usecase/MigrateLegacyDataUseCaseTest.kt | 441 ++++++++++++++++++ 8 files changed, 897 insertions(+) create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyKeyStoreCleanerImpl.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/repository/LegacyKeyStoreCleaner.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/HasLegacyDataUseCase.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/MigrateLegacyDataUseCase.kt create mode 100644 migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/FakeLegacyItemRepository.kt create mode 100644 migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/HasLegacyDataUseCaseTest.kt create mode 100644 migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/MigrateLegacyDataUseCaseTest.kt diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyKeyStoreCleanerImpl.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyKeyStoreCleanerImpl.kt new file mode 100644 index 000000000..8409b2906 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyKeyStoreCleanerImpl.kt @@ -0,0 +1,31 @@ +package de.davis.keygo.migration.legacy_data.data.repository + +import de.davis.keygo.migration.legacy_data.data.crypto.LEGACY_KEY_ALIAS +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyKeyStoreCleaner +import org.koin.core.annotation.Single +import java.security.KeyStore + +@Single +internal class LegacyKeyStoreCleanerImpl : LegacyKeyStoreCleaner { + + /** + * Deletes the alias if it is there, and stays quiet if anything goes wrong. + * + * A Keystore that will not open, or an alias that will not delete, leaves a key behind that + * nothing reads any more. That is untidy rather than harmful, and it runs after the user's data + * is already in v2, so it has no failure worth propagating into the unlock flow. + * + * [containsAlias] guards the delete rather than the delete being attempted blind, so the same + * call is safe on a v2 install that never had the alias and on a retry that already removed it. + */ + override fun deleteLegacyKey() { + runCatching { + val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) } + if (keyStore.containsAlias(LEGACY_KEY_ALIAS)) keyStore.deleteEntry(LEGACY_KEY_ALIAS) + } + } + + private companion object { + const val ANDROID_KEY_STORE = "AndroidKeyStore" + } +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigration.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigration.kt index b8049da41..772297eec 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigration.kt +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigration.kt @@ -51,10 +51,26 @@ data class LegacyRowFailure( data class LegacyMigrationReport( val migratedItems: Int, val failures: List, + + /** + * Rows the sanitizer had to repair before the inherited file would open at all, counted across + * every open in this run. Surfaced so the user can be told that some of what came across was + * patched up on the way in rather than read as v1 left it. + */ + val repairedRows: Int = 0, ) { val hasFailures: Boolean get() = failures.isNotEmpty() } +/** + * Why a whole run stopped. Carried by [LegacyMigrationOutcome.Failed], which never reports a + * partial import: whatever this describes, the legacy file was left exactly as it was found. + */ +class LegacyMigrationException internal constructor( + message: String, + cause: Throwable? = null, +) : Exception(message, cause) + sealed interface LegacyMigrationOutcome { /** No legacy file, or a file that turned out not to be v1's. */ diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/repository/LegacyKeyStoreCleaner.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/repository/LegacyKeyStoreCleaner.kt new file mode 100644 index 000000000..1a1e6a2db --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/repository/LegacyKeyStoreCleaner.kt @@ -0,0 +1,16 @@ +package de.davis.keygo.migration.legacy_data.domain.repository + +/** + * Removes v1's AES alias from the Android Keystore. + * + * The alias is what makes every blob in the inherited file readable, so removing it is as final as + * deleting the file itself. It may only run once the file is provably gone: while any encrypted row + * is still on disk, the key is the only thing that could ever open it again. + * + * It must never create the alias. v1's own `KeyUtil.getSecretKey` generated one on a miss, and a + * generated key here would turn "this data is unreadable" into "this data decrypts to garbage". + */ +internal interface LegacyKeyStoreCleaner { + + fun deleteLegacyKey() +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/HasLegacyDataUseCase.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/HasLegacyDataUseCase.kt new file mode 100644 index 000000000..b1a4586db --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/HasLegacyDataUseCase.kt @@ -0,0 +1,30 @@ +package de.davis.keygo.migration.legacy_data.domain.usecase + +import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyDatabaseState +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyItemRepository +import org.koin.core.annotation.Single + +/** + * True when v1 data is still on disk waiting to be imported. + * + * Exposed so a Settings surface can offer a manual retry later. The migration itself does not need + * it: it re-probes on every unlock. + */ +@Single +class HasLegacyDataUseCase internal constructor( + private val legacyItemRepository: LegacyItemRepository, +) { + + /** + * Only [LegacyDatabaseState.Present] can hold rows to import, so the other three answer false. + * That includes [LegacyDatabaseState.Unreadable]: there may well be data in a file that will + * not open, but no retry can reach it, and offering one would promise something we cannot do. + * + * A count that fails answers false for the same reason. It is not evidence of an empty file, it + * is the absence of evidence either way, and this question only ever offers the user a retry. + */ + suspend operator fun invoke(): Boolean = + legacyItemRepository.state() == LegacyDatabaseState.Present && + (legacyItemRepository.remainingCount().getOrNull() ?: 0) > 0 +} 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..a63f981e3 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/MigrateLegacyDataUseCase.kt @@ -0,0 +1,220 @@ +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.ItemTransactionRunner +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.data.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.LegacyRowFailure +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyDatabaseState +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyItemRepository +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyKeyStoreCleaner +import de.davisalessandro.keygo.rust.ItemAad +import kotlinx.coroutines.flow.first +import org.koin.core.annotation.Single +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 legacyKeyStoreCleaner: LegacyKeyStoreCleaner, + private val converter: LegacyItemConverter, + private val cryptographicScopeProvider: CryptographicScopeProvider, + private val vaultRepository: VaultRepository, + private val vaultContextRepository: VaultContextRepository, + private val upsertVaultItem: UpsertVaultItemUseCase, + private val transactionRunner: ItemTransactionRunner, +) { + + suspend operator fun invoke(): LegacyMigrationOutcome = when (legacyItemRepository.state()) { + // A clean install. Nothing to read, and nothing may be created either: opening the file + // would bring one into existence for every later run to find. + LegacyDatabaseState.Absent -> LegacyMigrationOutcome.NothingToMigrate + + // The one state that destroys the file without importing it, and only because it is the one + // state that proves there is nothing to import: the table v1 kept its data in is not there. + LegacyDatabaseState.NotLegacy -> { + legacyItemRepository.deleteDatabase() + LegacyMigrationOutcome.NothingToMigrate + } + + // A file that will not open tells us nothing about what is inside it, so it is left exactly + // where it was found. Never folded into NotLegacy, which is the branch above that deletes. + LegacyDatabaseState.Unreadable -> LegacyMigrationOutcome.Failed( + LegacyMigrationException("The legacy database exists but could not be opened"), + ) + + LegacyDatabaseState.Present -> runMigration() + } + + private suspend fun runMigration(): LegacyMigrationOutcome = try { + migrate() + } catch (e: CancellationException) { + // Rethrown rather than reported, for the same reason the repository rethrows it. A run cut + // short because the unlock scope went away has learned nothing about the user's file, and + // it must not get to answer for it. + 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 + + is Result.Failure -> return 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) + ) { + 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. + val writtenIds = mutableListOf() + if (converted.isNotEmpty()) + transactionRunner.inTransaction { + for ((legacyId, item) in converted) { + upsertVaultItem(item).onFailure { + throw LegacyMigrationException("Could not write legacy row $legacyId", it) + } + writtenIds += legacyId + } + } + + // 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(writtenIds).isSuccess() + + deleteWhenFullyImported(hasFailures = failures.isNotEmpty(), pruned = pruned) + + return LegacyMigrationOutcome.Migrated( + LegacyMigrationReport( + migratedItems = writtenIds.size, + failures = failures, + repairedRows = legacyItemRepository.repairedRows, + ), + ) + } + + /** + * Removes the inherited file and v1's Keystore alias, and only once everything that was in the + * file is in v2. + * + * Three things have to hold, and any one of them missing leaves the file alone. No row failed + * to read or convert; the prune succeeded, so the rows are gone from the file rather than + * merely copied out of it; and the file counts zero rows afterwards, which is the only direct + * evidence that nothing was left behind. A count that cannot be taken is not a zero. + * + * The alias goes last and only if the file actually went. Removing the key while encrypted rows + * are still on disk would make them unreadable for good. + */ + private suspend fun deleteWhenFullyImported(hasFailures: Boolean, pruned: Boolean) { + if (hasFailures || !pruned) return + if (legacyItemRepository.remainingCount().getOrNull() != 0) return + + if (legacyItemRepository.deleteDatabase()) legacyKeyStoreCleaner.deleteLegacyKey() + } + + private suspend fun convert( + legacyItem: LegacyItem, + itemId: ItemId, + vaultId: VaultId, + vaultKeyInformation: KeyInformation, + ): 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), + ), + ) { + // The itemScope receiver satisfies the converter's CryptographicScope context parameter. + converter.convert( + item = legacyItem, + itemId = itemId, + vaultId = vaultId, + keyInformation = wrapCurrentItemKey(), + ) + } + + 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/domain/usecase/FakeLegacyItemRepository.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/FakeLegacyItemRepository.kt new file mode 100644 index 000000000..240cc4344 --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/FakeLegacyItemRepository.kt @@ -0,0 +1,79 @@ +package de.davis.keygo.migration.legacy_data.domain.usecase + +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.LegacyDatabaseState +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyItemRepository +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyKeyStoreCleaner +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 state: LegacyDatabaseState = LegacyDatabaseState.Present + + var readResult: Result = + Result.Success(LegacyReadResult(emptyList(), emptyList())) + + 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 + + override var repairedRows: Int = 0 + + val prunedIds = mutableListOf() + + var databaseDeleted: Boolean = false + private set + + var readCalls: Int = 0 + private set + + override suspend fun state(): LegacyDatabaseState = state + + override suspend fun readAll(): Result { + readCalls++ + return 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 + + is Result.Failure -> 0 + } +} + +internal class FakeLegacyKeyStoreCleaner : LegacyKeyStoreCleaner { + + var deleted: Boolean = false + private set + + override fun deleteLegacyKey() { + deleted = true + } +} diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/HasLegacyDataUseCaseTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/HasLegacyDataUseCaseTest.kt new file mode 100644 index 000000000..186df54e2 --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/HasLegacyDataUseCaseTest.kt @@ -0,0 +1,64 @@ +package de.davis.keygo.migration.legacy_data.domain.usecase + +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.LegacyDatabaseState +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class HasLegacyDataUseCaseTest { + + private val legacyRepository = FakeLegacyItemRepository() + + private fun useCase() = HasLegacyDataUseCase(legacyRepository) + + @Test + fun `reports data waiting when the file is v1's and still holds rows`() = runTest { + legacyRepository.state = LegacyDatabaseState.Present + legacyRepository.countResult = Result.Success(2) + + assertTrue(useCase()()) + } + + @Test + fun `reports nothing waiting when the file is v1's but empty`() = runTest { + legacyRepository.state = LegacyDatabaseState.Present + legacyRepository.countResult = Result.Success(0) + + assertFalse(useCase()()) + } + + @Test + fun `reports nothing waiting when the rows cannot be counted`() = runTest { + legacyRepository.state = LegacyDatabaseState.Present + legacyRepository.countResult = Result.Failure(LegacyReadFailure.DatabaseUnreadable) + + assertFalse(useCase()()) + } + + @Test + fun `reports nothing waiting on a clean install`() = runTest { + legacyRepository.state = LegacyDatabaseState.Absent + legacyRepository.countResult = Result.Success(5) + + assertFalse(useCase()()) + } + + @Test + fun `reports nothing waiting for a file that is not v1's`() = runTest { + legacyRepository.state = LegacyDatabaseState.NotLegacy + legacyRepository.countResult = Result.Success(5) + + assertFalse(useCase()()) + } + + @Test + fun `reports nothing waiting for a file that cannot be read`() = runTest { + legacyRepository.state = LegacyDatabaseState.Unreadable + legacyRepository.countResult = Result.Success(5) + + assertFalse(useCase()()) + } +} 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..019f1bbd3 --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/MigrateLegacyDataUseCaseTest.kt @@ -0,0 +1,441 @@ +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.FakeItemTransactionRunner +import de.davis.keygo.core.item.FakeLoginRepository +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.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.CryptographicScope +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.domain.resolver.RegistrableDomainResolver +import de.davis.keygo.migration.legacy_data.data.crypto.LegacyCipher +import de.davis.keygo.migration.legacy_data.data.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.LegacyDatabaseState +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 + +/** Hands the blob straight back, so a legacy password arrives as its own plaintext. */ +private class PassthroughLegacyCipher : LegacyCipher { + override fun decrypt(blob: ByteArray): ByteArray? = blob +} + +/** Fails every blob, which is how a v1 row whose nested password is unrecoverable is modelled. */ +private class FailingLegacyCipher : LegacyCipher { + override fun decrypt(blob: ByteArray): ByteArray? = null +} + +private class StubDomainResolver : RegistrableDomainResolver { + override fun resolve(domain: String): String? = "example.com" +} + +/** + * Refuses to open a scope at all, standing in for a vault key that will not unwrap. Nothing about + * that is a statement about any one row, so the run has to stop rather than blame the rows. + */ +private class UnopenableScopeProvider : CryptographicScopeProvider { + + override suspend fun itemScope( + itemId: ItemId, + block: suspend CryptographicScope.() -> R, + ): Result = Result.Failure(CryptoScopeError.IdNotFound) + + override suspend fun itemScope( + wrappedVaultKeyInformation: WrappedVaultKeyInformation, + wrappedItemKeyInformation: WrappedItemKeyInformation, + block: suspend CryptographicScope.() -> R, + ): Result = Result.Failure(CryptoScopeError.IdNotFound) + + override suspend fun rewrapItemKey( + sourceVault: WrappedVaultKeyInformation, + sourceItem: WrappedItemKeyInformation, + destinationVault: WrappedVaultKeyInformation, + ): Result = Result.Failure(CryptoScopeError.IdNotFound) +} + +class MigrateLegacyDataUseCaseTest { + + private val vaultId: VaultId = newVaultId() + + private val legacyRepository = FakeLegacyItemRepository() + private val keyStoreCleaner = FakeLegacyKeyStoreCleaner() + private val transactionRunner = FakeItemTransactionRunner() + private val loginRepository = FakeLoginRepository() + private val creditCardRepository = FakeCreditCardRepository() + private val vaultRepository = FakeVaultRepository() + private val vaultContextRepository = FakeVaultContextRepository() + + private fun useCase( + cipher: LegacyCipher = PassthroughLegacyCipher(), + scopeProvider: CryptographicScopeProvider = + FakeCryptographicScopeProvider(FakeItemRepository()), + ) = MigrateLegacyDataUseCase( + legacyItemRepository = legacyRepository, + legacyKeyStoreCleaner = keyStoreCleaner, + converter = LegacyItemConverter(cipher, StubDomainResolver()), + 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 password(id: Long, title: String) = LegacyItem( + legacyId = id, + title = title, + favorite = false, + createdAt = 1_700_000_000_000L, + modifiedAt = null, + tags = emptySet(), + detail = LegacyDetail.Password( + username = "ada", + origin = "https://example.com", + password = "hunter2".encodeToByteArray(), + strength = null, + ), + ) + + private fun creditCard(id: Long, title: String) = LegacyItem( + legacyId = id, + title = title, + favorite = false, + createdAt = 1_700_000_000_000L, + modifiedAt = null, + tags = emptySet(), + 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)) + } + + @Test + fun `reports nothing to migrate when the legacy file is absent`() = runTest { + legacyRepository.state = LegacyDatabaseState.Absent + + assertEquals(LegacyMigrationOutcome.NothingToMigrate, useCase()()) + assertFalse(legacyRepository.databaseDeleted) + assertFalse(keyStoreCleaner.deleted) + assertEquals(0, legacyRepository.readCalls) + } + + @Test + fun `deletes a stale non legacy database and reports nothing to migrate`() = runTest { + legacyRepository.state = LegacyDatabaseState.NotLegacy + + assertEquals(LegacyMigrationOutcome.NothingToMigrate, useCase()()) + assertTrue(legacyRepository.databaseDeleted) + assertFalse(keyStoreCleaner.deleted) + assertEquals(0, legacyRepository.readCalls) + } + + @Test + fun `leaves an unreadable database exactly where it found it`() = runTest { + legacyRepository.state = LegacyDatabaseState.Unreadable + + assertIs(useCase()()) + assertFalse(legacyRepository.databaseDeleted) + assertFalse(keyStoreCleaner.deleted) + assertEquals(0, legacyRepository.readCalls) + } + + @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) + assertEquals(listOf(1L, 2L), legacyRepository.prunedIds) + assertTrue(legacyRepository.databaseDeleted) + assertTrue(keyStoreCleaner.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.transactionCount) + } + + @Test + fun `reports how many rows the sanitizer had to repair`() = runTest { + seedVault() + legacyRepository.repairedRows = 3 + seedRows(items = listOf(password(1L, "One"))) + + val outcome = assertIs(useCase()()) + + assertEquals(3, outcome.report.repairedRows) + } + + @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.Undecryptable)), + ) + + val outcome = assertIs(useCase()()) + + assertEquals(1, outcome.report.migratedItems) + assertEquals(1, outcome.report.failures.size) + assertEquals(listOf(1L), legacyRepository.prunedIds) + assertFalse(legacyRepository.databaseDeleted) + assertFalse(keyStoreCleaner.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.Undecryptable)), + ) + // 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(keyStoreCleaner.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 = FailingLegacyCipher())(), + ) + + assertEquals(0, outcome.report.migratedItems) + assertEquals( + LegacyFailureReason.UndecryptablePassword, + outcome.report.failures.single().reason, + ) + assertTrue(legacyRepository.prunedIds.isEmpty()) + assertFalse(legacyRepository.databaseDeleted) + assertFalse(keyStoreCleaner.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(keyStoreCleaner.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(keyStoreCleaner.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(keyStoreCleaner.deleted) + } + + @Test + fun `fails without importing when no cryptographic scope can be opened`() = runTest { + seedVault() + seedRows(items = listOf(password(1L, "One"), password(2L, "Two"))) + + assertIs(useCase(scopeProvider = UnopenableScopeProvider())()) + + assertEquals(0, transactionRunner.transactionCount) + assertTrue(legacyRepository.prunedIds.isEmpty()) + assertFalse(legacyRepository.databaseDeleted) + assertFalse(keyStoreCleaner.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(keyStoreCleaner.deleted) + } + + @Test + fun `a second run after a partial failure imports nothing new`() = runTest { + seedVault() + seedRows( + failures = listOf(LegacyRowFailure(2L, "Broken", LegacyFailureReason.Undecryptable)), + ) + + val outcome = assertIs(useCase()()) + + assertEquals(0, outcome.report.migratedItems) + assertTrue(loginRepository.observeLogins().first().isEmpty()) + assertFalse(legacyRepository.databaseDeleted) + assertFalse(keyStoreCleaner.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(keyStoreCleaner.deleted) + assertEquals(0, transactionRunner.transactionCount) + } + + @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(keyStoreCleaner.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(keyStoreCleaner.deleted) + } + + @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(keyStoreCleaner.deleted) + } + + @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) + + assertIs(useCase()()) + + assertFalse(legacyRepository.databaseDeleted) + assertFalse(keyStoreCleaner.deleted) + } + + @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(keyStoreCleaner.deleted) + } +} From 15e2b4e4b241757ff83c1ae0def71992b69f15e4 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 28 Jul 2026 12:57:28 +0200 Subject: [PATCH 18/31] feat(auth): import v1 data after every successful session start Runs MigrateLegacyDataUseCase on all four ways into the app: password create access, biometric create access, password unlock, and biometric unlock. The biometric unlock path finished in the composable and navigated on its own, so it now hands back to the ViewModel through onBiometricUnlockSucceeded and navigates via navigationEvent instead; left as it was it would have been the one entry point that never retried a partial import, and the one most returning users take daily. The import is waited for rather than launched and forgotten, because MainActivity pops AuthRoute with popUpTo(inclusive), which clears this ViewModel and cancels the scope underneath it. An import left running would be killed on every unlock and never finish. That wait is what importLegacyData exists to make safe. Unlock is how the user gets back to their data, so nothing about an optional one-off copy may stand between them and it. Corrects three defects in the plan's version of this step: - migrateLegacyData() was called bare inside viewModelScope.launch. Its invoke() is not total: state() and the NotLegacy deleteDatabase() sit outside its own catch, and it rethrows CancellationException on purpose. A throw skipped handleAuthenticationResult, so the navigation event never fired and the loading flag never cleared, stranding a user whose session was already live on the lock screen behind a permanent spinner. It is now caught, and a run that stops making progress is dropped after 30 seconds. - the new biometric path ended in trySend on a rendezvous channel whose only collector runs under repeatOnLifecycle(STARTED). With a migration now sitting between the unlock and the send, backgrounding the app during it dropped the event and left the user locked out of an already unlocked vault. Uses send. - feature/auth alone could not resolve the Koin definition, since implementation does not put the module on :app's classpath. Adds the dependency to app/build.gradle.kts, mirroring migration:create-access. The plan's fallback pointed at app/di/Koin.kt, which does not exist in this repo. AuthViewModel cannot be built in a JVM unit test (SavedStateHandle.toRoute lands in android.os.BaseBundle), so the behaviour lives in importLegacyData, which takes the migration as a lambda and is tested directly with no mocks. AuthViewModelMigrationTest guards the call sites, including that none of them calls the use case unguarded. Co-Authored-By: Claude Opus 5 --- app/build.gradle.kts | 1 + feature/auth/build.gradle.kts | 1 + .../feature/auth/presentation/AuthScreen.kt | 5 +- .../auth/presentation/AuthViewModel.kt | 37 ++++++-- .../auth/presentation/LegacyDataImport.kt | 60 +++++++++++++ .../AuthViewModelMigrationTest.kt | 68 +++++++++++++++ .../auth/presentation/LegacyDataImportTest.kt | 87 +++++++++++++++++++ 7 files changed, 253 insertions(+), 6 deletions(-) create mode 100644 feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/LegacyDataImport.kt create mode 100644 feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelMigrationTest.kt create mode 100644 feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/LegacyDataImportTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 01b8bd074..0e8648d4d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -119,6 +119,7 @@ dependencies { implementation(projects.feature.settings) implementation(projects.feature.backup) implementation(projects.migration.createAccess) + implementation(projects.migration.legacyData) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts index e421f02a6..3db202660 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { implementation(projects.core.item) implementation(projects.core.ui) implementation(projects.migration.createAccess) + implementation(projects.migration.legacyData) implementation(libs.androidx.navigation.compose) } diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt index 80ad7dcb9..9317cbb45 100644 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt +++ b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt @@ -47,7 +47,10 @@ fun AuthScreen(onSuccess: () -> Unit) { biometricUnlockAdapter.useAdapter { biometricCryptoController.requestUnlockVault() }.onSuccess { - currentOnSuccess() + // Not currentOnSuccess() any more. Navigating straight from here would leave + // the v1 import no scope to run in, so the ViewModel takes it from here and + // navigates through navigationEvent, which is already collected above. + viewModel.onBiometricUnlockSucceeded() } } } 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..85c79a9f1 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 @@ -22,6 +22,7 @@ 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.legacy_data.domain.usecase.MigrateLegacyDataUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview @@ -52,6 +53,7 @@ internal class AuthViewModel( hasV1MainPassword: HasMainPasswordUseCase, private val validateMainPassword: ValidateMainPassword, private val clearMainPasswordUseCase: ClearMainPasswordUseCase, + private val migrateLegacyData: MigrateLegacyDataUseCase, // ------------------- private val passwordStrengthEstimator: PasswordStrengthEstimator, @@ -181,9 +183,10 @@ internal class AuthViewModel( loading(setLoading = password.isNotBlank()) { unlockWithPassword( password = password - ).handleAuthenticationResult { - copyDefaultState(passwordError = UIPasswordError.Incorrect) - } + ).onSuccess { importLegacyData { migrateLegacyData() } } + .handleAuthenticationResult { + copyDefaultState(passwordError = UIPasswordError.Incorrect) + } } } @@ -249,7 +252,9 @@ internal class AuthViewModel( ) { if (!authState.biometricsAvailable || !authState.useBiometrics) { loading { - createAllAccesses(password = password).handleAuthenticationResult() + createAllAccesses(password = password) + .onSuccess { importLegacyData { migrateLegacyData() } } + .handleAuthenticationResult() } return @@ -294,7 +299,29 @@ internal class AuthViewModel( createAllAccesses( password = createAccessRequest.password, biometricCipher = cipher - ).handleAuthenticationResult() + ).onSuccess { importLegacyData { migrateLegacyData() } } + .handleAuthenticationResult() + } + } + + /** + * Biometric unlock completes in the composable rather than here, so it has to hand control back + * for the v1 import to get a chance to run. Without this the biometric path would be the one + * way into the app that never retries a partial import, and it is the way most returning users + * take every day. + * + * The import is waited for rather than left running, because navigating clears this ViewModel + * and cancels the scope underneath it. [importLegacyData] is what keeps that wait from turning + * into a lockout: it always returns, so the event below is always sent. + * + * Sent rather than offered, unlike the other paths. This one can take as long as the import + * does, and the screen may have stopped collecting by the time it finishes. A dropped event + * here would strand a user whose session is already live back on the lock screen. + */ + fun onBiometricUnlockSucceeded() { + viewModelScope.launch { + importLegacyData { migrateLegacyData() } + navigationEventChannel.send(Unit) } } } diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/LegacyDataImport.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/LegacyDataImport.kt new file mode 100644 index 000000000..365667531 --- /dev/null +++ b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/LegacyDataImport.kt @@ -0,0 +1,60 @@ +package de.davis.keygo.feature.auth.presentation + +import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationOutcome +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * How long the unlock flow waits for the v1 import before going on without it. + * + * There has to be a wait at all because navigating away clears the auth ViewModel and cancels the + * scope the import runs in, so an import started and left behind would be killed every time and + * never finish. Given that the wait is real, it needs a ceiling: long enough that no database v1 + * ever wrote comes close to it, short enough that a run which has stopped making progress cannot + * keep the user out of their own data. + */ +internal val LEGACY_IMPORT_BUDGET: Duration = 30.seconds + +/** + * Runs the v1 import on the way into the app, with the unlock outranking it in every case. + * + * The import is the least important thing happening at this moment. Unlock is how the user gets + * back to their data, so nothing here may stand between them and it: anything thrown is caught, a + * run that stops making progress is dropped at [budget], and the caller navigates either way. Every + * one of those endings leaves the legacy file exactly as it was found and gets retried on the next + * unlock, which is what makes giving up here cost the user nothing. + * + * `MigrateLegacyDataUseCase` reports the failures it can see as [LegacyMigrationOutcome.Failed], but + * it can still throw. It probes the file and deletes a non-legacy one outside its own catch, and it + * rethrows cancellation on purpose. Calling it bare from `viewModelScope.launch` would let any of + * that skip the navigation that follows, leaving a user whose session is already live stuck on the + * lock screen. + * + * [Throwable] and not [Exception], because not every way this goes wrong is an exception. A module + * that reaches Room, a native SQLite driver and the Keystore can raise a [LinkageError] or a + * [NoClassDefFoundError] on a device missing something it expected, and none of that is a reason to + * refuse the user their vault. + * + * Cancellation is passed through rather than swallowed. A cancelled scope means this screen is + * already going away, so there is no navigation left to protect. + * + * The [budget] is best effort. It can only end a run at a suspension point, so a call that blocks + * its thread outright still holds the unlock for as long as it blocks. + * + * @return what the import did, or null if it threw or ran out of budget. The unlock path ignores it + * on purpose. A legacy file that exists but cannot be opened is left alone rather than deleted on a + * guess, so it reports failure on every single unlock, forever, and turning that into something the + * user has to dismiss would make one unfixable problem into a second one. + */ +internal suspend fun importLegacyData( + budget: Duration = LEGACY_IMPORT_BUDGET, + migrate: suspend () -> LegacyMigrationOutcome, +): LegacyMigrationOutcome? = try { + withTimeoutOrNull(budget) { migrate() } +} catch (e: CancellationException) { + throw e +} catch (_: Throwable) { + null +} diff --git a/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelMigrationTest.kt b/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelMigrationTest.kt new file mode 100644 index 000000000..420ab6c57 --- /dev/null +++ b/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelMigrationTest.kt @@ -0,0 +1,68 @@ +package de.davis.keygo.feature.auth.presentation + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * `AuthViewModel` cannot be built in a JVM unit test at all: its `SavedStateHandle.toRoute` call + * lands in `android.os.BaseBundle`, which throws "not mocked" here. So the behaviour that matters + * is covered where it can be, in `LegacyDataImportTest`, and what is left for this file is the + * wiring those tests assume. What actually regresses is someone adding a fifth way into the app and + * forgetting the import, or tidying [importLegacyData] out of a call site because it looks like + * ceremony. + * + * If you add another successful-session-start path, add the call and bump the expected count. + */ +class AuthViewModelMigrationTest { + + private val viewModelSource = sourceOf("AuthViewModel") + + private val screenSource = sourceOf("AuthScreen") + + @Test + fun `every successful session start runs the legacy migration`() { + assertEquals( + 4, + Regex("""migrateLegacyData\(\)""").findAll(viewModelSource).count(), + "Expected the migration on the password-create, biometric-create, password-unlock " + + "and biometric-unlock paths.", + ) + } + + @Test + fun `no call site lets the migration into the unlock flow unguarded`() { + assertEquals( + 4, + Regex("""importLegacyData\s*\{\s*migrateLegacyData\(\)\s*}""") + .findAll(viewModelSource) + .count(), + "Every migration call has to go through importLegacyData. Called bare, a throw or a " + + "hang in the import skips the navigation that follows it and strands the user on " + + "the lock screen with a live session behind it.", + ) + } + + @Test + fun `biometric unlock hands control back to the view model`() { + assertTrue( + screenSource.contains("viewModel.onBiometricUnlockSucceeded()"), + "Biometric unlock must not call onSuccess directly; the migration would never run.", + ) + assertTrue( + viewModelSource.contains("fun onBiometricUnlockSucceeded()"), + "AuthScreen calls onBiometricUnlockSucceeded; AuthViewModel must declare it.", + ) + } + + private fun sourceOf(name: String): String { + val file = File("src/main/kotlin/de/davis/keygo/feature/auth/presentation/$name.kt") + // Read relative to the module directory, which is where Gradle runs these tests from. Named + // rather than left to fail on an empty string, so a runner with a different working + // directory says so instead of turning every assertion below into a puzzle. + assertTrue(file.exists(), "Expected to find $name.kt at ${file.absolutePath}") + + return file.readText() + } +} diff --git a/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/LegacyDataImportTest.kt b/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/LegacyDataImportTest.kt new file mode 100644 index 000000000..2f64b757e --- /dev/null +++ b/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/LegacyDataImportTest.kt @@ -0,0 +1,87 @@ +package de.davis.keygo.feature.auth.presentation + +import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationOutcome +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * The unlock flow waits for the import before it navigates, because navigating clears the auth + * ViewModel and cancels the scope the import runs in. That wait is what these tests are about: it + * has to end, whatever the import does, or a user who can no longer be let into their own data is + * the price of an optional one-off copy. + */ +class LegacyDataImportTest { + + @Test + fun `an outcome the import reports is handed straight back`() = runTest { + val outcome = importLegacyData { LegacyMigrationOutcome.NothingToMigrate } + + assertEquals(LegacyMigrationOutcome.NothingToMigrate, outcome) + } + + @Test + fun `a reported failure is not treated as a reason to stop`() = runTest { + val outcome = importLegacyData { + LegacyMigrationOutcome.Failed(IllegalStateException("unreadable legacy file")) + } + + assertTrue(outcome is LegacyMigrationOutcome.Failed) + } + + @Test + fun `an import that throws does not reach the unlock flow`() = runTest { + val outcome = importLegacyData { throw IllegalStateException("probing the file blew up") } + + assertNull(outcome) + } + + @Test + fun `an import that throws an Error does not reach the unlock flow either`() = runTest { + val outcome = importLegacyData { throw StackOverflowError("deep in Room") } + + assertNull(outcome) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `an import that never finishes is abandoned once the budget runs out`() = runTest { + val outcome = importLegacyData { awaitCancellation() } + + assertNull(outcome) + assertEquals(LEGACY_IMPORT_BUDGET.inWholeMilliseconds, testScheduler.currentTime) + } + + @Test + fun `an import that finishes inside the budget is waited for`() = runTest { + var finished = false + val outcome = importLegacyData(budget = 10.seconds) { + delay(9.seconds) + finished = true + LegacyMigrationOutcome.NothingToMigrate + } + + assertTrue(finished) + assertEquals(LegacyMigrationOutcome.NothingToMigrate, outcome) + } + + /** + * A cancelled scope means the auth screen is already going away, so there is no navigation left + * to protect and nothing to learn about the user's file. Swallowing it here would break + * structured concurrency and undo the rethrow the migration module deliberately keeps. + */ + @Test + fun `cancellation is passed through rather than swallowed`() = runTest { + assertFailsWith { + importLegacyData { throw CancellationException("the unlock scope went away") } + } + } +} From f9d6b340ac2ed5a76e14f7e29f3909f9dae78e0a Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 28 Jul 2026 16:43:32 +0200 Subject: [PATCH 19/31] fix(migration): run the v1 import off the unlock path The import was awaited inside the unlock flow under a 30s budget. That put the whole thing on the main thread (nothing in :migration:legacy-data hops off Main, and the per-row decrypt loop has no suspension point for the timeout to land on), inside the compare-and-set lambda of MutableStateFlow.update, and ahead of the navigation event. The budget could not help: the import is atomic, so a database too big to finish in 30s rolls back, prunes nothing and dies at the same point on every unlock, forever. Start it on an application-scoped coroutine on Dispatchers.IO instead and return at once. Unlock latency stops depending on the size of the user's v1 database, and with no wait there is nothing to budget. StartLegacyDataImportUseCase lives in :migration:legacy-data next to the use case it drives, so migration-only code stays in the migration module and @ComponentScan picks it up unchanged. Its LegacyImportRunner holds the single-flight gate: two concurrent runs would import every row twice under ids neither run can recognise as the other's, and a second unlock during a run is reachable on its own. Nothing escapes into the app scope, where an uncaught throwable would take the process down, and the throwable is now logged rather than discarded. Locking the vault mid-run fails that run, which leaves the legacy file as it found it and retries on the next unlock. Co-Authored-By: Claude Opus 5 --- .../feature/auth/presentation/AuthScreen.kt | 7 +- .../auth/presentation/AuthViewModel.kt | 25 +-- .../auth/presentation/LegacyDataImport.kt | 60 ------- .../AuthViewModelMigrationTest.kt | 27 +-- .../auth/presentation/LegacyDataImportTest.kt | 87 --------- .../usecase/StartLegacyDataImportUseCase.kt | 85 +++++++++ .../domain/usecase/LegacyImportRunnerTest.kt | 167 ++++++++++++++++++ 7 files changed, 272 insertions(+), 186 deletions(-) delete mode 100644 feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/LegacyDataImport.kt delete mode 100644 feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/LegacyDataImportTest.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/StartLegacyDataImportUseCase.kt create mode 100644 migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/LegacyImportRunnerTest.kt diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt index 9317cbb45..0ca70931f 100644 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt +++ b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt @@ -47,9 +47,10 @@ fun AuthScreen(onSuccess: () -> Unit) { biometricUnlockAdapter.useAdapter { biometricCryptoController.requestUnlockVault() }.onSuccess { - // Not currentOnSuccess() any more. Navigating straight from here would leave - // the v1 import no scope to run in, so the ViewModel takes it from here and - // navigates through navigationEvent, which is already collected above. + // Not currentOnSuccess() any more. Navigating straight from here would skip + // the v1 import, which every other way into the app starts, so the ViewModel + // takes it from here and navigates through navigationEvent, which is already + // collected above. viewModel.onBiometricUnlockSucceeded() } } 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 85c79a9f1..dee5fce0e 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 @@ -22,7 +22,7 @@ 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.legacy_data.domain.usecase.MigrateLegacyDataUseCase +import de.davis.keygo.migration.legacy_data.domain.usecase.StartLegacyDataImportUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview @@ -53,7 +53,7 @@ internal class AuthViewModel( hasV1MainPassword: HasMainPasswordUseCase, private val validateMainPassword: ValidateMainPassword, private val clearMainPasswordUseCase: ClearMainPasswordUseCase, - private val migrateLegacyData: MigrateLegacyDataUseCase, + private val startLegacyDataImport: StartLegacyDataImportUseCase, // ------------------- private val passwordStrengthEstimator: PasswordStrengthEstimator, @@ -183,7 +183,7 @@ internal class AuthViewModel( loading(setLoading = password.isNotBlank()) { unlockWithPassword( password = password - ).onSuccess { importLegacyData { migrateLegacyData() } } + ).onSuccess { startLegacyDataImport() } .handleAuthenticationResult { copyDefaultState(passwordError = UIPasswordError.Incorrect) } @@ -253,7 +253,7 @@ internal class AuthViewModel( if (!authState.biometricsAvailable || !authState.useBiometrics) { loading { createAllAccesses(password = password) - .onSuccess { importLegacyData { migrateLegacyData() } } + .onSuccess { startLegacyDataImport() } .handleAuthenticationResult() } @@ -299,7 +299,7 @@ internal class AuthViewModel( createAllAccesses( password = createAccessRequest.password, biometricCipher = cipher - ).onSuccess { importLegacyData { migrateLegacyData() } } + ).onSuccess { startLegacyDataImport() } .handleAuthenticationResult() } } @@ -310,19 +310,12 @@ internal class AuthViewModel( * way into the app that never retries a partial import, and it is the way most returning users * take every day. * - * The import is waited for rather than left running, because navigating clears this ViewModel - * and cancels the scope underneath it. [importLegacyData] is what keeps that wait from turning - * into a lockout: it always returns, so the event below is always sent. - * - * Sent rather than offered, unlike the other paths. This one can take as long as the import - * does, and the screen may have stopped collecting by the time it finishes. A dropped event - * here would strand a user whose session is already live back on the lock screen. + * The import is started and left to run on its own scope, so nothing stands between the unlock + * and the navigation that follows it. */ fun onBiometricUnlockSucceeded() { - viewModelScope.launch { - importLegacyData { migrateLegacyData() } - navigationEventChannel.send(Unit) - } + startLegacyDataImport() + viewModelScope.launch { navigationEventChannel.send(Unit) } } } diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/LegacyDataImport.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/LegacyDataImport.kt deleted file mode 100644 index 365667531..000000000 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/LegacyDataImport.kt +++ /dev/null @@ -1,60 +0,0 @@ -package de.davis.keygo.feature.auth.presentation - -import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationOutcome -import kotlinx.coroutines.withTimeoutOrNull -import kotlin.coroutines.cancellation.CancellationException -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds - -/** - * How long the unlock flow waits for the v1 import before going on without it. - * - * There has to be a wait at all because navigating away clears the auth ViewModel and cancels the - * scope the import runs in, so an import started and left behind would be killed every time and - * never finish. Given that the wait is real, it needs a ceiling: long enough that no database v1 - * ever wrote comes close to it, short enough that a run which has stopped making progress cannot - * keep the user out of their own data. - */ -internal val LEGACY_IMPORT_BUDGET: Duration = 30.seconds - -/** - * Runs the v1 import on the way into the app, with the unlock outranking it in every case. - * - * The import is the least important thing happening at this moment. Unlock is how the user gets - * back to their data, so nothing here may stand between them and it: anything thrown is caught, a - * run that stops making progress is dropped at [budget], and the caller navigates either way. Every - * one of those endings leaves the legacy file exactly as it was found and gets retried on the next - * unlock, which is what makes giving up here cost the user nothing. - * - * `MigrateLegacyDataUseCase` reports the failures it can see as [LegacyMigrationOutcome.Failed], but - * it can still throw. It probes the file and deletes a non-legacy one outside its own catch, and it - * rethrows cancellation on purpose. Calling it bare from `viewModelScope.launch` would let any of - * that skip the navigation that follows, leaving a user whose session is already live stuck on the - * lock screen. - * - * [Throwable] and not [Exception], because not every way this goes wrong is an exception. A module - * that reaches Room, a native SQLite driver and the Keystore can raise a [LinkageError] or a - * [NoClassDefFoundError] on a device missing something it expected, and none of that is a reason to - * refuse the user their vault. - * - * Cancellation is passed through rather than swallowed. A cancelled scope means this screen is - * already going away, so there is no navigation left to protect. - * - * The [budget] is best effort. It can only end a run at a suspension point, so a call that blocks - * its thread outright still holds the unlock for as long as it blocks. - * - * @return what the import did, or null if it threw or ran out of budget. The unlock path ignores it - * on purpose. A legacy file that exists but cannot be opened is left alone rather than deleted on a - * guess, so it reports failure on every single unlock, forever, and turning that into something the - * user has to dismiss would make one unfixable problem into a second one. - */ -internal suspend fun importLegacyData( - budget: Duration = LEGACY_IMPORT_BUDGET, - migrate: suspend () -> LegacyMigrationOutcome, -): LegacyMigrationOutcome? = try { - withTimeoutOrNull(budget) { migrate() } -} catch (e: CancellationException) { - throw e -} catch (_: Throwable) { - null -} diff --git a/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelMigrationTest.kt b/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelMigrationTest.kt index 420ab6c57..ce84c05e5 100644 --- a/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelMigrationTest.kt +++ b/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelMigrationTest.kt @@ -7,11 +7,11 @@ import kotlin.test.assertTrue /** * `AuthViewModel` cannot be built in a JVM unit test at all: its `SavedStateHandle.toRoute` call - * lands in `android.os.BaseBundle`, which throws "not mocked" here. So the behaviour that matters - * is covered where it can be, in `LegacyDataImportTest`, and what is left for this file is the - * wiring those tests assume. What actually regresses is someone adding a fifth way into the app and - * forgetting the import, or tidying [importLegacyData] out of a call site because it looks like - * ceremony. + * lands in `android.os.BaseBundle`, which throws "not mocked" here. So what the import actually + * does is covered where it can be, in `LegacyImportRunnerTest` over in `:migration:legacy-data`, + * and what is left for this file is the wiring that test assumes. Asserting on source text is a + * weak way to do it, but the thing it catches is real: someone adding a fifth way into the app and + * forgetting the import. * * If you add another successful-session-start path, add the call and bump the expected count. */ @@ -25,25 +25,12 @@ class AuthViewModelMigrationTest { fun `every successful session start runs the legacy migration`() { assertEquals( 4, - Regex("""migrateLegacyData\(\)""").findAll(viewModelSource).count(), - "Expected the migration on the password-create, biometric-create, password-unlock " + + Regex("""startLegacyDataImport\(\)""").findAll(viewModelSource).count(), + "Expected the import on the password-create, biometric-create, password-unlock " + "and biometric-unlock paths.", ) } - @Test - fun `no call site lets the migration into the unlock flow unguarded`() { - assertEquals( - 4, - Regex("""importLegacyData\s*\{\s*migrateLegacyData\(\)\s*}""") - .findAll(viewModelSource) - .count(), - "Every migration call has to go through importLegacyData. Called bare, a throw or a " + - "hang in the import skips the navigation that follows it and strands the user on " + - "the lock screen with a live session behind it.", - ) - } - @Test fun `biometric unlock hands control back to the view model`() { assertTrue( diff --git a/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/LegacyDataImportTest.kt b/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/LegacyDataImportTest.kt deleted file mode 100644 index 2f64b757e..000000000 --- a/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/LegacyDataImportTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -package de.davis.keygo.feature.auth.presentation - -import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationOutcome -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.delay -import kotlinx.coroutines.test.runTest -import kotlin.coroutines.cancellation.CancellationException -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertNull -import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.seconds - -/** - * The unlock flow waits for the import before it navigates, because navigating clears the auth - * ViewModel and cancels the scope the import runs in. That wait is what these tests are about: it - * has to end, whatever the import does, or a user who can no longer be let into their own data is - * the price of an optional one-off copy. - */ -class LegacyDataImportTest { - - @Test - fun `an outcome the import reports is handed straight back`() = runTest { - val outcome = importLegacyData { LegacyMigrationOutcome.NothingToMigrate } - - assertEquals(LegacyMigrationOutcome.NothingToMigrate, outcome) - } - - @Test - fun `a reported failure is not treated as a reason to stop`() = runTest { - val outcome = importLegacyData { - LegacyMigrationOutcome.Failed(IllegalStateException("unreadable legacy file")) - } - - assertTrue(outcome is LegacyMigrationOutcome.Failed) - } - - @Test - fun `an import that throws does not reach the unlock flow`() = runTest { - val outcome = importLegacyData { throw IllegalStateException("probing the file blew up") } - - assertNull(outcome) - } - - @Test - fun `an import that throws an Error does not reach the unlock flow either`() = runTest { - val outcome = importLegacyData { throw StackOverflowError("deep in Room") } - - assertNull(outcome) - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun `an import that never finishes is abandoned once the budget runs out`() = runTest { - val outcome = importLegacyData { awaitCancellation() } - - assertNull(outcome) - assertEquals(LEGACY_IMPORT_BUDGET.inWholeMilliseconds, testScheduler.currentTime) - } - - @Test - fun `an import that finishes inside the budget is waited for`() = runTest { - var finished = false - val outcome = importLegacyData(budget = 10.seconds) { - delay(9.seconds) - finished = true - LegacyMigrationOutcome.NothingToMigrate - } - - assertTrue(finished) - assertEquals(LegacyMigrationOutcome.NothingToMigrate, outcome) - } - - /** - * A cancelled scope means the auth screen is already going away, so there is no navigation left - * to protect and nothing to learn about the user's file. Swallowing it here would break - * structured concurrency and undo the rethrow the migration module deliberately keeps. - */ - @Test - fun `cancellation is passed through rather than swallowed`() = runTest { - assertFailsWith { - importLegacyData { throw CancellationException("the unlock scope went away") } - } - } -} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/StartLegacyDataImportUseCase.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/StartLegacyDataImportUseCase.kt new file mode 100644 index 000000000..a002cb3e0 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/StartLegacyDataImportUseCase.kt @@ -0,0 +1,85 @@ +package de.davis.keygo.migration.legacy_data.domain.usecase + +import android.util.Log +import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationOutcome +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.koin.core.annotation.Single +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.cancellation.CancellationException + +private const val TAG = "LegacyDataImport" + +/** + * Starts the v1 import in the background and returns at once. + * + * Everything that calls this is a session start, so the caller is the unlock the user is waiting + * on. The import is not work that can sit in front of them: 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 out of the caller's lifetime, + * because navigating away from the auth screen clears its ViewModel a moment after this returns. + * + * The trade-off that buys: a run still going when the user locks the vault will fail, because the + * session holds the key material it needs. That costs nothing. A failed run leaves the legacy file + * exactly as it found it, and the next unlock starts a fresh one. + */ +@Single +class StartLegacyDataImportUseCase internal constructor( + migrateLegacyData: MigrateLegacyDataUseCase, +) { + + private val runner = LegacyImportRunner( + scope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + onFailure = { Log.e(TAG, "v1 import failed, retrying on the next unlock", it) }, + import = { migrateLegacyData() }, + ) + + operator fun invoke() = runner.start() +} + +/** + * 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. + * + * Nothing thrown may reach [scope], where an uncaught throwable would take the process down. + * [Throwable] and not [Exception]: `MigrateLegacyDataUseCase` catches around its own migrate call, + * but not around the state probe it switches on or the deletion it does for a file that turns out + * not to be v1's, 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. A run cut short has learned nothing about the + * user's file and must not get to answer for it, and swallowing it would undo the rethrow the + * migration keeps on purpose. + */ +internal class LegacyImportRunner( + private val scope: CoroutineScope, + private val onFailure: (Throwable) -> Unit, + private val import: suspend () -> LegacyMigrationOutcome, +) { + + private val inFlight = AtomicBoolean(false) + + fun start() { + if (!inFlight.compareAndSet(false, true)) return + + scope.launch { + try { + import() + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + onFailure(e) + } + // 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. + }.invokeOnCompletion { inFlight.set(false) } + } +} 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..88313a1c3 --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/LegacyImportRunnerTest.kt @@ -0,0 +1,167 @@ +package de.davis.keygo.migration.legacy_data.domain.usecase + +import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationOutcome +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. Both of those are its behaviour rather than its wiring, so they are tested + * here rather than guarded by the source-text assertions in `:feature:auth`. + * + * `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 fun TestScope.runnerFor( + import: RecordingImport, + failures: MutableList = mutableListOf(), + ) = LegacyImportRunner( + scope = backgroundScope, + onFailure = { failures += it }, + 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. Two runs over one legacy file would " + + "import every row twice, under ids neither run can recognise as the other's.", + ) + + 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 has finished starts a new run`() = runTest { + val import = RecordingImport() + val runner = runnerFor(import) + + runner.start() + runCurrent() + + runner.start() + runCurrent() + + assertEquals( + 2, + import.invocations, + "Retrying on every unlock is the point. Dropping a call once a run has finished " + + "would leave a partial import partial until the next reinstall.", + ) + } + + @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 failures = mutableListOf() + val import = RecordingImport { throw boom } + val runner = runnerFor(import, failures) + + runner.start() + runCurrent() + + assertEquals( + listOf(boom), + failures, + "A throw has to be reported. Nothing else in the app is watching this run, so a silent " + + "one is a user whose data never arrives and a bug report with nothing in it.", + ) + + runner.start() + runCurrent() + + assertEquals( + 2, + import.invocations, + "A failed run must release the runner. Left held, one bad unlock would block the " + + "import for the life of the install.", + ) + } + + @Test + fun `an import that fails with an Error is contained too`() = runTest { + val failures = mutableListOf() + val import = RecordingImport { throw NoClassDefFoundError("a driver this device lacks") } + val runner = runnerFor(import, failures) + + 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(failures.single()) + } + + @Test + fun `cancellation is passed through rather than reported as a failure`() = runTest { + val failures = mutableListOf() + val import = RecordingImport { call -> + if (call == 1) throw CancellationException("the run was cancelled") + LegacyMigrationOutcome.NothingToMigrate + } + val runner = runnerFor(import, failures) + + runner.start() + runCurrent() + + assertTrue( + failures.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.") + } +} From 964cb14f42dc115187505556ad8c07c36dd51046 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 28 Jul 2026 17:07:05 +0200 Subject: [PATCH 20/31] fix(migration): report legacy import failures and row-skip diagnostics LegacyImportRunner discarded the returned LegacyMigrationOutcome, so every expected failure from MigrateLegacyDataUseCase (Failed, and a Migrated with row failures) produced no signal at all. The seam now reports Failed's cause and a summary of row failures by reason, while leaving the clean endings silent. Also corrects the KDoc's false claim about a lock interrupting an in-flight run (no such lock exists today) and closes the gap where a throwing report implementation could still reach the scope. --- .../usecase/StartLegacyDataImportUseCase.kt | 72 ++++++++++-- .../domain/usecase/LegacyImportRunnerTest.kt | 110 ++++++++++++++++-- 2 files changed, 158 insertions(+), 24 deletions(-) diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/StartLegacyDataImportUseCase.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/StartLegacyDataImportUseCase.kt index a002cb3e0..52585204e 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/StartLegacyDataImportUseCase.kt +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/StartLegacyDataImportUseCase.kt @@ -2,6 +2,7 @@ package de.davis.keygo.migration.legacy_data.domain.usecase import android.util.Log 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.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -22,9 +23,10 @@ private const val TAG = "LegacyDataImport" * application scope on [Dispatchers.IO], off the main thread and out of the caller's lifetime, * because navigating away from the auth screen clears its ViewModel a moment after this returns. * - * The trade-off that buys: a run still going when the user locks the vault will fail, because the - * session holds the key material it needs. That costs nothing. A failed run leaves the legacy file - * exactly as it found it, and the next unlock starts a fresh one. + * 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. */ @Single class StartLegacyDataImportUseCase internal constructor( @@ -33,7 +35,9 @@ class StartLegacyDataImportUseCase internal constructor( private val runner = LegacyImportRunner( scope = CoroutineScope(SupervisorJob() + Dispatchers.IO), - onFailure = { Log.e(TAG, "v1 import failed, retrying on the next unlock", it) }, + report = { message, cause -> + if (cause != null) Log.e(TAG, message, cause) else Log.e(TAG, message) + }, import = { migrateLegacyData() }, ) @@ -49,11 +53,20 @@ class StartLegacyDataImportUseCase internal constructor( * 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. * - * Nothing thrown may reach [scope], where an uncaught throwable would take the process down. - * [Throwable] and not [Exception]: `MigrateLegacyDataUseCase` catches around its own migrate call, - * but not around the state probe it switches on or the deletion it does for a file that turns out - * not to be v1's, 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. + * [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]. [LegacyMigrationOutcome.Failed] reports + * its cause directly. A [LegacyMigrationOutcome.Migrated] whose report carries row failures also + * reports, because a run that silently drops some of the user's entries needs a trace even though + * skipping a bad row is the designed behaviour. [LegacyMigrationOutcome.NothingToMigrate] and a + * [LegacyMigrationOutcome.Migrated] with no row failures are the normal endings and report nothing. + * + * 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 around its own migrate call, but not around the state probe it + * switches on or the deletion it does for a file that turns out not to be v1's, 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. A run cut short has learned nothing about the * user's file and must not get to answer for it, and swallowing it would undo the rethrow the @@ -61,7 +74,7 @@ class StartLegacyDataImportUseCase internal constructor( */ internal class LegacyImportRunner( private val scope: CoroutineScope, - private val onFailure: (Throwable) -> Unit, + private val report: (message: String, cause: Throwable?) -> Unit, private val import: suspend () -> LegacyMigrationOutcome, ) { @@ -72,14 +85,49 @@ internal class LegacyImportRunner( scope.launch { try { - import() + reportOutcome(import()) } catch (e: CancellationException) { throw e } catch (e: Throwable) { - onFailure(e) + reportSafely("v1 import threw", e) } // 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. }.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) + reportSafely(rowFailureSummary(outcome.report), null) + + LegacyMigrationOutcome.NothingToMigrate -> Unit + } + } + + private fun rowFailureSummary(migrationReport: LegacyMigrationReport): String { + // 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 + + return "v1 import finished with ${migrationReport.failures.size} of $total row(s) " + + "skipped: $byReason" + } + + /** + * Calls [report] without letting it reach [scope]. [report] is a reporting seam and not part of + * the import, so a throw out of it must be contained exactly like a throw out of [import] is. + */ + 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/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 index 88313a1c3..96b6c0d73 100644 --- 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 @@ -1,6 +1,9 @@ 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 @@ -46,10 +49,10 @@ class LegacyImportRunnerTest { private fun TestScope.runnerFor( import: RecordingImport, - failures: MutableList = mutableListOf(), + diagnostics: MutableList> = mutableListOf(), ) = LegacyImportRunner( scope = backgroundScope, - onFailure = { failures += it }, + report = { message, cause -> diagnostics += message to cause }, import = { import() }, ) @@ -103,16 +106,16 @@ class LegacyImportRunnerTest { @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 failures = mutableListOf() + val diagnostics = mutableListOf>() val import = RecordingImport { throw boom } - val runner = runnerFor(import, failures) + val runner = runnerFor(import, diagnostics) runner.start() runCurrent() assertEquals( - listOf(boom), - failures, + listOf(boom), + diagnostics.map { it.second }, "A throw has to be reported. Nothing else in the app is watching this run, so a silent " + "one is a user whose data never arrives and a bug report with nothing in it.", ) @@ -130,32 +133,32 @@ class LegacyImportRunnerTest { @Test fun `an import that fails with an Error is contained too`() = runTest { - val failures = mutableListOf() + val diagnostics = mutableListOf>() val import = RecordingImport { throw NoClassDefFoundError("a driver this device lacks") } - val runner = runnerFor(import, failures) + val runner = runnerFor(import, diagnostics) 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(failures.single()) + assertIs(diagnostics.single().second) } @Test fun `cancellation is passed through rather than reported as a failure`() = runTest { - val failures = mutableListOf() + val diagnostics = mutableListOf>() val import = RecordingImport { call -> if (call == 1) throw CancellationException("the run was cancelled") LegacyMigrationOutcome.NothingToMigrate } - val runner = runnerFor(import, failures) + val runner = runnerFor(import, diagnostics) runner.start() runCurrent() assertTrue( - failures.isEmpty(), + diagnostics.isEmpty(), "A cancelled run has learned nothing about the user's file and must not answer for it.", ) @@ -164,4 +167,87 @@ class LegacyImportRunnerTest { 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 diagnostics = mutableListOf>() + val import = RecordingImport { LegacyMigrationOutcome.Failed(cause) } + val runner = runnerFor(import, diagnostics) + + runner.start() + runCurrent() + + assertEquals( + cause, + diagnostics.singleOrNull()?.second, + "Failed is this module's real channel for an expected failure, the same way a returned " + + "Result is everywhere else in this codebase. If it is not routed to the seam, the " + + "failures that actually happen in the field, like a database that opens but fails " + + "validation, produce no signal at all.", + ) + } + + @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.Unparseable, + ), + ), + ) + val diagnostics = mutableListOf>() + val import = RecordingImport { LegacyMigrationOutcome.Migrated(report) } + val runner = runnerFor(import, diagnostics) + + runner.start() + runCurrent() + + assertEquals( + 1, + diagnostics.size, + "Individual row skips are the designed behaviour, but a run that silently drops some " + + "of the user's entries with no trace at all is not acceptable either.", + ) + assertTrue( + diagnostics.single().first.contains("Unparseable"), + "The diagnostic has to carry enough to work out what happened, 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 `NothingToMigrate and a clean Migrated produce no diagnostic`() = runTest { + val diagnostics = mutableListOf>() + val import = RecordingImport { call -> + if (call == 1) + LegacyMigrationOutcome.NothingToMigrate + else + LegacyMigrationOutcome.Migrated( + LegacyMigrationReport(migratedItems = 3, failures = emptyList()), + ) + } + val runner = runnerFor(import, diagnostics) + + runner.start() + runCurrent() + + runner.start() + runCurrent() + + assertEquals(2, import.invocations) + assertTrue( + diagnostics.isEmpty(), + "NothingToMigrate and a Migrated with no row failures are the normal endings; neither " + + "may produce a diagnostic line.", + ) + } } From a5db590cf793c75f1d8321086e86893ffe3030f4 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 28 Jul 2026 17:30:30 +0200 Subject: [PATCH 21/31] test(migration): prove the legacy import loses no data end to end Wires the real Room database, v1's real AES-GCM cipher, the real detail parser and the real converter together and drives them through the real use case. Two properties only exist between links and so could not be proved by the per-component tests: v1's double encryption, where a password comes back as plaintext only if the repository, the parser and the converter each undo their own layer, and the prune-then-delete order, where the inherited file is destroyed only once the rows it held are provably in v2. The probe is the production one over a real file rather than a fake, so the NotLegacy verdict that deletes the user's file is earned from the bytes each test put on disk. The stale v2 fixture is therefore a real database with no SecureElement table; a file of arbitrary bytes is a corrupt file, which the probe answers null for and which must be left where it was found. Co-Authored-By: Claude Opus 5 --- .../LegacyMigrationEndToEndTest.kt | 414 ++++++++++++++++++ 1 file changed, 414 insertions(+) create mode 100644 migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/LegacyMigrationEndToEndTest.kt 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..84fa2b4ce --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/LegacyMigrationEndToEndTest.kt @@ -0,0 +1,414 @@ +package de.davis.keygo.migration.legacy_data + +import android.content.Context +import androidx.room.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.FakeItemTransactionRunner +import de.davis.keygo.core.item.FakeLoginRepository +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.core.util.domain.resolver.RegistrableDomainResolver +import de.davis.keygo.migration.legacy_data.data.crypto.LegacyAesGcmCipher +import de.davis.keygo.migration.legacy_data.data.crypto.LegacyKeyProvider +import de.davis.keygo.migration.legacy_data.data.json.LegacyDetailParser +import de.davis.keygo.migration.legacy_data.data.local.datasource.AndroidLegacySecureElementProbe +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.mapper.LegacyItemConverter +import de.davis.keygo.migration.legacy_data.data.repository.LegacyItemRepositoryImpl +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.repository.LegacyDatabaseFiles +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyKeyStoreCleaner +import de.davis.keygo.migration.legacy_data.domain.usecase.MigrateLegacyDataUseCase +import de.davisalessandro.keygo.rust.ItemAad +import io.mockk.every +import io.mockk.mockk +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 javax.crypto.Cipher +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 + * `SanitizingLegacyDatabaseProvider`, 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") + + /** + * Room turns a database name into a path with `Context.getDatabasePath`, and so does the probe. + * A fully relaxed mock answers that with a mock of its own whose path is empty, which makes both + * of them quietly work on some other file: Room reports an empty database and the probe reports + * nothing at all. Stubbing it is what points the two at the file these tests seed. + * + * This is the only mock in the class. Nothing else here has an Android framework type in the way. + */ + private val context: Context = mockk(relaxed = true).apply { + every { getDatabasePath(any()) } returns dbFile + } + + 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 = FakeItemTransactionRunner() + private val cryptoProvider = FakeCryptographicScopeProvider(FakeItemRepository(loginRepository)) + + private var keyStoreCleared = false + + private val database: LegacyDatabase = Room.databaseBuilder( + context, + LegacyDatabase::class.java, + dbFile.name, + ) + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.IO) + .build() + + private val databaseFiles = object : LegacyDatabaseFiles { + override fun exists(): Boolean = dbFile.exists() + + override fun delete(): Boolean { + listOf("", "-wal", "-shm", "-journal").forEach { suffix -> + File(dbFile.absolutePath + suffix).delete() + } + return true + } + } + + /** + * Hands out the one database this class builds, and closes it on request. Closing has to work: + * the tests that assert the file is gone would otherwise be deleting it from under an open Room + * handle, which is not what production does. + * + * Nothing repairs anything here. The sanitizer only runs inside + * `SanitizingLegacyDatabaseProvider`, which this class deliberately does not go through, so the + * honest count is zero. + */ + private val databaseProvider = object : LegacyDatabaseProvider { + + override val repairedRows: Int = 0 + + override fun get(): LegacyDatabase = database + + override fun close() = database.close() + } + + /** One key for the row blobs and the nested password blobs, because v1 used one alias for both. */ + private val legacyKeyProvider = object : LegacyKeyProvider { + override fun secretKey(): SecretKey = legacyKey + } + + private val legacyCipher = LegacyAesGcmCipher(legacyKeyProvider) + + private val legacyRepository = LegacyItemRepositoryImpl( + databaseProvider = databaseProvider, + // The production probe rather than a fake. This is the answer that decides whether the + // user's file gets deleted, so each test earns its verdict from the bytes it actually put on + // disk instead of from a constant the test handed back to itself. + secureElementProbe = AndroidLegacySecureElementProbe( + context = context, + driver = BundledSQLiteDriver(), + ), + keyProvider = legacyKeyProvider, + cipher = legacyCipher, + parser = LegacyDetailParser(), + databaseFiles = databaseFiles, + ) + + private val useCase = MigrateLegacyDataUseCase( + legacyItemRepository = legacyRepository, + legacyKeyStoreCleaner = object : LegacyKeyStoreCleaner { + override fun deleteLegacyKey() { + keyStoreCleared = true + } + }, + converter = LegacyItemConverter( + cipher = legacyCipher, + registrableDomainResolver = object : RegistrableDomainResolver { + override fun resolve(domain: String): String? = + domain.substringAfterLast('.', "").takeIf { it.isNotEmpty() } + ?.let { "example.com" } + }, + ), + cryptographicScopeProvider = cryptoProvider, + vaultRepository = vaultRepository, + vaultContextRepository = vaultContextRepository, + upsertVaultItem = UpsertVaultItemUseCase(loginRepository, creditCardRepository), + transactionRunner = transactionRunner, + ) + + @AfterTest + fun tearDown() { + database.close() + tempDir.deleteRecursively() + } + + /** Byte-for-byte v1's `Cryptography.encryptAES`: 12 byte IV prefix, AES-256-GCM, 128 bit tag. */ + private fun encryptLikeV1(plaintext: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, legacyKey) + return cipher.iv + cipher.doFinal(plaintext) + } + + /** 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(), + ) + } + + 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 fun seedVault() { + vaultRepository.seed( + Vault( + id = vaultId, + name = "Default Vault", + keyInformation = KeyInformation(byteArrayOf(1), byteArrayOf(2)), + icon = Vault.Icon.Default, + ), + ) + } + + 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() + + @Test + fun `migrates a full v1 database and leaves nothing behind`() = runTest { + seedVault() + vaultContextRepository.setContextAndLastInteracted(vaultId) + 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)) + + assertTrue(keyStoreCleared) + assertFalse(File(dbFile.absolutePath).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() + vaultContextRepository.setContextAndLastInteracted(vaultId) + seed(title = "Fine", blob = passwordJson("ada", "https://example.com", "pw"), type = 1) + seed(title = "Broken", blob = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13), type = 1) + + val outcome = assertIs(useCase()) + + assertEquals(1, outcome.report.migratedItems) + assertEquals("Broken", outcome.report.failures.single().title) + assertEquals(LegacyFailureReason.Undecryptable, outcome.report.failures.single().reason) + assertFalse(keyStoreCleared) + assertTrue(dbFile.exists()) + assertEquals(1, legacyRepository.remainingCount().assertSuccess()) + } + + @Test + fun `keeps the database and reports the row when the json is malformed`() = runTest { + seedVault() + vaultContextRepository.setContextAndLastInteracted(vaultId) + 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.Unparseable, 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() + vaultContextRepository.setContextAndLastInteracted(vaultId) + seed(title = "Fine", blob = passwordJson("ada", "https://example.com", "pw"), type = 1) + seed(title = "Broken", blob = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13), 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() + vaultContextRepository.setContextAndLastInteracted(vaultId) + 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.transactionCount) + assertFalse(dbFile.exists()) + } + + @Test + fun `deletes a stale v2 development database without importing anything`() = runTest { + seedVault() + vaultContextRepository.setContextAndLastInteracted(vaultId) + // 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 the probe answers null for and which must + // be left exactly where it was found. + database.close() + 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()) + assertFalse(keyStoreCleared) + } + + @Test + fun `does nothing at all when no legacy file exists`() = runTest { + seedVault() + vaultContextRepository.setContextAndLastInteracted(vaultId) + database.close() + databaseFiles.delete() + + assertEquals(LegacyMigrationOutcome.NothingToMigrate, useCase()) + assertNull(loginRepository.observeLogins().first().firstOrNull()) + assertFalse(keyStoreCleared) + } +} From 01190e0b2bb394bd38d91691c16fcc3550b5285a Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 28 Jul 2026 17:56:07 +0200 Subject: [PATCH 22/31] test(migration): cover the null secure-element probe verdict in the e2e suite The end-to-end no-data-loss suite drove the probe's true and false answers but never null, so a mutation folding Unreadable into NotLegacy (== false -> != true) would delete a corrupt or half-restored file and take the v1 Keystore alias with it while every existing test stayed green. Add a test that corrupts the database file directly, confirming the run fails and the file and Keystore alias survive. Also read back the migrated credit card's fields, which were counted but never asserted, and note why readBackPassword's missing wrapped item key is only safe because the fake crypto provider ignores it. Co-Authored-By: Claude Opus 5 --- .../LegacyMigrationEndToEndTest.kt | 86 ++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) 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 index 84fa2b4ce..7b6e1e5a4 100644 --- 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 @@ -12,13 +12,16 @@ 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.core.util.domain.resolver.RegistrableDomainResolver import de.davis.keygo.migration.legacy_data.data.crypto.LegacyAesGcmCipher @@ -47,6 +50,7 @@ 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.Cipher import javax.crypto.KeyGenerator import javax.crypto.SecretKey @@ -156,6 +160,22 @@ class LegacyMigrationEndToEndTest { private val legacyCipher = LegacyAesGcmCipher(legacyKeyProvider) + /** 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, // The production probe rather than a fake. This is the answer that decides whether the @@ -189,7 +209,7 @@ class LegacyMigrationEndToEndTest { cryptographicScopeProvider = cryptoProvider, vaultRepository = vaultRepository, vaultContextRepository = vaultContextRepository, - upsertVaultItem = UpsertVaultItemUseCase(loginRepository, creditCardRepository), + upsertVaultItem = UpsertVaultItemUseCase(loginRepository, cardIdCapturingRepository), transactionRunner = transactionRunner, ) @@ -267,6 +287,10 @@ class LegacyMigrationEndToEndTest { 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), ), @@ -274,6 +298,38 @@ class LegacyMigrationEndToEndTest { 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() @@ -305,6 +361,12 @@ class LegacyMigrationEndToEndTest { ) 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(keyStoreCleared) assertFalse(File(dbFile.absolutePath).exists()) assertFalse(File(dbFile.absolutePath + "-wal").exists()) @@ -400,6 +462,28 @@ class LegacyMigrationEndToEndTest { assertFalse(keyStoreCleared) } + /** + * The probe's contract is three-valued on purpose: true, false, or null for a file it could + * not even open. Null is not a no. `LegacyItemRepositoryImpl.state()` only reaches `NotLegacy`, + * the verdict that deletes the file, on a provable false; a file it cannot inspect at all has + * to fall to `Unreadable` 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 the production probe hits its own catch and + * answers null, which is the one verdict nothing else in this suite drives. + */ + @Test + fun `keeps a file it could not inspect at all`() = runTest { + seedVault() + vaultContextRepository.setContextAndLastInteracted(vaultId) + dbFile.writeBytes(byteArrayOf(0, 1, 2, 3)) + + assertIs(useCase()) + assertTrue(dbFile.exists()) + assertFalse(keyStoreCleared) + } + @Test fun `does nothing at all when no legacy file exists`() = runTest { seedVault() From 4df0665ea5659a6478bbb0eee251e1f30bf94741 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Wed, 29 Jul 2026 16:44:55 +0200 Subject: [PATCH 23/31] fix(migration): chunk the prune delete and report a file the import could not clear SQLite's bound-parameter limit was 999 until 3.32.0, a ceiling Android's system SQLite still enforces through API 30. prune() bound one parameter per legacy id in a single IN (...) delete with no chunking, so a v1 vault over that size would have every prune throw, be reported as a clean success, and reimport the whole vault on every subsequent unlock. Chunk the delete, and give the report a fileRetained flag so a prune/count/delete failure is reported even when no row failed to read or convert - hasFailures alone couldn't see that ending. Found by the final whole-branch review across all 22 commits on this branch. --- .../repository/LegacyItemRepositoryImpl.kt | 17 +++-- .../domain/model/LegacyMigration.kt | 15 ++++- .../usecase/MigrateLegacyDataUseCase.kt | 22 ++++-- .../usecase/StartLegacyDataImportUseCase.kt | 37 ++++++---- .../LegacyItemRepositoryImplTest.kt | 67 +++++++++++++++++++ .../domain/usecase/LegacyImportRunnerTest.kt | 45 +++++++++++++ .../usecase/MigrateLegacyDataUseCaseTest.kt | 11 ++- 7 files changed, 189 insertions(+), 25 deletions(-) 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 index 6c1b880f9..3055e7344 100644 --- 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 @@ -23,6 +23,15 @@ 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, @@ -68,9 +77,9 @@ internal class LegacyItemRepositoryImpl( // tell the user every entry was individually damaged when the entries are intact, and it // could not be told apart from a file that really is corrupt end to end. // - // Asked before the rows are read, because reading them is the first query and the first - // query is what runs the 2-to-3 recreate. That rewrite is a one-way door, and a run that - // cannot decrypt a single blob has no business putting the user's file through it. + // Note this does not gate the 2-to-3 recreate: `state()`'s own `count()` query is the first + // one against the file and has already run the recreate by the time this is reached. What + // this probe still buys is not putting the row loop through a key that is already known gone. keyProvider.secretKey().asResult(LegacyReadFailure.KeyUnavailable).bind() val rows = withDao { it.getAllWithTags() }.bind() @@ -99,7 +108,7 @@ internal class LegacyItemRepositoryImpl( override suspend fun prune(legacyIds: List): Result { if (legacyIds.isEmpty()) return Result.Success(Unit) - return withDao { it.deleteByIds(legacyIds) } + return withDao { dao -> legacyIds.chunked(PRUNE_CHUNK_SIZE).forEach { dao.deleteByIds(it) } } } override suspend fun remainingCount(): Result = withDao { it.count() } diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigration.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigration.kt index 772297eec..92c0927e7 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigration.kt +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigration.kt @@ -58,13 +58,26 @@ data class LegacyMigrationReport( * patched up on the way in rather than read as v1 left it. */ val repairedRows: Int = 0, + + /** + * 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() } /** * Why a whole run stopped. Carried by [LegacyMigrationOutcome.Failed], which never reports a - * partial import: whatever this describes, the legacy file was left exactly as it was found. + * 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 `state()` issues one before this run is ever attempted; 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, 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 index a63f981e3..6e6521c9e 100644 --- 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 @@ -156,20 +156,26 @@ class MigrateLegacyDataUseCase internal constructor( // thing that could turn it into a loss. val pruned = legacyItemRepository.prune(writtenIds).isSuccess() - deleteWhenFullyImported(hasFailures = failures.isNotEmpty(), pruned = pruned) + // Read before the deletion below, which can close the provider. The count itself survives + // that close, but reading it first means the report never depends on that being true. + val repairedRows = legacyItemRepository.repairedRows + + val fileDeleted = deleteWhenFullyImported(hasFailures = failures.isNotEmpty(), pruned = pruned) return LegacyMigrationOutcome.Migrated( LegacyMigrationReport( migratedItems = writtenIds.size, failures = failures, - repairedRows = legacyItemRepository.repairedRows, + repairedRows = repairedRows, + fileRetained = !fileDeleted, ), ) } /** * Removes the inherited file and v1's Keystore alias, and only once everything that was in the - * file is in v2. + * file is in v2. Returns whether the file actually went, so the caller can tell a run that + * cleaned up fully apart from one that quietly could not. * * Three things have to hold, and any one of them missing leaves the file alone. No row failed * to read or convert; the prune succeeded, so the rows are gone from the file rather than @@ -179,11 +185,13 @@ class MigrateLegacyDataUseCase internal constructor( * The alias goes last and only if the file actually went. Removing the key while encrypted rows * are still on disk would make them unreadable for good. */ - private suspend fun deleteWhenFullyImported(hasFailures: Boolean, pruned: Boolean) { - if (hasFailures || !pruned) return - if (legacyItemRepository.remainingCount().getOrNull() != 0) return + private suspend fun deleteWhenFullyImported(hasFailures: Boolean, pruned: Boolean): Boolean { + if (hasFailures || !pruned) return false + if (legacyItemRepository.remainingCount().getOrNull() != 0) return false - if (legacyItemRepository.deleteDatabase()) legacyKeyStoreCleaner.deleteLegacyKey() + if (!legacyItemRepository.deleteDatabase()) return false + legacyKeyStoreCleaner.deleteLegacyKey() + return true } private suspend fun convert( diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/StartLegacyDataImportUseCase.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/StartLegacyDataImportUseCase.kt index 52585204e..9f579c48b 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/StartLegacyDataImportUseCase.kt +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/StartLegacyDataImportUseCase.kt @@ -57,8 +57,11 @@ class StartLegacyDataImportUseCase internal constructor( * not a clean success is turned into one call to [report]. [LegacyMigrationOutcome.Failed] reports * its cause directly. A [LegacyMigrationOutcome.Migrated] whose report carries row failures also * reports, because a run that silently drops some of the user's entries needs a trace even though - * skipping a bad row is the designed behaviour. [LegacyMigrationOutcome.NothingToMigrate] and a - * [LegacyMigrationOutcome.Migrated] with no row failures are the normal endings and report nothing. + * skipping a bad row is the designed behaviour. So does one whose file could not be cleared even + * though every row it looked at imported cleanly: that run will reimport the whole vault on the + * next unlock, and that is exactly the kind of ending "no row failures" must not be allowed to + * paper over. [LegacyMigrationOutcome.NothingToMigrate] and a [LegacyMigrationOutcome.Migrated] + * with no row failures and no file left behind are the only endings that report nothing. * * 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 @@ -101,22 +104,32 @@ internal class LegacyImportRunner( is LegacyMigrationOutcome.Failed -> reportSafely("v1 import failed, retrying on the next unlock", outcome.cause) - is LegacyMigrationOutcome.Migrated -> if (outcome.report.hasFailures) - reportSafely(rowFailureSummary(outcome.report), null) + is LegacyMigrationOutcome.Migrated -> if ( + outcome.report.hasFailures || outcome.report.fileRetained + ) + reportSafely(migrationSummary(outcome.report), null) LegacyMigrationOutcome.NothingToMigrate -> Unit } } - private fun rowFailureSummary(migrationReport: LegacyMigrationReport): String { - // 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 + private fun migrationSummary(migrationReport: LegacyMigrationReport): String { + val parts = mutableListOf() - return "v1 import finished with ${migrationReport.failures.size} of $total row(s) " + - "skipped: $byReason" + 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("; ")}" } /** 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 index 3e1a9a11c..a3daa4322 100644 --- 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 @@ -1,6 +1,7 @@ package de.davis.keygo.migration.legacy_data.data.repository import android.content.Context +import androidx.room.InvalidationTracker import androidx.room.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.execSQL @@ -110,6 +111,39 @@ private class CancellingLegacyElementDao : LegacyElementDao { error("this fake is only ever read from") } +/** Records how many ids each delete call carried, and actually removes them from [ids]. */ +private class RecordingLegacyElementDao(private val ids: MutableSet) : LegacyElementDao { + + val deleteCalls = mutableListOf>() + + override suspend fun count(): Int = ids.size + + override suspend fun getAllWithTags(): List = + error("this fake is only ever pruned from") + + override suspend fun deleteByIds(ids: List) { + deleteCalls += ids + this.ids -= ids.toSet() + } + + override suspend fun insertElement(element: LegacySecureElementEntity): Long = + error("this fake is only ever pruned from") + + override suspend fun insertTag(tag: LegacyTagEntity): Long = + error("this fake is only ever pruned from") + + override suspend fun insertCrossRef(crossRef: LegacySecureElementTagCrossRef): Unit = + error("this fake is only ever pruned from") +} + +/** A plain subclass rather than Room's builder, since only [legacyElementDao] is ever called. */ +private class RecordingLegacyDatabase(private val dao: LegacyElementDao) : LegacyDatabase() { + override fun legacyElementDao(): LegacyElementDao = dao + override fun createInvalidationTracker(): InvalidationTracker = + error("this fake is only ever pruned through") + override fun clearAllTables(): Unit = error("this fake is only ever pruned through") +} + /** Hands out one already-open in-memory database, or nothing when the file is unreadable. */ private class FakeLegacyDatabaseProvider( private val database: LegacyDatabase?, @@ -349,6 +383,39 @@ class LegacyItemRepositoryImplTest { assertTrue(keep > 0) } + /** + * 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 ids = (1L..1200L).toMutableSet() + val dao = RecordingLegacyElementDao(ids) + val repo = LegacyItemRepositoryImpl( + databaseProvider = FakeLegacyDatabaseProvider(RecordingLegacyDatabase(dao)), + secureElementProbe = secureElementProbe, + keyProvider = keyProvider, + cipher = FakeLegacyCipher(), + parser = LegacyDetailParser(), + databaseFiles = databaseFiles, + ) + + 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(ids.isEmpty()) + } + @Test fun `reads an empty database as no items and no failures`() = runTest { val result = repository.readAll().assertSuccess() 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 index 96b6c0d73..7735f89cd 100644 --- 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 @@ -224,6 +224,26 @@ class LegacyImportRunnerTest { ) } + @Test + fun `a Migrated outcome with no row failures but a retained file still reports`() = runTest { + val report = LegacyMigrationReport(migratedItems = 3, failures = emptyList(), fileRetained = true) + val diagnostics = mutableListOf>() + val import = RecordingImport { LegacyMigrationOutcome.Migrated(report) } + val runner = runnerFor(import, diagnostics) + + runner.start() + runCurrent() + + assertEquals( + 1, + diagnostics.size, + "Every row imported cleanly, but the legacy file survived the run. That is exactly the " + + "ending that duplicates the whole vault on the next unlock, and `hasFailures` alone " + + "cannot see it: there were none.", + ) + assertTrue(diagnostics.single().first.contains("retried")) + } + @Test fun `NothingToMigrate and a clean Migrated produce no diagnostic`() = runTest { val diagnostics = mutableListOf>() @@ -250,4 +270,29 @@ class LegacyImportRunnerTest { "may produce a 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 is a bug in the reporting seam, not in the import. It must not be " + + "able to do what a throwing import already cannot: take the whole application scope " + + "down with it.", + ) + } } 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 index 019f1bbd3..99636f1e4 100644 --- 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 @@ -192,6 +192,7 @@ class MigrateLegacyDataUseCaseTest { assertEquals(2, outcome.report.migratedItems) assertFalse(outcome.report.hasFailures) + assertFalse(outcome.report.fileRetained) assertEquals(listOf(1L, 2L), legacyRepository.prunedIds) assertTrue(legacyRepository.databaseDeleted) assertTrue(keyStoreCleaner.deleted) @@ -399,6 +400,11 @@ class MigrateLegacyDataUseCaseTest { assertEquals(1, outcome.report.migratedItems) assertFalse(legacyRepository.databaseDeleted) assertFalse(keyStoreCleaner.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 @@ -412,6 +418,7 @@ class MigrateLegacyDataUseCaseTest { assertEquals(1, outcome.report.migratedItems) assertFalse(legacyRepository.databaseDeleted) assertFalse(keyStoreCleaner.deleted) + assertTrue(outcome.report.fileRetained) } @Test @@ -420,10 +427,11 @@ class MigrateLegacyDataUseCaseTest { seedRows(items = listOf(password(1L, "One"))) legacyRepository.countResult = Result.Failure(LegacyReadFailure.DatabaseUnreadable) - assertIs(useCase()()) + val outcome = assertIs(useCase()()) assertFalse(legacyRepository.databaseDeleted) assertFalse(keyStoreCleaner.deleted) + assertTrue(outcome.report.fileRetained) } @Test @@ -437,5 +445,6 @@ class MigrateLegacyDataUseCaseTest { assertEquals(1, outcome.report.migratedItems) assertFalse(legacyRepository.databaseDeleted) assertFalse(keyStoreCleaner.deleted) + assertTrue(outcome.report.fileRetained) } } From 3606ca6d17859311e40514f2f133a9f860184d9c Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Wed, 29 Jul 2026 21:22:25 +0200 Subject: [PATCH 24/31] refactor(legacy-data): cleanup --- build.gradle.kts | 1 + .../data/local/datasource/DatabaseNameTest.kt | 34 -- .../core/item/FakeCreditCardRepository.kt | 2 +- .../auth/presentation/AuthViewModel.kt | 23 +- .../AuthViewModelMigrationTest.kt | 55 --- gradle/libs.versions.toml | 5 + .../datastore/MainPasswordSerializer.kt | 2 +- .../data/repository/HashValidatorImpl.kt | 2 +- .../repository/MainPasswordRepositoryImpl.kt | 2 +- .../di/MigrationCreateAccessModule.kt | 2 +- .../domain/model/MainPassword.kt | 2 +- .../domain/repository/HashValidator.kt | 2 +- .../repository/MainPasswordRepository.kt | 2 +- .../usecase/ClearMainPasswordUseCase.kt | 2 +- .../domain/usecase/HasMainPasswordUseCase.kt | 2 +- ...word.kt => ValidateMainPasswordUseCase.kt} | 8 +- migration/legacy-data/build.gradle.kts | 24 +- .../data/crypto/LegacyAesGcmCipher.kt | 35 ++ .../legacy_data/data/crypto/LegacyCipher.kt | 35 +- .../data/crypto/LegacyKeyProvider.kt | 30 -- .../data/local/dao/LegacyElementDao.kt | 8 +- .../AndroidLegacyDatabaseProvider.kt | 60 +++ .../data/local/datasource/LegacyDatabase.kt | 57 +-- .../datasource/LegacyDatabaseProvider.kt | 18 + .../datasource/LegacyDatabaseSanitizer.kt | 66 --- .../datasource/LegacySecureElementProbe.kt | 76 ---- .../SanitizingLegacyDatabaseProvider.kt | 79 ---- .../local/entity/LegacySecureElementEntity.kt | 8 +- .../entity/LegacySecureElementTagCrossRef.kt | 6 +- .../data/local/entity/LegacyTagEntity.kt | 6 +- .../local/migration/LegacyMigration2To3.kt | 91 ++++ .../local/migration/LegacyMigrationSpecs.kt | 20 - .../data/local/pojo/LegacyElementWithTags.kt | 15 +- ...DomainMapper.kt => LegacyItemConverter.kt} | 38 +- .../repository/LegacyItemRepositoryImpl.kt | 89 ++-- .../repository/LegacyKeyRepositoryImpl.kt | 33 ++ .../repository/LegacyKeyStoreCleanerImpl.kt | 31 -- .../legacy_data/di/LegacyDatabaseModule.kt | 25 +- .../legacy_data/domain/model/LegacyDetail.kt | 47 +++ .../domain/model/LegacyFailureReason.kt | 19 + .../legacy_data/domain/model/LegacyItem.kt | 55 +-- .../domain/model/LegacyMigration.kt | 96 ----- .../domain/model/LegacyMigrationOutcome.kt | 24 ++ .../domain/model/LegacyMigrationReport.kt | 23 ++ .../domain/model/LegacyReadFailure.kt | 37 ++ .../domain/model/LegacyStrength.kt | 13 + .../domain/repository/LegacyItemRepository.kt | 59 +-- .../domain/repository/LegacyKeyRepository.kt | 16 + .../repository/LegacyKeyStoreCleaner.kt | 16 - .../domain/usecase/HasLegacyDataUseCase.kt | 30 -- .../domain/usecase/LegacyImportRunner.kt | 108 +++++ .../usecase/MigrateLegacyDataUseCase.kt | 75 ++-- .../usecase/StartLegacyDataImportUseCase.kt | 106 ----- .../LegacyMigrationEndToEndTest.kt | 176 +++----- .../LegacyMigrationRealDatabaseTest.kt | 232 +++++++++++ .../data/crypto/LegacyAesGcmCipherTest.kt | 5 +- .../data/local/LegacyDatabaseOpenTest.kt | 107 +---- .../data/local/LegacyDatabaseSanitizerTest.kt | 180 -------- .../data/local/LegacyMigrationTest.kt | 177 ++++++++ .../data/local/LegacySchemaIdentityTest.kt | 67 +-- .../data/local/LegacySchemaSeed.kt | 100 +++-- .../local/LegacySecureElementProbeTest.kt | 110 ----- .../data/local/LegacyTestContext.kt | 32 ++ .../SanitizingLegacyDatabaseProviderTest.kt | 140 ------- ...pperTest.kt => LegacyItemConverterTest.kt} | 29 +- .../LegacyItemRepositoryImplTest.kt | 389 ++++-------------- .../usecase/HasLegacyDataUseCaseTest.kt | 64 --- .../domain/usecase/LegacyImportRunnerTest.kt | 4 +- .../usecase/MigrateLegacyDataUseCaseTest.kt | 140 +++---- .../legacy-fixtures/secure_element_database | Bin 0 -> 4096 bytes .../secure_element_database-shm | Bin 0 -> 32768 bytes .../secure_element_database-wal | Bin 0 -> 152472 bytes .../src/test/resources/legacy-schemas/1.json | 78 ---- .../src/test/resources/legacy-schemas/2.json | 97 ----- .../src/test/resources/legacy-schemas/3.json | 176 -------- .../legacy_data/data/FakeLegacyCipher.kt | 29 ++ .../legacy_data/data/FakeLegacyDatabase.kt | 24 ++ .../data/FakeLegacyDatabaseProvider.kt | 41 ++ .../legacy_data/data/FakeLegacyElementDao.kt | 50 +++ .../data}/FakeLegacyItemRepository.kt | 30 +- .../data/FakeLegacyKeyRepository.kt | 29 ++ .../data/FakeRegistrableDomainResolver.kt | 17 + 82 files changed, 1657 insertions(+), 2586 deletions(-) delete mode 100644 core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/datasource/DatabaseNameTest.kt delete mode 100644 feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelMigrationTest.kt rename migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/{ValidateMainPassword.kt => ValidateMainPasswordUseCase.kt} (78%) create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyAesGcmCipher.kt delete mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyKeyProvider.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/AndroidLegacyDatabaseProvider.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabaseProvider.kt delete mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabaseSanitizer.kt delete mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacySecureElementProbe.kt delete mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/SanitizingLegacyDatabaseProvider.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/migration/LegacyMigration2To3.kt delete mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/migration/LegacyMigrationSpecs.kt rename migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/{LegacyItemToDomainMapper.kt => LegacyItemConverter.kt} (83%) create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyKeyRepositoryImpl.kt delete mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyKeyStoreCleanerImpl.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyDetail.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyFailureReason.kt delete mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigration.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigrationOutcome.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigrationReport.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyReadFailure.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyStrength.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/repository/LegacyKeyRepository.kt delete mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/repository/LegacyKeyStoreCleaner.kt delete mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/HasLegacyDataUseCase.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/LegacyImportRunner.kt create mode 100644 migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/LegacyMigrationRealDatabaseTest.kt delete mode 100644 migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseSanitizerTest.kt create mode 100644 migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyMigrationTest.kt delete mode 100644 migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacySecureElementProbeTest.kt create mode 100644 migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyTestContext.kt delete mode 100644 migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/SanitizingLegacyDatabaseProviderTest.kt rename migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/{LegacyItemToDomainMapperTest.kt => LegacyItemConverterTest.kt} (91%) delete mode 100644 migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/HasLegacyDataUseCaseTest.kt create mode 100644 migration/legacy-data/src/test/resources/legacy-fixtures/secure_element_database create mode 100644 migration/legacy-data/src/test/resources/legacy-fixtures/secure_element_database-shm create mode 100644 migration/legacy-data/src/test/resources/legacy-fixtures/secure_element_database-wal delete mode 100644 migration/legacy-data/src/test/resources/legacy-schemas/1.json delete mode 100644 migration/legacy-data/src/test/resources/legacy-schemas/2.json delete mode 100644 migration/legacy-data/src/test/resources/legacy-schemas/3.json create mode 100644 migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyCipher.kt create mode 100644 migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyDatabase.kt create mode 100644 migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyDatabaseProvider.kt create mode 100644 migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyElementDao.kt rename migration/legacy-data/src/{test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase => testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data}/FakeLegacyItemRepository.kt (73%) create mode 100644 migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyKeyRepository.kt create mode 100644 migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeRegistrableDomainResolver.kt 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/src/test/kotlin/de/davis/keygo/core/item/data/local/datasource/DatabaseNameTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/datasource/DatabaseNameTest.kt deleted file mode 100644 index e91bb1149..000000000 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/datasource/DatabaseNameTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -package de.davis.keygo.core.item.data.local.datasource - -import java.io.File -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -/** - * v1 shipped its Room database as `secure_element_database` under the same applicationId. If - * `ItemDatabase` ever takes that name back, Room reads version 3 out of the inherited v1 file, - * compares it against version 1 and throws, and `:migration:legacy-data` loses the file it reads - * from. Guarding the literal is cheaper than rediscovering that on a device. - */ -class DatabaseNameTest { - - private val source = File("src/main/kotlin/de/davis/keygo/core/item/data/local/datasource/ItemDatabase.kt") - .readText() - - @Test - fun `does not reuse the v1 database file name`() { - assertFalse( - source.contains("secure_element_database"), - "ItemDatabase must not use the v1 file name; :migration:legacy-data reads that file.", - ) - } - - @Test - fun `uses the expected database file name`() { - assertTrue( - source.contains("\"keygo_database\""), - "ItemDatabase file name changed; update :migration:legacy-data and this guard together.", - ) - } -} 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/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 dee5fce0e..5906d48e8 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 de.davis.keygo.migration.legacy_data.domain.usecase.StartLegacyDataImportUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -51,7 +51,7 @@ internal class AuthViewModel( // ---- Migration ---- hasV1MainPassword: HasMainPasswordUseCase, - private val validateMainPassword: ValidateMainPassword, + private val validateMainPassword: ValidateMainPasswordUseCase, private val clearMainPasswordUseCase: ClearMainPasswordUseCase, private val startLegacyDataImport: StartLegacyDataImportUseCase, // ------------------- @@ -183,10 +183,9 @@ internal class AuthViewModel( loading(setLoading = password.isNotBlank()) { unlockWithPassword( password = password - ).onSuccess { startLegacyDataImport() } - .handleAuthenticationResult { - copyDefaultState(passwordError = UIPasswordError.Incorrect) - } + ).handleAuthenticationResult { + copyDefaultState(passwordError = UIPasswordError.Incorrect) + } } } @@ -252,9 +251,7 @@ internal class AuthViewModel( ) { if (!authState.biometricsAvailable || !authState.useBiometrics) { loading { - createAllAccesses(password = password) - .onSuccess { startLegacyDataImport() } - .handleAuthenticationResult() + createAllAccesses(password = password).handleAuthenticationResult() } return @@ -283,7 +280,10 @@ internal class AuthViewModel( LoadingScope( state = it, - onSuccess = { navigationEventChannel.trySend(Unit) }, + onSuccess = { + startLegacyDataImport() + navigationEventChannel.trySend(Unit) + }, ).apply { block() }.updatedState.copyDefaultState(loading = false) @@ -299,8 +299,7 @@ internal class AuthViewModel( createAllAccesses( password = createAccessRequest.password, biometricCipher = cipher - ).onSuccess { startLegacyDataImport() } - .handleAuthenticationResult() + ).handleAuthenticationResult() } } diff --git a/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelMigrationTest.kt b/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelMigrationTest.kt deleted file mode 100644 index ce84c05e5..000000000 --- a/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelMigrationTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -package de.davis.keygo.feature.auth.presentation - -import java.io.File -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -/** - * `AuthViewModel` cannot be built in a JVM unit test at all: its `SavedStateHandle.toRoute` call - * lands in `android.os.BaseBundle`, which throws "not mocked" here. So what the import actually - * does is covered where it can be, in `LegacyImportRunnerTest` over in `:migration:legacy-data`, - * and what is left for this file is the wiring that test assumes. Asserting on source text is a - * weak way to do it, but the thing it catches is real: someone adding a fifth way into the app and - * forgetting the import. - * - * If you add another successful-session-start path, add the call and bump the expected count. - */ -class AuthViewModelMigrationTest { - - private val viewModelSource = sourceOf("AuthViewModel") - - private val screenSource = sourceOf("AuthScreen") - - @Test - fun `every successful session start runs the legacy migration`() { - assertEquals( - 4, - Regex("""startLegacyDataImport\(\)""").findAll(viewModelSource).count(), - "Expected the import on the password-create, biometric-create, password-unlock " + - "and biometric-unlock paths.", - ) - } - - @Test - fun `biometric unlock hands control back to the view model`() { - assertTrue( - screenSource.contains("viewModel.onBiometricUnlockSucceeded()"), - "Biometric unlock must not call onSuccess directly; the migration would never run.", - ) - assertTrue( - viewModelSource.contains("fun onBiometricUnlockSucceeded()"), - "AuthScreen calls onBiometricUnlockSucceeded; AuthViewModel must declare it.", - ) - } - - private fun sourceOf(name: String): String { - val file = File("src/main/kotlin/de/davis/keygo/feature/auth/presentation/$name.kt") - // Read relative to the module directory, which is where Gradle runs these tests from. Named - // rather than left to fail on an empty string, so a runner with a different working - // directory says so instead of turning every assertion below into a puzzle. - assertTrue(file.exists(), "Expected to find $name.kt at ${file.absolutePath}") - - return file.readText() - } -} 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/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 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/repository/HashValidatorImpl.kt b/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 index c1aad8a37..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 @@ -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 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/domain/model/MainPassword.kt b/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 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 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 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 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/legacy-data/build.gradle.kts b/migration/legacy-data/build.gradle.kts index 95c19f1ed..e6e1aafa3 100644 --- a/migration/legacy-data/build.gradle.kts +++ b/migration/legacy-data/build.gradle.kts @@ -1,18 +1,21 @@ plugins { alias(libs.plugins.keygo.android.library) - alias(libs.plugins.androidx.room) + 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.room.runtime) - implementation(libs.androidx.room.ktx) - ksp(libs.androidx.room.compiler) + implementation(libs.androidx.room3.runtime) + ksp(libs.androidx.room3.compiler) implementation(libs.kotlinx.serialization.json) @@ -21,12 +24,23 @@ dependencies { 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) } -room { +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/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..2e873192a --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyAesGcmCipher.kt @@ -0,0 +1,35 @@ +package de.davis.keygo.migration.legacy_data.data.crypto + +import de.davis.keygo.migration.legacy_data.domain.repository.LegacyKeyRepository +import org.koin.core.annotation.Single +import javax.crypto.Cipher +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( + private val keyRepository: LegacyKeyRepository, +) : LegacyCipher { + + override fun decrypt(blob: ByteArray): ByteArray? { + if (blob.size <= IV_SIZE) return null + val key = keyRepository.secretKey() ?: 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/crypto/LegacyCipher.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyCipher.kt index 53d2268d3..8d99e85e6 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyCipher.kt +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyCipher.kt @@ -1,40 +1,7 @@ package de.davis.keygo.migration.legacy_data.data.crypto -import org.koin.core.annotation.Single -import javax.crypto.Cipher -import javax.crypto.spec.GCMParameterSpec - -internal interface LegacyCipher { +internal fun interface LegacyCipher { /** Returns null when the blob cannot be decrypted for any reason. */ fun decrypt(blob: ByteArray): ByteArray? } - -/** - * 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( - private val keyProvider: LegacyKeyProvider, -) : LegacyCipher { - - override fun decrypt(blob: ByteArray): ByteArray? { - if (blob.size <= IV_SIZE) return null - val key = keyProvider.secretKey() ?: 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/crypto/LegacyKeyProvider.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyKeyProvider.kt deleted file mode 100644 index 56fbf80da..000000000 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyKeyProvider.kt +++ /dev/null @@ -1,30 +0,0 @@ -package de.davis.keygo.migration.legacy_data.data.crypto - -import org.koin.core.annotation.Single -import java.security.KeyStore -import javax.crypto.SecretKey - -internal const val LEGACY_KEY_ALIAS = "password_manager_skey" - -/** - * Supplies the AES key v1 used to encrypt every `SecureElement.data` blob. - * - * Returns null when the alias is gone. It must never create a key: v1's own `KeyUtil.getSecretKey` - * generated one on a miss, and doing the same here would silently turn "this row cannot be read" - * into "this row decrypts to garbage". A null means the legacy data is unrecoverable, which the - * caller reports rather than papers over. - */ -internal interface LegacyKeyProvider { - - fun secretKey(): SecretKey? -} - -@Single -internal class AndroidLegacyKeyProvider : LegacyKeyProvider { - - override fun secretKey(): SecretKey? = runCatching { - val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - if (!keyStore.containsAlias(LEGACY_KEY_ALIAS)) return null - keyStore.getKey(LEGACY_KEY_ALIAS, null) as? SecretKey - }.getOrNull() -} 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 index bf0248047..d018bec94 100644 --- 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 @@ -1,9 +1,9 @@ package de.davis.keygo.migration.legacy_data.data.local.dao -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.Query -import androidx.room.Transaction +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 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..6525dd87d --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/AndroidLegacyDatabaseProvider.kt @@ -0,0 +1,60 @@ +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 that Room is never pointed at a path that is not there. + * + * @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) + // This module only ever reads a database it inherited, and 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 index 5b47c3228..20d176836 100644 --- 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 @@ -1,14 +1,12 @@ package de.davis.keygo.migration.legacy_data.data.local.datasource -import androidx.room.AutoMigration -import androidx.room.Database -import androidx.room.RoomDatabase +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 -import de.davis.keygo.migration.legacy_data.data.local.migration.LegacyMigrationSpec1To2 -import de.davis.keygo.migration.legacy_data.data.local.migration.LegacyMigrationSpec2To3 internal const val LEGACY_DATABASE_NAME = "secure_element_database" @@ -16,20 +14,21 @@ 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. The two - * auto-migrations are generated from v1's own exported `1.json` and `2.json`, which is why 2-to-3 - * needs no hand-written SQL despite being a table recreate. + * 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 auto-migrations, which permanently rewrite the file to version 3 and drop its + * 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, measured as 2 in - * `LegacyDatabaseOpenTest`. That is what makes `LegacyDatabaseSanitizer`'s "below version 3 only" - * guard useful on a retry: a file whose recreate aborted still reads as version 2, so the sanitizer - * still sees it and can repair it. + * 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, @@ -38,40 +37,10 @@ internal const val LEGACY_DATABASE_NAME = "secure_element_database" LegacyTagEntity::class, LegacySecureElementTagCrossRef::class, ], - autoMigrations = [ - AutoMigration(from = 1, to = 2, spec = LegacyMigrationSpec1To2::class), - AutoMigration(from = 2, to = 3, spec = LegacyMigrationSpec2To3::class), - ], + autoMigrations = [AutoMigration(from = 1, to = 2)], exportSchema = true, ) internal abstract class LegacyDatabase : RoomDatabase() { abstract fun legacyElementDao(): LegacyElementDao } - -/** - * Opens the legacy database on demand. - * - * Indirection rather than an injected instance for two reasons: on a clean v2 install the file - * never exists and eagerly opening it would create an empty one, and the repository re-opens after - * `deleteDatabase` closes the previous handle. - */ -internal interface LegacyDatabaseProvider { - - /** - * Returns null when there is no file to open, or when the file that is there cannot be made - * openable because it is corrupt, truncated, or not a SQLite database at all. Null rather than - * a throw, because a foreign file sitting where v1's used to be is an ordinary thing to find on - * a migration path and the caller reports it. - * - * 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? - - /** Rows repaired to make the file openable, counted across every open so far. */ - val repairedRows: Int - - /** Closes the open handle, if there is one. The next [get] opens a fresh one. */ - fun close() -} 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/datasource/LegacyDatabaseSanitizer.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabaseSanitizer.kt deleted file mode 100644 index c09458505..000000000 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabaseSanitizer.kt +++ /dev/null @@ -1,66 +0,0 @@ -package de.davis.keygo.migration.legacy_data.data.local.datasource - -import androidx.sqlite.SQLiteConnection -import androidx.sqlite.SQLiteDriver -import androidx.sqlite.driver.AndroidSQLiteDriver -import androidx.sqlite.execSQL -import java.io.File - -/** - * Repairs the rows in an inherited v1 file that would otherwise abort Room's 2-to-3 recreate. - * - * Versions 1 and 2 declare `title` and `data` nullable while version 3 declares both NOT NULL, and - * the generated recreate copies rows across with a plain `INSERT ... SELECT`. A single row carrying - * a real NULL therefore makes the whole file unopenable, which costs the user every other row as - * well. `LegacyDatabaseOpenTest` pins that hazard so it stays visible. - * - * The repair is deliberately the smallest thing that clears the constraint: an empty title and an - * empty blob, never a deletion and never an invented placeholder. An empty blob cannot decrypt, so - * the per-row skip policy reports such a row as one failed item instead of the whole import dying. - * - * Running before Room opens the file leaves the auto-migrations and the identity hash untouched. - */ -internal class LegacyDatabaseSanitizer( - private val driver: SQLiteDriver = AndroidSQLiteDriver(), -) { - - /** - * Repairs [path] and returns the number of rows it touched, so the migration can report it. - * Returns 0 and changes nothing when there is nothing to repair. - */ - fun sanitize(path: String): Int { - // Opening read-write creates the file, which would make a clean v2 install look like it - // inherited a legacy database. - if (!File(path).exists()) return 0 - - return driver.open(path).use { connection -> - // A version 3 file already survived the recreate, and its columns are NOT NULL anyway. - if (connection.userVersion() >= 3) return@use 0 - // No SecureElement means this is not a v1 file. Leave it alone rather than throw. - if (!connection.hasSecureElementTable()) return@use 0 - - // The count read and both updates run as one transaction: this is the user's only copy - // of that data, and autocommit would let a crash between statements leave it half-repaired. - connection.execSQL("BEGIN") - try { - val repaired = connection.countRepairableRows() - connection.execSQL("UPDATE SecureElement SET title = '' WHERE title IS NULL") - connection.execSQL("UPDATE SecureElement SET data = x'' WHERE data IS NULL") - connection.execSQL("END") - repaired - } catch (e: Exception) { - connection.execSQL("ROLLBACK") - throw e - } - } - } - - private fun SQLiteConnection.userVersion(): Int = selectInt("PRAGMA user_version") - - /** - * Counted before the updates rather than summed from their change counts, so a row that is null - * in both columns is reported once instead of twice. - */ - private fun SQLiteConnection.countRepairableRows(): Int = - selectInt("SELECT COUNT(*) FROM SecureElement WHERE title IS NULL OR data IS NULL") -} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacySecureElementProbe.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacySecureElementProbe.kt deleted file mode 100644 index 49cdf24b0..000000000 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacySecureElementProbe.kt +++ /dev/null @@ -1,76 +0,0 @@ -package de.davis.keygo.migration.legacy_data.data.local.datasource - -import android.content.Context -import androidx.sqlite.SQLiteConnection -import androidx.sqlite.SQLiteDriver -import androidx.sqlite.driver.AndroidSQLiteDriver - -/** - * Answers the one question that can prove an inherited file holds no v1 data: is v1's - * `SecureElement` table there? - * - * Named and kept apart because that answer is what gets the file deleted. It has to be asked of the - * closed file, before Room is allowed to run the 2-to-3 recreate, and it has to be asked directly. - * Inferring it from a failed query cannot work: a corrupt page, a disk that filled up during the - * recreate and a cancelled run all fail the same way a foreign file does, and reading any of those - * as "not v1's" throws the user's only copy of their data away on a guess. - */ -internal interface LegacySecureElementProbe { - - /** - * True when the table is there, false when the file opened and the table provably is not, and - * null when the file could not be inspected at all. - * - * Null is not a no. A file that will not open tells us nothing about what is inside it, so a - * caller deciding whether to delete has to treat null the way it treats true. - */ - fun hasSecureElementTable(): Boolean? -} - -/** - * Reads the answer off the file itself, with no Room in the way. - * - * @param driver left at the framework helper in production, which is what the rest of this module - * opens files with. A JVM test has no framework helper available, so it passes a bundled driver. - */ -internal class AndroidLegacySecureElementProbe( - private val context: Context, - private val driver: SQLiteDriver = AndroidSQLiteDriver(), -) : LegacySecureElementProbe { - - override fun hasSecureElementTable(): Boolean? { - val path = context.getDatabasePath(LEGACY_DATABASE_NAME) - // Opening read-write creates the file, which would make a clean v2 install look like it - // inherited a legacy database. Null rather than false, because a file that is not there - // says nothing about the file this probe was asked about. - if (!path.exists()) return null - - return try { - driver.open(path.absolutePath).use { it.hasSecureElementTable() } - } catch (_: Exception) { - // Corrupt, truncated, or not a SQLite database at all. Nothing was learned about what - // is inside it, and answering "no table" here is exactly the guess that costs a user - // with a half restored backup everything they had. - null - } - } -} - -/** - * Shared with [LegacyDatabaseSanitizer], which asks the same question of a connection it is already - * holding open. One copy, so the two can never drift into disagreeing about what a v1 file is. - * - * Not parameterized by table name: the callers are compile-time constants, and a String parameter - * here would invite an unescaped literal later. - */ -internal fun SQLiteConnection.hasSecureElementTable(): Boolean = - selectInt( - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'SecureElement'", - ) > 0 - -/** Every call site is a COUNT(*) or a PRAGMA, both of which always return exactly one row. */ -internal fun SQLiteConnection.selectInt(sql: String): Int = - prepare(sql).use { statement -> - check(statement.step()) { "expected a row from: $sql" } - statement.getLong(0).toInt() - } diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/SanitizingLegacyDatabaseProvider.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/SanitizingLegacyDatabaseProvider.kt deleted file mode 100644 index c084f614d..000000000 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/SanitizingLegacyDatabaseProvider.kt +++ /dev/null @@ -1,79 +0,0 @@ -package de.davis.keygo.migration.legacy_data.data.local.datasource - -import android.content.Context -import androidx.room.Room -import androidx.sqlite.SQLiteDriver - -/** - * Repairs the inherited file, then opens it. Never creates one. - * - * The order is the whole point. Room runs the 2-to-3 recreate on the first query, and a single row - * carrying a NULL `title` or `data` aborts it and takes every other row in the file down with it. - * The sanitizer is the only thing standing between one bad row and the user losing all of them, and - * it can only do its work while the file is still closed, so it runs here rather than anywhere - * further in. It is cheap and self-guarding on a file that needs nothing, so every open pays for it. - * - * Both of this class's guards are about the file's state before Room is allowed to touch it, which - * is why they sit together: one repairs what is there, the other refuses to invent what is not. - * - * @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 SanitizingLegacyDatabaseProvider( - private val context: Context, - private val sanitizer: LegacyDatabaseSanitizer = LegacyDatabaseSanitizer(), - private val driver: SQLiteDriver? = null, -) : LegacyDatabaseProvider { - - private var database: LegacyDatabase? = null - - // Written under the lock in `get` but read through a plain getter from whatever thread reports - // the migration, so the write needs to be visible without the reader taking the lock too. - @Volatile - override var repairedRows: Int = 0 - private set - - /** - * 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) - // This module only ever reads a database it inherited, and 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. There is nothing to read here, so - // there is nothing to open. The sanitizer no-ops on a missing file rather than reporting - // one, so this is the guard that has to stop the builder. - if (!path.exists()) return null - - val repaired = try { - sanitizer.sanitize(path.absolutePath) - } catch (_: Exception) { - // The sanitizer has no failure channel of its own: its guards cover a missing file, an - // already migrated one and a non-v1 one, and everything else it meets it opens. A - // corrupt, truncated or foreign file therefore throws out of the driver, the PRAGMA or - // the sqlite_master probe. Nothing can be read out of such a file, so it becomes an - // answer here instead of an exception thrown across the unlock flow. - return null - } - - repairedRows += repaired - return Room.databaseBuilder(context, LegacyDatabase::class.java, LEGACY_DATABASE_NAME) - .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 - } -} 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 index a4477769a..694f33790 100644 --- 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 @@ -1,9 +1,9 @@ package de.davis.keygo.migration.legacy_data.data.local.entity -import androidx.room.ColumnInfo -import androidx.room.Embedded -import androidx.room.Entity -import androidx.room.PrimaryKey +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. 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 index dc71cd57c..09b1cbd77 100644 --- 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 @@ -1,8 +1,8 @@ package de.davis.keygo.migration.legacy_data.data.local.entity -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.Index +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index @Entity( tableName = "SecureElementTagCrossRef", 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 index e75dd697b..f9a7d433a 100644 --- 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 @@ -1,8 +1,8 @@ package de.davis.keygo.migration.legacy_data.data.local.entity -import androidx.room.Entity -import androidx.room.Index -import androidx.room.PrimaryKey +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( 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..f652974da --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/migration/LegacyMigration2To3.kt @@ -0,0 +1,91 @@ +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. Kept apart on purpose: + // a missing title is a recoverable row with no name, a missing blob is not a row at all. + 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/migration/LegacyMigrationSpecs.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/migration/LegacyMigrationSpecs.kt deleted file mode 100644 index 235d4ea91..000000000 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/migration/LegacyMigrationSpecs.kt +++ /dev/null @@ -1,20 +0,0 @@ -package de.davis.keygo.migration.legacy_data.data.local.migration - -import androidx.room.DeleteTable -import androidx.room.migration.AutoMigrationSpec - -/** - * v1's 1-to-2 spec backfilled `created_at` where it was null. The importer does not need that: - * `LegacyElementMapper` already falls back to the current time for a null `created_at`, which it - * has to do anyway for rows that predate the column. Keeping this a no-op keeps hand-written SQL - * out of the module. - */ -internal class LegacyMigrationSpec1To2 : AutoMigrationSpec - -/** - * v1's 2-to-3 spec inserted `elementType:` discriminator tags, which the importer discards, so the - * body is dropped. The annotation is not optional: version 2 has a `MasterPassword` table that - * version 3 does not, and Room needs to be told the table was deleted rather than renamed. - */ -@DeleteTable(tableName = "MasterPassword") -internal class LegacyMigrationSpec2To3 : AutoMigrationSpec 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 index f6a579359..afd09f08c 100644 --- 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 @@ -1,21 +1,22 @@ package de.davis.keygo.migration.legacy_data.data.local.pojo -import androidx.room.Embedded -import androidx.room.Junction -import androidx.room.Relation +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( - parentColumn = "id", - entityColumn = "tagId", + parentColumns = ["id"], + entityColumns = ["tagId"], associateBy = Junction( value = LegacySecureElementTagCrossRef::class, - parentColumn = "id", - entityColumn = "tagId", + parentColumns = ["id"], + entityColumns = ["tagId"], ), ) val tags: List, diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemToDomainMapper.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemConverter.kt similarity index 83% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemToDomainMapper.kt rename to migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemConverter.kt index 3ca6f4b06..80076900a 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemToDomainMapper.kt +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemConverter.kt @@ -19,12 +19,12 @@ import de.davis.keygo.migration.legacy_data.data.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 java.time.YearMonth import java.time.format.DateTimeFormatter import java.time.format.DateTimeParseException import kotlin.time.Clock import kotlin.time.Instant -import org.koin.core.annotation.Single @Single internal class LegacyItemConverter( @@ -110,8 +110,12 @@ internal class LegacyItemConverter( 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 - ?.takeIf { it.isNotBlank() } + ?.filter(Char::isDigit) + ?.takeIf { it.isNotEmpty() } ?.let { CreditCard.CardNumber.encrypt(it) }, cvv = detail.cvv ?.takeIf { it.isNotBlank() } @@ -120,6 +124,21 @@ internal class LegacyItemConverter( 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" @@ -143,18 +162,3 @@ internal class LegacyItemConverter( val EXPIRATION_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("MM/yy") } } - -/** - * 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. - */ -internal 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 -} 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 index 3055e7344..d8140d11c 100644 --- 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 @@ -2,23 +2,19 @@ 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.fold import de.davis.keygo.core.util.resultBinding import de.davis.keygo.migration.legacy_data.data.crypto.LegacyCipher -import de.davis.keygo.migration.legacy_data.data.crypto.LegacyKeyProvider 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.datasource.LegacySecureElementProbe 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.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.LegacyDatabaseFiles -import de.davis.keygo.migration.legacy_data.domain.repository.LegacyDatabaseState 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 @@ -35,52 +31,22 @@ private const val PRUNE_CHUNK_SIZE = 500 @Single internal class LegacyItemRepositoryImpl( private val databaseProvider: LegacyDatabaseProvider, - private val secureElementProbe: LegacySecureElementProbe, - private val keyProvider: LegacyKeyProvider, + private val keyRepository: LegacyKeyRepository, private val cipher: LegacyCipher, private val parser: LegacyDetailParser, - private val databaseFiles: LegacyDatabaseFiles, ) : LegacyItemRepository { - override val repairedRows: Int get() = databaseProvider.repairedRows - - override suspend fun state(): LegacyDatabaseState { - // Asked first, and of the file rather than of the provider. The provider also comes back - // empty for a file that is simply not there, and `Absent` and `Unreadable` lead to opposite - // places: one is a clean install with nothing to do, the other is a file we must not touch. - if (!databaseFiles.exists()) return LegacyDatabaseState.Absent - - // NotLegacy is the verdict that deletes the file, so it has to rest on evidence rather than - // on an inference from something going wrong. The probe reads the closed file and answers - // the only question that proves there is no v1 data in it. `== false` and not `!= true` on - // purpose: a file the probe could not read at all answers null, and null is not a no. - if (secureElementProbe.hasSecureElementTable() == false) - return LegacyDatabaseState.NotLegacy - - // Past that check the provider can only come back empty for a file it could not repair. - if (databaseProvider.get() == null) return LegacyDatabaseState.Unreadable - - // Room opens the file on the first query rather than when the object is built, so querying - // is what turns up everything else that can go wrong: a corrupt page, a disk that fills up - // during the 2-to-3 recreate, a table Room refuses to validate. None of those say the file - // holds no v1 data, so none of them may end up at NotLegacy. - return withDao { it.count() }.fold( - onSuccess = { LegacyDatabaseState.Present }, - onFailure = { LegacyDatabaseState.Unreadable }, - ) - } - override suspend fun readAll(): Result = resultBinding { - // Probed once for the whole run, never once per row. `LegacyCipher.decrypt` folds four - // different failures into one null, and a gone alias is the only one of them that says - // nothing in this file is recoverable. Letting it arrive as one Undecryptable per row would - // tell the user every entry was individually damaged when the entries are intact, and it - // could not be told apart from a file that really is corrupt end to end. - // - // Note this does not gate the 2-to-3 recreate: `state()`'s own `count()` query is the first - // one against the file and has already run the recreate by the time this is reached. What - // this probe still buys is not putting the row loop through a key that is already known gone. - keyProvider.secretKey().asResult(LegacyReadFailure.KeyUnavailable).bind() + // 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() + + // Probed 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. + keyRepository.secretKey().asResult(LegacyReadFailure.KeyUnavailable).bind() val rows = withDao { it.getAllWithTags() }.bind() @@ -88,15 +54,9 @@ internal class LegacyItemRepositoryImpl( val failures = mutableListOf() for (row in rows) { - val decrypted = cipher.decrypt(row.element.data) - if (decrypted == null) { - failures += row.failure(LegacyFailureReason.Undecryptable) - continue - } - - val detail = parser.parse(decrypted) + val detail = cipher.decrypt(row.element.data)?.let(parser::parse) if (detail == null) { - failures += row.failure(LegacyFailureReason.Unparseable) + failures += row.failure(LegacyFailureReason.Unreadable) continue } @@ -108,26 +68,29 @@ internal class LegacyItemRepositoryImpl( 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) } } + 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.close() - return databaseFiles.delete() - } + override fun deleteDatabase(): Boolean = databaseProvider.delete() /** - * Runs [block] against the legacy DAO, turning both ways the file can refuse to be read into - * the same failure: the provider could not repair it, or Room could not validate it on the - * first query. Neither leaves anything to import, and neither should surface as a raw throw. + * 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.DatabaseUnreadable) + ?: return Result.Failure(LegacyReadFailure.DatabaseEmpty) return try { Result.Success(block(dao)) 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/data/repository/LegacyKeyStoreCleanerImpl.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyKeyStoreCleanerImpl.kt deleted file mode 100644 index 8409b2906..000000000 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyKeyStoreCleanerImpl.kt +++ /dev/null @@ -1,31 +0,0 @@ -package de.davis.keygo.migration.legacy_data.data.repository - -import de.davis.keygo.migration.legacy_data.data.crypto.LEGACY_KEY_ALIAS -import de.davis.keygo.migration.legacy_data.domain.repository.LegacyKeyStoreCleaner -import org.koin.core.annotation.Single -import java.security.KeyStore - -@Single -internal class LegacyKeyStoreCleanerImpl : LegacyKeyStoreCleaner { - - /** - * Deletes the alias if it is there, and stays quiet if anything goes wrong. - * - * A Keystore that will not open, or an alias that will not delete, leaves a key behind that - * nothing reads any more. That is untidy rather than harmful, and it runs after the user's data - * is already in v2, so it has no failure worth propagating into the unlock flow. - * - * [containsAlias] guards the delete rather than the delete being attempted blind, so the same - * call is safe on a v2 install that never had the alias and on a retry that already removed it. - */ - override fun deleteLegacyKey() { - runCatching { - val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) } - if (keyStore.containsAlias(LEGACY_KEY_ALIAS)) keyStore.deleteEntry(LEGACY_KEY_ALIAS) - } - } - - private companion object { - const val ANDROID_KEY_STORE = "AndroidKeyStore" - } -} 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 index de98b429f..c13f594aa 100644 --- 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 @@ -1,12 +1,8 @@ package de.davis.keygo.migration.legacy_data.di import android.content.Context -import de.davis.keygo.migration.legacy_data.data.local.datasource.AndroidLegacySecureElementProbe -import de.davis.keygo.migration.legacy_data.data.local.datasource.LEGACY_DATABASE_NAME +import de.davis.keygo.migration.legacy_data.data.local.datasource.AndroidLegacyDatabaseProvider import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabaseProvider -import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacySecureElementProbe -import de.davis.keygo.migration.legacy_data.data.local.datasource.SanitizingLegacyDatabaseProvider -import de.davis.keygo.migration.legacy_data.domain.repository.LegacyDatabaseFiles import org.koin.core.annotation.Module import org.koin.core.annotation.Single @@ -15,22 +11,5 @@ internal class LegacyDatabaseModule { @Single fun provideLegacyDatabaseProvider(context: Context): LegacyDatabaseProvider = - SanitizingLegacyDatabaseProvider(context) - - @Single - fun provideLegacySecureElementProbe(context: Context): LegacySecureElementProbe = - AndroidLegacySecureElementProbe(context) - - @Single - fun provideLegacyDatabaseFiles(context: Context): LegacyDatabaseFiles = - AndroidLegacyDatabaseFiles(context) -} - -private class AndroidLegacyDatabaseFiles( - private val context: Context, -) : LegacyDatabaseFiles { - - override fun exists(): Boolean = context.getDatabasePath(LEGACY_DATABASE_NAME).exists() - - override fun delete(): Boolean = context.deleteDatabase(LEGACY_DATABASE_NAME) + AndroidLegacyDatabaseProvider(context) } 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..ce66a740f --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyFailureReason.kt @@ -0,0 +1,19 @@ +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. + * + * Decryption and parsing were once reported apart. Nothing acted on the difference. The only + * consumer is a logcat summary, both outcomes skip the row and leave it in the legacy database + * for a later run, and v1 wrote neither shape on purpose, so the split described states that + * only a damaged file can reach. 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 index 7ccf8379a..1301f70e0 100644 --- 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 @@ -1,57 +1,5 @@ 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, -} - -/** 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 (other !is Password) return false - - if (username != other.username) return false - if (origin != other.origin) return false - if (password != null) { - if (other.password == null) return false - if (!password.contentEquals(other.password)) return false - } else if (other.password != null) return false - return strength == other.strength - } - - 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 -} - /** A single v1 row, decrypted and parsed, with its user tags already filtered. */ internal data class LegacyItem( val legacyId: Long, @@ -63,6 +11,5 @@ internal data class LegacyItem( val detail: LegacyDetail, ) -internal const val LEGACY_TYPE_PASSWORD = 0x1 -internal const val LEGACY_TYPE_CREDIT_CARD = 0x11 +/** 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/LegacyMigration.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigration.kt deleted file mode 100644 index 92c0927e7..000000000 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigration.kt +++ /dev/null @@ -1,96 +0,0 @@ -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 Keystore alias is gone, or the blob does not decrypt under it. */ - Undecryptable, - - /** The blob decrypted but the JSON inside it could not be read. */ - Unparseable, - - /** The JSON carried a `type` that v1 never shipped. */ - UnknownType, - - /** The nested password blob did not decrypt, so the login would have been silently emptied. */ - UndecryptablePassword, - - /** The row converted but the v2 write failed. */ - WriteFailed, -} - -/** - * 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.Undecryptable] 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, -} - -data class LegacyRowFailure( - val legacyId: Long, - val title: String, - val reason: LegacyFailureReason, -) - -data class LegacyMigrationReport( - val migratedItems: Int, - val failures: List, - - /** - * Rows the sanitizer had to repair before the inherited file would open at all, counted across - * every open in this run. Surfaced so the user can be told that some of what came across was - * patched up on the way in rather than read as v1 left it. - */ - val repairedRows: Int = 0, - - /** - * 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() -} - -/** - * 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 `state()` issues one before this run is ever attempted; 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) - -sealed interface LegacyMigrationOutcome { - - /** No legacy file, or a file that turned out not to be v1's. */ - data object NothingToMigrate : LegacyMigrationOutcome - - data class Migrated(val report: LegacyMigrationReport) : LegacyMigrationOutcome - - /** The run could not start or the batch write failed as a whole. Nothing was imported. */ - data class Failed(val cause: Throwable) : LegacyMigrationOutcome -} 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..d423e2618 --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigrationOutcome.kt @@ -0,0 +1,24 @@ +package de.davis.keygo.migration.legacy_data.domain.model + +sealed interface LegacyMigrationOutcome { + + /** No legacy file, or one that opened and held no v1 rows. Either way it is gone now. */ + data object NothingToMigrate : LegacyMigrationOutcome + + data class Migrated(val report: LegacyMigrationReport) : LegacyMigrationOutcome + + /** The run could not start or the batch write failed as a whole. Nothing was imported. */ + data class Failed(val cause: Throwable) : LegacyMigrationOutcome +} + +/** + * 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 index 8c89761cf..771b5c814 100644 --- 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 @@ -5,72 +5,25 @@ 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 -/** What the inherited database file turned out to be. */ -internal sealed interface LegacyDatabaseState { - - /** No file at `secure_element_database`. */ - data object Absent : LegacyDatabaseState - - /** - * A file exists, opened, and provably has no `SecureElement` table. On a developer device this - * is a leftover v2 database from before `ItemDatabase` was renamed to `keygo_database`. It - * holds no v1 data, so it is deleted rather than read. - * - * "Provably" is the whole of it. This is the one state that destroys the file, so it is only - * ever reached by looking for the table and not finding it, never by reading a failure as if it - * meant the table was gone. - */ - data object NotLegacy : LegacyDatabaseState - - /** - * A file exists but cannot be opened at all: corrupt, truncated, or not a SQLite database. - * - * Kept apart from [NotLegacy] on purpose. A file that will not open tells us nothing about what - * is inside it, and [NotLegacy] is the state that gets the file deleted. A partially restored - * backup deserves to be left where it is, not thrown away on a guess. - */ - data object Unreadable : LegacyDatabaseState - - data object Present : LegacyDatabaseState -} - internal data class LegacyReadResult( val items: List, val failures: List, ) -/** Locates and removes the legacy database file. Keeps `Context` out of the reader. */ -internal interface LegacyDatabaseFiles { - - fun exists(): Boolean - - /** Deletes the database and its `-wal`, `-shm` and `-journal` siblings. */ - fun delete(): Boolean -} - -/** - * Reads the inherited v1 database. - * - * [state] is the gate and has to be asked first. Everything below it opens the file, and Room - * creates a file it is asked to open, so reading on an install that never had a v1 database would - * leave an empty `secure_element_database` behind for every later run to find. - */ +/** Reads the inherited v1 database. */ internal interface LegacyItemRepository { - suspend fun state(): LegacyDatabaseState - - /** - * Rows the sanitizer repaired to make the inherited file openable at all, so the migration can - * tell the user that some of what it imported was patched up on the way in. - */ - val repairedRows: Int - /** * 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 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/repository/LegacyKeyStoreCleaner.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/repository/LegacyKeyStoreCleaner.kt deleted file mode 100644 index 1a1e6a2db..000000000 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/repository/LegacyKeyStoreCleaner.kt +++ /dev/null @@ -1,16 +0,0 @@ -package de.davis.keygo.migration.legacy_data.domain.repository - -/** - * Removes v1's AES alias from the Android Keystore. - * - * The alias is what makes every blob in the inherited file readable, so removing it is as final as - * deleting the file itself. It may only run once the file is provably gone: while any encrypted row - * is still on disk, the key is the only thing that could ever open it again. - * - * It must never create the alias. v1's own `KeyUtil.getSecretKey` generated one on a miss, and a - * generated key here would turn "this data is unreadable" into "this data decrypts to garbage". - */ -internal interface LegacyKeyStoreCleaner { - - fun deleteLegacyKey() -} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/HasLegacyDataUseCase.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/HasLegacyDataUseCase.kt deleted file mode 100644 index b1a4586db..000000000 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/HasLegacyDataUseCase.kt +++ /dev/null @@ -1,30 +0,0 @@ -package de.davis.keygo.migration.legacy_data.domain.usecase - -import de.davis.keygo.core.util.getOrNull -import de.davis.keygo.migration.legacy_data.domain.repository.LegacyDatabaseState -import de.davis.keygo.migration.legacy_data.domain.repository.LegacyItemRepository -import org.koin.core.annotation.Single - -/** - * True when v1 data is still on disk waiting to be imported. - * - * Exposed so a Settings surface can offer a manual retry later. The migration itself does not need - * it: it re-probes on every unlock. - */ -@Single -class HasLegacyDataUseCase internal constructor( - private val legacyItemRepository: LegacyItemRepository, -) { - - /** - * Only [LegacyDatabaseState.Present] can hold rows to import, so the other three answer false. - * That includes [LegacyDatabaseState.Unreadable]: there may well be data in a file that will - * not open, but no retry can reach it, and offering one would promise something we cannot do. - * - * A count that fails answers false for the same reason. It is not evidence of an empty file, it - * is the absence of evidence either way, and this question only ever offers the user a retry. - */ - suspend operator fun invoke(): Boolean = - legacyItemRepository.state() == LegacyDatabaseState.Present && - (legacyItemRepository.remainingCount().getOrNull() ?: 0) > 0 -} 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..6e47e0dfc --- /dev/null +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/LegacyImportRunner.kt @@ -0,0 +1,108 @@ +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. + * + * [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]. [LegacyMigrationOutcome.Failed] reports + * its cause directly. A [LegacyMigrationOutcome.Migrated] whose report carries row failures also + * reports, because a run that silently drops some of the user's entries needs a trace even though + * skipping a bad row is the designed behaviour. So does one whose file could not be cleared even + * though every row it looked at imported cleanly: that run will reimport the whole vault on the + * next unlock, and that is exactly the kind of ending "no row failures" must not be allowed to + * paper over. [LegacyMigrationOutcome.NothingToMigrate] and a [LegacyMigrationOutcome.Migrated] + * with no row failures and no file left behind are the only endings that report nothing. + * + * 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. A run cut short has learned nothing about the + * user's file and must not get to answer for it, and swallowing it would undo the rethrow the + * migration keeps on purpose. + */ +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) + + fun start() { + if (!inFlight.compareAndSet(false, true)) return + + scope.launch { + try { + reportOutcome(import()) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + reportSafely("v1 import threw", e) + } + // 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. + }.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 it reach [scope]. [report] is a reporting seam and not part of + * the import, so a throw out of it must be contained exactly like a throw out of [import] is. + */ + 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 index 6e6521c9e..fbb39f6d4 100644 --- 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 @@ -23,10 +23,10 @@ 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.LegacyDatabaseState import de.davis.keygo.migration.legacy_data.domain.repository.LegacyItemRepository -import de.davis.keygo.migration.legacy_data.domain.repository.LegacyKeyStoreCleaner +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 @@ -48,7 +48,7 @@ import kotlin.coroutines.cancellation.CancellationException @Single class MigrateLegacyDataUseCase internal constructor( private val legacyItemRepository: LegacyItemRepository, - private val legacyKeyStoreCleaner: LegacyKeyStoreCleaner, + private val legacyKeyRepository: LegacyKeyRepository, private val converter: LegacyItemConverter, private val cryptographicScopeProvider: CryptographicScopeProvider, private val vaultRepository: VaultRepository, @@ -57,28 +57,7 @@ class MigrateLegacyDataUseCase internal constructor( private val transactionRunner: ItemTransactionRunner, ) { - suspend operator fun invoke(): LegacyMigrationOutcome = when (legacyItemRepository.state()) { - // A clean install. Nothing to read, and nothing may be created either: opening the file - // would bring one into existence for every later run to find. - LegacyDatabaseState.Absent -> LegacyMigrationOutcome.NothingToMigrate - - // The one state that destroys the file without importing it, and only because it is the one - // state that proves there is nothing to import: the table v1 kept its data in is not there. - LegacyDatabaseState.NotLegacy -> { - legacyItemRepository.deleteDatabase() - LegacyMigrationOutcome.NothingToMigrate - } - - // A file that will not open tells us nothing about what is inside it, so it is left exactly - // where it was found. Never folded into NotLegacy, which is the branch above that deletes. - LegacyDatabaseState.Unreadable -> LegacyMigrationOutcome.Failed( - LegacyMigrationException("The legacy database exists but could not be opened"), - ) - - LegacyDatabaseState.Present -> runMigration() - } - - private suspend fun runMigration(): LegacyMigrationOutcome = try { + suspend operator fun invoke(): LegacyMigrationOutcome = try { migrate() } catch (e: CancellationException) { // Rethrown rather than reported, for the same reason the repository rethrows it. A run cut @@ -89,6 +68,22 @@ class MigrateLegacyDataUseCase internal constructor( LegacyMigrationOutcome.Failed(e) } + /** + * Removes a file that was counted and found to hold no v1 rows, and v1's Keystore alias with it. + * + * The alias has to go on this path too. An already-imported v1 file lands here rather than in + * the import below on the run after the one that emptied it, and if only the file went, the key + * that used to protect it would be left in the Keystore with no later run able to reach it. + * Deleting it is safe for exactly the 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 that are 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 discardEmptyDatabase() { + if (legacyItemRepository.deleteDatabase()) legacyKeyRepository.deleteLegacyKey() + } + 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 @@ -96,9 +91,23 @@ class MigrateLegacyDataUseCase internal constructor( val read = when (val result = legacyItemRepository.readAll()) { is Result.Success -> result.success - is Result.Failure -> return LegacyMigrationOutcome.Failed( - LegacyMigrationException("Could not read the legacy database: ${result.error}"), - ) + // 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 -> { + discardEmptyDatabase() + LegacyMigrationOutcome.NothingToMigrate + } + + LegacyReadFailure.DatabaseUnreadable, LegacyReadFailure.KeyUnavailable -> + LegacyMigrationOutcome.Failed( + LegacyMigrationException( + "Could not read the legacy database: ${result.error}", + ), + ) + } } val vaultId = targetVaultId() @@ -156,17 +165,13 @@ class MigrateLegacyDataUseCase internal constructor( // thing that could turn it into a loss. val pruned = legacyItemRepository.prune(writtenIds).isSuccess() - // Read before the deletion below, which can close the provider. The count itself survives - // that close, but reading it first means the report never depends on that being true. - val repairedRows = legacyItemRepository.repairedRows - - val fileDeleted = deleteWhenFullyImported(hasFailures = failures.isNotEmpty(), pruned = pruned) + val fileDeleted = + deleteWhenFullyImported(hasFailures = failures.isNotEmpty(), pruned = pruned) return LegacyMigrationOutcome.Migrated( LegacyMigrationReport( migratedItems = writtenIds.size, failures = failures, - repairedRows = repairedRows, fileRetained = !fileDeleted, ), ) @@ -190,7 +195,7 @@ class MigrateLegacyDataUseCase internal constructor( if (legacyItemRepository.remainingCount().getOrNull() != 0) return false if (!legacyItemRepository.deleteDatabase()) return false - legacyKeyStoreCleaner.deleteLegacyKey() + legacyKeyRepository.deleteLegacyKey() return true } diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/StartLegacyDataImportUseCase.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/StartLegacyDataImportUseCase.kt index 9f579c48b..2a115c607 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/StartLegacyDataImportUseCase.kt +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/StartLegacyDataImportUseCase.kt @@ -1,15 +1,10 @@ package de.davis.keygo.migration.legacy_data.domain.usecase import android.util.Log -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.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch import org.koin.core.annotation.Single -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.coroutines.cancellation.CancellationException private const val TAG = "LegacyDataImport" @@ -43,104 +38,3 @@ class StartLegacyDataImportUseCase internal constructor( operator fun invoke() = runner.start() } - -/** - * 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. - * - * [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]. [LegacyMigrationOutcome.Failed] reports - * its cause directly. A [LegacyMigrationOutcome.Migrated] whose report carries row failures also - * reports, because a run that silently drops some of the user's entries needs a trace even though - * skipping a bad row is the designed behaviour. So does one whose file could not be cleared even - * though every row it looked at imported cleanly: that run will reimport the whole vault on the - * next unlock, and that is exactly the kind of ending "no row failures" must not be allowed to - * paper over. [LegacyMigrationOutcome.NothingToMigrate] and a [LegacyMigrationOutcome.Migrated] - * with no row failures and no file left behind are the only endings that report nothing. - * - * 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 around its own migrate call, but not around the state probe it - * switches on or the deletion it does for a file that turns out not to be v1's, 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. A run cut short has learned nothing about the - * user's file and must not get to answer for it, and swallowing it would undo the rethrow the - * migration keeps on purpose. - */ -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) - - fun start() { - if (!inFlight.compareAndSet(false, true)) return - - scope.launch { - try { - reportOutcome(import()) - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - reportSafely("v1 import threw", e) - } - // 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. - }.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 it reach [scope]. [report] is a reporting seam and not part of - * the import, so a throw out of it must be contained exactly like a throw out of [import] is. - */ - 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/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 index 7b6e1e5a4..48f00cb17 100644 --- 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 @@ -1,7 +1,6 @@ package de.davis.keygo.migration.legacy_data -import android.content.Context -import androidx.room.Room +import androidx.room3.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.execSQL import de.davis.keygo.core.item.FakeCreditCardRepository @@ -23,13 +22,12 @@ import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformatio 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.core.util.domain.resolver.RegistrableDomainResolver +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.crypto.LegacyKeyProvider import de.davis.keygo.migration.legacy_data.data.json.LegacyDetailParser -import de.davis.keygo.migration.legacy_data.data.local.datasource.AndroidLegacySecureElementProbe 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 @@ -38,12 +36,8 @@ import de.davis.keygo.migration.legacy_data.data.mapper.LegacyItemConverter import de.davis.keygo.migration.legacy_data.data.repository.LegacyItemRepositoryImpl 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.repository.LegacyDatabaseFiles -import de.davis.keygo.migration.legacy_data.domain.repository.LegacyKeyStoreCleaner import de.davis.keygo.migration.legacy_data.domain.usecase.MigrateLegacyDataUseCase import de.davisalessandro.keygo.rust.ItemAad -import io.mockk.every -import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest @@ -79,7 +73,7 @@ import kotlin.time.Instant * this module owns, is the real cipher throughout. * * The other known ceiling is how Room is opened. Production goes through - * `SanitizingLegacyDatabaseProvider`, which opens in compat mode on the framework helper; a JVM test + * `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. */ @@ -88,18 +82,6 @@ class LegacyMigrationEndToEndTest { private val tempDir: File = Files.createTempDirectory("legacy-e2e").toFile() private val dbFile = File(tempDir, "secure_element_database") - /** - * Room turns a database name into a path with `Context.getDatabasePath`, and so does the probe. - * A fully relaxed mock answers that with a mock of its own whose path is empty, which makes both - * of them quietly work on some other file: Room reports an empty database and the probe reports - * nothing at all. Stubbing it is what points the two at the file these tests seed. - * - * This is the only mock in the class. Nothing else here has an Android framework type in the way. - */ - private val context: Context = mockk(relaxed = true).apply { - every { getDatabasePath(any()) } returns dbFile - } - private val legacyKey: SecretKey = KeyGenerator.getInstance("AES") .apply { init(256, SecureRandom()) } .generateKey() @@ -113,52 +95,26 @@ class LegacyMigrationEndToEndTest { private val transactionRunner = FakeItemTransactionRunner() private val cryptoProvider = FakeCryptographicScopeProvider(FakeItemRepository(loginRepository)) - private var keyStoreCleared = false - - private val database: LegacyDatabase = Room.databaseBuilder( - context, - LegacyDatabase::class.java, - dbFile.name, - ) - .setDriver(BundledSQLiteDriver()) - .setQueryCoroutineContext(Dispatchers.IO) - .build() - - private val databaseFiles = object : LegacyDatabaseFiles { - override fun exists(): Boolean = dbFile.exists() - - override fun delete(): Boolean { - listOf("", "-wal", "-shm", "-journal").forEach { suffix -> - File(dbFile.absolutePath + suffix).delete() - } - return true - } - } - /** - * Hands out the one database this class builds, and closes it on request. Closing has to work: - * the tests that assert the file is gone would otherwise be deleting it from under an open Room - * handle, which is not what production does. + * 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. * - * Nothing repairs anything here. The sanitizer only runs inside - * `SanitizingLegacyDatabaseProvider`, which this class deliberately does not go through, so the - * honest count is zero. + * 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 databaseProvider = object : LegacyDatabaseProvider { - - override val repairedRows: Int = 0 - - override fun get(): LegacyDatabase = database - - override fun close() = database.close() - } + private val database: LegacyDatabase = Room.databaseBuilder(dbFile.absolutePath) + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.IO) + .build() - /** One key for the row blobs and the nested password blobs, because v1 used one alias for both. */ - private val legacyKeyProvider = object : LegacyKeyProvider { - override fun secretKey(): SecretKey = legacyKey - } + private val databaseProvider = FakeLegacyDatabaseProvider(file = dbFile, database = database) - private val legacyCipher = LegacyAesGcmCipher(legacyKeyProvider) + private val legacyKeyRepository = FakeLegacyKeyRepository(legacyKey) + private val legacyCipher = LegacyAesGcmCipher(legacyKeyRepository) /** Id the migration mints for the seeded card, learned through [cardIdCapturingRepository]. */ private var migratedCardId: ItemId? = null @@ -178,33 +134,17 @@ class LegacyMigrationEndToEndTest { private val legacyRepository = LegacyItemRepositoryImpl( databaseProvider = databaseProvider, - // The production probe rather than a fake. This is the answer that decides whether the - // user's file gets deleted, so each test earns its verdict from the bytes it actually put on - // disk instead of from a constant the test handed back to itself. - secureElementProbe = AndroidLegacySecureElementProbe( - context = context, - driver = BundledSQLiteDriver(), - ), - keyProvider = legacyKeyProvider, + keyRepository = legacyKeyRepository, cipher = legacyCipher, parser = LegacyDetailParser(), - databaseFiles = databaseFiles, ) private val useCase = MigrateLegacyDataUseCase( legacyItemRepository = legacyRepository, - legacyKeyStoreCleaner = object : LegacyKeyStoreCleaner { - override fun deleteLegacyKey() { - keyStoreCleared = true - } - }, + legacyKeyRepository = legacyKeyRepository, converter = LegacyItemConverter( cipher = legacyCipher, - registrableDomainResolver = object : RegistrableDomainResolver { - override fun resolve(domain: String): String? = - domain.substringAfterLast('.', "").takeIf { it.isNotEmpty() } - ?.let { "example.com" } - }, + registrableDomainResolver = FakeRegistrableDomainResolver(), ), cryptographicScopeProvider = cryptoProvider, vaultRepository = vaultRepository, @@ -237,6 +177,12 @@ class LegacyMigrationEndToEndTest { ) } + /** + * 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"}""" @@ -265,12 +211,13 @@ class LegacyMigrationEndToEndTest { // `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 } + val tagId = + runCatching { dao.insertTag(LegacyTagEntity(name = name)) }.getOrElse { -1L } if (tagId > 0) dao.insertCrossRef(LegacySecureElementTagCrossRef(id, tagId)) } } - private fun seedVault() { + private suspend fun seedVault() { vaultRepository.seed( Vault( id = vaultId, @@ -279,6 +226,7 @@ class LegacyMigrationEndToEndTest { icon = Vault.Icon.Default, ), ) + vaultContextRepository.setContextAndLastInteracted(vaultId) } private suspend fun readBackPassword(loginId: ItemId): String = @@ -333,7 +281,6 @@ class LegacyMigrationEndToEndTest { @Test fun `migrates a full v1 database and leaves nothing behind`() = runTest { seedVault() - vaultContextRepository.setContextAndLastInteracted(vaultId) seed( title = "Example", blob = passwordJson("ada", "https://example.com", "hunter2"), @@ -367,8 +314,8 @@ class LegacyMigrationEndToEndTest { assertEquals("123", card.cvv) assertEquals(YearMonth.of(2029, 4), card.expirationDate) - assertTrue(keyStoreCleared) - assertFalse(File(dbFile.absolutePath).exists()) + assertTrue(legacyKeyRepository.deleted) + assertFalse(dbFile.exists()) assertFalse(File(dbFile.absolutePath + "-wal").exists()) assertFalse(File(dbFile.absolutePath + "-shm").exists()) } @@ -376,16 +323,15 @@ class LegacyMigrationEndToEndTest { @Test fun `keeps the database and reports the row when a blob will not decrypt`() = runTest { seedVault() - vaultContextRepository.setContextAndLastInteracted(vaultId) seed(title = "Fine", blob = passwordJson("ada", "https://example.com", "pw"), type = 1) - seed(title = "Broken", blob = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13), 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.Undecryptable, outcome.report.failures.single().reason) - assertFalse(keyStoreCleared) + assertEquals(LegacyFailureReason.Unreadable, outcome.report.failures.single().reason) + assertFalse(legacyKeyRepository.deleted) assertTrue(dbFile.exists()) assertEquals(1, legacyRepository.remainingCount().assertSuccess()) } @@ -393,23 +339,21 @@ class LegacyMigrationEndToEndTest { @Test fun `keeps the database and reports the row when the json is malformed`() = runTest { seedVault() - vaultContextRepository.setContextAndLastInteracted(vaultId) 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.Unparseable, outcome.report.failures.single().reason) + 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() - vaultContextRepository.setContextAndLastInteracted(vaultId) seed(title = "Fine", blob = passwordJson("ada", "https://example.com", "pw"), type = 1) - seed(title = "Broken", blob = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13), type = 1) + seed(title = "Broken", blob = undecryptableBlob(), type = 1) assertIs(useCase()) val afterFirst = loginRepository.observeLogins().first().map { it.name } @@ -425,7 +369,6 @@ class LegacyMigrationEndToEndTest { @Test fun `migrates a five hundred item database completely`() = runTest { seedVault() - vaultContextRepository.setContextAndLastInteracted(vaultId) repeat(500) { index -> seed( title = "Entry $index", @@ -445,13 +388,12 @@ class LegacyMigrationEndToEndTest { @Test fun `deletes a stale v2 development database without importing anything`() = runTest { seedVault() - vaultContextRepository.setContextAndLastInteracted(vaultId) // 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 the probe answers null for and which must - // be left exactly where it was found. - database.close() + // 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)") } @@ -459,40 +401,42 @@ class LegacyMigrationEndToEndTest { assertEquals(LegacyMigrationOutcome.NothingToMigrate, useCase()) assertTrue(loginRepository.observeLogins().first().isEmpty()) assertFalse(dbFile.exists()) - assertFalse(keyStoreCleared) + // 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) } /** - * The probe's contract is three-valued on purpose: true, false, or null for a file it could - * not even open. Null is not a no. `LegacyItemRepositoryImpl.state()` only reaches `NotLegacy`, - * the verdict that deletes the file, on a provable false; a file it cannot inspect at all has - * to fall to `Unreadable` 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. + * `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 the production probe hits its own catch and - * answers null, which is the one verdict nothing else in this suite drives. + * 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() - vaultContextRepository.setContextAndLastInteracted(vaultId) dbFile.writeBytes(byteArrayOf(0, 1, 2, 3)) assertIs(useCase()) assertTrue(dbFile.exists()) - assertFalse(keyStoreCleared) + assertFalse(legacyKeyRepository.deleted) } @Test fun `does nothing at all when no legacy file exists`() = runTest { seedVault() - vaultContextRepository.setContextAndLastInteracted(vaultId) - database.close() - databaseFiles.delete() + databaseProvider.delete() assertEquals(LegacyMigrationOutcome.NothingToMigrate, useCase()) assertNull(loginRepository.observeLogins().first().firstOrNull()) - assertFalse(keyStoreCleared) + // 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..5996b6bf0 --- /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.FakeItemTransactionRunner +import de.davis.keygo.core.item.FakeLoginRepository +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.mapper.LegacyItemConverter +import de.davis.keygo.migration.legacy_data.data.repository.LegacyItemRepositoryImpl +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 = FakeItemTransactionRunner() + 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(legacyKeyRepository) + + 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 index 187a76fa6..16150a091 100644 --- 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 @@ -1,5 +1,6 @@ package de.davis.keygo.migration.legacy_data.data.crypto +import de.davis.keygo.migration.legacy_data.data.FakeLegacyKeyRepository import java.security.SecureRandom import javax.crypto.Cipher import javax.crypto.KeyGenerator @@ -29,9 +30,7 @@ class LegacyAesGcmCipherTest { } private fun cipherWith(secretKey: SecretKey?) = - LegacyAesGcmCipher(object : LegacyKeyProvider { - override fun secretKey(): SecretKey? = secretKey - }) + LegacyAesGcmCipher(FakeLegacyKeyRepository(secretKey)) @Test fun `decrypts a blob written by v1`() { 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 index 97c46d785..0f9e52bc8 100644 --- 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 @@ -1,53 +1,42 @@ package de.davis.keygo.migration.legacy_data.data.local -import android.content.Context -import androidx.room.Room import androidx.sqlite.SQLiteConnection -import androidx.sqlite.SQLiteException 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 io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.runTest import java.io.File import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull 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 generated auto-migrations carry it forward to version 3. + * 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 = createTempDir() + private val tempDir: File = java.nio.file.Files.createTempDirectory("legacy-db-test").toFile() private val dbFile: File = File(tempDir, "secure_element_database") /** - * Room turns a database name into a path with `Context.getDatabasePath`. A fully relaxed mock - * answers that with an empty path, which makes Room quietly open some other file and report an - * empty database, so this one has to resolve to the seeded file. + * Opened through the production provider rather than a bare `Room.databaseBuilder`, so the test + * cannot pass while the provider forgets to register the hand-written 2-to-3 migration. */ - private val context: Context = mockk(relaxed = true).apply { - every { getDatabasePath(any()) } returns dbFile - } - - private fun createDatabase(version: Int): SQLiteConnection = - seedLegacyDatabase(dbFile, version) - private fun openMigrated(): LegacyDatabase = - Room.databaseBuilder(context, LegacyDatabase::class.java, dbFile.name) - .setDriver(BundledSQLiteDriver()) - .setQueryCoroutineContext(Dispatchers.IO) - .build() + assertNotNull( + AndroidLegacyDatabaseProvider(legacyContext(dbFile), BundledSQLiteDriver()).get(), + ) @AfterTest fun tearDown() { @@ -78,7 +67,7 @@ class LegacyDatabaseOpenTest { @Test fun `opens and migrates a version 1 file`() = runTest { - createDatabase(1).use { connection -> + seedLegacyDatabase(dbFile, version = 1).use { connection -> connection.insertElement("Old entry", type = 1, blob = byteArrayOf(1, 2, 3)) } @@ -95,7 +84,7 @@ class LegacyDatabaseOpenTest { @Test fun `opens and migrates a version 2 file`() = runTest { - createDatabase(2).use { connection -> + seedLegacyDatabase(dbFile, version = 2).use { connection -> connection.insertElement("Mid entry", type = 17, blob = byteArrayOf(4, 5)) connection.execSQL( "UPDATE SecureElement SET created_at = 1700000000000, " + @@ -112,14 +101,14 @@ class LegacyDatabaseOpenTest { assertEquals(1, count) assertEquals(17, rows.single().element.type) assertEquals(1_700_000_000_000L, rows.single().element.timestamps.createdAt) - // Task 5's mapper reads both of these, so the recreate has to carry them across intact. + // 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 { - createDatabase(3).use { connection -> + 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)") @@ -135,7 +124,7 @@ class LegacyDatabaseOpenTest { @Test fun `reads an empty database`() = runTest { - createDatabase(3).close() + seedLegacyDatabase(dbFile, version = 3).close() val db = openMigrated() val rows = db.legacyElementDao().getAllWithTags() @@ -148,7 +137,7 @@ class LegacyDatabaseOpenTest { @Test fun `deletes only the requested rows and cascades their cross refs`() = runTest { - createDatabase(3).use { connection -> + 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')") @@ -163,7 +152,7 @@ class LegacyDatabaseOpenTest { db.close() assertEquals(listOf("Keep"), remaining.map { it.element.title }) - // Task 7's prune leans on the cascade, so an orphaned cross ref has to fail this test. + // The prune leans on the cascade, so an orphaned cross ref has to fail this test. assertEquals(0, crossRefCount()) } @@ -178,62 +167,4 @@ class LegacyDatabaseOpenTest { stmt.getLong(0).toInt() } } - - /** - * Versions 1 and 2 declare `title` and `data` nullable, version 3 declares both NOT NULL, and - * Room's generated 2-to-3 recreate copies rows straight across with - * `INSERT INTO _new_SecureElement (...) SELECT ... FROM SecureElement`. A row carrying a real - * NULL therefore cannot survive the copy. - * - * These two tests record the raw hazard, not shipped behaviour: the migration aborts and the - * failure surfaces from the first query. Nothing is coerced to a default and no row is quietly - * skipped, so an unsanitized file cannot be imported at all. The affected population is real. - * Anyone who went from an early v1 build straight to v2 never ran v1's own 2-to-3, which makes - * this port the first code that ever attempts the recreate for them. - * - * The production path does not hit this, because `LegacyDatabaseSanitizer` runs first and - * rewrites those NULLs to an empty title and an empty blob. These tests stay as the regression - * guard proving the hazard the sanitizer exists to defend against is real, so deleting the - * sanitizer cannot go unnoticed. - * - * The assertion is on the type alone because the message is not observable here. AGP's mockable - * `android.jar` gives `android.database.SQLException`, which `androidx.sqlite.SQLiteException` - * aliases on Android, a generated empty constructor, so SQLite's text is dropped before the - * test can read it. On a device the message survives. Type is the only assertion true in both. - */ - @Test - fun `an unsanitized null title makes the file unopenable`() = runTest { - createDatabase(2).use { connection -> - connection.execSQL( - "INSERT INTO SecureElement (title, data, type) VALUES (NULL, x'0102', 1)", - ) - } - - val db = openMigrated() - assertFailsWith { db.legacyElementDao().getAllWithTags() } - db.close() - - assertEquals( - 2, - userVersionOf(dbFile), - "the sanitizer's below-version-3 guard depends on this: a file whose recreate " + - "aborted must still read as version 2 for a retry to repair it", - ) - } - - @Test - fun `unsanitized null data makes the file unopenable`() = runTest { - createDatabase(2).use { connection -> - connection.execSQL( - "INSERT INTO SecureElement (title, data, type) VALUES ('Has title', NULL, 1)", - ) - } - - val db = openMigrated() - assertFailsWith { db.legacyElementDao().getAllWithTags() } - db.close() - } } - -private fun createTempDir(): File = - java.nio.file.Files.createTempDirectory("legacy-db-test").toFile() diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseSanitizerTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseSanitizerTest.kt deleted file mode 100644 index 28b36b812..000000000 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseSanitizerTest.kt +++ /dev/null @@ -1,180 +0,0 @@ -package de.davis.keygo.migration.legacy_data.data.local - -import android.content.Context -import androidx.room.Room -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 de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabaseSanitizer -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.test.runTest -import java.io.File -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertContentEquals -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -/** - * The sanitizer exists because a NULL `title` or `data` in a version 1 or 2 file aborts Room's - * 2-to-3 recreate and takes the whole database with it. `LegacyDatabaseOpenTest` proves the hazard; - * these tests prove the repair clears it without disturbing anything else. - */ -class LegacyDatabaseSanitizerTest { - - private val tempDir: File = java.nio.file.Files.createTempDirectory("legacy-sanitize").toFile() - private val dbFile: File = File(tempDir, "secure_element_database") - - private val sanitizer = LegacyDatabaseSanitizer(BundledSQLiteDriver()) - - private val context: Context = mockk(relaxed = true).apply { - every { getDatabasePath(any()) } returns dbFile - } - - @AfterTest - fun tearDown() { - tempDir.deleteRecursively() - } - - private fun createDatabase(version: Int): SQLiteConnection = - seedLegacyDatabase(dbFile, version) - - private fun openMigrated(): LegacyDatabase = - Room.databaseBuilder(context, LegacyDatabase::class.java, dbFile.name) - .setDriver(BundledSQLiteDriver()) - .setQueryCoroutineContext(Dispatchers.IO) - .build() - - /** Every row rendered as `title|data`, so an unchanged file compares equal with a clear diff. */ - private fun rowSnapshot(): List = - BundledSQLiteDriver().open(dbFile.absolutePath).use { connection -> - connection.prepare("SELECT title, data FROM SecureElement ORDER BY id").use { stmt -> - buildList { - while (stmt.step()) { - val title = if (stmt.isNull(0)) "" else stmt.getText(0) - val data = if (stmt.isNull(1)) "" else stmt.getBlob(1).joinToString() - add("$title|$data") - } - } - } - } - - @Test - fun `repairs a null title so the file opens with an empty one`() = runTest { - createDatabase(2).use { connection -> - connection.execSQL( - "INSERT INTO SecureElement (title, data, type) VALUES (NULL, x'0102', 1)", - ) - } - - assertEquals(1, sanitizer.sanitize(dbFile.absolutePath)) - - val db = openMigrated() - val rows = db.legacyElementDao().getAllWithTags() - db.close() - - assertEquals(1, rows.size) - assertEquals("", rows.single().element.title) - assertContentEquals(byteArrayOf(1, 2), rows.single().element.data) - } - - @Test - fun `repairs a null title in a version 1 file so it survives both auto-migrations`() = runTest { - createDatabase(1).use { connection -> - connection.execSQL( - "INSERT INTO SecureElement (title, data, type) VALUES (NULL, x'0102', 1)", - ) - } - - assertEquals(1, sanitizer.sanitize(dbFile.absolutePath)) - - val db = openMigrated() - val rows = db.legacyElementDao().getAllWithTags() - db.close() - - assertEquals(1, rows.size) - assertEquals("", rows.single().element.title) - assertContentEquals(byteArrayOf(1, 2), rows.single().element.data) - } - - @Test - fun `repairs null data so the row survives with an empty blob`() = runTest { - createDatabase(2).use { connection -> - connection.execSQL( - "INSERT INTO SecureElement (title, data, type) VALUES ('Has title', NULL, 1)", - ) - } - - assertEquals(1, sanitizer.sanitize(dbFile.absolutePath)) - - val db = openMigrated() - val rows = db.legacyElementDao().getAllWithTags() - db.close() - - assertEquals(1, rows.size) - assertEquals("Has title", rows.single().element.title) - // Kept rather than deleted on purpose: an empty blob cannot decrypt, so the import reports - // this as one failed row instead of losing the whole file. - assertTrue(rows.single().element.data.isEmpty()) - } - - @Test - fun `returns zero and creates nothing when the file is missing`() { - assertFalse(dbFile.exists()) - - assertEquals(0, sanitizer.sanitize(dbFile.absolutePath)) - - assertFalse(dbFile.exists(), "sanitizing must never bring a legacy database into existence") - } - - /** - * Seeds the version 2 schema, which still allows a NULL title, then stamps the file as version - * 3 without going through the recreate. That is the only way to get a NULL title into a file the - * guard sees as version 3: a real version 3 file's NOT NULL column could never hold one, so - * seeding with `createDatabase(3)` would make the UPDATEs match nothing whether or not the guard - * exists. Snapshotting the row before and after `sanitize` catches the guard being removed, where - * asserting only the return value would not. - */ - @Test - fun `returns zero and leaves a version 3 file untouched`() { - createDatabase(2).use { connection -> - connection.execSQL( - "INSERT INTO SecureElement (title, data, type) VALUES (NULL, x'0909', 1)", - ) - connection.execSQL("PRAGMA user_version = 3") - } - val before = rowSnapshot() - - assertEquals(0, sanitizer.sanitize(dbFile.absolutePath)) - - assertEquals(before, rowSnapshot()) - } - - @Test - fun `returns zero when the file has no SecureElement table`() { - BundledSQLiteDriver().open(dbFile.absolutePath).use { connection -> - connection.execSQL("CREATE TABLE Leftover (id INTEGER PRIMARY KEY)") - connection.execSQL("PRAGMA user_version = 2") - } - - assertEquals(0, sanitizer.sanitize(dbFile.absolutePath)) - } - - @Test - fun `returns zero and changes nothing when no column is null`() { - createDatabase(2).use { connection -> - connection.execSQL( - "INSERT INTO SecureElement (title, data, type) VALUES ('Clean', x'0102', 1)", - ) - } - val before = rowSnapshot() - - assertEquals(0, sanitizer.sanitize(dbFile.absolutePath)) - - assertEquals(before, rowSnapshot()) - } -} 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..9fb063237 --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyMigrationTest.kt @@ -0,0 +1,177 @@ +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.assertNotNull +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 = + assertNotNull( + AndroidLegacyDatabaseProvider(legacyContext(dbFile), BundledSQLiteDriver()).get(), + ) + + @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 index c0541f8dc..0d6e95290 100644 --- 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 @@ -1,45 +1,37 @@ package de.davis.keygo.migration.legacy_data.data.local import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import java.io.File import kotlin.test.Test import kotlin.test.assertEquals /** - * Guards the ported entities against drifting from v1's frozen schema, with two assertions that - * cover different things. Neither is redundant, and deleting either one leaves a real gap. + * Guards the ported entities against drifting from v1's frozen schema. * - * The identity hash is what Room itself checks: it compares `room_master_table.identity_hash` - * against the hash derived from the declared entities and refuses to open the file on a mismatch. - * That hash is computed over sorted fields, so it pins column names, affinities, nullability, - * defaults, the primary key, indices and foreign keys, but it is blind to the order the columns are - * declared in. A drift on anything it covers stops every real v1 database on every device opening. + * 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. * - * The statement-for-statement DDL comparison is what pins column order. Order is runtime-inert, - * since Room validates an opened file by matching columns by name, so a drift here breaks nothing - * at runtime. It is asserted anyway so the schema we ship stays byte-identical to the one v1 - * shipped, which is what makes the exported JSON trustworthy as a record of v1 rather than a - * near-miss. + * 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. + * 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 val exportedDir = File( - "schemas/de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase", - ) - private val pristineDir = File("src/test/resources/legacy-schemas") - - private fun identityHashOf(file: File): String = - json.parseToJsonElement(file.readText()) + private fun identityHashOf(version: Int): String = + json.parseToJsonElement(LEGACY_SCHEMA_DIR.resolve("$version.json").toFile().readText()) .jsonObject .getValue("database") .jsonObject @@ -47,41 +39,18 @@ class LegacySchemaIdentityTest { .jsonPrimitive .content - private fun createSqlOf(file: File): List = - json.parseToJsonElement(file.readText()) - .jsonObject - .getValue("database") - .jsonObject - .getValue("entities") - .let { it as JsonArray } - .map { entity -> - val obj = entity as JsonObject - obj.getValue("createSql").jsonPrimitive.content - .replace("\${TABLE_NAME}", obj.getValue("tableName").jsonPrimitive.content) - } - .sorted() - @Test fun `version 3 identity hash matches v1`() { assertEquals( "0a97d13a94575bc2ed2ab009853b0086", - identityHashOf(File(exportedDir, "3.json")), + identityHashOf(3), "Ported entities no longer generate v1's version 3 schema.", ) } - @Test - fun `version 3 DDL matches v1 statement for statement`() { - assertEquals( - createSqlOf(File(pristineDir, "3.json")), - createSqlOf(File(exportedDir, "3.json")), - "Ported entity DDL drifted from v1. Column order counts.", - ) - } - @Test fun `copied version 1 and 2 schemas are v1's own`() { - assertEquals("6fb485e6e64b1e9cc5ceeb0440c58e23", identityHashOf(File(exportedDir, "1.json"))) - assertEquals("d5dd1a0120d5842be17e8ea9a19ee039", identityHashOf(File(exportedDir, "2.json"))) + 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 index f0f45cbd7..ae5c3b9e5 100644 --- 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 @@ -1,53 +1,83 @@ package de.davis.keygo.migration.legacy_data.data.local +import android.app.Instrumentation +import android.content.Context +import android.content.ContextWrapper +import android.content.res.AssetManager +import androidx.room3.testing.MigrationTestHelper import androidx.sqlite.SQLiteConnection import androidx.sqlite.driver.bundled.BundledSQLiteDriver -import androidx.sqlite.execSQL -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive +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 -private val json = Json { ignoreUnknownKeys = true } +/** + * 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 : ContextWrapper(null) { + override fun getAssets(): AssetManager = assets + + // Room asks for ActivityManager to decide whether it is on a low RAM device, and reads an + // absent service the same as a device that is not one. + override fun getSystemService(name: String): Any? = null + + override fun getApplicationContext(): Context = this + } + + 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. * - * Built by replaying v1's own exported schema JSON rather than with Room's `MigrationTestHelper`, - * whose Android artifact takes an `android.app.Instrumentation` in every constructor and so cannot - * run on a plain JVM test. Replaying `createSql` and `setupQueries` is what the helper does anyway, - * and it keeps the seed honest: it comes from v1's export, not from the ported entities under test. + * 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. * - * One copy for the whole test source set. The shape of that JSON is a detail every seeding test - * would otherwise have to know, and three copies of it are three places to update when one of them - * is wrong. + * 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 { - val schema = json - .parseToJsonElement(File("src/test/resources/legacy-schemas/$version.json").readText()) - .jsonObject - .getValue("database") - .jsonObject - - return BundledSQLiteDriver().open(file.absolutePath).apply { - schema.getValue("entities").jsonArray.forEach { entity -> - val table = entity.jsonObject.getValue("tableName").jsonPrimitive.content - execSQL(entity.jsonObject.createSqlFor(table)) - entity.jsonObject.getValue("indices").jsonArray.forEach { index -> - execSQL(index.jsonObject.createSqlFor(table)) - } - } - schema.getValue("setupQueries").jsonArray.forEach { execSQL(it.jsonPrimitive.content) } - execSQL("PRAGMA user_version = $version") - } +internal fun seedLegacyDatabase(file: File, version: Int): SQLiteConnection = runBlocking { + MigrationTestHelper( + instrumentation = schemaInstrumentation(), + file = file, + driver = BundledSQLiteDriver(), + databaseClass = LegacyDatabase::class, + ).createDatabase(version) } -private fun JsonObject.createSqlFor(tableName: String): String = - getValue("createSql").jsonPrimitive.content.replace("\${TABLE_NAME}", tableName) - /** 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 -> diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacySecureElementProbeTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacySecureElementProbeTest.kt deleted file mode 100644 index 11b22e486..000000000 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacySecureElementProbeTest.kt +++ /dev/null @@ -1,110 +0,0 @@ -package de.davis.keygo.migration.legacy_data.data.local - -import android.content.Context -import androidx.sqlite.driver.bundled.BundledSQLiteDriver -import androidx.sqlite.execSQL -import de.davis.keygo.migration.legacy_data.data.local.datasource.AndroidLegacySecureElementProbe -import io.mockk.every -import io.mockk.mockk -import java.io.File -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -/** - * The probe is what stands between a file that cannot be read and a file that gets deleted, so what - * matters here is which answers it is willing to give. False is the destructive one, and only a - * file it actually opened and looked inside may produce it. - */ -class LegacySecureElementProbeTest { - - private val tempDir: File = java.nio.file.Files.createTempDirectory("legacy-probe").toFile() - private val dbFile: File = File(tempDir, "secure_element_database") - - /** - * The probe turns the database name into a path with `Context.getDatabasePath`. A fully relaxed - * mock answers that with an empty path, which would point it at some other file entirely. - */ - private val context: Context = mockk(relaxed = true).apply { - every { getDatabasePath(any()) } returns dbFile - } - - private val probe = AndroidLegacySecureElementProbe(context, BundledSQLiteDriver()) - - @AfterTest - fun tearDown() { - tempDir.deleteRecursively() - } - - @Test - fun `finds the table in a file a v1 build left behind`() { - seedLegacyDatabase(dbFile, version = 3).close() - - assertEquals(true, probe.hasSecureElementTable()) - } - - @Test - fun `finds the table in the oldest v1 file, before either auto-migration`() { - seedLegacyDatabase(dbFile, version = 1).close() - - assertEquals(true, probe.hasSecureElementTable()) - assertEquals(1, userVersionOf(dbFile), "the probe must not migrate the file it reads") - } - - /** - * The leftover v2 database from before `ItemDatabase` was renamed. This is the only answer that - * gets a file deleted, and this is the only shape of file allowed to produce it. - */ - @Test - fun `reports no table for a database that is not v1's`() { - BundledSQLiteDriver().open(dbFile.absolutePath).use { connection -> - connection.execSQL("CREATE TABLE Leftover (id INTEGER PRIMARY KEY)") - } - - assertEquals(false, probe.hasSecureElementTable()) - } - - /** - * Null, never false. A partially restored backup is an ordinary thing to find on a migration - * path, and it says nothing about whether v1's data is in there. Answering false would hand the - * caller a verdict that deletes it. - */ - @Test - fun `answers nothing for a file that is not a database at all`() { - dbFile.writeText("this is not a sqlite database, it is a text file that got restored here") - - assertNull(probe.hasSecureElementTable()) - } - - @Test - fun `answers nothing and creates nothing when there is no file`() { - assertFalse(dbFile.exists()) - - assertNull(probe.hasSecureElementTable()) - - assertFalse(dbFile.exists(), "probing must never bring a legacy database into existence") - } - - /** A probe that rewrites the file it is asked about would be a one-way door of its own. */ - @Test - fun `leaves the rows it reads over alone`() { - seedLegacyDatabase(dbFile, version = 2).use { connection -> - connection.execSQL( - "INSERT INTO SecureElement (title, data, type) VALUES (NULL, x'0102', 1)", - ) - } - - assertEquals(true, probe.hasSecureElementTable()) - - BundledSQLiteDriver().open(dbFile.absolutePath).use { connection -> - connection.prepare("SELECT title FROM SecureElement").use { stmt -> - assertTrue(stmt.step()) - assertTrue(stmt.isNull(0), "the probe repaired a row it was only asked to look at") - } - } - assertEquals(2, userVersionOf(dbFile)) - } -} 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..33ff7988b --- /dev/null +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyTestContext.kt @@ -0,0 +1,32 @@ +package de.davis.keygo.migration.legacy_data.data.local + +import android.content.Context +import android.content.ContextWrapper +import java.io.File + +/** + * A [Context] that answers `getDatabasePath` with [databaseFile] and nothing else. + * + * 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. A relaxed mock would do it too, but it 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. Everything not overridden below throws instead. + * + * The two extra overrides are not guesses, they are what Room's android builder actually reaches + * for on the way to opening a file, and both are answered as narrowly as possible: + * - `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 fun legacyContext(databaseFile: File): Context = object : ContextWrapper(null) { + + override fun getDatabasePath(name: String?): File = databaseFile + + override fun getSystemService(name: String): Any? = null + + override fun getApplicationContext(): Context = this +} diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/SanitizingLegacyDatabaseProviderTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/SanitizingLegacyDatabaseProviderTest.kt deleted file mode 100644 index 54dc11592..000000000 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/SanitizingLegacyDatabaseProviderTest.kt +++ /dev/null @@ -1,140 +0,0 @@ -package de.davis.keygo.migration.legacy_data.data.local - -import android.content.Context -import androidx.sqlite.SQLiteConnection -import androidx.sqlite.driver.bundled.BundledSQLiteDriver -import androidx.sqlite.execSQL -import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabaseSanitizer -import de.davis.keygo.migration.legacy_data.data.local.datasource.SanitizingLegacyDatabaseProvider -import io.mockk.every -import io.mockk.mockk -import java.io.File -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNotSame -import kotlin.test.assertNull -import kotlin.test.assertSame - -/** - * The provider is the only place that can repair the inherited file, because it is the only place - * that still holds it closed. Room runs the 2-to-3 recreate on the first query, and one NULL title - * or blob aborts it and takes every other row down with it, so a repair that lands after the open - * lands too late to be worth anything. - * - * These tests therefore assert on the file itself rather than on what Room later reads out of it: - * that is the only way to see that the repair happened while the file was still at version 2. - */ -class SanitizingLegacyDatabaseProviderTest { - - private val tempDir: File = java.nio.file.Files.createTempDirectory("legacy-provider").toFile() - private val dbFile: File = File(tempDir, "secure_element_database") - - /** - * Room and the sanitizer both turn a database name into a path with `Context.getDatabasePath`. - * A fully relaxed mock answers that with an empty path, which would quietly point both of them - * at some other file, so this one has to resolve to the seeded file. - */ - private val context: Context = mockk(relaxed = true).apply { - every { getDatabasePath(any()) } returns dbFile - } - - private fun newProvider() = SanitizingLegacyDatabaseProvider( - context = context, - sanitizer = LegacyDatabaseSanitizer(BundledSQLiteDriver()), - ) - - @AfterTest - fun tearDown() { - tempDir.deleteRecursively() - } - - private fun createDatabase(version: Int): SQLiteConnection = - seedLegacyDatabase(dbFile, version) - - private fun titlesOnDisk(): List = - BundledSQLiteDriver().open(dbFile.absolutePath).use { connection -> - connection.prepare("SELECT title FROM SecureElement ORDER BY id").use { stmt -> - buildList { - while (stmt.step()) add(if (stmt.isNull(0)) "" else stmt.getText(0)) - } - } - } - - @Test - fun `repairs the file before the database is built`() { - createDatabase(2).use { connection -> - connection.execSQL( - "INSERT INTO SecureElement (title, data, type) VALUES (NULL, x'0102', 1)", - ) - } - - val provider = newProvider() - assertNotNull(provider.get()) - - assertEquals(1, provider.repairedRows) - assertEquals(listOf(""), titlesOnDisk()) - assertEquals( - 2, - userVersionOf(dbFile), - "the file must still be at version 2, which is what proves the repair ran before " + - "Room ever touched it", - ) - } - - @Test - fun `reports no repairs for a file that needs none`() { - createDatabase(2).use { connection -> - connection.execSQL( - "INSERT INTO SecureElement (title, data, type) VALUES ('Clean', x'0102', 1)", - ) - } - - val provider = newProvider() - assertNotNull(provider.get()) - - assertEquals(0, provider.repairedRows) - assertEquals(listOf("Clean"), titlesOnDisk()) - } - - /** - * A partially restored backup or a foreign file at this path is an ordinary thing to find on a - * migration path. The sanitizer has no failure channel of its own and throws, so the provider - * is where that has to stop being an exception and start being an answer. - */ - @Test - fun `returns nothing instead of throwing when the file is not a database`() { - dbFile.writeText("this is not a sqlite database, it is a text file that got restored here") - - assertNull(newProvider().get()) - } - - @Test - fun `opens nothing more than once until it is closed`() { - createDatabase(3).close() - val provider = newProvider() - - val first = assertNotNull(provider.get()) - assertSame(first, provider.get()) - - provider.close() - - assertNotSame(first, provider.get()) - } - - /** - * A clean v2 install has no legacy file. Room creates any file it is asked to open, so opening - * one here would make every later run believe it inherited a database from v1. - */ - @Test - fun `opens nothing and creates nothing when there is no file`() { - val provider = newProvider() - - assertNull(provider.get()) - - assertEquals(0, provider.repairedRows) - assertFalse(dbFile.exists(), "opening must never bring a legacy database into existence") - } -} diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemToDomainMapperTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemConverterTest.kt similarity index 91% rename from migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemToDomainMapperTest.kt rename to migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemConverterTest.kt index e4183b367..fe87722cb 100644 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemToDomainMapperTest.kt +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemConverterTest.kt @@ -12,7 +12,8 @@ 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.core.util.domain.resolver.RegistrableDomainResolver +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.data.crypto.LegacyCipher import de.davis.keygo.migration.legacy_data.domain.model.LegacyDetail import de.davis.keygo.migration.legacy_data.domain.model.LegacyItem @@ -27,27 +28,15 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Instant -/** Passes the plaintext straight through, so tests control the nested password bytes directly. */ -private class PassthroughLegacyCipher( - private val failFor: ByteArray? = null, -) : LegacyCipher { - - override fun decrypt(blob: ByteArray): ByteArray? = - if (failFor != null && blob.contentEquals(failFor)) null else blob -} - -private class StubDomainResolver : RegistrableDomainResolver { - override fun resolve(domain: String): String? = - if (domain.contains("example")) "example.com" else null -} - -class LegacyItemToDomainMapperTest { +class LegacyItemConverterTest { private val vaultId = newVaultId() private val provider = FakeCryptographicScopeProvider(FakeItemRepository()) - private fun converter(cipher: LegacyCipher = PassthroughLegacyCipher()) = - LegacyItemConverter(cipher = cipher, registrableDomainResolver = StubDomainResolver()) + private fun converter(cipher: LegacyCipher = FakeLegacyCipher()) = LegacyItemConverter( + cipher = cipher, + registrableDomainResolver = FakeRegistrableDomainResolver(), + ) private fun legacyItem( detail: LegacyDetail, @@ -68,7 +57,7 @@ class LegacyItemToDomainMapperTest { private suspend fun convert( item: LegacyItem, - cipher: LegacyCipher = PassthroughLegacyCipher(), + cipher: LegacyCipher = FakeLegacyCipher(), ) = provider.itemScope( wrappedVaultKeyInformation = WrappedVaultKeyInformation( wrappedVaultKey = KeyInformation(byteArrayOf(), byteArrayOf()), @@ -213,7 +202,7 @@ class LegacyItemToDomainMapperTest { val converted = convert( item = legacyItem(LegacyDetail.Password("ada", null, blob, null)), - cipher = PassthroughLegacyCipher(failFor = blob), + cipher = FakeLegacyCipher(failFor = blob), ) assertNull(converted) 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 index a3daa4322..b0567eca9 100644 --- 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 @@ -1,40 +1,32 @@ package de.davis.keygo.migration.legacy_data.data.repository -import android.content.Context -import androidx.room.InvalidationTracker -import androidx.room.Room +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.crypto.LegacyCipher -import de.davis.keygo.migration.legacy_data.data.crypto.LegacyKeyProvider +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.dao.LegacyElementDao -import de.davis.keygo.migration.legacy_data.data.local.datasource.AndroidLegacySecureElementProbe +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.datasource.LegacyDatabaseSanitizer -import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacySecureElementProbe -import de.davis.keygo.migration.legacy_data.data.local.datasource.SanitizingLegacyDatabaseProvider 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.pojo.LegacyElementWithTags +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 de.davis.keygo.migration.legacy_data.domain.repository.LegacyDatabaseFiles -import de.davis.keygo.migration.legacy_data.domain.repository.LegacyDatabaseState -import io.mockk.every -import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.runTest import java.io.File -import javax.crypto.SecretKey -import javax.crypto.spec.SecretKeySpec +import java.nio.file.Files import kotlin.coroutines.cancellation.CancellationException import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -46,163 +38,11 @@ import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue -/** - * Reversible stand-in for the Keystore cipher. A blob prefixed with [FAIL] decrypts to null, which - * is how tests simulate a row whose key is gone without needing a Keystore. - */ -private class FakeLegacyCipher : LegacyCipher { - - override fun decrypt(blob: ByteArray): ByteArray? { - val text = blob.decodeToString() - return if (text.startsWith(FAIL)) null else text.encodeToByteArray() - } - - companion object { - const val FAIL = "!!UNDECRYPTABLE!!" - } -} - -/** - * 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. - */ -private class FakeLegacyKeyProvider( - private val key: SecretKey? = SecretKeySpec(ByteArray(32), "AES"), -) : LegacyKeyProvider { - - var probes: Int = 0 - private set - - override fun secretKey(): SecretKey? { - probes++ - return key - } -} - -/** - * Answers the schema question without a file. [answer] carries the probe's three states: the table - * is there, the file opened and it provably is not, or nothing could be read at all. - */ -private class FakeLegacySecureElementProbe( - private val answer: Boolean? = true, -) : LegacySecureElementProbe { - - override fun hasSecureElementTable(): Boolean? = answer -} - -/** Fails every read the way a cancelled unlock scope does, and nothing else. */ -private class CancellingLegacyElementDao : LegacyElementDao { - - override suspend fun count(): Int = throw CancellationException("the unlock scope went away") - - override suspend fun getAllWithTags(): List = - throw CancellationException("the unlock scope went away") - - override suspend fun deleteByIds(ids: List): Unit = - throw CancellationException("the unlock scope went away") - - override suspend fun insertElement(element: LegacySecureElementEntity): Long = - error("this fake is only ever read from") - - override suspend fun insertTag(tag: LegacyTagEntity): Long = - error("this fake is only ever read from") - - override suspend fun insertCrossRef(crossRef: LegacySecureElementTagCrossRef): Unit = - error("this fake is only ever read from") -} - -/** Records how many ids each delete call carried, and actually removes them from [ids]. */ -private class RecordingLegacyElementDao(private val ids: MutableSet) : LegacyElementDao { - - val deleteCalls = mutableListOf>() - - override suspend fun count(): Int = ids.size - - override suspend fun getAllWithTags(): List = - error("this fake is only ever pruned from") - - override suspend fun deleteByIds(ids: List) { - deleteCalls += ids - this.ids -= ids.toSet() - } - - override suspend fun insertElement(element: LegacySecureElementEntity): Long = - error("this fake is only ever pruned from") - - override suspend fun insertTag(tag: LegacyTagEntity): Long = - error("this fake is only ever pruned from") - - override suspend fun insertCrossRef(crossRef: LegacySecureElementTagCrossRef): Unit = - error("this fake is only ever pruned from") -} - -/** A plain subclass rather than Room's builder, since only [legacyElementDao] is ever called. */ -private class RecordingLegacyDatabase(private val dao: LegacyElementDao) : LegacyDatabase() { - override fun legacyElementDao(): LegacyElementDao = dao - override fun createInvalidationTracker(): InvalidationTracker = - error("this fake is only ever pruned through") - override fun clearAllTables(): Unit = error("this fake is only ever pruned through") -} - -/** Hands out one already-open in-memory database, or nothing when the file is unreadable. */ -private class FakeLegacyDatabaseProvider( - private val database: LegacyDatabase?, -) : LegacyDatabaseProvider { - - var closed: Boolean = false - private set - - override var repairedRows: Int = 0 - - override fun get(): LegacyDatabase? = database - - override fun close() { - closed = true - } -} - -/** Answers from the real filesystem, so an empty directory models a clean install faithfully. */ -private class FileBackedLegacyDatabaseFiles(private val file: File) : LegacyDatabaseFiles { - - override fun exists(): Boolean = file.exists() - - override fun delete(): Boolean = file.delete() -} - -/** - * @param databaseClosed reports whether the provider's handle was already closed. Captured at the - * moment [delete] runs rather than read afterwards, because the order of the two is the behaviour: - * asserting after the fact holds just as well when the file is deleted first. - */ -private class FakeLegacyDatabaseFiles( - private val databaseClosed: () -> Boolean = { false }, -) : LegacyDatabaseFiles { - - var present: Boolean = true - var deleted: Boolean = false - private set - - /** Null until [delete] runs, then whatever the provider's handle was at that instant. */ - var closedWhenDeleted: Boolean? = null - private set - - override fun exists(): Boolean = present - - override fun delete(): Boolean { - closedWhenDeleted = databaseClosed() - deleted = true - present = false - return true - } -} - class LegacyItemRepositoryImplTest { private lateinit var db: LegacyDatabase - private lateinit var keyProvider: FakeLegacyKeyProvider + private lateinit var keyRepository: FakeLegacyKeyRepository private lateinit var databaseProvider: FakeLegacyDatabaseProvider - private lateinit var databaseFiles: FakeLegacyDatabaseFiles - private lateinit var secureElementProbe: LegacySecureElementProbe private lateinit var repository: LegacyItemRepositoryImpl /** @@ -210,48 +50,33 @@ class LegacyItemRepositoryImplTest { * 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 = - java.nio.file.Files.createTempDirectory("legacy-on-disk").toFile() + private val tempDir: File = Files.createTempDirectory("legacy-on-disk").toFile() private val legacyFile: File = File(tempDir, LEGACY_DATABASE_NAME) - private val legacyContext: Context = mockk(relaxed = true).apply { - every { getDatabasePath(any()) } returns legacyFile - } /** - * Wired over the real provider, the real probe 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. + * 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() = LegacyItemRepositoryImpl( - databaseProvider = SanitizingLegacyDatabaseProvider( - context = legacyContext, - sanitizer = LegacyDatabaseSanitizer(BundledSQLiteDriver()), + private fun fileBackedRepository() = repositoryOver( + AndroidLegacyDatabaseProvider( + context = legacyContext(legacyFile), driver = BundledSQLiteDriver(), ), - secureElementProbe = AndroidLegacySecureElementProbe( - context = legacyContext, - driver = BundledSQLiteDriver(), - ), - keyProvider = keyProvider, - cipher = FakeLegacyCipher(), - parser = LegacyDetailParser(), - databaseFiles = FileBackedLegacyDatabaseFiles(legacyFile), ) @BeforeTest fun setUp() { - db = Room.inMemoryDatabaseBuilder(mockk(relaxed = true), LegacyDatabase::class.java) + db = Room.inMemoryDatabaseBuilder() .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) .build() - keyProvider = FakeLegacyKeyProvider() - databaseProvider = FakeLegacyDatabaseProvider(db) - databaseFiles = FakeLegacyDatabaseFiles(databaseClosed = { databaseProvider.closed }) - secureElementProbe = FakeLegacySecureElementProbe() + keyRepository = FakeLegacyKeyRepository() + databaseProvider = FakeLegacyDatabaseProvider(legacyFile, db) repository = newRepository() } @@ -261,13 +86,13 @@ class LegacyItemRepositoryImplTest { tempDir.deleteRecursively() } - private fun newRepository() = LegacyItemRepositoryImpl( - databaseProvider = databaseProvider, - secureElementProbe = secureElementProbe, - keyProvider = keyProvider, + private fun newRepository() = repositoryOver(databaseProvider) + + private fun repositoryOver(provider: LegacyDatabaseProvider) = LegacyItemRepositoryImpl( + databaseProvider = provider, + keyRepository = keyRepository, cipher = FakeLegacyCipher(), parser = LegacyDetailParser(), - databaseFiles = databaseFiles, ) private suspend fun insert( @@ -347,7 +172,7 @@ class LegacyItemRepositoryImplTest { val result = repository.readAll().assertSuccess() assertEquals(listOf("Fine"), result.items.map { it.title }) - assertEquals(LegacyFailureReason.Undecryptable, result.failures.single().reason) + assertEquals(LegacyFailureReason.Unreadable, result.failures.single().reason) assertEquals("Broken", result.failures.single().title) } @@ -356,7 +181,7 @@ class LegacyItemRepositoryImplTest { insert(title = "Garbled", json = "definitely not json") assertEquals( - LegacyFailureReason.Unparseable, + LegacyFailureReason.Unreadable, repository.readAll().assertSuccess().failures.single().reason, ) } @@ -366,21 +191,20 @@ class LegacyItemRepositoryImplTest { insert(title = "Alien", json = """{"type":42}""", type = 42) assertEquals( - LegacyFailureReason.Unparseable, + LegacyFailureReason.Unreadable, repository.readAll().assertSuccess().failures.single().reason, ) } @Test fun `prune removes only the given rows`() = runTest { - val keep = insert(title = "Keep", json = """{"type":1}""") + 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()) - assertTrue(keep > 0) } /** @@ -393,48 +217,42 @@ class LegacyItemRepositoryImplTest { */ @Test fun `prune splits large batches across several deletes`() = runTest { - val ids = (1L..1200L).toMutableSet() - val dao = RecordingLegacyElementDao(ids) - val repo = LegacyItemRepositoryImpl( - databaseProvider = FakeLegacyDatabaseProvider(RecordingLegacyDatabase(dao)), - secureElementProbe = secureElementProbe, - keyProvider = keyProvider, - cipher = FakeLegacyCipher(), - parser = LegacyDetailParser(), - databaseFiles = databaseFiles, - ) + 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.", + "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(ids.isEmpty()) + 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 no items and no failures`() = runTest { - val result = repository.readAll().assertSuccess() - - assertTrue(result.items.isEmpty()) - assertTrue(result.failures.isEmpty()) + 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 `Undecryptable` rows + * 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}""") - keyProvider = FakeLegacyKeyProvider(key = null) + keyRepository = FakeLegacyKeyRepository(key = null) assertEquals(LegacyReadFailure.KeyUnavailable, newRepository().readAll().assertFailure()) } @@ -445,39 +263,19 @@ class LegacyItemRepositoryImplTest { repository.readAll().assertSuccess() - assertEquals(1, keyProvider.probes) + assertEquals(1, keyRepository.probes) } /** - * The provider hands back nothing when the file is corrupt or is not a database at all. That - * has to arrive as a failure the migration can report, never as an exception escaping into the - * unlock flow. + * 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 an unreadable database instead of throwing`() = runTest { - databaseProvider = FakeLegacyDatabaseProvider(database = null) - - assertEquals( - LegacyReadFailure.DatabaseUnreadable, - newRepository().readAll().assertFailure(), - ) - } - - @Test - fun `state is absent when there is no legacy file`() = runTest { - databaseFiles.present = false - - assertEquals(LegacyDatabaseState.Absent, repository.state()) - } + fun `reports no provider as an empty database instead of throwing`() = runTest { + databaseProvider = FakeLegacyDatabaseProvider(legacyFile, database = null) - /** - * `Absent` and not `NotLegacy`, because the two lead to opposite places: `NotLegacy` gets the - * file deleted, and deleting a file that was never there is at best confusing. Nor `Unreadable`, - * which means a file we must leave alone. - */ - @Test - fun `state is absent on a clean install`() = runTest { - assertEquals(LegacyDatabaseState.Absent, fileBackedRepository().state()) + assertEquals(LegacyReadFailure.DatabaseEmpty, newRepository().readAll().assertFailure()) } /** @@ -486,9 +284,8 @@ class LegacyItemRepositoryImplTest { * that never ran v1 would leave an empty `secure_element_database` behind for every later run to * find and treat as inherited data. * - * The read goes through `readAll` rather than `state`, because `state` is the caller's gate and - * a gate nobody is forced through is not a guarantee. Asserting the disk rather than the return - * value is the other half: the return value looks the same either way. + * 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 { @@ -498,64 +295,33 @@ class LegacyItemRepositoryImplTest { legacyFile.exists(), "reading must never bring a legacy database into existence", ) - assertEquals(LegacyReadFailure.DatabaseUnreadable, result.assertFailure()) - } - - @Test - fun `state is present when the file opens as a v1 database`() = runTest { - insert(title = "Example", json = """{"type":1}""") - - assertEquals(LegacyDatabaseState.Present, repository.state()) - } - - /** - * Unreadable rather than [LegacyDatabaseState.NotLegacy] on purpose: a file that cannot be - * opened tells us nothing about what is inside it, and NotLegacy is the state that gets the - * file deleted. The probe answers null here for the same reason it would in production, where - * a file the provider cannot open is a file the probe cannot read either. - */ - @Test - fun `state is unreadable when the file cannot be opened`() = runTest { - databaseProvider = FakeLegacyDatabaseProvider(database = null) - secureElementProbe = FakeLegacySecureElementProbe(answer = null) - - assertEquals(LegacyDatabaseState.Unreadable, newRepository().state()) - } - - /** - * The leftover v2 database from before `ItemDatabase` was renamed. No `SecureElement` table - * means no v1 data, and this is the one shape of file that earns the verdict that deletes it. - * - * Run over a real file rather than a fake probe, because the claim is about what is inside the - * file and a fake could only restate the expectation. - */ - @Test - fun `state is not legacy when the file has no SecureElement table`() = runTest { - BundledSQLiteDriver().open(legacyFile.absolutePath).use { connection -> - connection.execSQL("CREATE TABLE Leftover (id INTEGER PRIMARY KEY)") - } - - assertEquals(LegacyDatabaseState.NotLegacy, fileBackedRepository().state()) + assertEquals(LegacyReadFailure.DatabaseEmpty, result.assertFailure()) } /** - * The regression that matters most in this class. This file has the `SecureElement` table, so - * it is not a leftover v2 database, but Room refuses it on the first query. Folding that into - * [LegacyDatabaseState.NotLegacy] deletes a file that was never shown to be free of v1 data, - * and a corrupt page, a disk that fills up during the 2-to-3 recreate and a cancelled run all + * 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 `state is unreadable when the file has the table but the query fails`() = runTest { + 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(LegacyDatabaseState.Unreadable, fileBackedRepository().state()) + assertEquals(LegacyReadFailure.DatabaseUnreadable, repository.readAll().assertFailure()) + assertTrue(legacyFile.exists(), "a file nobody could read must survive the run") } /** @@ -565,32 +331,19 @@ class LegacyItemRepositoryImplTest { */ @Test fun `lets a cancellation out instead of reporting the file as unreadable`() = runTest { - // mockk stands in for the Room-generated database container alone, which has no fake to - // build from; the DAO underneath it, which is what the behaviour is about, is a real fake. - val database = mockk { - every { legacyElementDao() } returns CancellingLegacyElementDao() + val dao = FakeLegacyElementDao().apply { + countFailure = CancellationException("the unlock scope went away") } - databaseProvider = FakeLegacyDatabaseProvider(database) + databaseProvider = FakeLegacyDatabaseProvider(legacyFile, FakeLegacyDatabase(dao)) assertFailsWith { newRepository().remainingCount() } } @Test fun `deleteDatabase closes the open handle before removing the file`() = runTest { - assertTrue(repository.deleteDatabase()) - - assertTrue(databaseFiles.deleted) - assertEquals( - true, - databaseFiles.closedWhenDeleted, - "the file cannot be deleted from under an open handle", - ) - } - - @Test - fun `reports the rows the sanitizer repaired`() = runTest { - databaseProvider.repairedRows = 4 + legacyFile.createNewFile() - assertEquals(4, repository.repairedRows) + 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/usecase/HasLegacyDataUseCaseTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/HasLegacyDataUseCaseTest.kt deleted file mode 100644 index 186df54e2..000000000 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/HasLegacyDataUseCaseTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -package de.davis.keygo.migration.legacy_data.domain.usecase - -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.LegacyDatabaseState -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class HasLegacyDataUseCaseTest { - - private val legacyRepository = FakeLegacyItemRepository() - - private fun useCase() = HasLegacyDataUseCase(legacyRepository) - - @Test - fun `reports data waiting when the file is v1's and still holds rows`() = runTest { - legacyRepository.state = LegacyDatabaseState.Present - legacyRepository.countResult = Result.Success(2) - - assertTrue(useCase()()) - } - - @Test - fun `reports nothing waiting when the file is v1's but empty`() = runTest { - legacyRepository.state = LegacyDatabaseState.Present - legacyRepository.countResult = Result.Success(0) - - assertFalse(useCase()()) - } - - @Test - fun `reports nothing waiting when the rows cannot be counted`() = runTest { - legacyRepository.state = LegacyDatabaseState.Present - legacyRepository.countResult = Result.Failure(LegacyReadFailure.DatabaseUnreadable) - - assertFalse(useCase()()) - } - - @Test - fun `reports nothing waiting on a clean install`() = runTest { - legacyRepository.state = LegacyDatabaseState.Absent - legacyRepository.countResult = Result.Success(5) - - assertFalse(useCase()()) - } - - @Test - fun `reports nothing waiting for a file that is not v1's`() = runTest { - legacyRepository.state = LegacyDatabaseState.NotLegacy - legacyRepository.countResult = Result.Success(5) - - assertFalse(useCase()()) - } - - @Test - fun `reports nothing waiting for a file that cannot be read`() = runTest { - legacyRepository.state = LegacyDatabaseState.Unreadable - legacyRepository.countResult = Result.Success(5) - - assertFalse(useCase()()) - } -} 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 index 7735f89cd..f2e888a81 100644 --- 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 @@ -196,7 +196,7 @@ class LegacyImportRunnerTest { LegacyRowFailure( legacyId = 1L, title = "Gmail", - reason = LegacyFailureReason.Unparseable, + reason = LegacyFailureReason.Unreadable, ), ), ) @@ -214,7 +214,7 @@ class LegacyImportRunnerTest { "of the user's entries with no trace at all is not acceptable either.", ) assertTrue( - diagnostics.single().first.contains("Unparseable"), + diagnostics.single().first.contains("Unreadable"), "The diagnostic has to carry enough to work out what happened, not just that " + "something was skipped.", ) 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 index 99636f1e4..658a92ee4 100644 --- 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 @@ -19,7 +19,10 @@ import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformatio 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.domain.resolver.RegistrableDomainResolver +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.data.crypto.LegacyCipher import de.davis.keygo.migration.legacy_data.data.mapper.LegacyItemConverter import de.davis.keygo.migration.legacy_data.domain.model.LegacyDetail @@ -28,7 +31,6 @@ 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.LegacyDatabaseState import de.davis.keygo.migration.legacy_data.domain.repository.LegacyReadResult import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest @@ -38,20 +40,6 @@ import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertTrue -/** Hands the blob straight back, so a legacy password arrives as its own plaintext. */ -private class PassthroughLegacyCipher : LegacyCipher { - override fun decrypt(blob: ByteArray): ByteArray? = blob -} - -/** Fails every blob, which is how a v1 row whose nested password is unrecoverable is modelled. */ -private class FailingLegacyCipher : LegacyCipher { - override fun decrypt(blob: ByteArray): ByteArray? = null -} - -private class StubDomainResolver : RegistrableDomainResolver { - override fun resolve(domain: String): String? = "example.com" -} - /** * Refuses to open a scope at all, standing in for a vault key that will not unwrap. Nothing about * that is a statement about any one row, so the run has to stop rather than blame the rows. @@ -81,7 +69,7 @@ class MigrateLegacyDataUseCaseTest { private val vaultId: VaultId = newVaultId() private val legacyRepository = FakeLegacyItemRepository() - private val keyStoreCleaner = FakeLegacyKeyStoreCleaner() + private val keyRepository = FakeLegacyKeyRepository() private val transactionRunner = FakeItemTransactionRunner() private val loginRepository = FakeLoginRepository() private val creditCardRepository = FakeCreditCardRepository() @@ -89,13 +77,13 @@ class MigrateLegacyDataUseCaseTest { private val vaultContextRepository = FakeVaultContextRepository() private fun useCase( - cipher: LegacyCipher = PassthroughLegacyCipher(), + cipher: LegacyCipher = FakeLegacyCipher(), scopeProvider: CryptographicScopeProvider = FakeCryptographicScopeProvider(FakeItemRepository()), ) = MigrateLegacyDataUseCase( legacyItemRepository = legacyRepository, - legacyKeyStoreCleaner = keyStoreCleaner, - converter = LegacyItemConverter(cipher, StubDomainResolver()), + legacyKeyRepository = keyRepository, + converter = LegacyItemConverter(cipher, FakeRegistrableDomainResolver()), cryptographicScopeProvider = scopeProvider, vaultRepository = vaultRepository, vaultContextRepository = vaultContextRepository, @@ -115,13 +103,19 @@ class MigrateLegacyDataUseCaseTest { vaultContextRepository.setContextAndLastInteracted(vaultId) } - private fun password(id: Long, title: String) = LegacyItem( + 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", @@ -130,13 +124,9 @@ class MigrateLegacyDataUseCaseTest { ), ) - private fun creditCard(id: Long, title: String) = LegacyItem( - legacyId = id, + private fun creditCard(id: Long, title: String) = legacyItem( + id = id, title = title, - favorite = false, - createdAt = 1_700_000_000_000L, - modifiedAt = null, - tags = emptySet(), detail = LegacyDetail.CreditCard( firstName = "Ada", lastName = "Lovelace", @@ -153,34 +143,57 @@ class MigrateLegacyDataUseCaseTest { legacyRepository.readResult = Result.Success(LegacyReadResult(items, failures)) } + /** + * 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.state = LegacyDatabaseState.Absent + legacyRepository.readResult = Result.Failure(LegacyReadFailure.DatabaseEmpty) + legacyRepository.deleteSucceeds = false assertEquals(LegacyMigrationOutcome.NothingToMigrate, useCase()()) assertFalse(legacyRepository.databaseDeleted) - assertFalse(keyStoreCleaner.deleted) - assertEquals(0, legacyRepository.readCalls) + 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 a stale non legacy database and reports nothing to migrate`() = runTest { - legacyRepository.state = LegacyDatabaseState.NotLegacy + 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) - assertFalse(keyStoreCleaner.deleted) - assertEquals(0, legacyRepository.readCalls) + assertTrue(keyRepository.deleted) } + /** The key may never outlive the ciphertext, so a file that would not go keeps its alias. */ + @Test + fun `keeps the legacy key when the empty database could not be deleted`() = runTest { + legacyRepository.readResult = Result.Failure(LegacyReadFailure.DatabaseEmpty) + legacyRepository.deleteSucceeds = false + + assertEquals(LegacyMigrationOutcome.NothingToMigrate, useCase()()) + assertFalse(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.state = LegacyDatabaseState.Unreadable + legacyRepository.readResult = Result.Failure(LegacyReadFailure.DatabaseUnreadable) assertIs(useCase()()) assertFalse(legacyRepository.databaseDeleted) - assertFalse(keyStoreCleaner.deleted) - assertEquals(0, legacyRepository.readCalls) + assertFalse(keyRepository.deleted) } @Test @@ -195,7 +208,7 @@ class MigrateLegacyDataUseCaseTest { assertFalse(outcome.report.fileRetained) assertEquals(listOf(1L, 2L), legacyRepository.prunedIds) assertTrue(legacyRepository.databaseDeleted) - assertTrue(keyStoreCleaner.deleted) + assertTrue(keyRepository.deleted) assertEquals(2, loginRepository.observeLogins().first().size) } @@ -221,23 +234,12 @@ class MigrateLegacyDataUseCaseTest { assertEquals(1, transactionRunner.transactionCount) } - @Test - fun `reports how many rows the sanitizer had to repair`() = runTest { - seedVault() - legacyRepository.repairedRows = 3 - seedRows(items = listOf(password(1L, "One"))) - - val outcome = assertIs(useCase()()) - - assertEquals(3, outcome.report.repairedRows) - } - @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.Undecryptable)), + failures = listOf(LegacyRowFailure(2L, "Broken", LegacyFailureReason.Unreadable)), ) val outcome = assertIs(useCase()()) @@ -246,7 +248,7 @@ class MigrateLegacyDataUseCaseTest { assertEquals(1, outcome.report.failures.size) assertEquals(listOf(1L), legacyRepository.prunedIds) assertFalse(legacyRepository.databaseDeleted) - assertFalse(keyStoreCleaner.deleted) + assertFalse(keyRepository.deleted) } @Test @@ -254,7 +256,7 @@ class MigrateLegacyDataUseCaseTest { seedVault() seedRows( items = listOf(password(1L, "Fine")), - failures = listOf(LegacyRowFailure(2L, "Broken", LegacyFailureReason.Undecryptable)), + 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. @@ -264,7 +266,7 @@ class MigrateLegacyDataUseCaseTest { assertEquals(1, outcome.report.failures.size) assertFalse(legacyRepository.databaseDeleted) - assertFalse(keyStoreCleaner.deleted) + assertFalse(keyRepository.deleted) } @Test @@ -273,7 +275,7 @@ class MigrateLegacyDataUseCaseTest { seedRows(items = listOf(password(1L, "Fine"))) val outcome = assertIs( - useCase(cipher = FailingLegacyCipher())(), + useCase(cipher = FakeLegacyCipher.Failing)(), ) assertEquals(0, outcome.report.migratedItems) @@ -283,7 +285,7 @@ class MigrateLegacyDataUseCaseTest { ) assertTrue(legacyRepository.prunedIds.isEmpty()) assertFalse(legacyRepository.databaseDeleted) - assertFalse(keyStoreCleaner.deleted) + assertFalse(keyRepository.deleted) } @Test @@ -297,7 +299,7 @@ class MigrateLegacyDataUseCaseTest { assertEquals("database is locked", outcome.cause.message) assertTrue(legacyRepository.prunedIds.isEmpty()) assertFalse(legacyRepository.databaseDeleted) - assertFalse(keyStoreCleaner.deleted) + assertFalse(keyRepository.deleted) } @Test @@ -310,7 +312,7 @@ class MigrateLegacyDataUseCaseTest { assertTrue(legacyRepository.prunedIds.isEmpty()) assertFalse(legacyRepository.databaseDeleted) - assertFalse(keyStoreCleaner.deleted) + assertFalse(keyRepository.deleted) } @Test @@ -319,7 +321,7 @@ class MigrateLegacyDataUseCaseTest { assertIs(useCase()()) assertFalse(legacyRepository.databaseDeleted) - assertFalse(keyStoreCleaner.deleted) + assertFalse(keyRepository.deleted) } @Test @@ -332,7 +334,7 @@ class MigrateLegacyDataUseCaseTest { assertEquals(0, transactionRunner.transactionCount) assertTrue(legacyRepository.prunedIds.isEmpty()) assertFalse(legacyRepository.databaseDeleted) - assertFalse(keyStoreCleaner.deleted) + assertFalse(keyRepository.deleted) } @Test @@ -344,14 +346,14 @@ class MigrateLegacyDataUseCaseTest { assertEquals(0, outcome.report.migratedItems) assertTrue(legacyRepository.databaseDeleted) - assertTrue(keyStoreCleaner.deleted) + assertTrue(keyRepository.deleted) } @Test fun `a second run after a partial failure imports nothing new`() = runTest { seedVault() seedRows( - failures = listOf(LegacyRowFailure(2L, "Broken", LegacyFailureReason.Undecryptable)), + failures = listOf(LegacyRowFailure(2L, "Broken", LegacyFailureReason.Unreadable)), ) val outcome = assertIs(useCase()()) @@ -359,7 +361,7 @@ class MigrateLegacyDataUseCaseTest { assertEquals(0, outcome.report.migratedItems) assertTrue(loginRepository.observeLogins().first().isEmpty()) assertFalse(legacyRepository.databaseDeleted) - assertFalse(keyStoreCleaner.deleted) + assertFalse(keyRepository.deleted) } @Test @@ -370,7 +372,7 @@ class MigrateLegacyDataUseCaseTest { assertIs(useCase()()) assertFalse(legacyRepository.databaseDeleted) - assertFalse(keyStoreCleaner.deleted) + assertFalse(keyRepository.deleted) assertEquals(0, transactionRunner.transactionCount) } @@ -382,7 +384,7 @@ class MigrateLegacyDataUseCaseTest { assertIs(useCase()()) assertFalse(legacyRepository.databaseDeleted) - assertFalse(keyStoreCleaner.deleted) + assertFalse(keyRepository.deleted) } @Test @@ -399,11 +401,11 @@ class MigrateLegacyDataUseCaseTest { assertEquals(1, outcome.report.migratedItems) assertFalse(legacyRepository.databaseDeleted) - assertFalse(keyStoreCleaner.deleted) + 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.", + "or the next unlock reimports the whole vault with nothing in logcat to explain why.", ) } @@ -417,7 +419,7 @@ class MigrateLegacyDataUseCaseTest { assertEquals(1, outcome.report.migratedItems) assertFalse(legacyRepository.databaseDeleted) - assertFalse(keyStoreCleaner.deleted) + assertFalse(keyRepository.deleted) assertTrue(outcome.report.fileRetained) } @@ -430,7 +432,7 @@ class MigrateLegacyDataUseCaseTest { val outcome = assertIs(useCase()()) assertFalse(legacyRepository.databaseDeleted) - assertFalse(keyStoreCleaner.deleted) + assertFalse(keyRepository.deleted) assertTrue(outcome.report.fileRetained) } @@ -444,7 +446,7 @@ class MigrateLegacyDataUseCaseTest { assertEquals(1, outcome.report.migratedItems) assertFalse(legacyRepository.databaseDeleted) - assertFalse(keyStoreCleaner.deleted) + 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 0000000000000000000000000000000000000000..6b2d081aee6d931947b4f1f13b2b8e3173389414 GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AcX-ya>M{e2EC%CydaeV#3&zC rGa3S;Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3^7ix2<+-RK7? literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..afede7515be1afb06c51ae12262d06180920ba8a GIT binary patch literal 32768 zcmeI*NlF7j6b9fI1sNeovlV(}<#W>Z~$JiZ*?->u(1T|IZ--upMF7oCT*^XuL} z-yim_`hJeA(fiePnjI6V>fbfoPp17;s_9fSsT!%8samPpsj95Mz4O=SKYORv76AeT z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+Kvsd;;B1MkBQz&asKCVFRFy)*iA5l*Kz(p-P}UKe6DU+* zCKjU?dxfSGi$G3+Ml8j8?B^VzH-SP0nz0-kg=Q0rKwg1Xti)#C5xNs7RG=N5*ouQf z(}_hOr@(Bi#&#U$9HBRXLIvic8#~c2G@V!kath4HTI@#k9|DWOxCtzb+Xwqz0t5&U fAV7cs0RjXF5FkK+009C72oNAZfB*pkV<_+iTV^T= literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6ead3c7b3a5d8810e4f2438afc358b8b58793094 GIT binary patch literal 152472 zcmeI*3tUr2z6bDga`GU7aMVuRyGTQ5~>kyWbV1JL5D6fCG)QSX^R6trl!_v2k~fAhhd zXNH;YOg?_{XU@!s#o|oLt@^(xsvAYp@~yK=dtJKSe_cq?x`l)1OyN(_ZO?aKP%_wc z&e9Fx6NBT-oN88*F+p!u4ddS}5KyC4D*j#p|BoT>X-(w)!q$Cq-jZMIaYq{g^@?$$ zoZQTAruZKu2tWV=5P$##AOHafKmY;|fB*zK7MMhfm2Pfyj9EW3p3^59j7f2Zs07Ze zH|Wj!M~@l*Fs-jntJ3-U1#4B0UR4c@Pm0mUb1I#7vQEwaO7cDbLA;>JqjJRsO~mbA z^X0zd;^GPfAOHafKmY;|fB*y_009U<00Lb^fc%Z0v5q5<1O)zN{F!f#k-z1)4l(E= z)j%;J009U<00Izz00bZa0SG_<0ucBO0`1}gN7jEguG^ZeTUy5W-=f%CzadAw1p*L& z00bZa0SG_<0uX=z1Rwx`&MY9I1xhY4YK)dIq~(zT>v(~X<^M2+4cK$=Df0{1wW*v9 zzi*vifSf1!wVi*U&8-i1Tjm##8z`Byo}XX9eyRO1`Iqu|+Z}R+Z4cQT{v;9vAOHaf zKmY;|fWR{qNR>&Hg9pe*63ubu)Tmg!DYoTN){OpAi88{6&hU>*G;s4RFHzxKOp1}y z#&Ze${W|?@e`Au#6voZsi!(dO(9u=f)=~w9jL}Y3MgN+tqE(?Gs_2d~j8+Ydw*1Cu zbw+O+i87Qg=7?WY%(xEADEpVn7-x8T0r`8>nbBg2(#MBh5^ia)j_T9Vd7F04`9uy< zB5TWrgz8iw5y8Q(s#c|6xYDNe2S)R6esHhaRTU5#rVR=VAw9!#O|1&k257^yA^zI% zM;ax%^#;DRA|{L>k4gLchWq=D(USHXqYc)!IrB)ytqs^NZ@x?MIa;s$noQN960tJI zho&seZ<-s=&%YL>PcbJwrI!uu$Xv>QpviY9DqJL1sugr(t>PQ~Kk5>5whx$FJ1>ZYo>EBE;@{Rk8 z-Mnq@f4T|j>#51VoKuXE)0;U%l-~UC7md*d_(lZlRQ?fR{D(jnr3)Ib4cGaOpTK97 zkYtFP71#a^>i@U-1u|Yxz8_Q`muLwV{7Wy-^uH}+4gm;200Izz00bZa0SG_<0uX?} zvk|cTt=~FcAb!a22CWJlwFvVIJR5A}3IPZ}00Izz00bZa0SG_<0ubnA0wPi#Yh2(^ z+5U-2+j*A}7wBZIgyKK|0uX=z1Rwwb2tWV=5P$##o{fN*WNVEJ{ImG2*>6^)pGREa z*p?2l9Y4-y0*009U<00Izz00bZa0SG{#3khTnqNT%@ zyktX3MU;*+nN<_?Ceyqmqd}#MGsklyCuf^3cox4OA?z{pt4VL|sfw*|^IVl)x@`GW zMkdaPe2rc=WS_{jqSE7#{A5ky5skvVVDuMFE^8XDJNHN%l~nFh6WZU6&djU!N->p7 zE4k3r1JfKUGY?fX?oD$nsJiETH%D~A2%!-8@Mv8 zaC+k0-MO{9R>x{pRTmfUvbor-*_pQe(;)AuPWSJR%ab4TYqZZYzIx49ZC8KTy#8Q` ze9`t&$MPdSrK{WviVk#h@mycOH!J6QdXBwRq2DU=y1zfYx{&^G*8-O!zF|eAajkIy z)$pjb6CL)fCUF4)dx>J}_!lGyKmY;|fB*y_009U<00Izz00g?OfJDG3Xq|pG(~V{Z zNW;096eFjN=MuO?GhZn&shBk`@a^j>x*IZj9U^f7hCM;Cwd^^5g9HHxKmY;|fB*y_ z009U<00IzzKvxlvFicO1j{~^#0RhX-P$kW1D6SVi( zu4|SqR*-oI8TKT_o@Y<8m&qMS5P$##AOHafKmY;|fB*y_009Ve4FMabCmA2$&b00f z6(|{vw1c>S4XKkgE|Bxa&ahX{B_D1XFYqJ9-eH^AAG=1aQA!9v00Izz00bZa0SG_< z0uX=z1fD1$WEd&U2nGDBfT04a98d) z*-+BfT1;r2dS9iBGsklyCuf^3cox4OA?z{pt4VL|sfw*|^IVl)x@`GWMkdaPe2rc= zWS_{jqSE7#{A5ky5skvVVDuMFE^8XDJNHN%l~nFh6WZU6&djU!N->p7E4k3r1JfKU zGY?fX?oD$nsJiETH%D~A2%!-8@Mv8aC+k0-MO{9 zR>x{pRTmfUvbor-*_pQe(;)AuPWSJR%ab4TYqZZYzIx49ZC8KTy#8Q`e9`t&$MPdS zrK{WviVk#h@mycOH!J6QdXBwRq2DU=y1zfYx{%je;8MhQY!T_)*0@07@Fvx~0h>RT zvvrh!Jx;O5*-QTsogbBi00bZa0SG_<0uX=z1Rwwb2s{@8-Dze39nQt17&&dr!h`~T zxk3e5pMXRFdOTK1F*#$63%ndYm;R{u`#LgSK*VmN*jn}+dxHI(-$jA|1Rwwb2tWV= z5P$##AOHafK;YjgAQLgYDKdzI|Bmk{WW1=hD-XsPIYXQ|Du%x-VVIs2S*MT>2v~N8 zDrrX3?(XE)K@@y-B&15#xWM3_C+;3$T(2ba3y9bviY;Q#vnScT>}7Hn5(FRs0SG_< z0uX=z1Rwwb2tWV=|2_d*rZ?Rh7ZCMkyyy<%0zw<6CmA2$&b00f6(|{vwC&w(;sQ3L zKGwKE=^LJ>C*_w~#tVqq(`22(CiX}6GF!zTAVB~E5P$##AOHafKmY;|fB*y_&_x79 zVlug)NYpaNph!rLghGCkbt;lD-gA8g+j4VN>(GVYy^Ce zKx$c!fP?@Z#0AzZ+q8FGiYlTdE^vXYQ+SKL!Cq&-XD@XAilc-OfB*y_009U<00Izz z00bZa0SL4eu#6TETV7j64)8+;$cO>UfPt3r0=E49cGl+z3Y-ex9rd@tt#N^KBrb51 zy~x(HSJ`uI^Tj>{AOHafKmY;|fB*y_009U<00Ny|fW!ktZK47~Avwb^mNyySG%A6M!(~`P6+z;RL4bzU-Mf0x=ga-Yplzj1zj_oxFm$L60|R@=Q&eJi)(#Sc9WOcov7 zToc`GduhB^ti-#Pg^?v8g&+-GJZI8~qadff&@UQ#Wn>GH0P6>^-m(2OS z>4lX`Pj%;Ov2aU$<{(-+Y{^TOHnr5k+M+65oH?EwIXT;O!L#`N2w{(zUrl;zPgQJ% zo9C+Z(q+r1GBR;S7>nR%$9ac`PqLDfCyyE%%p*?ofFF3;NdZf5Ps$?~G<*_6XP z2iw)v?&XT*Yp;HA<)=#yW!tCKXx=NE6S!exU5WFI$W8vqyPcfx<`=48*9YvVlr`k^ z`?z7z*ua%xh0_z~?#`{tDP84WP;{W1i|6|Ky;(We({t>l3jJ1@*ZuwJ)rGv)0+%BG zgH2X1d=MANx${%rci}Va#!k>_6JAN5CH< zK>z{}fB*y_009U<00Izz00bb=$pwTGhL$qFZ9M{=HsZ`HKd_&Wc?TuzH|^FV;17`? z009U<00Izz00bZa0SG_<0ubo50%9S}Pbw(+KdeWP;&Z8FV!;oew!{Taw_A^ZJ>6+5 zj)Fk|0uX=z1Rwwb2tWV=5P$##Aka?0GQ*(6GQ(h-nFsmR37@zg!HLcHW;kU3abQba u;1r1qoWhC(?O<^L0SG_<0uX=z1Rwwb2tWV=5a|2@2>usJ1v 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..faa7dba13 --- /dev/null +++ b/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyDatabaseProvider.kt @@ -0,0 +1,41 @@ +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 + } + + /** + * 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/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/FakeLegacyItemRepository.kt b/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyItemRepository.kt similarity index 73% rename from migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/FakeLegacyItemRepository.kt rename to migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyItemRepository.kt index 240cc4344..98654037f 100644 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/FakeLegacyItemRepository.kt +++ b/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyItemRepository.kt @@ -1,10 +1,8 @@ -package de.davis.keygo.migration.legacy_data.domain.usecase +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.LegacyDatabaseState import de.davis.keygo.migration.legacy_data.domain.repository.LegacyItemRepository -import de.davis.keygo.migration.legacy_data.domain.repository.LegacyKeyStoreCleaner import de.davis.keygo.migration.legacy_data.domain.repository.LegacyReadResult /** @@ -17,8 +15,6 @@ import de.davis.keygo.migration.legacy_data.domain.repository.LegacyReadResult */ internal class FakeLegacyItemRepository : LegacyItemRepository { - var state: LegacyDatabaseState = LegacyDatabaseState.Present - var readResult: Result = Result.Success(LegacyReadResult(emptyList(), emptyList())) @@ -30,22 +26,12 @@ internal class FakeLegacyItemRepository : LegacyItemRepository { /** What [deleteDatabase] answers, so a file that refuses to go away can be modelled. */ var deleteSucceeds: Boolean = true - override var repairedRows: Int = 0 - val prunedIds = mutableListOf() var databaseDeleted: Boolean = false private set - var readCalls: Int = 0 - private set - - override suspend fun state(): LegacyDatabaseState = state - - override suspend fun readAll(): Result { - readCalls++ - return readResult - } + override suspend fun readAll(): Result = readResult override suspend fun prune(legacyIds: List): Result { if (pruneResult is Result.Success) prunedIds += legacyIds @@ -62,18 +48,8 @@ internal class FakeLegacyItemRepository : LegacyItemRepository { private fun rowsInFile(): Int = when (val result = readResult) { is Result.Success -> result.success.items.size + result.success.failures.size - - prunedIds.size + prunedIds.size is Result.Failure -> 0 } } - -internal class FakeLegacyKeyStoreCleaner : LegacyKeyStoreCleaner { - - var deleted: Boolean = false - private set - - override fun deleteLegacyKey() { - deleted = true - } -} 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..e293e300c --- /dev/null +++ b/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyKeyRepository.kt @@ -0,0 +1,29 @@ +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 class FakeLegacyKeyRepository( + private val key: SecretKey? = SecretKeySpec(ByteArray(32), "AES"), +) : 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 +} From f2f5ac31067973d23adc6780e10dabf8e0f96bf8 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 1 Aug 2026 09:22:40 +0200 Subject: [PATCH 25/31] refactor: cleanup --- .../keygo/core/item/data/mapper/ItemMapper.kt | 2 +- .../keygo/core/security/data/SessionImpl.kt | 15 +- .../keygo/core/security/domain/Session.kt | 14 +- .../core/security/data/SessionImplTest.kt | 34 ++++ .../crypto/FakeCryptographicScopeProvider.kt | 47 ++--- .../keygo/core/security/crypto/FakeSession.kt | 11 ++ feature/auth/build.gradle.kts | 1 - .../auth/presentation/AuthViewModel.kt | 18 +- .../CreateNewOrUpdateCreditCardUseCase.kt | 14 +- .../data/crypto/LegacyAesGcmCipher.kt | 10 +- .../legacy_data/data/crypto/LegacyCipher.kt | 7 - .../legacy_data/data/json/LegacyDetailJson.kt | 9 +- .../AndroidLegacyDatabaseProvider.kt | 12 +- .../local/entity/LegacySecureElementEntity.kt | 11 +- .../local/migration/LegacyMigration2To3.kt | 3 +- .../repository/LegacyItemRepositoryImpl.kt | 22 +-- .../legacy_data/domain/crypto/LegacyCipher.kt | 15 ++ .../mapper/LegacyItemConverter.kt | 28 ++- .../domain/model/LegacyFailureReason.kt | 9 +- .../domain/model/LegacyMigrationOutcome.kt | 21 ++- .../domain/repository/LegacyItemRepository.kt | 8 + ...tUseCase.kt => LegacyDataImportStarter.kt} | 33 +++- .../domain/usecase/LegacyImportRunner.kt | 37 ++-- .../usecase/MigrateLegacyDataUseCase.kt | 71 ++++---- .../LegacyMigrationEndToEndTest.kt | 13 +- .../LegacyMigrationRealDatabaseTest.kt | 4 +- .../data/crypto/LegacyAesGcmCipherTest.kt | 54 +++--- .../data/local/LegacyDatabaseOpenTest.kt | 14 +- .../data/local/LegacyMigrationTest.kt | 6 +- .../data/local/LegacySchemaSeed.kt | 9 +- .../data/local/LegacyTestContext.kt | 41 +++-- .../mapper/LegacyItemConverterTest.kt | 14 +- .../domain/usecase/LegacyImportRunnerTest.kt | 170 ++++++++---------- .../usecase/MigrateLegacyDataUseCaseTest.kt | 54 ++---- .../legacy_data/data/FakeLegacyCipher.kt | 7 +- .../data/FakeLegacyItemRepository.kt | 2 +- .../data/FakeLegacyKeyRepository.kt | 4 +- 37 files changed, 421 insertions(+), 423 deletions(-) delete mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyCipher.kt create mode 100644 migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/crypto/LegacyCipher.kt rename migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/{data => domain}/mapper/LegacyItemConverter.kt (87%) rename migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/{StartLegacyDataImportUseCase.kt => LegacyDataImportStarter.kt} (51%) rename migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/{data => domain}/mapper/LegacyItemConverterTest.kt (95%) 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 25c54e3e9..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,7 +18,7 @@ internal fun Item.toData() = ItemEntity( pinned = pinned, keyInformation = keyInformation.toEntity(), - timestamp = timestamp.toEntity() + timestamp = timestamp.toEntity(), ) internal fun LightweightItem.toDomain() = LiteItem.Concrete( 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/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..34e6168be 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,6 +18,13 @@ 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() } } @@ -22,6 +32,7 @@ class FakeSession( 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 3db202660..e421f02a6 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -12,7 +12,6 @@ dependencies { implementation(projects.core.item) implementation(projects.core.ui) implementation(projects.migration.createAccess) - implementation(projects.migration.legacyData) 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 5906d48e8..3f1cc4720 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 @@ -22,7 +22,6 @@ 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.ValidateMainPasswordUseCase -import de.davis.keygo.migration.legacy_data.domain.usecase.StartLegacyDataImportUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview @@ -53,7 +52,6 @@ internal class AuthViewModel( hasV1MainPassword: HasMainPasswordUseCase, private val validateMainPassword: ValidateMainPasswordUseCase, private val clearMainPasswordUseCase: ClearMainPasswordUseCase, - private val startLegacyDataImport: StartLegacyDataImportUseCase, // ------------------- private val passwordStrengthEstimator: PasswordStrengthEstimator, @@ -280,10 +278,7 @@ internal class AuthViewModel( LoadingScope( state = it, - onSuccess = { - startLegacyDataImport() - navigationEventChannel.trySend(Unit) - }, + onSuccess = { navigationEventChannel.trySend(Unit) }, ).apply { block() }.updatedState.copyDefaultState(loading = false) @@ -303,17 +298,8 @@ internal class AuthViewModel( } } - /** - * Biometric unlock completes in the composable rather than here, so it has to hand control back - * for the v1 import to get a chance to run. Without this the biometric path would be the one - * way into the app that never retries a partial import, and it is the way most returning users - * take every day. - * - * The import is started and left to run on its own scope, so nothing stands between the unlock - * and the navigation that follows it. - */ + /** Biometric unlock completes in the composable, which hands control back here to navigate. */ fun onBiometricUnlockSucceeded() { - startLegacyDataImport() viewModelScope.launch { navigationEventChannel.send(Unit) } } } 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 61a1167f3..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 @@ -5,6 +5,7 @@ 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 @@ -24,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( @@ -151,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/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 index 2e873192a..5b378bafc 100644 --- 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 @@ -1,8 +1,9 @@ package de.davis.keygo.migration.legacy_data.data.crypto -import de.davis.keygo.migration.legacy_data.domain.repository.LegacyKeyRepository +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 /** @@ -10,13 +11,10 @@ import javax.crypto.spec.GCMParameterSpec * 128 bit tag under the `password_manager_skey` Keystore alias. */ @Single -internal class LegacyAesGcmCipher( - private val keyRepository: LegacyKeyRepository, -) : LegacyCipher { +internal class LegacyAesGcmCipher : LegacyCipher { - override fun decrypt(blob: ByteArray): ByteArray? { + override fun decrypt(blob: ByteArray, key: SecretKey): ByteArray? { if (blob.size <= IV_SIZE) return null - val key = keyRepository.secretKey() ?: return null return runCatching { Cipher.getInstance(TRANSFORMATION) diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyCipher.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyCipher.kt deleted file mode 100644 index 8d99e85e6..000000000 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyCipher.kt +++ /dev/null @@ -1,7 +0,0 @@ -package de.davis.keygo.migration.legacy_data.data.crypto - -internal fun interface LegacyCipher { - - /** Returns null when the blob cannot be decrypted for any reason. */ - fun decrypt(blob: ByteArray): ByteArray? -} 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 index be14f3b93..c500a47f8 100644 --- 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 @@ -11,7 +11,7 @@ import kotlinx.serialization.json.JsonElement * older `{"type": ordinal}` object without a custom serializer. */ @Serializable -internal data class LegacyDetailJson( +internal class LegacyDetailJson( val type: Int? = null, val username: String? = null, val origin: String? = null, @@ -21,12 +21,7 @@ internal data class LegacyDetailJson( val expirationDate: String? = null, val cardNumber: String? = null, val cvv: String? = null, -) { - - override fun equals(other: Any?): Boolean = this === other - - override fun hashCode(): Int = System.identityHashCode(this) -} +) @Serializable internal data class LegacyNameJson( 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 index 6525dd87d..b47aeb6d1 100644 --- 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 @@ -8,9 +8,8 @@ import de.davis.keygo.migration.legacy_data.data.local.migration.LegacyMigration /** * 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 that Room is never pointed at a path that is not there. + * 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 @@ -32,10 +31,9 @@ internal class AndroidLegacyDatabaseProvider( database?.let { return it } val path = context.getDatabasePath(LEGACY_DATABASE_NAME) - // This module only ever reads a database it inherited, and 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. + // 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) 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 index 694f33790..b58ccb5c6 100644 --- 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 @@ -13,13 +13,10 @@ import androidx.room3.PrimaryKey * then the body property type) is what produces its column order of * `title, data, favorite, id, type, created_at, modified_at`. * - * What that order does and does not buy is worth being precise about, because getting it wrong in - * either direction is expensive. Room derives its identity hash from sorted fields and validates an - * opened file by comparing columns by name, so column order is runtime-inert: reordering these - * declarations would neither change the hash nor stop a real v1 file from opening. What the hash - * does pin is column names, affinities, nullability, defaults, the primary key, indices and foreign - * keys, and drifting on any of those is what makes Room refuse the file. Column order is kept - * identical to v1 purely for byte-fidelity, so the schema we ship is the schema v1 shipped, and + * 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 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 index f652974da..eee520c9d 100644 --- 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 @@ -40,8 +40,7 @@ import androidx.sqlite.execSQL 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. Kept apart on purpose: - // a missing title is a recoverable row with no name, a missing blob is not a row at all. + // 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") 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 index d8140d11c..b1d2d8977 100644 --- 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 @@ -3,12 +3,12 @@ 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.crypto.LegacyCipher 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 @@ -42,11 +42,12 @@ internal class LegacyItemRepositoryImpl( // as DatabaseUnreadable here and never reaches the comparison. (remainingCount().bind() > 0).asResult(LegacyReadFailure.DatabaseEmpty).bind() - // Probed once for the whole run rather than inferred from a run of nulls; see + // 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. - keyRepository.secretKey().asResult(LegacyReadFailure.KeyUnavailable).bind() + // 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() @@ -54,7 +55,7 @@ internal class LegacyItemRepositoryImpl( val failures = mutableListOf() for (row in rows) { - val detail = cipher.decrypt(row.element.data)?.let(parser::parse) + val detail = cipher.decrypt(row.element.data, key)?.let(parser::parse) if (detail == null) { failures += row.failure(LegacyFailureReason.Unreadable) continue @@ -63,7 +64,7 @@ internal class LegacyItemRepositoryImpl( items += row.toLegacyItem(detail) } - LegacyReadResult(items = items, failures = failures) + LegacyReadResult(items = items, failures = failures, legacyKey = key) } override suspend fun prune(legacyIds: List): Result { @@ -95,11 +96,10 @@ internal class LegacyItemRepositoryImpl( return try { Result.Success(block(dao)) } catch (e: CancellationException) { - // Deliberately not `runCatching`, and deliberately unlike the other repositories in - // this codebase. There a swallowed cancellation becomes a harmless failure; here it - // becomes 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. Leave this rethrow where it is. + // 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) 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/data/mapper/LegacyItemConverter.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/mapper/LegacyItemConverter.kt similarity index 87% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemConverter.kt rename to migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/mapper/LegacyItemConverter.kt index 80076900a..2dc65cea9 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemConverter.kt +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/mapper/LegacyItemConverter.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.data.mapper +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 @@ -12,17 +12,16 @@ 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.data.crypto.LegacyCipher +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 java.time.YearMonth -import java.time.format.DateTimeFormatter -import java.time.format.DateTimeParseException +import javax.crypto.SecretKey import kotlin.time.Clock import kotlin.time.Instant @@ -45,8 +44,11 @@ internal class LegacyItemConverter( itemId: ItemId, vaultId: VaultId, keyInformation: KeyInformation, + legacyKey: SecretKey, ): Item? = when (val detail = item.detail) { - is LegacyDetail.Password -> convertPassword(item, detail, itemId, vaultId, keyInformation) + is LegacyDetail.Password -> + convertPassword(item, detail, itemId, vaultId, keyInformation, legacyKey) + is LegacyDetail.CreditCard -> convertCard(item, detail, itemId, vaultId, keyInformation) } @@ -57,9 +59,10 @@ internal class LegacyItemConverter( itemId: ItemId, vaultId: VaultId, keyInformation: KeyInformation, + legacyKey: SecretKey, ): Login? { val credential = detail.password?.let { encrypted -> - val plaintext = cipher.decrypt(encrypted)?.decodeToString() ?: return null + val plaintext = cipher.decrypt(encrypted, legacyKey)?.decodeToString() ?: return null PasswordCredential( secret = PasswordSecret.encrypt(plaintext), score = detail.strength.toPasswordScore(), @@ -150,15 +153,4 @@ internal class LegacyItemConverter( modifiedAt = modifiedAt?.let(Instant::fromEpochMilliseconds), ) - private fun String.toYearMonthOrNull(): YearMonth? = try { - YearMonth.parse(this, EXPIRATION_FORMATTER) - } catch (_: DateTimeParseException) { - null - } - - private companion object { - // "yy" parses into the 2000-2099 range, matching v1's CreditCardUtil.isValidDateFormat - // and v2's CreateNewOrUpdateCreditCardUseCase. - val EXPIRATION_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("MM/yy") - } } 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 index ce66a740f..63396eaf5 100644 --- 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 @@ -4,13 +4,8 @@ package de.davis.keygo.migration.legacy_data.domain.model 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. - * - * Decryption and parsing were once reported apart. Nothing acted on the difference. The only - * consumer is a logcat summary, both outcomes skip the row and leave it in the legacy database - * for a later run, and v1 wrote neither shape on purpose, so the split described states that - * only a damaged file can reach. 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. + * 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, 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 index d423e2618..5b96a191f 100644 --- 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 @@ -2,13 +2,28 @@ 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 + data object NothingToMigrate : LegacyMigrationOutcome { + override val nothingLeftToImport = true + } + + data class Migrated(val report: LegacyMigrationReport) : LegacyMigrationOutcome { - 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 + data class Failed(val cause: Throwable) : LegacyMigrationOutcome { + override val nothingLeftToImport = false + } } /** 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 index 771b5c814..8ec4573cd 100644 --- 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 @@ -4,10 +4,18 @@ 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. */ diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/StartLegacyDataImportUseCase.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/LegacyDataImportStarter.kt similarity index 51% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/StartLegacyDataImportUseCase.kt rename to migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/LegacyDataImportStarter.kt index 2a115c607..7aed9dd18 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/StartLegacyDataImportUseCase.kt +++ b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/LegacyDataImportStarter.kt @@ -1,40 +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" /** - * Starts the v1 import in the background and returns at once. + * Runs the v1 import in the background on every unlock. * - * Everything that calls this is a session start, so the caller is the unlock the user is waiting - * on. The import is not work that can sit in front of them: it opens the inherited file, decrypts + * 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 out of the caller's lifetime, - * because navigating away from the auth screen clears its ViewModel a moment after this returns. + * 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 -class StartLegacyDataImportUseCase internal constructor( +@Single(createdAtStart = true) +internal class LegacyDataImportStarter( + session: Session, migrateLegacyData: MigrateLegacyDataUseCase, ) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val runner = LegacyImportRunner( - scope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + scope = scope, report = { message, cause -> if (cause != null) Log.e(TAG, message, cause) else Log.e(TAG, message) }, import = { migrateLegacyData() }, ) - operator fun invoke() = runner.start() + 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 index 6e47e0dfc..d96df5a79 100644 --- 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 @@ -16,15 +16,14 @@ import kotlin.coroutines.cancellation.CancellationException * 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]. [LegacyMigrationOutcome.Failed] reports - * its cause directly. A [LegacyMigrationOutcome.Migrated] whose report carries row failures also - * reports, because a run that silently drops some of the user's entries needs a trace even though - * skipping a bad row is the designed behaviour. So does one whose file could not be cleared even - * though every row it looked at imported cleanly: that run will reimport the whole vault on the - * next unlock, and that is exactly the kind of ending "no row failures" must not be allowed to - * paper over. [LegacyMigrationOutcome.NothingToMigrate] and a [LegacyMigrationOutcome.Migrated] - * with no row failures and no file left behind are the only endings that report nothing. + * 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 @@ -33,9 +32,7 @@ import kotlin.coroutines.cancellation.CancellationException * 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. A run cut short has learned nothing about the - * user's file and must not get to answer for it, and swallowing it would undo the rethrow the - * migration keeps on purpose. + * Cancellation is rethrown rather than reported. See LegacyItemRepositoryImpl.withDao. */ internal class LegacyImportRunner( private val scope: CoroutineScope, @@ -45,19 +42,26 @@ internal class LegacyImportRunner( 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 { - reportOutcome(import()) + 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) } - // 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. }.invokeOnCompletion { inFlight.set(false) } } @@ -94,10 +98,7 @@ internal class LegacyImportRunner( return "v1 import finished: ${parts.joinToString("; ")}" } - /** - * Calls [report] without letting it reach [scope]. [report] is a reporting seam and not part of - * the import, so a throw out of it must be contained exactly like a throw out of [import] is. - */ + /** Calls [report] without letting a throw out of it reach [scope]. */ private fun reportSafely(message: String, cause: Throwable?) { try { report(message, cause) 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 index fbb39f6d4..face5aa1f 100644 --- 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 @@ -17,7 +17,7 @@ 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.data.mapper.LegacyItemConverter +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 @@ -30,6 +30,7 @@ import de.davis.keygo.migration.legacy_data.domain.repository.LegacyKeyRepositor 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 /** @@ -60,30 +61,12 @@ class MigrateLegacyDataUseCase internal constructor( suspend operator fun invoke(): LegacyMigrationOutcome = try { migrate() } catch (e: CancellationException) { - // Rethrown rather than reported, for the same reason the repository rethrows it. A run cut - // short because the unlock scope went away has learned nothing about the user's file, and - // it must not get to answer for it. + // See LegacyItemRepositoryImpl.withDao. throw e } catch (e: Exception) { LegacyMigrationOutcome.Failed(e) } - /** - * Removes a file that was counted and found to hold no v1 rows, and v1's Keystore alias with it. - * - * The alias has to go on this path too. An already-imported v1 file lands here rather than in - * the import below on the run after the one that emptied it, and if only the file went, the key - * that used to protect it would be left in the Keystore with no later run able to reach it. - * Deleting it is safe for exactly the 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 that are 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 discardEmptyDatabase() { - if (legacyItemRepository.deleteDatabase()) legacyKeyRepository.deleteLegacyKey() - } - 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 @@ -97,7 +80,7 @@ class MigrateLegacyDataUseCase internal constructor( // 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 -> { - discardEmptyDatabase() + deleteDatabaseAndKey() LegacyMigrationOutcome.NothingToMigrate } @@ -125,7 +108,8 @@ class MigrateLegacyDataUseCase internal constructor( for (legacyItem in read.items) { val itemId = newItemId() val item = when ( - val scoped = convert(legacyItem, itemId, vaultId, vaultKeyInformation) + val scoped = + convert(legacyItem, itemId, vaultId, vaultKeyInformation, read.legacyKey) ) { is Result.Success -> scoped.success @@ -148,29 +132,27 @@ class MigrateLegacyDataUseCase internal constructor( } // 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. - val writtenIds = mutableListOf() + // 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.inTransaction { - for ((legacyId, item) in converted) { + for ((legacyId, item) in converted) upsertVaultItem(item).onFailure { throw LegacyMigrationException("Could not write legacy row $legacyId", it) } - writtenIds += legacyId - } } // 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(writtenIds).isSuccess() + val pruned = legacyItemRepository.prune(converted.map { it.first }).isSuccess() val fileDeleted = deleteWhenFullyImported(hasFailures = failures.isNotEmpty(), pruned = pruned) return LegacyMigrationOutcome.Migrated( LegacyMigrationReport( - migratedItems = writtenIds.size, + migratedItems = converted.size, failures = failures, fileRetained = !fileDeleted, ), @@ -178,22 +160,28 @@ class MigrateLegacyDataUseCase internal constructor( } /** - * Removes the inherited file and v1's Keystore alias, and only once everything that was in the - * file is in v2. Returns whether the file actually went, so the caller can tell a run that - * cleaned up fully apart from one that quietly could not. - * - * Three things have to hold, and any one of them missing leaves the file alone. No row failed - * to read or convert; the prune succeeded, so the rows are gone from the file rather than - * merely copied out of it; and the file counts zero rows afterwards, which is the only direct - * evidence that nothing was left behind. A count that cannot be taken is not a zero. - * - * The alias goes last and only if the file actually went. Removing the key while encrypted rows - * are still on disk would make them unreadable for good. + * 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 @@ -204,6 +192,7 @@ class MigrateLegacyDataUseCase internal constructor( itemId: ItemId, vaultId: VaultId, vaultKeyInformation: KeyInformation, + legacyKey: SecretKey, ): Result = cryptographicScopeProvider.itemScope( wrappedVaultKeyInformation = WrappedVaultKeyInformation( wrappedVaultKey = vaultKeyInformation, @@ -215,12 +204,12 @@ class MigrateLegacyDataUseCase internal constructor( itemAad = ItemAad(itemId = itemId, vaultId = vaultId), ), ) { - // The itemScope receiver satisfies the converter's CryptographicScope context parameter. converter.convert( item = legacyItem, itemId = itemId, vaultId = vaultId, keyInformation = wrapCurrentItemKey(), + legacyKey = legacyKey, ) } 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 index 48f00cb17..7f724bd2d 100644 --- 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 @@ -26,14 +26,15 @@ 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.mapper.LegacyItemConverter 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 @@ -45,7 +46,6 @@ import java.io.File import java.nio.file.Files import java.security.SecureRandom import java.time.YearMonth -import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.SecretKey import kotlin.test.AfterTest @@ -114,7 +114,7 @@ class LegacyMigrationEndToEndTest { private val databaseProvider = FakeLegacyDatabaseProvider(file = dbFile, database = database) private val legacyKeyRepository = FakeLegacyKeyRepository(legacyKey) - private val legacyCipher = LegacyAesGcmCipher(legacyKeyRepository) + private val legacyCipher = LegacyAesGcmCipher() /** Id the migration mints for the seeded card, learned through [cardIdCapturingRepository]. */ private var migratedCardId: ItemId? = null @@ -159,12 +159,7 @@ class LegacyMigrationEndToEndTest { tempDir.deleteRecursively() } - /** Byte-for-byte v1's `Cryptography.encryptAES`: 12 byte IV prefix, AES-256-GCM, 128 bit tag. */ - private fun encryptLikeV1(plaintext: ByteArray): ByteArray { - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.ENCRYPT_MODE, legacyKey) - return cipher.iv + cipher.doFinal(plaintext) - } + 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(",", "[", "]") 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 index 5996b6bf0..482c12681 100644 --- 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 @@ -24,8 +24,8 @@ 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.mapper.LegacyItemConverter 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 @@ -91,7 +91,7 @@ class LegacyMigrationRealDatabaseTest { private val databaseProvider = FakeLegacyDatabaseProvider(file = dbFile, database = database) private val legacyKeyRepository = FakeLegacyKeyRepository(STATIC_KEY) - private val legacyCipher = LegacyAesGcmCipher(legacyKeyRepository) + private val legacyCipher = LegacyAesGcmCipher() private val legacyRepository = LegacyItemRepositoryImpl( databaseProvider = databaseProvider, 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 index 16150a091..fcad2cf70 100644 --- 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 @@ -1,6 +1,6 @@ package de.davis.keygo.migration.legacy_data.data.crypto -import de.davis.keygo.migration.legacy_data.data.FakeLegacyKeyRepository +import de.davis.keygo.migration.legacy_data.data.encryptLikeV1 import java.security.SecureRandom import javax.crypto.Cipher import javax.crypto.KeyGenerator @@ -11,75 +11,63 @@ import kotlin.test.assertContentEquals import kotlin.test.assertNull /** - * The framing here is v1's, not ours: `Cryptography.encryptWithIV` prefixed the 12 byte GCM IV to - * the ciphertext in one blob. `encryptLikeV1` below is a transcription of that method, so a passing - * test means wire compatibility with bytes real v1 installs wrote, not merely self-consistency. + * 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 key: SecretKey = KeyGenerator.getInstance("AES") - .apply { init(256, SecureRandom()) } - .generateKey() + private val cipher = LegacyAesGcmCipher() - private fun encryptLikeV1(plaintext: ByteArray, withKey: SecretKey = key): ByteArray { - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.ENCRYPT_MODE, withKey) - val iv = cipher.iv - val encrypted = cipher.doFinal(plaintext) - return iv + encrypted - } - - private fun cipherWith(secretKey: SecretKey?) = - LegacyAesGcmCipher(FakeLegacyKeyRepository(secretKey)) + private val key: SecretKey = newKey() @Test fun `decrypts a blob written by v1`() { val plaintext = "{\"type\":1,\"username\":\"ada\"}".encodeToByteArray() - val decrypted = cipherWith(key).decrypt(encryptLikeV1(plaintext)) + val decrypted = cipher.decrypt(encryptLikeV1(plaintext, key), key) assertContentEquals(plaintext, decrypted) } @Test fun `decrypts an empty plaintext`() { - assertContentEquals(byteArrayOf(), cipherWith(key).decrypt(encryptLikeV1(byteArrayOf()))) - } - - @Test - fun `returns null when the keystore has no legacy alias`() { - assertNull(cipherWith(null).decrypt(encryptLikeV1(byteArrayOf(1, 2, 3)))) + assertContentEquals( + byteArrayOf(), + cipher.decrypt(encryptLikeV1(byteArrayOf(), key), key), + ) } @Test fun `returns null for a blob encrypted under a different key`() { - val otherKey = KeyGenerator.getInstance("AES") - .apply { init(256, SecureRandom()) } - .generateKey() + val otherKey = newKey() - assertNull(cipherWith(key).decrypt(encryptLikeV1(byteArrayOf(1, 2, 3), otherKey))) + assertNull(cipher.decrypt(encryptLikeV1(byteArrayOf(1, 2, 3), otherKey), key)) } @Test fun `returns null for a blob shorter than the iv`() { - assertNull(cipherWith(key).decrypt(byteArrayOf(1, 2, 3))) + assertNull(cipher.decrypt(byteArrayOf(1, 2, 3), key)) } @Test fun `returns null for a tampered blob`() { - val blob = encryptLikeV1("secret".encodeToByteArray()) + val blob = encryptLikeV1("secret".encodeToByteArray(), key) blob[blob.size - 1] = (blob[blob.size - 1] + 1).toByte() - assertNull(cipherWith(key).decrypt(blob)) + assertNull(cipher.decrypt(blob, key)) } @Test fun `uses a twelve byte iv prefix`() { - val blob = encryptLikeV1("x".encodeToByteArray()) + 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, cipherWith(key).decrypt(blob)) + 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/local/LegacyDatabaseOpenTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseOpenTest.kt index 0f9e52bc8..3ca67f6b5 100644 --- 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 @@ -3,14 +3,13 @@ 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.assertEquals -import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -26,17 +25,10 @@ import kotlin.test.assertTrue */ class LegacyDatabaseOpenTest { - private val tempDir: File = java.nio.file.Files.createTempDirectory("legacy-db-test").toFile() + private val tempDir: File = Files.createTempDirectory("legacy-db-test").toFile() private val dbFile: File = File(tempDir, "secure_element_database") - /** - * Opened through the production provider rather than a bare `Room.databaseBuilder`, so the test - * cannot pass while the provider forgets to register the hand-written 2-to-3 migration. - */ - private fun openMigrated(): LegacyDatabase = - assertNotNull( - AndroidLegacyDatabaseProvider(legacyContext(dbFile), BundledSQLiteDriver()).get(), - ) + private fun openMigrated(): LegacyDatabase = openMigratedLegacyDatabase(dbFile) @AfterTest fun tearDown() { 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 index 9fb063237..4418117cd 100644 --- 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 @@ -12,7 +12,6 @@ import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals -import kotlin.test.assertNotNull import kotlin.test.assertTrue /** @@ -33,10 +32,7 @@ class LegacyMigrationTest { private val tempDir: File = Files.createTempDirectory("legacy-migration").toFile() private val dbFile: File = File(tempDir, "secure_element_database") - private fun openMigrated(): LegacyDatabase = - assertNotNull( - AndroidLegacyDatabaseProvider(legacyContext(dbFile), BundledSQLiteDriver()).get(), - ) + private fun openMigrated(): LegacyDatabase = openMigratedLegacyDatabase(dbFile) @AfterTest fun tearDown() { 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 index ae5c3b9e5..fb82f8c93 100644 --- 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 @@ -2,7 +2,6 @@ package de.davis.keygo.migration.legacy_data.data.local import android.app.Instrumentation import android.content.Context -import android.content.ContextWrapper import android.content.res.AssetManager import androidx.room3.testing.MigrationTestHelper import androidx.sqlite.SQLiteConnection @@ -34,14 +33,8 @@ private fun schemaInstrumentation(): Instrumentation { LEGACY_SCHEMA_DIR.parent.resolve(firstArg()).toFile().inputStream() } - val context = object : ContextWrapper(null) { + val context = object : LegacyStubContext() { override fun getAssets(): AssetManager = assets - - // Room asks for ActivityManager to decide whether it is on a low RAM device, and reads an - // absent service the same as a device that is not one. - override fun getSystemService(name: String): Any? = null - - override fun getApplicationContext(): Context = this } return object : Instrumentation() { 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 index 33ff7988b..26a806904 100644 --- 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 @@ -2,19 +2,19 @@ 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 `getDatabasePath` with [databaseFile] and nothing else. + * A [Context] that answers only what Room reaches for, and throws for everything else. * - * 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. A relaxed mock would do it too, but it 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. Everything not overridden below throws instead. - * - * The two extra overrides are not guesses, they are what Room's android builder actually reaches - * for on the way to opening a file, and both are answered as narrowly as possible: + * 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. @@ -22,11 +22,28 @@ import java.io.File * 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 fun legacyContext(databaseFile: File): Context = object : ContextWrapper(null) { - - override fun getDatabasePath(name: String?): File = databaseFile +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/mapper/LegacyItemConverterTest.kt b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/mapper/LegacyItemConverterTest.kt similarity index 95% rename from migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemConverterTest.kt rename to migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/mapper/LegacyItemConverterTest.kt index fe87722cb..98124a9f1 100644 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyItemConverterTest.kt +++ b/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/mapper/LegacyItemConverterTest.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.data.mapper +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 @@ -12,9 +12,10 @@ 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.data.crypto.LegacyCipher +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 @@ -33,11 +34,6 @@ class LegacyItemConverterTest { private val vaultId = newVaultId() private val provider = FakeCryptographicScopeProvider(FakeItemRepository()) - private fun converter(cipher: LegacyCipher = FakeLegacyCipher()) = LegacyItemConverter( - cipher = cipher, - registrableDomainResolver = FakeRegistrableDomainResolver(), - ) - private fun legacyItem( detail: LegacyDetail, title: String = "Example", @@ -67,12 +63,12 @@ class LegacyItemConverterTest { itemAad = ItemAad(itemId = newItemId(), vaultId = vaultId), ), ) { - // The itemScope receiver satisfies the converter's CryptographicScope context parameter. - converter(cipher).convert( + LegacyItemConverter(cipher, FakeRegistrableDomainResolver()).convert( item = item, itemId = newItemId(), vaultId = vaultId, keyInformation = KeyInformation(byteArrayOf(1), byteArrayOf(2)), + legacyKey = FAKE_LEGACY_KEY, ) }.assertSuccess() 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 index f2e888a81..0fdd2e217 100644 --- 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 @@ -36,8 +36,13 @@ private class RecordingImport( /** * 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. Both of those are its behaviour rather than its wiring, so they are tested - * here rather than guarded by the source-text assertions in `:feature:auth`. + * 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 @@ -47,10 +52,17 @@ private class RecordingImport( @OptIn(ExperimentalCoroutinesApi::class) class LegacyImportRunnerTest { - private fun TestScope.runnerFor( - import: RecordingImport, - diagnostics: MutableList> = mutableListOf(), - ) = LegacyImportRunner( + 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() }, @@ -71,22 +83,17 @@ class LegacyImportRunnerTest { runner.start() runCurrent() - assertEquals( - 1, - import.invocations, - "A second unlock during a run must be dropped. Two runs over one legacy file would " + - "import every row twice, under ids neither run can recognise as the other's.", - ) + 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.") + assertEquals(1, import.invocations, "the dropped call must not be queued behind the run") } @Test - fun `a call made after a run has finished starts a new run`() = runTest { - val import = RecordingImport() + 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() @@ -95,47 +102,64 @@ class LegacyImportRunnerTest { runner.start() runCurrent() - assertEquals( - 2, - import.invocations, - "Retrying on every unlock is the point. Dropping a call once a run has finished " + - "would leave a partial import partial until the next reinstall.", - ) + 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 diagnostics = mutableListOf>() val import = RecordingImport { throw boom } - val runner = runnerFor(import, diagnostics) + val runner = runnerFor(import) runner.start() runCurrent() - assertEquals( - listOf(boom), - diagnostics.map { it.second }, - "A throw has to be reported. Nothing else in the app is watching this run, so a silent " + - "one is a user whose data never arrives and a bug report with nothing in it.", - ) + 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. Left held, one bad unlock would block the " + - "import for the life of the install.", - ) + 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 diagnostics = mutableListOf>() val import = RecordingImport { throw NoClassDefFoundError("a driver this device lacks") } - val runner = runnerFor(import, diagnostics) + val runner = runnerFor(import) runner.start() runCurrent() @@ -147,33 +171,31 @@ class LegacyImportRunnerTest { @Test fun `cancellation is passed through rather than reported as a failure`() = runTest { - val diagnostics = mutableListOf>() val import = RecordingImport { call -> if (call == 1) throw CancellationException("the run was cancelled") LegacyMigrationOutcome.NothingToMigrate } - val runner = runnerFor(import, diagnostics) + 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.", + "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.") + 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 diagnostics = mutableListOf>() val import = RecordingImport { LegacyMigrationOutcome.Failed(cause) } - val runner = runnerFor(import, diagnostics) + val runner = runnerFor(import) runner.start() runCurrent() @@ -181,10 +203,7 @@ class LegacyImportRunnerTest { assertEquals( cause, diagnostics.singleOrNull()?.second, - "Failed is this module's real channel for an expected failure, the same way a returned " + - "Result is everywhere else in this codebase. If it is not routed to the seam, the " + - "failures that actually happen in the field, like a database that opens but fails " + - "validation, produce no signal at all.", + "Failed is this module's channel for an expected failure and must reach the seam", ) } @@ -200,75 +219,46 @@ class LegacyImportRunnerTest { ), ), ) - val diagnostics = mutableListOf>() val import = RecordingImport { LegacyMigrationOutcome.Migrated(report) } - val runner = runnerFor(import, diagnostics) + val runner = runnerFor(import) runner.start() runCurrent() - assertEquals( - 1, - diagnostics.size, - "Individual row skips are the designed behaviour, but a run that silently drops some " + - "of the user's entries with no trace at all is not acceptable either.", - ) + assertEquals(1, diagnostics.size, "a run that drops entries must leave a trace") assertTrue( diagnostics.single().first.contains("Unreadable"), - "The diagnostic has to carry enough to work out what happened, not just that " + - "something was skipped.", + "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.", + "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 report = LegacyMigrationReport(migratedItems = 3, failures = emptyList(), fileRetained = true) - val diagnostics = mutableListOf>() - val import = RecordingImport { LegacyMigrationOutcome.Migrated(report) } - val runner = runnerFor(import, diagnostics) + val runner = runnerFor(RecordingImport { retainedFile }) runner.start() runCurrent() - assertEquals( - 1, - diagnostics.size, - "Every row imported cleanly, but the legacy file survived the run. That is exactly the " + - "ending that duplicates the whole vault on the next unlock, and `hasFailures` alone " + - "cannot see it: there were none.", - ) + // 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 { - val diagnostics = mutableListOf>() - val import = RecordingImport { call -> - if (call == 1) - LegacyMigrationOutcome.NothingToMigrate - else - LegacyMigrationOutcome.Migrated( - LegacyMigrationReport(migratedItems = 3, failures = emptyList()), - ) - } - val runner = runnerFor(import, diagnostics) - - runner.start() + runnerFor(RecordingImport { LegacyMigrationOutcome.NothingToMigrate }).start() runCurrent() - runner.start() + runnerFor(RecordingImport { clearedFile }).start() runCurrent() - assertEquals(2, import.invocations) - assertTrue( - diagnostics.isEmpty(), - "NothingToMigrate and a Migrated with no row failures are the normal endings; neither " + - "may produce a diagnostic line.", - ) + assertTrue(diagnostics.isEmpty(), "the normal endings must produce no diagnostic line") } @Test @@ -290,9 +280,7 @@ class LegacyImportRunnerTest { assertEquals( 2, import.invocations, - "A throwing reporter is a bug in the reporting seam, not in the import. It must not be " + - "able to do what a throwing import already cannot: take the whole application scope " + - "down with it.", + "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 index 658a92ee4..c19559f58 100644 --- 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 @@ -6,25 +6,22 @@ import de.davis.keygo.core.item.FakeItemTransactionRunner import de.davis.keygo.core.item.FakeLoginRepository 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.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.CryptographicScope 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.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.data.crypto.LegacyCipher -import de.davis.keygo.migration.legacy_data.data.mapper.LegacyItemConverter +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 @@ -40,30 +37,6 @@ import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertTrue -/** - * Refuses to open a scope at all, standing in for a vault key that will not unwrap. Nothing about - * that is a statement about any one row, so the run has to stop rather than blame the rows. - */ -private class UnopenableScopeProvider : CryptographicScopeProvider { - - override suspend fun itemScope( - itemId: ItemId, - block: suspend CryptographicScope.() -> R, - ): Result = Result.Failure(CryptoScopeError.IdNotFound) - - override suspend fun itemScope( - wrappedVaultKeyInformation: WrappedVaultKeyInformation, - wrappedItemKeyInformation: WrappedItemKeyInformation, - block: suspend CryptographicScope.() -> R, - ): Result = Result.Failure(CryptoScopeError.IdNotFound) - - override suspend fun rewrapItemKey( - sourceVault: WrappedVaultKeyInformation, - sourceItem: WrappedItemKeyInformation, - destinationVault: WrappedVaultKeyInformation, - ): Result = Result.Failure(CryptoScopeError.IdNotFound) -} - class MigrateLegacyDataUseCaseTest { private val vaultId: VaultId = newVaultId() @@ -140,7 +113,8 @@ class MigrateLegacyDataUseCaseTest { items: List = emptyList(), failures: List = emptyList(), ) { - legacyRepository.readResult = Result.Success(LegacyReadResult(items, failures)) + legacyRepository.readResult = + Result.Success(LegacyReadResult(items, failures, FAKE_LEGACY_KEY)) } /** @@ -173,16 +147,6 @@ class MigrateLegacyDataUseCaseTest { assertTrue(keyRepository.deleted) } - /** The key may never outlive the ciphertext, so a file that would not go keeps its alias. */ - @Test - fun `keeps the legacy key when the empty database could not be deleted`() = runTest { - legacyRepository.readResult = Result.Failure(LegacyReadFailure.DatabaseEmpty) - legacyRepository.deleteSucceeds = false - - assertEquals(LegacyMigrationOutcome.NothingToMigrate, useCase()()) - assertFalse(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. @@ -324,12 +288,18 @@ class MigrateLegacyDataUseCaseTest { 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 = UnopenableScopeProvider())()) + assertIs(useCase(scopeProvider = unopenable)()) assertEquals(0, transactionRunner.transactionCount) assertTrue(legacyRepository.prunedIds.isEmpty()) 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 index 37410f609..79c8c6338 100644 --- 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 @@ -1,7 +1,8 @@ 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.data.crypto.LegacyCipher +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 @@ -14,7 +15,7 @@ import de.davis.keygo.migration.legacy_data.data.crypto.LegacyCipher */ internal class FakeLegacyCipher(private val failFor: ByteArray? = null) : LegacyCipher { - override fun decrypt(blob: ByteArray): ByteArray? = when { + override fun decrypt(blob: ByteArray, key: SecretKey): ByteArray? = when { failFor != null && blob.contentEquals(failFor) -> null blob.decodeToString().startsWith(FAIL) -> null else -> blob @@ -24,6 +25,6 @@ internal class FakeLegacyCipher(private val failFor: ByteArray? = null) : Legacy const val FAIL = "!!UNDECRYPTABLE!!" /** Fails every blob, which is how a whole run under a gone v1 key is modelled. */ - val Failing = LegacyCipher { null } + val Failing = LegacyCipher { _, _ -> null } } } 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 index 98654037f..f1eb3703d 100644 --- 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 @@ -16,7 +16,7 @@ import de.davis.keygo.migration.legacy_data.domain.repository.LegacyReadResult internal class FakeLegacyItemRepository : LegacyItemRepository { var readResult: Result = - Result.Success(LegacyReadResult(emptyList(), emptyList())) + Result.Success(LegacyReadResult(emptyList(), emptyList(), FAKE_LEGACY_KEY)) var pruneResult: Result = Result.Success(Unit) 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 index e293e300c..4670bbe05 100644 --- 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 @@ -8,8 +8,10 @@ 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? = SecretKeySpec(ByteArray(32), "AES"), + private val key: SecretKey? = FAKE_LEGACY_KEY, ) : LegacyKeyRepository { var probes: Int = 0 From daf9b1e511088e3cfa1334e79896a48521c9db9a Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 1 Aug 2026 12:45:20 +0200 Subject: [PATCH 26/31] refactor: revert biometric unlock redirection --- .../de/davis/keygo/feature/auth/presentation/AuthScreen.kt | 6 +----- .../davis/keygo/feature/auth/presentation/AuthViewModel.kt | 5 ----- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt index 0ca70931f..80ad7dcb9 100644 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt +++ b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt @@ -47,11 +47,7 @@ fun AuthScreen(onSuccess: () -> Unit) { biometricUnlockAdapter.useAdapter { biometricCryptoController.requestUnlockVault() }.onSuccess { - // Not currentOnSuccess() any more. Navigating straight from here would skip - // the v1 import, which every other way into the app starts, so the ViewModel - // takes it from here and navigates through navigationEvent, which is already - // collected above. - viewModel.onBiometricUnlockSucceeded() + currentOnSuccess() } } } 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 3f1cc4720..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 @@ -297,11 +297,6 @@ internal class AuthViewModel( ).handleAuthenticationResult() } } - - /** Biometric unlock completes in the composable, which hands control back here to navigate. */ - fun onBiometricUnlockSucceeded() { - viewModelScope.launch { navigationEventChannel.send(Unit) } - } } private class LoadingScope( From 8472a468e801a75a5d250584d8c9238cb21afb93 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 1 Aug 2026 12:48:23 +0200 Subject: [PATCH 27/31] fix: review findings --- .../de/davis/keygo/core/security/crypto/FakeSession.kt | 5 ++++- .../migration/legacy_data/data/FakeLegacyDatabaseProvider.kt | 1 + .../migration/legacy_data/data/FakeLegacyItemRepository.kt | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) 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 34e6168be..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 @@ -26,7 +26,10 @@ class FakeSession( 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) { 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 index faa7dba13..aae83b26e 100644 --- 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 @@ -22,6 +22,7 @@ internal class FakeLegacyDatabaseProvider( override fun close() { closed = true + runCatching { database?.close() } } /** 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 index f1eb3703d..ff489573c 100644 --- 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 @@ -47,8 +47,8 @@ internal class FakeLegacyItemRepository : LegacyItemRepository { } private fun rowsInFile(): Int = when (val result = readResult) { - is Result.Success -> result.success.items.size + result.success.failures.size - - prunedIds.size + is Result.Success -> (result.success.items.size + result.success.failures.size - + prunedIds.size).coerceAtLeast(0) is Result.Failure -> 0 } From 066e3beda91323b9687a9704119413198f4b3754 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 1 Aug 2026 13:16:45 +0200 Subject: [PATCH 28/31] fix: add function --- .../keygo/core/item/domain/model/YearMonthExt.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/YearMonthExt.kt 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 +} From 9a0d257ffbb96c8d72f3eea337b6b1b186c80175 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 1 Aug 2026 13:48:43 +0200 Subject: [PATCH 29/31] fix(legacy-data): restore encryptLikeV1 test helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refactor commit changed LegacyAesGcmCipherTest and LegacyMigrationEndToEndTest to import encryptLikeV1 from de.davis.keygo.migration.legacy_data.data as a shared top-level function, but never actually created it there — it only ever existed as a private helper inside the test class it replaced. CI has been failing on the unresolved reference since. Co-Authored-By: Claude Sonnet 5 --- .../legacy_data/data/EncryptLikeV1.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/EncryptLikeV1.kt diff --git a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/EncryptLikeV1.kt b/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/EncryptLikeV1.kt new file mode 100644 index 000000000..b8abd198b --- /dev/null +++ b/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/EncryptLikeV1.kt @@ -0,0 +1,18 @@ +package de.davis.keygo.migration.legacy_data.data + +import javax.crypto.Cipher +import javax.crypto.SecretKey + +/** + * The framing here is v1's, not ours: `Cryptography.encryptWithIV` prefixed the 12 byte GCM IV to + * the ciphertext in one blob. This is a transcription of that method, so blobs built with it are + * wire compatible with what real v1 installs wrote, not merely self-consistent with + * `LegacyAesGcmCipher`. + */ +internal fun encryptLikeV1(plaintext: ByteArray, key: SecretKey): ByteArray { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key) + val iv = cipher.iv + val encrypted = cipher.doFinal(plaintext) + return iv + encrypted +} From 7b5f2bb30716b9fda142384bbab7f583670a3b86 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 1 Aug 2026 18:47:05 +0200 Subject: [PATCH 30/31] refactor: dedup transaction runner --- .../repository/ItemTransactionRunnerImpl.kt | 15 ----------- ...tionRunner.kt => TransactionRunnerImpl.kt} | 4 +-- .../core/item/domain/TransactionRunner.kt | 13 ---------- .../repository/ItemTransactionRunner.kt | 14 ----------- .../domain/repository/TransactionRunner.kt | 5 ++++ ...plTest.kt => TransactionRunnerImplTest.kt} | 6 ++--- .../core/item/FakeItemTransactionRunner.kt | 25 ------------------- .../keygo/core/item/FakeTransactionRunner.kt | 7 ++++-- .../usecase/MigrateLegacyDataUseCase.kt | 4 +-- .../LegacyMigrationEndToEndTest.kt | 4 +-- .../LegacyMigrationRealDatabaseTest.kt | 4 +-- .../usecase/MigrateLegacyDataUseCaseTest.kt | 4 +-- 12 files changed, 23 insertions(+), 82 deletions(-) delete mode 100644 core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/ItemTransactionRunnerImpl.kt rename core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/{RoomTransactionRunner.kt => TransactionRunnerImpl.kt} (78%) delete mode 100644 core/item/src/main/kotlin/de/davis/keygo/core/item/domain/TransactionRunner.kt delete mode 100644 core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/ItemTransactionRunner.kt create mode 100644 core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/TransactionRunner.kt rename core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/{ItemTransactionRunnerImplTest.kt => TransactionRunnerImplTest.kt} (90%) delete mode 100644 core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeItemTransactionRunner.kt diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/ItemTransactionRunnerImpl.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/ItemTransactionRunnerImpl.kt deleted file mode 100644 index 92a05ae14..000000000 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/ItemTransactionRunnerImpl.kt +++ /dev/null @@ -1,15 +0,0 @@ -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.repository.ItemTransactionRunner -import org.koin.core.annotation.Single - -@Single -internal class ItemTransactionRunnerImpl( - private val database: ItemDatabase, -) : ItemTransactionRunner { - - override suspend fun inTransaction(block: suspend () -> R): R = - database.withTransaction { block() } -} 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/repository/ItemTransactionRunner.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/ItemTransactionRunner.kt deleted file mode 100644 index 2f11c4d5b..000000000 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/ItemTransactionRunner.kt +++ /dev/null @@ -1,14 +0,0 @@ -package de.davis.keygo.core.item.domain.repository - -/** - * Runs a block of item writes as a single database transaction. - * - * Exists so callers outside `:core:item` can commit a batch atomically without reaching for - * `ItemDatabase`, which is internal to this module. Nesting is safe: Room's transaction support - * is reentrant on the same connection, so repository methods that already open their own - * transaction join the outer one rather than starting a second. - */ -interface ItemTransactionRunner { - - suspend fun inTransaction(block: suspend () -> R): R -} 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/repository/ItemTransactionRunnerImplTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/TransactionRunnerImplTest.kt similarity index 90% rename from core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/ItemTransactionRunnerImplTest.kt rename to core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/TransactionRunnerImplTest.kt index 567acc262..576eb510b 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/ItemTransactionRunnerImplTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/TransactionRunnerImplTest.kt @@ -15,17 +15,17 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith /** - * Verifies that [ItemTransactionRunnerImpl] delegates to [ItemDatabase.withTransaction] + * 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 ItemTransactionRunnerImplTest { +internal class TransactionRunnerImplTest { private val database = mockk() - private val runner = ItemTransactionRunnerImpl(database) + private val runner = TransactionRunnerImpl(database) @BeforeTest fun setUp() { diff --git a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeItemTransactionRunner.kt b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeItemTransactionRunner.kt deleted file mode 100644 index 8b21f9ea4..000000000 --- a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeItemTransactionRunner.kt +++ /dev/null @@ -1,25 +0,0 @@ -package de.davis.keygo.core.item - -import de.davis.keygo.core.item.domain.repository.ItemTransactionRunner - -/** - * Runs the block inline with no real transaction. - * - * Records how many times a transaction was opened so tests can assert a batch was committed as - * one unit rather than per item. Set [failWith] to make the next call throw before running the - * block, which stands in for a transaction that could not be opened. - */ -class FakeItemTransactionRunner : ItemTransactionRunner { - - var transactionCount: Int = 0 - private set - - /** Thrown by the next [inTransaction] call, then cleared. */ - var failWith: Throwable? = null - - override suspend fun inTransaction(block: suspend () -> R): R { - failWith?.let { failWith = null; throw it } - transactionCount++ - return block() - } -} 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/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 index face5aa1f..74d076513 100644 --- 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 @@ -5,7 +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.repository.ItemTransactionRunner +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 @@ -55,7 +55,7 @@ class MigrateLegacyDataUseCase internal constructor( private val vaultRepository: VaultRepository, private val vaultContextRepository: VaultContextRepository, private val upsertVaultItem: UpsertVaultItemUseCase, - private val transactionRunner: ItemTransactionRunner, + private val transactionRunner: TransactionRunner, ) { suspend operator fun invoke(): LegacyMigrationOutcome = try { 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 index 7f724bd2d..2cac2edc2 100644 --- 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 @@ -5,8 +5,8 @@ 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.FakeItemTransactionRunner 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 @@ -92,7 +92,7 @@ class LegacyMigrationEndToEndTest { private val creditCardRepository = FakeCreditCardRepository() private val vaultRepository = FakeVaultRepository() private val vaultContextRepository = FakeVaultContextRepository() - private val transactionRunner = FakeItemTransactionRunner() + private val transactionRunner = FakeTransactionRunner() private val cryptoProvider = FakeCryptographicScopeProvider(FakeItemRepository(loginRepository)) /** 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 index 482c12681..6eee051c5 100644 --- 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 @@ -4,8 +4,8 @@ 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.FakeItemTransactionRunner 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 @@ -80,7 +80,7 @@ class LegacyMigrationRealDatabaseTest { private val creditCardRepository = FakeCreditCardRepository() private val vaultRepository = FakeVaultRepository() private val vaultContextRepository = FakeVaultContextRepository() - private val transactionRunner = FakeItemTransactionRunner() + private val transactionRunner = FakeTransactionRunner() private val cryptoProvider = FakeCryptographicScopeProvider(FakeItemRepository(loginRepository)) private val database: LegacyDatabase = Room.databaseBuilder(dbFile.absolutePath) 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 index c19559f58..78762c440 100644 --- 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 @@ -2,8 +2,8 @@ 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.FakeItemTransactionRunner 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 @@ -43,7 +43,7 @@ class MigrateLegacyDataUseCaseTest { private val legacyRepository = FakeLegacyItemRepository() private val keyRepository = FakeLegacyKeyRepository() - private val transactionRunner = FakeItemTransactionRunner() + private val transactionRunner = FakeTransactionRunner() private val loginRepository = FakeLoginRepository() private val creditCardRepository = FakeCreditCardRepository() private val vaultRepository = FakeVaultRepository() From 580b18ef24612a534dda019c53ffb45b58c4a9b0 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 1 Aug 2026 19:08:16 +0200 Subject: [PATCH 31/31] fix: make CI pass --- .../repository/TransactionRunnerImplTest.kt | 6 +++--- .../keygo/feature/backup/data/BackupSession.kt | 4 ++++ .../feature/backup/domain/BackupRestorer.kt | 3 +-- .../keygo/feature/backup/BackupTestData.kt | 3 +++ .../domain/usecase/MigrateLegacyDataUseCase.kt | 2 +- .../legacy_data/LegacyMigrationEndToEndTest.kt | 2 +- .../usecase/MigrateLegacyDataUseCaseTest.kt | 6 +++--- .../legacy_data/data/EncryptLikeV1.kt | 18 ------------------ 8 files changed, 16 insertions(+), 28 deletions(-) delete mode 100644 migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/EncryptLikeV1.kt 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 index 576eb510b..e20535410 100644 --- 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 @@ -42,7 +42,7 @@ internal class TransactionRunnerImplTest { @Test fun `returns the block result`() = runTest { - assertEquals(42, runner.inTransaction { 42 }) + assertEquals(42, runner.runInTransaction { 42 }) } @Test @@ -50,7 +50,7 @@ internal class TransactionRunnerImplTest { val error = IllegalStateException("boom") val thrown = assertFailsWith { - runner.inTransaction { throw error } + runner.runInTransaction { throw error } } assertEquals(error, thrown) @@ -60,7 +60,7 @@ internal class TransactionRunnerImplTest { fun `invokes the block exactly once inside the transaction`() = runTest { var invocations = 0 - runner.inTransaction { invocations++ } + runner.runInTransaction { invocations++ } assertEquals(1, invocations) coVerify(exactly = 1) { database.withTransaction(any Any?>()) } 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/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 index 74d076513..f1a3463be 100644 --- 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 @@ -135,7 +135,7 @@ class MigrateLegacyDataUseCase internal constructor( // 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.inTransaction { + transactionRunner.runInTransaction { for ((legacyId, item) in converted) upsertVaultItem(item).onFailure { throw LegacyMigrationException("Could not write legacy row $legacyId", it) 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 index 2cac2edc2..efb45f699 100644 --- 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 @@ -376,7 +376,7 @@ class LegacyMigrationEndToEndTest { assertEquals(500, outcome.report.migratedItems) assertEquals(500, loginRepository.observeLogins().first().size) - assertEquals(1, transactionRunner.transactionCount) + assertEquals(1, transactionRunner.enteredCount) assertFalse(dbFile.exists()) } 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 index 78762c440..043517c3d 100644 --- 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 @@ -195,7 +195,7 @@ class MigrateLegacyDataUseCaseTest { useCase()() - assertEquals(1, transactionRunner.transactionCount) + assertEquals(1, transactionRunner.enteredCount) } @Test @@ -301,7 +301,7 @@ class MigrateLegacyDataUseCaseTest { assertIs(useCase(scopeProvider = unopenable)()) - assertEquals(0, transactionRunner.transactionCount) + assertEquals(0, transactionRunner.enteredCount) assertTrue(legacyRepository.prunedIds.isEmpty()) assertFalse(legacyRepository.databaseDeleted) assertFalse(keyRepository.deleted) @@ -343,7 +343,7 @@ class MigrateLegacyDataUseCaseTest { assertFalse(legacyRepository.databaseDeleted) assertFalse(keyRepository.deleted) - assertEquals(0, transactionRunner.transactionCount) + assertEquals(0, transactionRunner.enteredCount) } @Test diff --git a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/EncryptLikeV1.kt b/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/EncryptLikeV1.kt deleted file mode 100644 index b8abd198b..000000000 --- a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/EncryptLikeV1.kt +++ /dev/null @@ -1,18 +0,0 @@ -package de.davis.keygo.migration.legacy_data.data - -import javax.crypto.Cipher -import javax.crypto.SecretKey - -/** - * The framing here is v1's, not ours: `Cryptography.encryptWithIV` prefixed the 12 byte GCM IV to - * the ciphertext in one blob. This is a transcription of that method, so blobs built with it are - * wire compatible with what real v1 installs wrote, not merely self-consistent with - * `LegacyAesGcmCipher`. - */ -internal fun encryptLikeV1(plaintext: ByteArray, key: SecretKey): ByteArray { - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.ENCRYPT_MODE, key) - val iv = cipher.iv - val encrypted = cipher.doFinal(plaintext) - return iv + encrypted -}