diff --git a/CLAUDE.md b/CLAUDE.md index 1744087ba..8f607ac49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,8 +52,7 @@ Android password manager using Clean Architecture per module: | `:feature:*` (remaining) | `list_screen`, `item:{core,create,view}`, `credentials`, `totp`, `vault` | | `:automation` | `@VaultItem` annotation + automation support | | `:automation-processor` | KSP processor generating code from `@VaultItem` | -| `:migration:create-access` | Post-migration main-password/account setup (high risk) | -| `:migration:legacy-data` | Reads/decrypts the legacy v1 database for import (high risk) | +| `:legacy-migration` | The whole v1 to v2 migration: main password, item import (high risk) | | `:rust` | Rust crypto/passkey ops via UniFFI-generated Kotlin bindings | ## Key Patterns @@ -127,8 +126,12 @@ carries its own rules: ## Sensitive Areas -- **Migration** (`migration:create-access`, `migration:legacy-data`) — preserve backward compat, - smallest safe change +- **Migration** (`legacy-migration`) — preserve backward compat, smallest safe change. The v1 main + password record is the marker for the whole migration and is cleared only by + `RunPendingMigrationUseCase`, only after the item import has returned a verdict. Four names are + the on-disk identity of shipped v1 data and must never change: `main-password.db`, + `secure_element_database`, `password_manager_skey`, and the `ProtoMainPassword` field numbers. + `OnDiskIdentityTest` pins all four. - **Autofill** (`feature/autofill/`) — constrained by Android framework, keep conservative - **UniFFI** — preserve memory and type safety across the FFI boundary. - **Room schema** — check migration implications before changing entities diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ffb70bed1..72cec353d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -119,8 +119,7 @@ dependencies { implementation(projects.feature.settings) implementation(projects.feature.backup) implementation(projects.feature.onboarding) - implementation(projects.migration.createAccess) - implementation(projects.migration.legacyData) + implementation(projects.legacyMigration) implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.ktx) diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/AppViewModel.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/AppViewModel.kt index add7fd8e3..3e82a6e01 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/AppViewModel.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/AppViewModel.kt @@ -3,7 +3,7 @@ package de.davis.keygo.app.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import de.davis.keygo.core.identity.domain.repository.AccountRepository -import de.davis.keygo.migration.create_access.domain.usecase.HasMainPasswordUseCase +import de.davis.keygo.legacy_migration.domain.usecase.HasMainPasswordUseCase import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update diff --git a/core/identity/src/testFixtures/kotlin/de/davis/keygo/core/identity/FakeAccountRepository.kt b/core/identity/src/testFixtures/kotlin/de/davis/keygo/core/identity/FakeAccountRepository.kt index 20d84af6d..211beee49 100644 --- a/core/identity/src/testFixtures/kotlin/de/davis/keygo/core/identity/FakeAccountRepository.kt +++ b/core/identity/src/testFixtures/kotlin/de/davis/keygo/core/identity/FakeAccountRepository.kt @@ -3,6 +3,7 @@ package de.davis.keygo.core.identity import de.davis.keygo.core.identity.domain.model.Account import de.davis.keygo.core.identity.domain.repository.AccountRepository import de.davis.keygo.core.util.Result +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -14,16 +15,40 @@ class FakeAccountRepository : AccountRepository { var setFails: Boolean = false + /** + * When set, [getOrNull] suspends on it and answers with whatever it is completed to. + * + * The only place a test can stand inside an unlock. Reading the account is the first thing + * `UnlockWithPasswordUseCase` does and everything after it hops to `Dispatchers.Default`, which + * the test scheduler cannot see, so this is the one suspension a test can both hold open and + * release on demand. + */ + var pendingRead: CompletableDeferred? = null + + /** + * How many accounts were actually persisted. A caller that mints a second account overwrites + * the first here exactly as the real registry does, so the stored value alone cannot tell the + * two apart. + */ + var setCount: Int = 0 + private set + fun seed(account: Account) { this.account.update { account } } - override suspend fun getOrNull(): Account? = account.value + // Not an elvis over await(): completing the read with null is the point of it, and an elvis + // would quietly hand back the seeded account instead. + override suspend fun getOrNull(): Account? = when (val pending = pendingRead) { + null -> account.value + else -> pending.await() + } override fun observe(): Flow = account.asStateFlow() override suspend fun set(account: Account): Result { if (setFails) return Result.Failure(Unit) + setCount++ this.account.update { account } return Result.Success(Unit) } 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 f2b5c0fdd..75b2d742c 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,9 +1,6 @@ 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 @@ -12,22 +9,12 @@ 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() { 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 338732356..49126e789 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,21 +1,9 @@ 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() } 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 bed79086b..b0e3d106e 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,10 +1,5 @@ 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 @@ -63,33 +58,4 @@ 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/FakeSession.kt b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeSession.kt index 84c831d87..49ff11216 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,9 +1,6 @@ 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. @@ -18,24 +15,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() } - _sessionStarts.tryEmit(Unit) - } + if (startOnConstruct) _ark = ByteArray(32) { it.toByte() } } 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 b72e45587..ab3dcdedb 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -11,14 +11,15 @@ dependencies { implementation(projects.core.identity) implementation(projects.core.item) implementation(projects.core.ui) - implementation(projects.migration.createAccess) + implementation(projects.legacyMigration) implementation(libs.androidx.navigation.compose) testImplementation(projects.rust) + testImplementation(libs.robolectric) testImplementation(testFixtures(projects.core.identity)) testImplementation(testFixtures(projects.core.item)) testImplementation(testFixtures(projects.core.security)) testImplementation(testFixtures(projects.rust)) - testImplementation(testFixtures(projects.migration.createAccess)) + testImplementation(testFixtures(projects.legacyMigration)) } diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthContent.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthContent.kt index 07191375c..7e52e36a8 100644 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthContent.kt +++ b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthContent.kt @@ -31,6 +31,7 @@ import androidx.compose.material3.OutlinedSecureTextField import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -75,6 +76,79 @@ fun AuthContent( } } + is AuthState.ImportingLegacyData -> { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + ) { + ContainedLoadingIndicator() + + Text( + text = stringResource(R.string.importing_legacy_data), + style = MaterialTheme.typography.titleMedium, + ) + + Text( + text = stringResource(R.string.importing_legacy_data_description), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + is AuthState.MigrationSummary -> { + Surface(modifier = Modifier.fillMaxSize()) { + AlertDialog( + onDismissRequest = {}, + icon = { + Icon(imageVector = Icons.Default.AutoFixHigh, contentDescription = null) + }, + title = { Text(text = stringResource(R.string.migration_summary_title)) }, + text = { + Text( + text = stringResource( + R.string.migration_summary_description, + state.skippedItems, + ), + ) + }, + confirmButton = { + Button(onClick = { onEvent(AuthUIEvent.ContinueAfterMigration) }) { + Text(text = stringResource(R.string.continue_anyway)) + } + }, + ) + } + } + + is AuthState.MigrationFailed -> { + Surface(modifier = Modifier.fillMaxSize()) { + AlertDialog( + onDismissRequest = {}, + icon = { + Icon(imageVector = Icons.Default.AutoFixHigh, contentDescription = null) + }, + title = { Text(text = stringResource(R.string.migration_failed_title)) }, + text = { Text(text = stringResource(R.string.migration_failed_description)) }, + confirmButton = { + Button(onClick = { onEvent(AuthUIEvent.RetryMigration) }) { + Text(text = stringResource(R.string.retry)) + } + }, + dismissButton = { + TextButton(onClick = { onEvent(AuthUIEvent.ContinueAfterMigration) }) { + Text(text = stringResource(R.string.continue_anyway)) + } + }, + ) + } + } + is AuthState.Interactable -> InteractableAuthContent( state = state, onEvent = onEvent, 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 c21829175..9034b97c7 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 @@ -36,7 +36,7 @@ fun AuthScreen(onSuccess: () -> Unit) { keyId = KeyId.BiometricVaultKek, mode = request.cryptoMode ).onSuccess { - viewModel.executeCreateAccessAndClearV1(request.password, it) + viewModel.executeCreateAccess(request.password, it) }.onFailure { Log.e("AuthScreen", "Failed to create cipher for biometric access: $it") // TODO: show error @@ -47,7 +47,7 @@ fun AuthScreen(onSuccess: () -> Unit) { biometricUnlockAdapter.useAdapter { biometricCryptoController.requestUnlockVault() }.onSuccess { - currentOnSuccess() + viewModel.onSessionEstablished() } } } 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 4b2c71afd..3b7a3e981 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 @@ -17,9 +17,11 @@ import de.davis.keygo.core.util.onSuccess import de.davis.keygo.feature.auth.presentation.model.AuthState import de.davis.keygo.feature.auth.presentation.model.AuthUIEvent import de.davis.keygo.feature.auth.presentation.model.BiometricRequest -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.legacy_migration.domain.model.MigrationResult +import de.davis.keygo.legacy_migration.domain.usecase.HasMainPasswordUseCase +import de.davis.keygo.legacy_migration.domain.usecase.RunPendingMigrationUseCase +import de.davis.keygo.legacy_migration.domain.usecase.ValidateMainPasswordUseCase +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -36,9 +38,9 @@ internal class AuthViewModel( accountRepository: AccountRepository, // ---- Migration ---- - hasV1MainPassword: HasMainPasswordUseCase, + private val hasV1MainPassword: HasMainPasswordUseCase, private val validateMainPassword: ValidateMainPasswordUseCase, - private val clearMainPasswordUseCase: ClearMainPasswordUseCase, + private val runPendingMigration: RunPendingMigrationUseCase, // ------------------- private val unlockWithPassword: UnlockWithPasswordUseCase, @@ -88,9 +90,12 @@ internal class AuthViewModel( } } - private val navigationEventChannel = Channel() + private val navigationEventChannel = Channel(Channel.BUFFERED) val navigationEvent = navigationEventChannel.receiveAsFlow() + private var migrationJob: Job? = null + private var authJob: Job? = null + fun onEvent(event: AuthUIEvent) { when (event) { is AuthUIEvent.RequestBiometricAuthentication -> if (uiState.value is AuthState.Login) requestBiometricLogin() @@ -113,9 +118,12 @@ internal class AuthViewModel( loading { validateMainPassword(password).asResult(Unit) .onFailure { - _uiState.update { - if (it !is AuthState.Interactable) return@update it - it.copyDefaultState(passwordError = UiFieldError.Incorrect) + // Through the scope rather than straight to _uiState: loading + // writes the scope's state back when the block returns, so a + // direct write here would be overwritten and the user would see + // the spinner stop with no error against the field. + updateState { + copyDefaultState(passwordError = UiFieldError.Incorrect) } }.onSuccess { createPasswordOrBiometricAccess(state, password) @@ -138,19 +146,42 @@ internal class AuthViewModel( it.copy(useBiometrics = event.checked) } } + + AuthUIEvent.RetryMigration -> onSessionEstablished() + + AuthUIEvent.ContinueAfterMigration -> navigationEventChannel.trySend(Unit) } } - private fun createPasswordOrBiometricAccess( + /** + * Runs inside the caller's [loading] rather than starting a second one, so the screen stays + * loading until the account actually exists. A nested [loading] returned as soon as it had + * launched, which wrote `loading = false` back while key derivation was still running and + * re-enabled Submit for the whole of it. + */ + private suspend fun LoadingScope.createPasswordOrBiometricAccess( authState: AuthState.Migrating, - password: String + password: String, ) { - if (!authState.biometricsAvailable || !authState.useBiometrics) { - executeCreateAccessAndClearV1(password = password) + if (authState.biometricsAvailable && authState.useBiometrics) { + // Handed to the prompt. AuthScreen starts a fresh run with the cipher once the user has + // answered, and by then this one has finished, so the guard in loading does not eat it. + // + // That ordering is worth stating, because it is not obvious and it is not local. The + // collector observing this channel runs on Dispatchers.Main.immediate, so it resumes + // inline inside trySend and AuthScreen's handler begins running while this job is still + // active. What saves it is that requestCipher suspends until the user answers, and its + // one synchronous return is a failure that never reaches executeCreateAccess. A fast + // path added there that returned a cipher without suspending would be dropped by the + // guard, and the user would sit on the migrate screen with no account. + biometricChannel.trySend(BiometricRequest.CreateAccess(password)) return } - biometricChannel.trySend(BiometricRequest.CreateAccess(password)) + createAllAccesses( + password = password, + biometricCipher = null, + ).handleAuthenticationResult() } private fun requestBiometricLogin() { @@ -159,39 +190,83 @@ internal class AuthViewModel( private fun loading( setLoading: Boolean = true, - block: suspend LoadingScope.() -> Unit + block: suspend LoadingScope.() -> Unit, ) { + // One auth run at a time. Submit and the biometric callback both arrive here, and `onEvent` + // gates on the state being interactable rather than on the loading flag, so nothing else + // stops a second run. Two runs of account creation mint two accounts, two ARKs and two + // vaults and the second overwrites the registry, which leaves the first vault wrapped under + // an ARK that is no longer persisted anywhere. + if (authJob?.isActive == true) return + if (setLoading) _uiState.update { if (it !is AuthState.Interactable) return@update it it.copyDefaultState(loading = true) } - viewModelScope.launch { + authJob = viewModelScope.launch { + val current = _uiState.value as? AuthState.Interactable ?: return@launch + + var sessionEstablished = false + val scope = LoadingScope( + state = current, + onSuccess = { sessionEstablished = true }, + ) + scope.block() + _uiState.update { if (it !is AuthState.Interactable) return@update it + scope.updatedState.copyDefaultState(loading = false) + } + + if (sessionEstablished) onSessionEstablished() + } + } + + /** + * Run after every path that establishes a session, which is the only moment the import can + * happen: every secret it writes is re-encrypted under a key that hangs off the ARK. + * + * The marker is read here as well as inside the use case so the common case, an install with no + * v1 migration pending, never flips the screen into an import it is not going to run. + */ + fun onSessionEstablished() { + // Retry is a button on a screen the user reaches after a failure, so it can be tapped twice + // before the first run has published anything. Two concurrent imports would both read the + // same v1 rows and both write them, so a tap that lands while one is running is dropped. + if (migrationJob?.isActive == true) return + + migrationJob = viewModelScope.launch { + if (!hasV1MainPassword()) { + navigationEventChannel.trySend(Unit) + return@launch + } - LoadingScope( - state = it, - onSuccess = { navigationEventChannel.trySend(Unit) }, - ).apply { - block() - }.updatedState.copyDefaultState(loading = false) + _uiState.update { AuthState.ImportingLegacyData } + + when (val result = runPendingMigration()) { + MigrationResult.NotPending -> navigationEventChannel.trySend(Unit) + + is MigrationResult.Completed -> + if (result.skippedItems == 0) navigationEventChannel.trySend(Unit) + else _uiState.update { AuthState.MigrationSummary(result.skippedItems) } + + is MigrationResult.Incomplete -> + _uiState.update { AuthState.MigrationFailed } } } } - fun executeCreateAccessAndClearV1( + fun executeCreateAccess( password: String, - cipher: Cipher? = null + cipher: Cipher? = null, ) { loading { createAllAccesses( password = password, - biometricCipher = cipher - ).onSuccess { - clearMainPasswordUseCase() - }.handleAuthenticationResult() + biometricCipher = cipher, + ).handleAuthenticationResult() } } } @@ -203,6 +278,14 @@ private class LoadingScope( var updatedState: State = state private set + /** + * Records a state change without claiming a session was established, for the paths that have + * something to say about the screen but have not authenticated anything. + */ + fun updateState(transform: State.() -> State) { + updatedState = updatedState.transform() + } + fun Result.handleAuthenticationResult(onFailure: State.(E) -> State = { this }) { onSuccess { onSuccess() } .onFailure { updatedState = updatedState.onFailure(it) } diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/model/AuthState.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/model/AuthState.kt index 330a32306..2f4c9585d 100644 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/model/AuthState.kt +++ b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/model/AuthState.kt @@ -46,4 +46,19 @@ sealed interface AuthState { val useBiometrics: Boolean = true, val showMigrationDialog: Boolean = true ) : Interactable + + /** The v1 import is running. Nothing on screen is interactable while it does. */ + data object ImportingLegacyData : AuthState + + /** + * The import finished but left rows behind. Shown once, because the rows are gone as far as the + * user is concerned and nothing else would ever tell them. + */ + data class MigrationSummary(val skippedItems: Int) : AuthState + + /** + * The import could not reach a verdict. The v1 password is still on disk, so both buttons are + * safe: Retry runs it again, Continue enters the app and leaves it for the next launch. + */ + data object MigrationFailed : AuthState } diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/model/AuthUIEvent.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/model/AuthUIEvent.kt index 03245614a..fe9efd2dd 100644 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/model/AuthUIEvent.kt +++ b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/model/AuthUIEvent.kt @@ -8,4 +8,7 @@ sealed interface AuthUIEvent { data object CloseMigrationDialog : AuthUIEvent data class ToggleUseBiometrics(val checked: Boolean) : AuthUIEvent + + data object RetryMigration : AuthUIEvent + data object ContinueAfterMigration : AuthUIEvent } \ No newline at end of file diff --git a/feature/auth/src/main/res/values/strings.xml b/feature/auth/src/main/res/values/strings.xml index ed197a27e..114563069 100644 --- a/feature/auth/src/main/res/values/strings.xml +++ b/feature/auth/src/main/res/values/strings.xml @@ -18,4 +18,13 @@ Request biometric authentication Unlock your vault to add this authenticator code. + + Importing your data + Moving your items into your new vault. This happens once. + Import finished + %1$d of your items could not be read and were left behind. Everything else is in your vault. + Import incomplete + Your items could not be imported. Nothing was lost, and KeyGo will try again the next time you open it. + Retry + Continue \ No newline at end of file diff --git a/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelTest.kt b/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelTest.kt index adbe208b3..f719e21d5 100644 --- a/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelTest.kt +++ b/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelTest.kt @@ -1,21 +1,31 @@ package de.davis.keygo.feature.auth.presentation +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.lifecycle.SavedStateHandle import de.davis.keygo.core.identity.FakeAccountRepository +import de.davis.keygo.core.identity.domain.model.Account import de.davis.keygo.core.identity.domain.usecase.CreateAccessUseCase import de.davis.keygo.core.identity.domain.usecase.UnlockWithPasswordUseCase import de.davis.keygo.core.item.FakeVaultContextRepository import de.davis.keygo.core.item.FakeVaultRepository import de.davis.keygo.core.security.crypto.FakeBiometricAvailabilityRepository import de.davis.keygo.core.security.crypto.FakeSession +import de.davis.keygo.core.ui.model.UiFieldError import de.davis.keygo.feature.auth.presentation.model.AuthState -import de.davis.keygo.migration.create_access.FakeMainPasswordRepository -import de.davis.keygo.migration.create_access.clearMainPasswordUseCase -import de.davis.keygo.migration.create_access.hasMainPasswordUseCase -import de.davis.keygo.migration.create_access.validateMainPasswordUseCase +import de.davis.keygo.feature.auth.presentation.model.AuthUIEvent +import de.davis.keygo.legacy_migration.FakeMainPasswordRepository +import de.davis.keygo.legacy_migration.domain.model.LegacyFailureReason +import de.davis.keygo.legacy_migration.domain.model.LegacyMigrationOutcome +import de.davis.keygo.legacy_migration.domain.model.LegacyMigrationReport +import de.davis.keygo.legacy_migration.domain.model.LegacyRowFailure +import de.davis.keygo.legacy_migration.domain.usecase.RunPendingMigrationUseCase +import de.davis.keygo.legacy_migration.hasMainPasswordUseCase +import de.davis.keygo.legacy_migration.runPendingMigrationUseCase +import de.davis.keygo.legacy_migration.validateMainPasswordUseCase import de.davis.keygo.rust.FakeAccountManager import de.davis.keygo.rust.FakeKeyDeriver import de.davis.keygo.rust.FakeKeyWrapper +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first @@ -25,22 +35,29 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import javax.crypto.Cipher import javax.crypto.KeyGenerator import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs /** * Regression tests for the v1-password retry lockout fixed in `97b15f3c`. * - * [AuthViewModel.executeCreateAccessAndClearV1] used to clear the v1 migration password as soon - * as the password was validated, before the account was actually created. If account creation - * then failed for any reason - most notably a failed/declined biometric prompt - the v1 password - * was already gone, so `HasMainPasswordUseCase` reported no pending migration and the user had no - * way to retry. The fix defers `clearMainPasswordUseCase()` until account creation succeeds. + * [AuthViewModel.executeCreateAccess] used to clear the v1 migration password as soon as the + * password was validated, before the account was actually created. If account creation then + * failed for any reason - most notably a failed/declined biometric prompt - the v1 password was + * already gone, so `HasMainPasswordUseCase` reported no pending migration and the user had no way + * to retry. Clearing the marker now belongs to [RunPendingMigrationUseCase], which runs only after + * a session exists and only once the import has said something definite about the v1 file. */ +@RunWith(RobolectricTestRunner::class) // Robolectric for android.util.Log alone. +@Config(sdk = [34]) @OptIn(ExperimentalCoroutinesApi::class) class AuthViewModelTest { @@ -78,7 +95,6 @@ class AuthViewModelTest { // they can't be built here directly the way a plain fake dependency would be. private val hasV1MainPassword = hasMainPasswordUseCase(mainPasswordRepository) private val validateMainPassword = validateMainPasswordUseCase(mainPasswordRepository) - private val clearMainPassword = clearMainPasswordUseCase(mainPasswordRepository) @BeforeTest fun setUp() { @@ -93,14 +109,17 @@ class AuthViewModelTest { * [mainPasswordRepository]'s state, so seed a hash before calling this for the ViewModel to * resolve into `AuthState.Migrating`. */ - private fun TestScope.viewModel(): AuthViewModel { + private fun TestScope.viewModel( + runPendingMigration: RunPendingMigrationUseCase = + runPendingMigrationUseCase(backgroundScope, mainPasswordRepository), + ): AuthViewModel { val vm = AuthViewModel( savedStateHandle = SavedStateHandle(), biometricAvailabilityRepository = biometricAvailability, accountRepository = accountRepository, hasV1MainPassword = hasV1MainPassword, validateMainPassword = validateMainPassword, - clearMainPasswordUseCase = clearMainPassword, + runPendingMigration = runPendingMigration, unlockWithPassword = unlockWithPassword, createAllAccesses = createAllAccesses, ) @@ -127,7 +146,7 @@ class AuthViewModelTest { // failure mode described in the bug report. val failingCipher = Cipher.getInstance("AES/GCM/NoPadding") - vm.executeCreateAccessAndClearV1(password = "correct-password", cipher = failingCipher) + vm.executeCreateAccess(password = "correct-password", cipher = failingCipher) vm.awaitIdle() assertEquals("original-v1-hash", mainPasswordRepository.hash) @@ -142,8 +161,8 @@ class AuthViewModelTest { init(Cipher.WRAP_MODE, biometricKek) } - vm.executeCreateAccessAndClearV1(password = "correct-password", cipher = cipher) - vm.awaitIdle() + vm.executeCreateAccess(password = "correct-password", cipher = cipher) + vm.navigationEvent.first() assertEquals("", mainPasswordRepository.hash) } @@ -155,7 +174,7 @@ class AuthViewModelTest { accountRepository.setFails = true val vm = viewModel() - vm.executeCreateAccessAndClearV1(password = "correct-password") + vm.executeCreateAccess(password = "correct-password") vm.awaitIdle() assertEquals("original-v1-hash", mainPasswordRepository.hash) @@ -166,9 +185,209 @@ class AuthViewModelTest { mainPasswordRepository.hash = "original-v1-hash" val vm = viewModel() - vm.executeCreateAccessAndClearV1(password = "correct-password") + vm.executeCreateAccess(password = "correct-password") + vm.navigationEvent.first() + + assertEquals("", mainPasswordRepository.hash) + } + + @Test + fun `the migrate submit stays loading until the account exists`() = runTest(dispatcher) { + // Hex of a real bcrypt 2a hash of "password". The use case hex-decodes before verifying. + mainPasswordRepository.hash = "2432612431302471776e45776767315a6c5176435a58336450614a7a2e" + + "31494351504a334e6d4a64566b4251686577564655745363646665366d4847" + val vm = viewModel() + vm.onEvent(AuthUIEvent.ToggleUseBiometrics(checked = false)) + + val migrating = assertIs(vm.uiState.value) + migrating.passwordTextFieldState.setTextAndPlaceCursorAtEnd("password") + + vm.onEvent(AuthUIEvent.Submit) + runCurrent() + + // Key derivation is still running on a dispatcher the scheduler cannot see. The screen must + // not have handed control back yet. + assertEquals(true, assertIs(vm.uiState.value).loading) + + vm.navigationEvent.first() + + assertEquals(1, accountRepository.setCount) + } + + /** + * Key derivation takes long enough for a second tap to land inside it. `onEvent` gates on the + * state being interactable rather than on the loading flag, so before the guard the only thing + * stopping a second run was the button's own enabled state, and the migrate path stopped the + * spinner while derivation was still going. Two runs mint two accounts, two ARKs and two + * vaults, and the second overwrites the registry, leaving the first vault wrapped under an ARK + * nothing persists. + */ + @Test + fun `a second account creation started while one is in flight is dropped`() = + runTest(dispatcher) { + mainPasswordRepository.hash = "original-v1-hash" + val vm = viewModel() + + vm.executeCreateAccess(password = "correct-password") + // Leaves the first run suspended inside key derivation, which hops to a dispatcher the + // scheduler cannot see, so it cannot complete until something pumps the test one. + runCurrent() + vm.executeCreateAccess(password = "correct-password") + + vm.navigationEvent.first() + + assertEquals(1, accountRepository.setCount) + } + + @Test + fun `a rejected v1 main password leaves an error on the field`() = runTest(dispatcher) { + // Hex of a real bcrypt 2a hash of "password". The use case hex-decodes the stored hash + // before handing it to bcrypt, so a non-hex placeholder throws instead of returning false. + mainPasswordRepository.hash = "243261243130244e39716f38754c4f69636b6778325a4d525a6f4d7965" + + "496a5a416763666c377039326c644778616436384c4a5a644c31376c685779" + val vm = viewModel() + + val migrating = assertIs(vm.uiState.value) + migrating.passwordTextFieldState.setTextAndPlaceCursorAtEnd("the-wrong-password") + + vm.onEvent(AuthUIEvent.Submit) + // Bcrypt runs on Dispatchers.Default, which the scheduler cannot see, so wait on the + // loading flag for the same reason awaitIdle does. vm.awaitIdle() + val after = assertIs(vm.uiState.value) + assertEquals(UiFieldError.Incorrect, after.passwordError) + } + + @Test + fun `a failed import leaves the v1 password in place and offers a retry`() = + runTest(dispatcher) { + mainPasswordRepository.hash = "original-v1-hash" + val vm = viewModel( + runPendingMigration = runPendingMigrationUseCase( + scope = backgroundScope, + repository = mainPasswordRepository, + outcome = LegacyMigrationOutcome.Failed(IllegalStateException("unreadable")), + ), + ) + + vm.executeCreateAccess(password = "correct-password") + vm.uiState.first { it is AuthState.MigrationFailed } + + assertEquals("original-v1-hash", mainPasswordRepository.hash) + } + + @Test + fun `retrying a failed import runs it again`() = runTest(dispatcher) { + mainPasswordRepository.hash = "original-v1-hash" + var runs = 0 + var stateDuringImport: AuthState? = null + // Sampled from inside the import because uiState is conflated: ImportingLegacyData is + // replaced before any collector is resumed, so this is the only place it can be observed. + var underTest: AuthViewModel? = null + val vm = viewModel( + runPendingMigration = runPendingMigrationUseCase( + scope = backgroundScope, + repository = mainPasswordRepository, + outcome = LegacyMigrationOutcome.Failed(IllegalStateException("unreadable")), + onImport = { + runs++ + stateDuringImport = underTest?.uiState?.value + }, + ), + ) + underTest = vm + + vm.executeCreateAccess(password = "correct-password") + vm.uiState.first { it is AuthState.MigrationFailed } + assertEquals(1, runs) + assertEquals(AuthState.ImportingLegacyData, stateDuringImport) + + // The retry passes through ImportingLegacyData and back to MigrationFailed without ever + // suspending, because the import is a fake. uiState is conflated, so a collector waiting on + // the intermediate state is only ever resumed after the final one has replaced it and would + // wait forever. Drain the scheduler instead and assert on what the retry left behind. + vm.onEvent(AuthUIEvent.RetryMigration) + runCurrent() + + assertEquals(2, runs) + assertIs(vm.uiState.value) + assertEquals("original-v1-hash", mainPasswordRepository.hash) + } + + /** + * The write that ends a loading run puts back a snapshot taken before `block()` suspended, so + * it has to be guarded the same way the one that starts the run is. + * + * `onSessionEstablished` is reachable from outside `loading`: AuthScreen calls it straight from + * the BiometricRequest.Login success handler and nothing gates it on `authJob`. So the user + * submits the password form, the biometric prompt they already triggered comes back, the import + * starts, and an unguarded write here would drop the live login form back on top of it - a form + * they can submit again while the import runs behind it. + */ + @Test + fun `a state set while the auth run was suspended is not written over`() = runTest(dispatcher) { + // An account plus a marker still on disk: what the user is left with the moment they tap + // Continue on MigrationFailed. Every unlock after that is a login form over a migration + // that is still pending. + val first = viewModel() + first.executeCreateAccess(password = "correct-password") + first.navigationEvent.first() + mainPasswordRepository.hash = "original-v1-hash" + + val vm = viewModel( + runPendingMigration = runPendingMigrationUseCase( + scope = backgroundScope, + repository = mainPasswordRepository, + outcome = LegacyMigrationOutcome.Failed(IllegalStateException("unreadable")), + ), + ) + val login = assertIs(vm.uiState.value) + login.passwordTextFieldState.setTextAndPlaceCursorAtEnd("correct-password") + + // Holds the unlock at its account read, which is the last point before it hops to a + // dispatcher the scheduler cannot see. + val read = CompletableDeferred() + accountRepository.pendingRead = read + vm.onEvent(AuthUIEvent.Submit) + runCurrent() + assertEquals(true, assertIs(vm.uiState.value).loading) + + vm.onSessionEstablished() + runCurrent() + assertIs(vm.uiState.value) + + // Answering with no account fails the unlock where it stands, so the only thing left to + // happen is loading writing its snapshot back. + read.complete(null) + runCurrent() + + assertIs(vm.uiState.value) + } + + @Test + fun `an import that skipped rows reports them before navigating`() = runTest(dispatcher) { + mainPasswordRepository.hash = "original-v1-hash" + val vm = viewModel( + runPendingMigration = runPendingMigrationUseCase( + scope = backgroundScope, + repository = mainPasswordRepository, + outcome = LegacyMigrationOutcome.Migrated( + LegacyMigrationReport( + migratedItems = 14, + failures = listOf( + LegacyRowFailure(1, "an account", LegacyFailureReason.Unreadable), + LegacyRowFailure(2, "another", LegacyFailureReason.Unreadable), + ), + ), + ), + ), + ) + + vm.executeCreateAccess(password = "correct-password") + val state = vm.uiState.first { it is AuthState.MigrationSummary } + + assertEquals(2, (state as AuthState.MigrationSummary).skippedItems) assertEquals("", mainPasswordRepository.hash) } } 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 b109b8db8..9c98fd429 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,8 +1,6 @@ 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 @@ -12,8 +10,6 @@ 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/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts index 98ffcb9ba..e30014b80 100644 --- a/feature/onboarding/build.gradle.kts +++ b/feature/onboarding/build.gradle.kts @@ -17,7 +17,6 @@ dependencies { implementation(projects.core.identity) implementation(projects.feature.backup) implementation(projects.feature.autofill) - implementation(projects.migration.createAccess) implementation(libs.androidx.navigation.compose) } diff --git a/migration/legacy-data/build.gradle.kts b/legacy-migration/build.gradle.kts similarity index 85% rename from migration/legacy-data/build.gradle.kts rename to legacy-migration/build.gradle.kts index e6e1aafa3..e79b91cd6 100644 --- a/migration/legacy-data/build.gradle.kts +++ b/legacy-migration/build.gradle.kts @@ -1,12 +1,13 @@ plugins { alias(libs.plugins.keygo.android.library) + alias(libs.plugins.keygo.android.protobuf) alias(libs.plugins.androidx.room3) alias(libs.plugins.google.ksp) alias(libs.plugins.kotlin.serialization) } android { - namespace = "de.davis.keygo.migration.legacy_data" + namespace = "de.davis.keygo.legacy_migration" testFixtures { enable = true @@ -14,6 +15,9 @@ android { } dependencies { + implementation(libs.androidx.datastore) + implementation(libs.at.favre.bcrypt) + implementation(libs.androidx.room3.runtime) ksp(libs.androidx.room3.compiler) @@ -24,6 +28,7 @@ dependencies { implementation(projects.core.util) testImplementation(libs.io.mockk) + testImplementation(libs.robolectric) testImplementation(libs.androidx.room3.testing) testImplementation(libs.androidx.sqlite.bundled) testImplementation(testFixtures(projects.core.item)) diff --git a/migration/legacy-data/consumer-rules.pro b/legacy-migration/consumer-rules.pro similarity index 100% rename from migration/legacy-data/consumer-rules.pro rename to legacy-migration/consumer-rules.pro diff --git a/migration/legacy-data/schemas/de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase/1.json b/legacy-migration/schemas/de.davis.keygo.legacy_migration.data.local.datasource.LegacyDatabase/1.json similarity index 100% rename from migration/legacy-data/schemas/de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase/1.json rename to legacy-migration/schemas/de.davis.keygo.legacy_migration.data.local.datasource.LegacyDatabase/1.json diff --git a/migration/legacy-data/schemas/de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase/2.json b/legacy-migration/schemas/de.davis.keygo.legacy_migration.data.local.datasource.LegacyDatabase/2.json similarity index 100% rename from migration/legacy-data/schemas/de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase/2.json rename to legacy-migration/schemas/de.davis.keygo.legacy_migration.data.local.datasource.LegacyDatabase/2.json diff --git a/migration/legacy-data/schemas/de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase/3.json b/legacy-migration/schemas/de.davis.keygo.legacy_migration.data.local.datasource.LegacyDatabase/3.json similarity index 100% rename from migration/legacy-data/schemas/de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase/3.json rename to legacy-migration/schemas/de.davis.keygo.legacy_migration.data.local.datasource.LegacyDatabase/3.json diff --git a/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/crypto/LegacyAesGcmCipher.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/crypto/LegacyAesGcmCipher.kt new file mode 100644 index 000000000..023abc98d --- /dev/null +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/crypto/LegacyAesGcmCipher.kt @@ -0,0 +1,37 @@ +package de.davis.keygo.legacy_migration.data.crypto + +import de.davis.keygo.legacy_migration.domain.crypto.LegacyCipher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Reverses v1's `Cryptography.encryptAES`, which wrote `IV(12) || AES-256-GCM ciphertext` with a + * 128 bit tag under the `password_manager_skey` Keystore alias. + */ +@Single +internal class LegacyAesGcmCipher : LegacyCipher { + + override suspend fun decrypt(blob: ByteArray, key: SecretKey): ByteArray? { + if (blob.size <= IV_SIZE) return null + + return withContext(Dispatchers.Default) { + runCatching { + Cipher.getInstance(TRANSFORMATION) + .apply { + init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, blob, 0, IV_SIZE)) + } + .doFinal(blob, IV_SIZE, blob.size - IV_SIZE) + }.getOrNull() + } + } + + private companion object { + const val IV_SIZE = 12 + const val TAG_BITS = 128 + const val TRANSFORMATION = "AES/GCM/NoPadding" + } +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/json/LegacyDetailJson.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/json/LegacyDetailJson.kt similarity index 94% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/json/LegacyDetailJson.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/json/LegacyDetailJson.kt index c500a47f8..4a93d6907 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/json/LegacyDetailJson.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/json/LegacyDetailJson.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.data.json +package de.davis.keygo.legacy_migration.data.json import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/json/LegacyDetailParser.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/json/LegacyDetailParser.kt similarity index 85% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/json/LegacyDetailParser.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/json/LegacyDetailParser.kt index 8a4bb4198..6f2dcae58 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/json/LegacyDetailParser.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/json/LegacyDetailParser.kt @@ -1,9 +1,9 @@ -package de.davis.keygo.migration.legacy_data.data.json +package de.davis.keygo.legacy_migration.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 de.davis.keygo.legacy_migration.domain.model.LEGACY_TYPE_CREDIT_CARD +import de.davis.keygo.legacy_migration.domain.model.LEGACY_TYPE_PASSWORD +import de.davis.keygo.legacy_migration.domain.model.LegacyDetail +import de.davis.keygo.legacy_migration.domain.model.LegacyStrength import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/dao/LegacyElementDao.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/dao/LegacyElementDao.kt similarity index 68% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/dao/LegacyElementDao.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/dao/LegacyElementDao.kt index d018bec94..ef81b037a 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/dao/LegacyElementDao.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/dao/LegacyElementDao.kt @@ -1,13 +1,13 @@ -package de.davis.keygo.migration.legacy_data.data.local.dao +package de.davis.keygo.legacy_migration.data.local.dao import androidx.room3.Dao import androidx.room3.Insert import androidx.room3.Query import androidx.room3.Transaction -import de.davis.keygo.migration.legacy_data.data.local.entity.LegacySecureElementEntity -import de.davis.keygo.migration.legacy_data.data.local.entity.LegacySecureElementTagCrossRef -import de.davis.keygo.migration.legacy_data.data.local.entity.LegacyTagEntity -import de.davis.keygo.migration.legacy_data.data.local.pojo.LegacyElementWithTags +import de.davis.keygo.legacy_migration.data.local.entity.LegacySecureElementEntity +import de.davis.keygo.legacy_migration.data.local.entity.LegacySecureElementTagCrossRef +import de.davis.keygo.legacy_migration.data.local.entity.LegacyTagEntity +import de.davis.keygo.legacy_migration.data.local.pojo.LegacyElementWithTags @Dao internal interface LegacyElementDao { diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/AndroidLegacyDatabaseProvider.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/datasource/AndroidLegacyDatabaseProvider.kt similarity index 93% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/AndroidLegacyDatabaseProvider.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/datasource/AndroidLegacyDatabaseProvider.kt index b47aeb6d1..49febcd69 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/AndroidLegacyDatabaseProvider.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/datasource/AndroidLegacyDatabaseProvider.kt @@ -1,9 +1,9 @@ -package de.davis.keygo.migration.legacy_data.data.local.datasource +package de.davis.keygo.legacy_migration.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 +import de.davis.keygo.legacy_migration.data.local.migration.LegacyMigration2To3 /** * Opens the inherited file. Never creates one. diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabase.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/datasource/LegacyDatabase.kt similarity index 77% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabase.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/datasource/LegacyDatabase.kt index 20d176836..a6f480f24 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabase.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/datasource/LegacyDatabase.kt @@ -1,12 +1,12 @@ -package de.davis.keygo.migration.legacy_data.data.local.datasource +package de.davis.keygo.legacy_migration.data.local.datasource import androidx.room3.AutoMigration import androidx.room3.Database import androidx.room3.RoomDatabase -import de.davis.keygo.migration.legacy_data.data.local.dao.LegacyElementDao -import de.davis.keygo.migration.legacy_data.data.local.entity.LegacySecureElementEntity -import de.davis.keygo.migration.legacy_data.data.local.entity.LegacySecureElementTagCrossRef -import de.davis.keygo.migration.legacy_data.data.local.entity.LegacyTagEntity +import de.davis.keygo.legacy_migration.data.local.dao.LegacyElementDao +import de.davis.keygo.legacy_migration.data.local.entity.LegacySecureElementEntity +import de.davis.keygo.legacy_migration.data.local.entity.LegacySecureElementTagCrossRef +import de.davis.keygo.legacy_migration.data.local.entity.LegacyTagEntity internal const val LEGACY_DATABASE_NAME = "secure_element_database" @@ -19,7 +19,7 @@ internal const val LEGACY_DATABASE_NAME = "secure_element_database" * 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]. + * [de.davis.keygo.legacy_migration.data.local.migration.LegacyMigration2To3]. * * Opening this over an inherited file is a one-way door, but only once the open succeeds. The first * query runs the migrations, which permanently rewrite the file to version 3 and drop its diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabaseProvider.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/datasource/LegacyDatabaseProvider.kt similarity index 89% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabaseProvider.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/datasource/LegacyDatabaseProvider.kt index 37e7ea0cb..f8968afc1 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/datasource/LegacyDatabaseProvider.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/datasource/LegacyDatabaseProvider.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.data.local.datasource +package de.davis.keygo.legacy_migration.data.local.datasource internal interface LegacyDatabaseProvider { diff --git a/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/local/datasource/datastore/MainPasswordSerializer.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/datasource/datastore/MainPasswordSerializer.kt similarity index 75% rename from migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/local/datasource/datastore/MainPasswordSerializer.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/datasource/datastore/MainPasswordSerializer.kt index cebf6bf3f..586685ab8 100644 --- a/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/local/datasource/datastore/MainPasswordSerializer.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/datasource/datastore/MainPasswordSerializer.kt @@ -1,7 +1,7 @@ -package de.davis.keygo.migration.create_access.data.local.datasource.datastore +package de.davis.keygo.legacy_migration.data.local.datasource.datastore import androidx.datastore.core.Serializer -import de.davis.keygo.migration.create_access.data.local.model.ProtoMainPassword +import de.davis.keygo.legacy_migration.data.local.model.ProtoMainPassword import java.io.InputStream import java.io.OutputStream diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/entity/LegacySecureElementEntity.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/entity/LegacySecureElementEntity.kt similarity index 97% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/entity/LegacySecureElementEntity.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/entity/LegacySecureElementEntity.kt index b58ccb5c6..6c0135f28 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/entity/LegacySecureElementEntity.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/entity/LegacySecureElementEntity.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.data.local.entity +package de.davis.keygo.legacy_migration.data.local.entity import androidx.room3.ColumnInfo import androidx.room3.Embedded diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/entity/LegacySecureElementTagCrossRef.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/entity/LegacySecureElementTagCrossRef.kt similarity index 92% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/entity/LegacySecureElementTagCrossRef.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/entity/LegacySecureElementTagCrossRef.kt index 09b1cbd77..46a6d4272 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/entity/LegacySecureElementTagCrossRef.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/entity/LegacySecureElementTagCrossRef.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.data.local.entity +package de.davis.keygo.legacy_migration.data.local.entity import androidx.room3.Entity import androidx.room3.ForeignKey diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/entity/LegacyTagEntity.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/entity/LegacyTagEntity.kt similarity index 82% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/entity/LegacyTagEntity.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/entity/LegacyTagEntity.kt index f9a7d433a..1020d4e42 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/entity/LegacyTagEntity.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/entity/LegacyTagEntity.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.data.local.entity +package de.davis.keygo.legacy_migration.data.local.entity import androidx.room3.Entity import androidx.room3.Index diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/migration/LegacyMigration2To3.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/migration/LegacyMigration2To3.kt similarity index 98% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/migration/LegacyMigration2To3.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/migration/LegacyMigration2To3.kt index eee520c9d..deb9176ad 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/migration/LegacyMigration2To3.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/migration/LegacyMigration2To3.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.data.local.migration +package de.davis.keygo.legacy_migration.data.local.migration import androidx.room3.migration.Migration import androidx.sqlite.SQLiteConnection diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/pojo/LegacyElementWithTags.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/pojo/LegacyElementWithTags.kt similarity index 64% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/pojo/LegacyElementWithTags.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/pojo/LegacyElementWithTags.kt index afd09f08c..33e33bdf5 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/local/pojo/LegacyElementWithTags.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/local/pojo/LegacyElementWithTags.kt @@ -1,11 +1,11 @@ -package de.davis.keygo.migration.legacy_data.data.local.pojo +package de.davis.keygo.legacy_migration.data.local.pojo import androidx.room3.Embedded import androidx.room3.Junction import androidx.room3.Relation -import de.davis.keygo.migration.legacy_data.data.local.entity.LegacySecureElementEntity -import de.davis.keygo.migration.legacy_data.data.local.entity.LegacySecureElementTagCrossRef -import de.davis.keygo.migration.legacy_data.data.local.entity.LegacyTagEntity +import de.davis.keygo.legacy_migration.data.local.entity.LegacySecureElementEntity +import de.davis.keygo.legacy_migration.data.local.entity.LegacySecureElementTagCrossRef +import de.davis.keygo.legacy_migration.data.local.entity.LegacyTagEntity internal data class LegacyElementWithTags( @Embedded val element: LegacySecureElementEntity, diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyElementMapper.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/mapper/LegacyElementMapper.kt similarity index 73% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyElementMapper.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/mapper/LegacyElementMapper.kt index 1bc81722e..953ff576a 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/mapper/LegacyElementMapper.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/mapper/LegacyElementMapper.kt @@ -1,9 +1,9 @@ -package de.davis.keygo.migration.legacy_data.data.mapper +package de.davis.keygo.legacy_migration.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 +import de.davis.keygo.legacy_migration.data.local.pojo.LegacyElementWithTags +import de.davis.keygo.legacy_migration.domain.model.LEGACY_TAG_PREFIX +import de.davis.keygo.legacy_migration.domain.model.LegacyDetail +import de.davis.keygo.legacy_migration.domain.model.LegacyItem /** * Turns a decrypted, parsed row into a [LegacyItem]. diff --git a/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/mapper/MainPasswordMapper.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/mapper/MainPasswordMapper.kt new file mode 100644 index 000000000..acb5f9139 --- /dev/null +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/mapper/MainPasswordMapper.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.legacy_migration.data.mapper + +import de.davis.keygo.legacy_migration.data.local.model.ProtoMainPassword +import de.davis.keygo.legacy_migration.domain.model.MainPassword +import java.time.Instant + +internal fun ProtoMainPassword.toDomain() = + MainPassword(hash, Instant.ofEpochSecond(createdAt.seconds, createdAt.nanos.toLong())) diff --git a/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/repository/HashValidatorImpl.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/repository/HashValidatorImpl.kt similarity index 81% rename from migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/repository/HashValidatorImpl.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/repository/HashValidatorImpl.kt index 3064530e2..a2a574292 100644 --- a/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/repository/HashValidatorImpl.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/repository/HashValidatorImpl.kt @@ -1,8 +1,8 @@ -package de.davis.keygo.migration.create_access.data.repository +package de.davis.keygo.legacy_migration.data.repository import at.favre.lib.crypto.bcrypt.BCrypt import at.favre.lib.crypto.bcrypt.LongPasswordStrategies -import de.davis.keygo.migration.create_access.domain.repository.HashValidator +import de.davis.keygo.legacy_migration.domain.repository.HashValidator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.koin.core.annotation.Single diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyItemRepositoryImpl.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/repository/LegacyItemRepositoryImpl.kt similarity index 76% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyItemRepositoryImpl.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/repository/LegacyItemRepositoryImpl.kt index b1d2d8977..5e1908c58 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyItemRepositoryImpl.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/repository/LegacyItemRepositoryImpl.kt @@ -1,21 +1,23 @@ -package de.davis.keygo.migration.legacy_data.data.repository +package de.davis.keygo.legacy_migration.data.repository import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.asResult import de.davis.keygo.core.util.resultBinding -import de.davis.keygo.migration.legacy_data.data.json.LegacyDetailParser -import de.davis.keygo.migration.legacy_data.data.local.dao.LegacyElementDao -import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabaseProvider -import de.davis.keygo.migration.legacy_data.data.local.pojo.LegacyElementWithTags -import de.davis.keygo.migration.legacy_data.data.mapper.toLegacyItem -import de.davis.keygo.migration.legacy_data.domain.crypto.LegacyCipher -import de.davis.keygo.migration.legacy_data.domain.model.LegacyFailureReason -import de.davis.keygo.migration.legacy_data.domain.model.LegacyItem -import de.davis.keygo.migration.legacy_data.domain.model.LegacyReadFailure -import de.davis.keygo.migration.legacy_data.domain.model.LegacyRowFailure -import de.davis.keygo.migration.legacy_data.domain.repository.LegacyItemRepository -import de.davis.keygo.migration.legacy_data.domain.repository.LegacyKeyRepository -import de.davis.keygo.migration.legacy_data.domain.repository.LegacyReadResult +import de.davis.keygo.legacy_migration.data.json.LegacyDetailParser +import de.davis.keygo.legacy_migration.data.local.dao.LegacyElementDao +import de.davis.keygo.legacy_migration.data.local.datasource.LegacyDatabaseProvider +import de.davis.keygo.legacy_migration.data.local.pojo.LegacyElementWithTags +import de.davis.keygo.legacy_migration.data.mapper.toLegacyItem +import de.davis.keygo.legacy_migration.domain.crypto.LegacyCipher +import de.davis.keygo.legacy_migration.domain.model.LegacyFailureReason +import de.davis.keygo.legacy_migration.domain.model.LegacyItem +import de.davis.keygo.legacy_migration.domain.model.LegacyReadFailure +import de.davis.keygo.legacy_migration.domain.model.LegacyRowFailure +import de.davis.keygo.legacy_migration.domain.repository.LegacyItemRepository +import de.davis.keygo.legacy_migration.domain.repository.LegacyKeyRepository +import de.davis.keygo.legacy_migration.domain.repository.LegacyReadResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.koin.core.annotation.Single import kotlin.coroutines.cancellation.CancellationException @@ -76,7 +78,9 @@ internal class LegacyItemRepositoryImpl( override suspend fun remainingCount(): Result = withDao { it.count() } - override fun deleteDatabase(): Boolean = databaseProvider.delete() + override suspend fun deleteDatabase(): Boolean = withContext(Dispatchers.IO) { + databaseProvider.delete() + } /** * Runs [block] against the legacy DAO, turning the ways the file can refuse to be read into a @@ -89,11 +93,11 @@ internal class LegacyItemRepositoryImpl( */ private suspend fun withDao( block: suspend (LegacyElementDao) -> T, - ): Result { + ): Result = withContext(Dispatchers.IO) { val dao = databaseProvider.get()?.legacyElementDao() - ?: return Result.Failure(LegacyReadFailure.DatabaseEmpty) + ?: return@withContext Result.Failure(LegacyReadFailure.DatabaseEmpty) - return try { + try { Result.Success(block(dao)) } catch (e: CancellationException) { // Not swallowed into a failure the way the other repositories do it. There a swallowed diff --git a/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/repository/LegacyKeyRepositoryImpl.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/repository/LegacyKeyRepositoryImpl.kt new file mode 100644 index 000000000..ccf9050c8 --- /dev/null +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/repository/LegacyKeyRepositoryImpl.kt @@ -0,0 +1,40 @@ +package de.davis.keygo.legacy_migration.data.repository + +import de.davis.keygo.legacy_migration.domain.repository.LegacyKeyRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single +import java.security.KeyStore +import javax.crypto.SecretKey + +/** + * v1's Keystore alias. The identity of a shipped v1 install's item key. It must never change. + */ +internal const val LEGACY_KEY_ALIAS = "password_manager_skey" + +@Single +internal class LegacyKeyRepositoryImpl : LegacyKeyRepository { + + override suspend fun secretKey(): SecretKey? = + withAlias { getKey(LEGACY_KEY_ALIAS, null) as? SecretKey } + + override suspend 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 suspend fun withAlias(block: KeyStore.() -> T): T? = withContext(Dispatchers.IO) { + 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" + } +} diff --git a/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/repository/MainPasswordRepositoryImpl.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/repository/MainPasswordRepositoryImpl.kt similarity index 57% rename from migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/repository/MainPasswordRepositoryImpl.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/repository/MainPasswordRepositoryImpl.kt index 613725556..e83bf764f 100644 --- a/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/repository/MainPasswordRepositoryImpl.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/data/repository/MainPasswordRepositoryImpl.kt @@ -1,13 +1,13 @@ -package de.davis.keygo.migration.create_access.data.repository +package de.davis.keygo.legacy_migration.data.repository import androidx.datastore.core.DataStore import com.google.protobuf.timestamp -import de.davis.keygo.migration.create_access.data.local.model.ProtoMainPassword -import de.davis.keygo.migration.create_access.data.local.model.copy -import de.davis.keygo.migration.create_access.data.mapper.toDomain -import de.davis.keygo.migration.create_access.di.annotation.MainPasswordQualifier -import de.davis.keygo.migration.create_access.domain.model.MainPassword -import de.davis.keygo.migration.create_access.domain.repository.MainPasswordRepository +import de.davis.keygo.legacy_migration.data.local.model.ProtoMainPassword +import de.davis.keygo.legacy_migration.data.local.model.copy +import de.davis.keygo.legacy_migration.data.mapper.toDomain +import de.davis.keygo.legacy_migration.di.annotation.MainPasswordQualifier +import de.davis.keygo.legacy_migration.domain.model.MainPassword +import de.davis.keygo.legacy_migration.domain.repository.MainPasswordRepository import kotlinx.coroutines.flow.first import org.koin.core.annotation.Single diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/di/LegacyDatabaseModule.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/di/LegacyDatabaseModule.kt similarity index 56% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/di/LegacyDatabaseModule.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/di/LegacyDatabaseModule.kt index c13f594aa..c4e9a35e5 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/di/LegacyDatabaseModule.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/di/LegacyDatabaseModule.kt @@ -1,8 +1,8 @@ -package de.davis.keygo.migration.legacy_data.di +package de.davis.keygo.legacy_migration.di import android.content.Context -import de.davis.keygo.migration.legacy_data.data.local.datasource.AndroidLegacyDatabaseProvider -import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabaseProvider +import de.davis.keygo.legacy_migration.data.local.datasource.AndroidLegacyDatabaseProvider +import de.davis.keygo.legacy_migration.data.local.datasource.LegacyDatabaseProvider import org.koin.core.annotation.Module import org.koin.core.annotation.Single diff --git a/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/di/LegacyMigrationModule.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/di/LegacyMigrationModule.kt new file mode 100644 index 000000000..9a9aaaaf7 --- /dev/null +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/di/LegacyMigrationModule.kt @@ -0,0 +1,35 @@ +package de.davis.keygo.legacy_migration.di + +import de.davis.keygo.legacy_migration.di.annotation.MigrationScopeQualifier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single + +@Module( + includes = [ + LegacyDatabaseModule::class, + MainPasswordDataStoreModule::class, + ], +) +@Configuration +@ComponentScan("de.davis.keygo.legacy_migration") +object LegacyMigrationModule { + + /** + * The scope the v1 import runs in. Application-lived, so no screen's lifetime can cut a run + * short, and a SupervisorJob so a run that fails takes nothing else with it. + * + * Deliberately names no dispatcher. Every blocking call under the import switches for itself - + * `LegacyKeyRepository` for the Keystore, `LegacyItemRepositoryImpl.withDao` for the file, the + * row loop for the decrypt and parse, `CryptographicScopeImpl` for the re-encryption - so this + * scope has nothing left to correct. Pinning one here would only hide the next call that + * forgets to, and there would be no single place left that answers what thread a given + * operation runs on. + */ + @Single + @MigrationScopeQualifier + fun provideMigrationScope(): CoroutineScope = CoroutineScope(SupervisorJob()) +} diff --git a/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/di/MainPasswordDataStoreModule.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/di/MainPasswordDataStoreModule.kt new file mode 100644 index 000000000..f892201be --- /dev/null +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/di/MainPasswordDataStoreModule.kt @@ -0,0 +1,27 @@ +package de.davis.keygo.legacy_migration.di + +import android.content.Context +import androidx.datastore.dataStore +import de.davis.keygo.legacy_migration.data.local.datasource.datastore.MainPasswordSerializer +import de.davis.keygo.legacy_migration.di.annotation.MainPasswordQualifier +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single + +/** + * The file name is the on-disk identity of a shipped v1 install's main password record. It must + * never change, whatever the module or package around it is called. + */ +internal const val MAIN_PASSWORD_DATA_STORE_NAME = "main-password.db" + +@Module +internal object MainPasswordDataStoreModule { + + private val Context.protoMainPasswordDataStore by dataStore( + fileName = MAIN_PASSWORD_DATA_STORE_NAME, + serializer = MainPasswordSerializer, + ) + + @Single + @MainPasswordQualifier + fun provideProtoMainPasswordDataStore(context: Context) = context.protoMainPasswordDataStore +} diff --git a/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/di/annotation/MainPasswordQualifier.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/di/annotation/MainPasswordQualifier.kt similarity index 60% rename from migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/di/annotation/MainPasswordQualifier.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/di/annotation/MainPasswordQualifier.kt index 601dec055..11c1b7617 100644 --- a/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/di/annotation/MainPasswordQualifier.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/di/annotation/MainPasswordQualifier.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.create_access.di.annotation +package de.davis.keygo.legacy_migration.di.annotation import org.koin.core.annotation.Named diff --git a/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/di/annotation/MigrationScopeQualifier.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/di/annotation/MigrationScopeQualifier.kt new file mode 100644 index 000000000..74e32b781 --- /dev/null +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/di/annotation/MigrationScopeQualifier.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.legacy_migration.di.annotation + +import org.koin.core.annotation.Named + +@Named +internal annotation class MigrationScopeQualifier diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/crypto/LegacyCipher.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/crypto/LegacyCipher.kt similarity index 78% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/crypto/LegacyCipher.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/crypto/LegacyCipher.kt index bd25cb2eb..1aab3e5cb 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/crypto/LegacyCipher.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/crypto/LegacyCipher.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.domain.crypto +package de.davis.keygo.legacy_migration.domain.crypto import javax.crypto.SecretKey @@ -11,5 +11,5 @@ internal fun interface LegacyCipher { * 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? + suspend fun decrypt(blob: ByteArray, key: SecretKey): ByteArray? } diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/mapper/LegacyItemConverter.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/mapper/LegacyItemConverter.kt similarity index 94% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/mapper/LegacyItemConverter.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/mapper/LegacyItemConverter.kt index 2dc65cea9..a9136e428 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/mapper/LegacyItemConverter.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/mapper/LegacyItemConverter.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.domain.mapper +package de.davis.keygo.legacy_migration.domain.mapper import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.alias.VaultId @@ -16,10 +16,10 @@ import de.davis.keygo.core.item.domain.model.toYearMonthOrNull import de.davis.keygo.core.security.domain.crypto.CryptographicScope import de.davis.keygo.core.security.domain.crypto.encrypt import de.davis.keygo.core.util.domain.resolver.RegistrableDomainResolver -import de.davis.keygo.migration.legacy_data.domain.crypto.LegacyCipher -import de.davis.keygo.migration.legacy_data.domain.model.LegacyDetail -import de.davis.keygo.migration.legacy_data.domain.model.LegacyItem -import de.davis.keygo.migration.legacy_data.domain.model.LegacyStrength +import de.davis.keygo.legacy_migration.domain.crypto.LegacyCipher +import de.davis.keygo.legacy_migration.domain.model.LegacyDetail +import de.davis.keygo.legacy_migration.domain.model.LegacyItem +import de.davis.keygo.legacy_migration.domain.model.LegacyStrength import org.koin.core.annotation.Single import javax.crypto.SecretKey import kotlin.time.Clock diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyDetail.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/LegacyDetail.kt similarity index 96% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyDetail.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/LegacyDetail.kt index eced5bfad..b963ce33d 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyDetail.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/LegacyDetail.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.domain.model +package de.davis.keygo.legacy_migration.domain.model /** The decoded contents of a v1 `SecureElement.data` blob, before any v2 types are involved. */ internal sealed interface LegacyDetail { diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyFailureReason.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/LegacyFailureReason.kt similarity index 90% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyFailureReason.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/LegacyFailureReason.kt index 63396eaf5..697c29759 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyFailureReason.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/LegacyFailureReason.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.domain.model +package de.davis.keygo.legacy_migration.domain.model /** Why one v1 row could not be imported. The row stays in the legacy database either way. */ enum class LegacyFailureReason { diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyItem.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/LegacyItem.kt similarity index 88% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyItem.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/LegacyItem.kt index 1301f70e0..f2c32699d 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyItem.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/LegacyItem.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.domain.model +package de.davis.keygo.legacy_migration.domain.model /** A single v1 row, decrypted and parsed, with its user tags already filtered. */ internal data class LegacyItem( diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigrationOutcome.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/LegacyMigrationOutcome.kt similarity index 96% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigrationOutcome.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/LegacyMigrationOutcome.kt index 5b96a191f..ba399f638 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigrationOutcome.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/LegacyMigrationOutcome.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.domain.model +package de.davis.keygo.legacy_migration.domain.model sealed interface LegacyMigrationOutcome { diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigrationReport.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/LegacyMigrationReport.kt similarity index 92% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigrationReport.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/LegacyMigrationReport.kt index 88a0c7ff2..2f213a220 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyMigrationReport.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/LegacyMigrationReport.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.domain.model +package de.davis.keygo.legacy_migration.domain.model data class LegacyRowFailure( val legacyId: Long, diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyReadFailure.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/LegacyReadFailure.kt similarity index 96% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyReadFailure.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/LegacyReadFailure.kt index c41b68dc7..94153faac 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyReadFailure.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/LegacyReadFailure.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.domain.model +package de.davis.keygo.legacy_migration.domain.model /** * Why a whole read could not run. Nothing is imported, and the legacy file is left exactly as it diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyStrength.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/LegacyStrength.kt similarity index 81% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyStrength.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/LegacyStrength.kt index 377dca34a..54ac8e010 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/model/LegacyStrength.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/LegacyStrength.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.domain.model +package de.davis.keygo.legacy_migration.domain.model /** * v1's `Strength`, in v1's declaration order. The ordinal is load-bearing: the older JSON form diff --git a/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/model/MainPassword.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/MainPassword.kt similarity index 62% rename from migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/model/MainPassword.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/MainPassword.kt index 5f785bdf3..431497df5 100644 --- a/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/model/MainPassword.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/MainPassword.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.create_access.domain.model +package de.davis.keygo.legacy_migration.domain.model import java.time.Instant diff --git a/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/MigrationResult.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/MigrationResult.kt new file mode 100644 index 000000000..0292c2db1 --- /dev/null +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/model/MigrationResult.kt @@ -0,0 +1,30 @@ +package de.davis.keygo.legacy_migration.domain.model + +/** + * How a call to `RunPendingMigrationUseCase` ended, in the terms the auth screen has to act on. + */ +sealed interface MigrationResult { + + /** + * No v1 marker: a clean install, or a migration that already finished. Nothing was opened, and + * this is the answer for the overwhelming majority of unlocks. + */ + data object NotPending : MigrationResult + + /** + * The import reached a verdict about the v1 file. [skippedItems] counts rows that could not be + * read; those rows are still in the legacy file, which `MigrateLegacyDataUseCase` retains + * whenever anything failed. + * + * Says nothing about the marker. It is gone only if the run left nothing behind, and a run that + * did leave something is still a run that reached a verdict: the caller has the same nothing to + * act on either way, and the retry belongs to the next unlock rather than to this screen. + */ + data class Completed(val skippedItems: Int) : MigrationResult + + /** + * The import could not reach a verdict. The marker is kept, the legacy file is untouched, and + * the next attempt retries. + */ + data class Incomplete(val cause: Throwable) : MigrationResult +} diff --git a/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/repository/HashValidator.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/repository/HashValidator.kt similarity index 67% rename from migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/repository/HashValidator.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/repository/HashValidator.kt index c37cd9297..e22127b82 100644 --- a/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/repository/HashValidator.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/repository/HashValidator.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.create_access.domain.repository +package de.davis.keygo.legacy_migration.domain.repository internal interface HashValidator { diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/repository/LegacyItemRepository.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/repository/LegacyItemRepository.kt similarity index 80% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/repository/LegacyItemRepository.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/repository/LegacyItemRepository.kt index 8ec4573cd..58dac4ffe 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/repository/LegacyItemRepository.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/repository/LegacyItemRepository.kt @@ -1,9 +1,9 @@ -package de.davis.keygo.migration.legacy_data.domain.repository +package de.davis.keygo.legacy_migration.domain.repository import de.davis.keygo.core.util.Result -import de.davis.keygo.migration.legacy_data.domain.model.LegacyItem -import de.davis.keygo.migration.legacy_data.domain.model.LegacyReadFailure -import de.davis.keygo.migration.legacy_data.domain.model.LegacyRowFailure +import de.davis.keygo.legacy_migration.domain.model.LegacyItem +import de.davis.keygo.legacy_migration.domain.model.LegacyReadFailure +import de.davis.keygo.legacy_migration.domain.model.LegacyRowFailure import javax.crypto.SecretKey internal data class LegacyReadResult( @@ -40,6 +40,5 @@ internal interface LegacyItemRepository { suspend fun remainingCount(): Result - /** Closes the database and deletes the file. */ - fun deleteDatabase(): Boolean + suspend fun deleteDatabase(): Boolean } diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/repository/LegacyKeyRepository.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/repository/LegacyKeyRepository.kt similarity index 78% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/repository/LegacyKeyRepository.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/repository/LegacyKeyRepository.kt index 70c8da367..c74782fa2 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/repository/LegacyKeyRepository.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/repository/LegacyKeyRepository.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.domain.repository +package de.davis.keygo.legacy_migration.domain.repository import javax.crypto.SecretKey @@ -6,11 +6,11 @@ import javax.crypto.SecretKey internal interface LegacyKeyRepository { /** Returns null when the alias is gone, which no blob in the file can survive. */ - fun secretKey(): SecretKey? + suspend 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() + suspend fun deleteLegacyKey() } diff --git a/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/repository/MainPasswordRepository.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/repository/MainPasswordRepository.kt new file mode 100644 index 000000000..a038593dd --- /dev/null +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/repository/MainPasswordRepository.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.legacy_migration.domain.repository + +import de.davis.keygo.legacy_migration.domain.model.MainPassword + +internal interface MainPasswordRepository { + + suspend fun getMainPassword(): MainPassword + + suspend fun clearMainPassword() +} diff --git a/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/usecase/ClearMainPasswordUseCase.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/usecase/ClearMainPasswordUseCase.kt new file mode 100644 index 000000000..c4199a86d --- /dev/null +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/usecase/ClearMainPasswordUseCase.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.legacy_migration.domain.usecase + +import de.davis.keygo.legacy_migration.domain.repository.MainPasswordRepository +import org.koin.core.annotation.Single + +@Single +internal class ClearMainPasswordUseCase internal constructor( + private val mainPasswordRepository: MainPasswordRepository +) { + suspend operator fun invoke() = mainPasswordRepository.clearMainPassword() +} diff --git a/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/HasMainPasswordUseCase.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/usecase/HasMainPasswordUseCase.kt similarity index 65% rename from migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/HasMainPasswordUseCase.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/usecase/HasMainPasswordUseCase.kt index 7f2dc54c6..ea08b069b 100644 --- a/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/HasMainPasswordUseCase.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/usecase/HasMainPasswordUseCase.kt @@ -1,6 +1,6 @@ -package de.davis.keygo.migration.create_access.domain.usecase +package de.davis.keygo.legacy_migration.domain.usecase -import de.davis.keygo.migration.create_access.domain.repository.MainPasswordRepository +import de.davis.keygo.legacy_migration.domain.repository.MainPasswordRepository import org.koin.core.annotation.Single @Single diff --git a/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/usecase/LegacyDataImporter.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/usecase/LegacyDataImporter.kt new file mode 100644 index 000000000..0b9564f27 --- /dev/null +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/usecase/LegacyDataImporter.kt @@ -0,0 +1,16 @@ +package de.davis.keygo.legacy_migration.domain.usecase + +import de.davis.keygo.legacy_migration.domain.model.LegacyMigrationOutcome + +/** + * The one thing [RunPendingMigrationUseCase] needs of the v1 item import: run it, and say how it + * ended. + * + * Narrow on purpose. The sequencing this module exists to guarantee is worth testing on its own, + * and standing up Room, a SQLite driver, the Keystore and the whole v2 key hierarchy behind every + * one of those tests would say nothing extra about the sequencing. + */ +internal fun interface LegacyDataImporter { + + suspend operator fun invoke(): LegacyMigrationOutcome +} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/MigrateLegacyDataUseCase.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/usecase/MigrateLegacyDataUseCase.kt similarity index 90% rename from migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/MigrateLegacyDataUseCase.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/usecase/MigrateLegacyDataUseCase.kt index f1a3463be..1e5e9508d 100644 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/MigrateLegacyDataUseCase.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/usecase/MigrateLegacyDataUseCase.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.domain.usecase +package de.davis.keygo.legacy_migration.domain.usecase import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.alias.VaultId @@ -17,16 +17,16 @@ import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.getOrNull import de.davis.keygo.core.util.isSuccess import de.davis.keygo.core.util.onFailure -import de.davis.keygo.migration.legacy_data.domain.mapper.LegacyItemConverter -import de.davis.keygo.migration.legacy_data.domain.model.LegacyFailureReason -import de.davis.keygo.migration.legacy_data.domain.model.LegacyItem -import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationException -import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationOutcome -import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationReport -import de.davis.keygo.migration.legacy_data.domain.model.LegacyReadFailure -import de.davis.keygo.migration.legacy_data.domain.model.LegacyRowFailure -import de.davis.keygo.migration.legacy_data.domain.repository.LegacyItemRepository -import de.davis.keygo.migration.legacy_data.domain.repository.LegacyKeyRepository +import de.davis.keygo.legacy_migration.domain.mapper.LegacyItemConverter +import de.davis.keygo.legacy_migration.domain.model.LegacyFailureReason +import de.davis.keygo.legacy_migration.domain.model.LegacyItem +import de.davis.keygo.legacy_migration.domain.model.LegacyMigrationException +import de.davis.keygo.legacy_migration.domain.model.LegacyMigrationOutcome +import de.davis.keygo.legacy_migration.domain.model.LegacyMigrationReport +import de.davis.keygo.legacy_migration.domain.model.LegacyReadFailure +import de.davis.keygo.legacy_migration.domain.model.LegacyRowFailure +import de.davis.keygo.legacy_migration.domain.repository.LegacyItemRepository +import de.davis.keygo.legacy_migration.domain.repository.LegacyKeyRepository import de.davisalessandro.keygo.rust.ItemAad import kotlinx.coroutines.flow.first import org.koin.core.annotation.Single @@ -46,8 +46,8 @@ import kotlin.coroutines.cancellation.CancellationException * 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( +@Single(binds = [LegacyDataImporter::class]) +internal class MigrateLegacyDataUseCase internal constructor( private val legacyItemRepository: LegacyItemRepository, private val legacyKeyRepository: LegacyKeyRepository, private val converter: LegacyItemConverter, @@ -56,9 +56,9 @@ class MigrateLegacyDataUseCase internal constructor( private val vaultContextRepository: VaultContextRepository, private val upsertVaultItem: UpsertVaultItemUseCase, private val transactionRunner: TransactionRunner, -) { +) : LegacyDataImporter { - suspend operator fun invoke(): LegacyMigrationOutcome = try { + override suspend operator fun invoke(): LegacyMigrationOutcome = try { migrate() } catch (e: CancellationException) { // See LegacyItemRepositoryImpl.withDao. @@ -181,7 +181,7 @@ class MigrateLegacyDataUseCase internal constructor( * 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 { + private suspend fun deleteDatabaseAndKey(): Boolean { if (!legacyItemRepository.deleteDatabase()) return false legacyKeyRepository.deleteLegacyKey() return true diff --git a/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/usecase/RunPendingMigrationUseCase.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/usecase/RunPendingMigrationUseCase.kt new file mode 100644 index 000000000..194d52134 --- /dev/null +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/usecase/RunPendingMigrationUseCase.kt @@ -0,0 +1,99 @@ +package de.davis.keygo.legacy_migration.domain.usecase + +import android.util.Log +import de.davis.keygo.legacy_migration.di.annotation.MigrationScopeQualifier +import de.davis.keygo.legacy_migration.domain.model.LegacyMigrationOutcome +import de.davis.keygo.legacy_migration.domain.model.MigrationResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.koin.core.annotation.Single +import kotlin.coroutines.cancellation.CancellationException + +/** + * Runs whatever is left of the v1 migration, in order, once a session is live. + * + * The v1 main password record is the marker for the whole migration, not just for the access half. + * It is read first, so an install that never ran v1 never opens the legacy path at all, and it is + * cleared last, and only once the import has said a later run would find nothing left. Between + * those two the user's credential is the only way back to a migration that did not finish, so + * dropping it early would strand them with rows still on disk and nothing to act on. + * + * Called after every path that establishes a session on the auth screen, which is the only door a + * migrating install has: both unlock paths need an account that does not exist yet while the + * migration is pending, so the autofill service and the passkey activities cannot reach this. + */ +@Single +class RunPendingMigrationUseCase internal constructor( + private val hasMainPassword: HasMainPasswordUseCase, + private val importLegacyData: LegacyDataImporter, + private val clearMainPassword: ClearMainPasswordUseCase, + + @param:MigrationScopeQualifier + private val scope: CoroutineScope, +) { + + private val lock = Mutex() + private var inFlight: Deferred? = null + + suspend operator fun invoke(): MigrationResult = currentRun().await() + + private suspend fun currentRun(): Deferred = lock.withLock { + inFlight?.takeIf { it.isActive } + ?: scope.async { runPending() }.also { inFlight = it } + } + + private suspend fun runPending(): MigrationResult { + if (!hasMainPassword()) return MigrationResult.NotPending + + val outcome = try { + importLegacyData() + } catch (e: CancellationException) { + // A run cancelled because the scope went away tells us nothing about the user's file, + // and it must not be able to answer for it. See LegacyItemRepositoryImpl.withDao. + throw e + } catch (e: Throwable) { + // Throwable and not Exception: 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. Uncaught, that would + // surface as a crash at whoever joined the run rather than as a migration that failed. + return incomplete(e) + } + + return when (outcome) { + is LegacyMigrationOutcome.Failed -> incomplete(outcome.cause) + + // Exhaustive rather than an else, so an outcome added later cannot default into the + // branch that drops the user's v1 credential. + LegacyMigrationOutcome.NothingToMigrate -> { + clearMainPassword() + MigrationResult.Completed(skippedItems = 0) + } + + is LegacyMigrationOutcome.Migrated -> { + // A retained file is a file with v1 rows still in it, and the marker is the only + // thing that brings anything back to them: clearing it here would leave + // secure_element_database on disk forever, decryptable by an alias no later run + // can reach to delete either. So it goes only once a later run would find nothing. + // + // The cost is a prune that keeps failing while the rows are already in v2, which + // reimports them on every unlock. That is a duplicate the user can see and undo; + // the alternative is a v1 database they cannot. + if (outcome.nothingLeftToImport) clearMainPassword() + MigrationResult.Completed(skippedItems = outcome.report.failures.size) + } + } + } + + private fun incomplete(cause: Throwable): MigrationResult.Incomplete { + Log.e(TAG, "v1 import did not finish", cause) + return MigrationResult.Incomplete(cause) + } + + private companion object { + const val TAG = "RunPendingMigration" + } +} diff --git a/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/ValidateMainPasswordUseCase.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/usecase/ValidateMainPasswordUseCase.kt similarity index 70% rename from migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/ValidateMainPasswordUseCase.kt rename to legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/usecase/ValidateMainPasswordUseCase.kt index 456bd896b..9e2a76105 100644 --- a/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/ValidateMainPasswordUseCase.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/usecase/ValidateMainPasswordUseCase.kt @@ -1,7 +1,7 @@ -package de.davis.keygo.migration.create_access.domain.usecase +package de.davis.keygo.legacy_migration.domain.usecase -import de.davis.keygo.migration.create_access.domain.repository.HashValidator -import de.davis.keygo.migration.create_access.domain.repository.MainPasswordRepository +import de.davis.keygo.legacy_migration.domain.repository.HashValidator +import de.davis.keygo.legacy_migration.domain.repository.MainPasswordRepository import org.koin.core.annotation.Single @Single diff --git a/migration/create-access/src/main/proto/main_password.proto b/legacy-migration/src/main/proto/main_password.proto similarity index 70% rename from migration/create-access/src/main/proto/main_password.proto rename to legacy-migration/src/main/proto/main_password.proto index ff270cabc..6ae79fef3 100644 --- a/migration/create-access/src/main/proto/main_password.proto +++ b/legacy-migration/src/main/proto/main_password.proto @@ -2,7 +2,7 @@ syntax = "proto3"; import "google/protobuf/timestamp.proto"; -option java_package = "de.davis.keygo.migration.create_access.data.local.model"; +option java_package = "de.davis.keygo.legacy_migration.data.local.model"; option java_multiple_files = true; message ProtoMainPassword { diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/LegacyMigrationEndToEndTest.kt b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/LegacyMigrationEndToEndTest.kt similarity index 93% rename from migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/LegacyMigrationEndToEndTest.kt rename to legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/LegacyMigrationEndToEndTest.kt index efb45f699..733928797 100644 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/LegacyMigrationEndToEndTest.kt +++ b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/LegacyMigrationEndToEndTest.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data +package de.davis.keygo.legacy_migration import androidx.room3.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver @@ -22,22 +22,22 @@ 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.migration.legacy_data.data.FakeLegacyDatabaseProvider -import de.davis.keygo.migration.legacy_data.data.FakeLegacyKeyRepository -import de.davis.keygo.migration.legacy_data.data.FakeRegistrableDomainResolver -import de.davis.keygo.migration.legacy_data.data.crypto.LegacyAesGcmCipher -import de.davis.keygo.migration.legacy_data.data.encryptLikeV1 -import de.davis.keygo.migration.legacy_data.data.json.LegacyDetailParser -import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase -import de.davis.keygo.migration.legacy_data.data.local.entity.LegacySecureElementEntity -import de.davis.keygo.migration.legacy_data.data.local.entity.LegacySecureElementTagCrossRef -import de.davis.keygo.migration.legacy_data.data.local.entity.LegacyTagEntity -import de.davis.keygo.migration.legacy_data.data.local.entity.LegacyTimestamps -import de.davis.keygo.migration.legacy_data.data.repository.LegacyItemRepositoryImpl -import de.davis.keygo.migration.legacy_data.domain.mapper.LegacyItemConverter -import de.davis.keygo.migration.legacy_data.domain.model.LegacyFailureReason -import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationOutcome -import de.davis.keygo.migration.legacy_data.domain.usecase.MigrateLegacyDataUseCase +import de.davis.keygo.legacy_migration.data.FakeLegacyDatabaseProvider +import de.davis.keygo.legacy_migration.data.FakeLegacyKeyRepository +import de.davis.keygo.legacy_migration.data.FakeRegistrableDomainResolver +import de.davis.keygo.legacy_migration.data.crypto.LegacyAesGcmCipher +import de.davis.keygo.legacy_migration.data.encryptLikeV1 +import de.davis.keygo.legacy_migration.data.json.LegacyDetailParser +import de.davis.keygo.legacy_migration.data.local.datasource.LegacyDatabase +import de.davis.keygo.legacy_migration.data.local.entity.LegacySecureElementEntity +import de.davis.keygo.legacy_migration.data.local.entity.LegacySecureElementTagCrossRef +import de.davis.keygo.legacy_migration.data.local.entity.LegacyTagEntity +import de.davis.keygo.legacy_migration.data.local.entity.LegacyTimestamps +import de.davis.keygo.legacy_migration.data.repository.LegacyItemRepositoryImpl +import de.davis.keygo.legacy_migration.domain.mapper.LegacyItemConverter +import de.davis.keygo.legacy_migration.domain.model.LegacyFailureReason +import de.davis.keygo.legacy_migration.domain.model.LegacyMigrationOutcome +import de.davis.keygo.legacy_migration.domain.usecase.MigrateLegacyDataUseCase import de.davisalessandro.keygo.rust.ItemAad import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/LegacyMigrationRealDatabaseTest.kt b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/LegacyMigrationRealDatabaseTest.kt similarity index 92% rename from migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/LegacyMigrationRealDatabaseTest.kt rename to legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/LegacyMigrationRealDatabaseTest.kt index 6eee051c5..059fb7c73 100644 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/LegacyMigrationRealDatabaseTest.kt +++ b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/LegacyMigrationRealDatabaseTest.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data +package de.davis.keygo.legacy_migration import androidx.room3.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver @@ -18,16 +18,16 @@ import de.davis.keygo.core.security.domain.crypto.decrypt import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformation import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation import de.davis.keygo.core.util.assertSuccess -import de.davis.keygo.migration.legacy_data.data.FakeLegacyDatabaseProvider -import de.davis.keygo.migration.legacy_data.data.FakeLegacyKeyRepository -import de.davis.keygo.migration.legacy_data.data.FakeRegistrableDomainResolver -import de.davis.keygo.migration.legacy_data.data.crypto.LegacyAesGcmCipher -import de.davis.keygo.migration.legacy_data.data.json.LegacyDetailParser -import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase -import de.davis.keygo.migration.legacy_data.data.repository.LegacyItemRepositoryImpl -import de.davis.keygo.migration.legacy_data.domain.mapper.LegacyItemConverter -import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationOutcome -import de.davis.keygo.migration.legacy_data.domain.usecase.MigrateLegacyDataUseCase +import de.davis.keygo.legacy_migration.data.FakeLegacyDatabaseProvider +import de.davis.keygo.legacy_migration.data.FakeLegacyKeyRepository +import de.davis.keygo.legacy_migration.data.FakeRegistrableDomainResolver +import de.davis.keygo.legacy_migration.data.crypto.LegacyAesGcmCipher +import de.davis.keygo.legacy_migration.data.json.LegacyDetailParser +import de.davis.keygo.legacy_migration.data.local.datasource.LegacyDatabase +import de.davis.keygo.legacy_migration.data.repository.LegacyItemRepositoryImpl +import de.davis.keygo.legacy_migration.domain.mapper.LegacyItemConverter +import de.davis.keygo.legacy_migration.domain.model.LegacyMigrationOutcome +import de.davis.keygo.legacy_migration.domain.usecase.MigrateLegacyDataUseCase import de.davisalessandro.keygo.rust.ItemAad import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first diff --git a/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/OnDiskIdentityTest.kt b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/OnDiskIdentityTest.kt new file mode 100644 index 000000000..5d7bc0ed8 --- /dev/null +++ b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/OnDiskIdentityTest.kt @@ -0,0 +1,57 @@ +package de.davis.keygo.legacy_migration + +import com.google.protobuf.timestamp +import de.davis.keygo.legacy_migration.data.local.datasource.LEGACY_DATABASE_NAME +import de.davis.keygo.legacy_migration.data.local.model.protoMainPassword +import de.davis.keygo.legacy_migration.data.repository.LEGACY_KEY_ALIAS +import de.davis.keygo.legacy_migration.di.MAIN_PASSWORD_DATA_STORE_NAME +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +/** + * The four names below are the identity of a shipped v1 install's data on disk. Renaming the module + * or the Kotlin package around them is safe. Renaming them is not: it orphans the user's database, + * their main password record or their item key, with no way back. + * + * These assertions look tautological on purpose. They are here so that changing one of the values + * cannot be done quietly. + */ +class OnDiskIdentityTest { + + @Test + fun `legacy room database keeps v1's file name`() { + assertEquals("secure_element_database", LEGACY_DATABASE_NAME) + } + + @Test + fun `main password datastore keeps v1's file name`() { + assertEquals("main-password.db", MAIN_PASSWORD_DATA_STORE_NAME) + } + + @Test + fun `legacy keystore alias keeps v1's value`() { + assertEquals("password_manager_skey", LEGACY_KEY_ALIAS) + } + + /** + * The proto's `java_package` moved with the module. That renames the generated Kotlin class and + * nothing else, so an existing `main-password.db` still parses. This pins the wire format that + * makes the claim true: field 1 is the hash, field 2 is the timestamp. + */ + @Test + fun `main password proto keeps v1's field numbers`() { + val encoded = protoMainPassword { + hash = "ab" + createdAt = timestamp { seconds = 1 } + }.toByteArray() + + assertContentEquals( + byteArrayOf( + 0x0A, 0x02, 0x61, 0x62, // field 1, length-delimited, "ab" + 0x12, 0x02, 0x08, 0x01, // field 2, length-delimited, Timestamp { seconds = 1 } + ), + encoded, + ) + } +} diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyAesGcmCipherTest.kt b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/crypto/LegacyAesGcmCipherTest.kt similarity index 80% rename from migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyAesGcmCipherTest.kt rename to legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/crypto/LegacyAesGcmCipherTest.kt index fcad2cf70..ba6847414 100644 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyAesGcmCipherTest.kt +++ b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/crypto/LegacyAesGcmCipherTest.kt @@ -1,6 +1,7 @@ -package de.davis.keygo.migration.legacy_data.data.crypto +package de.davis.keygo.legacy_migration.data.crypto -import de.davis.keygo.migration.legacy_data.data.encryptLikeV1 +import de.davis.keygo.legacy_migration.data.encryptLikeV1 +import kotlinx.coroutines.test.runTest import java.security.SecureRandom import javax.crypto.Cipher import javax.crypto.KeyGenerator @@ -21,7 +22,7 @@ class LegacyAesGcmCipherTest { private val key: SecretKey = newKey() @Test - fun `decrypts a blob written by v1`() { + fun `decrypts a blob written by v1`() = runTest { val plaintext = "{\"type\":1,\"username\":\"ada\"}".encodeToByteArray() val decrypted = cipher.decrypt(encryptLikeV1(plaintext, key), key) @@ -30,7 +31,7 @@ class LegacyAesGcmCipherTest { } @Test - fun `decrypts an empty plaintext`() { + fun `decrypts an empty plaintext`() = runTest { assertContentEquals( byteArrayOf(), cipher.decrypt(encryptLikeV1(byteArrayOf(), key), key), @@ -38,19 +39,19 @@ class LegacyAesGcmCipherTest { } @Test - fun `returns null for a blob encrypted under a different key`() { + fun `returns null for a blob encrypted under a different key`() = runTest { val otherKey = newKey() assertNull(cipher.decrypt(encryptLikeV1(byteArrayOf(1, 2, 3), otherKey), key)) } @Test - fun `returns null for a blob shorter than the iv`() { + fun `returns null for a blob shorter than the iv`() = runTest { assertNull(cipher.decrypt(byteArrayOf(1, 2, 3), key)) } @Test - fun `returns null for a tampered blob`() { + fun `returns null for a tampered blob`() = runTest { val blob = encryptLikeV1("secret".encodeToByteArray(), key) blob[blob.size - 1] = (blob[blob.size - 1] + 1).toByte() @@ -58,7 +59,7 @@ class LegacyAesGcmCipherTest { } @Test - fun `uses a twelve byte iv prefix`() { + fun `uses a twelve byte iv prefix`() = runTest { val blob = encryptLikeV1("x".encodeToByteArray(), key) val manual = Cipher.getInstance("AES/GCM/NoPadding").apply { init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, blob, 0, 12)) diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/json/LegacyDetailParserTest.kt b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/json/LegacyDetailParserTest.kt similarity index 95% rename from migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/json/LegacyDetailParserTest.kt rename to legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/json/LegacyDetailParserTest.kt index 4cff958fa..0b022981d 100644 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/json/LegacyDetailParserTest.kt +++ b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/json/LegacyDetailParserTest.kt @@ -1,7 +1,7 @@ -package de.davis.keygo.migration.legacy_data.data.json +package de.davis.keygo.legacy_migration.data.json -import de.davis.keygo.migration.legacy_data.domain.model.LegacyDetail -import de.davis.keygo.migration.legacy_data.domain.model.LegacyStrength +import de.davis.keygo.legacy_migration.domain.model.LegacyDetail +import de.davis.keygo.legacy_migration.domain.model.LegacyStrength import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseOpenTest.kt b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/local/LegacyDatabaseOpenTest.kt similarity index 97% rename from migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseOpenTest.kt rename to legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/local/LegacyDatabaseOpenTest.kt index 3ca67f6b5..b8e92dd73 100644 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyDatabaseOpenTest.kt +++ b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/local/LegacyDatabaseOpenTest.kt @@ -1,9 +1,9 @@ -package de.davis.keygo.migration.legacy_data.data.local +package de.davis.keygo.legacy_migration.data.local import androidx.sqlite.SQLiteConnection import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.execSQL -import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase +import de.davis.keygo.legacy_migration.data.local.datasource.LegacyDatabase import kotlinx.coroutines.test.runTest import java.io.File import java.nio.file.Files diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyMigrationTest.kt b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/local/LegacyMigrationTest.kt similarity index 95% rename from migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyMigrationTest.kt rename to legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/local/LegacyMigrationTest.kt index 4418117cd..7c6901b43 100644 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyMigrationTest.kt +++ b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/local/LegacyMigrationTest.kt @@ -1,10 +1,10 @@ -package de.davis.keygo.migration.legacy_data.data.local +package de.davis.keygo.legacy_migration.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 de.davis.keygo.legacy_migration.data.local.datasource.AndroidLegacyDatabaseProvider +import de.davis.keygo.legacy_migration.data.local.datasource.LegacyDatabase import kotlinx.coroutines.test.runTest import java.io.File import java.nio.file.Files @@ -23,7 +23,7 @@ import kotlin.test.assertTrue * 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 + * [de.davis.keygo.legacy_migration.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. */ diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacySchemaIdentityTest.kt b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/local/LegacySchemaIdentityTest.kt similarity index 97% rename from migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacySchemaIdentityTest.kt rename to legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/local/LegacySchemaIdentityTest.kt index 0d6e95290..9b47fa52f 100644 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacySchemaIdentityTest.kt +++ b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/local/LegacySchemaIdentityTest.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.data.local +package de.davis.keygo.legacy_migration.data.local import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacySchemaSeed.kt b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/local/LegacySchemaSeed.kt similarity index 93% rename from migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacySchemaSeed.kt rename to legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/local/LegacySchemaSeed.kt index fb82f8c93..a39975650 100644 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacySchemaSeed.kt +++ b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/local/LegacySchemaSeed.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.data.local +package de.davis.keygo.legacy_migration.data.local import android.app.Instrumentation import android.content.Context @@ -6,7 +6,7 @@ import android.content.res.AssetManager import androidx.room3.testing.MigrationTestHelper import androidx.sqlite.SQLiteConnection import androidx.sqlite.driver.bundled.BundledSQLiteDriver -import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase +import de.davis.keygo.legacy_migration.data.local.datasource.LegacyDatabase import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.runBlocking @@ -55,7 +55,7 @@ private fun schemaInstrumentation(): Instrumentation { * * 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], + * through [de.davis.keygo.legacy_migration.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]. * diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyTestContext.kt b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/local/LegacyTestContext.kt similarity index 89% rename from migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyTestContext.kt rename to legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/local/LegacyTestContext.kt index 26a806904..db4ebc2fb 100644 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/local/LegacyTestContext.kt +++ b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/local/LegacyTestContext.kt @@ -1,10 +1,10 @@ -package de.davis.keygo.migration.legacy_data.data.local +package de.davis.keygo.legacy_migration.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 de.davis.keygo.legacy_migration.data.local.datasource.AndroidLegacyDatabaseProvider +import de.davis.keygo.legacy_migration.data.local.datasource.LegacyDatabase import java.io.File import kotlin.test.assertNotNull diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyItemRepositoryImplTest.kt b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/repository/LegacyItemRepositoryImplTest.kt similarity index 83% rename from migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyItemRepositoryImplTest.kt rename to legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/repository/LegacyItemRepositoryImplTest.kt index b0567eca9..0df9d276d 100644 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyItemRepositoryImplTest.kt +++ b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/data/repository/LegacyItemRepositoryImplTest.kt @@ -1,28 +1,28 @@ -package de.davis.keygo.migration.legacy_data.data.repository +package de.davis.keygo.legacy_migration.data.repository import androidx.room3.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.execSQL import de.davis.keygo.core.util.assertFailure import de.davis.keygo.core.util.assertSuccess -import de.davis.keygo.migration.legacy_data.data.FakeLegacyCipher -import de.davis.keygo.migration.legacy_data.data.FakeLegacyDatabase -import de.davis.keygo.migration.legacy_data.data.FakeLegacyDatabaseProvider -import de.davis.keygo.migration.legacy_data.data.FakeLegacyElementDao -import de.davis.keygo.migration.legacy_data.data.FakeLegacyKeyRepository -import de.davis.keygo.migration.legacy_data.data.json.LegacyDetailParser -import de.davis.keygo.migration.legacy_data.data.local.datasource.AndroidLegacyDatabaseProvider -import de.davis.keygo.migration.legacy_data.data.local.datasource.LEGACY_DATABASE_NAME -import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabase -import de.davis.keygo.migration.legacy_data.data.local.datasource.LegacyDatabaseProvider -import de.davis.keygo.migration.legacy_data.data.local.entity.LegacySecureElementEntity -import de.davis.keygo.migration.legacy_data.data.local.entity.LegacySecureElementTagCrossRef -import de.davis.keygo.migration.legacy_data.data.local.entity.LegacyTagEntity -import de.davis.keygo.migration.legacy_data.data.local.entity.LegacyTimestamps -import de.davis.keygo.migration.legacy_data.data.local.legacyContext -import de.davis.keygo.migration.legacy_data.domain.model.LegacyDetail -import de.davis.keygo.migration.legacy_data.domain.model.LegacyFailureReason -import de.davis.keygo.migration.legacy_data.domain.model.LegacyReadFailure +import de.davis.keygo.legacy_migration.data.FakeLegacyCipher +import de.davis.keygo.legacy_migration.data.FakeLegacyDatabase +import de.davis.keygo.legacy_migration.data.FakeLegacyDatabaseProvider +import de.davis.keygo.legacy_migration.data.FakeLegacyElementDao +import de.davis.keygo.legacy_migration.data.FakeLegacyKeyRepository +import de.davis.keygo.legacy_migration.data.json.LegacyDetailParser +import de.davis.keygo.legacy_migration.data.local.datasource.AndroidLegacyDatabaseProvider +import de.davis.keygo.legacy_migration.data.local.datasource.LEGACY_DATABASE_NAME +import de.davis.keygo.legacy_migration.data.local.datasource.LegacyDatabase +import de.davis.keygo.legacy_migration.data.local.datasource.LegacyDatabaseProvider +import de.davis.keygo.legacy_migration.data.local.entity.LegacySecureElementEntity +import de.davis.keygo.legacy_migration.data.local.entity.LegacySecureElementTagCrossRef +import de.davis.keygo.legacy_migration.data.local.entity.LegacyTagEntity +import de.davis.keygo.legacy_migration.data.local.entity.LegacyTimestamps +import de.davis.keygo.legacy_migration.data.local.legacyContext +import de.davis.keygo.legacy_migration.domain.model.LegacyDetail +import de.davis.keygo.legacy_migration.domain.model.LegacyFailureReason +import de.davis.keygo.legacy_migration.domain.model.LegacyReadFailure import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.runTest import java.io.File @@ -35,6 +35,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertNotEquals import kotlin.test.assertNull import kotlin.test.assertTrue @@ -346,4 +347,39 @@ class LegacyItemRepositoryImplTest { assertTrue(repository.deleteDatabase(), "the file was there and had to go") assertTrue(databaseProvider.closed, "the file cannot be deleted from under an open handle") } + + /** + * Main-safety, pinned rather than argued. + * + * The scope the import runs in names no dispatcher on purpose, so each blocking call has to + * move off the caller's thread itself. Nothing else would catch a `withContext` dropped from + * one of them: the work still happens, the results are still right, and every other test in + * this file still passes - it just happens on whatever thread called in, which for the auth + * screen is the main one. + * + * Covers this repository only. `LegacyKeyRepositoryImpl` switches for the same reason, but a + * JVM test cannot reach the Keystore, and asserting it through the fake would only pin the + * fake's own threading. + */ + @Test + fun `the legacy file is opened and deleted off the calling thread`() = runTest { + insert(title = "Example", json = """{"type":1,"username":"ada"}""") + val caller = Thread.currentThread() + + repository.readAll().assertSuccess() + + assertNotEquals( + caller, + databaseProvider.lastAccessThread, + "the legacy database was opened on the caller's thread", + ) + + repository.deleteDatabase() + + assertNotEquals( + caller, + databaseProvider.lastAccessThread, + "the legacy file was deleted on the caller's thread", + ) + } } diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/mapper/LegacyItemConverterTest.kt b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/domain/mapper/LegacyItemConverterTest.kt similarity index 94% rename from migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/mapper/LegacyItemConverterTest.kt rename to legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/domain/mapper/LegacyItemConverterTest.kt index 98124a9f1..54ea8c916 100644 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/mapper/LegacyItemConverterTest.kt +++ b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/domain/mapper/LegacyItemConverterTest.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.domain.mapper +package de.davis.keygo.legacy_migration.domain.mapper import de.davis.keygo.core.item.FakeItemRepository import de.davis.keygo.core.item.domain.alias.newItemId @@ -12,13 +12,13 @@ import de.davis.keygo.core.security.domain.crypto.decrypt import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformation import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation import de.davis.keygo.core.util.assertSuccess -import de.davis.keygo.migration.legacy_data.data.FAKE_LEGACY_KEY -import de.davis.keygo.migration.legacy_data.data.FakeLegacyCipher -import de.davis.keygo.migration.legacy_data.data.FakeRegistrableDomainResolver -import de.davis.keygo.migration.legacy_data.domain.crypto.LegacyCipher -import de.davis.keygo.migration.legacy_data.domain.model.LegacyDetail -import de.davis.keygo.migration.legacy_data.domain.model.LegacyItem -import de.davis.keygo.migration.legacy_data.domain.model.LegacyStrength +import de.davis.keygo.legacy_migration.data.FAKE_LEGACY_KEY +import de.davis.keygo.legacy_migration.data.FakeLegacyCipher +import de.davis.keygo.legacy_migration.data.FakeRegistrableDomainResolver +import de.davis.keygo.legacy_migration.domain.crypto.LegacyCipher +import de.davis.keygo.legacy_migration.domain.model.LegacyDetail +import de.davis.keygo.legacy_migration.domain.model.LegacyItem +import de.davis.keygo.legacy_migration.domain.model.LegacyStrength import de.davisalessandro.keygo.rust.ItemAad import kotlinx.coroutines.test.runTest import java.time.YearMonth diff --git a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/MigrateLegacyDataUseCaseTest.kt b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/domain/usecase/MigrateLegacyDataUseCaseTest.kt similarity index 93% rename from migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/MigrateLegacyDataUseCaseTest.kt rename to legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/domain/usecase/MigrateLegacyDataUseCaseTest.kt index 043517c3d..6dbc73b27 100644 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/MigrateLegacyDataUseCaseTest.kt +++ b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/domain/usecase/MigrateLegacyDataUseCaseTest.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.domain.usecase +package de.davis.keygo.legacy_migration.domain.usecase import de.davis.keygo.core.item.FakeCreditCardRepository import de.davis.keygo.core.item.FakeItemRepository @@ -15,20 +15,20 @@ import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider import de.davis.keygo.core.security.domain.model.CryptoScopeError import de.davis.keygo.core.util.Result -import de.davis.keygo.migration.legacy_data.data.FAKE_LEGACY_KEY -import de.davis.keygo.migration.legacy_data.data.FakeLegacyCipher -import de.davis.keygo.migration.legacy_data.data.FakeLegacyItemRepository -import de.davis.keygo.migration.legacy_data.data.FakeLegacyKeyRepository -import de.davis.keygo.migration.legacy_data.data.FakeRegistrableDomainResolver -import de.davis.keygo.migration.legacy_data.domain.crypto.LegacyCipher -import de.davis.keygo.migration.legacy_data.domain.mapper.LegacyItemConverter -import de.davis.keygo.migration.legacy_data.domain.model.LegacyDetail -import de.davis.keygo.migration.legacy_data.domain.model.LegacyFailureReason -import de.davis.keygo.migration.legacy_data.domain.model.LegacyItem -import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationOutcome -import de.davis.keygo.migration.legacy_data.domain.model.LegacyReadFailure -import de.davis.keygo.migration.legacy_data.domain.model.LegacyRowFailure -import de.davis.keygo.migration.legacy_data.domain.repository.LegacyReadResult +import de.davis.keygo.legacy_migration.data.FAKE_LEGACY_KEY +import de.davis.keygo.legacy_migration.data.FakeLegacyCipher +import de.davis.keygo.legacy_migration.data.FakeLegacyItemRepository +import de.davis.keygo.legacy_migration.data.FakeLegacyKeyRepository +import de.davis.keygo.legacy_migration.data.FakeRegistrableDomainResolver +import de.davis.keygo.legacy_migration.domain.crypto.LegacyCipher +import de.davis.keygo.legacy_migration.domain.mapper.LegacyItemConverter +import de.davis.keygo.legacy_migration.domain.model.LegacyDetail +import de.davis.keygo.legacy_migration.domain.model.LegacyFailureReason +import de.davis.keygo.legacy_migration.domain.model.LegacyItem +import de.davis.keygo.legacy_migration.domain.model.LegacyMigrationOutcome +import de.davis.keygo.legacy_migration.domain.model.LegacyReadFailure +import de.davis.keygo.legacy_migration.domain.model.LegacyRowFailure +import de.davis.keygo.legacy_migration.domain.repository.LegacyReadResult import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlin.test.Test diff --git a/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/domain/usecase/RunPendingMigrationUseCaseTest.kt b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/domain/usecase/RunPendingMigrationUseCaseTest.kt new file mode 100644 index 000000000..59550176c --- /dev/null +++ b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/domain/usecase/RunPendingMigrationUseCaseTest.kt @@ -0,0 +1,246 @@ +package de.davis.keygo.legacy_migration.domain.usecase + +import de.davis.keygo.legacy_migration.FakeMainPasswordRepository +import de.davis.keygo.legacy_migration.clearMainPasswordUseCase +import de.davis.keygo.legacy_migration.domain.model.LegacyFailureReason +import de.davis.keygo.legacy_migration.domain.model.LegacyMigrationOutcome +import de.davis.keygo.legacy_migration.domain.model.LegacyMigrationReport +import de.davis.keygo.legacy_migration.domain.model.LegacyRowFailure +import de.davis.keygo.legacy_migration.domain.model.MigrationResult +import de.davis.keygo.legacy_migration.hasMainPasswordUseCase +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue + +// Robolectric for android.util.Log alone +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class RunPendingMigrationUseCaseTest { + + private val mainPasswordRepository = FakeMainPasswordRepository(hash = "a-v1-bcrypt-hash") + + private var importsRun = 0 + + private fun TestScope.useCase(importer: LegacyDataImporter) = RunPendingMigrationUseCase( + hasMainPassword = hasMainPasswordUseCase(mainPasswordRepository), + importLegacyData = LegacyDataImporter { + importsRun++ + importer() + }, + clearMainPassword = clearMainPasswordUseCase(mainPasswordRepository), + // One the scheduler can see, so a run still starts outside the caller's coroutine. + scope = backgroundScope, + ) + + private fun migrated( + failures: List = emptyList(), + fileRetained: Boolean = false + ) = + LegacyMigrationOutcome.Migrated( + LegacyMigrationReport( + migratedItems = 3, + failures = failures, + fileRetained = fileRetained, + ), + ) + + private fun rowFailure(id: Long) = LegacyRowFailure( + legacyId = id, + title = "an account", + reason = LegacyFailureReason.Unreadable, + ) + + @Test + fun `clears the marker once the import reports rows migrated`() = runTest { + val result = useCase { migrated() }() + + assertEquals(MigrationResult.Completed(skippedItems = 0), result) + assertEquals("", mainPasswordRepository.hash) + } + + @Test + fun `clears the marker when there was nothing to migrate`() = runTest { + val result = useCase { LegacyMigrationOutcome.NothingToMigrate }() + + assertEquals(MigrationResult.Completed(skippedItems = 0), result) + assertEquals("", mainPasswordRepository.hash) + } + + /** + * The marker follows the file, not the row count. A report that names failures and still says + * the file is gone has nothing left to come back to, so counting the skipped rows for the + * screen and dropping the credential are not in conflict. + */ + @Test + fun `reports the rows that were skipped and still clears the marker`() = runTest { + val result = useCase { migrated(failures = listOf(rowFailure(1), rowFailure(2))) }() + + assertEquals(MigrationResult.Completed(skippedItems = 2), result) + assertEquals("", mainPasswordRepository.hash) + } + + /** + * A file left behind by a failed prune or a failed delete, rather than by a failed row. Nothing + * on screen distinguishes it - the run reached a verdict and the user is let through - but the + * marker has to survive it: it is the only thing that brings a later run back to a + * secure_element_database that is still on disk and still decryptable by an alias only that run + * can delete. Clearing it here made that file permanent. + * + * The trade is a prune that keeps failing over rows already in v2, which reimports them on the + * next unlock. A duplicate is visible and undoable; a retained v1 database is neither. + */ + @Test + fun `keeps the marker when the legacy file could not be deleted`() = runTest { + val result = useCase { migrated(fileRetained = true) }() + + assertEquals(MigrationResult.Completed(skippedItems = 0), result) + assertEquals("a-v1-bcrypt-hash", mainPasswordRepository.hash) + } + + /** + * The realistic shape of a skipped row: `MigrateLegacyDataUseCase` never deletes a file it + * could not empty, so failures and a retained file arrive together. The rows it could not read + * are still there, and the marker is what lets a later run try them again. + */ + @Test + fun `keeps the marker when rows were skipped and the file stayed behind`() = runTest { + val result = useCase { + migrated(failures = listOf(rowFailure(1), rowFailure(2)), fileRetained = true) + }() + + assertEquals(MigrationResult.Completed(skippedItems = 2), result) + assertEquals("a-v1-bcrypt-hash", mainPasswordRepository.hash) + } + + /** + * The invariant the whole module exists to hold. A run that could not reach a verdict about the + * v1 file must leave the credential that gets the user back to it. + */ + @Test + fun `keeps the marker when the import failed`() = runTest { + val cause = IllegalStateException("could not read the legacy database") + + val result = useCase { LegacyMigrationOutcome.Failed(cause) }() + + assertIs(result) + assertEquals(cause, result.cause) + assertEquals("a-v1-bcrypt-hash", mainPasswordRepository.hash) + } + + /** + * The short circuit that keeps a clean install from opening the legacy path at all. Before this + * existed, every unlock on every install swept it once per process. + */ + @Test + fun `never touches the import when there is no marker`() = runTest { + mainPasswordRepository.hash = "" + + val result = useCase { migrated() }() + + assertEquals(MigrationResult.NotPending, result) + assertEquals(0, importsRun) + } + + /** + * MigrateLegacyDataUseCase catches Exception, which leaves everything that is not one uncaught. + * A module reaching Room, a native SQLite driver and the Keystore can raise a LinkageError on a + * device missing something it expected, and uncaught it would surface as a crash at whoever + * joined the run rather than as a migration that failed. + */ + @Test + fun `contains a throwable that is not an exception`() = runTest { + val result = useCase { throw NoClassDefFoundError("libsqlite") }() + + assertIs(result) + assertTrue(result.cause is NoClassDefFoundError) + assertEquals("a-v1-bcrypt-hash", mainPasswordRepository.hash) + } + + @Test + fun `lets cancellation through rather than reporting it as a verdict`() = runTest { + assertFailsWith { + useCase { throw CancellationException("unlock scope went away") }() + } + + assertEquals("a-v1-bcrypt-hash", mainPasswordRepository.hash) + } + + /** + * The reason the run does not live in the caller's scope. MigrateLegacyDataUseCase commits the + * batch write before it prunes the rows it just wrote, and cancellation is rethrown all the way + * up on purpose, so a run cut short between the two leaves the marker set over rows already in + * v2. The next unlock imports them again under fresh ids: two copies of every v1 item, from a + * back press. + */ + @Test + fun `cancelling a joiner leaves the run to finish`() = runTest { + val started = CompletableDeferred() + val release = CompletableDeferred() + var reachedTheEnd = false + + val subject = useCase { + started.complete(Unit) + release.await() + reachedTheEnd = true + LegacyMigrationOutcome.NothingToMigrate + } + + val joiner = launch { subject() } + started.await() + joiner.cancelAndJoin() + + release.complete(Unit) + runCurrent() + + assertTrue(reachedTheEnd) + // The verdict still landed: the marker is gone and nothing is left for a retry to duplicate. + assertEquals("", mainPasswordRepository.hash) + } + + /** + * Two concurrent imports would read the same v1 rows and write them both. This is also how a + * ViewModel destroyed mid-import and rebuilt still reports the summary for a run it did not + * start: it joins the one already going rather than opening a second. + */ + @Test + fun `callers arriving together share one run and one verdict`() = runTest { + val release = CompletableDeferred() + val subject = useCase { + release.await() + migrated(failures = listOf(rowFailure(1)), fileRetained = true) + } + + val first = async { subject() } + val second = async { subject() } + runCurrent() + release.complete(Unit) + + assertEquals(MigrationResult.Completed(skippedItems = 1), first.await()) + assertEquals(MigrationResult.Completed(skippedItems = 1), second.await()) + assertEquals(1, importsRun) + } + + /** A run that has ended is not a run in flight, or Retry would hand back the failure again. */ + @Test + fun `a caller after a finished run starts a new one`() = runTest { + val subject = useCase { LegacyMigrationOutcome.Failed(IllegalStateException("unreadable")) } + + subject() + subject() + + assertEquals(2, importsRun) + } +} diff --git a/migration/legacy-data/src/test/resources/legacy-fixtures/secure_element_database b/legacy-migration/src/test/resources/legacy-fixtures/secure_element_database similarity index 100% rename from migration/legacy-data/src/test/resources/legacy-fixtures/secure_element_database rename to legacy-migration/src/test/resources/legacy-fixtures/secure_element_database diff --git a/migration/legacy-data/src/test/resources/legacy-fixtures/secure_element_database-shm b/legacy-migration/src/test/resources/legacy-fixtures/secure_element_database-shm similarity index 100% rename from migration/legacy-data/src/test/resources/legacy-fixtures/secure_element_database-shm rename to legacy-migration/src/test/resources/legacy-fixtures/secure_element_database-shm diff --git a/migration/legacy-data/src/test/resources/legacy-fixtures/secure_element_database-wal b/legacy-migration/src/test/resources/legacy-fixtures/secure_element_database-wal similarity index 100% rename from migration/legacy-data/src/test/resources/legacy-fixtures/secure_element_database-wal rename to legacy-migration/src/test/resources/legacy-fixtures/secure_element_database-wal diff --git a/migration/create-access/src/testFixtures/kotlin/de/davis/keygo/migration/create_access/FakeMainPasswordRepository.kt b/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/FakeMainPasswordRepository.kt similarity index 72% rename from migration/create-access/src/testFixtures/kotlin/de/davis/keygo/migration/create_access/FakeMainPasswordRepository.kt rename to legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/FakeMainPasswordRepository.kt index 56d472e1d..237c1d656 100644 --- a/migration/create-access/src/testFixtures/kotlin/de/davis/keygo/migration/create_access/FakeMainPasswordRepository.kt +++ b/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/FakeMainPasswordRepository.kt @@ -1,7 +1,7 @@ -package de.davis.keygo.migration.create_access +package de.davis.keygo.legacy_migration -import de.davis.keygo.migration.create_access.domain.model.MainPassword -import de.davis.keygo.migration.create_access.domain.repository.MainPasswordRepository +import de.davis.keygo.legacy_migration.domain.model.MainPassword +import de.davis.keygo.legacy_migration.domain.repository.MainPasswordRepository import java.time.Instant /** diff --git a/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/MigrationTestUseCases.kt b/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/MigrationTestUseCases.kt new file mode 100644 index 000000000..727b76fd2 --- /dev/null +++ b/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/MigrationTestUseCases.kt @@ -0,0 +1,40 @@ +package de.davis.keygo.legacy_migration + +import de.davis.keygo.legacy_migration.data.repository.HashValidatorImpl +import de.davis.keygo.legacy_migration.domain.model.LegacyMigrationOutcome +import de.davis.keygo.legacy_migration.domain.usecase.ClearMainPasswordUseCase +import de.davis.keygo.legacy_migration.domain.usecase.HasMainPasswordUseCase +import de.davis.keygo.legacy_migration.domain.usecase.LegacyDataImporter +import de.davis.keygo.legacy_migration.domain.usecase.RunPendingMigrationUseCase +import de.davis.keygo.legacy_migration.domain.usecase.ValidateMainPasswordUseCase +import kotlinx.coroutines.CoroutineScope + +internal fun clearMainPasswordUseCase(repository: FakeMainPasswordRepository): ClearMainPasswordUseCase = + ClearMainPasswordUseCase(repository.asMainPasswordRepository()) + +fun hasMainPasswordUseCase(repository: FakeMainPasswordRepository): HasMainPasswordUseCase = + HasMainPasswordUseCase(repository.asMainPasswordRepository()) + +fun validateMainPasswordUseCase(repository: FakeMainPasswordRepository): ValidateMainPasswordUseCase = + ValidateMainPasswordUseCase(HashValidatorImpl(), repository.asMainPasswordRepository()) + +/** + * Builds the real sequencing over a fake marker and a canned import outcome, so a consumer outside + * this module can drive the auth screen through a finished, a partial and a failed migration + * without depending on anything internal to it. [onImport] fires once per run, for callers that + * need to see that a retry actually retried. + */ +fun runPendingMigrationUseCase( + scope: CoroutineScope, + repository: FakeMainPasswordRepository, + outcome: LegacyMigrationOutcome = LegacyMigrationOutcome.NothingToMigrate, + onImport: () -> Unit = {}, +): RunPendingMigrationUseCase = RunPendingMigrationUseCase( + hasMainPassword = hasMainPasswordUseCase(repository), + importLegacyData = LegacyDataImporter { + onImport() + outcome + }, + clearMainPassword = clearMainPasswordUseCase(repository), + scope = scope, +) diff --git a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyCipher.kt b/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeLegacyCipher.kt similarity index 78% rename from migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyCipher.kt rename to legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeLegacyCipher.kt index 79c8c6338..54ec4a0aa 100644 --- a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyCipher.kt +++ b/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeLegacyCipher.kt @@ -1,7 +1,7 @@ -package de.davis.keygo.migration.legacy_data.data +package de.davis.keygo.legacy_migration.data -import de.davis.keygo.migration.legacy_data.data.FakeLegacyCipher.Companion.FAIL -import de.davis.keygo.migration.legacy_data.domain.crypto.LegacyCipher +import de.davis.keygo.legacy_migration.data.FakeLegacyCipher.Companion.FAIL +import de.davis.keygo.legacy_migration.domain.crypto.LegacyCipher import javax.crypto.SecretKey /** @@ -15,7 +15,7 @@ import javax.crypto.SecretKey */ internal class FakeLegacyCipher(private val failFor: ByteArray? = null) : LegacyCipher { - override fun decrypt(blob: ByteArray, key: SecretKey): ByteArray? = when { + override suspend fun decrypt(blob: ByteArray, key: SecretKey): ByteArray? = when { failFor != null && blob.contentEquals(failFor) -> null blob.decodeToString().startsWith(FAIL) -> null else -> blob diff --git a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyDatabase.kt b/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeLegacyDatabase.kt similarity index 79% rename from migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyDatabase.kt rename to legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeLegacyDatabase.kt index 5eb7611c9..23c822845 100644 --- a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyDatabase.kt +++ b/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeLegacyDatabase.kt @@ -1,8 +1,8 @@ -package de.davis.keygo.migration.legacy_data.data +package de.davis.keygo.legacy_migration.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 +import de.davis.keygo.legacy_migration.data.local.dao.LegacyElementDao +import de.davis.keygo.legacy_migration.data.local.datasource.LegacyDatabase /** * A plain subclass rather than Room's builder, for tests that only ever reach [legacyElementDao]. diff --git a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyDatabaseProvider.kt b/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeLegacyDatabaseProvider.kt similarity index 59% rename from migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyDatabaseProvider.kt rename to legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeLegacyDatabaseProvider.kt index aae83b26e..5e078b563 100644 --- a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyDatabaseProvider.kt +++ b/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeLegacyDatabaseProvider.kt @@ -1,7 +1,7 @@ -package de.davis.keygo.migration.legacy_data.data +package de.davis.keygo.legacy_migration.data -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.legacy_migration.data.local.datasource.LegacyDatabase +import de.davis.keygo.legacy_migration.data.local.datasource.LegacyDatabaseProvider import java.io.File /** @@ -16,9 +16,20 @@ internal class FakeLegacyDatabaseProvider( var closed: Boolean = false private set + /** + * The thread the last [get] or [delete] ran on. Production's provider opens and unlinks the + * file on whichever thread calls it, so this is what a test asserts against to pin that the + * repository moved off the caller's. + */ + var lastAccessThread: Thread? = null + private set + private var fileDeleted: Boolean = false - override fun get(): LegacyDatabase? = database.takeUnless { fileDeleted } + override fun get(): LegacyDatabase? { + lastAccessThread = Thread.currentThread() + return database.takeUnless { fileDeleted } + } override fun close() { closed = true @@ -31,6 +42,7 @@ internal class FakeLegacyDatabaseProvider( * alias is gated on this answer. */ override fun delete(): Boolean { + lastAccessThread = Thread.currentThread() close() fileDeleted = true return SUFFIXES.count { File(file.absolutePath + it).delete() } > 0 diff --git a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyElementDao.kt b/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeLegacyElementDao.kt similarity index 77% rename from migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyElementDao.kt rename to legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeLegacyElementDao.kt index 9a96c7e6c..6f5df1a6d 100644 --- a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyElementDao.kt +++ b/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeLegacyElementDao.kt @@ -1,10 +1,10 @@ -package de.davis.keygo.migration.legacy_data.data +package de.davis.keygo.legacy_migration.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 +import de.davis.keygo.legacy_migration.data.local.dao.LegacyElementDao +import de.davis.keygo.legacy_migration.data.local.entity.LegacySecureElementEntity +import de.davis.keygo.legacy_migration.data.local.entity.LegacySecureElementTagCrossRef +import de.davis.keygo.legacy_migration.data.local.entity.LegacyTagEntity +import de.davis.keygo.legacy_migration.data.local.pojo.LegacyElementWithTags /** * In-memory [LegacyElementDao] over a set of row ids, for the tests that are about counting and diff --git a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyItemRepository.kt b/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeLegacyItemRepository.kt similarity index 85% rename from migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyItemRepository.kt rename to legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeLegacyItemRepository.kt index ff489573c..3f7665590 100644 --- a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyItemRepository.kt +++ b/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeLegacyItemRepository.kt @@ -1,9 +1,9 @@ -package de.davis.keygo.migration.legacy_data.data +package de.davis.keygo.legacy_migration.data import de.davis.keygo.core.util.Result -import de.davis.keygo.migration.legacy_data.domain.model.LegacyReadFailure -import de.davis.keygo.migration.legacy_data.domain.repository.LegacyItemRepository -import de.davis.keygo.migration.legacy_data.domain.repository.LegacyReadResult +import de.davis.keygo.legacy_migration.domain.model.LegacyReadFailure +import de.davis.keygo.legacy_migration.domain.repository.LegacyItemRepository +import de.davis.keygo.legacy_migration.domain.repository.LegacyReadResult /** * In-memory [LegacyItemRepository] that keeps the real contract: every read, prune and count can @@ -41,7 +41,7 @@ internal class FakeLegacyItemRepository : LegacyItemRepository { override suspend fun remainingCount(): Result = countResult ?: Result.Success(rowsInFile()) - override fun deleteDatabase(): Boolean { + override suspend fun deleteDatabase(): Boolean { databaseDeleted = deleteSucceeds return deleteSucceeds } diff --git a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyKeyRepository.kt b/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeLegacyKeyRepository.kt similarity index 74% rename from migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyKeyRepository.kt rename to legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeLegacyKeyRepository.kt index 4670bbe05..7b944a263 100644 --- a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeLegacyKeyRepository.kt +++ b/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeLegacyKeyRepository.kt @@ -1,6 +1,6 @@ -package de.davis.keygo.migration.legacy_data.data +package de.davis.keygo.legacy_migration.data -import de.davis.keygo.migration.legacy_data.domain.repository.LegacyKeyRepository +import de.davis.keygo.legacy_migration.domain.repository.LegacyKeyRepository import javax.crypto.SecretKey import javax.crypto.spec.SecretKeySpec @@ -20,12 +20,12 @@ internal class FakeLegacyKeyRepository( var deleted: Boolean = false private set - override fun secretKey(): SecretKey? { + override suspend fun secretKey(): SecretKey? { probes++ return key } - override fun deleteLegacyKey() { + override suspend fun deleteLegacyKey() { deleted = true } } diff --git a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeRegistrableDomainResolver.kt b/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeRegistrableDomainResolver.kt similarity index 93% rename from migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeRegistrableDomainResolver.kt rename to legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeRegistrableDomainResolver.kt index 673f9f219..7bf837b87 100644 --- a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/FakeRegistrableDomainResolver.kt +++ b/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeRegistrableDomainResolver.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.data +package de.davis.keygo.legacy_migration.data import de.davis.keygo.core.util.domain.resolver.RegistrableDomainResolver diff --git a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/LegacyV1Encryption.kt b/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/LegacyV1Encryption.kt similarity index 93% rename from migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/LegacyV1Encryption.kt rename to legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/LegacyV1Encryption.kt index 40da9f6ca..aa3590554 100644 --- a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/LegacyV1Encryption.kt +++ b/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/LegacyV1Encryption.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.migration.legacy_data.data +package de.davis.keygo.legacy_migration.data import javax.crypto.Cipher import javax.crypto.SecretKey diff --git a/migration/create-access/.gitignore b/migration/create-access/.gitignore deleted file mode 100644 index 42afabfd2..000000000 --- a/migration/create-access/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/migration/create-access/build.gradle.kts b/migration/create-access/build.gradle.kts deleted file mode 100644 index 944332436..000000000 --- a/migration/create-access/build.gradle.kts +++ /dev/null @@ -1,17 +0,0 @@ -plugins { - alias(libs.plugins.keygo.android.library) - alias(libs.plugins.keygo.android.protobuf) -} - -android { - namespace = "de.davis.keygo.migration.create_access" - - testFixtures { - enable = true - } -} - -dependencies { - implementation(libs.androidx.datastore) - implementation(libs.at.favre.bcrypt) -} diff --git a/migration/create-access/consumer-rules.pro b/migration/create-access/consumer-rules.pro deleted file mode 100644 index 087733ca4..000000000 --- a/migration/create-access/consumer-rules.pro +++ /dev/null @@ -1,16 +0,0 @@ -# Protobuf Lite --keep class com.google.protobuf.** { *; } - -# Keep generated protobuf message fields --keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite { - ; -} - -# Keep protobuf enums --keepclassmembers enum * { - public static **[] values(); - public static ** valueOf(java.lang.String); -} - -# DataStore Proto --keep class androidx.datastore.** { *; } \ No newline at end of file diff --git a/migration/create-access/proguard-rules.pro b/migration/create-access/proguard-rules.pro deleted file mode 100644 index 481bb4348..000000000 --- a/migration/create-access/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/migration/create-access/src/main/AndroidManifest.xml b/migration/create-access/src/main/AndroidManifest.xml deleted file mode 100644 index 44008a433..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/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 deleted file mode 100644 index 92451b051..000000000 --- a/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/data/mapper/MainPasswordMapper.kt +++ /dev/null @@ -1,8 +0,0 @@ -package de.davis.keygo.migration.create_access.data.mapper - -import de.davis.keygo.migration.create_access.data.local.model.ProtoMainPassword -import de.davis.keygo.migration.create_access.domain.model.MainPassword -import java.time.Instant - -internal fun ProtoMainPassword.toDomain() = - MainPassword(hash, Instant.ofEpochSecond(createdAt.seconds, createdAt.nanos.toLong())) 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 deleted file mode 100644 index bb87b8664..000000000 --- a/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/di/MigrationCreateAccessModule.kt +++ /dev/null @@ -1,28 +0,0 @@ -package de.davis.keygo.migration.create_access.di - -import android.content.Context -import androidx.datastore.dataStore -import de.davis.keygo.migration.create_access.data.local.datasource.datastore.MainPasswordSerializer -import de.davis.keygo.migration.create_access.di.annotation.MainPasswordQualifier -import org.koin.core.annotation.ComponentScan -import org.koin.core.annotation.Configuration -import org.koin.core.annotation.Module -import org.koin.core.annotation.Single - -@Module -@Configuration -@ComponentScan("de.davis.keygo.migration.create_access") -object MigrationCreateAccessModule { - - private const val DATA_STORE_NAME = "main-password.db" - - private val Context.protoMainPasswordDataStore by dataStore( - fileName = DATA_STORE_NAME, - serializer = MainPasswordSerializer, - ) - - @Single - @MainPasswordQualifier - internal fun provideProtoMainPasswordDataStore(context: Context) = - context.protoMainPasswordDataStore -} 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 deleted file mode 100644 index bb5ed527e..000000000 --- a/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/repository/MainPasswordRepository.kt +++ /dev/null @@ -1,10 +0,0 @@ -package de.davis.keygo.migration.create_access.domain.repository - -import de.davis.keygo.migration.create_access.domain.model.MainPassword - -internal interface MainPasswordRepository { - - suspend fun getMainPassword(): MainPassword - - suspend fun clearMainPassword() -} 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 deleted file mode 100644 index 583759ee3..000000000 --- a/migration/create-access/src/main/kotlin/de/davis/keygo/migration/create_access/domain/usecase/ClearMainPasswordUseCase.kt +++ /dev/null @@ -1,11 +0,0 @@ -package de.davis.keygo.migration.create_access.domain.usecase - -import de.davis.keygo.migration.create_access.domain.repository.MainPasswordRepository -import org.koin.core.annotation.Single - -@Single -class ClearMainPasswordUseCase internal constructor( - private val mainPasswordRepository: MainPasswordRepository -) { - suspend operator fun invoke() = mainPasswordRepository.clearMainPassword() -} diff --git a/migration/create-access/src/testFixtures/kotlin/de/davis/keygo/migration/create_access/MigrationTestUseCases.kt b/migration/create-access/src/testFixtures/kotlin/de/davis/keygo/migration/create_access/MigrationTestUseCases.kt deleted file mode 100644 index de78a697f..000000000 --- a/migration/create-access/src/testFixtures/kotlin/de/davis/keygo/migration/create_access/MigrationTestUseCases.kt +++ /dev/null @@ -1,15 +0,0 @@ -package de.davis.keygo.migration.create_access - -import de.davis.keygo.migration.create_access.data.repository.HashValidatorImpl -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 - -fun clearMainPasswordUseCase(repository: FakeMainPasswordRepository): ClearMainPasswordUseCase = - ClearMainPasswordUseCase(repository.asMainPasswordRepository()) - -fun hasMainPasswordUseCase(repository: FakeMainPasswordRepository): HasMainPasswordUseCase = - HasMainPasswordUseCase(repository.asMainPasswordRepository()) - -fun validateMainPasswordUseCase(repository: FakeMainPasswordRepository): ValidateMainPasswordUseCase = - ValidateMainPasswordUseCase(HashValidatorImpl(), repository.asMainPasswordRepository()) 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 deleted file mode 100644 index 5b378bafc..000000000 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/crypto/LegacyAesGcmCipher.kt +++ /dev/null @@ -1,33 +0,0 @@ -package de.davis.keygo.migration.legacy_data.data.crypto - -import de.davis.keygo.migration.legacy_data.domain.crypto.LegacyCipher -import org.koin.core.annotation.Single -import javax.crypto.Cipher -import javax.crypto.SecretKey -import javax.crypto.spec.GCMParameterSpec - -/** - * Reverses v1's `Cryptography.encryptAES`, which wrote `IV(12) || AES-256-GCM ciphertext` with a - * 128 bit tag under the `password_manager_skey` Keystore alias. - */ -@Single -internal class LegacyAesGcmCipher : LegacyCipher { - - override fun decrypt(blob: ByteArray, key: SecretKey): ByteArray? { - if (blob.size <= IV_SIZE) return null - - return runCatching { - Cipher.getInstance(TRANSFORMATION) - .apply { - init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, blob, 0, IV_SIZE)) - } - .doFinal(blob, IV_SIZE, blob.size - IV_SIZE) - }.getOrNull() - } - - private companion object { - const val IV_SIZE = 12 - const val TAG_BITS = 128 - const val TRANSFORMATION = "AES/GCM/NoPadding" - } -} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyKeyRepositoryImpl.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyKeyRepositoryImpl.kt deleted file mode 100644 index 11112b162..000000000 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/data/repository/LegacyKeyRepositoryImpl.kt +++ /dev/null @@ -1,33 +0,0 @@ -package de.davis.keygo.migration.legacy_data.data.repository - -import de.davis.keygo.migration.legacy_data.domain.repository.LegacyKeyRepository -import org.koin.core.annotation.Single -import java.security.KeyStore -import javax.crypto.SecretKey - -@Single -internal class LegacyKeyRepositoryImpl : LegacyKeyRepository { - - override fun secretKey(): SecretKey? = - withAlias { getKey(LEGACY_KEY_ALIAS, null) as? SecretKey } - - override fun deleteLegacyKey() { - withAlias { deleteEntry(LEGACY_KEY_ALIAS) } - } - - /** - * Runs [block] against the Keystore only when v1's alias is actually there, and never throws. - * A Keystore that will not load, or an alias that is already gone, is the same answer to both - * callers: there is no legacy key. - */ - private fun withAlias(block: KeyStore.() -> T): T? = runCatching { - val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) } - if (keyStore.containsAlias(LEGACY_KEY_ALIAS)) keyStore.block() else null - }.getOrNull() - - private companion object { - const val ANDROID_KEY_STORE = "AndroidKeyStore" - - const val LEGACY_KEY_ALIAS = "password_manager_skey" - } -} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/di/MigrationLegacyDataModule.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/di/MigrationLegacyDataModule.kt deleted file mode 100644 index 29fb92479..000000000 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/di/MigrationLegacyDataModule.kt +++ /dev/null @@ -1,10 +0,0 @@ -package de.davis.keygo.migration.legacy_data.di - -import org.koin.core.annotation.ComponentScan -import org.koin.core.annotation.Configuration -import org.koin.core.annotation.Module - -@Module(includes = [LegacyDatabaseModule::class]) -@Configuration -@ComponentScan("de.davis.keygo.migration.legacy_data") -object MigrationLegacyDataModule diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/LegacyDataImportStarter.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/LegacyDataImportStarter.kt deleted file mode 100644 index 7aed9dd18..000000000 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/LegacyDataImportStarter.kt +++ /dev/null @@ -1,55 +0,0 @@ -package de.davis.keygo.migration.legacy_data.domain.usecase - -import android.util.Log -import de.davis.keygo.core.security.domain.Session -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import org.koin.core.annotation.Single - -private const val TAG = "LegacyDataImport" - -/** - * Runs the v1 import in the background on every unlock. - * - * Driven by [Session.sessionStarts] rather than called from the auth screen, because the auth - * screen is only one of the doors: the autofill service and both passkey activities start sessions - * of their own. An import wired to some of them would leave a partial migration sitting until the - * user happened to come back through the right one, and would go quietly stale again the next time - * a door is added. - * - * The import is not work that can sit in front of the user: it opens the inherited file, decrypts - * every row through the Keystore and writes them all back under new keys, and how long that takes - * is a property of the user's old database rather than anything we can bound. It runs on its own - * application scope on [Dispatchers.IO], off the main thread and outside the lifetime of whatever - * unlocked, which for the auth screen is a ViewModel cleared moments later. - * - * There is no in-process lock today for a run to be interrupted by: the session lives for the - * process, and the app's only current "lock" is a process or activity restart, which ends the run - * along with everything else. A run interrupted by a future lock feature would fail instead, leave - * the legacy file exactly as it found it, and be retried on the next unlock. - * - * Created at Koin start so the collector is already listening when the first session begins. - */ -@Single(createdAtStart = true) -internal class LegacyDataImportStarter( - session: Session, - migrateLegacyData: MigrateLegacyDataUseCase, -) { - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - private val runner = LegacyImportRunner( - scope = scope, - report = { message, cause -> - if (cause != null) Log.e(TAG, message, cause) else Log.e(TAG, message) - }, - import = { migrateLegacyData() }, - ) - - init { - session.sessionStarts.onEach { runner.start() }.launchIn(scope) - } -} diff --git a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/LegacyImportRunner.kt b/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/LegacyImportRunner.kt deleted file mode 100644 index d96df5a79..000000000 --- a/migration/legacy-data/src/main/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/LegacyImportRunner.kt +++ /dev/null @@ -1,109 +0,0 @@ -package de.davis.keygo.migration.legacy_data.domain.usecase - -import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationOutcome -import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationReport -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.coroutines.cancellation.CancellationException - -/** - * Runs [import] on [scope], one run at a time, with nothing escaping into [scope]. - * - * One run at a time is what stops two unlocks from copying the same rows twice. The import mints a - * fresh item id for every row it takes across and has no key to recognise a row it already - * imported, so two runs over one legacy file would leave the user holding two of everything. A call - * made while a run is in flight is dropped rather than queued, and a call made after one has - * finished starts a new run, which is what makes the import retry on every unlock. - * - * That retry stops for good once a run reports [LegacyMigrationOutcome.nothingLeftToImport], which - * is the answer for the overwhelming majority of installs: no v1 file was ever there. Without the - * latch every unlock for the rest of the process would open the file, count it and sweep the - * filesystem to conclude the same nothing, and unlocks are not rare, since the autofill service and - * both passkey activities each start a session of their own. - * - * [import] returns its outcome rather than throwing for anything expected, and every ending that is - * not a clean success is turned into one call to [report]. - * - * Nothing thrown may reach [scope], where an uncaught throwable would take the process down. That - * covers [import] itself and also [report]: a throwing reporting implementation must not be able to - * do what a throwing import already cannot. [Throwable] and not [Exception] for [import]: - * [MigrateLegacyDataUseCase] catches [Exception] around its whole run, which leaves everything that - * is not one uncaught, and a module reaching Room, a native SQLite driver and the Keystore can raise - * a [LinkageError] or a [NoClassDefFoundError] on a device missing something it expected. - * - * Cancellation is rethrown rather than reported. See LegacyItemRepositoryImpl.withDao. - */ -internal class LegacyImportRunner( - private val scope: CoroutineScope, - private val report: (message: String, cause: Throwable?) -> Unit, - private val import: suspend () -> LegacyMigrationOutcome, -) { - - private val inFlight = AtomicBoolean(false) - - private val finished = AtomicBoolean(false) - - fun start() { - if (finished.get()) return - if (!inFlight.compareAndSet(false, true)) return - - // The flag is released on completion rather than in a finally, so a run whose scope died - // before its body ever ran still gives the next unlock its turn. - scope.launch { - try { - val outcome = import() - // Latched before the in-flight flag is released, so no start can slip between the - // two and win a run the verdict has already ruled out. - if (outcome.nothingLeftToImport) finished.set(true) - reportOutcome(outcome) - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - reportSafely("v1 import threw", e) - } - }.invokeOnCompletion { inFlight.set(false) } - } - - private fun reportOutcome(outcome: LegacyMigrationOutcome) { - when (outcome) { - is LegacyMigrationOutcome.Failed -> - reportSafely("v1 import failed, retrying on the next unlock", outcome.cause) - - is LegacyMigrationOutcome.Migrated -> if ( - outcome.report.hasFailures || outcome.report.fileRetained - ) - reportSafely(migrationSummary(outcome.report), null) - - LegacyMigrationOutcome.NothingToMigrate -> Unit - } - } - - private fun migrationSummary(migrationReport: LegacyMigrationReport): String { - val parts = mutableListOf() - - if (migrationReport.hasFailures) { - // Grouped by reason rather than by row: a row's title is the user's own account name, - // and logcat is not the place for it. Counts and reasons are enough to work out what - // happened. - val byReason = migrationReport.failures.groupingBy { it.reason }.eachCount().entries - .joinToString { (reason, count) -> "$reason=$count" } - val total = migrationReport.migratedItems + migrationReport.failures.size - parts += "${migrationReport.failures.size} of $total row(s) skipped: $byReason" - } - - if (migrationReport.fileRetained) - parts += "the legacy file could not be cleared and will be retried on the next unlock" - - return "v1 import finished: ${parts.joinToString("; ")}" - } - - /** Calls [report] without letting a throw out of it reach [scope]. */ - private fun reportSafely(message: String, cause: Throwable?) { - try { - report(message, cause) - } catch (_: Throwable) { - // Nothing to escalate to: this is already the containment boundary. - } - } -} diff --git a/migration/legacy-data/src/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 deleted file mode 100644 index 0fdd2e217..000000000 --- a/migration/legacy-data/src/test/kotlin/de/davis/keygo/migration/legacy_data/domain/usecase/LegacyImportRunnerTest.kt +++ /dev/null @@ -1,286 +0,0 @@ -package de.davis.keygo.migration.legacy_data.domain.usecase - -import de.davis.keygo.migration.legacy_data.domain.model.LegacyFailureReason -import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationOutcome -import de.davis.keygo.migration.legacy_data.domain.model.LegacyMigrationReport -import de.davis.keygo.migration.legacy_data.domain.model.LegacyRowFailure -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import kotlin.coroutines.cancellation.CancellationException -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertTrue - -/** - * Stands in for the import. Counts its calls and takes the call number, so a test can make the - * first run behave differently from the one that follows it. - */ -private class RecordingImport( - private val body: suspend (call: Int) -> LegacyMigrationOutcome = { - LegacyMigrationOutcome.NothingToMigrate - }, -) { - var invocations = 0 - private set - - suspend operator fun invoke(): LegacyMigrationOutcome { - invocations++ - return body(invocations) - } -} - -/** - * The runner is the only thing standing between a second unlock and a second copy of the user's - * vault, and the only thing standing between a throw in the import and a process the user watches - * die on the way in. - * - * Two runs over one legacy file would import every row twice under ids neither run can recognise as - * the other's, which is why a call during a run is dropped. A run that finished with rows still on - * disk must release, because retrying on the next unlock is what turns a partial import into a - * complete one; a run that finished with nothing left must not, because every later unlock would - * pay to conclude the same nothing. - * - * `runCurrent` and not `advanceUntilIdle` throughout. The runner launches into a background scope, - * which is what production does too, and `advanceUntilIdle` returns as soon as the foreground has - * nothing left rather than running the background work. It would leave every assertion below - * looking at an import that never started. - */ -@OptIn(ExperimentalCoroutinesApi::class) -class LegacyImportRunnerTest { - - private val diagnostics = mutableListOf>() - - private val clearedFile = LegacyMigrationOutcome.Migrated( - LegacyMigrationReport(migratedItems = 3, failures = emptyList()), - ) - - private val retainedFile = LegacyMigrationOutcome.Migrated( - LegacyMigrationReport(migratedItems = 3, failures = emptyList(), fileRetained = true), - ) - - private fun TestScope.runnerFor(import: RecordingImport) = LegacyImportRunner( - scope = backgroundScope, - report = { message, cause -> diagnostics += message to cause }, - import = { import() }, - ) - - @Test - fun `a call made while a run is in flight is dropped`() = runTest { - val gate = CompletableDeferred() - val import = RecordingImport { - gate.await() - LegacyMigrationOutcome.NothingToMigrate - } - val runner = runnerFor(import) - - runner.start() - runCurrent() // the first run gets going and parks on the gate - - runner.start() - runCurrent() - - assertEquals(1, import.invocations, "a second unlock during a run must be dropped") - - gate.complete(Unit) - runCurrent() - - assertEquals(1, import.invocations, "the dropped call must not be queued behind the run") - } - - @Test - fun `a call made after a run that left rows behind starts a new run`() = runTest { - val import = RecordingImport { retainedFile } - val runner = runnerFor(import) - - runner.start() - runCurrent() - - runner.start() - runCurrent() - - assertEquals(2, import.invocations, "a finished run must leave the next unlock free") - } - - /** - * The verdict that ends the retry. Almost every install never had a v1 file, and without this - * each later unlock would re-open it, count it and sweep the filesystem to reach the same - * nothing. Unlocks are not rare either: the autofill service and both passkey activities start - * sessions of their own. - */ - @Test - fun `a run with nothing left to import is not repeated`() = runTest { - val import = RecordingImport { LegacyMigrationOutcome.NothingToMigrate } - val runner = runnerFor(import) - - runner.start() - runCurrent() - - runner.start() - runCurrent() - - assertEquals(1, import.invocations, "a verdict of nothing to import holds for the process") - } - - @Test - fun `a run that cleared the legacy file is not repeated either`() = runTest { - val import = RecordingImport { clearedFile } - val runner = runnerFor(import) - - runner.start() - runCurrent() - - runner.start() - runCurrent() - - assertEquals(1, import.invocations, "the file is gone; a retry has nothing left to find") - } - - @Test - fun `an import that throws is reported and leaves the next unlock free to retry`() = runTest { - val boom = IllegalStateException("probing the legacy file blew up") - val import = RecordingImport { throw boom } - val runner = runnerFor(import) - - runner.start() - runCurrent() - - assertEquals(listOf(boom), diagnostics.map { it.second }, "a throw must be reported") - - runner.start() - runCurrent() - - assertEquals(2, import.invocations, "a failed run must release the runner") - } - - @Test - fun `an import that fails with an Error is contained too`() = runTest { - val import = RecordingImport { throw NoClassDefFoundError("a driver this device lacks") } - val runner = runnerFor(import) - - runner.start() - runCurrent() - - // Not Exception. This runs on an application scope, where anything that gets out takes the - // process down, and a device missing a native library is not a reason to lose the vault. - assertIs(diagnostics.single().second) - } - - @Test - fun `cancellation is passed through rather than reported as a failure`() = runTest { - val import = RecordingImport { call -> - if (call == 1) throw CancellationException("the run was cancelled") - LegacyMigrationOutcome.NothingToMigrate - } - val runner = runnerFor(import) - - runner.start() - runCurrent() - - assertTrue( - diagnostics.isEmpty(), - "a cancelled run has learned nothing about the user's file and must not answer for it", - ) - - runner.start() - runCurrent() - - assertEquals(2, import.invocations, "a cancelled run must release the runner as well") - } - - @Test - fun `a Failed outcome reports its cause through the seam`() = runTest { - val cause = IllegalStateException("the legacy database exists but could not be opened") - val import = RecordingImport { LegacyMigrationOutcome.Failed(cause) } - val runner = runnerFor(import) - - runner.start() - runCurrent() - - assertEquals( - cause, - diagnostics.singleOrNull()?.second, - "Failed is this module's channel for an expected failure and must reach the seam", - ) - } - - @Test - fun `a Migrated outcome with row failures produces a diagnostic`() = runTest { - val report = LegacyMigrationReport( - migratedItems = 2, - failures = listOf( - LegacyRowFailure( - legacyId = 1L, - title = "Gmail", - reason = LegacyFailureReason.Unreadable, - ), - ), - ) - val import = RecordingImport { LegacyMigrationOutcome.Migrated(report) } - val runner = runnerFor(import) - - runner.start() - runCurrent() - - assertEquals(1, diagnostics.size, "a run that drops entries must leave a trace") - assertTrue( - diagnostics.single().first.contains("Unreadable"), - "the diagnostic must carry the reason, not just that something was skipped", - ) - assertTrue( - !diagnostics.single().first.contains("Gmail"), - "a row's title is the user's own account name; it must not end up in logcat", - ) - } - - @Test - fun `a Migrated outcome with no row failures but a retained file still reports`() = runTest { - val runner = runnerFor(RecordingImport { retainedFile }) - - runner.start() - runCurrent() - - // The ending that duplicates the whole vault on the next unlock, and `hasFailures` alone - // cannot see it: there were none. - assertEquals(1, diagnostics.size, "a retained file must report even with no row failures") - assertTrue(diagnostics.single().first.contains("retried")) - } - - /** A runner each, because both of these endings latch and so cannot follow one another. */ - @Test - fun `NothingToMigrate and a clean Migrated produce no diagnostic`() = runTest { - runnerFor(RecordingImport { LegacyMigrationOutcome.NothingToMigrate }).start() - runCurrent() - - runnerFor(RecordingImport { clearedFile }).start() - runCurrent() - - assertTrue(diagnostics.isEmpty(), "the normal endings must produce no diagnostic line") - } - - @Test - fun `a reporter that throws is contained, and the runner still releases`() = runTest { - val cause = IllegalStateException("the legacy database exists but could not be opened") - val import = RecordingImport { LegacyMigrationOutcome.Failed(cause) } - val runner = LegacyImportRunner( - scope = backgroundScope, - report = { _, _ -> throw RuntimeException("the reporter itself is broken") }, - import = { import() }, - ) - - runner.start() - runCurrent() - - runner.start() - runCurrent() - - assertEquals( - 2, - import.invocations, - "a throwing reporter must not take the application scope down", - ) - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index c7e735f27..f5b0fe299 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,8 +30,7 @@ rootProject.name = "KeyGoV2" include(":app") include(":automation-processor") include(":automation") -include(":migration:create-access") -include(":migration:legacy-data") +include(":legacy-migration") include(":core:item") include(":core:util") include(":core:security")