diff --git a/CLAUDE.md b/CLAUDE.md index 0581afa2e..cc737965f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,12 +155,19 @@ carries its own rules: - Do not use mocks as the default way to model dependencies when a fake or testFixture exists - Run broader tests for cross-module or security changes - **Rust fakes** — `:rust` uses UniFFI (not raw JNI) to generate Kotlin bindings. UniFFI emits - `KeyDeriverInterface`/`KeyWrapperInterface`/`AccountManagerInterface`/`ItemManagerInterface`/ - `VaultManagerInterface`/`CardFormatterInterface`/`CsvBackupManagerInterface`/ - `JsonBackupManagerInterface`/`RustPasskeyInterface`/`TotpServiceInterface` for test seams; fakes - live in `:rust` testFixtures (`de.davis.keygo.rust`). - Never instantiate the real UniFFI classes (`KeyDeriver()`, `KeyWrapper()`, etc.) in JVM unit + `KeyWrapperInterface`/`ItemManagerInterface`/`VaultManagerInterface`/`CardFormatterInterface`/ + `CsvBackupManagerInterface`/`JsonBackupManagerInterface`/`RustPasskeyInterface`/ + `TotpServiceInterface` for test seams; fakes live in `:rust` testFixtures + (`de.davis.keygo.rust`). + Never instantiate the real UniFFI classes (`KeyWrapper()`, etc.) in JVM unit tests — their default constructors require the native Rust library at runtime. + `ArkCredential(NoHandle)` is uniffi's own test constructor: it sets the handle to 0 and allocates + no Rust object, which is how `FakeArkCredential` extends the generated class without touching the + native library. +- **Session fakes**: the app reaches the Rust session only through the `Session` interface + (`SessionImpl` wraps the UniFFI `ArkSessionInterface`). Its fakes live in `:core:security` + testFixtures (`de.davis.keygo.core.security`): `FakeSession`, `FakeArkCredential`, and + `FakeSessionFactory` for code that opens a throwaway session through `SessionFactory`. - **testFixtures + Compose plugin** — Any module with `kotlin.compose` that enables testFixtures must add `testFixturesImplementation(libs.androidx.compose.runtime)` to avoid "Compose Runtime not on classpath" compile errors. See `:core:item` for the canonical pattern. diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCase.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCase.kt index f12b42146..3d9a50bc6 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCase.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCase.kt @@ -4,21 +4,17 @@ import de.davis.keygo.core.identity.domain.model.ChangePasswordError import de.davis.keygo.core.identity.domain.model.PasswordWrappedArk import de.davis.keygo.core.identity.domain.model.Reauthentication import de.davis.keygo.core.identity.domain.repository.AccountRepository +import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.core.security.domain.SessionError import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.resultBinding -import de.davis.keygo.rust.derive.KeyDeriver -import de.davis.keygo.rust.derive.deriveRootKekFromPasswordWithResult -import de.davis.keygo.rust.wrap.KeyWrapper -import de.davis.keygo.rust.wrap.unwrapAccountRootKeyWithResult -import de.davis.keygo.rust.wrap.wrapAccountRootKeyWithResult import de.davisalessandro.keygo.rust.WrappedKeyBlob import org.koin.core.annotation.Single @Single class ChangePasswordUseCase( private val accountRepository: AccountRepository, - private val keyDeriver: KeyDeriver, - private val keyWrapper: KeyWrapper, + private val session: Session, ) { suspend operator fun invoke( @@ -39,63 +35,48 @@ class ChangePasswordUseCase( val account = accountRepository.getOrNull() ?: return Result.Failure(ChangePasswordError.ActiveAccountNotFound) - val ark = when (reauthentication) { - is Reauthentication.Password -> { - val kek = keyDeriver.deriveRootKekFromPasswordWithResult( - password = reauthentication.currentPassword, - salt = account.passwordWrappedArk.salt, - ).bind { ChangePasswordError.KeyDerivationFailed } - - try { - keyWrapper.unwrapAccountRootKeyWithResult( - kek = kek, - wrapped = WrappedKeyBlob( - ciphertext = account.passwordWrappedArk.key, - nonce = account.passwordWrappedArk.keyIV, - ), - userId = account.id, - ).bind { ChangePasswordError.IncorrectPassword } - } finally { - kek.fill(0) + when (reauthentication) { + is Reauthentication.Password -> session.verifyPassword( + password = reauthentication.currentPassword, + salt = account.passwordWrappedArk.salt, + wrapped = WrappedKeyBlob( + ciphertext = account.passwordWrappedArk.key, + nonce = account.passwordWrappedArk.keyIV, + ), + userId = account.id, + ).bind { + when (it) { + is SessionError.Derivation -> ChangePasswordError.KeyDerivationFailed + SessionError.Locked -> ChangePasswordError.ActiveAccountNotFound + else -> ChangePasswordError.IncorrectPassword } } is Reauthentication.Biometric -> { account.biometricWrappedArk ?: return Result.Failure(ChangePasswordError.BiometricNotEnrolled) - reauthentication.recoveredArk + val matches = session.verifyArk(reauthentication.recoveredArk) + .bind { ChangePasswordError.ActiveAccountNotFound } + if (!matches) return Result.Failure(ChangePasswordError.IncorrectPassword) } } - try { - val newSalt = keyDeriver.generateSalt() - val newKek = keyDeriver.deriveRootKekFromPasswordWithResult( - password = newPassword, - salt = newSalt, - ).bind { ChangePasswordError.KeyDerivationFailed } - - val rewrapped = try { - keyWrapper.wrapAccountRootKeyWithResult( - kek = newKek, - ark = ark, - userId = account.id, - ).bind { ChangePasswordError.WrappingFailed } - } finally { - newKek.fill(0) + val rewrapped = session.rewrapForNewPassword(newPassword, account.id).bind { + when (it) { + is SessionError.Derivation -> ChangePasswordError.KeyDerivationFailed + SessionError.Locked -> ChangePasswordError.ActiveAccountNotFound + else -> ChangePasswordError.WrappingFailed } + } - accountRepository.set( - account.copy( - passwordWrappedArk = PasswordWrappedArk( - key = rewrapped.ciphertext, - keyIV = rewrapped.nonce, - salt = newSalt, - ), + accountRepository.set( + account.copy( + passwordWrappedArk = PasswordWrappedArk( + key = rewrapped.wrapped.ciphertext, + keyIV = rewrapped.wrapped.nonce, + salt = rewrapped.salt, ), - ).bind { ChangePasswordError.PersistenceFailed } - } finally { - // Scrub the in-memory ARK on success *and* on every failure path after unwrap. - ark.fill(0) - } + ), + ).bind { ChangePasswordError.PersistenceFailed } } } diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/CreateAccessUseCase.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/CreateAccessUseCase.kt index d632b1a3e..a662f1b9d 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/CreateAccessUseCase.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/CreateAccessUseCase.kt @@ -9,43 +9,32 @@ import de.davis.keygo.core.item.domain.model.Vault import de.davis.keygo.core.item.domain.repository.VaultContextRepository import de.davis.keygo.core.item.domain.repository.VaultRepository import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.core.security.domain.SessionError +import de.davis.keygo.core.security.domain.useArk import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.asResult -import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.core.util.isFailure import de.davis.keygo.core.util.resultBinding -import de.davis.keygo.rust.account.AccountManager -import de.davis.keygo.rust.derive.KeyDeriver -import de.davis.keygo.rust.derive.deriveRootKekFromPasswordWithResult -import de.davis.keygo.rust.wrap.KeyWrapper -import de.davis.keygo.rust.wrap.wrapAccountRootKeyWithResult -import de.davis.keygo.rust.wrap.wrapVaultKeyWithResult -import de.davisalessandro.keygo.rust.AccountRootKey -import de.davisalessandro.keygo.rust.RootKek import org.koin.core.annotation.Single import javax.crypto.Cipher import javax.crypto.spec.SecretKeySpec -import de.davisalessandro.keygo.rust.Account as RustAccount - @Single class CreateAccessUseCase( - private val keyDeriver: KeyDeriver, - private val keyWrapper: KeyWrapper, - private val accountManager: AccountManager, private val accountRepository: AccountRepository, private val vaultRepository: VaultRepository, private val vaultContextRepository: VaultContextRepository, - private val session: Session + private val session: Session, ) { /** - * Use case to create access by generating a new account and vault, which are then wrapped - * with a Key Encryption Key (KEK) derived from the user's password. Optionally, the ARK - * (AccountRootKey) can also be wrapped with a KEK derived from biometric data. + * Use case to create access by generating a new account and vault. The session mints the ARK + * in Rust, wraps it under a KEK derived from the user's password, and keeps custody of it, so + * the caller is left unlocked without the key ever reaching the JVM heap. Optionally, a second + * copy of the ARK is wrapped with a biometric-backed Keystore cipher. * - * The generated ARK is stored in the session for immediate use. The password-wrapped ARK and, - * if applicable, the biometric-wrapped ARK are stored in the [AccountRepository] for future - * retrieval. + * The password-wrapped ARK and, if applicable, the biometric-wrapped ARK are stored in the + * [AccountRepository] for future retrieval. * * @param password The user's password used to derive the KEK for wrapping the ARK. * @param biometricCipher An optional [Cipher] initialized for wrapping the ARK with biometric data. @@ -55,23 +44,33 @@ class CreateAccessUseCase( biometricCipher: Cipher? = null, vaultName: String = "Default Vault", accountDisplayName: String = "Default Account", - ): Result = resultBinding { - val salt = keyDeriver.generateSalt() - val derivedKek = keyDeriver.deriveRootKekFromPasswordWithResult( - password = password, - salt = salt, - ).getOrNull() ?: return Result.Failure(CreateAccessError.KeyDerivationFailed) - - val accountHolder = accountManager.createAccount() - - val passwordWrappedArk = - getPasswordWrappedArk(accountHolder.account, derivedKek, salt).bind() - - val wrappedVaultKey = accountHolder.defaultVault.wrap(accountHolder.account.ark) - .bind { CreateAccessError.WrappingFailed } + ): Result { + var handBack = true + try { + val result = create(password, biometricCipher, vaultName, accountDisplayName) + handBack = result.isFailure() + return result + } finally { + if (handBack) session.endSession() + } + } - val biometricWrappedArk = biometricCipher?.let { - getBiometricWrappedArk(accountHolder.account, it).bind() + private suspend fun create( + password: String, + biometricCipher: Cipher?, + vaultName: String, + accountDisplayName: String, + ): Result = resultBinding { + val created = session.createAccount(password) + .bind { + if (it is SessionError.Derivation) CreateAccessError.KeyDerivationFailed + else CreateAccessError.WrappingFailed + } + + val biometricWrappedArk = biometricCipher?.let { cipher -> + session.useArk { ark -> + wrapArk(ark, cipher).asResult(CreateAccessError.WrappingFailed).bind() + }.bind { CreateAccessError.WrappingFailed } } // Persist the account before the vault: the vault is encrypted under the account's @@ -79,9 +78,13 @@ class CreateAccessUseCase( // write fails after this, the half-state is recoverable on retry, since `set` overwrites. accountRepository.set( Account( - id = accountHolder.account.id, + id = created.userId, displayName = accountDisplayName, - passwordWrappedArk = passwordWrappedArk, + passwordWrappedArk = PasswordWrappedArk( + key = created.passwordWrappedArk.ciphertext, + keyIV = created.passwordWrappedArk.nonce, + salt = created.salt, + ), biometricWrappedArk = biometricWrappedArk, ) ).bind { CreateAccessError.AccountPersistenceFailed } @@ -90,52 +93,22 @@ class CreateAccessUseCase( runCatching { vaultRepository.createVault( Vault( - id = accountHolder.defaultVault.id, + id = created.vaultId, name = vaultName, - wrappedVaultKey = wrappedVaultKey.ciphertext, - vaultKeyNonce = wrappedVaultKey.nonce, + wrappedVaultKey = created.wrappedVaultKey.ciphertext, + vaultKeyNonce = created.wrappedVaultKey.nonce, icon = Vault.Icon.Default, ) ) }.onFailure { return Result.Failure(CreateAccessError.VaultPersistenceFailed(it)) } - vaultContextRepository.setContextAndLastInteracted(accountHolder.defaultVault.id) - - session.startSession(accountHolder.account.ark) + vaultContextRepository.setContextAndLastInteracted(created.vaultId) } - private fun getPasswordWrappedArk( - account: RustAccount, - derivedKek: RootKek, - salt: ByteArray - ) = account.wrap(derivedKek) - .getOrNull() - ?.let { wrappedKey -> - PasswordWrappedArk( - key = wrappedKey.ciphertext, - keyIV = wrappedKey.nonce, - salt = salt - ) - }.asResult(CreateAccessError.WrappingFailed) - - private fun getBiometricWrappedArk( - account: RustAccount, - biometricCipher: Cipher - ) = account.wrapUsingCipher(biometricCipher) - ?.let { (wrappedKey, iv) -> - BiometricWrappedArk( - key = wrappedKey, - keyIV = iv - ) - }.asResult(CreateAccessError.WrappingFailed) - - private fun de.davisalessandro.keygo.rust.Vault.wrap(ark: AccountRootKey) = - keyWrapper.wrapVaultKeyWithResult(ark, vaultKey, id) - - private fun RustAccount.wrap(kek: RootKek) = - keyWrapper.wrapAccountRootKeyWithResult(kek, ark, id) - - private fun RustAccount.wrapUsingCipher(cipher: Cipher) = runCatching { - cipher.wrap(SecretKeySpec(ark, 0, ark.size, "AES")) to cipher.iv + private fun wrapArk(ark: ByteArray, cipher: Cipher): BiometricWrappedArk? = runCatching { + BiometricWrappedArk( + key = cipher.wrap(SecretKeySpec(ark, 0, ark.size, "AES")), + keyIV = cipher.iv, + ) }.getOrNull() } diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCase.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCase.kt index 16723f9cb..881e2e37e 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCase.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCase.kt @@ -1,28 +1,18 @@ package de.davis.keygo.core.identity.domain.usecase -import de.davis.keygo.core.identity.domain.model.PasswordWrappedArk import de.davis.keygo.core.identity.domain.model.UnlockError import de.davis.keygo.core.identity.domain.repository.AccountRepository import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.core.security.domain.SessionError import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.resultBinding -import de.davis.keygo.rust.derive.KeyDeriver -import de.davis.keygo.rust.derive.deriveRootKekFromPasswordWithResult -import de.davis.keygo.rust.wrap.KeyWrapper -import de.davis.keygo.rust.wrap.unwrapAccountRootKeyWithResult -import de.davisalessandro.keygo.rust.AccountRootKey -import de.davisalessandro.keygo.rust.KeyWrapException -import de.davisalessandro.keygo.rust.RootKek import de.davisalessandro.keygo.rust.WrappedKeyBlob import org.koin.core.annotation.Single -import java.util.UUID @Single class UnlockWithPasswordUseCase( private val session: Session, private val accountRepository: AccountRepository, - private val keyDeriver: KeyDeriver, - private val keyWrapper: KeyWrapper, ) { suspend operator fun invoke(password: String): Result = resultBinding { @@ -30,23 +20,14 @@ class UnlockWithPasswordUseCase( ?: return Result.Failure(UnlockError.ActiveAccountNotFound) val wrappedKey = account.passwordWrappedArk - val derivedKey = keyDeriver.deriveRootKekFromPasswordWithResult( + session.unlockWithPassword( password = password, salt = wrappedKey.salt, - ).bind { UnlockError.DerivationFailed } - - val key = wrappedKey.unwrapUsing(derivedKey, account.id) - .bind { UnlockError.UnwrappingFailed } - - session.startSession(key) + wrapped = WrappedKeyBlob(ciphertext = wrappedKey.key, nonce = wrappedKey.keyIV), + userId = account.id, + ).bind { + if (it is SessionError.Derivation) UnlockError.DerivationFailed + else UnlockError.UnwrappingFailed + } } - - private fun PasswordWrappedArk.unwrapUsing( - kek: RootKek, - userId: UUID, - ): Result = keyWrapper.unwrapAccountRootKeyWithResult( - kek = kek, - wrapped = WrappedKeyBlob(ciphertext = this.key, nonce = this.keyIV), - userId = userId, - ) } diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricEnrollmentAdapterImpl.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricEnrollmentAdapterImpl.kt index fe52e764d..b0bcbafc2 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricEnrollmentAdapterImpl.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricEnrollmentAdapterImpl.kt @@ -9,7 +9,7 @@ import de.davis.keygo.core.security.domain.Session import de.davis.keygo.core.security.domain.model.BiometricPolicy import de.davis.keygo.core.security.domain.model.CryptographicMode import de.davis.keygo.core.security.domain.model.KeyId -import de.davis.keygo.core.security.domain.withArkOr +import de.davis.keygo.core.security.domain.useArk import de.davis.keygo.core.security.presentation.BiometricCryptoController import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.asResult @@ -37,9 +37,9 @@ internal class BiometricEnrollmentAdapterImpl( val cipher = requestCipher(KeyId.BiometricVaultKek, CryptographicMode.Wrap, policy) .bind { BiometricEnrollmentError.BiometricFailed(it) } - val wrapped = session.withArkOr(BiometricEnrollmentError.NoActiveSession) { ark -> - wrapArk(ark, cipher).asResult(BiometricEnrollmentError.WrappingFailed) - }.bind() + val wrapped = session.useArk { ark -> + wrapArk(ark, cipher).asResult(BiometricEnrollmentError.WrappingFailed).bind() + }.bind { BiometricEnrollmentError.NoActiveSession } accountRepository.set(account.copy(biometricWrappedArk = wrapped)).bind { BiometricEnrollmentError.PersistenceFailed diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricUnlockAdapterImpl.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricUnlockAdapterImpl.kt index 41699f92f..67d9a0a76 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricUnlockAdapterImpl.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricUnlockAdapterImpl.kt @@ -11,6 +11,7 @@ import de.davis.keygo.core.security.domain.model.CiphertextData import de.davis.keygo.core.security.domain.model.KeyId import de.davis.keygo.core.security.presentation.BiometricCryptoController import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.mapFailure import org.koin.compose.koinInject import org.koin.core.annotation.Single @@ -51,8 +52,12 @@ internal class BiometricUnlockAdapterImpl( } is Result.Success -> { - session.startSession(unwrapResult.success.encoded) - Result.Success(Unit) + val ark = unwrapResult.success.encoded + try { + session.unlockWithArk(ark).mapFailure { UnlockError.UnwrappingFailed } + } finally { + ark.fill(0) + } } } } diff --git a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCaseTest.kt b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCaseTest.kt index 7aad6a564..c30c0b834 100644 --- a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCaseTest.kt +++ b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCaseTest.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExportArk::class) + package de.davis.keygo.core.identity.domain.usecase import de.davis.keygo.core.identity.FakeAccountRepository @@ -6,14 +8,14 @@ import de.davis.keygo.core.identity.domain.model.BiometricWrappedArk import de.davis.keygo.core.identity.domain.model.ChangePasswordError import de.davis.keygo.core.identity.domain.model.PasswordWrappedArk import de.davis.keygo.core.identity.domain.model.Reauthentication +import de.davis.keygo.core.security.FakeSession +import de.davis.keygo.core.security.domain.ExportArk import de.davis.keygo.core.util.getOrNull import de.davis.keygo.core.util.isFailure import de.davis.keygo.core.util.isSuccess -import de.davis.keygo.rust.FakeKeyDeriver -import de.davis.keygo.rust.FakeKeyWrapper +import de.davisalessandro.keygo.rust.NewAccount import de.davisalessandro.keygo.rust.WrappedKeyBlob import kotlinx.coroutines.test.runTest -import java.util.UUID import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -22,53 +24,67 @@ import kotlin.test.assertTrue class ChangePasswordUseCaseTest { + private val session = FakeSession() private val accountRepository = FakeAccountRepository() - private val keyDeriver = FakeKeyDeriver() - private val keyWrapper = FakeKeyWrapper() private val useCase = ChangePasswordUseCase( accountRepository = accountRepository, - keyDeriver = keyDeriver, - keyWrapper = keyWrapper, + session = session, ) - private val accountId = UUID.randomUUID() - private val ark = ByteArray(32) { (it + 1).toByte() } + /** What the session minted for the seeded account, for round-trip assertions. */ + private lateinit var created: NewAccount - private fun seedAccount( + /** + * Mints an account through the session and persists it. The session stays unlocked, which is + * what the change-password screen guarantees. + */ + private suspend fun seedAccount( password: String, withBiometric: Boolean = false, ): Account { - val salt = keyDeriver.generateSalt() - val kek = keyDeriver.deriveRootKekFromPassword(password, salt) - val wrapped = keyWrapper.wrapAccountRootKey(kek, ark, accountId) + created = checkNotNull(session.createAccount(password).getOrNull()) + val account = Account( - id = accountId, + id = created.userId, displayName = "Test", passwordWrappedArk = PasswordWrappedArk( - key = wrapped.ciphertext, - keyIV = wrapped.nonce, - salt = salt, + key = created.passwordWrappedArk.ciphertext, + keyIV = created.passwordWrappedArk.nonce, + salt = created.salt, ), biometricWrappedArk = if (withBiometric) { - BiometricWrappedArk(key = ByteArray(48) { it.toByte() }, keyIV = ByteArray(12) { it.toByte() }) + BiometricWrappedArk( + key = ByteArray(48) { it.toByte() }, + keyIV = ByteArray(12) { it.toByte() }, + ) } else null, ) accountRepository.seed(account) return account } - /** Unwraps the stored password-wrapped ARK with [password]; returns null if it doesn't unwrap. */ - private suspend fun unwrapStoredArkWith(password: String): ByteArray? { + /** The live ARK, which the biometric path has to hand back to prove reauthentication. */ + private fun liveArk(): ByteArray = checkNotNull(session.exportArk().getOrNull()) + + /** + * Whether the stored password-wrapped ARK opens under [password], in a session that shares no + * state with the one under test. It is the same ARK, not merely a well-formed one, when the + * default vault key minted alongside the account still unwraps in that fresh session. + */ + private suspend fun unlocksWith(password: String): Boolean { val stored = accountRepository.getOrNull()!!.passwordWrappedArk - val kek = keyDeriver.deriveRootKekFromPassword(password, stored.salt) - return runCatching { - keyWrapper.unwrapAccountRootKey( - kek = kek, - wrapped = WrappedKeyBlob(ciphertext = stored.key, nonce = stored.keyIV), - userId = accountId, - ) - }.getOrNull() + val probe = FakeSession() + + val unlocked = probe.unlockWithPassword( + password = password, + salt = stored.salt, + wrapped = WrappedKeyBlob(ciphertext = stored.key, nonce = stored.keyIV), + userId = created.userId, + ) + if (unlocked.isFailure()) return false + + return probe.unwrapVaultKey(created.wrappedVaultKey, created.vaultId).isSuccess() } @Test @@ -89,6 +105,24 @@ class ChangePasswordUseCaseTest { assertEquals(ChangePasswordError.IncorrectPassword, result.error) } + /** + * The stored blob opens under the right password, but around a different key than the session + * holds. Rewrapping would put the new password around the session's key, which the stored + * account never had, so the next password unlock would open nothing. + */ + @Test + fun `returns IncorrectPassword when the stored ARK is not the one the session holds`() = + runTest { + seedAccount("old") + session.unlockWithArk(ByteArray(32) { (it + 7).toByte() }) + + val result = useCase(Reauthentication.Password("old"), "new") + + assertTrue(result.isFailure()) + assertEquals(ChangePasswordError.IncorrectPassword, result.error) + assertTrue(unlocksWith("old")) + } + @Test fun `password path re-wraps ARK so new password unwraps and old fails`() = runTest { seedAccount("old") @@ -96,8 +130,8 @@ class ChangePasswordUseCaseTest { val result = useCase(Reauthentication.Password("old"), "new") assertTrue(result.isSuccess()) - assertContentEquals(ark, unwrapStoredArkWith("new")) - assertEquals(null, unwrapStoredArkWith("old")) + assertTrue(unlocksWith("new")) + assertFalse(unlocksWith("old")) } @Test @@ -122,20 +156,43 @@ class ChangePasswordUseCaseTest { } @Test - fun `biometric path re-wraps the supplied ARK under the new password`() = runTest { + fun `biometric path re-wraps the live ARK under the new password`() = runTest { seedAccount("old", withBiometric = true) - val result = useCase(Reauthentication.Biometric(ark.copyOf()), "new") + val result = useCase(Reauthentication.Biometric(liveArk()), "new") assertTrue(result.isSuccess()) - assertContentEquals(ark, unwrapStoredArkWith("new")) + assertTrue(unlocksWith("new")) } + @Test + fun `returns IncorrectPassword when the biometric ARK is not the live one`() = runTest { + seedAccount("old", withBiometric = true) + + val result = useCase(Reauthentication.Biometric(ByteArray(32) { it.toByte() }), "new") + + assertTrue(result.isFailure()) + assertEquals(ChangePasswordError.IncorrectPassword, result.error) + } + + @Test + fun `biometric path on a locked session fails as ActiveAccountNotFound, not IncorrectPassword`() = + runTest { + seedAccount("old", withBiometric = true) + val recovered = liveArk() + session.endSession() + + val result = useCase(Reauthentication.Biometric(recovered), "new") + + assertTrue(result.isFailure()) + assertEquals(ChangePasswordError.ActiveAccountNotFound, result.error) + } + @Test fun `returns BiometricNotEnrolled when biometric proof given but none enrolled`() = runTest { seedAccount("old", withBiometric = false) - val result = useCase(Reauthentication.Biometric(ark.copyOf()), "new") + val result = useCase(Reauthentication.Biometric(liveArk()), "new") assertTrue(result.isFailure()) assertEquals(ChangePasswordError.BiometricNotEnrolled, result.error) @@ -144,7 +201,7 @@ class ChangePasswordUseCaseTest { @Test fun `returns KeyDerivationFailed when derivation fails`() = runTest { seedAccount("old") - keyDeriver.failDerivation = true + session.failDerivation = true val result = useCase(Reauthentication.Password("old"), "new") @@ -163,10 +220,26 @@ class ChangePasswordUseCaseTest { assertEquals(ChangePasswordError.PersistenceFailed, result.error) } + /** + * Changing a password needs the live ARK, and proving the current password compares against + * it, so a locked session fails at reauthentication. It is reported as the missing session it + * is, not as a wrong password. + */ + @Test + fun `change password fails as ActiveAccountNotFound when the session is locked`() = runTest { + seedAccount("old") + session.endSession() + + val result = useCase(Reauthentication.Password("old"), "new") + + assertTrue(result.isFailure()) + assertEquals(ChangePasswordError.ActiveAccountNotFound, result.error) + } + @Test fun `scrubs the supplied biometric ARK after a successful change`() = runTest { seedAccount("old", withBiometric = true) - val recovered = ark.copyOf() + val recovered = liveArk() useCase(Reauthentication.Biometric(recovered), "new") @@ -176,8 +249,8 @@ class ChangePasswordUseCaseTest { @Test fun `scrubs the supplied biometric ARK when persistence fails`() = runTest { seedAccount("old", withBiometric = true) + val recovered = liveArk() accountRepository.setFails = true - val recovered = ark.copyOf() useCase(Reauthentication.Biometric(recovered), "new") @@ -187,7 +260,7 @@ class ChangePasswordUseCaseTest { @Test fun `scrubs the supplied biometric ARK when biometric reauth is not enrolled`() = runTest { seedAccount("old", withBiometric = false) - val recovered = ark.copyOf() + val recovered = liveArk() useCase(Reauthentication.Biometric(recovered), "new") diff --git a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/CreateAccessUseCaseTest.kt b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/CreateAccessUseCaseTest.kt index 307307244..0dd505ce2 100644 --- a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/CreateAccessUseCaseTest.kt +++ b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/CreateAccessUseCaseTest.kt @@ -4,12 +4,12 @@ import de.davis.keygo.core.identity.FakeAccountRepository import de.davis.keygo.core.identity.domain.model.CreateAccessError import de.davis.keygo.core.item.FakeVaultContextRepository import de.davis.keygo.core.item.FakeVaultRepository -import de.davis.keygo.core.security.crypto.FakeSession +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.repository.VaultContextRepository +import de.davis.keygo.core.security.FakeSession import de.davis.keygo.core.util.isFailure import de.davis.keygo.core.util.isSuccess -import de.davis.keygo.rust.FakeAccountManager -import de.davis.keygo.rust.FakeKeyDeriver -import de.davis.keygo.rust.FakeKeyWrapper +import de.davisalessandro.keygo.rust.WrappedKeyBlob import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import javax.crypto.Cipher @@ -17,6 +17,8 @@ import javax.crypto.KeyGenerator import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -26,14 +28,8 @@ class CreateAccessUseCaseTest { private val accountRepository = FakeAccountRepository() private val vaultRepository = FakeVaultRepository() private val vaultContextRepository = FakeVaultContextRepository() - private val keyDeriver = FakeKeyDeriver() - private val keyWrapper = FakeKeyWrapper() - private val accountManager = FakeAccountManager() private val useCase = CreateAccessUseCase( - keyDeriver = keyDeriver, - keyWrapper = keyWrapper, - accountManager = accountManager, accountRepository = accountRepository, vaultRepository = vaultRepository, vaultContextRepository = vaultContextRepository, @@ -42,7 +38,7 @@ class CreateAccessUseCaseTest { @Test fun `returns KeyDerivationFailed when derivation fails`() = runTest { - keyDeriver.failDerivation = true + session.failDerivation = true val result = useCase("password") @@ -86,12 +82,22 @@ class CreateAccessUseCaseTest { } @Test - fun `returns Success and starts session without biometric cipher`() = runTest { + fun `returns Success and leaves the session unlocked without biometric cipher`() = runTest { val result = useCase("password", biometricCipher = null) assertTrue(result.isSuccess()) - assertTrue(session.startSessionCalled) - assertContentEquals(accountManager.createAccount.account.ark, session.currentArk) + assertTrue(session.isActive.value) + // The vault the use case persisted has to unwrap under the ARK the session now holds. + val vault = vaultRepository.observeVaults().first().single() + assertTrue( + session.unwrapVaultKey( + wrapped = WrappedKeyBlob( + ciphertext = vault.keyInformation.wrappedKey, + nonce = vault.keyInformation.keyNonce, + ), + vaultId = vault.id, + ).isSuccess() + ) } @Test @@ -142,6 +148,75 @@ class CreateAccessUseCaseTest { assertEquals("Work", accountRepository.getOrNull()?.displayName) } + /** + * The ARK reaches the JVM here only so a Keystore cipher can wrap it, and the `finally` that + * zeroes it afterwards is the only thing keeping it from staying resident. [FakeSession] hands + * out the array itself rather than a copy, so the wipe is observable. + */ + @Test + fun `wipes the exported ARK after wrapping it for biometrics`() = runTest { + val recording = FakeSession(startUnlocked = true) + val biometricKek = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + val biometricCipher = Cipher.getInstance("AES/GCM/NoPadding").apply { + init(Cipher.WRAP_MODE, biometricKek) + } + + useCaseOver(recording)("password", biometricCipher = biometricCipher) + + assertContentEquals(ByteArray(32), recording.onlyExported()) + } + + @Test + fun `wipes the exported ARK even when wrapping fails`() = runTest { + val recording = FakeSession(startUnlocked = true) + // A cipher in the wrong mode makes Cipher.wrap throw, so the wrap fails after the export. + val kek = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + val wrongMode = Cipher.getInstance("AES/GCM/NoPadding").apply { + init(Cipher.ENCRYPT_MODE, kek) + } + + val result = useCaseOver(recording)("password", biometricCipher = wrongMode) + + assertTrue(result.isFailure()) + assertEquals(CreateAccessError.WrappingFailed, result.error) + assertContentEquals(ByteArray(32), recording.onlyExported()) + } + + @Test + fun `ends the session when account persistence fails`() = runTest { + accountRepository.setFails = true + + useCase("password") + + // Nothing was persisted, so a retained ARK would be a key with nothing left to unwrap. + assertFalse(session.isActive.value) + } + + @Test + fun `ends the session when vault persistence fails`() = runTest { + vaultRepository.createError = RuntimeException("disk full") + + useCase("password") + + assertFalse(session.isActive.value) + } + + @Test + fun `ends the session when the last write throws`() = runTest { + val throwing = CreateAccessUseCase( + accountRepository = accountRepository, + vaultRepository = vaultRepository, + vaultContextRepository = ThrowingVaultContextRepository(), + session = session, + ) + + assertFailsWith { throwing("password") } + + // The throw leaves `create` without a return value, so only a `finally` can hand back + // the ARK. A guard on the result would let this path keep the key resident. + assertFalse(session.isActive.value) + } + @Test fun `generates different salts for different invocations`() = runTest { useCase("password") @@ -152,4 +227,24 @@ class CreateAccessUseCaseTest { assertTrue(!salt1.contentEquals(salt2)) } + + private fun useCaseOver(session: FakeSession) = CreateAccessUseCase( + accountRepository = accountRepository, + vaultRepository = vaultRepository, + vaultContextRepository = vaultContextRepository, + session = session, + ) +} + +/** + * Throws on the last write the use case makes, which is the only step reached after both persists + * have succeeded. None of the fakes throw, so the exception path out of `create` needs its own + * stand-in to be observable at all. + */ +private class ThrowingVaultContextRepository( + private val delegate: FakeVaultContextRepository = FakeVaultContextRepository(), +) : VaultContextRepository by delegate { + + override suspend fun setContextAndLastInteracted(vaultId: VaultId): Unit = + throw RuntimeException("datastore gone") } diff --git a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCaseTest.kt b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCaseTest.kt index 0d1c5c644..646b23837 100644 --- a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCaseTest.kt +++ b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCaseTest.kt @@ -4,53 +4,49 @@ import de.davis.keygo.core.identity.FakeAccountRepository import de.davis.keygo.core.identity.domain.model.Account import de.davis.keygo.core.identity.domain.model.PasswordWrappedArk import de.davis.keygo.core.identity.domain.model.UnlockError -import de.davis.keygo.core.security.crypto.FakeSession +import de.davis.keygo.core.security.FakeSession +import de.davis.keygo.core.util.getOrNull import de.davis.keygo.core.util.isFailure import de.davis.keygo.core.util.isSuccess -import de.davis.keygo.rust.FakeKeyDeriver -import de.davis.keygo.rust.FakeKeyWrapper +import de.davisalessandro.keygo.rust.NewAccount import kotlinx.coroutines.test.runTest -import java.util.UUID import kotlin.test.Test -import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class UnlockWithPasswordUseCaseTest { private val session = FakeSession() private val accountRepository = FakeAccountRepository() - private val keyDeriver = FakeKeyDeriver() - private val keyWrapper = FakeKeyWrapper() private val useCase = UnlockWithPasswordUseCase( session = session, accountRepository = accountRepository, - keyDeriver = keyDeriver, - keyWrapper = keyWrapper, ) - private fun seedAccount( - password: String, - accountId: UUID = UUID.randomUUID(), - ark: ByteArray = ByteArray(32) { it.toByte() }, - ): Account { - val salt = keyDeriver.generateSalt() - val kek = keyDeriver.deriveRootKekFromPassword(password, salt) - val wrapped = keyWrapper.wrapAccountRootKey(kek, ark, accountId) - - val account = Account( - id = accountId, - displayName = "Test", - passwordWrappedArk = PasswordWrappedArk( - key = wrapped.ciphertext, - keyIV = wrapped.nonce, - salt = salt, + /** + * Mints an account through the session, persists what the app would persist, then locks the + * session again so the use case has something to unlock. + */ + private suspend fun seedAccount(password: String): NewAccount { + val created = checkNotNull(session.createAccount(password).getOrNull()) + + accountRepository.seed( + Account( + id = created.userId, + displayName = "Test", + passwordWrappedArk = PasswordWrappedArk( + key = created.passwordWrappedArk.ciphertext, + keyIV = created.passwordWrappedArk.nonce, + salt = created.salt, + ), + biometricWrappedArk = null, ), - biometricWrappedArk = null, ) - accountRepository.seed(account) - return account + + session.endSession() + return created } @Test @@ -64,7 +60,7 @@ class UnlockWithPasswordUseCaseTest { @Test fun `returns DerivationFailed when key derivation fails`() = runTest { seedAccount("password") - keyDeriver.failDerivation = true + session.failDerivation = true val result = useCase("password") @@ -80,17 +76,19 @@ class UnlockWithPasswordUseCaseTest { assertTrue(result.isFailure()) assertEquals(UnlockError.UnwrappingFailed, result.error) + assertFalse(session.isActive.value) } @Test fun `returns Success and starts session with correct password`() = runTest { - val ark = ByteArray(32) { (it + 1).toByte() } - seedAccount("password", ark = ark) + val created = seedAccount("password") val result = useCase("password") assertTrue(result.isSuccess()) - assertTrue(session.startSessionCalled) - assertContentEquals(ark, session.currentArk) + assertTrue(session.isActive.value) + // The recovered ARK is the one the account was created under: it still unwraps the + // default vault's key. + assertTrue(session.unwrapVaultKey(created.wrappedVaultKey, created.vaultId).isSuccess()) } } diff --git a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/presentation/BiometricEnrollmentAdapterImplTest.kt b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/presentation/BiometricEnrollmentAdapterImplTest.kt index 461b27f0e..0fc42cb81 100644 --- a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/presentation/BiometricEnrollmentAdapterImplTest.kt +++ b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/presentation/BiometricEnrollmentAdapterImplTest.kt @@ -5,27 +5,36 @@ import de.davis.keygo.core.identity.domain.model.Account import de.davis.keygo.core.identity.domain.model.BiometricEnrollmentError import de.davis.keygo.core.identity.domain.model.BiometricWrappedArk import de.davis.keygo.core.identity.domain.model.PasswordWrappedArk +import de.davis.keygo.core.security.FakeSession import de.davis.keygo.core.security.crypto.FakeBiometricCryptoController import de.davis.keygo.core.security.crypto.FakeKeyStoreManager -import de.davis.keygo.core.security.crypto.FakeSession import de.davis.keygo.core.security.domain.model.BiometricAuthError -import de.davis.keygo.core.security.domain.model.BiometricPolicy import de.davis.keygo.core.security.domain.model.CryptographicMode import de.davis.keygo.core.security.domain.model.KeyId +import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.isFailure import de.davis.keygo.core.util.isSuccess import kotlinx.coroutines.test.runTest import java.util.UUID +import javax.crypto.Cipher +import javax.crypto.KeyGenerator import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +/** + * Enrolment is one of only three places the ARK crosses into the JVM, because the Keystore cipher + * that seals the biometric copy only runs on this side of the FFI. The `finally` that zeroes the + * exported array is the sole thing keeping that copy from staying resident, so it is asserted + * directly here through [FakeSession], which hands out its array rather than a copy. + */ class BiometricEnrollmentAdapterImplTest { - private val session = FakeSession() + private val session = FakeSession(startUnlocked = true) private val accountRepository = FakeAccountRepository() private val keyStoreManager = FakeKeyStoreManager() private val controller = FakeBiometricCryptoController() @@ -36,38 +45,112 @@ class BiometricEnrollmentAdapterImplTest { keyStoreManager = keyStoreManager, ) - private fun seedUnenrolledAccount() { + private fun seedAccount(biometricWrappedArk: BiometricWrappedArk? = null) = accountRepository.seed( Account( id = UUID.randomUUID(), displayName = "Test", passwordWrappedArk = PasswordWrappedArk( - key = byteArrayOf(1), - keyIV = byteArrayOf(2), - salt = byteArrayOf(3), + key = ByteArray(48) { 1 }, + keyIV = ByteArray(12) { 2 }, + salt = ByteArray(16) { 3 }, ), - biometricWrappedArk = null, - ) + biometricWrappedArk = biometricWrappedArk, + ), ) - } private fun seedEnrolledAccount() { keyStoreManager.getOrCreateCipherFor(KeyId.BiometricVaultKek, CryptographicMode.Wrap) - accountRepository.seed( - Account( - id = UUID.randomUUID(), - displayName = "Test", - passwordWrappedArk = PasswordWrappedArk( - key = byteArrayOf(1), - keyIV = byteArrayOf(2), - salt = byteArrayOf(3), - ), - biometricWrappedArk = BiometricWrappedArk( - key = byteArrayOf(4), - keyIV = byteArrayOf(5), - ), - ) + seedAccount( + biometricWrappedArk = BiometricWrappedArk( + key = byteArrayOf(4), + keyIV = byteArrayOf(5), + ), + ) + } + + private fun wrappingCipher() = Cipher.getInstance("AES/GCM/NoPadding").apply { + init(Cipher.WRAP_MODE, KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + } + + private suspend fun enroll() = with(adapter) { controller.requestEnableBiometric() } + + @Test + fun `enrolling persists a biometric-wrapped ARK`() = runTest { + seedAccount() + controller.cipherResult = Result.Success(wrappingCipher()) + + val result = enroll() + + assertTrue(result.isSuccess()) + val wrapped = assertNotNull(accountRepository.getOrNull()?.biometricWrappedArk) + assertTrue(wrapped.key.isNotEmpty()) + assertTrue(wrapped.keyIV.isNotEmpty()) + } + + @Test + fun `wipes the exported ARK once it has been wrapped`() = runTest { + seedAccount() + controller.cipherResult = Result.Success(wrappingCipher()) + + enroll() + + assertContentEquals(ByteArray(32), session.onlyExported()) + } + + @Test + fun `wipes the exported ARK even when wrapping fails`() = runTest { + seedAccount() + // A cipher in the wrong mode makes Cipher.wrap throw, after the ARK has been exported. + val wrongMode = Cipher.getInstance("AES/GCM/NoPadding").apply { + init(Cipher.ENCRYPT_MODE, KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + } + controller.cipherResult = Result.Success(wrongMode) + + val result = enroll() + + assertTrue(result.isFailure()) + assertEquals(BiometricEnrollmentError.WrappingFailed, result.error) + assertContentEquals(ByteArray(32), session.onlyExported()) + } + + @Test + fun `a locked session reports NoActiveSession and never persists`() = runTest { + seedAccount() + controller.cipherResult = Result.Success(wrappingCipher()) + session.endSession() + + val result = enroll() + + assertTrue(result.isFailure()) + assertEquals(BiometricEnrollmentError.NoActiveSession, result.error) + assertNull(accountRepository.getOrNull()?.biometricWrappedArk) + } + + @Test + fun `no account reports NoActiveAccount without touching the session`() = runTest { + controller.cipherResult = Result.Success(wrappingCipher()) + + val result = enroll() + + assertTrue(result.isFailure()) + assertEquals(BiometricEnrollmentError.NoActiveAccount, result.error) + assertTrue(session.exported.isEmpty()) + } + + @Test + fun `a biometric failure is reported without exporting the ARK`() = runTest { + seedAccount() + controller.cipherResult = Result.Failure(BiometricAuthError.NoCipher) + + val result = enroll() + + assertTrue(result.isFailure()) + assertEquals( + BiometricEnrollmentError.BiometricFailed(BiometricAuthError.NoCipher), + result.error, ) + assertTrue(session.exported.isEmpty()) } @Test @@ -110,7 +193,7 @@ class BiometricEnrollmentAdapterImplTest { fun `a failed enrollment leaves the stored enrollment intact`() = runTest { seedEnrolledAccount() - val result = with(adapter) { controller.requestEnableBiometric(BiometricPolicy.Default) } + val result = enroll() assertTrue(result.isFailure()) assertEquals( @@ -128,12 +211,12 @@ class BiometricEnrollmentAdapterImplTest { */ @Test fun `enrolling from an unenrolled account drops the key left behind`() = runTest { - seedUnenrolledAccount() + seedAccount() keyStoreManager.getOrCreateCipherFor(KeyId.BiometricVaultKek, CryptographicMode.Wrap) // The prompt fails afterwards, so what is left on the keystore is what enrollment decided // to start from: nothing. - with(adapter) { controller.requestEnableBiometric(BiometricPolicy.Default) } + enroll() assertFalse(KeyId.BiometricVaultKek in keyStoreManager.keys) } @@ -145,7 +228,7 @@ class BiometricEnrollmentAdapterImplTest { // The prompt fails, as a user declining it would. The stored ARK is still wrapped under // this key, so taking it down here would strand an enrollment that works. - with(adapter) { controller.requestEnableBiometric(BiometricPolicy.Default) } + enroll() assertEquals(inUse, keyStoreManager.keys[KeyId.BiometricVaultKek]) assertNotNull(accountRepository.getOrNull()?.biometricWrappedArk) @@ -155,7 +238,7 @@ class BiometricEnrollmentAdapterImplTest { fun `enrolling without an account touches nothing`() = runTest { keyStoreManager.getOrCreateCipherFor(KeyId.BiometricVaultKek, CryptographicMode.Wrap) - val result = with(adapter) { controller.requestEnableBiometric(BiometricPolicy.Default) } + val result = enroll() assertTrue(result.isFailure()) assertEquals(BiometricEnrollmentError.NoActiveAccount, result.error) diff --git a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/presentation/BiometricUnlockAdapterImplTest.kt b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/presentation/BiometricUnlockAdapterImplTest.kt index b440d39d8..4f9fa9299 100644 --- a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/presentation/BiometricUnlockAdapterImplTest.kt +++ b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/presentation/BiometricUnlockAdapterImplTest.kt @@ -5,9 +5,9 @@ import de.davis.keygo.core.identity.domain.model.Account import de.davis.keygo.core.identity.domain.model.BiometricWrappedArk import de.davis.keygo.core.identity.domain.model.PasswordWrappedArk import de.davis.keygo.core.identity.domain.model.UnlockError +import de.davis.keygo.core.security.FakeSession import de.davis.keygo.core.security.crypto.FakeBiometricCryptoController import de.davis.keygo.core.security.crypto.FakeKeyStoreManager -import de.davis.keygo.core.security.crypto.FakeSession import de.davis.keygo.core.security.domain.model.BiometricAuthError import de.davis.keygo.core.security.domain.model.BiometricPolicy import de.davis.keygo.core.security.domain.model.CryptographicMode @@ -19,6 +19,7 @@ import kotlinx.coroutines.test.runTest import java.util.UUID import javax.crypto.spec.SecretKeySpec import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull @@ -44,6 +45,12 @@ class BiometricUnlockAdapterImplTest { biometricEnrollmentAdapter = enrollmentAdapter, ) + private fun adapterOver(session: FakeSession) = BiometricUnlockAdapterImpl( + session = session, + accountRepository = accountRepository, + biometricEnrollmentAdapter = enrollmentAdapter, + ) + private fun seedAccountWithBiometric() { keyStoreManager.getOrCreateCipherFor(KeyId.BiometricVaultKek, CryptographicMode.Wrap) accountRepository.seed( @@ -157,6 +164,55 @@ class BiometricUnlockAdapterImplTest { val result = with(adapter) { controller.requestUnlockVault(BiometricPolicy.Default) } assertTrue(result.isSuccess()) - assertTrue(session.startSessionCalled) + assertTrue(session.isActive.value) + } + + /** + * Unlocking is the inbound half of the two Keystore doors: the biometric cipher runs JVM-side, + * so the ARK exists here as a plain array before Rust takes custody of it. [FakeSession] keeps + * the array it was handed rather than copying, which is what makes the wipe observable. + * + * Note this covers only the copy this code owns. `SecretKeySpec.getEncoded` hands back a fresh + * copy each call, so JCA still holds one that no `fill(0)` here can reach. + */ + @Test + fun `wipes the recovered ARK once the session has taken it`() = runTest { + val recording = FakeSession() + seedAccountWithBiometric() + controller.unwrapResult = Result.Success(SecretKeySpec(ByteArray(32) { 1 }, "AES")) + + val result = with(adapterOver(recording)) { + controller.requestUnlockVault(BiometricPolicy.Default) + } + + assertTrue(result.isSuccess()) + assertContentEquals(ByteArray(32), recording.handedOver) + } + + @Test + fun `wipes the recovered ARK even when the session rejects it`() = runTest { + val recording = FakeSession().apply { failUnlock = true } + seedAccountWithBiometric() + controller.unwrapResult = Result.Success(SecretKeySpec(ByteArray(32) { 1 }, "AES")) + + val result = with(adapterOver(recording)) { + controller.requestUnlockVault(BiometricPolicy.Default) + } + + assertTrue(result.isFailure()) + assertContentEquals(ByteArray(32), recording.handedOver) } + + @Test + fun `returns UnwrappingFailed and stays locked when the recovered key is not an ARK`() = + runTest { + seedAccountWithBiometric() + controller.unwrapResult = Result.Success(SecretKeySpec(ByteArray(16) { 1 }, "AES")) + + val result = with(adapter) { controller.requestUnlockVault(BiometricPolicy.Default) } + + assertTrue(result.isFailure()) + assertEquals(UnlockError.UnwrappingFailed, result.error) + assertFalse(session.isActive.value) + } } 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 9c91ec390..8ba33bea5 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,30 +1,108 @@ package de.davis.keygo.core.security.data -import de.davis.keygo.core.security.domain.ArkHolder +import de.davis.keygo.core.security.domain.ExportArk import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.core.security.domain.SessionError +import de.davis.keygo.core.security.domain.toSessionError +import de.davis.keygo.core.util.Result +import de.davisalessandro.keygo.rust.ArkCredential +import de.davisalessandro.keygo.rust.ArkSessionException +import de.davisalessandro.keygo.rust.ArkSessionInterface +import de.davisalessandro.keygo.rust.NewAccount +import de.davisalessandro.keygo.rust.PasswordWrapped +import de.davisalessandro.keygo.rust.WrappedKeyBlob +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext import org.koin.core.annotation.Single +import java.util.UUID @Single -internal class SessionImpl : Session { - - private val holder = ArkHolder() - private val _isActive = MutableStateFlow(false) +internal class SessionImpl( + private val binding: ArkSessionInterface, +) : Session { + private val _isActive = MutableStateFlow(binding.isActive()) override val isActive: StateFlow = _isActive.asStateFlow() - override suspend fun withArk(block: suspend (ByteArray) -> R): R? = holder.withArk(block) + private val syncLock = Any() + + override suspend fun createAccount(password: String): Result = + derived { binding.createAccount(password) } + + override suspend fun unlockWithPassword( + password: String, + salt: ByteArray, + wrapped: WrappedKeyBlob, + userId: UUID, + ): Result = + derived { binding.unlockWithPassword(password, salt, wrapped, userId) } + + override suspend fun unlockWithArk(arkBytes: ByteArray): Result = + catching { binding.unlockWithArk(arkBytes) }.also { syncIsActive() } + + @ExportArk + override fun exportArk(): Result = catching { binding.exportArk() } + + override fun arkCredential(): ArkCredential = binding.arkCredential() + + override suspend fun verifyPassword( + password: String, + salt: ByteArray, + wrapped: WrappedKeyBlob, + userId: UUID, + ): Result = + derived { binding.verifyPassword(password, salt, wrapped, userId) } + + override fun verifyArk(arkBytes: ByteArray): Result = + catching { binding.verifyArk(arkBytes) } - override fun startSession(ark: ByteArray) { - holder.set(ark) - _isActive.value = true + override suspend fun rewrapForNewPassword( + newPassword: String, + userId: UUID, + ): Result = + derived { binding.rewrapForNewPassword(newPassword, userId) } + + override suspend fun wrapVaultKey( + vaultKey: ByteArray, + vaultId: UUID, + ): Result = withContext(Dispatchers.Default) { + catching { binding.wrapVaultKey(vaultKey, vaultId) } + } + + override suspend fun unwrapVaultKey( + wrapped: WrappedKeyBlob, + vaultId: UUID, + ): Result = withContext(Dispatchers.Default) { + catching { binding.unwrapVaultKey(wrapped, vaultId) } } - /** [isActive] goes false at once even with a block in flight: the gate never waits on it. */ override fun endSession() { - holder.clear() - _isActive.value = false + binding.end() + syncIsActive() + } + + private suspend fun derived(block: () -> R): Result = + withContext(Dispatchers.Default) { + try { + catching(block) + } finally { + syncIsActive() + } + } + + private fun syncIsActive() { + synchronized(syncLock) { + _isActive.update { isActive -> runCatching { binding.isActive() }.getOrDefault(isActive) } + } + } + + inline fun catching(block: () -> R): Result = try { + Result.Success(block()) + } catch (e: ArkSessionException) { + Result.Failure(e.toSessionError()) } } diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImpl.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImpl.kt index 318f688f0..1f716f436 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImpl.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImpl.kt @@ -4,12 +4,12 @@ import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.repository.ItemRepository import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.core.security.domain.SessionError import de.davis.keygo.core.security.domain.crypto.CryptographicScope import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformation import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation import de.davis.keygo.core.security.domain.model.CryptoScopeError -import de.davis.keygo.core.security.domain.withArkOr import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.mapFailure import de.davis.keygo.core.util.mapSuccess @@ -17,9 +17,9 @@ import de.davis.keygo.core.util.resultBinding import de.davis.keygo.rust.item.ItemManager import de.davis.keygo.rust.wrap.KeyWrapper import de.davis.keygo.rust.wrap.unwrapItemKeyWithResult -import de.davis.keygo.rust.wrap.unwrapVaultKeyWithResult import de.davis.keygo.rust.wrap.wrapItemKeyWithResult import de.davisalessandro.keygo.rust.ItemAad +import de.davisalessandro.keygo.rust.KeyWrapException import de.davisalessandro.keygo.rust.WrappedKeyBlob import org.koin.core.annotation.Single @@ -113,13 +113,17 @@ internal class CryptographicScopeProviderImpl( } private suspend fun unwrapVaultKeyWithResult(info: WrappedVaultKeyInformation) = - session.withArkOr(CryptoScopeError.NoActiveSession) { ark -> - keyWrapper.unwrapVaultKeyWithResult( - ark = ark, - wrapped = info.wrappedVaultKey.toWrappedKeyBlob(), - vaultId = info.vaultId, - ).mapFailure(CryptoScopeError::KeyWrapError) - } + session.unwrapVaultKey( + wrapped = info.wrappedVaultKey.toWrappedKeyBlob(), + vaultId = info.vaultId, + ).mapFailure { it.toCryptoScopeError() } +} + +private fun SessionError.toCryptoScopeError(): CryptoScopeError = when (this) { + SessionError.Locked -> CryptoScopeError.NoActiveSession + is SessionError.KeyWrap -> CryptoScopeError.KeyWrapError(cause) + SessionError.WrongPassword, is SessionError.Derivation -> + CryptoScopeError.KeyWrapError(KeyWrapException.UnwrapFailed()) } private fun KeyInformation.toWrappedKeyBlob() = WrappedKeyBlob( diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/di/CoreSecurityModule.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/di/CoreSecurityModule.kt index 449e6058d..2d3f5c2ff 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/di/CoreSecurityModule.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/di/CoreSecurityModule.kt @@ -2,9 +2,13 @@ package de.davis.keygo.core.security.di import android.content.Context import androidx.datastore.dataStore +import de.davis.keygo.core.security.data.SessionImpl import de.davis.keygo.core.security.data.local.model.ProtoLockInfo import de.davis.keygo.core.security.di.annotation.LockInfoQualifier +import de.davis.keygo.core.security.domain.SessionFactory import de.davis.keygo.core.util.data.serializer.DefaultProtoSerializer +import de.davisalessandro.keygo.rust.ArkSession +import de.davisalessandro.keygo.rust.ArkSessionInterface import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Configuration import org.koin.core.annotation.Module @@ -27,4 +31,15 @@ object CoreSecurityModule { @LockInfoQualifier internal fun provideLockInfoDataStore(context: Context) = context.protoLockInfoDataStore + + @Single + internal fun provideArkSession(): ArkSessionInterface = ArkSession() + + /** + * Sessions that are not the app-wide one, each over its own Rust session. Backup opens its + * escrowed ARK in one of these, so that key never reaches the session the rest of the app reads. + */ + @Single + internal fun provideSessionFactory(): SessionFactory = + SessionFactory { SessionImpl(ArkSession()) } } diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/ArkHolder.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/ArkHolder.kt deleted file mode 100644 index 783ba4135..000000000 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/ArkHolder.kt +++ /dev/null @@ -1,44 +0,0 @@ -package de.davis.keygo.core.security.domain - -class ArkHolder { - - private val lock = Any() - - private class Generation(val ark: ByteArray) { - var readers = 0 - var wipe = false - } - - private var current: Generation? = null - - suspend fun withArk(block: suspend (ByteArray) -> R): R? { - val generation = synchronized(lock) { - val gen = current ?: return null - gen.readers++ - gen - } - - try { - return block(generation.ark) - } finally { - synchronized(lock) { - generation.readers-- - if (generation.readers == 0 && generation.wipe) generation.ark.fill(0) - } - } - } - - fun set(ark: ByteArray) = replace(ark) - - fun clear() = replace(null) - - private fun replace(next: ByteArray?) { - synchronized(lock) { - current?.let { retiring -> - retiring.wipe = true - if (retiring.readers == 0) retiring.ark.fill(0) - } - current = next?.let { Generation(it) } - } - } -} 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 3c1e6b51b..8a6a6e380 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,29 +1,91 @@ package de.davis.keygo.core.security.domain import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.fold +import de.davis.keygo.core.util.resultBinding +import de.davisalessandro.keygo.rust.ArkCredential +import de.davisalessandro.keygo.rust.ArkSessionException +import de.davisalessandro.keygo.rust.NewAccount +import de.davisalessandro.keygo.rust.PasswordWrapped +import de.davisalessandro.keygo.rust.WrappedKeyBlob import kotlinx.coroutines.flow.StateFlow +import java.util.UUID + +@RequiresOptIn("This API must be used with caution! Callers should wipe the returned ARK. Call `useArk` instead to ensure that the ARK is zeroed after use.") +@Retention(AnnotationRetention.BINARY) +annotation class ExportArk interface Session { - /** Observable lock state, for callers that have to react to a session ending rather than read it. */ val isActive: StateFlow - /** - * Runs [block] with the live ARK, or returns `null` without running it when locked. Null is the - * ordinary locked branch every caller handles. - * - * The ARK is wiped in place when a session ends, so the array is only valid inside [block] - - * copy what has to outlive it. The bytes stay intact for the whole of [block] however long it - * suspends, even if the session ends underneath. - */ - suspend fun withArk(block: suspend (ByteArray) -> R): R? - - fun startSession(ark: ByteArray) + suspend fun createAccount(password: String): Result + + suspend fun unlockWithPassword( + password: String, + salt: ByteArray, + wrapped: WrappedKeyBlob, + userId: UUID, + ): Result + + suspend fun unlockWithArk(arkBytes: ByteArray): Result + + @ExportArk + fun exportArk(): Result + + fun arkCredential(): ArkCredential + + suspend fun verifyPassword( + password: String, + salt: ByteArray, + wrapped: WrappedKeyBlob, + userId: UUID, + ): Result + + fun verifyArk(arkBytes: ByteArray): Result + + suspend fun rewrapForNewPassword( + newPassword: String, + userId: UUID, + ): Result + + suspend fun wrapVaultKey( + vaultKey: ByteArray, + vaultId: UUID, + ): Result + + suspend fun unwrapVaultKey( + wrapped: WrappedKeyBlob, + vaultId: UUID, + ): Result + fun endSession() } -/** [Session.withArk] for callers in [Result]: a locked session becomes [locked], not a null. */ -suspend fun Session.withArkOr( - locked: E, - block: suspend (ByteArray) -> Result, -): Result = withArk(block) ?: Result.Failure(locked) +/** + * Deliberately plain control flow, no [resultBinding]: [block] is caller-supplied and often binds + * its own, unrelated error type. Using [resultBinding] here would let a caller's `.bind()` - even + * though it resolves correctly to their own outer scope - throw through this function's own catch + * on its way out, matching the wrong error type. [fold] can't make that mistake: there is no shared + * exception type to catch. + */ +@OptIn(ExportArk::class) +inline fun Session.useArk(block: (ByteArray) -> T): Result = + exportArk().fold( + onSuccess = { ark -> + try { + Result.Success(block(ark)) + } finally { + ark.fill(0) + } + }, + onFailure = { Result.Failure(it) }, + ) + +@PublishedApi +internal fun ArkSessionException.toSessionError(): SessionError = when (this) { + is ArkSessionException.Locked -> SessionError.Locked + is ArkSessionException.WrongPassword -> SessionError.WrongPassword + is ArkSessionException.Derivation -> SessionError.Derivation(v1) + is ArkSessionException.KeyWrap -> SessionError.KeyWrap(v1) +} diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/SessionError.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/SessionError.kt new file mode 100644 index 000000000..a1fbc98ac --- /dev/null +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/SessionError.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.core.security.domain + +import de.davisalessandro.keygo.rust.KeyWrapException + +sealed interface SessionError { + + data object Locked : SessionError + data object WrongPassword : SessionError + data class Derivation(val message: String) : SessionError + data class KeyWrap(val cause: KeyWrapException) : SessionError +} diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/SessionFactory.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/SessionFactory.kt new file mode 100644 index 000000000..c9d2d001e --- /dev/null +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/SessionFactory.kt @@ -0,0 +1,5 @@ +package de.davis.keygo.core.security.domain + +fun interface SessionFactory { + fun create(): Session +} diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProviderFactory.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProviderFactory.kt index 5dc7d0cda..0351ee33c 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProviderFactory.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProviderFactory.kt @@ -3,8 +3,8 @@ package de.davis.keygo.core.security.domain.crypto import de.davis.keygo.core.security.domain.Session /** - * Builds a [CryptographicScopeProvider] bound to a specific [Session]. The default binding uses the - * app-wide session; backup uses this to run against a recovered ARK without mutating global state. + * Builds a [CryptographicScopeProvider] bound to a specific [Session]. The default binding uses + * the app-wide session; backup uses this to run against a recovered ARK without mutating global state. */ fun interface CryptographicScopeProviderFactory { fun forSession(session: Session): CryptographicScopeProvider diff --git a/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/CryptographicScopeImplTest.kt b/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/CryptographicScopeImplTest.kt index a83da6d26..7e9def141 100644 --- a/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/CryptographicScopeImplTest.kt +++ b/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/CryptographicScopeImplTest.kt @@ -2,12 +2,14 @@ package de.davis.keygo.core.security.crypto import de.davis.keygo.core.item.FakeItemRepository import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.security.FakeSession import de.davis.keygo.core.security.data.crypto.CryptographicScopeProviderImpl import de.davis.keygo.core.security.domain.crypto.model.CryptographicData 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.assertFailure import de.davis.keygo.core.util.assertSuccess +import de.davis.keygo.core.util.getOrNull import de.davis.keygo.rust.FakeItemManager import de.davis.keygo.rust.FakeKeyWrapper import de.davisalessandro.keygo.rust.ItemAad @@ -28,7 +30,7 @@ class CryptographicScopeImplTest { private val random = Random(42) - private val session = FakeSession(startOnConstruct = true) + private val session = FakeSession(startUnlocked = true) private val itemRepository = FakeItemRepository() private val itemManager = FakeItemManager() private val keyWrapper = FakeKeyWrapper() @@ -38,13 +40,14 @@ class CryptographicScopeImplTest { private val label = "password" - private fun wrappedVaultKeyInformation( + private suspend fun wrappedVaultKeyInformation( vaultId: UUID = UUID.randomUUID(), ): WrappedVaultKeyInformation { - val blob = keyWrapper.wrapVaultKey( - ark = assertNotNull(session.currentArk), - vaultKey = ByteArray(32) { random.nextBytes(1)[0] }, - vaultId = vaultId, + val blob = checkNotNull( + session.wrapVaultKey( + vaultKey = ByteArray(32) { random.nextBytes(1)[0] }, + vaultId = vaultId, + ).getOrNull(), ) return WrappedVaultKeyInformation( wrappedVaultKey = KeyInformation( diff --git a/core/security/src/test/kotlin/de/davis/keygo/core/security/data/SessionArkLifetimeTest.kt b/core/security/src/test/kotlin/de/davis/keygo/core/security/data/SessionArkLifetimeTest.kt deleted file mode 100644 index 587d92c88..000000000 --- a/core/security/src/test/kotlin/de/davis/keygo/core/security/data/SessionArkLifetimeTest.kt +++ /dev/null @@ -1,140 +0,0 @@ -package de.davis.keygo.core.security.data - -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertContentEquals -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -@OptIn(ExperimentalCoroutinesApi::class) -class SessionArkLifetimeTest { - - private val session = SessionImpl() - - private fun generateArk(): ByteArray = ByteArray(32) { (it + 1).toByte() } - - private class HeldArk( - val ark: ByteArray, - private val job: Job, - private val resume: CompletableDeferred, - ) { - - suspend fun finish() { - resume.complete(Unit) - job.join() - } - } - - private fun TestScope.holdArk(): HeldArk { - val resume = CompletableDeferred() - val handedOut = CompletableDeferred() - - val job = launch { - session.withArk { ark -> - handedOut.complete(ark) - resume.await() - } - } - advanceUntilIdle() - - return HeldArk(handedOut.getCompleted(), job, resume) - } - - @Test - fun `a session ending does not zero an ark a suspended block still holds`() = runTest { - // The defect this file exists for: CreateVaultUseCase and FinishExportWizardUseCase read - // the ark, suspend, then use it. Zeroing under them persists a key wrapped with zeros. - val expected = generateArk() - session.startSession(expected.copyOf()) - - val held = holdArk() - session.endSession() - - assertContentEquals(expected, held.ark, "wiped while the block was still holding it") - held.finish() - } - - @Test - fun `the ark is zeroed once the last in-flight block finishes`() = runTest { - // Deferring the wipe must not cancel it: the ark still has to leave memory. - session.startSession(generateArk()) - - val held = holdArk() - session.endSession() - held.finish() - - assertTrue(held.ark.all { it == 0.toByte() }, "never wiped after the block finished") - } - - @Test - fun `the session reports itself ended at once even with a block in flight`() = runTest { - // The UI gate keys on isActive, so it must not wait for crypto to drain. - session.startSession(generateArk()) - - val held = holdArk() - session.endSession() - - assertEquals(false, session.isActive.value) - held.finish() - } - - @Test - fun `an ark left over from a replaced session is still zeroed`() = runTest { - // The old ark must not be forgotten in favour of the new one. - val first = generateArk() - session.startSession(first) - - val held = holdArk() - session.endSession() - session.startSession(generateArk()) - session.endSession() - held.finish() - - assertTrue(first.all { it == 0.toByte() }, "the replaced ark was never wiped") - } - - @Test - fun `a still-held ark is wiped once its own last reader finishes, not blocked by a newer generation's readers`() = - runTest { - // The defect this test guards: a reader count shared across generations meant an - // overlapping reader on a newer ark could keep an older, already-replaced one resident - // well past when its own last reader was done with it. - val first = generateArk() - session.startSession(first) - - val heldFirst = holdArk() - session.endSession() - session.startSession(generateArk()) - - val heldSecond = holdArk() - heldFirst.finish() - - assertTrue( - first.all { it == 0.toByte() }, - "old generation not wiped once its own last reader finished" - ) - heldSecond.finish() - } - - @Test - fun `a block that throws still releases the ark for wiping`() = runTest { - session.startSession(generateArk()) - val handedOut = CompletableDeferred() - - runCatching { - session.withArk { ark -> - handedOut.complete(ark) - error("boom") - } - } - session.endSession() - - assertTrue(handedOut.await().all { it == 0.toByte() }) - } -} 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 deleted file mode 100644 index 2d7977c26..000000000 --- a/core/security/src/test/kotlin/de/davis/keygo/core/security/data/SessionImplTest.kt +++ /dev/null @@ -1,106 +0,0 @@ -package de.davis.keygo.core.security.data - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertContentEquals -import kotlin.test.assertEquals -import kotlin.test.assertNull -import kotlin.test.assertSame - -class SessionImplTest { - - private val session = SessionImpl() - - private fun generateArk(): ByteArray = ByteArray(32) { it.toByte() } - - @Test - fun `no ark is handed out when there is no active session`() = runTest { - assertNull(session.withArk { it }) - } - - @Test - fun `isActive is false when no active session`() { - assertEquals(false, session.isActive.value) - } - - @Test - fun `isActive is true after startSession`() { - session.startSession(generateArk()) - assertEquals(true, session.isActive.value) - } - - @Test - fun `isActive is false after endSession`() { - session.startSession(generateArk()) - session.endSession() - assertEquals(false, session.isActive.value) - } - - @Test - fun `startSession makes the ark available`() = runTest { - val key = generateArk() - session.startSession(key) - assertSame(key, session.withArk { it }) - } - - @Test - fun `endSession clears the ark`() = runTest { - session.startSession(generateArk()) - session.endSession() - - assertNull(session.withArk { it }) - } - - @Test - fun `startSession replaces previous session`() = runTest { - val key1 = generateArk() - val key2 = generateArk() - - session.startSession(key1) - session.startSession(key2) - - assertSame(key2, session.withArk { it }) - } - - @Test - fun `startSession wipes the ark it replaces`() { - val replaced = generateArk() - session.startSession(replaced) - session.startSession(generateArk()) - - assertContentEquals(ByteArray(32), replaced) - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun `startSession does not pulse isActive when replacing an already-active session`() = - runTest(UnconfinedTestDispatcher()) { - // A swap is not a lock. The app gate locks on any false it observes and only a - // successful unlock takes it back down, so a pulse here would make replacing a live - // session cost a re-auth. Unconfined so a collector that could see the edge does. - session.startSession(generateArk()) - - val collected = mutableListOf() - val job = launch { session.isActive.collect { collected.add(it) } } - - session.startSession(generateArk()) - - job.cancel() - assertEquals(listOf(true), collected) - } - - @Test - fun `endSession is safe to call without active session`() { - session.endSession() // should not throw - } - - @Test - fun `endSession is safe to call multiple times`() { - session.startSession(generateArk()) - session.endSession() - session.endSession() // should not throw - } -} diff --git a/core/security/src/test/kotlin/de/davis/keygo/core/security/data/SessionLockObserverTest.kt b/core/security/src/test/kotlin/de/davis/keygo/core/security/data/SessionLockObserverTest.kt index 1ad7a1f11..e79a44289 100644 --- a/core/security/src/test/kotlin/de/davis/keygo/core/security/data/SessionLockObserverTest.kt +++ b/core/security/src/test/kotlin/de/davis/keygo/core/security/data/SessionLockObserverTest.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import de.davis.keygo.core.security.FakeLockInfoRepository +import de.davis.keygo.core.security.FakeSession import de.davis.keygo.core.security.data.time.SessionClockImpl import de.davis.keygo.core.security.domain.model.LockInfo import de.davis.keygo.core.security.domain.repository.LockInfoRepository @@ -13,6 +14,7 @@ import de.davis.keygo.core.security.time.FakeElapsedTimeProvider import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.runner.RunWith @@ -28,7 +30,7 @@ import kotlin.test.assertEquals internal class SessionLockObserverTest { private val context = RuntimeEnvironment.getApplication() - private val session = SessionImpl().apply { startSession(ByteArray(32) { it.toByte() }) } + private val session = FakeSession(startUnlocked = true) private val time = FakeElapsedTimeProvider() private val handoff = SystemHandoffImpl() private val clock = SessionClockImpl(time) @@ -274,7 +276,7 @@ internal class SessionLockObserverTest { time.advanceBy(fiveMinutes * 2) observer.onStart(owner) - session.startSession(ByteArray(32) { it.toByte() }) + runBlocking { session.unlockWithArk(ByteArray(32) { it.toByte() }) } observer.onStart(owner) assertEquals(true, session.isActive.value) diff --git a/core/security/src/test/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImplTest.kt b/core/security/src/test/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImplTest.kt index 117a32eb0..bb3289760 100644 --- a/core/security/src/test/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImplTest.kt +++ b/core/security/src/test/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImplTest.kt @@ -4,7 +4,8 @@ import de.davis.keygo.core.item.FakeItemRepository import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.item.domain.alias.newVaultId import de.davis.keygo.core.item.domain.model.KeyInformation -import de.davis.keygo.core.security.data.SessionImpl +import de.davis.keygo.core.security.FakeSession +import de.davis.keygo.core.security.domain.SessionError import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformation import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation import de.davis.keygo.core.security.domain.model.CryptoScopeError @@ -13,14 +14,16 @@ import de.davis.keygo.core.util.isFailure import de.davis.keygo.rust.FakeItemManager import de.davis.keygo.rust.FakeKeyWrapper import de.davisalessandro.keygo.rust.ItemAad +import de.davisalessandro.keygo.rust.KeyWrapException import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertIs +import kotlin.test.assertSame import kotlin.test.assertTrue class CryptographicScopeProviderImplTest { - private val session = SessionImpl() + private val session = FakeSession() private val provider = CryptographicScopeProviderImpl( session = session, itemRepository = FakeItemRepository(), @@ -61,7 +64,7 @@ class CryptographicScopeProviderImplTest { val vaultId = newVaultId() val itemId = newItemId() - session.startSession(ByteArray(32) { it.toByte() }) + session.unlockWithArk(ByteArray(32) { it.toByte() }) session.endSession() val result = provider.itemScope( @@ -73,4 +76,23 @@ class CryptographicScopeProviderImplTest { val failure = assertIs>(result) assertIs(failure.error) } + + @Test + fun `itemScope keeps the key-wrap cause the session reported`() = runTest { + val vaultId = newVaultId() + val itemId = newItemId() + val cause = KeyWrapException.InvalidKeyLength(expected = 32uL, got = 7uL) + + session.unlockWithArk(ByteArray(32) { it.toByte() }) + session.unwrapVaultKeyFailure = SessionError.KeyWrap(cause) + + val result = provider.itemScope( + wrappedVaultKeyInformation = wrappedVaultKeyInformation(vaultId), + wrappedItemKeyInformation = wrappedItemKeyInformation(itemId, vaultId), + ) { } + + val failure = assertIs>(result) + val error = assertIs(failure.error) + assertSame(cause, error.exception) + } } diff --git a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/FakeSession.kt b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/FakeSession.kt new file mode 100644 index 000000000..626b78b6a --- /dev/null +++ b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/FakeSession.kt @@ -0,0 +1,223 @@ +package de.davis.keygo.core.security + +import de.davis.keygo.core.security.domain.ExportArk +import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.core.security.domain.SessionError +import de.davis.keygo.core.util.Result +import de.davisalessandro.keygo.rust.ArkCredential +import de.davisalessandro.keygo.rust.KeyWrapException +import de.davisalessandro.keygo.rust.NewAccount +import de.davisalessandro.keygo.rust.NoHandle +import de.davisalessandro.keygo.rust.PasswordWrapped +import de.davisalessandro.keygo.rust.WrappedKeyBlob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.security.MessageDigest +import java.util.UUID +import kotlin.random.Random + +class FakeSession(startUnlocked: Boolean = false) : Session { + + var failDerivation: Boolean = false + var failUnlock: Boolean = false + + var unwrapVaultKeyFailure: SessionError? = null + + var handedOver: ByteArray? = null + private set + + val exported: MutableList = mutableListOf() + + fun onlyExported(): ByteArray = exported.singleOrNull() + ?: error("expected exactly one exportArk call, got ${exported.size}") + + private val random = Random(SEED) + + private var ark: ByteArray? = if (startUnlocked) randomBytes(32) else null + + private val _isActive = MutableStateFlow(ark != null) + override val isActive: StateFlow = _isActive.asStateFlow() + + override suspend fun createAccount(password: String): Result { + if (failDerivation) return Result.Failure(SessionError.Derivation("forced")) + + val ark = randomBytes(32) + val vaultKey = randomBytes(32) + val userId = randomUUID() + val vaultId = randomUUID() + val salt = randomBytes(16) + + this.ark = ark + _isActive.value = true + + return Result.Success( + NewAccount( + userId = userId, + salt = salt, + passwordWrappedArk = wrap(kek(password, salt), ark, userId), + vaultId = vaultId, + wrappedVaultKey = wrap(ark, vaultKey, vaultId), + ), + ) + } + + override suspend fun unlockWithPassword( + password: String, + salt: ByteArray, + wrapped: WrappedKeyBlob, + userId: UUID, + ): Result { + if (failDerivation) return Result.Failure(SessionError.Derivation("forced")) + + val recovered = unwrap(kek(password, salt), wrapped, userId) + ?: return Result.Failure(SessionError.KeyWrap(KeyWrapException.UnwrapFailed())) + + ark = recovered + _isActive.value = true + return Result.Success(Unit) + } + + override suspend fun unlockWithArk(arkBytes: ByteArray): Result { + handedOver = arkBytes + if (failUnlock) return Result.Failure(SessionError.Locked) + if (arkBytes.size != 32) { + val cause = KeyWrapException.InvalidKeyLength(32uL, arkBytes.size.toULong()) + return Result.Failure(SessionError.KeyWrap(cause)) + } + + ark = arkBytes.copyOf() + _isActive.value = true + return Result.Success(Unit) + } + + @ExportArk + override fun exportArk(): Result { + val active = ark ?: return Result.Failure(SessionError.Locked) + return Result.Success(active.copyOf().also { exported += it }) + } + + override fun arkCredential(): ArkCredential = FakeArkCredential(this) + + override suspend fun verifyPassword( + password: String, + salt: ByteArray, + wrapped: WrappedKeyBlob, + userId: UUID, + ): Result { + val active = ark ?: return Result.Failure(SessionError.Locked) + if (failDerivation) return Result.Failure(SessionError.Derivation("forced")) + + // Like Rust: the blob has to open to the ARK this session holds, not merely open. + val stored = unwrap(kek(password, salt), wrapped, userId) + return if (stored?.contentEquals(active) == true) Result.Success(Unit) + else Result.Failure(SessionError.WrongPassword) + } + + override fun verifyArk(arkBytes: ByteArray): Result { + val active = ark ?: return Result.Failure(SessionError.Locked) + return Result.Success(active.contentEquals(arkBytes)) + } + + override suspend fun rewrapForNewPassword( + newPassword: String, + userId: UUID, + ): Result { + val active = ark ?: return Result.Failure(SessionError.Locked) + if (failDerivation) return Result.Failure(SessionError.Derivation("forced")) + + val salt = randomBytes(16) + return Result.Success( + PasswordWrapped( + salt = salt, + wrapped = wrap(kek(newPassword, salt), active, userId), + ), + ) + } + + override suspend fun wrapVaultKey( + vaultKey: ByteArray, + vaultId: UUID, + ): Result { + val active = ark ?: return Result.Failure(SessionError.Locked) + return Result.Success(wrap(active, vaultKey, vaultId)) + } + + override suspend fun unwrapVaultKey( + wrapped: WrappedKeyBlob, + vaultId: UUID, + ): Result { + unwrapVaultKeyFailure?.let { return Result.Failure(it) } + val active = ark ?: return Result.Failure(SessionError.Locked) + val recovered = unwrap(active, wrapped, vaultId) + ?: return Result.Failure(SessionError.KeyWrap(KeyWrapException.UnwrapFailed())) + return Result.Success(recovered) + } + + override fun endSession() { + ark = null + _isActive.value = false + } + + private fun kek(password: String, salt: ByteArray): ByteArray = + MessageDigest.getInstance("SHA-256").digest(password.toByteArray() + salt) + + /** Wraps [innerKey] under (outerKey, id). The nonce and a short tag ride together in [WrappedKeyBlob.nonce]. */ + private fun wrap(outerKey: ByteArray, innerKey: ByteArray, id: UUID): WrappedKeyBlob { + val nonce = randomBytes(NONCE_SIZE) + val ciphertext = xorStream(innerKey, outerKey, id, nonce) + return WrappedKeyBlob( + ciphertext = ciphertext, + nonce = nonce + tagFor(outerKey, id, nonce, innerKey), + ) + } + + /** Inverts [wrap], or returns null when [wrapped] was not sealed under this (outerKey, id). */ + private fun unwrap(outerKey: ByteArray, wrapped: WrappedKeyBlob, id: UUID): ByteArray? { + if (wrapped.nonce.size < NONCE_SIZE + TAG_SIZE) return null + + val nonce = wrapped.nonce.copyOfRange(0, NONCE_SIZE) + val tag = wrapped.nonce.copyOfRange(NONCE_SIZE, wrapped.nonce.size) + val candidate = xorStream(wrapped.ciphertext, outerKey, id, nonce) + + return candidate.takeIf { tagFor(outerKey, id, nonce, it).contentEquals(tag) } + } + + /** A short, non-cryptographic integrity tag: enough to reject a wrong key or id in tests. */ + private fun tagFor( + outerKey: ByteArray, + id: UUID, + nonce: ByteArray, + innerKey: ByteArray, + ): ByteArray = + MessageDigest.getInstance("SHA-256") + .digest(outerKey + id.toString().toByteArray() + nonce + innerKey) + .copyOf(TAG_SIZE) + + private fun xorStream( + data: ByteArray, + outerKey: ByteArray, + id: UUID, + nonce: ByteArray, + ): ByteArray { + val idBytes = id.toString().toByteArray() + return ByteArray(data.size) { i -> + val mask = outerKey[i % outerKey.size].toInt() xor + idBytes[i % idBytes.size].toInt() xor + nonce[i % nonce.size].toInt() + (data[i].toInt() xor mask).toByte() + } + } + + private fun randomBytes(size: Int): ByteArray = random.nextBytes(size) + + private fun randomUUID(): UUID = UUID(random.nextLong(), random.nextLong()) + + private companion object { + const val SEED = 42L + const val NONCE_SIZE = 12 + const val TAG_SIZE = 8 + } +} + +class FakeArkCredential(val session: FakeSession) : ArkCredential(NoHandle) diff --git a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/FakeSessionFactory.kt b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/FakeSessionFactory.kt new file mode 100644 index 000000000..7d22fab74 --- /dev/null +++ b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/FakeSessionFactory.kt @@ -0,0 +1,17 @@ +package de.davis.keygo.core.security + +import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.core.security.domain.SessionFactory + +class FakeSessionFactory : SessionFactory { + + var failUnlock: Boolean = false + + val created: MutableList = mutableListOf() + + override fun create(): Session = + FakeSession().also { + it.failUnlock = failUnlock + created += it + } +} diff --git a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeBiometricCryptoController.kt b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeBiometricCryptoController.kt index 388c784ce..3724ef6fe 100644 --- a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeBiometricCryptoController.kt +++ b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeBiometricCryptoController.kt @@ -14,11 +14,14 @@ class FakeBiometricCryptoController : BiometricCryptoController { var unwrapResult: Result = Result.Failure(BiometricAuthError.NoCipher) + var cipherResult: Result = + Result.Failure(BiometricAuthError.NoCipher) + override suspend fun requestCipher( keyId: KeyId, mode: CryptographicMode, policy: BiometricPolicy, - ): Result = Result.Failure(BiometricAuthError.NoCipher) + ): Result = cipherResult override suspend fun requestUnwrap( keyId: KeyId, 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 deleted file mode 100644 index aa5671cca..000000000 --- a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeSession.kt +++ /dev/null @@ -1,54 +0,0 @@ -package de.davis.keygo.core.security.crypto - -import de.davis.keygo.core.security.domain.ArkHolder -import de.davis.keygo.core.security.domain.Session -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.runBlocking - -/** - * A fake [Session] with a fixed ARK. Shares [ArkHolder] with the real one, so it wipes the same - * way - a fake that skipped the wipe would hide use-after-wipe bugs from every test. - */ -class FakeSession( - startOnConstruct: Boolean = false -) : Session { - - var startSessionCalled = false - - private val holder = ArkHolder() - private val _isActive = MutableStateFlow(false) - - /** - * The live ARK as a copy, for assertions. Null once the session has ended. Goes through - * [ArkHolder.withArk] like any other reader - `runBlocking` only bridges the suspend call for - * a synchronous test property, it does not bypass the reader accounting the way a raw peek - * would. - */ - val currentArk: ByteArray? - get() = runBlocking { holder.withArk { it.copyOf() } } - - override val isActive: StateFlow = _isActive.asStateFlow() - - init { - // Constructing pre-unlocked is not a startSession call. - if (startOnConstruct) { - startSession(ByteArray(32) { it.toByte() }) - startSessionCalled = false - } - } - - override suspend fun withArk(block: suspend (ByteArray) -> R): R? = holder.withArk(block) - - override fun startSession(ark: ByteArray) { - holder.set(ark) - _isActive.value = true - startSessionCalled = true - } - - override fun endSession() { - holder.clear() - _isActive.value = false - } -} 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 e0bda80de..df9ad21c5 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 @@ -10,8 +10,8 @@ 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.FakeSession import de.davis.keygo.core.security.crypto.FakeBiometricAvailabilityRepository -import de.davis.keygo.core.security.crypto.FakeSession import de.davis.keygo.core.security.domain.model.BiometricAuthError import de.davis.keygo.core.ui.model.UiFieldError import de.davis.keygo.feature.auth.presentation.model.AuthState @@ -25,9 +25,6 @@ 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 @@ -71,16 +68,10 @@ class AuthViewModelTest { private val vaultRepository = FakeVaultRepository() private val vaultContextRepository = FakeVaultContextRepository() private val session = FakeSession() - private val keyDeriver = FakeKeyDeriver() - private val keyWrapper = FakeKeyWrapper() - private val accountManager = FakeAccountManager() private val biometricAvailability = FakeBiometricAvailabilityRepository() private val mainPasswordRepository = FakeMainPasswordRepository() private val createAllAccesses = CreateAccessUseCase( - keyDeriver = keyDeriver, - keyWrapper = keyWrapper, - accountManager = accountManager, accountRepository = accountRepository, vaultRepository = vaultRepository, vaultContextRepository = vaultContextRepository, @@ -90,8 +81,6 @@ class AuthViewModelTest { private val unlockWithPassword = UnlockWithPasswordUseCase( session = session, accountRepository = accountRepository, - keyDeriver = keyDeriver, - keyWrapper = keyWrapper, ) // Real use cases, wired to mainPasswordRepository via factories - HasMainPasswordUseCase and diff --git a/feature/backup/build.gradle.kts b/feature/backup/build.gradle.kts index e517e0b68..73614e6fb 100644 --- a/feature/backup/build.gradle.kts +++ b/feature/backup/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { implementation(projects.feature.item.core) implementation(projects.feature.vault) + testImplementation(testFixtures(projects.core.util)) testImplementation(testFixtures(projects.core.item)) testImplementation(testFixtures(projects.core.security)) testImplementation(testFixtures(projects.rust)) 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 deleted file mode 100644 index a7d9a4f71..000000000 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupSession.kt +++ /dev/null @@ -1,22 +0,0 @@ -package de.davis.keygo.feature.backup.data - -import de.davis.keygo.core.security.domain.Session -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -/** - * A read-only [Session] holding a recovered ARK for the duration of a single backup. It never - * mutates app-wide session state; [startSession] is unsupported and [endSession] is a no-op. - */ -internal class BackupSession(private val backupArk: ByteArray) : Session { - - override val isActive: StateFlow = MutableStateFlow(true) - - /** Always runs [block]: the ARK was already recovered, and whoever recovered it wipes it. */ - override suspend fun withArk(block: suspend (ByteArray) -> R): R? = block(backupArk) - - override fun startSession(ark: ByteArray) = - error("BackupSession is read-only") - - override fun endSession() = Unit -} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupArkUnlocker.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupArkUnlocker.kt index 999078163..65ba61f3e 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupArkUnlocker.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupArkUnlocker.kt @@ -1,66 +1,61 @@ package de.davis.keygo.feature.backup.domain -import de.davis.keygo.core.item.domain.repository.VaultRepository import de.davis.keygo.core.security.domain.KeyStoreManager import de.davis.keygo.core.security.domain.Session -import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProviderFactory +import de.davis.keygo.core.security.domain.SessionFactory import de.davis.keygo.core.security.domain.crypto.suspendDoFinal import de.davis.keygo.core.security.domain.model.CryptographicMode import de.davis.keygo.core.security.domain.model.KeyId -import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase 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.feature.backup.data.BackupSession import de.davis.keygo.feature.backup.domain.model.ExportError import de.davis.keygo.feature.backup.domain.model.exportError import de.davis.keygo.feature.backup.domain.repository.BackupArkKeyStore import org.koin.core.annotation.Single /** - * Resolves the crypto scope for a backup. Prefers the live [Session]; when locked, silently recovers - * the ARK copy via the non-auth [KeyId.BackupArkKey] and binds the scope to a throwaway - * [BackupSession]. The global session is never touched. + * Resolves the session a backup runs under. Prefers the live [Session]; when locked, silently + * recovers the ARK copy via the non-auth [KeyId.BackupArkKey] and hands it to a throwaway session + * from [SessionFactory]. + * + * The app-wide session is never touched. The escrowed ARK is readable with no user present, so + * unlocking the app-wide session with it would open the whole app, and every feature reading that + * session, for as long as the backup ran. Ending it afterwards would also end a session the user + * unlocked in the meantime. */ @Single internal class BackupArkUnlocker( private val session: Session, + private val sessionFactory: SessionFactory, private val keyStoreManager: KeyStoreManager, private val arkKeyStore: BackupArkKeyStore, - private val scopeProviderFactory: CryptographicScopeProviderFactory, - private val vaultRepository: VaultRepository, ) { /** - * Runs [block] with the ARK for this backup. A recovered ARK is zeroed afterwards; a live - * session's ARK is the app's own key, left for the session to wipe. + * Runs [block] with a session holding the ARK for this backup: the live one when unlocked, + * otherwise a throwaway holding a copy recovered from escrow. The throwaway is ended and the + * recovered bytes are zeroed afterwards; a live session is left alone, since its ARK is the + * app's own key. */ - suspend fun withArk(block: suspend (ByteArray) -> R): Result { - session.withArk { ark -> Result.Success(block(ark)) }?.let { return it } + suspend fun withSession(block: suspend (Session) -> R): Result { + if (session.isActive.value) return Result.Success(block(session)) return resultBinding { val ark = recoverArk().bind() try { - block(ark) - } finally { - ark.fill(0) - } - } - } - - /** Runs [block] with a crypto scope bound to the live session, or to a throwaway - * [BackupSession] holding a recovered ARK that is zeroed afterwards. */ - suspend fun withScope( - block: suspend (ItemWithCryptoScopeUseCase) -> R, - ): Result { - // The ark itself goes unused: this is the liveness check that prefers the live session. - session.withArk { Result.Success(block(scopeFor(session))) } - ?.let { return it } - - return resultBinding { - val ark = recoverArk().bind() - try { - block(scopeFor(BackupSession(ark))) + // Creating the session sits inside the wipe guard: it can throw, and the recovered + // ARK is already in hand by then. Ending it has its own guard, so a session is + // never left holding a key because the block below failed. + val recovered = sessionFactory.create() + try { + // Nothing else can lock a session this fresh, so a rejection is the escrowed + // bytes themselves. No retry can fix that; it would only hold the escrow open. + recovered.unlockWithArk(ark).bind { ExportError.CryptoFailed } + block(recovered) + } finally { + recovered.endSession() + } } finally { ark.fill(0) } @@ -79,7 +74,4 @@ internal class BackupArkUnlocker( cipher.suspendDoFinal(wrapped.data).bind { ExportError.DeviceLocked } } - - private fun scopeFor(session: Session): ItemWithCryptoScopeUseCase = - ItemWithCryptoScopeUseCase(vaultRepository, scopeProviderFactory.forSession(session)) } diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupCollector.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupCollector.kt index f9734349e..d94289662 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupCollector.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupCollector.kt @@ -8,14 +8,20 @@ import de.davis.keygo.core.item.domain.repository.CreditCardRepository import de.davis.keygo.core.item.domain.repository.LoginRepository import de.davis.keygo.core.item.domain.repository.PasskeyRepository import de.davis.keygo.core.item.domain.repository.VaultRepository +import de.davis.keygo.core.security.domain.Session import de.davis.keygo.core.security.domain.crypto.CryptographicScope -import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProviderFactory +import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation +import de.davis.keygo.core.security.domain.crypto.wrappedItemKeyInformation import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.ResultBinding import de.davis.keygo.core.util.asResult import de.davis.keygo.core.util.resultBinding import de.davis.keygo.feature.backup.domain.mapper.toBackupCard import de.davis.keygo.feature.backup.domain.mapper.toBackupIcon import de.davis.keygo.feature.backup.domain.mapper.toBackupLogin +import de.davis.keygo.feature.backup.domain.mapper.toExportError import de.davis.keygo.feature.backup.domain.model.CollectedBackup import de.davis.keygo.feature.backup.domain.model.ExportError import de.davisalessandro.keygo.rust.Backup @@ -34,7 +40,7 @@ internal class BackupCollector( private val loginRepository: LoginRepository, private val creditCardRepository: CreditCardRepository, private val passkeyRepository: PasskeyRepository, - private val arkUnlocker: BackupArkUnlocker, + private val scopeProviderFactory: CryptographicScopeProviderFactory, ) { private data class VaultItems( @@ -45,14 +51,28 @@ internal class BackupCollector( val items get() = logins.size + cards.size } - suspend fun collect( - onProgress: suspend (processed: Int, total: Int) -> Unit, - ): Result = resultBinding { - arkUnlocker.withScope { scope -> collectWith(scope, onProgress).bind() }.bind() + private class ItemExporter( + private val scopeProvider: CryptographicScopeProvider, + private val total: Int, + private val onProgress: suspend (processed: Int, total: Int) -> Unit, + ) { + private var processed = 0 + private val progressMutex = Mutex() + + context(binder: ResultBinding) + suspend fun export( + item: I, + vaultKey: WrappedVaultKeyInformation, + map: suspend CryptographicScope.(I) -> R, + ): R = with(binder) { + scopeProvider.itemScope(vaultKey, item.wrappedItemKeyInformation()) { map(item) } + .bind { it.toExportError() } + .also { progressMutex.withLock { onProgress(++processed, total) } } + } } - private suspend fun collectWith( - scope: ItemWithCryptoScopeUseCase, + suspend fun collect( + session: Session, onProgress: suspend (processed: Int, total: Int) -> Unit, ): Result = resultBinding { val perVault = coroutineScope { @@ -72,22 +92,30 @@ internal class BackupCollector( val total = perVault.sumOf { it.items } (total > 0).asResult(ExportError.NothingToExport).bind() - var processed = 0 - val progressMutex = Mutex() - suspend fun I.export(map: suspend CryptographicScope.(I) -> R): R = - scope.withItem(this, map) - .bind { ExportError.CryptoFailed } - .also { progressMutex.withLock { onProgress(++processed, total) } } + val exporter = ItemExporter( + scopeProvider = scopeProviderFactory.forSession(session), + total = total, + onProgress = onProgress, + ) val backupVaults = perVault.map { (meta, logins, cards) -> + // Every item here was fetched by this vault's id, so one lookup serves all of them. + val vaultKey = WrappedVaultKeyInformation( + wrappedVaultKey = vaultRepository.getKeyInformation(meta.vaultId) + .asResult(ExportError.CryptoFailed).bind(), + vaultId = meta.vaultId, + ) + val (exportedLogins, exportedCards) = coroutineScope { val loginResults = logins.map { login -> async { val passkeys = passkeyRepository.getPasskeysByLogin(login.id) - login.export { it.toBackupLogin(passkeys) } + exporter.export(login, vaultKey) { it.toBackupLogin(passkeys) } } } - val cardResults = cards.map { card -> async { card.export { it.toBackupCard() } } } + val cardResults = cards.map { card -> + async { exporter.export(card, vaultKey) { it.toBackupCard() } } + } loginResults.awaitAll() to cardResults.awaitAll() } diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ExportErrorMappers.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ExportErrorMappers.kt new file mode 100644 index 000000000..b637035f3 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ExportErrorMappers.kt @@ -0,0 +1,14 @@ +package de.davis.keygo.feature.backup.domain.mapper + +import de.davis.keygo.core.security.domain.model.CryptoScopeError +import de.davis.keygo.feature.backup.domain.model.ExportError +import de.davisalessandro.keygo.rust.BackupException + +internal fun BackupException.toExportError(): ExportError = when (this) { + is BackupException.Locked -> ExportError.SessionLocked + else -> ExportError.SerializationFailed(this) +} + +internal fun CryptoScopeError.toExportError(): ExportError = + if (this == CryptoScopeError.NoActiveSession) ExportError.SessionLocked + else ExportError.CryptoFailed diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ImportErrorMappers.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ImportErrorMappers.kt index 53e61a003..1d188e560 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ImportErrorMappers.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ImportErrorMappers.kt @@ -4,6 +4,8 @@ import de.davis.keygo.feature.backup.domain.model.ImportError import de.davisalessandro.keygo.rust.BackupException internal fun BackupException.toImportError(): ImportError = when (this) { + is BackupException.Locked -> ImportError.SessionLocked + is BackupException.Crypto, is BackupException.CredentialMismatch -> ImportError.WrongCredential diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ExportBackupUseCase.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ExportBackupUseCase.kt index b05504e72..8fc930c18 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ExportBackupUseCase.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ExportBackupUseCase.kt @@ -1,6 +1,7 @@ package de.davis.keygo.feature.backup.domain.usecase import de.davis.keygo.core.security.domain.KeyStoreManager +import de.davis.keygo.core.security.domain.Session import de.davis.keygo.core.security.domain.crypto.suspendDoFinal import de.davis.keygo.core.security.domain.model.CryptographicMode import de.davis.keygo.core.security.domain.model.KeyId @@ -13,6 +14,7 @@ import de.davis.keygo.core.util.resultBinding import de.davis.keygo.feature.backup.domain.BackupArkUnlocker import de.davis.keygo.feature.backup.domain.BackupCollector import de.davis.keygo.feature.backup.domain.BackupFileStore +import de.davis.keygo.feature.backup.domain.mapper.toExportError import de.davis.keygo.feature.backup.domain.mapper.toRust import de.davis.keygo.feature.backup.domain.model.BACKUP_BASE_NAME import de.davis.keygo.feature.backup.domain.model.BackupEntry @@ -46,18 +48,25 @@ internal class ExportBackupUseCase( operator fun invoke(job: BackupJob): Flow = channelFlow { resultBinding { - val collected = collector.collect { p, t -> send(ExportProgress.Running(p, t)) }.bind() + // Collecting and sealing share one session: on a locked device, one escrow recovery and + // one throwaway session per run rather than one for each step. It ends before the + // write, which needs no key. + val (itemCount, serialized) = arkUnlocker.withSession { session -> + val collected = collector + .collect(session) { p, t -> send(ExportProgress.Running(p, t)) } + .bind() - send(ExportProgress.Writing) + send(ExportProgress.Writing) - val serialized = serialize(job, collected.backup).bind() + collected.itemCount to serialize(job, collected.backup, session).bind() + }.bind() val fileName = job.format.backupFileName(System.currentTimeMillis()) fileStore.writeNewDocument(job.uri, fileName, job.format.mimeType, serialized) .bind { ExportError.WriteFailed } - collected.itemCount + itemCount }.onSuccess { count -> prune(job) send(ExportProgress.Succeeded(count)) @@ -89,14 +98,17 @@ internal class ExportBackupUseCase( ?.removeSuffix(".${format.extension}") ?.toLongOrNull() - private suspend fun serialize(job: BackupJob, backup: Backup): Result = + private suspend fun serialize( + job: BackupJob, + backup: Backup, + session: Session, + ): Result = resultBinding { when (job.format) { FileFormat.JSON -> when (job.encryption) { - EncryptionMethod.Ark -> arkUnlocker.withArk { ark -> - jsonBackupManager.exportWithResult(backup, BackupCredential.Ark(ark)) - .bindToSerializationFailed() - }.bind() + EncryptionMethod.Ark -> jsonBackupManager + .exportWithResult(backup, BackupCredential.Ark(session.arkCredential())) + .bindToSerializationFailed() // null on a persisted pre-field job means passphrase (see mapper). EncryptionMethod.Passphrase, null -> { @@ -120,7 +132,7 @@ internal class ExportBackupUseCase( context(binder: ResultBinding) private fun Result.bindToSerializationFailed(): String = - with(binder) { bind { ExportError.SerializationFailed(it) } } + with(binder) { bind { it.toExportError() } } private suspend fun decryptPassphrase(job: BackupJob): Result = resultBinding { diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/FinishExportWizardUseCase.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/FinishExportWizardUseCase.kt index c9ed7ddcd..b6b0328a4 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/FinishExportWizardUseCase.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/FinishExportWizardUseCase.kt @@ -6,7 +6,7 @@ import de.davis.keygo.core.security.domain.crypto.model.CryptographicData import de.davis.keygo.core.security.domain.crypto.suspendDoFinal import de.davis.keygo.core.security.domain.model.CryptographicMode import de.davis.keygo.core.security.domain.model.KeyId -import de.davis.keygo.core.security.domain.withArkOr +import de.davis.keygo.core.security.domain.useArk import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.asResult import de.davis.keygo.core.util.mapFailure @@ -111,7 +111,7 @@ class FinishExportWizardUseCase( } private suspend fun provisionBackupArk() = resultBinding { - val escrowed = session.withArkOr(FinishExportWizardError.CryptoFailed) { ark -> + val escrowed = session.useArk { ark -> val cipher = keyStoreManager.getOrCreateCipherFor( keyId = KeyId.BackupArkKey, cryptographicMode = CryptographicMode.Encrypt, @@ -120,7 +120,8 @@ class FinishExportWizardUseCase( cipher.suspendDoFinal(ark) .mapSuccess { CryptographicData(it, cipher.iv) } .mapFailure { FinishExportWizardError.CryptoFailed } - }.bind() + .bind() + }.bind { FinishExportWizardError.CryptoFailed } arkKeyStore.save(escrowed) } diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ImportBackupUseCase.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ImportBackupUseCase.kt index 49b69b03e..f6537f2af 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ImportBackupUseCase.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ImportBackupUseCase.kt @@ -1,7 +1,6 @@ package de.davis.keygo.feature.backup.domain.usecase import de.davis.keygo.core.security.domain.Session -import de.davis.keygo.core.security.domain.withArkOr import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.fold import de.davis.keygo.core.util.mapFailure @@ -85,9 +84,11 @@ internal class ImportBackupUseCase( } } - JsonEncryption.ARK -> session.withArkOr(ImportError.SessionLocked) { ark -> - importJson(text, BackupCredential.Ark(ark)) - }.bind() + JsonEncryption.ARK -> { + if (!session.isActive.value) + Result.Failure(ImportError.SessionLocked).bind() + importJson(text, BackupCredential.Ark(session.arkCredential())).bind() + } } FileFormat.CSV -> { diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/RestorerTestEnv.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/RestorerTestEnv.kt index 6c0c5cf17..ab8b99711 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/RestorerTestEnv.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/RestorerTestEnv.kt @@ -8,14 +8,13 @@ import de.davis.keygo.core.item.FakeTransactionRunner import de.davis.keygo.core.item.FakeVaultContextRepository import de.davis.keygo.core.item.FakeVaultRepository import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase +import de.davis.keygo.core.security.FakeSession import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider -import de.davis.keygo.core.security.crypto.FakeSession import de.davis.keygo.feature.backup.domain.BackupRestorer import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateCreditCardUseCase import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateLoginUseCase import de.davis.keygo.feature.vault.domain.usecase.CreateVaultUseCase import de.davis.keygo.rust.FakeCardFormatter -import de.davis.keygo.rust.FakeKeyWrapper import de.davis.keygo.rust.FakeTotpService import de.davis.keygo.rust.FakeVaultManager @@ -46,8 +45,7 @@ internal class RestorerTestEnv { vaultRepository = vaultRepo, vaultContextRepository = FakeVaultContextRepository(), vaultManager = FakeVaultManager(), - keyWrapper = FakeKeyWrapper(), - session = FakeSession(startOnConstruct = true), + session = FakeSession(startUnlocked = true), ) val restorer = BackupRestorer( diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupArkUnlockerTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupArkUnlockerTest.kt index ea80c2537..fd29f863f 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupArkUnlockerTest.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupArkUnlockerTest.kt @@ -1,19 +1,21 @@ +@file:OptIn(ExportArk::class) + package de.davis.keygo.feature.backup.domain -import de.davis.keygo.core.item.FakeItemRepository -import de.davis.keygo.core.item.FakeVaultRepository -import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider -import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProviderFactory +import de.davis.keygo.core.security.FakeSession +import de.davis.keygo.core.security.FakeSessionFactory import de.davis.keygo.core.security.crypto.FakeKeyStoreManager -import de.davis.keygo.core.security.crypto.FakeSession +import de.davis.keygo.core.security.domain.ExportArk +import de.davis.keygo.core.security.domain.Session import de.davis.keygo.core.security.domain.crypto.model.CryptographicData import de.davis.keygo.core.security.domain.model.CryptographicMode import de.davis.keygo.core.security.domain.model.KeyId import de.davis.keygo.core.security.domain.model.KeyStoreManagerError -import de.davis.keygo.core.util.Result +import de.davis.keygo.core.security.domain.useArk +import de.davis.keygo.core.util.assertFailure +import de.davis.keygo.core.util.assertSuccess import de.davis.keygo.core.util.getOrNull import de.davis.keygo.feature.backup.FakeBackupArkKeyStore -import de.davis.keygo.feature.backup.data.BackupSession import de.davis.keygo.feature.backup.domain.model.ExportError import de.davis.keygo.feature.backup.domain.model.retryable import kotlinx.coroutines.test.runTest @@ -21,28 +23,31 @@ import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertIs import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertSame import kotlin.test.assertTrue class BackupArkUnlockerTest { - private val vaultRepo = FakeVaultRepository() private val keyStore = FakeKeyStoreManager() private val arkStore = FakeBackupArkKeyStore() - private val factory = FakeCryptographicScopeProviderFactory( - FakeCryptographicScopeProvider(FakeItemRepository()), - ) + private val sessionFactory = FakeSessionFactory() - private fun unlocker(session: FakeSession) = BackupArkUnlocker( + private fun unlocker(session: Session) = BackupArkUnlocker( session = session, + sessionFactory = sessionFactory, keyStoreManager = keyStore, arkKeyStore = arkStore, - scopeProviderFactory = factory, - vaultRepository = vaultRepo, ) - private suspend fun provision(ark: ByteArray) { + private fun unlocked() = FakeSession(startUnlocked = true) + + private fun locked() = FakeSession() + + private fun throwaway(): FakeSession = sessionFactory.created.single() + + private suspend fun provision(ark: ByteArray = ByteArray(32) { (it + 1).toByte() }) { val cipher = assertNotNull( keyStore .getOrCreateCipherFor(KeyId.BackupArkKey, CryptographicMode.Encrypt) @@ -52,41 +57,78 @@ class BackupArkUnlockerTest { } @Test - fun `unlocked session builds a scope on the live session`() = runTest { - val session = FakeSession(startOnConstruct = true) - val result = unlocker(session).withScope { } - assertIs>(result) - assertEquals(session, factory.lastSession) + fun `withSession hands over the live session itself`() = runTest { + val session = unlocked() + + unlocker(session).withSession { assertSame(session, it) }.assertSuccess() + + assertTrue(sessionFactory.created.isEmpty()) } @Test - fun `locked and unprovisioned fails with NotProvisioned`() = runTest { - val result = unlocker(FakeSession(startOnConstruct = false)).withScope { } - assertEquals(Result.Failure(ExportError.NotProvisioned), result) + fun `a live session is left holding its own ark`() = runTest { + // Ending the live session, or wiping its ARK, would be wiping the app's own session key. + val session = unlocked() + val before = assertNotNull(session.exportArk().getOrNull()) + + unlocker(session).withSession { }.assertSuccess() + + assertTrue(session.isActive.value) + assertContentEquals(before, session.exportArk().getOrNull()) } @Test - fun `locked but provisioned recovers the ARK into a BackupSession`() = runTest { - val ark = ByteArray(32) { (it + 1).toByte() } - provision(ark) - - // The recovered ARK is zeroed once the block returns, so assert on it from inside. - val result = unlocker(FakeSession(startOnConstruct = false)).withScope { - val used = factory.lastSession - assertIs(used) - used.withArk { assertContentEquals(ark, it) } + fun `withSession recovers the provisioned ark into a throwaway session when locked`() = + runTest { + val ark = ByteArray(32) { (it + 1).toByte() } + provision(ark) + + unlocker(locked()).withSession { + assertSame(throwaway(), it) + it.useArk { sessionArk -> assertContentEquals(ark, sessionArk) }.assertSuccess() + }.assertSuccess() } - assertIs>(result) + @Test + fun `the app-wide session stays locked while a backup runs on the escrowed ark`() = runTest { + provision() + val session = locked() + + unlocker(session).withSession { + assertNotSame(session, it) + assertFalse(session.isActive.value) + }.assertSuccess() + + assertFalse(session.isActive.value) + } + + @Test + fun `a user unlocking during a backup keeps their session when it finishes`() = runTest { + provision() + val session = locked() + val userArk = ByteArray(32) { (it + 50).toByte() } + + unlocker(session).withSession { + session.unlockWithArk(userArk.copyOf()).assertSuccess() + }.assertSuccess() + + assertTrue(session.isActive.value) + assertContentEquals(userArk, session.exportArk().getOrNull()) + } + + @Test + fun `withSession fails with NotProvisioned when locked and no ark copy exists`() = runTest { + val result = unlocker(locked()).withSession { }.assertFailure() + assertEquals(ExportError.NotProvisioned, result) } @Test fun `locked provisioned but device locked fails with DeviceLocked`() = runTest { - provision(ByteArray(32) { it.toByte() }) + provision() keyStore.deviceLocked = true - val result = unlocker(FakeSession(startOnConstruct = false)).withScope { } - assertEquals(Result.Failure(ExportError.DeviceLocked), result) + val result = unlocker(locked()).withSession { }.assertFailure() + assertEquals(ExportError.DeviceLocked, result) } /** @@ -96,84 +138,61 @@ class BackupArkUnlockerTest { */ @Test fun `locked provisioned but key permanently invalidated fails terminally`() = runTest { - provision(ByteArray(32) { it.toByte() }) + provision() keyStore.failure = KeyStoreManagerError.KeyInvalidated - val result = unlocker(FakeSession(startOnConstruct = false)).withScope { } + val result = unlocker(locked()).withSession { }.assertFailure() - assertEquals(Result.Failure(ExportError.CryptoFailed), result) - assertFalse(ExportError.CryptoFailed.retryable) + assertEquals(ExportError.CryptoFailed, result) + assertFalse(result.retryable) } @Test - fun `withArk hands over the live session ark`() = runTest { - val session = FakeSession(startOnConstruct = true) - val expected = assertNotNull(session.currentArk) + fun `an escrowed ark the session rejects fails as CryptoFailed, not a retry`() = runTest { + provision(ByteArray(16) { (it + 1).toByte() }) - val result = unlocker(session).withArk { assertContentEquals(expected, it) } + val result = unlocker(locked()).withSession { }.assertFailure() - assertIs>(result) + assertEquals(ExportError.CryptoFailed, result) + assertFalse(result.retryable) } @Test - fun `withArk recovers the provisioned ark when locked`() = runTest { - val ark = ByteArray(32) { (it + 1).toByte() } - provision(ark) + fun `the recovered ark is zeroed after use`() = runTest { + provision() - val result = unlocker(FakeSession(startOnConstruct = false)).withArk { - assertContentEquals(ark, it) + unlocker(locked()).withSession { + assertTrue(assertNotNull(throwaway().handedOver).any { byte -> byte != 0.toByte() }) } - assertIs>(result) + assertTrue(assertNotNull(throwaway().handedOver).all { it == 0.toByte() }) } @Test - fun `withArk fails with NotProvisioned when locked and no ark copy exists`() = runTest { - val result = unlocker(FakeSession(startOnConstruct = false)).withArk { } + fun `the throwaway session is ended after use`() = runTest { + provision() - val failure = assertIs>(result) - assertEquals(ExportError.NotProvisioned, failure.error) - } - - @Test - fun `a recovered ark is zeroed after use`() = runTest { - provision(ByteArray(32) { (it + 1).toByte() }) + unlocker(locked()).withSession { }.assertSuccess() - var seen: ByteArray? = null - unlocker(FakeSession(startOnConstruct = false)).withArk { ark -> - seen = ark - assertTrue(ark.any { it != 0.toByte() }) - } - - assertTrue(assertNotNull(seen).all { it == 0.toByte() }) + assertFalse(throwaway().isActive.value) } @Test - fun `a recovered ark is zeroed after use in withScope`() = runTest { - val ark = ByteArray(32) { (it + 1).toByte() } - provision(ark) - - val result = unlocker(FakeSession(startOnConstruct = false)).withScope { - val used = factory.lastSession - assertIs(used) - used.withArk { assertContentEquals(ark, it) } - } + fun `the throwaway session is ended when the block throws`() = runTest { + provision() - assertIs>(result) + runCatching { unlocker(locked()).withSession { error("boom") } } - val used = factory.lastSession - assertIs(used) - used.withArk { recovered -> assertTrue(recovered.all { it == 0.toByte() }) } + assertFalse(throwaway().isActive.value) } @Test - fun `a live session ark is left intact`() = runTest { - // FakeSession seeds ByteArray(32) { it.toByte() } - zeroing it would be zeroing the app's - // own session key. - val session = FakeSession(startOnConstruct = true) + fun `the recovered ark is zeroed when unlocking the session fails`() = runTest { + provision() + sessionFactory.failUnlock = true - unlocker(session).withArk { } + unlocker(locked()).withSession { }.assertFailure() - assertTrue(assertNotNull(session.currentArk).any { it != 0.toByte() }) + assertTrue(assertNotNull(throwaway().handedOver).all { it == 0.toByte() }) } } diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupCollectorTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupCollectorTest.kt index b0d600acc..815a9fbc3 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupCollectorTest.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupCollectorTest.kt @@ -5,32 +5,37 @@ import de.davis.keygo.core.item.FakeItemRepository import de.davis.keygo.core.item.FakeLoginRepository import de.davis.keygo.core.item.FakePasskeyRepository import de.davis.keygo.core.item.FakeVaultRepository +import de.davis.keygo.core.item.domain.alias.VaultId import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.repository.VaultRepository import de.davis.keygo.core.item.passkeyRef +import de.davis.keygo.core.security.FakeSession import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProviderFactory -import de.davis.keygo.core.security.crypto.FakeKeyStoreManager -import de.davis.keygo.core.security.crypto.FakeSession +import de.davis.keygo.core.security.domain.model.CryptoScopeError import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.getOrNull -import de.davis.keygo.feature.backup.FakeBackupArkKeyStore import de.davis.keygo.feature.backup.domain.model.CollectedBackup import de.davis.keygo.feature.backup.domain.model.ExportError +import de.davis.keygo.feature.backup.domain.model.retryable import de.davis.keygo.feature.backup.testCard import de.davis.keygo.feature.backup.testLogin import de.davis.keygo.feature.backup.testPasskey import de.davis.keygo.feature.backup.testVault +import de.davisalessandro.keygo.rust.KeyWrapException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import java.time.YearMonth import java.util.concurrent.CopyOnWriteArrayList import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNotNull +import kotlin.test.assertSame import kotlin.test.assertTrue -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.runTest class BackupCollectorTest { @@ -38,30 +43,21 @@ class BackupCollectorTest { private val loginRepo = FakeLoginRepository() private val cardRepo = FakeCreditCardRepository() private val passkeyRepo = FakePasskeyRepository() - private val factory = FakeCryptographicScopeProviderFactory( - FakeCryptographicScopeProvider(FakeItemRepository()), - ) + private val scopeProvider = FakeCryptographicScopeProvider(FakeItemRepository()) + private val factory = FakeCryptographicScopeProviderFactory(scopeProvider) + private val session = FakeSession(startUnlocked = true) - private fun collector( - session: FakeSession = FakeSession(startOnConstruct = true), - unlockerVaultRepo: FakeVaultRepository = vaultRepo, - ) = BackupCollector( - vaultRepository = vaultRepo, + private fun collector(vaultRepository: VaultRepository = vaultRepo) = BackupCollector( + vaultRepository = vaultRepository, loginRepository = loginRepo, creditCardRepository = cardRepo, passkeyRepository = passkeyRepo, - arkUnlocker = BackupArkUnlocker( - session = session, - keyStoreManager = FakeKeyStoreManager(), - arkKeyStore = FakeBackupArkKeyStore(), - scopeProviderFactory = factory, - vaultRepository = unlockerVaultRepo, - ), + scopeProviderFactory = factory, ) @Test fun `empty database fails with NothingToExport`() = runTest { - val result = collector().collect { _, _ -> } + val result = collector().collect(session) { _, _ -> } assertIs>(result) assertEquals(ExportError.NothingToExport, result.error) } @@ -83,7 +79,7 @@ class BackupCollectorTest { ) ) - val result = collector().collect { _, _ -> } + val result = collector().collect(session) { _, _ -> } val collected: CollectedBackup = assertNotNull(result.getOrNull()) val login = collected.backup.vaults.single().logins.single() assertEquals("Email", login.title) @@ -108,7 +104,7 @@ class BackupCollectorTest { ) ) - val result = collector().collect { _, _ -> } + val result = collector().collect(session) { _, _ -> } val login = assertNotNull(result.getOrNull()).backup.vaults.single().logins.single() assertEquals( @@ -133,7 +129,7 @@ class BackupCollectorTest { ) ) - val result = collector().collect { _, _ -> } + val result = collector().collect(session) { _, _ -> } val collected: CollectedBackup = assertNotNull(result.getOrNull()) val card = collected.backup.vaults.single().cards.single() assertEquals("Visa", card.title) @@ -154,7 +150,7 @@ class BackupCollectorTest { cardRepo.seed(testCard(vaultId = b.id, name = "InB", number = "4111111111111111")) val collected: CollectedBackup = - assertNotNull(collector().collect { _, _ -> }.getOrNull()) + assertNotNull(collector().collect(session) { _, _ -> }.getOrNull()) val byName = collected.backup.vaults.associateBy { it.name } assertEquals(listOf("InA"), byName.getValue("A").logins.map { it.title }) @@ -171,7 +167,7 @@ class BackupCollectorTest { loginRepo.seed(testLogin(vaultId = personal.id, name = "InPersonal")) val collected: CollectedBackup = - assertNotNull(collector().collect { _, _ -> }.getOrNull()) + assertNotNull(collector().collect(session) { _, _ -> }.getOrNull()) assertEquals( mapOf("Work" to "Business", "Personal" to "Home"), @@ -189,7 +185,7 @@ class BackupCollectorTest { ) val seen = mutableListOf>() - val result = collector().collect { processed, total -> seen += processed to total } + val result = collector().collect(session) { processed, total -> seen += processed to total } assertIs>(result) assertEquals(listOf(1 to 2, 2 to 2), seen) @@ -213,7 +209,7 @@ class BackupCollectorTest { repeat(1000) { val seen = CopyOnWriteArrayList() - val result = collector().collect { processed, _ -> seen += processed } + val result = collector().collect(session) { processed, _ -> seen += processed } assertIs>(result) assertEquals((1..total).toList(), seen.toList()) @@ -221,33 +217,58 @@ class BackupCollectorTest { } @Test - fun `crypto scope failure surfaces CryptoFailed`() = runTest { + fun `items are decrypted under the session the caller hands in`() = runTest { val vault = testVault(name = "V") vaultRepo.seed(vault) loginRepo.seed(testLogin(vaultId = vault.id, name = "Email")) - // The crypto-scope use case is given an empty vault repo, so it cannot find the - // vault key and fails to build a scope - the collector maps any such failure to CryptoFailed. - val result = collector(unlockerVaultRepo = FakeVaultRepository()).collect { _, _ -> } + collector().collect(session) { _, _ -> } + + assertSame(session, factory.lastSession) + } + + @Test + fun `a crypto scope failure surfaces CryptoFailed`() = runTest { + val vault = testVault(name = "V") + vaultRepo.seed(vault) + loginRepo.seed(testLogin(vaultId = vault.id, name = "Email")) + scopeProvider.itemScopeFailure = + CryptoScopeError.KeyWrapError(KeyWrapException.UnwrapFailed()) + + val result = collector().collect(session) { _, _ -> } assertIs>(result) assertEquals(ExportError.CryptoFailed, result.error) } @Test - fun `locked and unprovisioned session fails with NotProvisioned before reporting progress`() = - runTest { - val vault = testVault(name = "V") - vaultRepo.seed(vault) - loginRepo.seed(testLogin(vaultId = vault.id, name = "Email")) + fun `a vault whose key cannot be found surfaces CryptoFailed`() = runTest { + val vault = testVault(name = "V") + vaultRepo.seed(vault) + loginRepo.seed(testLogin(vaultId = vault.id, name = "Email")) + val keyless = object : VaultRepository by vaultRepo { + override suspend fun getKeyInformation(vaultId: VaultId): KeyInformation? = null + } - val seen = mutableListOf>() - val result = collector(session = FakeSession(startOnConstruct = false)) - .collect { processed, total -> seen += processed to total } + val result = collector(vaultRepository = keyless).collect(session) { _, _ -> } - assertEquals(Result.Failure(ExportError.NotProvisioned), result) - assertTrue(seen.isEmpty()) - } + assertIs>(result) + assertEquals(ExportError.CryptoFailed, result.error) + } + + @Test + fun `a session ending during collection surfaces the retryable SessionLocked`() = runTest { + val vault = testVault(name = "V") + vaultRepo.seed(vault) + loginRepo.seed(testLogin(vaultId = vault.id, name = "Email")) + scopeProvider.itemScopeFailure = CryptoScopeError.NoActiveSession + + val result = collector().collect(session) { _, _ -> } + + assertIs>(result) + assertEquals(ExportError.SessionLocked, result.error) + assertTrue(result.error.retryable) + } @Test fun `a login's passkeys are collected and decrypted`() = runTest { @@ -267,7 +288,7 @@ class BackupCollectorTest { testPasskey(loginId = loginId, rp = "example.org", privateKey = "pk-two"), ) - val result = collector().collect { _, _ -> } + val result = collector().collect(session) { _, _ -> } val login = assertNotNull(result.getOrNull()).backup.vaults.single().logins.single() assertEquals(listOf("example.com", "example.org"), login.passkeys.map { it.rp }) @@ -281,7 +302,7 @@ class BackupCollectorTest { vaultRepo.seed(vault) loginRepo.seed(testLogin(vaultId = vault.id, name = "Email", password = "s3cr3t")) - val result = collector().collect { _, _ -> } + val result = collector().collect(session) { _, _ -> } val login = assertNotNull(result.getOrNull()).backup.vaults.single().logins.single() assertTrue(login.passkeys.isEmpty()) @@ -296,7 +317,7 @@ class BackupCollectorTest { loginRepo.seed(testLogin(vaultId = vault.id, id = loginId, name = "Email")) passkeyRepo.seed(testPasskey(loginId = loginId, rp = "example.com", privateKey = "pk-one")) - val result = collector().collect { _, _ -> } + val result = collector().collect(session) { _, _ -> } val login = assertNotNull(result.getOrNull()).backup.vaults.single().logins.single() assertEquals(listOf("example.com"), login.passkeys.map { it.rp }) diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/BackupProvisioningSerializationTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/BackupProvisioningSerializationTest.kt index b8d7f6f0a..1a7bdd575 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/BackupProvisioningSerializationTest.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/BackupProvisioningSerializationTest.kt @@ -1,7 +1,7 @@ package de.davis.keygo.feature.backup.domain.usecase +import de.davis.keygo.core.security.FakeSession import de.davis.keygo.core.security.crypto.FakeKeyStoreManager -import de.davis.keygo.core.security.crypto.FakeSession import de.davis.keygo.core.security.domain.crypto.model.CryptographicData import de.davis.keygo.core.security.domain.model.KeyId import de.davis.keygo.feature.backup.FakeBackupArkKeyStore @@ -39,7 +39,7 @@ class BackupProvisioningSerializationTest { FakeBackupArkKeyStore(CryptographicData(byteArrayOf(7), byteArrayOf(8))) private val keyStoreManager = FakeKeyStoreManager() private val uriManager = FakePersistableUriManager() - private val session = FakeSession(startOnConstruct = true) + private val session = FakeSession(startUnlocked = true) private val lock = BackupProvisioningLock() private val scheduler = FakeBackupScheduler(jobRepository) diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ExportBackupUseCaseTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ExportBackupUseCaseTest.kt index 1e8d3a249..80679fa1c 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ExportBackupUseCaseTest.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ExportBackupUseCaseTest.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExportArk::class) + package de.davis.keygo.feature.backup.domain.usecase import de.davis.keygo.core.item.FakeCreditCardRepository @@ -5,10 +7,14 @@ import de.davis.keygo.core.item.FakeItemRepository import de.davis.keygo.core.item.FakeLoginRepository import de.davis.keygo.core.item.FakePasskeyRepository import de.davis.keygo.core.item.FakeVaultRepository +import de.davis.keygo.core.security.FakeArkCredential +import de.davis.keygo.core.security.FakeSession +import de.davis.keygo.core.security.FakeSessionFactory import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProviderFactory import de.davis.keygo.core.security.crypto.FakeKeyStoreManager -import de.davis.keygo.core.security.crypto.FakeSession +import de.davis.keygo.core.security.domain.ExportArk +import de.davis.keygo.core.security.domain.Session import de.davis.keygo.core.security.domain.crypto.model.CryptographicData import de.davis.keygo.core.security.domain.model.CryptographicMode import de.davis.keygo.core.security.domain.model.KeyId @@ -26,6 +32,8 @@ import de.davis.keygo.feature.backup.domain.model.EncryptionMethod import de.davis.keygo.feature.backup.domain.model.ExportError import de.davis.keygo.feature.backup.domain.model.ExportProgress import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.domain.model.failureReason +import de.davis.keygo.feature.backup.domain.model.retryable import de.davis.keygo.feature.backup.testLogin import de.davis.keygo.feature.backup.testVault import de.davis.keygo.rust.FakeCsvBackupManager @@ -36,10 +44,12 @@ import de.davisalessandro.keygo.rust.ExportPreset import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest import kotlin.test.Test -import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame import kotlin.test.assertTrue class ExportBackupUseCaseTest { @@ -52,21 +62,27 @@ class ExportBackupUseCaseTest { private val keyStore = FakeKeyStoreManager() private val arkStore = FakeBackupArkKeyStore() private val factory = FakeCryptographicScopeProviderFactory(scope) + private val sessionFactory = FakeSessionFactory() private val fileStore = FakeBackupFileStore() private val json = FakeJsonBackupManager() private val csv = FakeCsvBackupManager() private val folder = BackupDestinationUri("content://tree") - private fun useCase(session: FakeSession): ExportBackupUseCase { - val arkUnlocker = BackupArkUnlocker(session, keyStore, arkStore, factory, vaultRepo) + private fun useCase(session: Session): ExportBackupUseCase { + val arkUnlocker = BackupArkUnlocker( + session = session, + sessionFactory = sessionFactory, + keyStoreManager = keyStore, + arkKeyStore = arkStore, + ) return ExportBackupUseCase( collector = BackupCollector( vaultRepository = vaultRepo, loginRepository = loginRepo, creditCardRepository = cardRepo, passkeyRepository = passkeyRepo, - arkUnlocker = arkUnlocker, + scopeProviderFactory = factory, ), fileStore = fileStore, jsonBackupManager = json, @@ -76,13 +92,13 @@ class ExportBackupUseCaseTest { ) } - private suspend fun provision(session: FakeSession) { + private suspend fun provision(session: Session) { val cipher = assertNotNull( keyStore .getOrCreateCipherFor(KeyId.BackupArkKey, CryptographicMode.Encrypt) .getOrNull(), ) - val ark = assertNotNull(session.currentArk) + val ark = assertNotNull(session.exportArk().getOrNull()) arkStore.save(CryptographicData(cipher.doFinal(ark), cipher.iv)) } @@ -92,7 +108,7 @@ class ExportBackupUseCaseTest { format = FileFormat.CSV, ) - private fun unlocked() = FakeSession(startOnConstruct = true) + private fun unlocked() = FakeSession(startUnlocked = true) private fun seedSingleLogin() { val vault = testVault(name = "V") @@ -107,8 +123,9 @@ class ExportBackupUseCaseTest { @Test fun `locked and unprovisioned session fails with NotProvisioned`() = runTest { seedSingleLogin() - val emissions = useCase(FakeSession(startOnConstruct = false))(csvJob).toList() - assertEquals(ExportProgress.Failed(ExportError.NotProvisioned), emissions.last()) + val emissions = useCase(FakeSession())(csvJob).toList() + // Fails before any item is counted or reported. + assertEquals(listOf(ExportProgress.Failed(ExportError.NotProvisioned)), emissions) } @Test @@ -116,7 +133,7 @@ class ExportBackupUseCaseTest { seedSingleLogin() csv.exportResult = "data" provision(unlocked()) - val emissions = useCase(FakeSession(startOnConstruct = false))(csvJob).toList() + val emissions = useCase(FakeSession())(csvJob).toList() assertIs(emissions.last()) } @@ -231,8 +248,7 @@ class ExportBackupUseCaseTest { val emissions = useCase(session)(jsonJob).toList() assertIs(emissions.last()) - val credential = assertIs(json.exportCalls.single().credential) - assertContentEquals(session.currentArk, credential.key) + assertIs(json.exportCalls.single().credential) } @Test @@ -248,11 +264,18 @@ class ExportBackupUseCaseTest { encryption = EncryptionMethod.Ark, ) - val emissions = useCase(FakeSession(startOnConstruct = false))(jsonJob).toList() + val locked = FakeSession() + + val emissions = useCase(locked)(jsonJob).toList() assertIs(emissions.last()) + // One throwaway session holds the recovered ARK for the whole run: it decrypts the items + // and seals the file. The app-wide session is never unlocked with the escrowed key. + val throwaway = sessionFactory.created.single() + assertSame(throwaway, factory.lastSession) val credential = assertIs(json.exportCalls.single().credential) - assertContentEquals(unlockedSession.currentArk, credential.key) + assertSame(throwaway, assertIs(credential.credential).session) + assertFalse(locked.isActive.value) } @Test @@ -275,6 +298,51 @@ class ExportBackupUseCaseTest { assertEquals(ExportPreset.BROWSER, csv.exportCalls.single().preset) } + /** + * The session can lock at any point after [BackupArkUnlocker] hands back the live session, + * because auto-lock fires from the lock observer and not from this flow. Folding that into + * [ExportError.SerializationFailed] would record the job as terminally failed and release the + * escrowed credentials its retry needs, so the distinction is what keeps the retry possible. + */ + @Test + fun `a session locked mid-export is retryable rather than a serialization failure`() = runTest { + seedSingleLogin() + val session = unlocked() + json.exportException = BackupException.Locked() + val jsonJob = BackupJob( + uri = folder, + wrappedPassphrase = null, + format = FileFormat.JSON, + encryption = EncryptionMethod.Ark, + ) + + val emissions = useCase(session)(jsonJob).toList() + + val failed = assertIs(emissions.last()) + assertEquals(ExportError.SessionLocked, failed.error) + assertTrue(failed.error.retryable) + assertNull(failed.error.failureReason) + } + + @Test + fun `a non-lock export exception is still a terminal serialization failure`() = runTest { + seedSingleLogin() + val session = unlocked() + json.exportException = BackupException.Crypto("boom") + val jsonJob = BackupJob( + uri = folder, + wrappedPassphrase = null, + format = FileFormat.JSON, + encryption = EncryptionMethod.Ark, + ) + + val emissions = useCase(session)(jsonJob).toList() + + val failed = assertIs(emissions.last()) + assertIs(failed.error) + assertFalse(failed.error.retryable) + } + @Test fun `csv serialization failure surfaces SerializationFailed`() = runTest { seedSingleLogin() diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/FinishExportWizardUseCaseTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/FinishExportWizardUseCaseTest.kt index 66a55e6d9..3e4f14c3a 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/FinishExportWizardUseCaseTest.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/FinishExportWizardUseCaseTest.kt @@ -1,7 +1,11 @@ +@file:OptIn(ExportArk::class) + package de.davis.keygo.feature.backup.domain.usecase +import de.davis.keygo.core.security.FakeSession import de.davis.keygo.core.security.crypto.FakeKeyStoreManager -import de.davis.keygo.core.security.crypto.FakeSession +import de.davis.keygo.core.security.domain.ExportArk +import de.davis.keygo.core.security.domain.Session import de.davis.keygo.core.security.domain.model.CryptographicMode import de.davis.keygo.core.security.domain.model.KeyId import de.davis.keygo.core.util.Result @@ -33,12 +37,15 @@ class FinishExportWizardUseCaseTest { private val scheduler = FakeBackupScheduler() private val persistable = FakePersistableUriManager() - private val session = FakeSession(startOnConstruct = true) + private val session = FakeSession(startUnlocked = true) private val keyStoreManager = FakeKeyStoreManager() private val arkKeyStore = FakeBackupArkKeyStore() private val destinationResolver = FakeBackupDestinationResolver() - private fun useCase() = FinishExportWizardUseCase( + private fun useCase() = useCaseOver(session) + + /** The wipe tests need their own recording session in place of the shared one. */ + private fun useCaseOver(session: Session) = FinishExportWizardUseCase( backupScheduler = scheduler, destinationResolver = destinationResolver, keyStoreManager = keyStoreManager, @@ -139,7 +146,35 @@ class FinishExportWizardUseCaseTest { .getOrNull(), ) val recovered = cipher.doFinal(wrapped.data) - assertContentEquals(session.currentArk, recovered) + assertContentEquals(session.exportArk().getOrNull(), recovered) + } + + @Test + fun `wipes the exported ARK after escrowing it`() = runTest { + val recording = FakeSession(startUnlocked = true) + + useCaseOver(recording)( + details(interval = BackupInterval(count = 3, unit = IntervalUnit.Days)), + ) + + assertContentEquals(ByteArray(32), recording.onlyExported()) + } + + @Test + fun `wipes the exported ARK even when escrowing fails`() = runTest { + val recording = FakeSession(startUnlocked = true) + // A locked device fails the Keystore cipher, which is the step right after the export. + keyStoreManager.deviceLocked = true + + val result = useCaseOver(recording)( + details(interval = BackupInterval(count = 3, unit = IntervalUnit.Days)), + ) + + // Without this the test would pass on a use case that escrowed successfully, which is + // the one case where the wipe is not what kept the ARK from staying resident. + val failure = assertIs>(result) + assertEquals(FinishExportWizardError.CryptoFailed, failure.error) + assertContentEquals(ByteArray(32), recording.onlyExported()) } @Test diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ImportBackupUseCaseTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ImportBackupUseCaseTest.kt index 3f8e91b59..9fcfeab04 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ImportBackupUseCaseTest.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ImportBackupUseCaseTest.kt @@ -1,6 +1,7 @@ package de.davis.keygo.feature.backup.domain.usecase -import de.davis.keygo.core.security.crypto.FakeSession +import de.davis.keygo.core.security.FakeSession +import de.davis.keygo.core.security.domain.Session import de.davis.keygo.core.util.Result import de.davis.keygo.feature.backup.FakeBackupFileStore import de.davis.keygo.feature.backup.RestorerTestEnv @@ -40,7 +41,7 @@ class ImportBackupUseCaseTest { private val json = FakeJsonBackupManager() private val csv = FakeCsvBackupManager() - private fun useCase(session: FakeSession = FakeSession(startOnConstruct = true)) = + private fun useCase(session: Session = FakeSession(startUnlocked = true)) = ImportBackupUseCase(fileStore, json, csv, env.restorer, session) private fun jsonRequest(passphrase: String? = "pw") = ImportRequest( @@ -63,7 +64,7 @@ class ImportBackupUseCaseTest { @Test fun `locked session fails fast`() = runTest { - val emissions = useCase(FakeSession(startOnConstruct = false))(jsonRequest()).toList() + val emissions = useCase(FakeSession())(jsonRequest()).toList() assertEquals(listOf(ImportProgress.Failed(ImportError.SessionLocked)), emissions) } @@ -224,13 +225,12 @@ class ImportBackupUseCaseTest { fileStore.contents = "{}" json.inspectResult = JsonEncryption.ARK json.importResult = Backup(listOf(backupVault("V", listOf(login("A"))))) - val session = FakeSession(startOnConstruct = true) + val session = FakeSession(startUnlocked = true) val emissions = useCase(session)(jsonRequest(passphrase = null)).toList() assertIs(emissions.last()) - val credential = assertIs(json.importCalls.single().credential) - assertContentEquals(session.currentArk, credential.key) + assertIs(json.importCalls.single().credential) } @Test @@ -271,10 +271,25 @@ class ImportBackupUseCaseTest { assertIs(emissions.last()) } + @Test + fun `a lock raised by rust during parse reports SessionLocked, not a parse failure`() = + runTest { + val session = FakeSession(startUnlocked = true) + fileStore.contents = "{}" + json.inspectResult = JsonEncryption.ARK + json.importException = BackupException.Locked() + + val emissions = ImportBackupUseCase(fileStore, json, csv, env.restorer, session)( + jsonRequest(passphrase = null), + ).toList() + + assertEquals(ImportProgress.Failed(ImportError.SessionLocked), emissions.last()) + } + @Test fun `session locked between read and parse fails with SessionLocked instead of throwing`() = runTest { - val session = FakeSession(startOnConstruct = true) + val session = FakeSession(startUnlocked = true) fileStore.contents = "{}" json.inspectResult = JsonEncryption.ARK val lockDuringRead = object : BackupFileStore by fileStore { diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModelTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModelTest.kt index a897501b2..f05369a5c 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModelTest.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModelTest.kt @@ -4,7 +4,8 @@ import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import de.davis.keygo.core.item.FakeVaultContextRepository import de.davis.keygo.core.item.domain.model.Vault import de.davis.keygo.core.item.domain.model.VaultContext -import de.davis.keygo.core.security.crypto.FakeSession +import de.davis.keygo.core.security.FakeSession +import de.davis.keygo.core.security.domain.Session import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.feature.backup.FakeBackupFileStore import de.davis.keygo.feature.backup.RestorerTestEnv @@ -97,7 +98,7 @@ class ImportWizardViewModelTest { */ private fun TestScope.viewModel( resolver: FakeBackupDestinationResolver = FakeBackupDestinationResolver(), - session: FakeSession = FakeSession(startOnConstruct = true), + session: Session = FakeSession(startUnlocked = true), contextRepo: FakeVaultContextRepository = FakeVaultContextRepository(), ) = ImportWizardViewModel( resolver, diff --git a/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModelTest.kt b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModelTest.kt index af8db06d0..5877b126c 100644 --- a/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModelTest.kt +++ b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModelTest.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExportArk::class) + package de.davis.keygo.feature.settings.presentation.changepassword import androidx.compose.foundation.ExperimentalFoundationApi @@ -9,17 +11,18 @@ import de.davis.keygo.core.identity.domain.model.PasswordWrappedArk import de.davis.keygo.core.identity.domain.usecase.ChangePasswordUseCase import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.security.FakeSession import de.davis.keygo.core.security.crypto.FakeBiometricAvailabilityRepository -import de.davis.keygo.core.security.crypto.FakeSession +import de.davis.keygo.core.security.domain.ExportArk import de.davis.keygo.core.security.domain.model.BiometricAuthError import de.davis.keygo.core.ui.model.UiFieldError import de.davis.keygo.core.util.Result -import de.davis.keygo.rust.FakeKeyDeriver -import de.davis.keygo.rust.FakeKeyWrapper +import de.davis.keygo.core.util.getOrNull import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle @@ -27,7 +30,6 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import java.security.Key -import java.util.UUID import javax.crypto.spec.SecretKeySpec import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -43,28 +45,31 @@ class ChangePasswordViewModelTest { private val accountRepository = FakeAccountRepository() private val biometricAvailability = FakeBiometricAvailabilityRepository() - private val session = FakeSession(startOnConstruct = true) - private val keyDeriver = FakeKeyDeriver() - private val keyWrapper = FakeKeyWrapper() + private val session = FakeSession() + + // The screen starts out on an unlocked session, so the account has to exist up front. + private val created = checkNotNull(runBlocking { session.createAccount("old") }.getOrNull()) + private val estimator = object : PasswordStrengthEstimator { override suspend fun estimate(password: String): PasswordScore = PasswordScore.None } - private val changePassword = ChangePasswordUseCase(accountRepository, keyDeriver, keyWrapper) + private val changePassword = ChangePasswordUseCase(accountRepository, session) - private val accountId = UUID.randomUUID() - private val ark = ByteArray(32) { (it + 1).toByte() } + /** The live ARK, which is what a successful biometric prompt hands back to the screen. */ + private val ark: ByteArray get() = checkNotNull(session.exportArk().getOrNull()) @BeforeTest fun setUp() { Dispatchers.setMain(dispatcher) - val salt = keyDeriver.generateSalt() - val kek = keyDeriver.deriveRootKekFromPassword("old", salt) - val wrapped = keyWrapper.wrapAccountRootKey(kek, ark, accountId) accountRepository.seed( Account( - id = accountId, + id = created.userId, displayName = "Test", - passwordWrappedArk = PasswordWrappedArk(wrapped.ciphertext, wrapped.nonce, salt), + passwordWrappedArk = PasswordWrappedArk( + key = created.passwordWrappedArk.ciphertext, + keyIV = created.passwordWrappedArk.nonce, + salt = created.salt, + ), biometricWrappedArk = null, ) ) diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/CreateVaultUseCase.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/CreateVaultUseCase.kt index 6df4ce96d..d9dd25801 100644 --- a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/CreateVaultUseCase.kt +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/CreateVaultUseCase.kt @@ -6,14 +6,11 @@ import de.davis.keygo.core.item.domain.model.Vault import de.davis.keygo.core.item.domain.repository.VaultContextRepository import de.davis.keygo.core.item.domain.repository.VaultRepository import de.davis.keygo.core.security.domain.Session -import de.davis.keygo.core.security.domain.withArkOr +import de.davis.keygo.core.security.domain.SessionError import de.davis.keygo.core.util.Result -import de.davis.keygo.core.util.mapFailure import de.davis.keygo.core.util.resultBinding import de.davis.keygo.feature.vault.domain.model.VaultCreationError import de.davis.keygo.rust.vault.VaultManager -import de.davis.keygo.rust.wrap.KeyWrapper -import de.davis.keygo.rust.wrap.wrapVaultKeyWithResult import org.koin.core.annotation.Single /** @@ -25,8 +22,7 @@ class CreateVaultUseCase( private val vaultRepository: VaultRepository, private val vaultContextRepository: VaultContextRepository, private val vaultManager: VaultManager, - private val keyWrapper: KeyWrapper, - private val session: Session + private val session: Session, ) { suspend operator fun invoke( @@ -38,10 +34,11 @@ class CreateVaultUseCase( val vaultId = newVaultId() val vaultKey = vaultManager.createNewVaultKey() - val wrappedVaultKey = session.withArkOr(VaultCreationError.NoActiveSession) { ark -> - keyWrapper.wrapVaultKeyWithResult(ark, vaultKey, vaultId) - .mapFailure { VaultCreationError.WrapFailed } - }.bind() + val wrappedVaultKey = session.wrapVaultKey(vaultKey, vaultId) + .bind { + if (it == SessionError.Locked) VaultCreationError.NoActiveSession + else VaultCreationError.WrapFailed + } val vault = Vault( id = vaultId, diff --git a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/CreateVaultUseCaseTest.kt b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/CreateVaultUseCaseTest.kt index 10c24cc3e..5ac13e40c 100644 --- a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/CreateVaultUseCaseTest.kt +++ b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/CreateVaultUseCaseTest.kt @@ -4,12 +4,11 @@ import de.davis.keygo.core.item.FakeVaultContextRepository import de.davis.keygo.core.item.FakeVaultRepository import de.davis.keygo.core.item.domain.model.Vault import de.davis.keygo.core.item.domain.model.VaultContext -import de.davis.keygo.core.security.crypto.FakeSession +import de.davis.keygo.core.security.FakeSession import de.davis.keygo.core.util.getOrNull import de.davis.keygo.core.util.isFailure import de.davis.keygo.core.util.isSuccess import de.davis.keygo.feature.vault.domain.model.VaultCreationError -import de.davis.keygo.rust.FakeKeyWrapper import de.davis.keygo.rust.FakeVaultManager import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest @@ -20,18 +19,16 @@ import kotlin.test.assertTrue class CreateVaultUseCaseTest { - private val session = FakeSession(startOnConstruct = true) + private val session = FakeSession(startUnlocked = true) private val vaultRepository = FakeVaultRepository() private val vaultContextRepository = FakeVaultContextRepository() private val vaultManager = FakeVaultManager() - private val keyWrapper = FakeKeyWrapper() private val useCase = CreateVaultUseCase( vaultRepository = vaultRepository, vaultContextRepository = vaultContextRepository, vaultManager = vaultManager, - keyWrapper = keyWrapper, session = session, ) diff --git a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt index 51a7ad054..2c491a34d 100644 --- a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt +++ b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt @@ -17,8 +17,8 @@ import de.davis.keygo.core.item.domain.model.PasswordSecret import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.domain.model.Totp import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.security.FakeSession import de.davis.keygo.core.security.crypto.BindingCryptographicScopeProvider -import de.davis.keygo.core.security.crypto.FakeSession import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider import de.davis.keygo.core.security.domain.crypto.decrypt import de.davis.keygo.core.security.domain.crypto.encrypt @@ -27,6 +27,7 @@ import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformati import de.davis.keygo.core.security.domain.crypto.wrappedItemKeyInformation import de.davis.keygo.core.security.domain.model.CryptoScopeError import de.davis.keygo.core.util.assertSuccess +import de.davis.keygo.core.util.getOrNull import de.davis.keygo.core.util.isFailure import de.davis.keygo.core.util.isSuccess import de.davis.keygo.feature.vault.domain.model.MoveItemsError @@ -35,6 +36,7 @@ import de.davis.keygo.rust.FakeItemManager import de.davis.keygo.rust.FakeKeyWrapper import de.davisalessandro.keygo.rust.ItemAad import de.davisalessandro.keygo.rust.KeyWrapException +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals @@ -47,7 +49,7 @@ import kotlin.test.assertTrue class MoveItemsToVaultUseCaseTest { - private val session = FakeSession(startOnConstruct = true) + private val session = FakeSession(startUnlocked = true) private val loginRepository = FakeLoginRepository() private val itemRepository = FakeItemRepository(loginRepository) private val itemManager = FakeItemManager() @@ -315,11 +317,8 @@ class MoveItemsToVaultUseCaseTest { private fun makeVault(name: String, id: VaultId = newVaultId()): Vault { val vaultKey = ByteArray(32) { (id.hashCode() + it).toByte() } - val wrapped = keyWrapper.wrapVaultKey( - ark = assertNotNull(session.currentArk), - vaultKey = vaultKey, - vaultId = id, - ) + // This runs from a property initialiser, which cannot suspend to call session.wrapVaultKey. + val wrapped = checkNotNull(runBlocking { session.wrapVaultKey(vaultKey, id) }.getOrNull()) return Vault( id = id, name = name, diff --git a/rust/rust-code/Cargo.lock b/rust/rust-code/Cargo.lock index 9719e585b..5535e3147 100644 --- a/rust/rust-code/Cargo.lock +++ b/rust/rust-code/Cargo.lock @@ -1062,22 +1062,16 @@ dependencies = [ name = "keygo-bindings" version = "0.1.0" dependencies = [ - "lib", - "serde_json", + "keygo-core", "thiserror", "tokio", "uniffi", "uuid", + "zeroize", ] [[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "lib" +name = "keygo-core" version = "0.1.0" dependencies = [ "aead", @@ -1100,6 +1094,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "subtle", "thiserror", "totp-rs", "url", @@ -1107,6 +1102,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.189" @@ -1990,7 +1991,6 @@ dependencies = [ "cargo_metadata", "clap", "uniffi_bindgen", - "uniffi_build", "uniffi_core", "uniffi_macros", "uniffi_pipeline", @@ -2022,17 +2022,6 @@ dependencies = [ "uniffi_udl", ] -[[package]] -name = "uniffi_build" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "763a19ad720fce8c9a98576e04c0ccc5d2d155aa6b953c864587073292c7ccfc" -dependencies = [ - "anyhow", - "camino", - "uniffi_bindgen", -] - [[package]] name = "uniffi_core" version = "0.32.0" diff --git a/rust/rust-code/Cargo.toml b/rust/rust-code/Cargo.toml index 8dd6a9ada..ea28c111f 100644 --- a/rust/rust-code/Cargo.toml +++ b/rust/rust-code/Cargo.toml @@ -1,6 +1,14 @@ [workspace] members = [ - "lib", + "core", "bindings", ] resolver = "3" + +[workspace.package] +version = "0.1.0" +edition = "2024" + +[workspace.dependencies] +thiserror = "2.0.18" +uuid = { version = "1.23.1", features = ["serde", "v4"] } diff --git a/rust/rust-code/bindings/Cargo.toml b/rust/rust-code/bindings/Cargo.toml index 39105c0c1..d44eab026 100644 --- a/rust/rust-code/bindings/Cargo.toml +++ b/rust/rust-code/bindings/Cargo.toml @@ -1,24 +1,20 @@ [package] name = "keygo-bindings" -version = "0.1.0" -edition = "2024" +version.workspace = true +edition.workspace = true [lib] name = "keygo_bindings" crate-type = ["cdylib", "staticlib"] [dependencies] -lib = { path = "../lib" } -thiserror = "2.0.18" -tokio = { version = "1.48.0", features = ["rt", "rt-multi-thread"] } -uniffi = { version = "0.32", features = ["tokio", "cli"] } -uuid = "1.23.1" - -[build-dependencies] -uniffi = { version = "0.32.0", features = ["build"] } +keygo-core = { path = "../core" } +thiserror.workspace = true +uuid.workspace = true +zeroize = "1.8.2" -[dev-dependencies] -serde_json = "1.0" +tokio = { version = "1.48.0", features = ["rt", "rt-multi-thread"] } +uniffi = { version = "0.32.0", features = ["tokio", "cli"] } [[bin]] name = "uniffi-bindgen" diff --git a/rust/rust-code/bindings/src/account.rs b/rust/rust-code/bindings/src/account.rs deleted file mode 100644 index 1afbdf491..000000000 --- a/rust/rust-code/bindings/src/account.rs +++ /dev/null @@ -1,64 +0,0 @@ -use lib::crypto::types::{UserId, VaultId}; -use lib::crypto::{AccountRootKey, KeyMaterial, VaultKey}; -use lib::item::account::Account; -use lib::item::create_account::CreateAccount; -use lib::item::vault::Vault; -use std::sync::Arc; -use uuid::Uuid; - -uniffi::custom_type!(Uuid, String, { - remote, - try_lift: |s| Uuid::parse_str(&s).map_err(|e| uniffi::deps::anyhow::anyhow!("{e}")), - lower: |u| u.to_string(), -}); - -uniffi::custom_type!(AccountRootKey, Vec, { - remote, - try_lift: |bytes| { - AccountRootKey::try_from_bytes(&bytes) - .map_err(|e| uniffi::deps::anyhow::anyhow!("{e:?}")) - }, - lower: |key| key.as_bytes().to_vec(), -}); - -uniffi::custom_type!(VaultKey, Vec, { - remote, - try_lift: |bytes| { - VaultKey::try_from_bytes(&bytes) - .map_err(|e| uniffi::deps::anyhow::anyhow!("{e:?}")) - }, - lower: |key| key.as_bytes().to_vec(), -}); - -#[uniffi::remote(Record)] -pub struct Account { - pub id: UserId, - pub ark: AccountRootKey, -} - -#[uniffi::remote(Record)] -pub struct Vault { - pub id: VaultId, - pub vault_key: VaultKey, -} - -#[uniffi::remote(Record)] -pub struct CreateAccount { - pub account: Account, - pub default_vault: Vault, -} - -#[derive(uniffi::Object)] -pub struct AccountManager; - -#[uniffi::export] -impl AccountManager { - #[uniffi::constructor] - pub fn new() -> Arc { - Arc::new(Self) - } - - pub fn create_account(&self) -> CreateAccount { - CreateAccount::generate_new() - } -} diff --git a/rust/rust-code/bindings/src/ark_session.rs b/rust/rust-code/bindings/src/ark_session.rs new file mode 100644 index 000000000..4d2f42a81 --- /dev/null +++ b/rust/rust-code/bindings/src/ark_session.rs @@ -0,0 +1,181 @@ +use crate::key_wrap::{KeyWrapError, WrappedKeyBlob}; +use keygo_core::ark_session::{ + ArkSession as CoreArkSession, ArkSessionError as CoreArkSessionError, +}; +use keygo_core::crypto::VaultKey; +use keygo_core::crypto::primitive::wrap_key::{AeadWrappedKey, WrappedKey}; +use keygo_core::crypto::types::{UserId, VaultId}; +use std::sync::Arc; + +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum ArkSessionError { + #[error("No active session")] + Locked, + #[error("Wrong password")] + WrongPassword, + #[error("Key derivation failed: {0}")] + Derivation(String), + #[error("{0}")] + KeyWrap(#[from] KeyWrapError), +} + +impl From for ArkSessionError { + fn from(value: CoreArkSessionError) -> Self { + match value { + CoreArkSessionError::Locked => Self::Locked, + CoreArkSessionError::WrongPassword => Self::WrongPassword, + CoreArkSessionError::Derivation(msg) => Self::Derivation(msg), + CoreArkSessionError::KeyWrap(crypto_error) => Self::KeyWrap(crypto_error.into()), + } + } +} + +#[derive(uniffi::Record)] +pub struct NewAccount { + pub user_id: UserId, + pub salt: Vec, + pub password_wrapped_ark: WrappedKeyBlob, + pub vault_id: VaultId, + pub wrapped_vault_key: WrappedKeyBlob, +} + +#[derive(uniffi::Record)] +pub struct PasswordWrapped { + pub salt: Vec, + pub wrapped: WrappedKeyBlob, +} + +fn blob(wrapped: &impl WrappedKey) -> WrappedKeyBlob +where + T: keygo_core::crypto::KeyMaterial, + W: keygo_core::crypto::AeadKey, +{ + WrappedKeyBlob { + ciphertext: wrapped.ciphertext().to_vec(), + nonce: wrapped.nonce_bytes().to_vec(), + } +} + +#[derive(uniffi::Object)] +pub struct ArkCredential { + ark_session: Arc, +} + +impl ArkCredential { + /// The session this credential borrows its key from. + pub(crate) fn session(&self) -> &CoreArkSession { + &self.ark_session.session + } +} + +#[derive(uniffi::Object)] +pub struct ArkSession { + pub(crate) session: CoreArkSession, +} + +#[uniffi::export] +impl ArkSession { + #[uniffi::constructor] + pub fn new() -> Arc { + Arc::new(Self { + session: CoreArkSession::new(), + }) + } + + pub fn end(&self) { + self.session.end() + } + + pub fn is_active(&self) -> bool { + self.session.is_active() + } + + pub fn create_account(&self, password: String) -> Result { + let account = self.session.create_account(&password)?; + Ok(NewAccount { + user_id: account.user_id, + salt: account.salt, + password_wrapped_ark: blob(&account.password_wrapped_ark), + vault_id: account.vault_id, + wrapped_vault_key: blob(&account.wrapped_vault_key), + }) + } + + pub fn unlock_with_password( + &self, + password: String, + salt: Vec, + wrapped: WrappedKeyBlob, + user_id: UserId, + ) -> Result<(), ArkSessionError> { + let wrapped = AeadWrappedKey::from_parts_bytes(wrapped.ciphertext, &wrapped.nonce); + Ok(self + .session + .unlock_with_password(&password, &salt, wrapped, user_id)?) + } + + pub fn unlock_with_ark(&self, ark: Vec) -> Result<(), ArkSessionError> { + Ok(self.session.unlock_with_ark(&ark)?) + } + + pub fn export_ark(&self) -> Result, ArkSessionError> { + Ok(self.session.export_ark()?.to_vec()) + } + + pub fn verify_password( + &self, + password: String, + salt: Vec, + wrapped: WrappedKeyBlob, + user_id: UserId, + ) -> Result<(), ArkSessionError> { + let wrapped = AeadWrappedKey::from_parts_bytes(wrapped.ciphertext, &wrapped.nonce); + Ok(self + .session + .verify_password(&password, &salt, wrapped, user_id)?) + } + + pub fn verify_ark(&self, ark: Vec) -> Result { + Ok(self.session.verify_ark(&ark)?) + } + + pub fn rewrap_for_new_password( + &self, + new_password: String, + user_id: UserId, + ) -> Result { + let wrapped = self + .session + .rewrap_for_new_password(&new_password, user_id)?; + Ok(PasswordWrapped { + salt: wrapped.salt, + wrapped: blob(&wrapped.wrapped), + }) + } + + pub fn unwrap_vault_key( + &self, + wrapped: WrappedKeyBlob, + vault_id: VaultId, + ) -> Result { + let wrapped = AeadWrappedKey::from_parts_bytes(wrapped.ciphertext, &wrapped.nonce); + self.session + .unwrap_vault_key(wrapped, vault_id) + .map_err(ArkSessionError::from) + } + + pub fn wrap_vault_key( + &self, + vault_key: VaultKey, + vault_id: VaultId, + ) -> Result { + self.session + .wrap_vault_key(vault_key, vault_id) + .map_err(ArkSessionError::from) + .map(|wrapped| blob(&wrapped)) + } + + pub fn ark_credential(self: Arc) -> Arc { + Arc::new(ArkCredential { ark_session: self }) + } +} diff --git a/rust/rust-code/bindings/src/backup.rs b/rust/rust-code/bindings/src/backup.rs deleted file mode 100644 index 3eab2be1a..000000000 --- a/rust/rust-code/bindings/src/backup.rs +++ /dev/null @@ -1,456 +0,0 @@ -use std::sync::Arc; - -use lib::backup as core; -use lib::crypto::AccountRootKey; - -#[derive(uniffi::Record)] -pub struct Backup { - pub vaults: Vec, -} - -#[derive(uniffi::Record)] -pub struct BackupVault { - pub name: String, - pub icon: String, - pub logins: Vec, - pub cards: Vec, -} - -#[derive(uniffi::Record)] -pub struct BackupLogin { - pub title: String, - pub notes: Option, - pub tags: Vec, - pub pinned: bool, - pub username: Option, - pub password: Option, - pub totp_secret: Option, - pub websites: Vec, - pub passkeys: Vec, -} - -#[derive(uniffi::Record)] -pub struct BackupCard { - pub title: String, - pub notes: Option, - pub tags: Vec, - pub pinned: bool, - pub cardholder: Option, - pub number: String, - pub expiration_month: Option, - pub expiration_year: Option, - pub cvv: Option, -} - -#[derive(uniffi::Record)] -pub struct BackupPasskey { - pub user_name: String, - pub user_display_name: String, - pub credential_id: Vec, - pub private_key: Vec, - pub rp: String, -} - -impl From for BackupPasskey { - fn from(p: core::Passkey) -> Self { - Self { - user_name: p.user_name, - user_display_name: p.user_display_name, - credential_id: p.credential_id, - private_key: p.private_key, - rp: p.rp, - } - } -} - -impl From for core::Passkey { - fn from(p: BackupPasskey) -> Self { - Self { - user_name: p.user_name, - user_display_name: p.user_display_name, - credential_id: p.credential_id, - private_key: p.private_key, - rp: p.rp, - } - } -} - -impl From for BackupLogin { - fn from(l: core::Login) -> Self { - Self { - title: l.title, - notes: l.notes, - tags: l.tags, - pinned: l.pinned, - username: l.username, - password: l.password, - totp_secret: l.totp_secret, - websites: l.websites, - passkeys: l.passkeys.into_iter().map(Into::into).collect(), - } - } -} - -impl From for core::Login { - fn from(l: BackupLogin) -> Self { - Self { - title: l.title, - notes: l.notes, - tags: l.tags, - pinned: l.pinned, - username: l.username, - password: l.password, - totp_secret: l.totp_secret, - websites: l.websites, - passkeys: l.passkeys.into_iter().map(Into::into).collect(), - } - } -} - -impl From for BackupCard { - fn from(c: core::Card) -> Self { - Self { - title: c.title, - notes: c.notes, - tags: c.tags, - pinned: c.pinned, - cardholder: c.cardholder, - number: c.number, - expiration_month: c.expiration_month, - expiration_year: c.expiration_year, - cvv: c.cvv, - } - } -} - -impl From for core::Card { - fn from(c: BackupCard) -> Self { - Self { - title: c.title, - notes: c.notes, - tags: c.tags, - pinned: c.pinned, - cardholder: c.cardholder, - number: c.number, - expiration_month: c.expiration_month, - expiration_year: c.expiration_year, - cvv: c.cvv, - } - } -} - -impl From for BackupVault { - fn from(v: core::Vault) -> Self { - Self { - name: v.name, - icon: v.icon, - logins: v.logins.into_iter().map(Into::into).collect(), - cards: v.cards.into_iter().map(Into::into).collect(), - } - } -} - -impl From for core::Vault { - fn from(v: BackupVault) -> Self { - Self { - name: v.name, - icon: v.icon, - logins: v.logins.into_iter().map(Into::into).collect(), - cards: v.cards.into_iter().map(Into::into).collect(), - } - } -} - -impl From for Backup { - fn from(b: core::Backup) -> Self { - Self { - vaults: b.vaults.into_iter().map(Into::into).collect(), - } - } -} - -impl From for core::Backup { - fn from(b: Backup) -> Self { - Self { - vaults: b.vaults.into_iter().map(Into::into).collect(), - } - } -} - -#[derive(uniffi::Record)] -pub struct CsvColumn { - pub index: u32, - pub header: String, - pub sample_values: Vec, -} - -#[derive(uniffi::Enum)] -pub enum Confidence { - High, - Medium, - Low, -} - -#[derive(uniffi::Record)] -pub struct FieldConfidence { - pub title: Option, - pub url: Option, - pub username: Option, - pub password: Option, - pub notes: Option, - pub totp: Option, -} - -#[derive(uniffi::Record, Default)] -pub struct ColumnMapping { - pub title: Option, - pub url: Option, - pub username: Option, - pub password: Option, - pub notes: Option, - pub totp: Option, -} - -#[derive(uniffi::Record)] -pub struct CsvAnalysis { - pub columns: Vec, - pub suggested: ColumnMapping, - pub confidence: FieldConfidence, -} - -#[derive(uniffi::Record)] -pub struct ImportReport { - pub imported: u32, - pub skipped: u32, -} - -#[derive(uniffi::Record)] -pub struct CsvImportResult { - pub backup: Backup, - pub report: ImportReport, -} - -#[derive(uniffi::Enum)] -pub enum ExportPreset { - KeyGo, - Browser, -} - -#[derive(uniffi::Enum)] -pub enum JsonEncryption { - Passphrase, - Ark, -} - -impl From for Confidence { - fn from(c: core::Confidence) -> Self { - match c { - core::Confidence::High => Self::High, - core::Confidence::Medium => Self::Medium, - core::Confidence::Low => Self::Low, - } - } -} - -impl From for CsvColumn { - fn from(c: core::CsvColumn) -> Self { - Self { - index: c.index, - header: c.header, - sample_values: c.sample_values, - } - } -} - -impl From for FieldConfidence { - fn from(f: core::FieldConfidence) -> Self { - Self { - title: f.title.map(Into::into), - url: f.url.map(Into::into), - username: f.username.map(Into::into), - password: f.password.map(Into::into), - notes: f.notes.map(Into::into), - totp: f.totp.map(Into::into), - } - } -} - -impl From for ColumnMapping { - fn from(m: core::ColumnMapping) -> Self { - Self { - title: m.title.map(|i| i as u32), - url: m.url.map(|i| i as u32), - username: m.username.map(|i| i as u32), - password: m.password.map(|i| i as u32), - notes: m.notes.map(|i| i as u32), - totp: m.totp.map(|i| i as u32), - } - } -} - -impl From for core::ColumnMapping { - fn from(m: ColumnMapping) -> Self { - Self { - title: m.title.map(|i| i as usize), - url: m.url.map(|i| i as usize), - username: m.username.map(|i| i as usize), - password: m.password.map(|i| i as usize), - notes: m.notes.map(|i| i as usize), - totp: m.totp.map(|i| i as usize), - } - } -} - -impl From for CsvAnalysis { - fn from(a: core::CsvAnalysis) -> Self { - Self { - columns: a.columns.into_iter().map(Into::into).collect(), - suggested: a.suggested.into(), - confidence: a.confidence.into(), - } - } -} - -impl From for ImportReport { - fn from(r: core::ImportReport) -> Self { - Self { - imported: r.imported, - skipped: r.skipped, - } - } -} - -impl From for core::ExportPreset { - fn from(p: ExportPreset) -> Self { - match p { - ExportPreset::KeyGo => core::ExportPreset::KeyGo, - ExportPreset::Browser => core::ExportPreset::Browser, - } - } -} - -#[derive(uniffi::Enum)] -pub enum BackupCredential { - Passphrase { bytes: Vec }, - Ark { key: AccountRootKey }, -} - -#[derive(Debug, thiserror::Error, uniffi::Error)] -pub enum BackupError { - #[error("crypto error: {0}")] - Crypto(String), - #[error("json error: {0}")] - Json(String), - #[error("invalid base64 in backup payload or header")] - Base64, - #[error("unsupported backup version: {0}")] - UnsupportedVersion(u32), - #[error("malformed encryption header")] - MalformedHeader, - #[error("credential does not match the backup's key source")] - CredentialMismatch, - #[error("malformed csv: {0}")] - Csv(String), - #[error("csv contained no rows")] - EmptyCsv, -} - -impl From for BackupError { - fn from(e: core::BackupError) -> Self { - use core::BackupError as E; - match e { - E::Crypto(c) => Self::Crypto(format!("{c}")), - E::Json(j) => Self::Json(format!("{j}")), - E::Base64 => Self::Base64, - E::UnsupportedVersion(v) => Self::UnsupportedVersion(v), - E::MalformedHeader => Self::MalformedHeader, - E::CredentialMismatch => Self::CredentialMismatch, - E::Csv(s) => Self::Csv(s), - E::EmptyCsv => Self::EmptyCsv, - } - } -} - -#[derive(uniffi::Object)] -pub struct JsonBackupManager; - -#[uniffi::export] -impl JsonBackupManager { - #[uniffi::constructor] - pub fn new() -> Arc { - Arc::new(Self) - } - - pub fn export( - &self, - backup: Backup, - credential: BackupCredential, - ) -> Result { - let backup: core::Backup = backup.into(); - let json = match credential { - BackupCredential::Passphrase { bytes } => { - core::json::export(&backup, core::BackupCredential::Passphrase(&bytes)) - } - BackupCredential::Ark { key } => { - core::json::export(&backup, core::BackupCredential::Ark(&key)) - } - }?; - Ok(json) - } - - pub fn import( - &self, - data: String, - credential: BackupCredential, - ) -> Result { - let backup = match credential { - BackupCredential::Passphrase { bytes } => { - core::json::import(&data, core::BackupCredential::Passphrase(&bytes)) - } - BackupCredential::Ark { key } => { - core::json::import(&data, core::BackupCredential::Ark(&key)) - } - }?; - Ok(backup.into()) - } - - pub fn inspect(&self, data: String) -> Result { - Ok(match core::json::inspect(&data)? { - core::encryption::KeySource::Passphrase => JsonEncryption::Passphrase, - core::encryption::KeySource::Ark => JsonEncryption::Ark, - }) - } -} - -#[derive(uniffi::Object)] -pub struct CsvBackupManager; - -#[uniffi::export] -impl CsvBackupManager { - #[uniffi::constructor] - pub fn new() -> Arc { - Arc::new(Self) - } - - pub fn analyze(&self, data: String) -> Result { - Ok(core::csv::analyze(&data)?.into()) - } - - pub fn import( - &self, - data: String, - mapping: ColumnMapping, - ) -> Result { - let mapping: core::ColumnMapping = mapping.into(); - let (backup, report) = core::csv::import(&data, &mapping)?; - Ok(CsvImportResult { - backup: backup.into(), - report: report.into(), - }) - } - - pub fn export(&self, backup: Backup, preset: ExportPreset) -> Result { - let backup: core::Backup = backup.into(); - Ok(core::csv::export(&backup, preset.into())?) - } -} diff --git a/rust/rust-code/bindings/src/backup/csv.rs b/rust/rust-code/bindings/src/backup/csv.rs new file mode 100644 index 000000000..d2b4ab708 --- /dev/null +++ b/rust/rust-code/bindings/src/backup/csv.rs @@ -0,0 +1,105 @@ +use keygo_core::backup::{ + Backup, ColumnMapping as CoreColumnMapping, Confidence, CsvAnalysis as CoreCsvAnalysis, + CsvColumn, ExportPreset, FieldConfidence, ImportReport, +}; + +#[uniffi::remote(Enum)] +enum Confidence { + High, + Medium, + Low, +} + +#[uniffi::remote(Enum)] +enum ExportPreset { + KeyGo, + Browser, +} + +#[uniffi::remote(Record)] +struct CsvColumn { + pub index: u32, + pub header: String, + pub sample_values: Vec, +} + +#[uniffi::remote(Record)] +struct ImportReport { + pub imported: u32, + pub skipped: u32, +} + +#[uniffi::remote(Record)] +struct FieldConfidence { + pub title: Option, + pub url: Option, + pub username: Option, + pub password: Option, + pub notes: Option, + pub totp: Option, +} + +#[derive(uniffi::Enum)] +pub enum JsonEncryption { + Passphrase, + Ark, +} + +#[derive(uniffi::Record, Default)] +pub struct ColumnMapping { + pub title: Option, + pub url: Option, + pub username: Option, + pub password: Option, + pub notes: Option, + pub totp: Option, +} + +impl From for ColumnMapping { + fn from(m: CoreColumnMapping) -> Self { + Self { + title: m.title.map(|i| i as u32), + url: m.url.map(|i| i as u32), + username: m.username.map(|i| i as u32), + password: m.password.map(|i| i as u32), + notes: m.notes.map(|i| i as u32), + totp: m.totp.map(|i| i as u32), + } + } +} + +impl From for CoreColumnMapping { + fn from(m: ColumnMapping) -> Self { + Self { + title: m.title.map(|i| i as usize), + url: m.url.map(|i| i as usize), + username: m.username.map(|i| i as usize), + password: m.password.map(|i| i as usize), + notes: m.notes.map(|i| i as usize), + totp: m.totp.map(|i| i as usize), + } + } +} + +#[derive(uniffi::Record)] +pub struct CsvAnalysis { + pub columns: Vec, + pub suggested: ColumnMapping, + pub confidence: FieldConfidence, +} + +impl From for CsvAnalysis { + fn from(a: CoreCsvAnalysis) -> Self { + Self { + columns: a.columns, + suggested: a.suggested.into(), + confidence: a.confidence, + } + } +} + +#[derive(uniffi::Record)] +pub struct CsvImportResult { + pub backup: Backup, + pub report: ImportReport, +} diff --git a/rust/rust-code/bindings/src/backup/mod.rs b/rust/rust-code/bindings/src/backup/mod.rs new file mode 100644 index 000000000..5bd854ad5 --- /dev/null +++ b/rust/rust-code/bindings/src/backup/mod.rs @@ -0,0 +1,188 @@ +mod csv; +mod model; + +use std::sync::Arc; + +use keygo_core::ark_session::ArkSessionError; +use keygo_core::backup::{ + Backup, BackupCredential as CoreCredential, BackupError as CoreError, ExportPreset, KeySource, + csv as core_csv, json as core_json, +}; + +use self::csv::{ColumnMapping, CsvAnalysis, CsvImportResult, JsonEncryption}; +use crate::ark_session::ArkCredential; + +#[derive(uniffi::Enum)] +pub enum BackupCredential { + Passphrase { bytes: Vec }, + Ark { credential: Arc }, +} + +impl BackupCredential { + /// Run `f` with the core credential. The ARK is borrowed from the session for exactly the + /// length of the call, so it is never copied out to build a credential. + fn with_core( + &self, + f: impl FnOnce(CoreCredential<'_>) -> Result, + ) -> Result { + match self { + Self::Passphrase { bytes } => Ok(f(CoreCredential::Passphrase(bytes))?), + Self::Ark { credential } => credential + .session() + .with_ark(|ark| f(CoreCredential::Ark(ark))) + .map_err(BackupError::from)? + .map_err(BackupError::from), + } + } +} + +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum BackupError { + #[error("crypto error: {0}")] + Crypto(String), + #[error("json error: {0}")] + Json(String), + #[error("invalid base64 in backup payload or header")] + Base64, + #[error("unsupported backup version: {0}")] + UnsupportedVersion(u32), + #[error("malformed encryption header")] + MalformedHeader, + #[error("credential does not match the backup's key source")] + CredentialMismatch, + #[error("malformed csv: {0}")] + Csv(String), + #[error("csv contained no rows")] + EmptyCsv, + #[error("no active session")] + Locked, +} + +impl From for BackupError { + fn from(e: CoreError) -> Self { + match e { + CoreError::Crypto(c) => Self::Crypto(format!("{c}")), + CoreError::Json(j) => Self::Json(format!("{j}")), + CoreError::Base64 => Self::Base64, + CoreError::UnsupportedVersion(v) => Self::UnsupportedVersion(v), + CoreError::MalformedHeader => Self::MalformedHeader, + CoreError::CredentialMismatch => Self::CredentialMismatch, + CoreError::Csv(s) => Self::Csv(s), + CoreError::EmptyCsv => Self::EmptyCsv, + } + } +} + +impl From for BackupError { + fn from(e: ArkSessionError) -> Self { + match e { + ArkSessionError::Locked => Self::Locked, + other => Self::Crypto(format!("{other}")), + } + } +} + +#[derive(uniffi::Object)] +pub struct JsonBackupManager; + +#[uniffi::export] +impl JsonBackupManager { + #[uniffi::constructor] + pub fn new() -> Arc { + Arc::new(Self) + } + + pub fn export( + &self, + backup: Backup, + credential: BackupCredential, + ) -> Result { + credential.with_core(|c| core_json::export(&backup, c)) + } + + pub fn import( + &self, + data: String, + credential: BackupCredential, + ) -> Result { + credential.with_core(|c| core_json::import(&data, c)) + } + + pub fn inspect(&self, data: String) -> Result { + Ok(match core_json::inspect(&data)? { + KeySource::Passphrase => JsonEncryption::Passphrase, + KeySource::Ark => JsonEncryption::Ark, + }) + } +} + +#[derive(uniffi::Object)] +pub struct CsvBackupManager; + +#[uniffi::export] +impl CsvBackupManager { + #[uniffi::constructor] + pub fn new() -> Arc { + Arc::new(Self) + } + + pub fn analyze(&self, data: String) -> Result { + Ok(core_csv::analyze(&data)?.into()) + } + + pub fn import( + &self, + data: String, + mapping: ColumnMapping, + ) -> Result { + let (backup, report) = core_csv::import(&data, &mapping.into())?; + Ok(CsvImportResult { backup, report }) + } + + pub fn export(&self, backup: Backup, preset: ExportPreset) -> Result { + Ok(core_csv::export(&backup, preset)?) + } +} + +#[cfg(test)] +mod tests { + use keygo_core::crypto::KeyMaterial; + + use super::*; + use crate::ark_session::ArkSession; + + #[test] + fn an_ark_credential_resolves_to_its_own_sessions_ark() { + let session = ArkSession::new(); + session + .create_account("hunter2".to_string()) + .expect("account creation"); + let expected = session.export_ark().expect("live ark"); + + let credential = BackupCredential::Ark { + credential: Arc::clone(&session).ark_credential(), + }; + + let seen = credential + .with_core(|core| match core { + CoreCredential::Ark(ark) => Ok(ark.as_bytes().to_vec()), + CoreCredential::Passphrase(_) => panic!("expected the ark key source"), + }) + .expect("credential resolves"); + + assert_eq!(&*expected, seen.as_slice()); + } + + #[test] + fn an_ark_credential_from_a_locked_session_reports_locked() { + let session = ArkSession::new(); + + let credential = BackupCredential::Ark { + credential: session.ark_credential(), + }; + + let resolved = credential.with_core(|_| Ok(())); + + assert!(matches!(resolved, Err(BackupError::Locked))); + } +} diff --git a/rust/rust-code/bindings/src/backup/model.rs b/rust/rust-code/bindings/src/backup/model.rs new file mode 100644 index 000000000..e85827c4f --- /dev/null +++ b/rust/rust-code/bindings/src/backup/model.rs @@ -0,0 +1,53 @@ +use keygo_core::backup::{Backup, Card, Login, Passkey, Vault}; + +#[uniffi::remote(Record)] +#[uniffi(name = "BackupPasskey")] +struct Passkey { + pub user_name: String, + pub user_display_name: String, + pub credential_id: Vec, + pub private_key: Vec, + pub rp: String, +} + +#[uniffi::remote(Record)] +#[uniffi(name = "BackupLogin")] +struct Login { + pub title: String, + pub notes: Option, + pub tags: Vec, + pub pinned: bool, + pub username: Option, + pub password: Option, + pub totp_secret: Option, + pub websites: Vec, + pub passkeys: Vec, +} + +#[uniffi::remote(Record)] +#[uniffi(name = "BackupCard")] +struct Card { + pub title: String, + pub notes: Option, + pub tags: Vec, + pub pinned: bool, + pub cardholder: Option, + pub number: String, + pub expiration_month: Option, + pub expiration_year: Option, + pub cvv: Option, +} + +#[uniffi::remote(Record)] +#[uniffi(name = "BackupVault")] +struct Vault { + pub name: String, + pub icon: String, + pub logins: Vec, + pub cards: Vec, +} + +#[uniffi::remote(Record)] +struct Backup { + pub vaults: Vec, +} diff --git a/rust/rust-code/bindings/src/card.rs b/rust/rust-code/bindings/src/card.rs index 04e4e7fb7..1bfc14fd0 100644 --- a/rust/rust-code/bindings/src/card.rs +++ b/rust/rust-code/bindings/src/card.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use lib::card::{Card, format_expiration_after_edit as core_format_expiration_after_edit}; +use keygo_core::card::{Card, format_expiration_after_edit as core_format_expiration_after_edit}; #[derive(uniffi::Object)] pub struct CardFormatter; diff --git a/rust/rust-code/bindings/src/item.rs b/rust/rust-code/bindings/src/item.rs index a7a080634..2d67970e0 100644 --- a/rust/rust-code/bindings/src/item.rs +++ b/rust/rust-code/bindings/src/item.rs @@ -1,8 +1,8 @@ -use lib::crypto::KeyMaterial; -use lib::crypto::error::CryptoError; -use lib::crypto::item_key::{ItemAad, ItemDataAad, ItemKey}; -use lib::crypto::primitive::aead_data::{AeadCiphertext, AeadEncryptor}; -use lib::crypto::types::{ItemId, VaultId}; +use keygo_core::crypto::KeyMaterial; +use keygo_core::crypto::error::CryptoError; +use keygo_core::crypto::primitive::aead_data::{AeadCiphertext, AeadEncryptor}; +use keygo_core::crypto::types::{ItemId, VaultId}; +use keygo_core::crypto::{ItemAad, ItemDataAad, ItemKey}; use std::sync::Arc; uniffi::custom_type!(ItemKey, Vec, { diff --git a/rust/rust-code/bindings/src/key_derivation.rs b/rust/rust-code/bindings/src/key_derivation.rs deleted file mode 100644 index f19abf8f5..000000000 --- a/rust/rust-code/bindings/src/key_derivation.rs +++ /dev/null @@ -1,71 +0,0 @@ -use lib::crypto::TryDeriveFrom; -use lib::crypto::error::CryptoError; -use lib::crypto::keys::RootKEK; -use lib::crypto::primitive::argon2::MIN_SALT_LEN; -use lib::crypto::random::random_bytes; -use std::sync::Arc; - -const PASSWORD_DOMAIN: &[u8] = b"v1:kek/pwd"; -const RECOVERY_KEY_DOMAIN: &[u8] = b"v1:kek/rk"; -const SALT_LEN: usize = MIN_SALT_LEN; - -#[derive(Debug, thiserror::Error, uniffi::Error)] -pub enum KeyDerivationError { - #[error("Key derivation failed: {0}")] - Failed(String), - #[error("{0}")] - Other(String), -} - -impl From for KeyDerivationError { - fn from(value: CryptoError) -> Self { - match value { - CryptoError::KdfError(msg) => Self::Failed(msg), - CryptoError::InvalidKeyLength { expected, got } => Self::Failed(format!( - "invalid key length: expected {expected}, got {got}" - )), - other => Self::Other(format!("{other}")), - } - } -} - -#[derive(uniffi::Object)] -pub struct KeyDeriver; - -#[uniffi::export] -impl KeyDeriver { - #[uniffi::constructor] - pub fn new() -> Arc { - Arc::new(Self) - } - - /// Generate a fresh random salt suitable for password-based KEK derivation. - /// Persist this salt alongside the credential so the same KEK can be re-derived on login. - pub fn generate_salt(&self) -> Vec { - random_bytes::().to_vec() - } - - pub fn derive_root_kek_from_password( - &self, - password: String, - salt: Vec, - ) -> Result { - Ok(RootKEK::try_derive_from( - password.as_bytes(), - &salt, - PASSWORD_DOMAIN, - )?) - } - - pub fn derive_root_kek_from_recovery_key( - &self, - recovery_key: Vec, - salt: Vec, - ) -> Result { - Ok(RootKEK::try_derive_from( - &recovery_key, - &salt, - RECOVERY_KEY_DOMAIN, - )?) - } -} diff --git a/rust/rust-code/bindings/src/key_wrap.rs b/rust/rust-code/bindings/src/key_wrap.rs index bd706ef84..d67de3762 100644 --- a/rust/rust-code/bindings/src/key_wrap.rs +++ b/rust/rust-code/bindings/src/key_wrap.rs @@ -1,20 +1,10 @@ -use lib::crypto::error::CryptoError; -use lib::crypto::item_key::{ItemAad, ItemKey}; -use lib::crypto::key::KeyMaterial; -use lib::crypto::keys::{AccountRootKey, RootKEK, VaultKey}; -use lib::crypto::primitive::wrap_key::{KeyWrapper as KeyWrapperTrait, WrappedKey}; -use lib::crypto::types::{UserId, VaultId}; +use keygo_core::crypto::KeyMaterial; +use keygo_core::crypto::VaultKey; +use keygo_core::crypto::error::CryptoError; +use keygo_core::crypto::primitive::wrap_key::{KeyWrapper as KeyWrapperTrait, WrappedKey}; +use keygo_core::crypto::{ItemAad, ItemKey}; use std::sync::Arc; -uniffi::custom_type!(RootKEK, Vec, { - remote, - try_lift: |bytes| { - RootKEK::try_from_bytes(&bytes) - .map_err(|e| uniffi::deps::anyhow::anyhow!("{e:?}")) - }, - lower: |key| key.as_bytes().to_vec(), -}); - #[derive(uniffi::Record)] pub struct WrappedKeyBlob { pub ciphertext: Vec, @@ -50,7 +40,7 @@ impl From for KeyWrapError { } } -fn wrap( +pub(crate) fn wrap( wrapper: &Wrapper, target: &Target, aad: &Wrapper::Aad, @@ -66,7 +56,7 @@ where }) } -fn unwrap( +pub(crate) fn unwrap( wrapper: &Wrapper, blob: &WrappedKeyBlob, aad: &Wrapper::Aad, @@ -89,42 +79,6 @@ impl KeyWrapper { Arc::new(Self) } - pub fn wrap_account_root_key( - &self, - kek: RootKEK, - ark: AccountRootKey, - user_id: UserId, - ) -> Result { - wrap::(&kek, &ark, &user_id) - } - - pub fn unwrap_account_root_key( - &self, - kek: RootKEK, - wrapped: WrappedKeyBlob, - user_id: UserId, - ) -> Result { - unwrap::(&kek, &wrapped, &user_id) - } - - pub fn wrap_vault_key( - &self, - ark: AccountRootKey, - vault_key: VaultKey, - vault_id: VaultId, - ) -> Result { - wrap::(&ark, &vault_key, &vault_id) - } - - pub fn unwrap_vault_key( - &self, - ark: AccountRootKey, - wrapped: WrappedKeyBlob, - vault_id: VaultId, - ) -> Result { - unwrap::(&ark, &wrapped, &vault_id) - } - pub fn wrap_item_key( &self, vault_key: VaultKey, diff --git a/rust/rust-code/bindings/src/lib.rs b/rust/rust-code/bindings/src/lib.rs index 5bf113fcb..f82cb37a7 100644 --- a/rust/rust-code/bindings/src/lib.rs +++ b/rust/rust-code/bindings/src/lib.rs @@ -1,11 +1,11 @@ -mod account; +mod ark_session; mod backup; mod card; mod item; -mod key_derivation; mod key_wrap; mod passkey; -pub mod totp; +mod totp; +mod types; mod vault; uniffi::setup_scaffolding!(); diff --git a/rust/rust-code/bindings/src/passkey.rs b/rust/rust-code/bindings/src/passkey.rs index 899c05dfd..282f624af 100644 --- a/rust/rust-code/bindings/src/passkey.rs +++ b/rust/rust-code/bindings/src/passkey.rs @@ -1,8 +1,8 @@ -use lib::passkey::provider::{ProviderError, provide_passkey}; -use lib::passkey::registration::{ +use keygo_core::passkey::{ KeyGoRegistrationResponse, PasskeyInformation as CorePasskeyInformation, RegistrationError, get_passkey_information, register_passkey, }; +use keygo_core::passkey::{ProviderError, provide_passkey}; use std::sync::Arc; #[derive(Debug, thiserror::Error, uniffi::Error)] diff --git a/rust/rust-code/bindings/src/totp.rs b/rust/rust-code/bindings/src/totp.rs index ae6558464..14eae82ec 100644 --- a/rust/rust-code/bindings/src/totp.rs +++ b/rust/rust-code/bindings/src/totp.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use lib::totp::{ +use keygo_core::totp::{ TotpInfo as CoreTotpInfo, get_totp as core_get_totp, get_totp_info_from_uri as core_get_totp_info_from_uri, get_totp_url as core_get_totp_url, }; @@ -13,28 +13,28 @@ pub enum Algorithm { Sha512, } -impl From for lib::totp::Algorithm { +impl From for keygo_core::totp::Algorithm { fn from(value: Algorithm) -> Self { match value { - Algorithm::Sha1 => lib::totp::Algorithm::SHA1, - Algorithm::Sha256 => lib::totp::Algorithm::SHA256, - Algorithm::Sha512 => lib::totp::Algorithm::SHA512, + Algorithm::Sha1 => keygo_core::totp::Algorithm::SHA1, + Algorithm::Sha256 => keygo_core::totp::Algorithm::SHA256, + Algorithm::Sha512 => keygo_core::totp::Algorithm::SHA512, } } } -impl TryFrom for Algorithm { +impl TryFrom for Algorithm { type Error = TotpError; /// `totp_rs::Algorithm` is `#[non_exhaustive]`, so a variant this binding /// does not expose stays representable no matter what we match on. Reaching /// one means the input named an algorithm we cannot hand to Kotlin, which /// is an error to report, not a reason to unwind across the FFI boundary. - fn try_from(value: lib::totp::Algorithm) -> Result { + fn try_from(value: keygo_core::totp::Algorithm) -> Result { match value { - lib::totp::Algorithm::SHA1 => Ok(Algorithm::Sha1), - lib::totp::Algorithm::SHA256 => Ok(Algorithm::Sha256), - lib::totp::Algorithm::SHA512 => Ok(Algorithm::Sha512), + keygo_core::totp::Algorithm::SHA1 => Ok(Algorithm::Sha1), + keygo_core::totp::Algorithm::SHA256 => Ok(Algorithm::Sha256), + keygo_core::totp::Algorithm::SHA512 => Ok(Algorithm::Sha512), _ => Err(TotpError::InvalidInput), } } @@ -74,8 +74,8 @@ pub enum TotpError { InvalidInput, } -impl From for TotpError { - fn from(err: lib::totp::TotpError) -> Self { +impl From for TotpError { + fn from(err: keygo_core::totp::TotpError) -> Self { Self::Generic(err.to_string()) } } diff --git a/rust/rust-code/bindings/src/types.rs b/rust/rust-code/bindings/src/types.rs new file mode 100644 index 000000000..911d98bce --- /dev/null +++ b/rust/rust-code/bindings/src/types.rs @@ -0,0 +1,17 @@ +use keygo_core::crypto::{KeyMaterial, VaultKey}; +use uuid::Uuid; + +uniffi::custom_type!(Uuid, String, { + remote, + try_lift: |s| Uuid::parse_str(&s).map_err(|e| uniffi::deps::anyhow::anyhow!("{e}")), + lower: |u| u.to_string(), +}); + +uniffi::custom_type!(VaultKey, Vec, { + remote, + try_lift: |bytes| { + VaultKey::try_from_bytes(&bytes) + .map_err(|e| uniffi::deps::anyhow::anyhow!("{e:?}")) + }, + lower: |key| key.as_bytes().to_vec(), +}); diff --git a/rust/rust-code/bindings/src/vault.rs b/rust/rust-code/bindings/src/vault.rs index 9d35ad02c..e74f66381 100644 --- a/rust/rust-code/bindings/src/vault.rs +++ b/rust/rust-code/bindings/src/vault.rs @@ -1,4 +1,4 @@ -use lib::crypto::VaultKey; +use keygo_core::crypto::VaultKey; use std::sync::Arc; #[derive(uniffi::Object)] diff --git a/rust/rust-code/lib/Cargo.toml b/rust/rust-code/core/Cargo.toml similarity index 85% rename from rust/rust-code/lib/Cargo.toml rename to rust/rust-code/core/Cargo.toml index 21b4fc323..3e1656875 100644 --- a/rust/rust-code/lib/Cargo.toml +++ b/rust/rust-code/core/Cargo.toml @@ -1,9 +1,12 @@ [package] -name = "lib" -version = "0.1.0" -edition = "2024" +name = "keygo-core" +version.workspace = true +edition.workspace = true [dependencies] +thiserror.workspace = true +uuid.workspace = true + aead = "0.6.0" aes-gcm-siv = "0.12.1" async-trait = "0.1.89" @@ -15,19 +18,18 @@ passkey-authenticator = { version = "0.5.0", features = ["tokio", "testable"] } # Needed so passkey JSON responses are serialized into base64 strings passkey-types = { version = "0.5.0", features = ["serialize_bytes_as_base64_string"] } +argon2 = "0.5.3" +base32 = "0.5.1" base64 = "0.23.1" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" -thiserror = "2.0.18" -url = "2.5.8" -zeroize = "1.8.2" -uuid = { version = "1.23.0", features = ["serde", "v4"] } -rand = { version = "0.10.0", features = ["sys_rng"] } bcs = "0.2.0" -argon2 = "0.5.3" +csv = "1.4.0" +email_address = "0.2.9" hkdf = "0.13.0" +rand = { version = "0.10.0", features = ["sys_rng"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" sha2 = "0.11.0" +subtle = "2.6" totp-rs = { version = "6.0.0", features = ["otpauth", "zeroize"] } -csv = "1.4.0" -email_address = "0.2.9" -base32 = "0.5.1" +url = "2.5.8" +zeroize = "1.8.2" diff --git a/rust/rust-code/core/src/ark_session.rs b/rust/rust-code/core/src/ark_session.rs new file mode 100644 index 000000000..48463b307 --- /dev/null +++ b/rust/rust-code/core/src/ark_session.rs @@ -0,0 +1,527 @@ +use crate::crypto::error::CryptoError; +use crate::crypto::primitive::argon2::MIN_SALT_LEN; +use crate::crypto::primitive::wrap_key::{AeadWrappedKey, KeyWrapper}; +use crate::crypto::random::random_bytes; +use crate::crypto::types::{UserId, VaultId}; +use crate::crypto::{AccountRootKey, KeyMaterial, RootKEK, TryDeriveFrom, VaultKey}; +use std::sync::Mutex; +use subtle::ConstantTimeEq; +use zeroize::Zeroizing; + +#[derive(Debug, thiserror::Error)] +pub enum ArkSessionError { + #[error("No active session")] + Locked, + #[error("Wrong password")] + WrongPassword, + #[error("Key derivation failed: {0}")] + Derivation(String), + #[error("{0}")] + KeyWrap(#[from] CryptoError), +} + +pub struct NewAccount { + pub user_id: UserId, + pub salt: Vec, + pub password_wrapped_ark: AeadWrappedKey, + pub vault_id: VaultId, + pub wrapped_vault_key: AeadWrappedKey, +} + +pub struct PasswordWrapped { + pub salt: Vec, + pub wrapped: AeadWrappedKey, +} + +type ArkSessionResult = Result; + +const PASSWORD_DOMAIN: &[u8] = b"v1:kek/pwd"; +const SALT_LEN: usize = MIN_SALT_LEN; + +pub struct ArkSession { + ark: Mutex>, +} + +impl Default for ArkSession { + fn default() -> Self { + Self::new() + } +} + +impl ArkSession { + pub fn new() -> Self { + Self { + ark: Mutex::new(None), + } + } + + fn unlock( + &self, + kek: RootKEK, + wrapped_key: AeadWrappedKey, + aad: UserId, + ) -> ArkSessionResult<()> { + let ark = kek.unwrap_key(&wrapped_key, &aad)?; + *self.lock() = Some(ark); + Ok(()) + } + + pub fn end(&self) { + self.lock().take(); + } + + pub fn is_active(&self) -> bool { + self.lock().is_some() + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Option> { + self.ark.lock().unwrap_or_else(|e| e.into_inner()) + } + + pub fn unwrap_vault_key( + &self, + wrapped: AeadWrappedKey, + aad: VaultId, + ) -> ArkSessionResult { + let guard = self.lock(); + let ark = guard.as_ref().ok_or(ArkSessionError::Locked)?; + Ok(ark.unwrap_key(&wrapped, &aad)?) + } + + pub fn wrap_vault_key( + &self, + vault_key: VaultKey, + aad: VaultId, + ) -> ArkSessionResult> { + let guard = self.lock(); + let ark = guard.as_ref().ok_or(ArkSessionError::Locked)?; + Ok(ark.wrap_key(&vault_key, &aad)?) + } + + pub fn create_account(&self, password: &str) -> ArkSessionResult { + let user_id = UserId::new_v4(); + let vault_id = VaultId::new_v4(); + let ark = AccountRootKey::generate_random(); + let vault_key = VaultKey::generate_random(); + + let salt = random_bytes::().to_vec(); + let kek = derive_kek(password, &salt)?; + + let password_wrapped_ark = kek.wrap_key(&ark, &user_id)?; + let wrapped_vault_key = ark.wrap_key(&vault_key, &vault_id)?; + + *self.lock() = Some(ark); + + Ok(NewAccount { + user_id, + salt, + password_wrapped_ark, + vault_id, + wrapped_vault_key, + }) + } + + pub fn unlock_with_password( + &self, + password: &str, + salt: &[u8], + wrapped: AeadWrappedKey, + user_id: UserId, + ) -> ArkSessionResult<()> { + let kek = derive_kek(password, salt)?; + self.unlock(kek, wrapped, user_id) + } + + pub fn unlock_with_ark(&self, ark: &[u8]) -> ArkSessionResult<()> { + let ark = AccountRootKey::try_from_bytes(ark)?; + *self.lock() = Some(ark); + Ok(()) + } + + pub fn export_ark(&self) -> ArkSessionResult>> { + self.with_ark(|ark| Zeroizing::new(ark.as_bytes().to_vec())) + } + + pub fn verify_password( + &self, + password: &str, + salt: &[u8], + wrapped: AeadWrappedKey, + user_id: UserId, + ) -> ArkSessionResult<()> { + let kek = derive_kek(password, salt)?; + let stored = kek + .unwrap_key(&wrapped, &user_id) + .map_err(|_| ArkSessionError::WrongPassword)?; + + let guard = self.lock(); + let live = guard.as_ref().ok_or(ArkSessionError::Locked)?; + if bool::from(live.as_bytes().ct_eq(stored.as_bytes())) { + Ok(()) + } else { + Err(ArkSessionError::WrongPassword) + } + } + + pub fn verify_ark(&self, candidate: &[u8]) -> ArkSessionResult { + let guard = self.lock(); + let ark = guard.as_ref().ok_or(ArkSessionError::Locked)?; + Ok(ark.as_bytes().ct_eq(candidate).into()) + } + + pub fn rewrap_for_new_password( + &self, + new_password: &str, + user_id: UserId, + ) -> ArkSessionResult { + let salt = random_bytes::().to_vec(); + let kek = derive_kek(new_password, &salt)?; + + let guard = self.lock(); + let ark = guard.as_ref().ok_or(ArkSessionError::Locked)?; + let wrapped = kek.wrap_key(ark, &user_id)?; + + Ok(PasswordWrapped { salt, wrapped }) + } + + pub fn with_ark(&self, f: impl FnOnce(&AccountRootKey) -> R) -> ArkSessionResult { + let ark = { + let guard = self.lock(); + let live = guard.as_ref().ok_or(ArkSessionError::Locked)?; + AccountRootKey::try_from_bytes(live.as_bytes())? + }; + + Ok(f(&ark)) + } +} + +fn derive_kek(password: &str, salt: &[u8]) -> ArkSessionResult { + RootKEK::try_derive_from(password.as_bytes(), salt, PASSWORD_DOMAIN) + .map_err(|e| ArkSessionError::Derivation(format!("{e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PASSWORD: &str = "hunter2"; + + fn unlocked() -> (ArkSession, NewAccount) { + let session = ArkSession::new(); + let new_account = session.create_account(PASSWORD).unwrap(); + (session, new_account) + } + + #[test] + fn create_account_leaves_session_unlocked() { + let (session, _) = unlocked(); + assert!(session.is_active()); + } + + #[test] + fn create_account_produces_a_blob_the_password_can_unlock() { + let (session, account) = unlocked(); + session.end(); + + session + .unlock_with_password( + PASSWORD, + &account.salt, + account.password_wrapped_ark, + account.user_id, + ) + .unwrap(); + + assert!(session.is_active()); + } + + #[test] + fn create_account_default_vault_key_unwraps_under_the_ark() { + let (session, account) = unlocked(); + + assert!( + session + .unwrap_vault_key(account.wrapped_vault_key, account.vault_id) + .is_ok() + ); + } + + #[test] + fn unlock_with_wrong_password_leaves_session_locked() { + let (session, account) = unlocked(); + session.end(); + + let result = session.unlock_with_password( + "wrong", + &account.salt, + account.password_wrapped_ark, + account.user_id, + ); + + assert!(matches!(result, Err(ArkSessionError::KeyWrap(_)))); + assert!(!session.is_active()); + } + + #[test] + fn unlock_with_password_rejects_a_blob_wrapped_for_a_different_user_id() { + let (session, account) = unlocked(); + session.end(); + + // The blob was wrapped with account.user_id as AAD; unlocking with a different id must + // fail the AEAD tag check, the same binding that stops a blob transplant between users. + let result = session.unlock_with_password( + PASSWORD, + &account.salt, + account.password_wrapped_ark, + UserId::new_v4(), + ); + + assert!(matches!(result, Err(ArkSessionError::KeyWrap(_)))); + assert!(!session.is_active()); + } + + #[test] + fn failed_unlock_on_an_active_session_retains_the_original_ark() { + let (session, account) = unlocked(); + let original = session.export_ark().unwrap(); + + let result = session.unlock_with_password( + "wrong", + &account.salt, + account.password_wrapped_ark, + account.user_id, + ); + + // A failed unlock attempt must not log the user out: the session stays active and keeps + // holding the ARK it had before the attempt. + assert!(result.is_err()); + assert!(session.is_active()); + assert_eq!(session.export_ark().unwrap(), original); + } + + #[test] + fn export_and_unlock_with_ark_round_trip() { + let (session, account) = unlocked(); + let exported = session.export_ark().unwrap(); + + let second = ArkSession::new(); + second.unlock_with_ark(&exported).unwrap(); + + // Both sessions hold the same ARK, so a key wrapped by one unwraps under the other. + let wrapped = session + .wrap_vault_key(VaultKey::generate_random(), account.vault_id) + .unwrap(); + assert!(second.unwrap_vault_key(wrapped, account.vault_id).is_ok()); + } + + #[test] + fn export_ark_fails_once_the_session_ends() { + let (session, _) = unlocked(); + session.end(); + + assert!(matches!(session.export_ark(), Err(ArkSessionError::Locked))); + } + + #[test] + fn unlock_with_ark_rejects_a_wrong_length_key() { + let session = ArkSession::new(); + + assert!(matches!( + session.unlock_with_ark(&[0u8; 8]), + Err(ArkSessionError::KeyWrap(_)) + )); + assert!(!session.is_active()); + } + + #[test] + fn verify_password_accepts_the_current_password_and_rejects_others() { + let (session, account) = unlocked(); + let wrapped = session + .rewrap_for_new_password(PASSWORD, account.user_id) + .unwrap(); + + assert!( + session + .verify_password(PASSWORD, &wrapped.salt, wrapped.wrapped, account.user_id) + .is_ok() + ); + + let wrapped = session + .rewrap_for_new_password(PASSWORD, account.user_id) + .unwrap(); + assert!(matches!( + session.verify_password("nope", &wrapped.salt, wrapped.wrapped, account.user_id), + Err(ArkSessionError::WrongPassword) + )); + } + + #[test] + fn verify_password_rejects_a_blob_that_holds_a_different_ark() { + let (session, _) = unlocked(); + // Same password, another account: the blob opens, but around a key this session does not + // hold. Rewrapping after this would put the new password around the wrong ARK. + let (_, other) = unlocked(); + + assert!(matches!( + session.verify_password( + PASSWORD, + &other.salt, + other.password_wrapped_ark, + other.user_id, + ), + Err(ArkSessionError::WrongPassword) + )); + } + + #[test] + fn verify_password_fails_when_locked() { + let (session, account) = unlocked(); + session.end(); + + assert!(matches!( + session.verify_password( + PASSWORD, + &account.salt, + account.password_wrapped_ark, + account.user_id, + ), + Err(ArkSessionError::Locked) + )); + } + + #[test] + fn verify_ark_matches_only_the_live_ark() { + let (session, _) = unlocked(); + let exported = session.export_ark().unwrap(); + + assert!(session.verify_ark(&exported).unwrap()); + assert!(!session.verify_ark(&[0u8; 32]).unwrap()); + } + + #[test] + fn verify_ark_reports_a_locked_session_rather_than_a_mismatch() { + let (session, _) = unlocked(); + let exported = session.export_ark().unwrap(); + session.end(); + + assert!(matches!( + session.verify_ark(&exported), + Err(ArkSessionError::Locked) + )); + } + + #[test] + fn rewrap_for_new_password_produces_a_blob_the_new_password_unlocks() { + let (session, account) = unlocked(); + + let rewrapped = session + .rewrap_for_new_password("new-password", account.user_id) + .unwrap(); + let ark_before = session.export_ark().unwrap(); + session.end(); + + session + .unlock_with_password( + "new-password", + &rewrapped.salt, + rewrapped.wrapped, + account.user_id, + ) + .unwrap(); + + // Same ARK, only the wrapping changed. + assert_eq!(ark_before, session.export_ark().unwrap()); + } + + #[test] + fn rewrap_uses_a_fresh_salt_each_time() { + let (session, account) = unlocked(); + + let first = session + .rewrap_for_new_password("new-password", account.user_id) + .unwrap(); + let second = session + .rewrap_for_new_password("new-password", account.user_id) + .unwrap(); + + assert_ne!(first.salt, second.salt); + } + + #[test] + fn rewrap_fails_when_locked() { + let session = ArkSession::new(); + + assert!(matches!( + session.rewrap_for_new_password("new-password", UserId::new_v4()), + Err(ArkSessionError::Locked) + )); + } + + /// Known-answer test: pins `PASSWORD_DOMAIN` together with the Argon2 cost profile and the + /// derived key length behind it. Every shipped account's ARK is wrapped under a KEK derived + /// with this exact domain and parameter set, so if any of them drift, no existing account's + /// password can unlock it again. This is the tripwire for that. + #[test] + fn password_domain_is_pinned() { + assert_eq!(PASSWORD_DOMAIN, b"v1:kek/pwd"); + + const SALT: [u8; 16] = [7; 16]; + let kek = derive_kek("hunter2", &SALT).unwrap(); + + assert_eq!( + kek.as_bytes(), + &[ + 243, 77, 26, 134, 177, 95, 102, 67, 54, 167, 232, 38, 115, 170, 132, 28, 98, 29, + 146, 108, 157, 245, 225, 131, 93, 9, 236, 235, 207, 6, 219, 103, + ][..] + ); + } + + #[test] + fn with_ark_does_not_hold_the_lock_across_the_closure() { + let (session, _) = unlocked(); + + // Every one of these takes the same lock. Under a lock held across the closure they would + // all deadlock rather than fail, so this test hanging is itself the regression signal. + let reentered = session + .with_ark(|ark| { + let exported = session.export_ark().unwrap(); + assert_eq!(exported.as_slice(), ark.as_bytes()); + assert!(session.is_active()); + session.verify_ark(ark.as_bytes()).unwrap() + }) + .unwrap(); + + assert!(reentered); + } + + #[test] + fn with_ark_sees_the_ark_that_was_live_when_it_started() { + let (session, _) = unlocked(); + let original = session.export_ark().unwrap(); + + // Ending the session mid-closure is the case the clone exists for: `f` keeps working on + // the key it was handed instead of reading a slot that is now empty. + let observed = session + .with_ark(|ark| { + session.end(); + ark.as_bytes().to_vec() + }) + .unwrap(); + + assert_eq!(observed, *original); + assert!(!session.is_active()); + } + + #[test] + fn with_ark_runs_the_closure_only_when_unlocked() { + let (session, _) = unlocked(); + assert_eq!(session.with_ark(|ark| ark.as_bytes().len()).unwrap(), 32); + + session.end(); + assert!(matches!( + session.with_ark(|_| ()), + Err(ArkSessionError::Locked) + )); + } +} diff --git a/rust/rust-code/lib/src/b64.rs b/rust/rust-code/core/src/b64.rs similarity index 100% rename from rust/rust-code/lib/src/b64.rs rename to rust/rust-code/core/src/b64.rs diff --git a/rust/rust-code/lib/src/backup/encryption.rs b/rust/rust-code/core/src/backup/encryption.rs similarity index 98% rename from rust/rust-code/lib/src/backup/encryption.rs rename to rust/rust-code/core/src/backup/encryption.rs index 26c54e09f..77feb3042 100644 --- a/rust/rust-code/lib/src/backup/encryption.rs +++ b/rust/rust-code/core/src/backup/encryption.rs @@ -1,7 +1,7 @@ use crate::b64; use crate::backup::BackupError; -use crate::backup::key::BackupKey; -use crate::crypto::keys::AccountRootKey; +use crate::backup::BackupKey; +use crate::crypto::AccountRootKey; use crate::crypto::primitive::aead_data::{AeadCiphertext, AeadEncryptor}; use crate::crypto::primitive::argon2::Argon2Params; use crate::crypto::random::random_bytes; @@ -169,9 +169,9 @@ pub fn open( mod tests { use super::*; use crate::backup::CURRENT_VERSION; + use crate::crypto::AccountRootKey; + use crate::crypto::KeyMaterial; use crate::crypto::error::CryptoError; - use crate::crypto::key::KeyMaterial; - use crate::crypto::keys::AccountRootKey; use crate::crypto::primitive::argon2::MAX_ARGON2_MEM_KIB; #[test] diff --git a/rust/rust-code/lib/src/backup/error.rs b/rust/rust-code/core/src/backup/error.rs similarity index 100% rename from rust/rust-code/lib/src/backup/error.rs rename to rust/rust-code/core/src/backup/error.rs diff --git a/rust/rust-code/core/src/backup/format/csv/detect.rs b/rust/rust-code/core/src/backup/format/csv/detect.rs new file mode 100644 index 000000000..83fcf3398 --- /dev/null +++ b/rust/rust-code/core/src/backup/format/csv/detect.rs @@ -0,0 +1,477 @@ +use csv::StringRecord; +use email_address::Options; + +use super::{ALL_FIELDS, ColumnMapping, Confidence, Field, FieldConfidence}; +use crate::totp::is_valid_totp_secret; +use crate::url::sanitize_to_https_url; + +const DELIMITERS: [u8; 4] = *b",;\t|"; + +/// Strip a leading UTF-8 BOM, if present. +pub(super) fn strip_bom(data: &str) -> &str { + data.strip_prefix('\u{feff}').unwrap_or(data) +} + +fn detect_delimiter(data: &str) -> u8 { + let mut best = b','; + let mut best_score = -1i64; + + for &delim in &DELIMITERS { + let mut rdr = csv::ReaderBuilder::new() + .delimiter(delim) + .has_headers(false) // Treat all lines as data for counting + .flexible(true) + .from_reader(data.as_bytes()); + + let mut columns = Vec::with_capacity(5); + for result in rdr.records().take(5) { + match result { + Ok(record) => columns.push(record.len()), + Err(_) => break, // If parsing fails wildly, abandon this delimiter + } + } + if columns.is_empty() { + continue; + } + + let max = *columns.iter().max().unwrap_or(&1); + if max <= 1 { + continue; + } + + let consistent = columns.iter().all(|&c| c == columns[0]); + let score = (consistent as i64) * 1000 + max as i64; + if score > best_score { + best_score = score; + best = delim; + } + } + best +} + +pub(super) fn build_reader(data: &str) -> csv::Reader<&[u8]> { + csv::ReaderBuilder::new() + .delimiter(detect_delimiter(data)) + .has_headers(true) + .flexible(true) + .from_reader(data.as_bytes()) +} + +fn looks_like_email(s: &str) -> bool { + email_address::EmailAddress::parse_with_options(s, Options::default().with_required_tld()) + .is_ok() +} + +fn looks_like_url(s: &str) -> bool { + !looks_like_email(s) && sanitize_to_https_url(s).is_ok() +} + +fn looks_like_totp(s: &str) -> bool { + let s = s.trim(); + if s.is_empty() { + return false; + } + if s.to_ascii_lowercase().starts_with("otpauth://") { + return true; + } + + is_valid_totp_secret(s) && s.len() >= 16 +} + +const HEADER_EXACT: u32 = 100; +const HEADER_CONTAINS: u32 = 30; +const VALUE_MAX: u32 = 50; +const MIN_SCORE: u32 = 25; + +/// Lowercase a header and collapse every run of non-alphanumeric characters +/// (spaces, `_`, `-`, `.`, `/`, ...) into a single space, trimming the ends. This +/// makes `login_uri`, `Login-URI`, `login.uri`, and `Login URI` all compare +/// equal, so a header matches the keyword tables regardless of separator style. +fn normalize_header(header: &str) -> String { + let mut out = String::with_capacity(header.len()); + let mut pending_space = false; + for c in header.chars().flat_map(char::to_lowercase) { + if c.is_alphanumeric() { + if pending_space && !out.is_empty() { + out.push(' '); + } + pending_space = false; + out.push(c); + } else { + pending_space = true; + } + } + out +} + +/// Score a single field against an already-[`normalize_header`]d header. +fn header_score(field: Field, header: &str) -> u32 { + let (exact, contains): (&[&str], &[&str]) = match field { + Field::Title => ( + &[ + "title", + "name", + "account", + "account name", + "item", + "entry", + "display name", + "service", + ], + &["title", "name"], + ), + Field::Url => ( + &[ + "url", + "uri", + "website", + "web site", + "web", + "site", + "link", + "host", + "hostname", + "domain", + "login uri", + "login url", + ], + &[ + "url", "uri", "website", "web", "site", "host", "domain", "link", + ], + ), + Field::Username => ( + &[ + "username", + "user name", + "user", + "user id", + "userid", + "login", + "login name", + "login username", + "email", + "e mail", + ], + &["user", "login", "email"], + ), + Field::Password => ( + &[ + "password", + "pass", + "pwd", + "passwd", + "secret", + "login password", + ], + &["password", "passwd", "pwd"], + ), + Field::Notes => ( + &[ + "notes", + "note", + "comment", + "comments", + "description", + "extra", + "memo", + ], + &["note", "comment", "description", "memo"], + ), + Field::Totp => ( + &[ + "totp", + "otp", + "otpauth", + "2fa", + "two factor", + "twofactor", + "authenticator", + "seed", + "login totp", + ], + &["totp", "otp", "2fa", "authenticator"], + ), + }; + if exact.contains(&header) { + HEADER_EXACT + } else if contains.iter().any(|k| header.contains(k)) { + HEADER_CONTAINS + } else { + 0 + } +} + +struct Profile { + url: f32, + email: f32, + totp: f32, +} + +impl Profile { + fn score(&self, field: Field) -> u32 { + let frac = match field { + Field::Url => self.url, + Field::Username => self.email, + Field::Totp => self.totp, + _ => 0.0, + }; + (frac * VALUE_MAX as f32) as u32 + } +} + +fn profile_column(samples: &[StringRecord], col: usize) -> Profile { + let mut total = 0u32; + let mut url = 0u32; + let mut email = 0u32; + let mut totp = 0u32; + for row in samples { + if let Some(cell) = row.get(col) { + let cell = cell.trim(); + if cell.is_empty() { + continue; + } + total += 1; + + // url and email are mutually exclusive: looks_like_url already + // rejects anything that parses as an email. + if looks_like_url(cell) { + url += 1; + } else if looks_like_email(cell) { + email += 1; + } + + if looks_like_totp(cell) { + totp += 1; + } + } + } + let t = total.max(1) as f32; + Profile { + url: url as f32 / t, + email: email as f32 / t, + totp: totp as f32 / t, + } +} + +/// Greedy best-fit assignment: each column maps to at most one field and each +/// field to at most one column, taking the highest scores first. Ties resolve by +/// field declaration order, then column index, for determinism. +pub(super) fn build_mapping( + headers: &[String], + samples: &[StringRecord], +) -> (ColumnMapping, FieldConfidence) { + let profiles: Vec = (0..headers.len()) + .map(|c| profile_column(samples, c)) + .collect(); + + let mut candidates: Vec<(u32, usize, usize)> = Vec::new(); // (score, field_idx, col) + for (col, header) in headers.iter().enumerate() { + let h = normalize_header(header); + + for (field_idx, field) in ALL_FIELDS.into_iter().enumerate() { + let score = header_score(field, &h) + profiles[col].score(field); + if score >= MIN_SCORE { + candidates.push((score, field_idx, col)); + } + } + } + candidates.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2))); + + let mut mapping = ColumnMapping::default(); + let mut confidence = FieldConfidence::default(); + let mut used_cols = vec![false; headers.len()]; + let mut used_fields = [false; ALL_FIELDS.len()]; + + for (score, field_idx, col) in candidates { + if used_cols[col] || used_fields[field_idx] { + continue; + } + let field = ALL_FIELDS[field_idx]; + mapping.set(field, col); + confidence.set(field, Confidence::from_score(score)); + used_cols[col] = true; + used_fields[field_idx] = true; + } + + (mapping, confidence) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rows(data: &[&[&str]]) -> Vec { + data.iter() + .map(|r| r.iter().map(|c| c.to_string()).collect()) + .collect() + } + + fn hdrs(h: &[&str]) -> Vec { + h.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn maps_chrome_headers() { + let headers = hdrs(&["name", "url", "username", "password", "note"]); + let (m, c) = build_mapping(&headers, &[]); + assert_eq!(m.title, Some(0)); + assert_eq!(m.url, Some(1)); + assert_eq!(m.username, Some(2)); + assert_eq!(m.password, Some(3)); + assert_eq!(m.notes, Some(4)); + assert_eq!(c.password, Some(Confidence::High)); // exact header match + } + + #[test] + fn maps_bitwarden_headers() { + let headers = hdrs(&[ + "folder", + "favorite", + "type", + "name", + "notes", + "fields", + "reprompt", + "login_uri", + "login_username", + "login_password", + "login_totp", + ]); + let (m, _) = build_mapping(&headers, &[]); + assert_eq!(m.title, Some(3)); + assert_eq!(m.notes, Some(4)); + assert_eq!(m.url, Some(7)); + assert_eq!(m.username, Some(8)); + assert_eq!(m.password, Some(9)); + assert_eq!(m.totp, Some(10)); + } + + #[test] + fn maps_keepass_headers_case_insensitively() { + let headers = hdrs(&["Account", "Login Name", "Password", "Web Site", "Comments"]); + let (m, _) = build_mapping(&headers, &[]); + assert_eq!(m.title, Some(0)); + assert_eq!(m.username, Some(1)); + assert_eq!(m.password, Some(2)); + assert_eq!(m.url, Some(3)); + assert_eq!(m.notes, Some(4)); + } + + #[test] + fn maps_ms_headers_titlecase() { + let headers = hdrs(&["Name", "Url", "Username", "Password", "Notes"]); + let (m, _) = build_mapping(&headers, &[]); + assert_eq!(m.title, Some(0)); + assert_eq!(m.url, Some(1)); + assert_eq!(m.username, Some(2)); + assert_eq!(m.password, Some(3)); + assert_eq!(m.notes, Some(4)); + } + + #[test] + fn value_sniffing_drives_vague_headers() { + // Columns 1 and 2 have meaningless headers; only their values reveal them. + let headers = hdrs(&["name", "field_a", "field_b"]); + let samples = rows(&[ + &["Site One", "alice@example.com", "https://one.example"], + &["Site Two", "bob@example.com", "https://two.example"], + ]); + let (m, c) = build_mapping(&headers, &samples); + assert_eq!(m.title, Some(0)); + assert_eq!(m.username, Some(1)); // emails + assert_eq!(m.url, Some(2)); // urls + assert_eq!(c.username, Some(Confidence::Medium)); // value-only match + } + + #[test] + fn unmatched_columns_stay_unmapped() { + let headers = hdrs(&["folder", "favorite", "reprompt"]); + let (m, _) = build_mapping(&headers, &[]); + assert_eq!(m, ColumnMapping::default()); + } + + #[test] + fn header_separators_normalize_to_exact_match() { + // Underscore, hyphen, dot, mixed case, and repeated spaces all normalize + // to one exact-match phrase and earn High (not merely "contains") + // confidence. + for h in [ + "login_username", + "login-username", + "login.username", + "Login Username", + "LOGIN USERNAME", + ] { + let headers = hdrs(&[h, "login-password"]); + let (m, c) = build_mapping(&headers, &[]); + assert_eq!(m.username, Some(0), "{h:?} should map to username"); + assert_eq!(m.password, Some(1), "{h:?} row: password should map"); + assert_eq!( + c.username, + Some(Confidence::High), + "{h:?} should be an exact match" + ); + } + } + + #[test] + fn normalize_header_collapses_separators() { + assert_eq!(normalize_header(" Login_URI "), "login uri"); + assert_eq!(normalize_header("E-Mail"), "e mail"); + assert_eq!(normalize_header("web..site"), "web site"); + assert_eq!(normalize_header("___"), ""); + } + + #[test] + fn detects_comma_semicolon_tab() { + assert_eq!(detect_delimiter("a,b,c\n1,2,3"), b','); + assert_eq!(detect_delimiter("a;b;c\n1;2;3"), b';'); + assert_eq!(detect_delimiter("a\tb\tc\n1\t2\t3"), b'\t'); + } + + #[test] + fn semicolon_wins_when_commas_only_inside_fields() { + // header has no commas; a data cell does. The semicolon count is + // consistent across lines, so it must win over the ragged comma count. + let data = "name;url;notes\nSite;https://x.com;\"a, b, c\""; + assert_eq!(detect_delimiter(data), b';'); + } + + #[test] + fn strips_leading_bom() { + assert_eq!(strip_bom("\u{feff}name,url"), "name,url"); + assert_eq!(strip_bom("name,url"), "name,url"); + } + + #[test] + fn email_detection() { + assert!(looks_like_email("alice@example.com")); + assert!(looks_like_email("a.b+c@mail.co.uk")); + assert!(!looks_like_email("alice@localhost")); // no dot in domain + assert!(!looks_like_email("not an email")); + assert!(!looks_like_email("https://example.com")); + assert!(!looks_like_email("")); + } + + #[test] + fn url_detection() { + assert!(looks_like_url("https://example.com/login")); + assert!(looks_like_url("http://sub.example.org")); + assert!(looks_like_url("example.com")); // bare host + assert!(!looks_like_url("alice@example.com")); // email, not url + assert!(!looks_like_url("just a note")); + assert!(!looks_like_url("")); + } + + #[test] + fn totp_detection() { + assert!(looks_like_totp( + "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP" + )); + assert!(looks_like_totp("JBSWY3DPEHPK3PXP234")); // base32, >=16 chars + assert!(!looks_like_totp("jbsw y3dp ehpk 3pxp 234")); // spaced/lowercase: not importable as-is + assert!(!looks_like_totp("short")); // too short + assert!(!looks_like_totp("has-symbols-!@#$%^&*()")); // not base32 + assert!(!looks_like_totp("")); + } +} diff --git a/rust/rust-code/lib/src/backup/format/csv.rs b/rust/rust-code/core/src/backup/format/csv/mod.rs similarity index 58% rename from rust/rust-code/lib/src/backup/format/csv.rs rename to rust/rust-code/core/src/backup/format/csv/mod.rs index d1aaaf279..ac517c735 100644 --- a/rust/rust-code/lib/src/backup/format/csv.rs +++ b/rust/rust-code/core/src/backup/format/csv/mod.rs @@ -1,8 +1,16 @@ -use crate::backup::{Backup, BackupError, Login, Vault}; -use crate::totp::is_valid_totp_secret; -use crate::url::sanitize_to_https_url; +//! CSV import and export. +//! +//! [`analyze`] inspects an unknown CSV and proposes a [`ColumnMapping`]; the user +//! edits it; [`import`] then reads the file under that mapping. [`export`] writes +//! one back out in a chosen [`ExportPreset`]. The column-guessing heuristics that +//! back `analyze` live in `detect`. + +mod detect; + use csv::StringRecord; -use email_address::Options; + +use self::detect::{build_mapping, build_reader, strip_bom}; +use crate::backup::{Backup, BackupError, Login, Vault}; #[derive(Debug, Default, PartialEq, Eq)] pub struct ColumnMapping { @@ -155,295 +163,7 @@ impl ExportPreset { } } -const DELIMITERS: [u8; 4] = *b",;\t|"; - -/// Strip a leading UTF-8 BOM, if present. -fn strip_bom(data: &str) -> &str { - data.strip_prefix('\u{feff}').unwrap_or(data) -} - -fn detect_delimiter(data: &str) -> u8 { - let mut best = b','; - let mut best_score = -1i64; - - for &delim in &DELIMITERS { - let mut rdr = csv::ReaderBuilder::new() - .delimiter(delim) - .has_headers(false) // Treat all lines as data for counting - .flexible(true) - .from_reader(data.as_bytes()); - - let mut columns = Vec::with_capacity(5); - for result in rdr.records().take(5) { - match result { - Ok(record) => columns.push(record.len()), - Err(_) => break, // If parsing fails wildly, abandon this delimiter - } - } - if columns.is_empty() { - continue; - } - - let max = *columns.iter().max().unwrap_or(&1); - if max <= 1 { - continue; - } - - let consistent = columns.iter().all(|&c| c == columns[0]); - let score = (consistent as i64) * 1000 + max as i64; - if score > best_score { - best_score = score; - best = delim; - } - } - best -} - -fn build_reader(data: &str) -> csv::Reader<&[u8]> { - csv::ReaderBuilder::new() - .delimiter(detect_delimiter(data)) - .has_headers(true) - .flexible(true) - .from_reader(data.as_bytes()) -} - -fn looks_like_email(s: &str) -> bool { - email_address::EmailAddress::parse_with_options(s, Options::default().with_required_tld()) - .is_ok() -} - -fn looks_like_url(s: &str) -> bool { - !looks_like_email(s) && sanitize_to_https_url(s).is_ok() -} - -fn looks_like_totp(s: &str) -> bool { - let s = s.trim(); - if s.is_empty() { - return false; - } - if s.to_ascii_lowercase().starts_with("otpauth://") { - return true; - } - - is_valid_totp_secret(s) && s.len() >= 16 -} - -const HEADER_EXACT: u32 = 100; -const HEADER_CONTAINS: u32 = 30; -const VALUE_MAX: u32 = 50; -const MIN_SCORE: u32 = 25; - -/// Lowercase a header and collapse every run of non-alphanumeric characters -/// (spaces, `_`, `-`, `.`, `/`, ...) into a single space, trimming the ends. This -/// makes `login_uri`, `Login-URI`, `login.uri`, and `Login URI` all compare -/// equal, so a header matches the keyword tables regardless of separator style. -fn normalize_header(header: &str) -> String { - let mut out = String::with_capacity(header.len()); - let mut pending_space = false; - for c in header.chars().flat_map(char::to_lowercase) { - if c.is_alphanumeric() { - if pending_space && !out.is_empty() { - out.push(' '); - } - pending_space = false; - out.push(c); - } else { - pending_space = true; - } - } - out -} - -/// Score a single field against an already-[`normalize_header`]d header. -fn header_score(field: Field, header: &str) -> u32 { - let (exact, contains): (&[&str], &[&str]) = match field { - Field::Title => ( - &[ - "title", - "name", - "account", - "account name", - "item", - "entry", - "display name", - "service", - ], - &["title", "name"], - ), - Field::Url => ( - &[ - "url", - "uri", - "website", - "web site", - "web", - "site", - "link", - "host", - "hostname", - "domain", - "login uri", - "login url", - ], - &[ - "url", "uri", "website", "web", "site", "host", "domain", "link", - ], - ), - Field::Username => ( - &[ - "username", - "user name", - "user", - "user id", - "userid", - "login", - "login name", - "login username", - "email", - "e mail", - ], - &["user", "login", "email"], - ), - Field::Password => ( - &[ - "password", - "pass", - "pwd", - "passwd", - "secret", - "login password", - ], - &["password", "passwd", "pwd"], - ), - Field::Notes => ( - &[ - "notes", - "note", - "comment", - "comments", - "description", - "extra", - "memo", - ], - &["note", "comment", "description", "memo"], - ), - Field::Totp => ( - &[ - "totp", - "otp", - "otpauth", - "2fa", - "two factor", - "twofactor", - "authenticator", - "seed", - "login totp", - ], - &["totp", "otp", "2fa", "authenticator"], - ), - }; - if exact.contains(&header) { - HEADER_EXACT - } else if contains.iter().any(|k| header.contains(k)) { - HEADER_CONTAINS - } else { - 0 - } -} - -struct Profile { - url: f32, - email: f32, - totp: f32, -} - -impl Profile { - fn score(&self, field: Field) -> u32 { - let frac = match field { - Field::Url => self.url, - Field::Username => self.email, - Field::Totp => self.totp, - _ => 0.0, - }; - (frac * VALUE_MAX as f32) as u32 - } -} - -fn profile_column(samples: &[StringRecord], col: usize) -> Profile { - let mut total = 0u32; - let mut url = 0u32; - let mut email = 0u32; - let mut totp = 0u32; - for row in samples { - if let Some(cell) = row.get(col) { - let cell = cell.trim(); - if cell.is_empty() { - continue; - } - total += 1; - - // url and email are mutually exclusive: looks_like_url already - // rejects anything that parses as an email. - if looks_like_url(cell) { - url += 1; - } else if looks_like_email(cell) { - email += 1; - } - - if looks_like_totp(cell) { - totp += 1; - } - } - } - let t = total.max(1) as f32; - Profile { - url: url as f32 / t, - email: email as f32 / t, - totp: totp as f32 / t, - } -} - -/// Greedy best-fit assignment: each column maps to at most one field and each -/// field to at most one column, taking the highest scores first. Ties resolve by -/// field declaration order, then column index, for determinism. -fn build_mapping(headers: &[String], samples: &[StringRecord]) -> (ColumnMapping, FieldConfidence) { - let profiles: Vec = (0..headers.len()) - .map(|c| profile_column(samples, c)) - .collect(); - - let mut candidates: Vec<(u32, usize, usize)> = Vec::new(); // (score, field_idx, col) - for (col, header) in headers.iter().enumerate() { - let h = normalize_header(header); - - for (field_idx, field) in ALL_FIELDS.into_iter().enumerate() { - let score = header_score(field, &h) + profiles[col].score(field); - if score >= MIN_SCORE { - candidates.push((score, field_idx, col)); - } - } - } - candidates.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2))); - - let mut mapping = ColumnMapping::default(); - let mut confidence = FieldConfidence::default(); - let mut used_cols = vec![false; headers.len()]; - let mut used_fields = [false; ALL_FIELDS.len()]; - - for (score, field_idx, col) in candidates { - if used_cols[col] || used_fields[field_idx] { - continue; - } - let field = ALL_FIELDS[field_idx]; - mapping.set(field, col); - confidence.set(field, Confidence::from_score(score)); - used_cols[col] = true; - used_fields[field_idx] = true; - } - - (mapping, confidence) -} - -/// must be `>= DISPLAY_SAMPLES`. +/// How many data rows feed the analysis. Must be `>= DISPLAY_SAMPLES`. const SAMPLE_ROWS: usize = 10; const DISPLAY_SAMPLES: usize = 5; @@ -451,7 +171,7 @@ const DISPLAY_SAMPLES: usize = 5; /// sample values each), a suggested editable mapping, and per-field confidence. /// /// Column types are inferred from the header names and from the first -/// [`SAMPLE_ROWS`] parseable data rows (malformed rows are skipped). Only those +/// `SAMPLE_ROWS` parseable data rows (malformed rows are skipped). Only those /// rows are read, so the result is deterministic and the cost is independent of /// file size. pub fn analyze(data: &str) -> Result { @@ -600,129 +320,6 @@ pub fn export(backup: &Backup, preset: ExportPreset) -> Result Vec { - data.iter() - .map(|r| r.iter().map(|c| c.to_string()).collect()) - .collect() - } - - fn hdrs(h: &[&str]) -> Vec { - h.iter().map(|s| s.to_string()).collect() - } - - #[test] - fn maps_chrome_headers() { - let headers = hdrs(&["name", "url", "username", "password", "note"]); - let (m, c) = build_mapping(&headers, &[]); - assert_eq!(m.title, Some(0)); - assert_eq!(m.url, Some(1)); - assert_eq!(m.username, Some(2)); - assert_eq!(m.password, Some(3)); - assert_eq!(m.notes, Some(4)); - assert_eq!(c.password, Some(Confidence::High)); // exact header match - } - - #[test] - fn maps_bitwarden_headers() { - let headers = hdrs(&[ - "folder", - "favorite", - "type", - "name", - "notes", - "fields", - "reprompt", - "login_uri", - "login_username", - "login_password", - "login_totp", - ]); - let (m, _) = build_mapping(&headers, &[]); - assert_eq!(m.title, Some(3)); - assert_eq!(m.notes, Some(4)); - assert_eq!(m.url, Some(7)); - assert_eq!(m.username, Some(8)); - assert_eq!(m.password, Some(9)); - assert_eq!(m.totp, Some(10)); - } - - #[test] - fn maps_keepass_headers_case_insensitively() { - let headers = hdrs(&["Account", "Login Name", "Password", "Web Site", "Comments"]); - let (m, _) = build_mapping(&headers, &[]); - assert_eq!(m.title, Some(0)); - assert_eq!(m.username, Some(1)); - assert_eq!(m.password, Some(2)); - assert_eq!(m.url, Some(3)); - assert_eq!(m.notes, Some(4)); - } - - #[test] - fn maps_ms_headers_titlecase() { - let headers = hdrs(&["Name", "Url", "Username", "Password", "Notes"]); - let (m, _) = build_mapping(&headers, &[]); - assert_eq!(m.title, Some(0)); - assert_eq!(m.url, Some(1)); - assert_eq!(m.username, Some(2)); - assert_eq!(m.password, Some(3)); - assert_eq!(m.notes, Some(4)); - } - - #[test] - fn value_sniffing_drives_vague_headers() { - // Columns 1 and 2 have meaningless headers; only their values reveal them. - let headers = hdrs(&["name", "field_a", "field_b"]); - let samples = rows(&[ - &["Site One", "alice@example.com", "https://one.example"], - &["Site Two", "bob@example.com", "https://two.example"], - ]); - let (m, c) = build_mapping(&headers, &samples); - assert_eq!(m.title, Some(0)); - assert_eq!(m.username, Some(1)); // emails - assert_eq!(m.url, Some(2)); // urls - assert_eq!(c.username, Some(Confidence::Medium)); // value-only match - } - - #[test] - fn unmatched_columns_stay_unmapped() { - let headers = hdrs(&["folder", "favorite", "reprompt"]); - let (m, _) = build_mapping(&headers, &[]); - assert_eq!(m, ColumnMapping::default()); - } - - #[test] - fn header_separators_normalize_to_exact_match() { - // Underscore, hyphen, dot, mixed case, and repeated spaces all normalize - // to one exact-match phrase and earn High (not merely "contains") - // confidence. - for h in [ - "login_username", - "login-username", - "login.username", - "Login Username", - "LOGIN USERNAME", - ] { - let headers = hdrs(&[h, "login-password"]); - let (m, c) = build_mapping(&headers, &[]); - assert_eq!(m.username, Some(0), "{h:?} should map to username"); - assert_eq!(m.password, Some(1), "{h:?} row: password should map"); - assert_eq!( - c.username, - Some(Confidence::High), - "{h:?} should be an exact match" - ); - } - } - - #[test] - fn normalize_header_collapses_separators() { - assert_eq!(normalize_header(" Login_URI "), "login uri"); - assert_eq!(normalize_header("E-Mail"), "e mail"); - assert_eq!(normalize_header("web..site"), "web site"); - assert_eq!(normalize_header("___"), ""); - } #[test] fn analyze_samples_only_leading_rows_deterministically() { @@ -748,59 +345,6 @@ mod tests { assert_eq!(a1.confidence.url, Some(Confidence::High)); } - #[test] - fn detects_comma_semicolon_tab() { - assert_eq!(detect_delimiter("a,b,c\n1,2,3"), b','); - assert_eq!(detect_delimiter("a;b;c\n1;2;3"), b';'); - assert_eq!(detect_delimiter("a\tb\tc\n1\t2\t3"), b'\t'); - } - - #[test] - fn semicolon_wins_when_commas_only_inside_fields() { - // header has no commas; a data cell does. The semicolon count is - // consistent across lines, so it must win over the ragged comma count. - let data = "name;url;notes\nSite;https://x.com;\"a, b, c\""; - assert_eq!(detect_delimiter(data), b';'); - } - - #[test] - fn strips_leading_bom() { - assert_eq!(strip_bom("\u{feff}name,url"), "name,url"); - assert_eq!(strip_bom("name,url"), "name,url"); - } - - #[test] - fn email_detection() { - assert!(looks_like_email("alice@example.com")); - assert!(looks_like_email("a.b+c@mail.co.uk")); - assert!(!looks_like_email("alice@localhost")); // no dot in domain - assert!(!looks_like_email("not an email")); - assert!(!looks_like_email("https://example.com")); - assert!(!looks_like_email("")); - } - - #[test] - fn url_detection() { - assert!(looks_like_url("https://example.com/login")); - assert!(looks_like_url("http://sub.example.org")); - assert!(looks_like_url("example.com")); // bare host - assert!(!looks_like_url("alice@example.com")); // email, not url - assert!(!looks_like_url("just a note")); - assert!(!looks_like_url("")); - } - - #[test] - fn totp_detection() { - assert!(looks_like_totp( - "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP" - )); - assert!(looks_like_totp("JBSWY3DPEHPK3PXP234")); // base32, >=16 chars - assert!(!looks_like_totp("jbsw y3dp ehpk 3pxp 234")); // spaced/lowercase: not importable as-is - assert!(!looks_like_totp("short")); // too short - assert!(!looks_like_totp("has-symbols-!@#$%^&*()")); // not base32 - assert!(!looks_like_totp("")); - } - const CHROME_CSV: &str = "name,url,username,password,note\n\ Email,https://mail.example,alice,s3cr3t,primary\n\ Bank,https://bank.example,bob,hunter2,\n"; diff --git a/rust/rust-code/lib/src/backup/format/json.rs b/rust/rust-code/core/src/backup/format/json.rs similarity index 99% rename from rust/rust-code/lib/src/backup/format/json.rs rename to rust/rust-code/core/src/backup/format/json.rs index 2c427b4fa..8dcc6e4e4 100644 --- a/rust/rust-code/lib/src/backup/format/json.rs +++ b/rust/rust-code/core/src/backup/format/json.rs @@ -48,9 +48,9 @@ mod tests { use super::*; use crate::backup::encryption::{Kdf, KeySource}; use crate::backup::{Card, Login, Passkey, Vault}; + use crate::crypto::AccountRootKey; + use crate::crypto::KeyMaterial; use crate::crypto::error::CryptoError; - use crate::crypto::key::KeyMaterial; - use crate::crypto::keys::AccountRootKey; fn sample_backup() -> Backup { Backup { diff --git a/rust/rust-code/lib/src/backup/format/mod.rs b/rust/rust-code/core/src/backup/format/mod.rs similarity index 100% rename from rust/rust-code/lib/src/backup/format/mod.rs rename to rust/rust-code/core/src/backup/format/mod.rs diff --git a/rust/rust-code/lib/src/backup/key.rs b/rust/rust-code/core/src/backup/key.rs similarity index 94% rename from rust/rust-code/lib/src/backup/key.rs rename to rust/rust-code/core/src/backup/key.rs index 1f4ae465e..4df86f847 100644 --- a/rust/rust-code/lib/src/backup/key.rs +++ b/rust/rust-code/core/src/backup/key.rs @@ -1,6 +1,6 @@ +use crate::crypto::AccountRootKey; +use crate::crypto::KeyMaterial; use crate::crypto::error::CryptoResult; -use crate::crypto::key::KeyMaterial; -use crate::crypto::keys::AccountRootKey; use crate::crypto::primitive::argon2::{Argon2Params, derive_argon2id_with_params}; use crate::crypto::primitive::hkdf::derive_hkdf_sha256; use crate::define_aead_key; @@ -32,8 +32,8 @@ impl BackupKey { #[cfg(test)] mod tests { use super::*; - use crate::crypto::key::KeyMaterial; - use crate::crypto::keys::AccountRootKey; + use crate::crypto::AccountRootKey; + use crate::crypto::KeyMaterial; const SALT: &[u8] = &[3u8; 16]; diff --git a/rust/rust-code/lib/src/backup/mod.rs b/rust/rust-code/core/src/backup/mod.rs similarity index 70% rename from rust/rust-code/lib/src/backup/mod.rs rename to rust/rust-code/core/src/backup/mod.rs index 6023e3ed0..cea814172 100644 --- a/rust/rust-code/lib/src/backup/mod.rs +++ b/rust/rust-code/core/src/backup/mod.rs @@ -1,10 +1,10 @@ -pub mod encryption; -pub mod error; -pub mod format; -pub mod key; -pub mod model; +mod encryption; +mod error; +mod format; +mod key; +mod model; -pub use encryption::BackupCredential; +pub use encryption::{BackupCredential, KeySource}; pub use error::BackupError; pub use format::csv::{ ColumnMapping, Confidence, CsvAnalysis, CsvColumn, ExportPreset, FieldConfidence, ImportReport, @@ -18,8 +18,8 @@ pub const CURRENT_VERSION: u32 = 1; /// Oldest envelope version this build can still read. Backups are long-lived: a /// file written by an old build may be restored by a much newer one. When -/// `CURRENT_VERSION` is bumped, keep older versions readable here (and preserve -/// their exact AAD/BCS layout - see [`encryption::BackupAad`]) instead of +/// [`CURRENT_VERSION`] is bumped, keep older versions readable here (and +/// preserve their exact AAD/BCS layout - see `encryption::BackupAad`) instead of /// rejecting them. Per-version decode branches belong in the format's `import`, /// keyed off the envelope version. pub const MIN_SUPPORTED_VERSION: u32 = 1; diff --git a/rust/rust-code/lib/src/backup/model.rs b/rust/rust-code/core/src/backup/model.rs similarity index 100% rename from rust/rust-code/lib/src/backup/model.rs rename to rust/rust-code/core/src/backup/model.rs diff --git a/rust/rust-code/lib/src/card/expiration.rs b/rust/rust-code/core/src/card/expiration.rs similarity index 100% rename from rust/rust-code/lib/src/card/expiration.rs rename to rust/rust-code/core/src/card/expiration.rs diff --git a/rust/rust-code/lib/src/card/mod.rs b/rust/rust-code/core/src/card/mod.rs similarity index 96% rename from rust/rust-code/lib/src/card/mod.rs rename to rust/rust-code/core/src/card/mod.rs index ee128369c..2a80b08c1 100644 --- a/rust/rust-code/lib/src/card/mod.rs +++ b/rust/rust-code/core/src/card/mod.rs @@ -10,7 +10,7 @@ //! value or on text being typed live into a field. //! //! ``` -//! use lib::card::{Card, CardNetwork}; +//! use keygo_core::card::{Card, CardNetwork}; //! //! let card = Card::parse("378282246310005"); //! assert_eq!(card.network, CardNetwork::Amex); diff --git a/rust/rust-code/lib/src/card/network.rs b/rust/rust-code/core/src/card/network.rs similarity index 100% rename from rust/rust-code/lib/src/card/network.rs rename to rust/rust-code/core/src/card/network.rs diff --git a/rust/rust-code/lib/src/card/number.rs b/rust/rust-code/core/src/card/number.rs similarity index 100% rename from rust/rust-code/lib/src/card/number.rs rename to rust/rust-code/core/src/card/number.rs diff --git a/rust/rust-code/lib/src/crypto/error.rs b/rust/rust-code/core/src/crypto/error.rs similarity index 78% rename from rust/rust-code/lib/src/crypto/error.rs rename to rust/rust-code/core/src/crypto/error.rs index c208d5ed6..fbb15c725 100644 --- a/rust/rust-code/lib/src/crypto/error.rs +++ b/rust/rust-code/core/src/crypto/error.rs @@ -12,8 +12,6 @@ pub enum CryptoError { #[error("Key unwrap failed: wrong key or corrupted data")] KeyUnwrapFailed, - #[error("CBOR serialisation failed: {0}")] - CborError(String), #[error("BCS serialisation failed: {0}")] BCS(String), @@ -23,11 +21,6 @@ pub enum CryptoError { #[error("Invalid key material")] InvalidKey, - #[error("HPKE encapsulation failed: {0}")] - HpkeEncapFailed(String), - #[error("HPKE decapsulation failed: {0}")] - HpkeDecapFailed(String), - #[error("Key derivation failed: {0}")] KdfError(String), #[error("Invalid key length: expected {expected} bytes, got {got} bytes")] diff --git a/rust/rust-code/lib/src/crypto/key.rs b/rust/rust-code/core/src/crypto/key.rs similarity index 100% rename from rust/rust-code/lib/src/crypto/key.rs rename to rust/rust-code/core/src/crypto/key.rs diff --git a/rust/rust-code/lib/src/crypto/keys/account_root_key.rs b/rust/rust-code/core/src/crypto/keys/account_root_key.rs similarity index 100% rename from rust/rust-code/lib/src/crypto/keys/account_root_key.rs rename to rust/rust-code/core/src/crypto/keys/account_root_key.rs diff --git a/rust/rust-code/lib/src/crypto/keys/item_key.rs b/rust/rust-code/core/src/crypto/keys/item_key.rs similarity index 100% rename from rust/rust-code/lib/src/crypto/keys/item_key.rs rename to rust/rust-code/core/src/crypto/keys/item_key.rs diff --git a/rust/rust-code/core/src/crypto/keys/mod.rs b/rust/rust-code/core/src/crypto/keys/mod.rs new file mode 100644 index 000000000..464857873 --- /dev/null +++ b/rust/rust-code/core/src/crypto/keys/mod.rs @@ -0,0 +1,17 @@ +mod account_root_key; +mod item_key; +mod root_kek; +mod signing_key; +mod vault_key; + +use crate::crypto::error::CryptoResult; + +pub use account_root_key::AccountRootKey; +pub use item_key::{ItemAad, ItemDataAad, ItemKey}; +pub use root_kek::RootKEK; +pub use signing_key::ScopedSigningKey; +pub use vault_key::VaultKey; + +pub trait TryDeriveFrom: Sized { + fn try_derive_from(source: T, salt: &[u8], domain: &[u8]) -> CryptoResult; +} diff --git a/rust/rust-code/lib/src/crypto/keys/root_kek.rs b/rust/rust-code/core/src/crypto/keys/root_kek.rs similarity index 95% rename from rust/rust-code/lib/src/crypto/keys/root_kek.rs rename to rust/rust-code/core/src/crypto/keys/root_kek.rs index e9773f048..7f5fb6ada 100644 --- a/rust/rust-code/lib/src/crypto/keys/root_kek.rs +++ b/rust/rust-code/core/src/crypto/keys/root_kek.rs @@ -1,7 +1,7 @@ +use crate::crypto::AccountRootKey; +use crate::crypto::KeyMaterial; use crate::crypto::TryDeriveFrom; use crate::crypto::error::CryptoResult; -use crate::crypto::key::KeyMaterial; -use crate::crypto::keys::account_root_key::AccountRootKey; use crate::crypto::primitive::argon2::derive_argon2id; use crate::crypto::types::UserId; use crate::{define_aead_key, define_wrap}; diff --git a/rust/rust-code/lib/src/crypto/keys/signing_key.rs b/rust/rust-code/core/src/crypto/keys/signing_key.rs similarity index 98% rename from rust/rust-code/lib/src/crypto/keys/signing_key.rs rename to rust/rust-code/core/src/crypto/keys/signing_key.rs index be2490f52..35f291396 100644 --- a/rust/rust-code/lib/src/crypto/keys/signing_key.rs +++ b/rust/rust-code/core/src/crypto/keys/signing_key.rs @@ -1,5 +1,5 @@ +use crate::crypto::KeyMaterial; use crate::crypto::error::{CryptoError, CryptoResult}; -use crate::crypto::key::KeyMaterial; use crate::crypto::primitive::wrap_key::KeyWrapper; use ed25519_dalek::{SECRET_KEY_LENGTH, Signature, Signer, SigningKey}; use rand::rand_core::UnwrapErr; diff --git a/rust/rust-code/lib/src/crypto/keys/vault_key.rs b/rust/rust-code/core/src/crypto/keys/vault_key.rs similarity index 82% rename from rust/rust-code/lib/src/crypto/keys/vault_key.rs rename to rust/rust-code/core/src/crypto/keys/vault_key.rs index 3dbf31437..198d10998 100644 --- a/rust/rust-code/lib/src/crypto/keys/vault_key.rs +++ b/rust/rust-code/core/src/crypto/keys/vault_key.rs @@ -1,4 +1,4 @@ -use crate::crypto::keys::account_root_key::AccountRootKey; +use crate::crypto::AccountRootKey; use crate::crypto::types::VaultId; use crate::{define_aead_key, define_wrap}; use aes_gcm_siv::Aes256GcmSiv; diff --git a/rust/rust-code/lib/src/crypto/macros.rs b/rust/rust-code/core/src/crypto/macros.rs similarity index 93% rename from rust/rust-code/lib/src/crypto/macros.rs rename to rust/rust-code/core/src/crypto/macros.rs index 08af68736..b3ec2305b 100644 --- a/rust/rust-code/lib/src/crypto/macros.rs +++ b/rust/rust-code/core/src/crypto/macros.rs @@ -11,7 +11,7 @@ macro_rules! define_wrap { #[macro_export] macro_rules! define_scoped_signing_key { (wrapper = $wrapper:ident, key = $key:ident, wrapped_key = $wrapped:ident, aad = $aad:path $(,)?) => { - pub type $key = $crate::crypto::keys::signing_key::ScopedSigningKey<$wrapper>; + pub type $key = $crate::crypto::ScopedSigningKey<$wrapper>; pub type $wrapped = <$wrapper as $crate::crypto::primitive::wrap_key::KeyWrapper< ::ed25519_dalek::SigningKey, >>::Wrapped; @@ -65,7 +65,7 @@ macro_rules! define_aead_key { #[derive(::zeroize::Zeroize, ::zeroize::ZeroizeOnDrop)] $vis struct $name(::aead::Key<$algo>); - impl $crate::crypto::key::AeadKey for $name { + impl $crate::crypto::AeadKey for $name { type Algorithm = $algo; fn key(&self) -> &::aead::Key { @@ -73,7 +73,7 @@ macro_rules! define_aead_key { } } - impl $crate::crypto::key::KeyMaterial for $name { + impl $crate::crypto::KeyMaterial for $name { fn try_from_bytes(bytes: &[u8]) -> $crate::crypto::error::CryptoResult { let key = ::aead::Key::<$algo>::try_from(bytes).map_err(|_| $crate::crypto::error::CryptoError::InvalidKeyLength { diff --git a/rust/rust-code/core/src/crypto/mod.rs b/rust/rust-code/core/src/crypto/mod.rs new file mode 100644 index 000000000..c427f0d3c --- /dev/null +++ b/rust/rust-code/core/src/crypto/mod.rs @@ -0,0 +1,13 @@ +pub mod error; +mod key; +mod keys; +mod macros; +pub mod primitive; +pub mod random; +pub mod types; + +pub use key::{AeadKey, KeyMaterial}; +pub use keys::{ + AccountRootKey, ItemAad, ItemDataAad, ItemKey, RootKEK, ScopedSigningKey, TryDeriveFrom, + VaultKey, +}; diff --git a/rust/rust-code/lib/src/crypto/primitive/aead_data.rs b/rust/rust-code/core/src/crypto/primitive/aead_data.rs similarity index 99% rename from rust/rust-code/lib/src/crypto/primitive/aead_data.rs rename to rust/rust-code/core/src/crypto/primitive/aead_data.rs index d720aef86..59723ab1d 100644 --- a/rust/rust-code/lib/src/crypto/primitive/aead_data.rs +++ b/rust/rust-code/core/src/crypto/primitive/aead_data.rs @@ -1,5 +1,5 @@ +use crate::crypto::AeadKey; use crate::crypto::error::{CryptoError, CryptoResult}; -use crate::crypto::key::AeadKey; use aead::{Aead, Generate, KeyInit, Nonce, Payload}; use serde::Serialize; diff --git a/rust/rust-code/lib/src/crypto/primitive/argon2.rs b/rust/rust-code/core/src/crypto/primitive/argon2.rs similarity index 100% rename from rust/rust-code/lib/src/crypto/primitive/argon2.rs rename to rust/rust-code/core/src/crypto/primitive/argon2.rs diff --git a/rust/rust-code/lib/src/crypto/primitive/hkdf.rs b/rust/rust-code/core/src/crypto/primitive/hkdf.rs similarity index 100% rename from rust/rust-code/lib/src/crypto/primitive/hkdf.rs rename to rust/rust-code/core/src/crypto/primitive/hkdf.rs diff --git a/rust/rust-code/lib/src/crypto/primitive/mod.rs b/rust/rust-code/core/src/crypto/primitive/mod.rs similarity index 100% rename from rust/rust-code/lib/src/crypto/primitive/mod.rs rename to rust/rust-code/core/src/crypto/primitive/mod.rs diff --git a/rust/rust-code/lib/src/crypto/primitive/wrap_key.rs b/rust/rust-code/core/src/crypto/primitive/wrap_key.rs similarity index 98% rename from rust/rust-code/lib/src/crypto/primitive/wrap_key.rs rename to rust/rust-code/core/src/crypto/primitive/wrap_key.rs index 626b971e4..1edd21222 100644 --- a/rust/rust-code/lib/src/crypto/primitive/wrap_key.rs +++ b/rust/rust-code/core/src/crypto/primitive/wrap_key.rs @@ -1,5 +1,5 @@ use crate::crypto::error::{CryptoError, CryptoResult}; -use crate::crypto::key::{AeadKey, KeyMaterial}; +use crate::crypto::{AeadKey, KeyMaterial}; use aead::{Aead, Generate, KeyInit, Nonce, Payload}; use serde::Serialize; use std::marker::PhantomData; @@ -128,7 +128,7 @@ where mod tests { use super::KeyWrapper; use crate::crypto::error::{CryptoError, CryptoResult}; - use crate::crypto::key::{AeadKey, KeyMaterial}; + use crate::crypto::{AeadKey, KeyMaterial}; use aead::Key; use aes_gcm_siv::Aes256GcmSiv; use serde::Serialize; diff --git a/rust/rust-code/lib/src/crypto/random.rs b/rust/rust-code/core/src/crypto/random.rs similarity index 100% rename from rust/rust-code/lib/src/crypto/random.rs rename to rust/rust-code/core/src/crypto/random.rs diff --git a/rust/rust-code/lib/src/crypto/types.rs b/rust/rust-code/core/src/crypto/types.rs similarity index 100% rename from rust/rust-code/lib/src/crypto/types.rs rename to rust/rust-code/core/src/crypto/types.rs diff --git a/rust/rust-code/lib/src/lib.rs b/rust/rust-code/core/src/lib.rs similarity index 81% rename from rust/rust-code/lib/src/lib.rs rename to rust/rust-code/core/src/lib.rs index 8e72d71d6..11ca2d60d 100644 --- a/rust/rust-code/lib/src/lib.rs +++ b/rust/rust-code/core/src/lib.rs @@ -1,8 +1,8 @@ +pub mod ark_session; mod b64; pub mod backup; pub mod card; pub mod crypto; -pub mod item; pub mod passkey; pub mod totp; mod url; diff --git a/rust/rust-code/lib/src/passkey/authenticator.rs b/rust/rust-code/core/src/passkey/authenticator.rs similarity index 100% rename from rust/rust-code/lib/src/passkey/authenticator.rs rename to rust/rust-code/core/src/passkey/authenticator.rs diff --git a/rust/rust-code/lib/src/passkey/keygo_passkey.rs b/rust/rust-code/core/src/passkey/keygo_passkey.rs similarity index 100% rename from rust/rust-code/lib/src/passkey/keygo_passkey.rs rename to rust/rust-code/core/src/passkey/keygo_passkey.rs diff --git a/rust/rust-code/core/src/passkey/mod.rs b/rust/rust-code/core/src/passkey/mod.rs new file mode 100644 index 000000000..9fcf4f0f7 --- /dev/null +++ b/rust/rust-code/core/src/passkey/mod.rs @@ -0,0 +1,11 @@ +mod authenticator; +mod keygo_passkey; +mod provider; +mod registration; + +pub use keygo_passkey::PasskeyCodecError; +pub use provider::{ProviderError, provide_passkey}; +pub use registration::{ + KeyGoRegistrationResponse, PasskeyInformation, RegistrationError, get_passkey_information, + register_passkey, +}; diff --git a/rust/rust-code/lib/src/passkey/provider.rs b/rust/rust-code/core/src/passkey/provider.rs similarity index 100% rename from rust/rust-code/lib/src/passkey/provider.rs rename to rust/rust-code/core/src/passkey/provider.rs diff --git a/rust/rust-code/lib/src/passkey/registration.rs b/rust/rust-code/core/src/passkey/registration.rs similarity index 100% rename from rust/rust-code/lib/src/passkey/registration.rs rename to rust/rust-code/core/src/passkey/registration.rs diff --git a/rust/rust-code/lib/src/totp.rs b/rust/rust-code/core/src/totp.rs similarity index 100% rename from rust/rust-code/lib/src/totp.rs rename to rust/rust-code/core/src/totp.rs diff --git a/rust/rust-code/lib/src/url.rs b/rust/rust-code/core/src/url.rs similarity index 100% rename from rust/rust-code/lib/src/url.rs rename to rust/rust-code/core/src/url.rs diff --git a/rust/rust-code/lib/src/crypto/keys/mod.rs b/rust/rust-code/lib/src/crypto/keys/mod.rs deleted file mode 100644 index 36c69381f..000000000 --- a/rust/rust-code/lib/src/crypto/keys/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -pub mod account_root_key; -pub mod item_key; -pub mod root_kek; -pub mod signing_key; -pub mod vault_key; - -use crate::crypto::error::CryptoResult; -pub use account_root_key::*; -pub use root_kek::*; -pub use signing_key::*; -pub use vault_key::*; - -pub trait TryDeriveFrom: Sized { - fn try_derive_from(source: T, salt: &[u8], domain: &[u8]) -> CryptoResult; -} diff --git a/rust/rust-code/lib/src/crypto/mod.rs b/rust/rust-code/lib/src/crypto/mod.rs deleted file mode 100644 index fa25f4597..000000000 --- a/rust/rust-code/lib/src/crypto/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -pub mod error; -pub mod key; -pub mod keys; -mod macros; -pub mod primitive; -pub mod random; -pub mod types; - -pub use key::*; -pub use keys::*; diff --git a/rust/rust-code/lib/src/item/account.rs b/rust/rust-code/lib/src/item/account.rs deleted file mode 100644 index 7d6cf8e52..000000000 --- a/rust/rust-code/lib/src/item/account.rs +++ /dev/null @@ -1,16 +0,0 @@ -use crate::crypto::AccountRootKey; -use crate::crypto::types::UserId; - -pub struct Account { - pub id: UserId, - pub ark: AccountRootKey, -} - -impl Account { - pub fn generate_new() -> Self { - Self { - id: UserId::new_v4(), - ark: AccountRootKey::generate_random(), - } - } -} diff --git a/rust/rust-code/lib/src/item/create_account.rs b/rust/rust-code/lib/src/item/create_account.rs deleted file mode 100644 index d665ca2ea..000000000 --- a/rust/rust-code/lib/src/item/create_account.rs +++ /dev/null @@ -1,16 +0,0 @@ -use crate::item::account::Account; -use crate::item::vault::Vault; - -pub struct CreateAccount { - pub account: Account, - pub default_vault: Vault, -} - -impl CreateAccount { - pub fn generate_new() -> Self { - Self { - account: Account::generate_new(), - default_vault: Vault::generate_new(), - } - } -} diff --git a/rust/rust-code/lib/src/item/mod.rs b/rust/rust-code/lib/src/item/mod.rs deleted file mode 100644 index a7128dda1..000000000 --- a/rust/rust-code/lib/src/item/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod account; -pub mod create_account; -pub mod vault; diff --git a/rust/rust-code/lib/src/item/vault.rs b/rust/rust-code/lib/src/item/vault.rs deleted file mode 100644 index 738ee3d8e..000000000 --- a/rust/rust-code/lib/src/item/vault.rs +++ /dev/null @@ -1,16 +0,0 @@ -use crate::crypto::VaultKey; -use crate::crypto::types::VaultId; - -pub struct Vault { - pub id: VaultId, - pub vault_key: VaultKey, -} - -impl Vault { - pub fn generate_new() -> Self { - Self { - id: VaultId::new_v4(), - vault_key: VaultKey::generate_random(), - } - } -} diff --git a/rust/rust-code/lib/src/passkey/mod.rs b/rust/rust-code/lib/src/passkey/mod.rs deleted file mode 100644 index 13a703942..000000000 --- a/rust/rust-code/lib/src/passkey/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -mod authenticator; -pub mod keygo_passkey; -pub mod provider; -pub mod registration; diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/account/AccountManager.kt b/rust/src/main/kotlin/de/davis/keygo/rust/account/AccountManager.kt deleted file mode 100644 index 3321197fd..000000000 --- a/rust/src/main/kotlin/de/davis/keygo/rust/account/AccountManager.kt +++ /dev/null @@ -1,5 +0,0 @@ -package de.davis.keygo.rust.account - -import de.davisalessandro.keygo.rust.AccountManagerInterface - -typealias AccountManager = AccountManagerInterface \ No newline at end of file diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/derive/KeyDeriver.kt b/rust/src/main/kotlin/de/davis/keygo/rust/derive/KeyDeriver.kt deleted file mode 100644 index 66601ddc5..000000000 --- a/rust/src/main/kotlin/de/davis/keygo/rust/derive/KeyDeriver.kt +++ /dev/null @@ -1,22 +0,0 @@ -package de.davis.keygo.rust.derive - -import de.davis.keygo.core.util.Result -import de.davisalessandro.keygo.rust.KeyDerivationException -import de.davisalessandro.keygo.rust.KeyDeriverInterface -import de.davisalessandro.keygo.rust.RootKek -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -typealias KeyDeriver = KeyDeriverInterface - -suspend fun KeyDeriver.deriveRootKekFromPasswordWithResult( - password: String, - salt: ByteArray, -): Result = withContext(Dispatchers.Default) { - runCatching { - deriveRootKekFromPassword(password, salt) - }.fold( - onSuccess = { Result.Success(it) }, - onFailure = { Result.Failure(it as KeyDerivationException) } - ) -} diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/di/RustModule.kt b/rust/src/main/kotlin/de/davis/keygo/rust/di/RustModule.kt index db7ebb225..d33d05b9c 100644 --- a/rust/src/main/kotlin/de/davis/keygo/rust/di/RustModule.kt +++ b/rust/src/main/kotlin/de/davis/keygo/rust/di/RustModule.kt @@ -1,7 +1,5 @@ package de.davis.keygo.rust.di -import de.davisalessandro.keygo.rust.AccountManager -import de.davisalessandro.keygo.rust.AccountManagerInterface import de.davisalessandro.keygo.rust.CardFormatter import de.davisalessandro.keygo.rust.CardFormatterInterface import de.davisalessandro.keygo.rust.CsvBackupManager @@ -10,8 +8,6 @@ import de.davisalessandro.keygo.rust.ItemManager import de.davisalessandro.keygo.rust.ItemManagerInterface import de.davisalessandro.keygo.rust.JsonBackupManager import de.davisalessandro.keygo.rust.JsonBackupManagerInterface -import de.davisalessandro.keygo.rust.KeyDeriver -import de.davisalessandro.keygo.rust.KeyDeriverInterface import de.davisalessandro.keygo.rust.KeyWrapper import de.davisalessandro.keygo.rust.KeyWrapperInterface import de.davisalessandro.keygo.rust.RustPasskey @@ -37,15 +33,9 @@ object RustModule { @Single internal fun providePasskeyManager(): RustPasskeyInterface = RustPasskey() - @Single - internal fun provideAccountManager(): AccountManagerInterface = AccountManager() - @Single internal fun provideKeyWrapper(): KeyWrapperInterface = KeyWrapper() - @Single - internal fun provideKeyDeriver(): KeyDeriverInterface = KeyDeriver() - @Single internal fun provideItemManager(): ItemManagerInterface = ItemManager() diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/wrap/KeyWrapper.kt b/rust/src/main/kotlin/de/davis/keygo/rust/wrap/KeyWrapper.kt index 8bb59cec5..2ad7b2229 100644 --- a/rust/src/main/kotlin/de/davis/keygo/rust/wrap/KeyWrapper.kt +++ b/rust/src/main/kotlin/de/davis/keygo/rust/wrap/KeyWrapper.kt @@ -1,62 +1,15 @@ package de.davis.keygo.rust.wrap import de.davis.keygo.core.util.Result -import de.davisalessandro.keygo.rust.AccountRootKey import de.davisalessandro.keygo.rust.ItemAad import de.davisalessandro.keygo.rust.ItemKey import de.davisalessandro.keygo.rust.KeyWrapException import de.davisalessandro.keygo.rust.KeyWrapperInterface -import de.davisalessandro.keygo.rust.RootKek import de.davisalessandro.keygo.rust.VaultKey import de.davisalessandro.keygo.rust.WrappedKeyBlob -import java.util.UUID typealias KeyWrapper = KeyWrapperInterface -fun KeyWrapper.unwrapAccountRootKeyWithResult( - kek: RootKek, - wrapped: WrappedKeyBlob, - userId: UUID, -): Result = runCatching { - unwrapAccountRootKey(kek, wrapped, userId) -}.fold( - onSuccess = { Result.Success(it) }, - onFailure = { Result.Failure(it as KeyWrapException) } -) - -fun KeyWrapper.unwrapVaultKeyWithResult( - ark: AccountRootKey, - wrapped: WrappedKeyBlob, - vaultId: UUID, -): Result = runCatching { - unwrapVaultKey(ark, wrapped, vaultId) -}.fold( - onSuccess = { Result.Success(it) }, - onFailure = { Result.Failure(it as KeyWrapException) } -) - -fun KeyWrapper.wrapAccountRootKeyWithResult( - kek: RootKek, - ark: AccountRootKey, - userId: UUID, -): Result = runCatching { - wrapAccountRootKey(kek, ark, userId) -}.fold( - onSuccess = { Result.Success(it) }, - onFailure = { Result.Failure(it as KeyWrapException) } -) - -fun KeyWrapper.wrapVaultKeyWithResult( - ark: AccountRootKey, - vaultKey: VaultKey, - vaultId: UUID, -): Result = runCatching { - wrapVaultKey(ark, vaultKey, vaultId) -}.fold( - onSuccess = { Result.Success(it) }, - onFailure = { Result.Failure(it as KeyWrapException) } -) - fun KeyWrapper.wrapItemKeyWithResult( vaultKey: VaultKey, itemKey: ItemKey, diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeAccountManager.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeAccountManager.kt deleted file mode 100644 index 87c54f00a..000000000 --- a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeAccountManager.kt +++ /dev/null @@ -1,39 +0,0 @@ -package de.davis.keygo.rust - -import de.davisalessandro.keygo.rust.Account -import de.davisalessandro.keygo.rust.AccountManagerInterface -import de.davisalessandro.keygo.rust.CreateAccount -import de.davisalessandro.keygo.rust.Vault -import java.util.UUID - -/** - * In-memory [AccountManagerInterface] for tests. [seedAccount] MUST be called before any - * [createAccount] calls. - */ -class FakeAccountManager : AccountManagerInterface { - - var key: ByteArray = ByteArray(32) { it.toByte() } - - var createAccount: CreateAccount = CreateAccount( - account = Account( - id = UUID.randomUUID(), - ark = ByteArray(32) { (it + 1).toByte() }, - ), - defaultVault = Vault( - id = UUID.randomUUID(), - vaultKey = ByteArray(32) { (it + 2).toByte() }, - ) - ) - - fun seedAccount(createAccount: CreateAccount) { - this.createAccount = createAccount - } - - fun seedKey(key: ByteArray) { - this.key = key - } - - override fun createAccount(): CreateAccount = createAccount - - private fun randomKey(): ByteArray = key -} \ No newline at end of file diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeJsonBackupManager.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeJsonBackupManager.kt index b60e29316..a409c45de 100644 --- a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeJsonBackupManager.kt +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeJsonBackupManager.kt @@ -41,11 +41,12 @@ class FakeJsonBackupManager : JsonBackupManagerInterface { return importResult } - // Callers zero secret key material as soon as the call returns (a recovered ARK, a decrypted - // passphrase), so record the bytes we were called with rather than a live reference to them. + // Callers zero secret key material as soon as the call returns (a decrypted passphrase), so + // record the bytes we were called with rather than a live reference to them. An ark + // credential holds no byte array of its own to protect, so its reference is recorded as is. private fun BackupCredential.snapshot(): BackupCredential = when (this) { - is BackupCredential.Ark -> BackupCredential.Ark(key.copyOf()) is BackupCredential.Passphrase -> BackupCredential.Passphrase(bytes.copyOf()) + is BackupCredential.Ark -> this } override fun inspect(data: String): JsonEncryption { diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyDeriver.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyDeriver.kt deleted file mode 100644 index 94895d5cb..000000000 --- a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyDeriver.kt +++ /dev/null @@ -1,34 +0,0 @@ -package de.davis.keygo.rust - -import de.davisalessandro.keygo.rust.KeyDerivationException -import de.davisalessandro.keygo.rust.KeyDeriverInterface -import de.davisalessandro.keygo.rust.RootKek -import java.security.MessageDigest -import java.security.SecureRandom - -/** - * In-memory [KeyDeriverInterface] for tests. - * - * Derivation is deterministic (SHA-256 of password + salt), so a KEK derived for the same - * (password, salt) pair round-trips with [FakeKeyWrapper]. Set [failDerivation] to force the - * next call to throw [KeyDerivationException.Failed]. - */ -class FakeKeyDeriver : KeyDeriverInterface { - - var failDerivation: Boolean = false - - override fun deriveRootKekFromPassword(password: String, salt: ByteArray): RootKek { - if (failDerivation) throw KeyDerivationException.Failed("forced") - return digest(password.toByteArray() + salt) - } - - override fun deriveRootKekFromRecoveryKey(recoveryKey: ByteArray, salt: ByteArray): RootKek { - if (failDerivation) throw KeyDerivationException.Failed("forced") - return digest(recoveryKey + salt) - } - - override fun generateSalt(): ByteArray = ByteArray(16).also { SecureRandom().nextBytes(it) } - - private fun digest(input: ByteArray): ByteArray = - MessageDigest.getInstance("SHA-256").digest(input) -} diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt index 4cf26aea0..0e29d7d00 100644 --- a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt @@ -1,11 +1,9 @@ package de.davis.keygo.rust -import de.davisalessandro.keygo.rust.AccountRootKey import de.davisalessandro.keygo.rust.ItemAad import de.davisalessandro.keygo.rust.ItemKey import de.davisalessandro.keygo.rust.KeyWrapException import de.davisalessandro.keygo.rust.KeyWrapperInterface -import de.davisalessandro.keygo.rust.RootKek import de.davisalessandro.keygo.rust.VaultKey import de.davisalessandro.keygo.rust.WrappedKeyBlob import java.security.SecureRandom @@ -16,9 +14,10 @@ import java.util.UUID * * Wrapping XORs the plaintext key with a stream derived from (outer key, id, nonce) so that * wrap/unwrap round-trips correctly when the same outer key and id are supplied. Unwrapping - * with a different outer key or id yields garbage; every `unwrap*` call throws + * with a different outer key or id yields garbage; [unwrapItemKey] throws * [KeyWrapException.UnwrapFailed] when the result does not match a recorded ciphertext, which - * is sufficient to exercise the wrong-password / wrong-key paths in use case tests. + * is sufficient to exercise the wrong-key path in use case tests. The wrong-password path lives + * in `core:security`'s `FakeSession` instead: this class no longer does any KEK-level unwrapping. * * Set [failUnwrapItemForId] to force [unwrapItemKey] to throw the supplied exception whenever * it is called for an item whose id matches the recorded id. @@ -29,30 +28,6 @@ class FakeKeyWrapper : KeyWrapperInterface { private val wrapRecord = mutableMapOf, List, UUID>, ByteArray>() - override fun wrapAccountRootKey( - kek: RootKek, - ark: AccountRootKey, - userId: UUID, - ): WrappedKeyBlob = wrap(outerKey = kek, innerKey = ark, id = userId) - - override fun unwrapAccountRootKey( - kek: RootKek, - wrapped: WrappedKeyBlob, - userId: UUID, - ): AccountRootKey = unwrap(outerKey = kek, wrapped = wrapped, id = userId) - - override fun wrapVaultKey( - ark: AccountRootKey, - vaultKey: VaultKey, - vaultId: UUID, - ): WrappedKeyBlob = wrap(outerKey = ark, innerKey = vaultKey, id = vaultId) - - override fun unwrapVaultKey( - ark: AccountRootKey, - wrapped: WrappedKeyBlob, - vaultId: UUID, - ): VaultKey = unwrap(outerKey = ark, wrapped = wrapped, id = vaultId) - override fun wrapItemKey( vaultKey: VaultKey, itemKey: ItemKey,