diff --git a/CLAUDE.md b/CLAUDE.md index 217f190e..bd626b8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,8 +107,14 @@ Password / Biometric ## Testing -- kotlin-test + MockK + kotlinx-coroutines-test; Compose UI tests with Espresso -- Use `runTest { }`, `mockk(relaxed = true)`, `coEvery { }`, assert against `Result` +- kotlin-test + kotlinx-coroutines-test; Compose UI tests with Espresso +- Prefer **testFixtures-provided fakes** and concrete fake implementations as the default testing + strategy +- Use `runTest { }` and assert against `Result` +- Prefer behavior/state assertions over interaction verification +- Use **MockK only in rare cases** where interaction verification is the actual behavior under test + (for example, validating that a side-effecting API was invoked) +- 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`/ diff --git a/core/identity/build.gradle.kts b/core/identity/build.gradle.kts index 1f803b6d..af7f220b 100644 --- a/core/identity/build.gradle.kts +++ b/core/identity/build.gradle.kts @@ -68,6 +68,7 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.io.mockk) testImplementation(testFixtures(projects.core.item)) + testImplementation(testFixtures(projects.core.security)) testImplementation(testFixtures(projects.rust)) testFixturesApi(projects.core.util) diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/UnlockError.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/UnlockError.kt index d6357442..798a8a23 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/UnlockError.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/UnlockError.kt @@ -1,8 +1,11 @@ package de.davis.keygo.core.identity.domain.model +import de.davis.keygo.core.security.domain.model.BiometricAuthError + sealed interface UnlockError { data object WrappedKeyNotFound : UnlockError data object UnwrappingFailed : UnlockError data object DerivationFailed : UnlockError data object ActiveAccountNotFound : UnlockError + data class BiometricFailed(val error: BiometricAuthError) : UnlockError } \ No newline at end of file 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 4fae405b..ed2727ec 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,7 +11,6 @@ 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.getOrNull import org.koin.compose.koinInject import org.koin.core.annotation.Single @@ -27,17 +26,22 @@ internal class BiometricUnlockAdapterImpl( val wrappedKey = accountRepository.getOrNull()?.biometricWrappedArk ?: return Result.Failure(UnlockError.WrappedKeyNotFound) - val result = requestUnwrap( + val unwrapResult = requestUnwrap( keyId = KeyId.BiometricVaultKek, ciphertextData = CiphertextData( bytes = wrappedKey.key, iv = wrappedKey.keyIV ), policy = policy - ).getOrNull() ?: return Result.Failure(UnlockError.UnwrappingFailed) + ) - session.startSession(result.asAesKey()) - return Result.Success(Unit) + return when (unwrapResult) { + is Result.Failure -> Result.Failure(UnlockError.BiometricFailed(unwrapResult.error)) + is Result.Success -> { + session.startSession(unwrapResult.success.asAesKey()) + Result.Success(Unit) + } + } } } 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 new file mode 100644 index 00000000..c920c539 --- /dev/null +++ b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/presentation/BiometricUnlockAdapterImplTest.kt @@ -0,0 +1,107 @@ +package de.davis.keygo.core.identity.presentation + +import de.davis.keygo.core.identity.FakeAccountRepository +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.crypto.FakeBiometricCryptoController +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.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.spec.SecretKeySpec +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class BiometricUnlockAdapterImplTest { + + private val session = FakeSession() + private val accountRepository = FakeAccountRepository() + private val controller = FakeBiometricCryptoController() + + private val adapter = BiometricUnlockAdapterImpl( + session = session, + accountRepository = accountRepository, + ) + + private fun seedAccountWithBiometric() { + 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), + ), + ) + ) + } + + @Test + fun `returns WrappedKeyNotFound when account has no biometricWrappedArk`() = runTest { + accountRepository.seed( + Account( + id = UUID.randomUUID(), + displayName = "Test", + passwordWrappedArk = PasswordWrappedArk( + key = byteArrayOf(1), + keyIV = byteArrayOf(2), + salt = byteArrayOf(3), + ), + biometricWrappedArk = null, + ) + ) + + val result = with(adapter) { controller.requestUnlockVault(BiometricPolicy.Default) } + + assertTrue(result.isFailure()) + assertEquals(UnlockError.WrappedKeyNotFound, result.error) + } + + @Test + fun `returns BiometricFailed with the underlying BiometricError code on unwrap failure`() = + runTest { + seedAccountWithBiometric() + val biometricError = BiometricAuthError.CanNotAuthenticate(code = 12) + controller.unwrapResult = Result.Failure(biometricError) + + val result = with(adapter) { controller.requestUnlockVault(BiometricPolicy.Default) } + + assertTrue(result.isFailure()) + assertEquals(UnlockError.BiometricFailed(biometricError), result.error) + } + + @Test + fun `returns BiometricFailed(NoCipher) when manager refuses with NoCipher`() = runTest { + seedAccountWithBiometric() + controller.unwrapResult = Result.Failure(BiometricAuthError.NoCipher) + + val result = with(adapter) { controller.requestUnlockVault(BiometricPolicy.Default) } + + assertTrue(result.isFailure()) + assertEquals(UnlockError.BiometricFailed(BiometricAuthError.NoCipher), result.error) + } + + @Test + fun `on success starts session and returns Success`() = runTest { + seedAccountWithBiometric() + val key = SecretKeySpec(ByteArray(32) { 1 }, "AES") + controller.unwrapResult = Result.Success(key) + + val result = with(adapter) { controller.requestUnlockVault(BiometricPolicy.Default) } + + assertTrue(result.isSuccess()) + assertTrue(session.startSessionCalled) + } +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Passkey.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Passkey.kt index 291156fa..73f81664 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Passkey.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Passkey.kt @@ -32,4 +32,8 @@ data class Passkey( result = 31 * result + user.hashCode() return result } + + companion object { + const val LABEL_PRIVATE_KEY = "passkey_private_key" + } } \ No newline at end of file diff --git a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakePasskeyRepository.kt b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakePasskeyRepository.kt new file mode 100644 index 00000000..483ce566 --- /dev/null +++ b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakePasskeyRepository.kt @@ -0,0 +1,26 @@ +package de.davis.keygo.core.item + +import de.davis.keygo.core.item.domain.model.Passkey +import de.davis.keygo.core.item.domain.model.PasskeyMetadata +import de.davis.keygo.core.item.domain.repository.PasskeyRepository + +class FakePasskeyRepository : PasskeyRepository { + + private val store = mutableListOf() + + fun seed(vararg passkeys: Passkey) { + store += passkeys + } + + override suspend fun createPasskey(passkey: Passkey) { + store += passkey + } + + override suspend fun doCredentialIdsExist(credentialIds: Set): Boolean = + store.any { p -> credentialIds.any { it.contentEquals(p.credentialId) } } + + override suspend fun getPasskeysForRP(rpId: String): List = emptyList() + + override suspend fun getPasskey(credentialId: ByteArray): Passkey? = + store.firstOrNull { it.credentialId.contentEquals(credentialId) } +} diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/KeyId.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/KeyId.kt index 9f18bb16..e39388c0 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/KeyId.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/KeyId.kt @@ -3,7 +3,6 @@ package de.davis.keygo.core.security.domain.model data class KeyId(val id: String) { companion object { - val PasskeyEncryptionKey = KeyId("passkey_encryption_key") val BiometricVaultKek = KeyId("biometric_vault_kek") } } \ No newline at end of file diff --git a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeBiometricAvailabilityRepository.kt b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeBiometricAvailabilityRepository.kt new file mode 100644 index 00000000..d8159e22 --- /dev/null +++ b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeBiometricAvailabilityRepository.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.core.security.crypto + +import de.davis.keygo.core.security.domain.repository.BiometricAvailabilityRepository + +class FakeBiometricAvailabilityRepository : BiometricAvailabilityRepository { + + var isAvailable: Boolean = false + + override fun availability(): Boolean = isAvailable +} 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 new file mode 100644 index 00000000..388c784c --- /dev/null +++ b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeBiometricCryptoController.kt @@ -0,0 +1,40 @@ +package de.davis.keygo.core.security.crypto + +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.CiphertextData +import de.davis.keygo.core.security.domain.model.CryptographicMode +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 java.security.Key +import javax.crypto.Cipher + +class FakeBiometricCryptoController : BiometricCryptoController { + + var unwrapResult: Result = Result.Failure(BiometricAuthError.NoCipher) + + override suspend fun requestCipher( + keyId: KeyId, + mode: CryptographicMode, + policy: BiometricPolicy, + ): Result = Result.Failure(BiometricAuthError.NoCipher) + + override suspend fun requestUnwrap( + keyId: KeyId, + ciphertextData: CiphertextData, + policy: BiometricPolicy, + ): Result = unwrapResult + + override suspend fun requestEncryption( + keyId: KeyId, + byteArray: ByteArray, + policy: BiometricPolicy, + ): Result = Result.Failure(BiometricAuthError.NoCipher) + + override suspend fun requestDecryption( + keyId: KeyId, + ciphertextData: CiphertextData, + policy: BiometricPolicy, + ): Result = Result.Failure(BiometricAuthError.NoCipher) +} diff --git a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeSession.kt b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeSession.kt index c3676336..72728ed9 100644 --- a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeSession.kt +++ b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeSession.kt @@ -9,9 +9,14 @@ import javax.crypto.spec.SecretKeySpec */ class FakeSession : Session { + var startSessionCalled = false + override val dek: AesKey get() = AesKey(SecretKeySpec(ByteArray(32) { it.toByte() }, "AES")) - override fun startSession(dek: AesKey) = Unit + override fun startSession(dek: AesKey) { + startSessionCalled = true + } + override fun endSession() = Unit } \ No newline at end of file diff --git a/feature/credentials/build.gradle.kts b/feature/credentials/build.gradle.kts index 7af5cb2d..a6a6a396 100644 --- a/feature/credentials/build.gradle.kts +++ b/feature/credentials/build.gradle.kts @@ -57,11 +57,13 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(projects.rust) + implementation(projects.core.identity) implementation(projects.core.security) implementation(projects.core.item) implementation(projects.core.ui) implementation(projects.feature.item.create) implementation(projects.feature.listScreen) + implementation(projects.feature.auth) // Koin DI implementation(project.dependencies.platform(libs.koin.bom)) @@ -71,6 +73,10 @@ dependencies { testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.io.mockk) + testImplementation(testFixtures(projects.core.identity)) + testImplementation(testFixtures(projects.core.item)) + testImplementation(testFixtures(projects.core.security)) + testImplementation(testFixtures(projects.rust)) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/auth/SessionAuthState.kt b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/auth/SessionAuthState.kt new file mode 100644 index 00000000..38ac891c --- /dev/null +++ b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/auth/SessionAuthState.kt @@ -0,0 +1,36 @@ +package de.davis.keygo.feature.credentials.presentation.auth + +import androidx.biometric.BiometricPrompt +import de.davis.keygo.core.identity.domain.model.UnlockError +import de.davis.keygo.core.security.domain.model.BiometricAuthError + +internal sealed interface SessionAuthState { + data object TryBiometric : SessionAuthState + data object NeedsPassword : SessionAuthState + data object Authenticated : SessionAuthState +} + +internal enum class UnlockOutcome { Abort, NeedsPassword } + +internal fun mapUnlockError(error: UnlockError): UnlockOutcome = when (error) { + is UnlockError.BiometricFailed -> when (val biometricError = error.error) { + is BiometricAuthError.BiometricError -> when (biometricError.errorCode) { + BiometricPrompt.ERROR_USER_CANCELED, + BiometricPrompt.ERROR_CANCELED -> UnlockOutcome.Abort + + BiometricPrompt.ERROR_NEGATIVE_BUTTON, + BiometricPrompt.ERROR_LOCKOUT, + BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> UnlockOutcome.NeedsPassword + + else -> UnlockOutcome.NeedsPassword + } + + is BiometricAuthError.CanNotAuthenticate -> UnlockOutcome.NeedsPassword + BiometricAuthError.NoCipher -> UnlockOutcome.Abort + } + + UnlockError.WrappedKeyNotFound -> UnlockOutcome.NeedsPassword + UnlockError.UnwrappingFailed, + UnlockError.DerivationFailed, + UnlockError.ActiveAccountNotFound -> UnlockOutcome.Abort +} diff --git a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyActivity.kt b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyActivity.kt index fe74fe5e..21c31e22 100644 --- a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyActivity.kt +++ b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyActivity.kt @@ -29,16 +29,24 @@ import androidx.credentials.CreatePublicKeyCredentialResponse import androidx.credentials.exceptions.CreateCredentialUnknownException import androidx.credentials.provider.PendingIntentHandler import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import de.davis.keygo.core.identity.presentation.rememberBiometricUnlockAdapter +import de.davis.keygo.core.identity.presentation.useAdapter import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.security.domain.model.BiometricPolicy +import de.davis.keygo.core.security.domain.model.BiometricString import de.davis.keygo.core.security.presentation.rememberBiometricCryptoController import de.davis.keygo.core.ui.theme.KeyGoTheme import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.onSuccess import de.davis.keygo.core.util.presentation.ObserveAsEvents +import de.davis.keygo.feature.auth.presentation.AuthRoute +import de.davis.keygo.feature.auth.presentation.authGraph import de.davis.keygo.feature.credentials.R +import de.davis.keygo.feature.credentials.presentation.auth.SessionAuthState import de.davis.keygo.feature.item.create.presentation.login.LoginScreen import de.davis.keygo.feature.list_screen.presentation.ItemListScreen import de.davis.keygo.feature.list_screen.presentation.NoItemStrategy @@ -46,6 +54,9 @@ import kotlinx.serialization.Serializable import org.koin.androidx.viewmodel.ext.android.viewModel +@Serializable +private data object AuthenticatedHome + @Serializable private data object ListDest @@ -63,39 +74,25 @@ internal class CreatePasskeyActivity : FragmentActivity() { val callingRequest = request?.callingRequest as? CreatePublicKeyCredentialRequest ?: return cancel("Invalid CreatePublicKeyCredentialRequest") - viewModel.updateCreatePublicKeyCredentialRequest(callingRequest) + viewModel.setRequest(callingRequest) + setResult(RESULT_CANCELED) setContent { KeyGoTheme { - val navController = rememberNavController() - val biometricCryptoController = rememberBiometricCryptoController() BackHandler { cancel() } - ObserveAsEvents(flow = viewModel.biometricRequest) { - when (it) { - is CreatePasskeyBiometricRequestEvent.EncryptPasskeyEncryptionKey -> { - biometricCryptoController.requestEncryption( - keyId = it.keyId, - byteArray = it.key, - policy = it.policy - ).onSuccess(viewModel::passkeyEncrypted) - .onFailure { error -> cancel("Biometric Failed: $error") } - } - - else -> cancel("Unsupported Biometric Request") - } - } - var confirmationEvent by rememberSaveable { mutableStateOf(null) } + val authenticatedNavController = rememberNavController() + ObserveAsEvents(flow = viewModel.event) { when (it) { CreatePasskeyEvent.Abort -> cancel() - CreatePasskeyEvent.ShowList -> navController.navigate(ListDest) { - popUpTo { inclusive = true } + CreatePasskeyEvent.ShowList -> authenticatedNavController.navigate(ListDest) { + popUpTo { inclusive = true } } is CreatePasskeyEvent.Finish -> finishWithSuccess(it.responseJson) @@ -143,30 +140,79 @@ internal class CreatePasskeyActivity : FragmentActivity() { ) } - NavHost( - navController = navController, - startDestination = Unit - ) { - composable { - // Show nothing by default + val biometricCryptoController = rememberBiometricCryptoController() + val biometricUnlockAdapter = rememberBiometricUnlockAdapter() + + ObserveAsEvents(viewModel.biometricFlow) { + biometricUnlockAdapter.useAdapter { + biometricCryptoController.requestUnlockVault( + policy = BiometricPolicy( + title = BiometricString.Title.Authenticate, + negativeButton = BiometricString.NegativeButton.Password, + ) + ) + }.onSuccess { + viewModel.onUnlocked() + }.onFailure { + viewModel.onUnlockFailed(it) + } + } + + val authState by viewModel.authState.collectAsStateWithLifecycle() + when (authState) { + SessionAuthState.TryBiometric -> { + // render nothing — activity stays transparent while system biometric prompt is shown } - composable { - PasskeyItemListScreen( - onItemClick = viewModel::onItemClicked, - onCreateClicked = { - navController.navigate(CreateItem) + SessionAuthState.NeedsPassword -> { + val authNavController = rememberNavController() + Scaffold { innerPadding -> + NavHost( + navController = authNavController, + startDestination = AuthRoute(showBiometricPromptIfPossible = false), + modifier = Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding), + ) { + authGraph( + onSuccess = { viewModel.onUnlocked() } + ) } - ) + } } - composable { - LoginScreen( - loginCreated = { - viewModel.associatePasskeyAndFinish(it) - }, - navigateBack = { cancel("User cancelled passkey creation") }, - ) + SessionAuthState.Authenticated -> { + Scaffold { innerPadding -> + NavHost( + navController = authenticatedNavController, + startDestination = AuthenticatedHome, + modifier = Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding), + ) { + composable { + // empty placeholder while operation runs + } + + composable { + PasskeyItemListScreen( + onItemClick = viewModel::onItemClicked, + onCreateClicked = { + authenticatedNavController.navigate(CreateItem) + } + ) + } + + composable { + LoginScreen( + loginCreated = { + viewModel.associatePasskeyAndFinish(it) + }, + navigateBack = { cancel("User cancelled passkey creation") }, + ) + } + } + } } } } diff --git a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyBiometricRequestEvent.kt b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyBiometricRequestEvent.kt deleted file mode 100644 index d89b793f..00000000 --- a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyBiometricRequestEvent.kt +++ /dev/null @@ -1,44 +0,0 @@ -package de.davis.keygo.feature.credentials.presentation.create.activity - -import de.davis.keygo.core.security.domain.model.BiometricPolicy -import de.davis.keygo.core.security.domain.model.CiphertextData -import de.davis.keygo.core.security.domain.model.KeyId - -internal sealed interface CreatePasskeyBiometricRequestEvent { - val keyId: KeyId - val policy: BiometricPolicy - - data class DecryptPasskeyEncryptionKey( - val ciphertextData: CiphertextData, - override val policy: BiometricPolicy = BiometricPolicy.Default - ) : CreatePasskeyBiometricRequestEvent { - override val keyId: KeyId = KeyId.PasskeyEncryptionKey - } - - data class EncryptPasskeyEncryptionKey( - val key: ByteArray, - override val policy: BiometricPolicy = BiometricPolicy.Default - ) : CreatePasskeyBiometricRequestEvent { - override val keyId: KeyId = KeyId.PasskeyEncryptionKey - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as EncryptPasskeyEncryptionKey - - if (!key.contentEquals(other.key)) return false - if (policy != other.policy) return false - if (keyId != other.keyId) return false - - return true - } - - override fun hashCode(): Int { - var result = key.contentHashCode() - result = 31 * result + policy.hashCode() - result = 31 * result + keyId.hashCode() - return result - } - } -} \ No newline at end of file diff --git a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyViewModel.kt b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyViewModel.kt index 08df1ba5..83f68a10 100644 --- a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyViewModel.kt +++ b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyViewModel.kt @@ -4,18 +4,30 @@ import android.util.Log import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import de.davis.keygo.core.identity.domain.model.UnlockError +import de.davis.keygo.core.identity.domain.repository.AccountRepository import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.Passkey import de.davis.keygo.core.item.domain.model.PasskeyUser import de.davis.keygo.core.item.domain.model.SecretData +import de.davis.keygo.core.item.domain.repository.LoginRepository import de.davis.keygo.core.item.domain.repository.PasskeyRepository -import de.davis.keygo.core.security.domain.model.CiphertextData +import de.davis.keygo.core.item.domain.repository.VaultRepository +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation +import de.davis.keygo.core.security.domain.crypto.wrappedItemKeyInformation +import de.davis.keygo.core.security.domain.repository.BiometricAvailabilityRepository import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.feature.credentials.presentation.auth.SessionAuthState +import de.davis.keygo.feature.credentials.presentation.auth.UnlockOutcome +import de.davis.keygo.feature.credentials.presentation.auth.mapUnlockError import de.davis.keygo.rust.passkey.PasskeyManager import de.davis.keygo.rust.passkey.getExcludedCredentialIds import de.davis.keygo.rust.passkey.registerWithResult import de.davisalessandro.keygo.rust.RegistrationResponse import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel @@ -23,20 +35,59 @@ import org.koin.core.annotation.KoinViewModel @KoinViewModel internal class CreatePasskeyViewModel( private val passkeyRepository: PasskeyRepository, + private val loginRepository: LoginRepository, + private val vaultRepository: VaultRepository, + private val cryptographicScopeProvider: CryptographicScopeProvider, private val passkeyManager: PasskeyManager, + private val accountRepository: AccountRepository, + private val biometricAvailabilityRepository: BiometricAvailabilityRepository, ) : ViewModel() { - private val biometricChannel = Channel() - val biometricRequest = biometricChannel.receiveAsFlow() - - private val _event = Channel() + private val _event = Channel(Channel.BUFFERED) val event = _event.receiveAsFlow() + private val _authState = MutableStateFlow(SessionAuthState.TryBiometric) + val authState = _authState.asStateFlow() + + private val biometricChannel = Channel(Channel.BUFFERED) + val biometricFlow = biometricChannel.receiveAsFlow() + + private var pendingRequest: CreatePublicKeyCredentialRequest? = null private var registrationResponse: RegistrationResponse? = null - private var key: CiphertextData? = null + init { + viewModelScope.launch { + val account = accountRepository.getOrNull() + val biometricUsable = biometricAvailabilityRepository.availability() + && account?.biometricWrappedArk != null + + if (biometricUsable) { + _authState.value = SessionAuthState.TryBiometric + biometricChannel.send(Unit) + } else + _authState.value = SessionAuthState.NeedsPassword + } + } + + fun setRequest(request: CreatePublicKeyCredentialRequest) { + pendingRequest = request + } + + fun onUnlocked() { + if (_authState.value == SessionAuthState.Authenticated) return + _authState.value = SessionAuthState.Authenticated + val req = pendingRequest ?: return + runOperation(req) + } + + fun onUnlockFailed(error: UnlockError) { + when (mapUnlockError(error)) { + UnlockOutcome.Abort -> viewModelScope.launch { abort("biometric $error") } + UnlockOutcome.NeedsPassword -> _authState.value = SessionAuthState.NeedsPassword + } + } - fun updateCreatePublicKeyCredentialRequest(request: CreatePublicKeyCredentialRequest) { + private fun runOperation(request: CreatePublicKeyCredentialRequest) { viewModelScope.launch { val idsToExclude = passkeyManager.getExcludedCredentialIds(request.requestJson).getOrNull() @@ -49,46 +100,52 @@ internal class CreatePasskeyViewModel( registrationResponse = passkeyManager.registerWithResult(request.requestJson) .getOrNull() ?: return@launch abort("Failed to register passkey") - // Request authentication - registrationResponse?.let { - biometricChannel.send( - CreatePasskeyBiometricRequestEvent.EncryptPasskeyEncryptionKey( - key = it.privateKey - ) - ) - } + _event.send(CreatePasskeyEvent.ShowList) } } - - fun passkeyEncrypted(key: CiphertextData) { - this.key = key - _event.trySend(CreatePasskeyEvent.ShowList) - } - fun associatePasskeyAndFinish(itemId: ItemId) { viewModelScope.launch { - val registrationResponse = - registrationResponse ?: return@launch abort("Response was null") - val key = key ?: return@launch abort("Key was null") + val response = registrationResponse ?: return@launch abort("Response was null") + + val login = loginRepository.getLoginById(itemId) + ?: return@launch abort("Login not found for id $itemId") + val vaultKeyInfo = vaultRepository.getKeyInformation(login.vaultId) + ?: return@launch abort("Vault key information missing for ${login.vaultId}") + + val encryptedPrivateKey = try { + cryptographicScopeProvider.itemScope( + wrappedVaultKeyInformation = WrappedVaultKeyInformation( + wrappedVaultKey = vaultKeyInfo, + vaultId = login.vaultId, + ), + wrappedItemKeyInformation = login.wrappedItemKeyInformation(), + ) { + val ct = response.privateKey.encrypt(label = Passkey.LABEL_PRIVATE_KEY) + SecretData( + data = ct.data, + iv = ct.iv, + decryptedDataType = SecretData.DecryptedDataType.StringType, + ) + } + } catch (t: Throwable) { + Log.w(TAG, "Failed to encrypt passkey private key", t) + return@launch abort("Failed to encrypt passkey private key") + } val passkey = Passkey( - credentialId = registrationResponse.credentialId, - privateKey = SecretData( - data = key.bytes, - iv = key.iv, - decryptedDataType = SecretData.DecryptedDataType.StringType - ), - rp = registrationResponse.rp, + credentialId = response.credentialId, + privateKey = encryptedPrivateKey, + rp = response.rp, loginId = itemId, user = PasskeyUser( - name = registrationResponse.userName, - displayName = registrationResponse.userDisplayName - ) + name = response.userName, + displayName = response.userDisplayName, + ), ) passkeyRepository.createPasskey(passkey) - _event.send(CreatePasskeyEvent.Finish(registrationResponse.response)) + _event.send(CreatePasskeyEvent.Finish(response.response)) } } @@ -110,4 +167,4 @@ internal class CreatePasskeyViewModel( companion object { private const val TAG = "CreatePasskeyViewModel" } -} \ No newline at end of file +} diff --git a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/provide/activity/DecryptPasskeyEncryptionKeyRequest.kt b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/provide/activity/DecryptPasskeyEncryptionKeyRequest.kt deleted file mode 100644 index 64395c95..00000000 --- a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/provide/activity/DecryptPasskeyEncryptionKeyRequest.kt +++ /dev/null @@ -1,12 +0,0 @@ -package de.davis.keygo.feature.credentials.presentation.provide.activity - -import de.davis.keygo.core.security.domain.model.BiometricPolicy -import de.davis.keygo.core.security.domain.model.CiphertextData -import de.davis.keygo.core.security.domain.model.KeyId - -data class DecryptPasskeyEncryptionKeyRequest( - val ciphertextData: CiphertextData, - val policy: BiometricPolicy = BiometricPolicy.Default -) { - val keyId: KeyId = KeyId.PasskeyEncryptionKey -} \ No newline at end of file diff --git a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/provide/activity/ProvidePasskeyActivity.kt b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/provide/activity/ProvidePasskeyActivity.kt index aa70109c..df2044ff 100644 --- a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/provide/activity/ProvidePasskeyActivity.kt +++ b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/provide/activity/ProvidePasskeyActivity.kt @@ -5,17 +5,32 @@ import android.content.Intent import android.os.Bundle import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import androidx.credentials.GetCredentialResponse import androidx.credentials.GetPublicKeyCredentialOption import androidx.credentials.PublicKeyCredential import androidx.credentials.exceptions.GetCredentialUnknownException import androidx.credentials.provider.PendingIntentHandler import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import de.davis.keygo.core.identity.presentation.rememberBiometricUnlockAdapter +import de.davis.keygo.core.identity.presentation.useAdapter +import de.davis.keygo.core.security.domain.model.BiometricPolicy +import de.davis.keygo.core.security.domain.model.BiometricString import de.davis.keygo.core.security.presentation.rememberBiometricCryptoController import de.davis.keygo.core.ui.theme.KeyGoTheme import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.onSuccess import de.davis.keygo.core.util.presentation.ObserveAsEvents +import de.davis.keygo.feature.auth.presentation.AuthRoute +import de.davis.keygo.feature.auth.presentation.authGraph +import de.davis.keygo.feature.credentials.presentation.auth.SessionAuthState import org.koin.androidx.viewmodel.ext.android.viewModel internal class ProvidePasskeyActivity : FragmentActivity() { @@ -34,30 +49,63 @@ internal class ProvidePasskeyActivity : FragmentActivity() { val credentialId = intent.extras?.getByteArray(EXTRA_CREDENTIAL_ID) ?: return cancel("No credential ID found") - viewModel.updateGetPublicKeyCredentialOption(publicKeyRequest, credentialId) + viewModel.setRequest(publicKeyRequest, credentialId) setResult(RESULT_CANCELED) setContent { KeyGoTheme { - val biometricCryptoController = rememberBiometricCryptoController() - BackHandler { cancel() } - ObserveAsEvents(flow = viewModel.biometricRequest) { - biometricCryptoController.requestDecryption( - keyId = it.keyId, - ciphertextData = it.ciphertextData, - policy = it.policy - ).onSuccess(viewModel::onPasskeyDecrypted) - .onFailure { error -> cancel("Biometric authentication failed: $error") } - } - - ObserveAsEvents(flow = viewModel.event) { + ObserveAsEvents(viewModel.event) { when (it) { is ProvidePasskeyEvent.Abort -> cancel("Operation aborted") is ProvidePasskeyEvent.Finish -> finishWithSuccess(it.responseJson) + } + } + + val biometricCryptoController = rememberBiometricCryptoController() + val biometricUnlockAdapter = rememberBiometricUnlockAdapter() + + ObserveAsEvents(viewModel.biometricFlow) { + biometricUnlockAdapter.useAdapter { + biometricCryptoController.requestUnlockVault( + policy = BiometricPolicy( + title = BiometricString.Title.Authenticate, + negativeButton = BiometricString.NegativeButton.Password, + ) + ) + }.onSuccess { + viewModel.onUnlocked() + }.onFailure { + viewModel.onUnlockFailed(it) + } + } + + val authState by viewModel.authState.collectAsStateWithLifecycle() + when (authState) { + SessionAuthState.TryBiometric -> { + // render nothing — activity stays transparent while system biometric prompt is shown + } + + SessionAuthState.NeedsPassword -> { + val navController = rememberNavController() + Scaffold { innerPadding -> + NavHost( + navController = navController, + startDestination = AuthRoute(showBiometricPromptIfPossible = false), + modifier = Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding), + ) { + authGraph( + onSuccess = { viewModel.onUnlocked() } + ) + } + } + } - else -> {} + SessionAuthState.Authenticated -> { + // render nothing — operation runs in ViewModel and emits Finish/Abort } } } @@ -108,4 +156,4 @@ internal class ProvidePasskeyActivity : FragmentActivity() { // also make requestCodes unique per account (see PendingIntent below) } } -} \ No newline at end of file +} diff --git a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/provide/activity/ProvidePasskeyViewModel.kt b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/provide/activity/ProvidePasskeyViewModel.kt index 8d05182d..d93964b2 100644 --- a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/provide/activity/ProvidePasskeyViewModel.kt +++ b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/provide/activity/ProvidePasskeyViewModel.kt @@ -4,13 +4,27 @@ import android.util.Log import androidx.credentials.GetPublicKeyCredentialOption import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import de.davis.keygo.core.identity.domain.model.UnlockError +import de.davis.keygo.core.identity.domain.repository.AccountRepository +import de.davis.keygo.core.item.domain.model.Passkey +import de.davis.keygo.core.item.domain.repository.LoginRepository import de.davis.keygo.core.item.domain.repository.PasskeyRepository -import de.davis.keygo.core.security.domain.model.CiphertextData +import de.davis.keygo.core.item.domain.repository.VaultRepository +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData +import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation +import de.davis.keygo.core.security.domain.crypto.wrappedItemKeyInformation +import de.davis.keygo.core.security.domain.repository.BiometricAvailabilityRepository import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.onSuccess +import de.davis.keygo.feature.credentials.presentation.auth.SessionAuthState +import de.davis.keygo.feature.credentials.presentation.auth.UnlockOutcome +import de.davis.keygo.feature.credentials.presentation.auth.mapUnlockError import de.davis.keygo.rust.passkey.PasskeyManager import de.davis.keygo.rust.passkey.authenticateWithResult import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel @@ -18,49 +32,95 @@ import org.koin.core.annotation.KoinViewModel @KoinViewModel internal class ProvidePasskeyViewModel( private val passkeyRepository: PasskeyRepository, - private val passkeyManager: PasskeyManager + private val loginRepository: LoginRepository, + private val vaultRepository: VaultRepository, + private val cryptographicScopeProvider: CryptographicScopeProvider, + private val passkeyManager: PasskeyManager, + private val accountRepository: AccountRepository, + private val biometricAvailabilityRepository: BiometricAvailabilityRepository, ) : ViewModel() { - private val biometricChannel = Channel() - val biometricRequest = biometricChannel.receiveAsFlow() + private val _event = Channel(Channel.BUFFERED) + val event = _event.receiveAsFlow() + private val _authState = MutableStateFlow(SessionAuthState.TryBiometric) + val authState = _authState.asStateFlow() - private val _event = Channel() - val event = _event.receiveAsFlow() + private val biometricChannel = Channel(Channel.BUFFERED) + val biometricFlow = biometricChannel.receiveAsFlow() - private var requestJson: String? = null - private var clientHashData: ByteArray? = null + private data class PendingRequest( + val option: GetPublicKeyCredentialOption, + val credentialId: ByteArray, + ) - fun updateGetPublicKeyCredentialOption( - option: GetPublicKeyCredentialOption, - credentialId: ByteArray - ) { - requestJson = option.requestJson - clientHashData = option.clientDataHash + private lateinit var pendingRequest: PendingRequest + init { viewModelScope.launch { - val passkey = passkeyRepository.getPasskey(credentialId) - ?: return@launch abort("No passkey found!") + val account = accountRepository.getOrNull() + val biometricUsable = biometricAvailabilityRepository.availability() + && account?.biometricWrappedArk != null - biometricChannel.send( - DecryptPasskeyEncryptionKeyRequest( - ciphertextData = CiphertextData( - bytes = passkey.privateKey.data, - iv = passkey.privateKey.iv, - ) - ) - ) + if (biometricUsable) { + _authState.value = SessionAuthState.TryBiometric + biometricChannel.send(Unit) + } else + _authState.value = SessionAuthState.NeedsPassword + } + } + + fun setRequest(option: GetPublicKeyCredentialOption, credentialId: ByteArray) { + pendingRequest = PendingRequest(option, credentialId) + } + + fun onUnlocked() { + _authState.value = SessionAuthState.Authenticated + runOperation(pendingRequest) + } + + fun onUnlockFailed(error: UnlockError) { + when (mapUnlockError(error)) { + UnlockOutcome.Abort -> viewModelScope.launch { abort() } + UnlockOutcome.NeedsPassword -> _authState.value = SessionAuthState.NeedsPassword } } - fun onPasskeyDecrypted(key: ByteArray) { + private fun runOperation(req: PendingRequest) { viewModelScope.launch { - val requestJson = requestJson ?: return@launch abort("Request was null") - val clientHashData = clientHashData ?: return@launch abort("ClientHashData was null") + val clientDataHash = req.option.clientDataHash + ?: return@launch abort("ClientDataHash was null") + + val passkey = passkeyRepository.getPasskey(req.credentialId) + ?: return@launch abort("No passkey found!") + + val login = loginRepository.getLoginById(passkey.loginId) + ?: return@launch abort("Parent login not found for passkey") + val vaultKeyInfo = vaultRepository.getKeyInformation(login.vaultId) + ?: return@launch abort("Vault key information missing for ${login.vaultId}") + + val privateKey = try { + cryptographicScopeProvider.itemScope( + wrappedVaultKeyInformation = WrappedVaultKeyInformation( + wrappedVaultKey = vaultKeyInfo, + vaultId = login.vaultId, + ), + wrappedItemKeyInformation = login.wrappedItemKeyInformation(), + ) { + CryptographicData( + data = passkey.privateKey.data, + iv = passkey.privateKey.iv, + ).decrypt(label = Passkey.LABEL_PRIVATE_KEY) + } + } catch (t: Throwable) { + Log.w(TAG, "Failed to decrypt passkey private key", t) + return@launch abort("Failed to decrypt passkey private key") + } + passkeyManager.authenticateWithResult( - requestJson = requestJson, - passkey = key, - clientDataHash = clientHashData + requestJson = req.option.requestJson, + passkey = privateKey, + clientDataHash = clientDataHash, ).onFailure { Log.w(TAG, "Error during passkey authentication", it) abort() diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakePasskeyManager.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakePasskeyManager.kt new file mode 100644 index 00000000..49b6272c --- /dev/null +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakePasskeyManager.kt @@ -0,0 +1,45 @@ +package de.davis.keygo.rust + +import de.davisalessandro.keygo.rust.RegistrationResponse +import de.davisalessandro.keygo.rust.RustPasskeyInterface + +class FakePasskeyManager : RustPasskeyInterface { + + data class AuthenticateCall( + val jsonRequest: String, + val passkey: ByteArray, + val clientDataHash: ByteArray?, + ) + + val authenticateCalls = mutableListOf() + val registerCalls = mutableListOf() + val excludedCredentialsCalls = mutableListOf() + + /** Result returned by [authenticate]. Throws if null. */ + var authenticateResult: String? = null + + /** Result returned by [register]. Throws if null. */ + var registerResult: RegistrationResponse? = null + + /** Result returned by [excludedCredentials]. */ + var excludedCredentialsResult: List = emptyList() + + override suspend fun authenticate( + jsonRequest: String, + passkey: ByteArray, + clientDataHash: ByteArray?, + ): String { + authenticateCalls += AuthenticateCall(jsonRequest, passkey.copyOf(), clientDataHash?.copyOf()) + return authenticateResult ?: error("authenticateResult not set on FakePasskeyManager") + } + + override suspend fun excludedCredentials(jsonRequest: String): List { + excludedCredentialsCalls += jsonRequest + return excludedCredentialsResult + } + + override suspend fun register(jsonRequest: String): RegistrationResponse { + registerCalls += jsonRequest + return registerResult ?: error("registerResult not set on FakePasskeyManager") + } +}