diff --git a/CLAUDE.md b/CLAUDE.md index cc737965f..869f43688 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,8 @@ Android password manager using Clean Architecture per module: | Module | Purpose | |--------------------------|--------------------------------------------------------------------------| | `:app` | Navigation, app-level DI, dashboard | -| `:core:security` | Crypto, biometrics, Android Keystore | +| `:core:security` | Crypto, Android Keystore | +| `:core:biometrics` | Biometric prompt (`BiometricCrypto`), biometric availability | | `:core:identity` | Key wrapping, auth data, proto schemas (`core/identity/src/main/proto/`) | | `:core:item` | Room database, login/item entities | | `:core:ui` | Shared composables and UI utilities | @@ -70,7 +71,7 @@ Composition root: `app/di/Koin.kt`. Wire dependencies in the most local owning m ## Security -`KeyStoreManager`, `BiometricCryptoController`, `Session` (active DEK). Wrapped keys in proto +`KeyStoreManager`, `BiometricCrypto`, `Session` (active DEK). Wrapped keys in proto DataStore: `biometric_key_data.pb`, `password_key_data.pb`. Do not change key lifecycle, wrapping, prompt flow, or persistence semantics without explicit instruction. diff --git a/core/biometrics/build.gradle.kts b/core/biometrics/build.gradle.kts new file mode 100644 index 000000000..cc4adaebd --- /dev/null +++ b/core/biometrics/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + alias(libs.plugins.keygo.android.library) +} + +android { + namespace = "de.davis.keygo.core.biometrics" + + testFixtures { + enable = true + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +dependencies { + api(projects.core.security) + implementation(libs.androidx.biometric) + + testImplementation(libs.robolectric) + testImplementation(testFixtures(projects.core.security)) + testImplementation(testFixtures(projects.core.util)) + + testFixturesApi(testFixtures(projects.core.security)) + testFixturesImplementation(libs.kotlinx.coroutines.core) +} diff --git a/core/biometrics/consumer-rules.pro b/core/biometrics/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/data/BiometricCryptoImpl.kt b/core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/data/BiometricCryptoImpl.kt new file mode 100644 index 000000000..e0633ae08 --- /dev/null +++ b/core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/data/BiometricCryptoImpl.kt @@ -0,0 +1,209 @@ +package de.davis.keygo.core.biometrics.data + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.os.Bundle +import android.util.Log +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.fragment.app.FragmentActivity +import de.davis.keygo.core.biometrics.domain.BiometricCrypto +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError +import de.davis.keygo.core.biometrics.domain.model.BiometricPolicy +import de.davis.keygo.core.biometrics.domain.repository.BiometricAvailabilityRepository +import de.davis.keygo.core.security.data.keyStoreManagerErrorFrom +import de.davis.keygo.core.security.domain.KeyStoreManager +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.util.asResult +import de.davis.keygo.core.util.resultBinding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import org.koin.core.annotation.Single +import java.security.Key +import javax.crypto.Cipher +import javax.crypto.spec.SecretKeySpec +import kotlin.coroutines.resume +import kotlin.time.Duration.Companion.milliseconds + +@Single(createdAtStart = true, binds = [BiometricCrypto::class]) +internal class BiometricCryptoImpl( + context: Context, + private val biometricAvailabilityRepository: BiometricAvailabilityRepository, + private val keyStoreManager: KeyStoreManager, +) : BiometricCrypto, Application.ActivityLifecycleCallbacks { + + private val host = MutableStateFlow(null) + + private val promptLock = Mutex() + + init { + (context.applicationContext as Application).registerActivityLifecycleCallbacks(this) + } + + override fun onActivityResumed(activity: Activity) { + if (activity is FragmentActivity) host.update { activity } + } + + override fun onActivityPaused(activity: Activity) { + host.update { if (it === activity) null else it } + } + + override fun onActivityCreated(p0: Activity, p1: Bundle?) = Unit + override fun onActivityDestroyed(p0: Activity) = Unit + override fun onActivitySaveInstanceState(p0: Activity, p1: Bundle) = Unit + override fun onActivityStarted(p0: Activity) = Unit + override fun onActivityStopped(p0: Activity) = Unit + + private suspend fun awaitHost(): FragmentActivity? = withTimeoutOrNull(250.milliseconds) { + host.filterNotNull().first { !it.isFinishing } + } + + override suspend fun requestWrap( + keyId: KeyId, + policy: BiometricPolicy, + wrap: (seal: (key: ByteArray) -> CryptographicData) -> T, + ): Result = request( + keyId = keyId, + policy = policy, + mode = CryptographicMode.Wrap, + ) { cipher -> + wrap { key -> + CryptographicData( + data = cipher.wrap(SecretKeySpec(key, 0, key.size, "AES")), + iv = cipher.iv, + ) + } + } + + override suspend fun requestUnwrap( + keyId: KeyId, + cryptographicData: CryptographicData, + policy: BiometricPolicy, + ): Result = request( + keyId = keyId, + policy = policy, + mode = CryptographicMode.Unwrap, + iv = cryptographicData.iv, + ) { it.unwrap(cryptographicData.data, "AES", Cipher.SECRET_KEY) } + + private suspend fun request( + keyId: KeyId, + policy: BiometricPolicy, + mode: CryptographicMode, + iv: ByteArray? = null, + onSuccess: (Cipher) -> T, + ): Result = promptLock.withLock { + resultBinding { + biometricAvailabilityRepository.availability() + .asResult(BiometricAuthError.BiometricsNotAvailable) + .bind() + + val activity = awaitHost() + .asResult(BiometricAuthError.NoPromptHost) + .bind() + + val cipher = keyStoreManager.getOrCreateCipherFor(keyId, mode, iv) + .bind { it.toBiometricAuthError() } + + activity.authenticate(policy, cipher, onSuccess).bind() + } + } + + private suspend fun FragmentActivity.authenticate( + policy: BiometricPolicy, + cipher: Cipher, + onSuccess: (Cipher) -> T, + ): Result = suspendCancellableCoroutine { c -> + // The prompt that ran before this one removes its fragment in a transaction that has not + // run yet. A new prompt would reuse that fragment, which shows nothing and never calls back. + supportFragmentManager.executePendingTransactions() + + val prompt = BiometricPrompt( + this, + Dispatchers.Main.asExecutor(), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + val authenticated = result.cryptoObject?.cipher ?: return c.resume( + Result.Failure(BiometricAuthError.NoCipher) + ) + + runCatching { onSuccess(authenticated) }.fold( + onSuccess = { c.resume(Result.Success(it)) }, + onFailure = { + Log.e( + TAG, + "Cipher operation failed after authentication succeeded", + it, + ) + c.resume(Result.Failure(cipherFailureToBiometricAuthError(it))) + }, + ) + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + c.resume(Result.Failure(biometricAuthErrorFrom(errorCode, errString))) + } + + override fun onAuthenticationFailed() { + // Not an outcome. A rejected attempt leaves the prompt open for another one, + // and how it ends still arrives through the callbacks above. + } + }, + ) + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(policy.title.resolve(this)) + .setNegativeButtonText(policy.negativeButton.resolve(this)) + .setAllowedAuthenticators(AUTHENTICATORS) + .build() + + prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher)) + + c.invokeOnCancellation { prompt.cancelAuthentication() } + } + + companion object { + + private const val AUTHENTICATORS = BiometricManager.Authenticators.BIOMETRIC_STRONG + private const val TAG = "BiometricCryptoImpl" + } +} + +internal fun cipherFailureToBiometricAuthError(throwable: Throwable): BiometricAuthError = + keyStoreManagerErrorFrom(throwable).toBiometricAuthError() + +internal fun KeyStoreManagerError.toBiometricAuthError(): BiometricAuthError = when (this) { + KeyStoreManagerError.KeyInvalidated -> BiometricAuthError.KeyInvalidated + KeyStoreManagerError.AuthenticationRequired -> BiometricAuthError.CryptoFailed + KeyStoreManagerError.Unknown -> BiometricAuthError.CryptoFailed +} + +internal fun biometricAuthErrorFrom( + errorCode: Int, + errString: CharSequence, +): BiometricAuthError = when (errorCode) { + BiometricPrompt.ERROR_NEGATIVE_BUTTON -> BiometricAuthError.Declined + + BiometricPrompt.ERROR_LOCKOUT, + BiometricPrompt.ERROR_LOCKOUT_PERMANENT, + -> BiometricAuthError.LockedOut + + BiometricPrompt.ERROR_USER_CANCELED, + BiometricPrompt.ERROR_CANCELED, + -> BiometricAuthError.Canceled + + else -> BiometricAuthError.Unknown(errorCode, errString.toString()) +} diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/BiometricStringResolver.kt b/core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/data/BiometricStringResolver.kt similarity index 67% rename from core/security/src/main/kotlin/de/davis/keygo/core/security/data/BiometricStringResolver.kt rename to core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/data/BiometricStringResolver.kt index 97f6b061f..160ed6b2b 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/BiometricStringResolver.kt +++ b/core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/data/BiometricStringResolver.kt @@ -1,10 +1,10 @@ -package de.davis.keygo.core.security.data +package de.davis.keygo.core.biometrics.data import android.content.Context -import de.davis.keygo.core.security.R -import de.davis.keygo.core.security.domain.model.BiometricString +import de.davis.keygo.core.biometrics.R +import de.davis.keygo.core.biometrics.domain.model.BiometricString -internal fun BiometricString.resolve(context: Context) = when (this) { +fun BiometricString.resolve(context: Context) = when (this) { is BiometricString.Title.Authenticate -> context.getString( R.string.authenticate ) diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/repository/BiometricAvailabilityRepositoryImpl.kt b/core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/data/repository/BiometricAvailabilityRepositoryImpl.kt similarity index 80% rename from core/security/src/main/kotlin/de/davis/keygo/core/security/data/repository/BiometricAvailabilityRepositoryImpl.kt rename to core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/data/repository/BiometricAvailabilityRepositoryImpl.kt index 8dd11db23..764250fc1 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/repository/BiometricAvailabilityRepositoryImpl.kt +++ b/core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/data/repository/BiometricAvailabilityRepositoryImpl.kt @@ -1,8 +1,8 @@ -package de.davis.keygo.core.security.data.repository +package de.davis.keygo.core.biometrics.data.repository import android.content.Context import androidx.biometric.BiometricManager -import de.davis.keygo.core.security.domain.repository.BiometricAvailabilityRepository +import de.davis.keygo.core.biometrics.domain.repository.BiometricAvailabilityRepository import org.koin.core.annotation.Single @Single diff --git a/core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/di/CoreBiometricsModule.kt b/core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/di/CoreBiometricsModule.kt new file mode 100644 index 000000000..49e87fd6f --- /dev/null +++ b/core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/di/CoreBiometricsModule.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.core.biometrics.di + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module + +@Module +@Configuration +@ComponentScan("de.davis.keygo.core.biometrics") +object CoreBiometricsModule diff --git a/core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/domain/BiometricCrypto.kt b/core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/domain/BiometricCrypto.kt new file mode 100644 index 000000000..7b99a9e66 --- /dev/null +++ b/core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/domain/BiometricCrypto.kt @@ -0,0 +1,23 @@ +package de.davis.keygo.core.biometrics.domain + +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError +import de.davis.keygo.core.biometrics.domain.model.BiometricPolicy +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData +import de.davis.keygo.core.security.domain.model.KeyId +import de.davis.keygo.core.util.Result +import java.security.Key + +interface BiometricCrypto { + + suspend fun requestWrap( + keyId: KeyId, + policy: BiometricPolicy = BiometricPolicy.Default, + wrap: (seal: (key: ByteArray) -> CryptographicData) -> T, + ): Result + + suspend fun requestUnwrap( + keyId: KeyId, + cryptographicData: CryptographicData, + policy: BiometricPolicy = BiometricPolicy.Default, + ): Result +} diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/BiometricAuthError.kt b/core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/domain/model/BiometricAuthError.kt similarity index 80% rename from core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/BiometricAuthError.kt rename to core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/domain/model/BiometricAuthError.kt index 93001528e..648297da2 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/BiometricAuthError.kt +++ b/core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/domain/model/BiometricAuthError.kt @@ -1,7 +1,10 @@ -package de.davis.keygo.core.security.domain.model +package de.davis.keygo.core.biometrics.domain.model sealed interface BiometricAuthError { + data object NoPromptHost : BiometricAuthError + + /** User canceled the prompt by pressing the negative button. */ data object Declined : BiometricAuthError data object LockedOut : BiometricAuthError @@ -11,7 +14,7 @@ sealed interface BiometricAuthError { data class Unknown(val errorCode: Int, val errString: String) : BiometricAuthError /** Biometrics cannot be used at all (no hardware, none enrolled, etc.). */ - data class CanNotAuthenticate(val code: Int) : BiometricAuthError + data object BiometricsNotAvailable : BiometricAuthError data object NoCipher : BiometricAuthError data object CryptoFailed : BiometricAuthError diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/BiometricPolicy.kt b/core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/domain/model/BiometricPolicy.kt similarity index 74% rename from core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/BiometricPolicy.kt rename to core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/domain/model/BiometricPolicy.kt index d7382fb65..190b9cf57 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/BiometricPolicy.kt +++ b/core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/domain/model/BiometricPolicy.kt @@ -1,8 +1,8 @@ -package de.davis.keygo.core.security.domain.model +package de.davis.keygo.core.biometrics.domain.model data class BiometricPolicy( val title: BiometricString.Title = BiometricString.Title.Authenticate, - val negativeButton: BiometricString.NegativeButton = BiometricString.NegativeButton.Cancel + val negativeButton: BiometricString.NegativeButton = BiometricString.NegativeButton.Cancel, ) { companion object { val Default = BiometricPolicy() diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/BiometricString.kt b/core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/domain/model/BiometricString.kt similarity index 86% rename from core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/BiometricString.kt rename to core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/domain/model/BiometricString.kt index 4ba83c981..e78e8edaa 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/BiometricString.kt +++ b/core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/domain/model/BiometricString.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.core.security.domain.model +package de.davis.keygo.core.biometrics.domain.model sealed interface BiometricString { diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/repository/BiometricAvailabilityRepository.kt b/core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/domain/repository/BiometricAvailabilityRepository.kt similarity index 58% rename from core/security/src/main/kotlin/de/davis/keygo/core/security/domain/repository/BiometricAvailabilityRepository.kt rename to core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/domain/repository/BiometricAvailabilityRepository.kt index 9b112e349..a8a0def38 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/repository/BiometricAvailabilityRepository.kt +++ b/core/biometrics/src/main/kotlin/de/davis/keygo/core/biometrics/domain/repository/BiometricAvailabilityRepository.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.core.security.domain.repository +package de.davis.keygo.core.biometrics.domain.repository interface BiometricAvailabilityRepository { diff --git a/core/security/src/main/res/values/strings.xml b/core/biometrics/src/main/res/values/strings.xml similarity index 100% rename from core/security/src/main/res/values/strings.xml rename to core/biometrics/src/main/res/values/strings.xml diff --git a/core/security/src/test/kotlin/de/davis/keygo/core/security/presentation/BiometricAuthErrorFromTest.kt b/core/biometrics/src/test/kotlin/de/davis/keygo/core/biometrics/data/BiometricAuthErrorFromTest.kt similarity index 92% rename from core/security/src/test/kotlin/de/davis/keygo/core/security/presentation/BiometricAuthErrorFromTest.kt rename to core/biometrics/src/test/kotlin/de/davis/keygo/core/biometrics/data/BiometricAuthErrorFromTest.kt index 56cf14c81..c5581d8c1 100644 --- a/core/security/src/test/kotlin/de/davis/keygo/core/security/presentation/BiometricAuthErrorFromTest.kt +++ b/core/biometrics/src/test/kotlin/de/davis/keygo/core/biometrics/data/BiometricAuthErrorFromTest.kt @@ -1,7 +1,7 @@ -package de.davis.keygo.core.security.presentation +package de.davis.keygo.core.biometrics.data import androidx.biometric.BiometricPrompt -import de.davis.keygo.core.security.domain.model.BiometricAuthError +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError import kotlin.test.Test import kotlin.test.assertEquals diff --git a/core/biometrics/src/test/kotlin/de/davis/keygo/core/biometrics/data/BiometricCryptoImplTest.kt b/core/biometrics/src/test/kotlin/de/davis/keygo/core/biometrics/data/BiometricCryptoImplTest.kt new file mode 100644 index 000000000..18ccc8bd7 --- /dev/null +++ b/core/biometrics/src/test/kotlin/de/davis/keygo/core/biometrics/data/BiometricCryptoImplTest.kt @@ -0,0 +1,220 @@ +package de.davis.keygo.core.biometrics.data + +import androidx.fragment.app.FragmentActivity +import de.davis.keygo.core.biometrics.FakeBiometricAvailabilityRepository +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError +import de.davis.keygo.core.security.crypto.FakeKeyStoreManager +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData +import de.davis.keygo.core.security.domain.model.KeyId +import de.davis.keygo.core.security.domain.model.KeyStoreManagerError +import de.davis.keygo.core.util.assertFailure +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@OptIn(ExperimentalCoroutinesApi::class) +class BiometricCryptoImplTest { + + private val availability = FakeBiometricAvailabilityRepository().apply { isAvailable = true } + private val keyStoreManager = FakeKeyStoreManager() + + private val crypto = BiometricCryptoImpl( + context = RuntimeEnvironment.getApplication(), + biometricAvailabilityRepository = availability, + keyStoreManager = keyStoreManager, + ) + + private fun resumedActivity() = Robolectric.buildActivity(FragmentActivity::class.java).setup() + + private suspend fun unwrap() = crypto.requestUnwrap( + keyId = KeyId.BiometricVaultKek, + cryptographicData = CryptographicData(data = ByteArray(48), iv = ByteArray(12)), + ) + + private suspend fun wrap() = crypto.requestWrap(keyId = KeyId.BiometricVaultKek) { seal -> + seal(ByteArray(32) { 1 }) + } + + private fun FragmentActivity.showsPrompt(): Boolean = + supportFragmentManager.findFragmentByTag(BIOMETRIC_FRAGMENT_TAG) != null + + @Test + fun `without a resumed activity there is nothing to show the prompt on`() = runTest { + assertEquals(BiometricAuthError.NoPromptHost, unwrap().assertFailure()) + assertEquals(BiometricAuthError.NoPromptHost, wrap().assertFailure()) + } + + @Test + fun `a destroyed activity is not used to host the prompt`() = runTest { + resumedActivity().pause().stop().destroy() + + assertEquals(BiometricAuthError.NoPromptHost, unwrap().assertFailure()) + } + + @Test + fun `a finishing activity is not used to host the prompt`() = runTest { + resumedActivity().get().finish() + + assertEquals(BiometricAuthError.NoPromptHost, unwrap().assertFailure()) + } + + /** + * With several activities resumed at once (multi-window on API 29+), one can pause after another + * has become the host. Clearing the host on any pause would drop the one in front, so every + * request would fail until it resumed again. + */ + @Test + fun `an activity leaving behind the host leaves the host in place`() = runTest { + val behind = resumedActivity() + resumedActivity() + behind.pause().stop().destroy() + // Reaching the keystore at all means a host was found. + keyStoreManager.failure = KeyStoreManagerError.KeyInvalidated + + assertEquals(BiometricAuthError.KeyInvalidated, unwrap().assertFailure()) + } + + /** + * BiometricPrompt drops an authenticate() made after onSaveInstanceState without calling back, + * so a request that used a stopped host hung with nothing on screen. + */ + @Test + fun `a host the user has left is not used to show the prompt`() = runTest { + val controller = resumedActivity().pause().stop() + + assertEquals(BiometricAuthError.NoPromptHost, unwrap().assertFailure()) + assertFalse(controller.get().showsPrompt()) + } + + /** + * A paused host is not stopped yet, so its state is not saved either. When the activity covering + * it belongs to the app, nothing cancels a prompt opened on it, and on API 26-27 that prompt is a + * dialog in a window the user cannot see. + */ + @Test + fun `a paused host is not used to show the prompt`() = runTest { + val controller = resumedActivity().pause() + + val request = async { unwrap() } + advanceUntilIdle() + + assertFalse(controller.get().showsPrompt()) + assertEquals(BiometricAuthError.NoPromptHost, request.await().assertFailure()) + } + + /** + * An activity can start a request before it has resumed, once the one the user left has paused. + * Failing on the host that is missing in that gap failed the request before its own activity got + * there. + */ + @Test + fun `a request made while its activity starts waits for it past the host left behind`() = + runTest { + resumedActivity().pause().stop() + val starting = Robolectric.buildActivity(FragmentActivity::class.java) + .create() + .start() + .postCreate(null) + + val request = async { unwrap() } + runCurrent() + assertFalse(request.isCompleted) + + starting.resume().visible() + runCurrent() + + assertTrue(starting.get().showsPrompt()) + request.cancel() + } + + /** + * Every prompt built on an activity replaces the callback the one before it registered. The + * failure the second request would fail with shows whether it ran alongside the first. + */ + @Test + fun `a second request waits for the prompt already on screen`() = runTest { + resumedActivity() + val first = async { unwrap() } + runCurrent() + keyStoreManager.failure = KeyStoreManagerError.Unknown + + val second = async { unwrap() } + runCurrent() + assertFalse(second.isCompleted) + + first.cancel() + runCurrent() + + assertEquals(BiometricAuthError.CryptoFailed, second.await().assertFailure()) + } + + @Test + fun `the key to wrap is not asked for before the user authenticates`() = runTest { + val controller = resumedActivity() + var asked = false + + val request = async { + crypto.requestWrap(keyId = KeyId.BiometricVaultKek) { seal -> + asked = true + seal(ByteArray(32)) + } + } + runCurrent() + + assertTrue(controller.get().showsPrompt()) + assertFalse(asked) + request.cancel() + } + + @Test + fun `unusable biometrics are reported before the keystore is asked for a cipher`() = runTest { + resumedActivity() + availability.isAvailable = false + + assertEquals(BiometricAuthError.BiometricsNotAvailable, unwrap().assertFailure()) + assertTrue(keyStoreManager.keys.isEmpty()) + } + + @Test + fun `a permanently invalidated key is reported without showing the prompt`() = runTest { + resumedActivity() + keyStoreManager.failure = KeyStoreManagerError.KeyInvalidated + + assertEquals(BiometricAuthError.KeyInvalidated, unwrap().assertFailure()) + assertEquals(BiometricAuthError.KeyInvalidated, wrap().assertFailure()) + } + + @Test + fun `a keystore failure that names nothing stays retryable`() = runTest { + resumedActivity() + keyStoreManager.failure = KeyStoreManagerError.Unknown + + assertEquals(BiometricAuthError.CryptoFailed, unwrap().assertFailure()) + } + + @Test + fun `a key gated on an unlocked device stays retryable`() = runTest { + resumedActivity() + keyStoreManager.deviceLocked = true + + assertEquals(BiometricAuthError.CryptoFailed, wrap().assertFailure()) + } + + private companion object { + // BiometricPrompt.BIOMETRIC_FRAGMENT_TAG is package-private. + const val BIOMETRIC_FRAGMENT_TAG = "androidx.biometric.internal.BiometricFragment" + } +} diff --git a/core/biometrics/src/test/kotlin/de/davis/keygo/core/biometrics/data/BiometricStringResolverTest.kt b/core/biometrics/src/test/kotlin/de/davis/keygo/core/biometrics/data/BiometricStringResolverTest.kt new file mode 100644 index 000000000..d408ce6a4 --- /dev/null +++ b/core/biometrics/src/test/kotlin/de/davis/keygo/core/biometrics/data/BiometricStringResolverTest.kt @@ -0,0 +1,33 @@ +package de.davis.keygo.core.biometrics.data + +import de.davis.keygo.core.biometrics.domain.model.BiometricPolicy +import de.davis.keygo.core.biometrics.domain.model.BiometricString +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class BiometricStringResolverTest { + + private val context = RuntimeEnvironment.getApplication() + + @Test + fun `the default policy asks to authenticate and offers to cancel`() { + assertEquals("Authenticate", BiometricPolicy.Default.title.resolve(context)) + assertEquals("Cancel", BiometricPolicy.Default.negativeButton.resolve(context)) + } + + @Test + fun `unlocking an item names the item`() { + assertEquals("Unlock GitHub", BiometricString.Title.UnlockItem("GitHub").resolve(context)) + } + + @Test + fun `the password fallback button says so`() { + assertEquals("Use Password", BiometricString.NegativeButton.Password.resolve(context)) + } +} diff --git a/core/security/src/test/kotlin/de/davis/keygo/core/security/presentation/KeyStoreManagerErrorMappingTest.kt b/core/biometrics/src/test/kotlin/de/davis/keygo/core/biometrics/data/KeyStoreManagerErrorMappingTest.kt similarity index 95% rename from core/security/src/test/kotlin/de/davis/keygo/core/security/presentation/KeyStoreManagerErrorMappingTest.kt rename to core/biometrics/src/test/kotlin/de/davis/keygo/core/biometrics/data/KeyStoreManagerErrorMappingTest.kt index 6ba603483..8799ec0c2 100644 --- a/core/security/src/test/kotlin/de/davis/keygo/core/security/presentation/KeyStoreManagerErrorMappingTest.kt +++ b/core/biometrics/src/test/kotlin/de/davis/keygo/core/biometrics/data/KeyStoreManagerErrorMappingTest.kt @@ -1,6 +1,6 @@ -package de.davis.keygo.core.security.presentation +package de.davis.keygo.core.biometrics.data -import de.davis.keygo.core.security.domain.model.BiometricAuthError +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError import de.davis.keygo.core.security.domain.model.KeyStoreManagerError import java.security.InvalidKeyException import javax.crypto.AEADBadTagException diff --git a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeBiometricAvailabilityRepository.kt b/core/biometrics/src/testFixtures/kotlin/de/davis/keygo/core/biometrics/FakeBiometricAvailabilityRepository.kt similarity index 57% rename from core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeBiometricAvailabilityRepository.kt rename to core/biometrics/src/testFixtures/kotlin/de/davis/keygo/core/biometrics/FakeBiometricAvailabilityRepository.kt index d8159e225..9de02f37e 100644 --- a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeBiometricAvailabilityRepository.kt +++ b/core/biometrics/src/testFixtures/kotlin/de/davis/keygo/core/biometrics/FakeBiometricAvailabilityRepository.kt @@ -1,6 +1,6 @@ -package de.davis.keygo.core.security.crypto +package de.davis.keygo.core.biometrics -import de.davis.keygo.core.security.domain.repository.BiometricAvailabilityRepository +import de.davis.keygo.core.biometrics.domain.repository.BiometricAvailabilityRepository class FakeBiometricAvailabilityRepository : BiometricAvailabilityRepository { diff --git a/core/biometrics/src/testFixtures/kotlin/de/davis/keygo/core/biometrics/FakeBiometricCrypto.kt b/core/biometrics/src/testFixtures/kotlin/de/davis/keygo/core/biometrics/FakeBiometricCrypto.kt new file mode 100644 index 000000000..781ace7aa --- /dev/null +++ b/core/biometrics/src/testFixtures/kotlin/de/davis/keygo/core/biometrics/FakeBiometricCrypto.kt @@ -0,0 +1,96 @@ +package de.davis.keygo.core.biometrics + +import de.davis.keygo.core.biometrics.domain.BiometricCrypto +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError +import de.davis.keygo.core.biometrics.domain.model.BiometricPolicy +import de.davis.keygo.core.security.crypto.FakeKeyStoreManager +import de.davis.keygo.core.security.domain.KeyStoreManager +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 kotlinx.coroutines.CompletableDeferred +import java.security.Key +import javax.crypto.AEADBadTagException +import javax.crypto.Cipher +import javax.crypto.SecretKey + +class FakeBiometricCrypto( + val keyStoreManager: KeyStoreManager = FakeKeyStoreManager(), +) : BiometricCrypto { + + data class Prompt( + val keyId: KeyId, + val mode: CryptographicMode, + val policy: BiometricPolicy, + ) + + var promptFailure: BiometricAuthError? = null + + var pendingPrompt: CompletableDeferred? = null + + val prompts: MutableList = mutableListOf() + + val unwrapped: MutableList = mutableListOf() + + override suspend fun requestWrap( + keyId: KeyId, + policy: BiometricPolicy, + wrap: (seal: (key: ByteArray) -> CryptographicData) -> T, + ): Result = + prompt(keyId, CryptographicMode.Wrap, policy, iv = null) { cipher -> + wrap { key -> CryptographicData(data = cipher.doFinal(key), iv = cipher.iv) } + } + + override suspend fun requestUnwrap( + keyId: KeyId, + cryptographicData: CryptographicData, + policy: BiometricPolicy, + ): Result = + prompt(keyId, CryptographicMode.Unwrap, policy, iv = cryptographicData.iv) { cipher -> + val material = cipher.doFinal(cryptographicData.data) + unwrapped += material + HandedOutKey(material) + } + + private suspend fun prompt( + keyId: KeyId, + mode: CryptographicMode, + policy: BiometricPolicy, + iv: ByteArray?, + onAuthenticated: (Cipher) -> T, + ): Result { + prompts += Prompt(keyId, mode, policy) + pendingPrompt?.await() + promptFailure?.let { return Result.Failure(it) } + + val cipher = when (val result = keyStoreManager.getOrCreateCipherFor(keyId, mode, iv)) { + is Result.Success -> result.success + is Result.Failure -> return Result.Failure(result.error.toBiometricAuthError()) + } + + return runCatching { onAuthenticated(cipher) }.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it.toBiometricAuthError()) }, + ) + } + + private fun KeyStoreManagerError.toBiometricAuthError(): BiometricAuthError = when (this) { + KeyStoreManagerError.KeyInvalidated -> BiometricAuthError.KeyInvalidated + KeyStoreManagerError.AuthenticationRequired, + KeyStoreManagerError.Unknown, + -> BiometricAuthError.CryptoFailed + } + + private fun Throwable.toBiometricAuthError(): BiometricAuthError = + if (generateSequence(this) { it.cause }.any { it is AEADBadTagException }) + BiometricAuthError.KeyInvalidated + else BiometricAuthError.CryptoFailed + + private class HandedOutKey(private val material: ByteArray) : SecretKey { + override fun getAlgorithm(): String = "AES" + override fun getFormat(): String = "RAW" + override fun getEncoded(): ByteArray = material + } +} diff --git a/core/identity/build.gradle.kts b/core/identity/build.gradle.kts index d17b65b0d..a08eedff7 100644 --- a/core/identity/build.gradle.kts +++ b/core/identity/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - alias(libs.plugins.keygo.android.compose) + alias(libs.plugins.keygo.android.library) alias(libs.plugins.keygo.android.protobuf) } @@ -12,6 +12,7 @@ android { } dependencies { + api(projects.core.biometrics) api(projects.core.security) implementation(projects.core.item) implementation(projects.rust) @@ -20,14 +21,12 @@ dependencies { implementation(libs.androidx.datastore) testImplementation(libs.io.mockk) + testImplementation(testFixtures(projects.core.biometrics)) testImplementation(testFixtures(projects.core.item)) testImplementation(testFixtures(projects.core.security)) + testImplementation(testFixtures(projects.core.util)) testImplementation(testFixtures(projects.rust)) testFixturesApi(projects.core.util) testFixturesImplementation(projects.rust) - testFixturesImplementation(project.dependencies.platform(libs.androidx.compose.bom)) - testFixturesImplementation(libs.androidx.compose.runtime) { - because("https://issuetracker.google.com/issues/259523353#comment32") - } } diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/mapper/BiometricWrappedArkMapper.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/mapper/BiometricWrappedArkMapper.kt new file mode 100644 index 000000000..593d1d5c8 --- /dev/null +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/mapper/BiometricWrappedArkMapper.kt @@ -0,0 +1,9 @@ +package de.davis.keygo.core.identity.domain.mapper + +import de.davis.keygo.core.identity.domain.model.BiometricWrappedArk +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData + +fun CryptographicData.toBiometricWrappedArk() = BiometricWrappedArk( + key = data, + keyIV = iv, +) diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/BiometricEnrollmentError.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/BiometricEnrollmentError.kt index 8ba44ad58..302b4f36e 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/BiometricEnrollmentError.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/BiometricEnrollmentError.kt @@ -1,11 +1,14 @@ package de.davis.keygo.core.identity.domain.model -import de.davis.keygo.core.security.domain.model.BiometricAuthError +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError sealed interface BiometricEnrollmentError { data object NoActiveAccount : BiometricEnrollmentError data object NoActiveSession : BiometricEnrollmentError - data object WrappingFailed : BiometricEnrollmentError data object PersistenceFailed : BiometricEnrollmentError data class BiometricFailed(val error: BiometricAuthError) : BiometricEnrollmentError -} \ No newline at end of file +} + +fun BiometricEnrollmentError.isUserDismissal(): Boolean = + this is BiometricEnrollmentError.BiometricFailed && + (error == BiometricAuthError.Declined || error == BiometricAuthError.Canceled) diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/ChangePasswordError.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/ChangePasswordError.kt index 5c6732792..00d0a51cb 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/ChangePasswordError.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/ChangePasswordError.kt @@ -4,6 +4,9 @@ sealed interface ChangePasswordError { data object ActiveAccountNotFound : ChangePasswordError data object IncorrectPassword : ChangePasswordError + data object BiometricAuthFailed : ChangePasswordError + data object BiometricDeclined : ChangePasswordError + data object BiometricCanceled : ChangePasswordError data object BiometricNotEnrolled : ChangePasswordError data object KeyDerivationFailed : ChangePasswordError data object WrappingFailed : ChangePasswordError diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/Reauthentication.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/Reauthentication.kt index a6f5717d8..f79ce9ba9 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/Reauthentication.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/Reauthentication.kt @@ -4,5 +4,5 @@ sealed interface Reauthentication { data class Password(val currentPassword: String) : Reauthentication - class Biometric(val recoveredArk: ByteArray) : Reauthentication + data object Biometric : Reauthentication } 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 3594da598..b1c587fce 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,6 +1,6 @@ package de.davis.keygo.core.identity.domain.model -import de.davis.keygo.core.security.domain.model.BiometricAuthError +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError sealed interface UnlockError { data object WrappedKeyNotFound : UnlockError diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/UnlockableByBiometricsResult.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/UnlockableByBiometricsResult.kt new file mode 100644 index 000000000..20045462f --- /dev/null +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/UnlockableByBiometricsResult.kt @@ -0,0 +1,15 @@ +package de.davis.keygo.core.identity.domain.model + +sealed interface UnlockableByBiometricsResult { + data object Available : UnlockableByBiometricsResult + data object NoHardware : UnlockableByBiometricsResult + data object NotEnrolled : UnlockableByBiometricsResult + + data class NoAccount(val hardwareAvailable: Boolean) : UnlockableByBiometricsResult +} + +fun UnlockableByBiometricsResult.hasHardware() = when (this) { + UnlockableByBiometricsResult.NoHardware -> false + is UnlockableByBiometricsResult.NoAccount -> hardwareAvailable + else -> true +} 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 3d9a50bc6..f4a8a0e5f 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 @@ -1,18 +1,28 @@ package de.davis.keygo.core.identity.domain.usecase +import de.davis.keygo.core.biometrics.domain.BiometricCrypto +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError +import de.davis.keygo.core.biometrics.domain.model.BiometricPolicy +import de.davis.keygo.core.biometrics.domain.model.BiometricString +import de.davis.keygo.core.identity.domain.model.Account 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.security.domain.crypto.model.CryptographicData +import de.davis.keygo.core.security.domain.model.KeyId 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.davisalessandro.keygo.rust.WrappedKeyBlob import org.koin.core.annotation.Single @Single class ChangePasswordUseCase( + private val biometricCrypto: BiometricCrypto, private val accountRepository: AccountRepository, private val session: Session, ) { @@ -20,45 +30,13 @@ class ChangePasswordUseCase( suspend operator fun invoke( reauthentication: Reauthentication, newPassword: String, - ): Result = try { - changePassword(reauthentication, newPassword) - } finally { - // We take ownership of the caller-supplied ARK: never leave a copy behind, - // even when an early validation bails out or re-wrapping fails part-way. - if (reauthentication is Reauthentication.Biometric) reauthentication.recoveredArk.fill(0) - } - - private suspend fun changePassword( - reauthentication: Reauthentication, - newPassword: String, ): Result = resultBinding { val account = accountRepository.getOrNull() ?: return Result.Failure(ChangePasswordError.ActiveAccountNotFound) 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) - val matches = session.verifyArk(reauthentication.recoveredArk) - .bind { ChangePasswordError.ActiveAccountNotFound } - if (!matches) return Result.Failure(ChangePasswordError.IncorrectPassword) - } + is Reauthentication.Password -> passwordAuthenticationPath(reauthentication, account) + is Reauthentication.Biometric -> biometricAuthenticationPath(account) } val rewrapped = session.rewrapForNewPassword(newPassword, account.id).bind { @@ -79,4 +57,61 @@ class ChangePasswordUseCase( ), ).bind { ChangePasswordError.PersistenceFailed } } + + private suspend fun ResultBinding.passwordAuthenticationPath( + reauthentication: Reauthentication.Password, + account: Account, + ) { + 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 + } + } + } + + private suspend fun ResultBinding.biometricAuthenticationPath( + account: Account, + ) { + val wrappedKey = account.biometricWrappedArk + .asResult(ChangePasswordError.BiometricNotEnrolled) + .bind() + + var unwrappedArk: ByteArray? = null + + try { + unwrappedArk = biometricCrypto.requestUnwrap( + keyId = KeyId.BiometricVaultKek, + cryptographicData = CryptographicData( + data = wrappedKey.key, + iv = wrappedKey.keyIV + ), + policy = BiometricPolicy( + negativeButton = BiometricString.NegativeButton.Password + ) + ).bind { + when (it) { + BiometricAuthError.Declined -> ChangePasswordError.BiometricDeclined + BiometricAuthError.Canceled -> ChangePasswordError.BiometricCanceled + else -> ChangePasswordError.BiometricAuthFailed + } + }.encoded + + val matches = session.verifyArk(unwrappedArk) + .bind { ChangePasswordError.ActiveAccountNotFound } + + matches.asResult(ChangePasswordError.BiometricAuthFailed).bind() + } finally { + unwrappedArk?.fill(0) + } + } } 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 a662f1b9d..2982c5c1d 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 @@ -1,7 +1,7 @@ package de.davis.keygo.core.identity.domain.usecase +import de.davis.keygo.core.biometrics.domain.model.BiometricPolicy 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.CreateAccessError import de.davis.keygo.core.identity.domain.model.PasswordWrappedArk import de.davis.keygo.core.identity.domain.repository.AccountRepository @@ -10,45 +10,33 @@ 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.isFailure +import de.davis.keygo.core.util.isSuccess import de.davis.keygo.core.util.resultBinding import org.koin.core.annotation.Single -import javax.crypto.Cipher -import javax.crypto.spec.SecretKeySpec @Single class CreateAccessUseCase( private val accountRepository: AccountRepository, private val vaultRepository: VaultRepository, private val vaultContextRepository: VaultContextRepository, + private val enableBiometrics: EnableBiometricsUseCase, private val session: Session, ) { - /** - * 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 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. - */ suspend operator fun invoke( password: String, - biometricCipher: Cipher? = null, + withBiometrics: Boolean = false, vaultName: String = "Default Vault", accountDisplayName: String = "Default Account", + policy: BiometricPolicy = BiometricPolicy.Default, ): Result { var handBack = true try { - val result = create(password, biometricCipher, vaultName, accountDisplayName) + val result = create(password, vaultName, accountDisplayName) handBack = result.isFailure() + if (result.isSuccess() && withBiometrics) enableBiometrics(policy) return result } finally { if (handBack) session.endSession() @@ -57,7 +45,6 @@ class CreateAccessUseCase( private suspend fun create( password: String, - biometricCipher: Cipher?, vaultName: String, accountDisplayName: String, ): Result = resultBinding { @@ -67,12 +54,6 @@ class CreateAccessUseCase( 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 // ARK, so a vault row without a recoverable account is dead weight. If the vault // write fails after this, the half-state is recoverable on retry, since `set` overwrites. @@ -85,7 +66,7 @@ class CreateAccessUseCase( keyIV = created.passwordWrappedArk.nonce, salt = created.salt, ), - biometricWrappedArk = biometricWrappedArk, + biometricWrappedArk = null, ) ).bind { CreateAccessError.AccountPersistenceFailed } @@ -104,11 +85,4 @@ class CreateAccessUseCase( vaultContextRepository.setContextAndLastInteracted(created.vaultId) } - - 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/DisableBiometricsUseCase.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/DisableBiometricsUseCase.kt new file mode 100644 index 000000000..0210c9f63 --- /dev/null +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/DisableBiometricsUseCase.kt @@ -0,0 +1,27 @@ +package de.davis.keygo.core.identity.domain.usecase + +import de.davis.keygo.core.identity.domain.model.BiometricEnrollmentError +import de.davis.keygo.core.identity.domain.repository.AccountRepository +import de.davis.keygo.core.security.domain.KeyStoreManager +import de.davis.keygo.core.security.domain.model.KeyId +import de.davis.keygo.core.util.asResult +import de.davis.keygo.core.util.resultBinding +import org.koin.core.annotation.Single + +@Single +class DisableBiometricsUseCase( + private val accountRepository: AccountRepository, + private val keyStoreManager: KeyStoreManager, +) { + + suspend operator fun invoke() = resultBinding { + val account = accountRepository.getOrNull() + .asResult(BiometricEnrollmentError.NoActiveAccount) + .bind() + + accountRepository.set(account.copy(biometricWrappedArk = null)) + .bind { BiometricEnrollmentError.PersistenceFailed } + + keyStoreManager.deleteKey(KeyId.BiometricVaultKek) + } +} diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/EnableBiometricsUseCase.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/EnableBiometricsUseCase.kt new file mode 100644 index 000000000..3c769c2f3 --- /dev/null +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/EnableBiometricsUseCase.kt @@ -0,0 +1,43 @@ +package de.davis.keygo.core.identity.domain.usecase + +import de.davis.keygo.core.biometrics.domain.BiometricCrypto +import de.davis.keygo.core.biometrics.domain.model.BiometricPolicy +import de.davis.keygo.core.identity.domain.mapper.toBiometricWrappedArk +import de.davis.keygo.core.identity.domain.model.BiometricEnrollmentError +import de.davis.keygo.core.identity.domain.repository.AccountRepository +import de.davis.keygo.core.security.domain.KeyStoreManager +import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.core.security.domain.model.KeyId +import de.davis.keygo.core.security.domain.useArk +import de.davis.keygo.core.util.asResult +import de.davis.keygo.core.util.resultBinding +import org.koin.core.annotation.Single + +@Single +class EnableBiometricsUseCase( + private val accountRepository: AccountRepository, + private val session: Session, + private val keyStoreManager: KeyStoreManager, + private val biometricCrypto: BiometricCrypto, +) { + + suspend operator fun invoke(policy: BiometricPolicy = BiometricPolicy.Default) = resultBinding { + val account = accountRepository.getOrNull() + .asResult(BiometricEnrollmentError.NoActiveAccount) + .bind() + + session.isActive.value.asResult(BiometricEnrollmentError.NoActiveSession).bind() + + if (account.biometricWrappedArk == null) keyStoreManager.deleteKey(KeyId.BiometricVaultKek) + + val wrapped = biometricCrypto.requestWrap( + keyId = KeyId.BiometricVaultKek, + policy = policy, + ) { seal -> session.useArk(seal) } + .bind { BiometricEnrollmentError.BiometricFailed(it) } + .bind { BiometricEnrollmentError.NoActiveSession } + + accountRepository.set(account.copy(biometricWrappedArk = wrapped.toBiometricWrappedArk())) + .bind { BiometricEnrollmentError.PersistenceFailed } + } +} diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithBiometricsUseCase.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithBiometricsUseCase.kt new file mode 100644 index 000000000..0e8200f13 --- /dev/null +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithBiometricsUseCase.kt @@ -0,0 +1,63 @@ +package de.davis.keygo.core.identity.domain.usecase + +import de.davis.keygo.core.biometrics.domain.BiometricCrypto +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError +import de.davis.keygo.core.biometrics.domain.model.BiometricPolicy +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.crypto.model.CryptographicData +import de.davis.keygo.core.security.domain.model.KeyId +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.asResult +import de.davis.keygo.core.util.fold +import de.davis.keygo.core.util.resultBinding +import org.koin.core.annotation.Single + +@Single +class UnlockWithBiometricsUseCase( + private val session: Session, + private val accountRepository: AccountRepository, + private val biometricCrypto: BiometricCrypto, + private val disableBiometrics: DisableBiometricsUseCase, +) { + + suspend operator fun invoke( + policy: BiometricPolicy = BiometricPolicy.Default, + ): Result = resultBinding { + val account = accountRepository.getOrNull() + .asResult(UnlockError.ActiveAccountNotFound) + .bind() + + val wrappedKey = account.biometricWrappedArk + .asResult(UnlockError.WrappedKeyNotFound) + .bind() + + val key = biometricCrypto.requestUnwrap( + keyId = KeyId.BiometricVaultKek, + cryptographicData = CryptographicData( + data = wrappedKey.key, + iv = wrappedKey.keyIV + ), + policy = policy, + ).bind { error -> + when (error) { + BiometricAuthError.KeyInvalidated -> { + disableBiometrics().fold( + onSuccess = { UnlockError.BiometricEnrollmentReset }, + onFailure = { UnlockError.BiometricFailed(BiometricAuthError.KeyInvalidated) } + ) + } + + else -> UnlockError.BiometricFailed(error) + } + } + + val ark = key.encoded + try { + session.unlockWithArk(ark).bind { UnlockError.UnwrappingFailed } + } finally { + ark.fill(0) + } + } +} \ No newline at end of file diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockableByBiometricsUseCase.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockableByBiometricsUseCase.kt new file mode 100644 index 000000000..d2b8fb166 --- /dev/null +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockableByBiometricsUseCase.kt @@ -0,0 +1,24 @@ +package de.davis.keygo.core.identity.domain.usecase + +import de.davis.keygo.core.biometrics.domain.repository.BiometricAvailabilityRepository +import de.davis.keygo.core.identity.domain.model.UnlockableByBiometricsResult +import de.davis.keygo.core.identity.domain.repository.AccountRepository +import org.koin.core.annotation.Single + +@Single +class UnlockableByBiometricsUseCase( + private val accountRepository: AccountRepository, + private val biometricAvailabilityRepository: BiometricAvailabilityRepository, +) { + + suspend operator fun invoke(): UnlockableByBiometricsResult { + val hardwareAvailable = biometricAvailabilityRepository.availability() + + val account = accountRepository.getOrNull() + ?: return UnlockableByBiometricsResult.NoAccount(hardwareAvailable) + if (!hardwareAvailable) return UnlockableByBiometricsResult.NoHardware + if (account.biometricWrappedArk == null) return UnlockableByBiometricsResult.NotEnrolled + + return UnlockableByBiometricsResult.Available + } +} \ No newline at end of file diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricEnrollmentAdapter.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricEnrollmentAdapter.kt deleted file mode 100644 index c050ff0f4..000000000 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricEnrollmentAdapter.kt +++ /dev/null @@ -1,19 +0,0 @@ -package de.davis.keygo.core.identity.presentation - -import de.davis.keygo.core.identity.domain.model.BiometricEnrollmentError -import de.davis.keygo.core.security.domain.model.BiometricPolicy -import de.davis.keygo.core.security.presentation.BiometricCryptoController -import de.davis.keygo.core.util.Result - -interface BiometricEnrollmentAdapter { - - suspend fun BiometricCryptoController.requestEnableBiometric( - policy: BiometricPolicy = BiometricPolicy.Default - ): Result - - suspend fun disableBiometric(): Result -} - -inline fun BiometricEnrollmentAdapter.useEnrollmentAdapter( - block: BiometricEnrollmentAdapter.() -> Result, -): Result = with(this) { block() } \ No newline at end of file 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 deleted file mode 100644 index b0bcbafc2..000000000 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricEnrollmentAdapterImpl.kt +++ /dev/null @@ -1,71 +0,0 @@ -package de.davis.keygo.core.identity.presentation - -import androidx.compose.runtime.Composable -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.repository.AccountRepository -import de.davis.keygo.core.security.domain.KeyStoreManager -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.useArk -import de.davis.keygo.core.security.presentation.BiometricCryptoController -import de.davis.keygo.core.util.Result -import de.davis.keygo.core.util.asResult -import de.davis.keygo.core.util.resultBinding -import org.koin.compose.koinInject -import org.koin.core.annotation.Single -import javax.crypto.Cipher -import javax.crypto.spec.SecretKeySpec - -@Single -internal class BiometricEnrollmentAdapterImpl( - private val accountRepository: AccountRepository, - private val session: Session, - private val keyStoreManager: KeyStoreManager, -) : BiometricEnrollmentAdapter { - - override suspend fun BiometricCryptoController.requestEnableBiometric( - policy: BiometricPolicy - ): Result = resultBinding { - val account = accountRepository.getOrNull() - .asResult(BiometricEnrollmentError.NoActiveAccount).bind() - - if (account.biometricWrappedArk == null) keyStoreManager.deleteKey(KeyId.BiometricVaultKek) - - val cipher = requestCipher(KeyId.BiometricVaultKek, CryptographicMode.Wrap, policy) - .bind { BiometricEnrollmentError.BiometricFailed(it) } - - val wrapped = session.useArk { ark -> - wrapArk(ark, cipher).asResult(BiometricEnrollmentError.WrappingFailed).bind() - }.bind { BiometricEnrollmentError.NoActiveSession } - - accountRepository.set(account.copy(biometricWrappedArk = wrapped)).bind { - BiometricEnrollmentError.PersistenceFailed - } - } - - override suspend fun disableBiometric(): Result = - resultBinding { - val account = accountRepository.getOrNull() - .asResult(BiometricEnrollmentError.NoActiveAccount).bind() - - accountRepository.set(account.copy(biometricWrappedArk = null)) - .bind { BiometricEnrollmentError.PersistenceFailed } - - keyStoreManager.deleteKey(KeyId.BiometricVaultKek) - } - - private fun wrapArk(ark: ByteArray, cipher: Cipher): BiometricWrappedArk? = runCatching { - BiometricWrappedArk( - key = cipher.wrap(SecretKeySpec(ark, 0, ark.size, "AES")), - keyIV = cipher.iv, - ) - }.getOrNull() -} - -@Composable -fun rememberBiometricEnrollmentAdapter(): BiometricEnrollmentAdapter { - return koinInject() -} \ No newline at end of file diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricUnlockAdapter.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricUnlockAdapter.kt deleted file mode 100644 index bc9798cdd..000000000 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricUnlockAdapter.kt +++ /dev/null @@ -1,19 +0,0 @@ -package de.davis.keygo.core.identity.presentation - -import de.davis.keygo.core.identity.domain.model.UnlockError -import de.davis.keygo.core.security.domain.model.BiometricPolicy -import de.davis.keygo.core.security.presentation.BiometricCryptoController -import de.davis.keygo.core.util.Result - -interface BiometricUnlockAdapter { - - suspend fun BiometricCryptoController.requestUnlockVault( - policy: BiometricPolicy = BiometricPolicy.Default - ): Result -} - -inline fun BiometricUnlockAdapter.useAdapter( - block: BiometricUnlockAdapter.() -> Result -): Result = with(this) { - block() -} \ 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 deleted file mode 100644 index 67d9a0a76..000000000 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricUnlockAdapterImpl.kt +++ /dev/null @@ -1,79 +0,0 @@ -package de.davis.keygo.core.identity.presentation - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -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.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.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 - -@Single -internal class BiometricUnlockAdapterImpl( - private val session: Session, - private val accountRepository: AccountRepository, - private val biometricEnrollmentAdapter: BiometricEnrollmentAdapter -) : BiometricUnlockAdapter { - - override suspend fun BiometricCryptoController.requestUnlockVault( - policy: BiometricPolicy - ): Result { - val wrappedKey = accountRepository.getOrNull()?.biometricWrappedArk - ?: return Result.Failure(UnlockError.WrappedKeyNotFound) - - val unwrapResult = requestUnwrap( - keyId = KeyId.BiometricVaultKek, - ciphertextData = CiphertextData( - bytes = wrappedKey.key, - iv = wrappedKey.keyIV - ), - policy = policy - ) - - return when (unwrapResult) { - is Result.Failure -> when (unwrapResult.error) { - BiometricAuthError.KeyInvalidated -> - when (biometricEnrollmentAdapter.disableBiometric()) { - is Result.Success -> Result.Failure(UnlockError.BiometricEnrollmentReset) - - is Result.Failure -> Result.Failure( - UnlockError.BiometricFailed(BiometricAuthError.KeyInvalidated), - ) - } - - else -> Result.Failure(UnlockError.BiometricFailed(unwrapResult.error)) - } - - is Result.Success -> { - val ark = unwrapResult.success.encoded - try { - session.unlockWithArk(ark).mapFailure { UnlockError.UnwrappingFailed } - } finally { - ark.fill(0) - } - } - } - } -} - -@Composable -fun rememberBiometricUnlockAdapter(): BiometricUnlockAdapter { - val session = koinInject() - val accountRepository = koinInject() - val biometricEnrollmentAdapter = rememberBiometricEnrollmentAdapter() - - return remember(session, accountRepository, biometricEnrollmentAdapter) { - BiometricUnlockAdapterImpl( - session = session, - accountRepository = accountRepository, - biometricEnrollmentAdapter = biometricEnrollmentAdapter, - ) - } -} \ No newline at end of file diff --git a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/model/BiometricEnrollmentErrorTest.kt b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/model/BiometricEnrollmentErrorTest.kt new file mode 100644 index 000000000..e24933792 --- /dev/null +++ b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/model/BiometricEnrollmentErrorTest.kt @@ -0,0 +1,49 @@ +package de.davis.keygo.core.identity.domain.model + +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BiometricEnrollmentErrorTest { + + @Test + fun `declining the prompt is a dismissal`() { + assertTrue( + BiometricEnrollmentError.BiometricFailed(BiometricAuthError.Declined).isUserDismissal() + ) + } + + @Test + fun `canceling the prompt is a dismissal`() { + assertTrue( + BiometricEnrollmentError.BiometricFailed(BiometricAuthError.Canceled).isUserDismissal() + ) + } + + @Test + fun `a prompt that failed on its own is not a dismissal`() { + listOf( + BiometricAuthError.LockedOut, + BiometricAuthError.NoPromptHost, + BiometricAuthError.NoCipher, + BiometricAuthError.CryptoFailed, + BiometricAuthError.KeyInvalidated, + BiometricAuthError.BiometricsNotAvailable, + BiometricAuthError.Unknown(errorCode = 3, errString = "timed out"), + ).forEach { + assertFalse(BiometricEnrollmentError.BiometricFailed(it).isUserDismissal(), "$it") + } + } + + @Test + fun `failures outside the prompt are not dismissals`() { + listOf( + BiometricEnrollmentError.NoActiveAccount, + BiometricEnrollmentError.NoActiveSession, + BiometricEnrollmentError.PersistenceFailed, + ).forEach { + assertFalse(it.isUserDismissal(), "$it") + } + } +} 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 c30c0b834..ba06425aa 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 @@ -2,14 +2,20 @@ package de.davis.keygo.core.identity.domain.usecase +import de.davis.keygo.core.biometrics.FakeBiometricCrypto +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError +import de.davis.keygo.core.biometrics.domain.model.BiometricString import de.davis.keygo.core.identity.FakeAccountRepository +import de.davis.keygo.core.identity.domain.mapper.toBiometricWrappedArk 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.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.security.domain.model.CryptographicMode +import de.davis.keygo.core.security.domain.model.KeyId +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 @@ -26,8 +32,10 @@ class ChangePasswordUseCaseTest { private val session = FakeSession() private val accountRepository = FakeAccountRepository() + private val biometricCrypto = FakeBiometricCrypto() private val useCase = ChangePasswordUseCase( + biometricCrypto = biometricCrypto, accountRepository = accountRepository, session = session, ) @@ -42,6 +50,7 @@ class ChangePasswordUseCaseTest { private suspend fun seedAccount( password: String, withBiometric: Boolean = false, + biometricArk: () -> ByteArray = ::liveArk, ): Account { created = checkNotNull(session.createAccount(password).getOrNull()) @@ -54,13 +63,14 @@ class ChangePasswordUseCaseTest { salt = created.salt, ), biometricWrappedArk = if (withBiometric) { - BiometricWrappedArk( - key = ByteArray(48) { it.toByte() }, - keyIV = ByteArray(12) { it.toByte() }, - ) + biometricCrypto + .requestWrap(KeyId.BiometricVaultKek) { seal -> seal(biometricArk()) } + .assertSuccess() + .toBiometricWrappedArk() } else null, ) accountRepository.seed(account) + biometricCrypto.prompts.clear() return account } @@ -156,46 +166,115 @@ class ChangePasswordUseCaseTest { } @Test - fun `biometric path re-wraps the live ARK under the new password`() = runTest { + fun `password path never shows a biometric prompt`() = runTest { seedAccount("old", withBiometric = true) - val result = useCase(Reauthentication.Biometric(liveArk()), "new") + useCase(Reauthentication.Password("old"), "new") - assertTrue(result.isSuccess()) - assertTrue(unlocksWith("new")) + assertTrue(biometricCrypto.prompts.isEmpty()) } @Test - fun `returns IncorrectPassword when the biometric ARK is not the live one`() = runTest { + fun `biometric path re-wraps the live ARK under the new password`() = runTest { seedAccount("old", withBiometric = true) - val result = useCase(Reauthentication.Biometric(ByteArray(32) { it.toByte() }), "new") + val result = useCase(Reauthentication.Biometric, "new") - assertTrue(result.isFailure()) - assertEquals(ChangePasswordError.IncorrectPassword, result.error) + assertTrue(result.isSuccess()) + assertTrue(unlocksWith("new")) + assertFalse(unlocksWith("old")) } @Test - fun `biometric path on a locked session fails as ActiveAccountNotFound, not IncorrectPassword`() = + fun `biometric path unwraps with the biometric key and offers the password as the way out`() = runTest { seedAccount("old", withBiometric = true) - val recovered = liveArk() - session.endSession() - val result = useCase(Reauthentication.Biometric(recovered), "new") + useCase(Reauthentication.Biometric, "new") - assertTrue(result.isFailure()) - assertEquals(ChangePasswordError.ActiveAccountNotFound, result.error) + val prompt = biometricCrypto.prompts.single() + assertEquals(KeyId.BiometricVaultKek, prompt.keyId) + assertEquals(CryptographicMode.Unwrap, prompt.mode) + assertEquals(BiometricString.NegativeButton.Password, prompt.policy.negativeButton) } @Test - fun `returns BiometricNotEnrolled when biometric proof given but none enrolled`() = runTest { + fun `returns BiometricAuthFailed when the biometric ARK is not the live one`() = runTest { + seedAccount("old", withBiometric = true) { ByteArray(32) { it.toByte() } } + + val result = useCase(Reauthentication.Biometric, "new") + + assertTrue(result.isFailure()) + assertEquals(ChangePasswordError.BiometricAuthFailed, result.error) + assertTrue(unlocksWith("old")) + } + + @Test + fun `biometric path on a locked session fails as ActiveAccountNotFound`() = runTest { + seedAccount("old", withBiometric = true) + session.endSession() + + val result = useCase(Reauthentication.Biometric, "new") + + assertTrue(result.isFailure()) + assertEquals(ChangePasswordError.ActiveAccountNotFound, result.error) + } + + @Test + fun `returns BiometricNotEnrolled without prompting when none is enrolled`() = runTest { seedAccount("old", withBiometric = false) - val result = useCase(Reauthentication.Biometric(liveArk()), "new") + val result = useCase(Reauthentication.Biometric, "new") assertTrue(result.isFailure()) assertEquals(ChangePasswordError.BiometricNotEnrolled, result.error) + assertTrue(biometricCrypto.prompts.isEmpty()) + } + + @Test + fun `a declined prompt is reported apart so the screen can ask for the password`() = runTest { + seedAccount("old", withBiometric = true) + biometricCrypto.promptFailure = BiometricAuthError.Declined + + val result = useCase(Reauthentication.Biometric, "new") + + assertTrue(result.isFailure()) + assertEquals(ChangePasswordError.BiometricDeclined, result.error) + assertTrue(unlocksWith("old")) + } + + @Test + fun `a canceled prompt is reported apart so the screen can stay quiet`() = runTest { + seedAccount("old", withBiometric = true) + biometricCrypto.promptFailure = BiometricAuthError.Canceled + + val result = useCase(Reauthentication.Biometric, "new") + + assertTrue(result.isFailure()) + assertEquals(ChangePasswordError.BiometricCanceled, result.error) + } + + @Test + fun `a prompt that fails on its own is BiometricAuthFailed`() = runTest { + seedAccount("old", withBiometric = true) + biometricCrypto.promptFailure = BiometricAuthError.LockedOut + + val result = useCase(Reauthentication.Biometric, "new") + + assertTrue(result.isFailure()) + assertEquals(ChangePasswordError.BiometricAuthFailed, result.error) + assertTrue(unlocksWith("old")) + } + + @Test + fun `an invalidated biometric key is BiometricAuthFailed`() = runTest { + seedAccount("old", withBiometric = true) + biometricCrypto.keyStoreManager.deleteKey(KeyId.BiometricVaultKek) + + val result = useCase(Reauthentication.Biometric, "new") + + assertTrue(result.isFailure()) + assertEquals(ChangePasswordError.BiometricAuthFailed, result.error) } @Test @@ -237,33 +316,40 @@ class ChangePasswordUseCaseTest { } @Test - fun `scrubs the supplied biometric ARK after a successful change`() = runTest { + fun `scrubs the unwrapped biometric ARK after a successful change`() = runTest { seedAccount("old", withBiometric = true) - val recovered = liveArk() - useCase(Reauthentication.Biometric(recovered), "new") + useCase(Reauthentication.Biometric, "new") - assertContentEquals(ByteArray(recovered.size), recovered) + assertContentEquals(ByteArray(32), biometricCrypto.unwrapped.single()) } @Test - fun `scrubs the supplied biometric ARK when persistence fails`() = runTest { + fun `scrubs the unwrapped biometric ARK when persistence fails`() = runTest { seedAccount("old", withBiometric = true) - val recovered = liveArk() accountRepository.setFails = true - useCase(Reauthentication.Biometric(recovered), "new") + useCase(Reauthentication.Biometric, "new") - assertContentEquals(ByteArray(recovered.size), recovered) + assertContentEquals(ByteArray(32), biometricCrypto.unwrapped.single()) } @Test - fun `scrubs the supplied biometric ARK when biometric reauth is not enrolled`() = runTest { - seedAccount("old", withBiometric = false) - val recovered = liveArk() + fun `scrubs the unwrapped biometric ARK when it is not the live one`() = runTest { + seedAccount("old", withBiometric = true) { ByteArray(32) { it.toByte() } } + + useCase(Reauthentication.Biometric, "new") + + assertContentEquals(ByteArray(32), biometricCrypto.unwrapped.single()) + } + + @Test + fun `scrubs the unwrapped biometric ARK when the session is locked`() = runTest { + seedAccount("old", withBiometric = true) + session.endSession() - useCase(Reauthentication.Biometric(recovered), "new") + useCase(Reauthentication.Biometric, "new") - assertContentEquals(ByteArray(recovered.size), recovered) + assertContentEquals(ByteArray(32), biometricCrypto.unwrapped.single()) } } 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 0dd505ce2..4d0ae8151 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 @@ -1,5 +1,9 @@ package de.davis.keygo.core.identity.domain.usecase +import de.davis.keygo.core.biometrics.FakeBiometricCrypto +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError +import de.davis.keygo.core.biometrics.domain.model.BiometricPolicy +import de.davis.keygo.core.biometrics.domain.model.BiometricString import de.davis.keygo.core.identity.FakeAccountRepository import de.davis.keygo.core.identity.domain.model.CreateAccessError import de.davis.keygo.core.item.FakeVaultContextRepository @@ -7,19 +11,24 @@ import de.davis.keygo.core.item.FakeVaultRepository 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.security.crypto.FakeKeyStoreManager +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.assertSuccess import de.davis.keygo.core.util.isFailure import de.davis.keygo.core.util.isSuccess import de.davisalessandro.keygo.rust.WrappedKeyBlob import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest -import javax.crypto.Cipher -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.assertNull import kotlin.test.assertTrue class CreateAccessUseCaseTest { @@ -28,13 +37,9 @@ class CreateAccessUseCaseTest { private val accountRepository = FakeAccountRepository() private val vaultRepository = FakeVaultRepository() private val vaultContextRepository = FakeVaultContextRepository() + private val biometricCrypto = FakeBiometricCrypto() - private val useCase = CreateAccessUseCase( - accountRepository = accountRepository, - vaultRepository = vaultRepository, - vaultContextRepository = vaultContextRepository, - session = session, - ) + private val useCase = useCaseOver(session) @Test fun `returns KeyDerivationFailed when derivation fails`() = runTest { @@ -82,8 +87,8 @@ class CreateAccessUseCaseTest { } @Test - fun `returns Success and leaves the session unlocked without biometric cipher`() = runTest { - val result = useCase("password", biometricCipher = null) + fun `returns Success and leaves the session unlocked without biometrics`() = runTest { + val result = useCase("password", withBiometrics = false) assertTrue(result.isSuccess()) assertTrue(session.isActive.value) @@ -111,13 +116,8 @@ class CreateAccessUseCaseTest { } @Test - fun `persists biometric-wrapped ARK when cipher is provided`() = runTest { - val biometricKek = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() - val biometricCipher = Cipher.getInstance("AES/GCM/NoPadding").apply { - init(Cipher.WRAP_MODE, biometricKek) - } - - val result = useCase("password", biometricCipher = biometricCipher) + fun `persists biometric-wrapped ARK when biometrics are requested`() = runTest { + val result = useCase("password", withBiometrics = true) assertTrue(result.isSuccess()) val stored = accountRepository.getOrNull()!! @@ -127,10 +127,54 @@ class CreateAccessUseCaseTest { } @Test - fun `does not persist biometric-wrapped ARK when no cipher provided`() = runTest { - useCase("password", biometricCipher = null) + fun `the biometric-wrapped ARK opens back to the ARK the session holds`() = runTest { + useCase("password", withBiometrics = true) + + val bio = accountRepository.getOrNull()!!.biometricWrappedArk!! + val recovered = biometricCrypto.requestUnwrap( + keyId = KeyId.BiometricVaultKek, + cryptographicData = CryptographicData(data = bio.key, iv = bio.keyIV), + ).assertSuccess().encoded + + assertEquals(true, session.verifyArk(recovered).assertSuccess()) + } + + @Test + fun `wraps under the biometric key with the policy it was given`() = runTest { + val policy = BiometricPolicy(negativeButton = BiometricString.NegativeButton.Password) + + useCase("password", withBiometrics = true, policy = policy) + + val prompt = biometricCrypto.prompts.single() + assertEquals(KeyId.BiometricVaultKek, prompt.keyId) + assertEquals(CryptographicMode.Wrap, prompt.mode) + assertEquals(policy, prompt.policy) + } + + @Test + fun `does not persist biometric-wrapped ARK or prompt when biometrics are not requested`() = + runTest { + useCase("password", withBiometrics = false) + + assertNull(accountRepository.getOrNull()?.biometricWrappedArk) + assertTrue(biometricCrypto.prompts.isEmpty()) + } + + /** + * Biometrics are an optional extra on top of the password the user just set. Failing the whole + * creation over them threw away a finished key derivation and left the user on the step with + * nothing to show for it. + */ + @Test + fun `a failed biometric prompt still creates a password-only account`() = runTest { + biometricCrypto.promptFailure = BiometricAuthError.Declined - assertEquals(null, accountRepository.getOrNull()?.biometricWrappedArk) + val result = useCase("password", withBiometrics = true) + + assertTrue(result.isSuccess()) + assertNull(assertNotNull(accountRepository.getOrNull()).biometricWrappedArk) + assertEquals(1, vaultRepository.observeVaults().first().size) + assertTrue(session.isActive.value) } @Test @@ -156,30 +200,23 @@ class CreateAccessUseCaseTest { @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) + useCaseOver(recording)("password", withBiometrics = true) assertContentEquals(ByteArray(32), recording.onlyExported()) } @Test - fun `wipes the exported ARK even when wrapping fails`() = runTest { + fun `a biometric step the keystore refuses never exports the ARK`() = 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 refusing = FakeBiometricCrypto( + keyStoreManager = FakeKeyStoreManager(failure = KeyStoreManagerError.Unknown), + ) - val result = useCaseOver(recording)("password", biometricCipher = wrongMode) + val result = useCaseOver(recording, refusing)("password", withBiometrics = true) - assertTrue(result.isFailure()) - assertEquals(CreateAccessError.WrappingFailed, result.error) - assertContentEquals(ByteArray(32), recording.onlyExported()) + assertTrue(result.isSuccess()) + assertTrue(recording.exported.isEmpty()) } @Test @@ -203,11 +240,9 @@ class CreateAccessUseCaseTest { @Test fun `ends the session when the last write throws`() = runTest { - val throwing = CreateAccessUseCase( - accountRepository = accountRepository, - vaultRepository = vaultRepository, - vaultContextRepository = ThrowingVaultContextRepository(), + val throwing = useCaseOver( session = session, + vaultContextRepository = ThrowingVaultContextRepository(), ) assertFailsWith { throwing("password") } @@ -228,10 +263,20 @@ class CreateAccessUseCaseTest { assertTrue(!salt1.contentEquals(salt2)) } - private fun useCaseOver(session: FakeSession) = CreateAccessUseCase( + private fun useCaseOver( + session: FakeSession, + biometricCrypto: FakeBiometricCrypto = this.biometricCrypto, + vaultContextRepository: VaultContextRepository = this.vaultContextRepository, + ) = CreateAccessUseCase( accountRepository = accountRepository, vaultRepository = vaultRepository, vaultContextRepository = vaultContextRepository, + enableBiometrics = EnableBiometricsUseCase( + accountRepository = accountRepository, + session = session, + keyStoreManager = biometricCrypto.keyStoreManager, + biometricCrypto = biometricCrypto, + ), session = session, ) } diff --git a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/DisableBiometricsUseCaseTest.kt b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/DisableBiometricsUseCaseTest.kt new file mode 100644 index 000000000..0b452ebbb --- /dev/null +++ b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/DisableBiometricsUseCaseTest.kt @@ -0,0 +1,121 @@ +package de.davis.keygo.core.identity.domain.usecase + +import de.davis.keygo.core.biometrics.FakeBiometricCrypto +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError +import de.davis.keygo.core.identity.FakeAccountRepository +import de.davis.keygo.core.identity.domain.mapper.toBiometricWrappedArk +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.PasswordWrappedArk +import de.davis.keygo.core.security.crypto.FakeKeyStoreManager +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.util.assertFailure +import de.davis.keygo.core.util.assertSuccess +import kotlinx.coroutines.test.runTest +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DisableBiometricsUseCaseTest { + + private val accountRepository = FakeAccountRepository() + private val keyStoreManager = FakeKeyStoreManager() + private val biometricCrypto = FakeBiometricCrypto(keyStoreManager) + + private val disableBiometrics = DisableBiometricsUseCase( + accountRepository = accountRepository, + keyStoreManager = keyStoreManager, + ) + + private suspend fun seedEnrolledAccount() { + accountRepository.seed( + Account( + id = UUID.randomUUID(), + displayName = "Test", + passwordWrappedArk = PasswordWrappedArk( + key = ByteArray(48) { 1 }, + keyIV = ByteArray(12) { 2 }, + salt = ByteArray(16) { 3 }, + ), + biometricWrappedArk = biometricCrypto + .requestWrap(KeyId.BiometricVaultKek) { seal -> seal(ByteArray(32) { 4 }) } + .assertSuccess() + .toBiometricWrappedArk(), + ), + ) + biometricCrypto.prompts.clear() + } + + @Test + fun `disabling drops the wrapped ARK and the keystore alias behind it`() = runTest { + seedEnrolledAccount() + + disableBiometrics().assertSuccess() + + assertNull(accountRepository.getOrNull()?.biometricWrappedArk) + assertFalse(KeyId.BiometricVaultKek in keyStoreManager.keys) + } + + @Test + fun `a wrapped ARK captured before disabling no longer opens`() = runTest { + seedEnrolledAccount() + val captured = accountRepository.getOrNull()!!.biometricWrappedArk!! + + disableBiometrics() + + val error = biometricCrypto.requestUnwrap( + keyId = KeyId.BiometricVaultKek, + cryptographicData = CryptographicData(data = captured.key, iv = captured.keyIV), + ).assertFailure() + assertEquals(BiometricAuthError.KeyInvalidated, error) + } + + @Test + fun `disabling never shows a prompt`() = runTest { + seedEnrolledAccount() + + disableBiometrics() + + assertTrue(biometricCrypto.prompts.isEmpty()) + } + + @Test + fun `disabling leaves the backup escrow aliases alone`() = runTest { + seedEnrolledAccount() + keyStoreManager.getOrCreateCipherFor(KeyId.BackupArkKey, CryptographicMode.Encrypt) + keyStoreManager.getOrCreateCipherFor(KeyId.BackupPassphraseKey, CryptographicMode.Encrypt) + + disableBiometrics() + + assertTrue(KeyId.BackupArkKey in keyStoreManager.keys) + assertTrue(KeyId.BackupPassphraseKey in keyStoreManager.keys) + } + + @Test + fun `a failed clear keeps the key that the stored enrollment still needs`() = runTest { + seedEnrolledAccount() + accountRepository.setFails = true + + val error = disableBiometrics().assertFailure() + + assertEquals(BiometricEnrollmentError.PersistenceFailed, error) + assertNotNull(accountRepository.getOrNull()?.biometricWrappedArk) + assertTrue(KeyId.BiometricVaultKek in keyStoreManager.keys) + } + + @Test + fun `disabling without an account touches nothing`() = runTest { + keyStoreManager.getOrCreateCipherFor(KeyId.BiometricVaultKek, CryptographicMode.Wrap) + + val error = disableBiometrics().assertFailure() + + assertEquals(BiometricEnrollmentError.NoActiveAccount, error) + assertTrue(KeyId.BiometricVaultKek in keyStoreManager.keys) + } +} diff --git a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/EnableBiometricsUseCaseTest.kt b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/EnableBiometricsUseCaseTest.kt new file mode 100644 index 000000000..1b7e440da --- /dev/null +++ b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/EnableBiometricsUseCaseTest.kt @@ -0,0 +1,284 @@ +@file:OptIn(ExportArk::class, ExperimentalCoroutinesApi::class) + +package de.davis.keygo.core.identity.domain.usecase + +import de.davis.keygo.core.biometrics.FakeBiometricCrypto +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError +import de.davis.keygo.core.biometrics.domain.model.BiometricPolicy +import de.davis.keygo.core.biometrics.domain.model.BiometricString +import de.davis.keygo.core.identity.FakeAccountRepository +import de.davis.keygo.core.identity.domain.mapper.toBiometricWrappedArk +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.FakeKeyStoreManager +import de.davis.keygo.core.security.domain.ExportArk +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.util.assertFailure +import de.davis.keygo.core.util.assertSuccess +import de.davis.keygo.core.util.getOrNull +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent +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.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertSame +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 EnableBiometricsUseCaseTest { + + private val session = FakeSession(startUnlocked = true) + private val accountRepository = FakeAccountRepository() + private val keyStoreManager = FakeKeyStoreManager() + private val biometricCrypto = FakeBiometricCrypto(keyStoreManager) + + private val enableBiometrics = EnableBiometricsUseCase( + accountRepository = accountRepository, + session = session, + keyStoreManager = keyStoreManager, + biometricCrypto = biometricCrypto, + ) + + private fun seedAccount(biometricWrappedArk: BiometricWrappedArk? = null) = + accountRepository.seed( + Account( + id = UUID.randomUUID(), + displayName = "Test", + passwordWrappedArk = PasswordWrappedArk( + key = ByteArray(48) { 1 }, + keyIV = ByteArray(12) { 2 }, + salt = ByteArray(16) { 3 }, + ), + biometricWrappedArk = biometricWrappedArk, + ), + ) + + private suspend fun seedEnrolledAccount() { + val ark = checkNotNull(session.exportArk().getOrNull()) + seedAccount( + biometricWrappedArk = biometricCrypto + .requestWrap(KeyId.BiometricVaultKek) { seal -> seal(ark) } + .assertSuccess() + .toBiometricWrappedArk(), + ) + session.exported.clear() + biometricCrypto.prompts.clear() + } + + private suspend fun opensToLiveArk(wrapped: BiometricWrappedArk): Boolean { + val recovered = biometricCrypto.requestUnwrap( + keyId = KeyId.BiometricVaultKek, + cryptographicData = CryptographicData(data = wrapped.key, iv = wrapped.keyIV), + ).getOrNull() ?: return false + return session.verifyArk(recovered.encoded).assertSuccess() + } + + @Test + fun `enrolling persists a biometric-wrapped ARK that opens back to the live ARK`() = runTest { + seedAccount() + + enableBiometrics().assertSuccess() + + val wrapped = assertNotNull(accountRepository.getOrNull()?.biometricWrappedArk) + assertTrue(opensToLiveArk(wrapped)) + } + + @Test + fun `enrolling wraps under the biometric key with the policy it was given`() = runTest { + seedAccount() + val policy = BiometricPolicy(title = BiometricString.Title.Authenticate) + + enableBiometrics(policy) + + val prompt = biometricCrypto.prompts.single() + assertEquals(KeyId.BiometricVaultKek, prompt.keyId) + assertEquals(CryptographicMode.Wrap, prompt.mode) + assertEquals(policy, prompt.policy) + } + + @Test + fun `wipes the exported ARK once it has been wrapped`() = runTest { + seedAccount() + + enableBiometrics() + + assertContentEquals(ByteArray(32), session.onlyExported()) + } + + @Test + fun `a failed prompt never exports the ARK`() = runTest { + seedAccount() + biometricCrypto.promptFailure = BiometricAuthError.LockedOut + + enableBiometrics().assertFailure() + + assertTrue(session.exported.isEmpty()) + } + + /** + * A prompt can stay open for as long as the user leaves it. Exporting the ARK before showing it + * kept a plaintext copy on the heap for all of that time instead of the moment the wrap takes. + */ + @Test + fun `the ARK is not exported while the prompt is open`() = runTest { + seedAccount() + val prompt = CompletableDeferred() + biometricCrypto.pendingPrompt = prompt + + val enrollment = async { enableBiometrics() } + runCurrent() + assertEquals(1, biometricCrypto.prompts.size) + assertTrue(session.exported.isEmpty()) + + prompt.complete(Unit) + enrollment.await().assertSuccess() + + assertContentEquals(ByteArray(32), session.onlyExported()) + } + + @Test + fun `a session that locks while the prompt is open reports NoActiveSession`() = runTest { + seedAccount() + val prompt = CompletableDeferred() + biometricCrypto.pendingPrompt = prompt + + val enrollment = async { enableBiometrics() } + runCurrent() + session.endSession() + prompt.complete(Unit) + + assertEquals(BiometricEnrollmentError.NoActiveSession, enrollment.await().assertFailure()) + assertNull(accountRepository.getOrNull()?.biometricWrappedArk) + } + + @Test + fun `a declined prompt is reported as a dismissal and persists nothing`() = runTest { + seedAccount() + biometricCrypto.promptFailure = BiometricAuthError.Declined + + val error = enableBiometrics().assertFailure() + + assertEquals(BiometricEnrollmentError.BiometricFailed(BiometricAuthError.Declined), error) + assertNull(accountRepository.getOrNull()?.biometricWrappedArk) + } + + @Test + fun `a locked session reports NoActiveSession without prompting and never persists`() = + runTest { + seedAccount() + session.endSession() + + val error = enableBiometrics().assertFailure() + + assertEquals(BiometricEnrollmentError.NoActiveSession, error) + assertTrue(biometricCrypto.prompts.isEmpty()) + assertNull(accountRepository.getOrNull()?.biometricWrappedArk) + } + + @Test + fun `no account reports NoActiveAccount without touching the session or the prompt`() = + runTest { + val error = enableBiometrics().assertFailure() + + assertEquals(BiometricEnrollmentError.NoActiveAccount, error) + assertTrue(session.exported.isEmpty()) + assertTrue(biometricCrypto.prompts.isEmpty()) + } + + @Test + fun `enrolling without an account leaves the keystore alone`() = runTest { + keyStoreManager.getOrCreateCipherFor(KeyId.BiometricVaultKek, CryptographicMode.Wrap) + + enableBiometrics().assertFailure() + + assertTrue(KeyId.BiometricVaultKek in keyStoreManager.keys) + } + + @Test + fun `returns PersistenceFailed when the account cannot be saved`() = runTest { + seedAccount() + accountRepository.setFails = true + + val error = enableBiometrics().assertFailure() + + assertEquals(BiometricEnrollmentError.PersistenceFailed, error) + assertNull(accountRepository.getOrNull()?.biometricWrappedArk) + } + + @Test + fun `a failed enrollment leaves the stored enrollment intact`() = runTest { + seedEnrolledAccount() + val inUse = keyStoreManager.keys.getValue(KeyId.BiometricVaultKek) + val before = accountRepository.getOrNull()!!.biometricWrappedArk!! + // 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. + biometricCrypto.promptFailure = BiometricAuthError.Declined + + enableBiometrics().assertFailure() + + assertSame(inUse, keyStoreManager.keys[KeyId.BiometricVaultKek]) + val after = assertNotNull(accountRepository.getOrNull()?.biometricWrappedArk) + assertContentEquals(before.key, after.key) + biometricCrypto.promptFailure = null + assertTrue(opensToLiveArk(after)) + } + + @Test + fun `enrolling again while enrolled rewraps under the key already in use`() = runTest { + seedEnrolledAccount() + val inUse = keyStoreManager.keys.getValue(KeyId.BiometricVaultKek) + + enableBiometrics().assertSuccess() + + assertSame(inUse, keyStoreManager.keys[KeyId.BiometricVaultKek]) + assertTrue(opensToLiveArk(accountRepository.getOrNull()!!.biometricWrappedArk!!)) + } + + /** + * The interrupted-disable case: the wrapped ARK is gone but its key survived. Nothing can open + * that key any more, so an enrollment starting here must not adopt it - on the devices this + * exists for, adopting it is how the unusable key comes back. + */ + @Test + fun `enrolling from an unenrolled account drops the key left behind`() = runTest { + 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. + biometricCrypto.promptFailure = BiometricAuthError.Declined + + enableBiometrics() + + assertFalse(KeyId.BiometricVaultKek in keyStoreManager.keys) + } + + @Test + fun `enrolling from an unenrolled account wraps under a fresh key`() = runTest { + seedAccount() + keyStoreManager.getOrCreateCipherFor(KeyId.BiometricVaultKek, CryptographicMode.Wrap) + val leftBehind = keyStoreManager.keys.getValue(KeyId.BiometricVaultKek) + + enableBiometrics().assertSuccess() + + assertNotSame(leftBehind, keyStoreManager.keys[KeyId.BiometricVaultKek]) + assertTrue(opensToLiveArk(accountRepository.getOrNull()!!.biometricWrappedArk!!)) + } +} diff --git a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithBiometricsUseCaseTest.kt b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithBiometricsUseCaseTest.kt new file mode 100644 index 000000000..052b23620 --- /dev/null +++ b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithBiometricsUseCaseTest.kt @@ -0,0 +1,204 @@ +package de.davis.keygo.core.identity.domain.usecase + +import de.davis.keygo.core.biometrics.FakeBiometricCrypto +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError +import de.davis.keygo.core.biometrics.domain.model.BiometricPolicy +import de.davis.keygo.core.biometrics.domain.model.BiometricString +import de.davis.keygo.core.identity.FakeAccountRepository +import de.davis.keygo.core.identity.domain.mapper.toBiometricWrappedArk +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.FakeSession +import de.davis.keygo.core.security.crypto.FakeKeyStoreManager +import de.davis.keygo.core.security.domain.model.CryptographicMode +import de.davis.keygo.core.security.domain.model.KeyId +import de.davis.keygo.core.util.assertFailure +import de.davis.keygo.core.util.assertSuccess +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.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class UnlockWithBiometricsUseCaseTest { + + private val session = FakeSession() + private val accountRepository = FakeAccountRepository() + private val keyStoreManager = FakeKeyStoreManager() + private val biometricCrypto = FakeBiometricCrypto(keyStoreManager) + + private val ark = ByteArray(32) { (it + 1).toByte() } + + private fun unlockOver(session: FakeSession) = UnlockWithBiometricsUseCase( + session = session, + accountRepository = accountRepository, + biometricCrypto = biometricCrypto, + disableBiometrics = DisableBiometricsUseCase(accountRepository, keyStoreManager), + ) + + private val unlockWithBiometrics = unlockOver(session) + + private suspend fun seedAccount(biometricWrappedArk: Boolean) { + accountRepository.seed( + Account( + id = UUID.randomUUID(), + displayName = "Test", + passwordWrappedArk = PasswordWrappedArk( + key = byteArrayOf(1), + keyIV = byteArrayOf(2), + salt = byteArrayOf(3), + ), + biometricWrappedArk = null, + ) + ) + if (biometricWrappedArk) seedEnrollment(ark) + } + + private suspend fun seedEnrollment(wrappedKey: ByteArray) { + val wrapped = biometricCrypto + .requestWrap(KeyId.BiometricVaultKek) { seal -> seal(wrappedKey) } + .assertSuccess() + .toBiometricWrappedArk() + accountRepository.seed(accountRepository.getOrNull()!!.copy(biometricWrappedArk = wrapped)) + biometricCrypto.prompts.clear() + } + + @Test + fun `returns ActiveAccountNotFound without prompting when no account exists`() = runTest { + val error = unlockWithBiometrics().assertFailure() + + assertEquals(UnlockError.ActiveAccountNotFound, error) + assertTrue(biometricCrypto.prompts.isEmpty()) + } + + @Test + fun `returns WrappedKeyNotFound without prompting when the account is not enrolled`() = + runTest { + seedAccount(biometricWrappedArk = false) + + val error = unlockWithBiometrics().assertFailure() + + assertEquals(UnlockError.WrappedKeyNotFound, error) + assertTrue(biometricCrypto.prompts.isEmpty()) + } + + @Test + fun `on success starts the session over the recovered ARK`() = runTest { + seedAccount(biometricWrappedArk = true) + + unlockWithBiometrics().assertSuccess() + + assertTrue(session.isActive.value) + assertTrue(session.verifyArk(ark).assertSuccess()) + } + + @Test + fun `unwraps with the biometric key under the policy it was given`() = runTest { + seedAccount(biometricWrappedArk = true) + val policy = BiometricPolicy( + title = BiometricString.Title.UnlockItem("GitHub"), + negativeButton = BiometricString.NegativeButton.Password, + ) + + unlockWithBiometrics(policy) + + val prompt = biometricCrypto.prompts.single() + assertEquals(KeyId.BiometricVaultKek, prompt.keyId) + assertEquals(CryptographicMode.Unwrap, prompt.mode) + assertEquals(policy, prompt.policy) + } + + @Test + fun `returns BiometricFailed with the prompt's error and stays locked`() = runTest { + seedAccount(biometricWrappedArk = true) + biometricCrypto.promptFailure = BiometricAuthError.Declined + + val error = unlockWithBiometrics().assertFailure() + + assertEquals(UnlockError.BiometricFailed(BiometricAuthError.Declined), error) + assertFalse(session.isActive.value) + } + + @Test + fun `a retryable biometric failure leaves the stored enrollment in place`() = runTest { + seedAccount(biometricWrappedArk = true) + biometricCrypto.promptFailure = BiometricAuthError.CryptoFailed + + val error = unlockWithBiometrics().assertFailure() + + assertEquals(UnlockError.BiometricFailed(BiometricAuthError.CryptoFailed), error) + assertNotNull(accountRepository.getOrNull()?.biometricWrappedArk) + assertTrue(KeyId.BiometricVaultKek in keyStoreManager.keys) + } + + @Test + fun `an invalidated key drops the stored enrollment and reports it as reset`() = runTest { + seedAccount(biometricWrappedArk = true) + keyStoreManager.deleteKey(KeyId.BiometricVaultKek) + + val error = unlockWithBiometrics().assertFailure() + + assertEquals(UnlockError.BiometricEnrollmentReset, error) + assertNull(accountRepository.getOrNull()?.biometricWrappedArk) + assertFalse(KeyId.BiometricVaultKek in keyStoreManager.keys) + assertFalse(session.isActive.value) + } + + @Test + fun `a teardown that does not persist is not reported as a reset`() = runTest { + seedAccount(biometricWrappedArk = true) + biometricCrypto.promptFailure = BiometricAuthError.KeyInvalidated + accountRepository.setFails = true + + val error = unlockWithBiometrics().assertFailure() + + assertEquals(UnlockError.BiometricFailed(BiometricAuthError.KeyInvalidated), error) + assertNotNull(accountRepository.getOrNull()?.biometricWrappedArk) + assertTrue(KeyId.BiometricVaultKek in keyStoreManager.keys) + } + + /** + * 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 { + seedAccount(biometricWrappedArk = true) + + unlockWithBiometrics().assertSuccess() + + assertContentEquals(ByteArray(32), session.handedOver) + } + + @Test + fun `wipes the recovered ARK even when the session rejects it`() = runTest { + val rejecting = FakeSession().apply { failUnlock = true } + seedAccount(biometricWrappedArk = true) + + val result = unlockOver(rejecting)() + + assertEquals(UnlockError.UnwrappingFailed, result.assertFailure()) + assertContentEquals(ByteArray(32), rejecting.handedOver) + } + + @Test + fun `returns UnwrappingFailed and stays locked when the recovered key is not an ARK`() = + runTest { + seedAccount(biometricWrappedArk = false) + seedEnrollment(ByteArray(16) { 1 }) + + val error = unlockWithBiometrics().assertFailure() + + assertEquals(UnlockError.UnwrappingFailed, error) + assertFalse(session.isActive.value) + } +} diff --git a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockableByBiometricsUseCaseTest.kt b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockableByBiometricsUseCaseTest.kt new file mode 100644 index 000000000..a00947b3b --- /dev/null +++ b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockableByBiometricsUseCaseTest.kt @@ -0,0 +1,95 @@ +package de.davis.keygo.core.identity.domain.usecase + +import de.davis.keygo.core.biometrics.FakeBiometricAvailabilityRepository +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.UnlockableByBiometricsResult +import de.davis.keygo.core.identity.domain.model.hasHardware +import kotlinx.coroutines.test.runTest +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class UnlockableByBiometricsUseCaseTest { + + private val accountRepository = FakeAccountRepository() + private val availability = FakeBiometricAvailabilityRepository() + + private val unlockableByBiometrics = UnlockableByBiometricsUseCase( + accountRepository = accountRepository, + biometricAvailabilityRepository = availability, + ) + + private fun seedAccount(enrolled: Boolean) = accountRepository.seed( + Account( + id = UUID.randomUUID(), + displayName = "Test", + passwordWrappedArk = PasswordWrappedArk( + key = byteArrayOf(1), + keyIV = byteArrayOf(2), + salt = byteArrayOf(3), + ), + biometricWrappedArk = if (enrolled) BiometricWrappedArk( + key = byteArrayOf(4), + keyIV = byteArrayOf(5), + ) else null, + ) + ) + + @Test + fun `an enrolled account on usable hardware is Available`() = runTest { + availability.isAvailable = true + seedAccount(enrolled = true) + + assertEquals(UnlockableByBiometricsResult.Available, unlockableByBiometrics()) + } + + @Test + fun `unusable hardware wins over an enrolled account`() = runTest { + availability.isAvailable = false + seedAccount(enrolled = true) + + assertEquals(UnlockableByBiometricsResult.NoHardware, unlockableByBiometrics()) + } + + @Test + fun `usable hardware without an account is NoAccount`() = runTest { + availability.isAvailable = true + + assertEquals( + UnlockableByBiometricsResult.NoAccount(hardwareAvailable = true), + unlockableByBiometrics(), + ) + } + + @Test + fun `a missing account is reported even when the hardware is unusable`() = runTest { + availability.isAvailable = false + + assertEquals( + UnlockableByBiometricsResult.NoAccount(hardwareAvailable = false), + unlockableByBiometrics(), + ) + } + + @Test + fun `an account that never enrolled is NotEnrolled`() = runTest { + availability.isAvailable = true + seedAccount(enrolled = false) + + assertEquals(UnlockableByBiometricsResult.NotEnrolled, unlockableByBiometrics()) + } + + @Test + fun `NoHardware and a NoAccount without hardware lack hardware`() { + assertFalse(UnlockableByBiometricsResult.NoHardware.hasHardware()) + assertFalse(UnlockableByBiometricsResult.NoAccount(hardwareAvailable = false).hasHardware()) + assertTrue(UnlockableByBiometricsResult.Available.hasHardware()) + assertTrue(UnlockableByBiometricsResult.NoAccount(hardwareAvailable = true).hasHardware()) + assertTrue(UnlockableByBiometricsResult.NotEnrolled.hasHardware()) + } +} 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 deleted file mode 100644 index 0fc42cb81..000000000 --- a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/presentation/BiometricEnrollmentAdapterImplTest.kt +++ /dev/null @@ -1,258 +0,0 @@ -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.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.domain.model.BiometricAuthError -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(startUnlocked = true) - private val accountRepository = FakeAccountRepository() - private val keyStoreManager = FakeKeyStoreManager() - private val controller = FakeBiometricCryptoController() - - private val adapter = BiometricEnrollmentAdapterImpl( - accountRepository = accountRepository, - session = session, - keyStoreManager = keyStoreManager, - ) - - private fun seedAccount(biometricWrappedArk: BiometricWrappedArk? = null) = - accountRepository.seed( - Account( - id = UUID.randomUUID(), - displayName = "Test", - passwordWrappedArk = PasswordWrappedArk( - key = ByteArray(48) { 1 }, - keyIV = ByteArray(12) { 2 }, - salt = ByteArray(16) { 3 }, - ), - biometricWrappedArk = biometricWrappedArk, - ), - ) - - private fun seedEnrolledAccount() { - keyStoreManager.getOrCreateCipherFor(KeyId.BiometricVaultKek, CryptographicMode.Wrap) - 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 - fun `disabling drops the wrapped ARK and the keystore alias behind it`() = runTest { - seedEnrolledAccount() - - val result = adapter.disableBiometric() - - assertTrue(result.isSuccess()) - assertNull(accountRepository.getOrNull()?.biometricWrappedArk) - assertFalse(KeyId.BiometricVaultKek in keyStoreManager.keys) - } - - @Test - fun `disabling leaves the backup escrow aliases alone`() = runTest { - seedEnrolledAccount() - keyStoreManager.getOrCreateCipherFor(KeyId.BackupArkKey, CryptographicMode.Encrypt) - keyStoreManager.getOrCreateCipherFor(KeyId.BackupPassphraseKey, CryptographicMode.Encrypt) - - adapter.disableBiometric() - - assertTrue(KeyId.BackupArkKey in keyStoreManager.keys) - assertTrue(KeyId.BackupPassphraseKey in keyStoreManager.keys) - } - - @Test - fun `a failed clear keeps the key that the stored enrollment still needs`() = runTest { - seedEnrolledAccount() - accountRepository.setFails = true - - val result = adapter.disableBiometric() - - assertTrue(result.isFailure()) - assertEquals(BiometricEnrollmentError.PersistenceFailed, result.error) - assertNotNull(accountRepository.getOrNull()?.biometricWrappedArk) - assertTrue(KeyId.BiometricVaultKek in keyStoreManager.keys) - } - - @Test - fun `a failed enrollment leaves the stored enrollment intact`() = runTest { - seedEnrolledAccount() - - val result = enroll() - - assertTrue(result.isFailure()) - assertEquals( - BiometricEnrollmentError.BiometricFailed(BiometricAuthError.NoCipher), - result.error, - ) - assertTrue(KeyId.BiometricVaultKek in keyStoreManager.keys) - assertNotNull(accountRepository.getOrNull()?.biometricWrappedArk) - } - - /** - * The interrupted-disable case: the wrapped ARK is gone but its key survived. Nothing can open - * that key any more, so an enrollment starting here must not adopt it - on the devices this - * exists for, adopting it is how the unusable key comes back. - */ - @Test - fun `enrolling from an unenrolled account drops the key left behind`() = runTest { - 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. - enroll() - - assertFalse(KeyId.BiometricVaultKek in keyStoreManager.keys) - } - - @Test - fun `enrolling while still enrolled keeps the key the stored ARK needs`() = runTest { - seedEnrolledAccount() - val inUse = keyStoreManager.keys.getValue(KeyId.BiometricVaultKek) - - // 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. - enroll() - - assertEquals(inUse, keyStoreManager.keys[KeyId.BiometricVaultKek]) - assertNotNull(accountRepository.getOrNull()?.biometricWrappedArk) - } - - @Test - fun `enrolling without an account touches nothing`() = runTest { - keyStoreManager.getOrCreateCipherFor(KeyId.BiometricVaultKek, CryptographicMode.Wrap) - - val result = enroll() - - assertTrue(result.isFailure()) - assertEquals(BiometricEnrollmentError.NoActiveAccount, result.error) - assertTrue(KeyId.BiometricVaultKek in keyStoreManager.keys) - } - - @Test - fun `disabling without an account touches nothing`() = runTest { - keyStoreManager.getOrCreateCipherFor(KeyId.BiometricVaultKek, CryptographicMode.Wrap) - - val result = adapter.disableBiometric() - - assertTrue(result.isFailure()) - assertEquals(BiometricEnrollmentError.NoActiveAccount, result.error) - assertTrue(KeyId.BiometricVaultKek in keyStoreManager.keys) - } -} 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 deleted file mode 100644 index 4f9fa9299..000000000 --- a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/presentation/BiometricUnlockAdapterImplTest.kt +++ /dev/null @@ -1,218 +0,0 @@ -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.FakeSession -import de.davis.keygo.core.security.crypto.FakeBiometricCryptoController -import de.davis.keygo.core.security.crypto.FakeKeyStoreManager -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.spec.SecretKeySpec -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 - -class BiometricUnlockAdapterImplTest { - - private val session = FakeSession() - private val accountRepository = FakeAccountRepository() - private val controller = FakeBiometricCryptoController() - private val keyStoreManager = FakeKeyStoreManager() - - private val enrollmentAdapter = BiometricEnrollmentAdapterImpl( - accountRepository = accountRepository, - session = session, - keyStoreManager = keyStoreManager, - ) - - private val adapter = BiometricUnlockAdapterImpl( - session = session, - accountRepository = accountRepository, - 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( - 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 `KeyInvalidated drops the stored enrollment and reports it as reset`() = runTest { - seedAccountWithBiometric() - controller.unwrapResult = Result.Failure(BiometricAuthError.KeyInvalidated) - - val result = with(adapter) { controller.requestUnlockVault(BiometricPolicy.Default) } - - assertTrue(result.isFailure()) - assertEquals(UnlockError.BiometricEnrollmentReset, result.error) - assertNull(accountRepository.getOrNull()?.biometricWrappedArk) - assertFalse(KeyId.BiometricVaultKek in keyStoreManager.keys) - } - - @Test - fun `a teardown that does not persist is not reported as a reset`() = runTest { - seedAccountWithBiometric() - controller.unwrapResult = Result.Failure(BiometricAuthError.KeyInvalidated) - accountRepository.setFails = true - - val result = with(adapter) { controller.requestUnlockVault(BiometricPolicy.Default) } - - assertTrue(result.isFailure()) - assertEquals(UnlockError.BiometricFailed(BiometricAuthError.KeyInvalidated), result.error) - assertNotNull(accountRepository.getOrNull()?.biometricWrappedArk) - assertTrue(KeyId.BiometricVaultKek in keyStoreManager.keys) - } - - @Test - fun `a retryable biometric failure leaves the stored enrollment in place`() = runTest { - seedAccountWithBiometric() - controller.unwrapResult = Result.Failure(BiometricAuthError.CryptoFailed) - - val result = with(adapter) { controller.requestUnlockVault(BiometricPolicy.Default) } - - assertTrue(result.isFailure()) - assertEquals(UnlockError.BiometricFailed(BiometricAuthError.CryptoFailed), result.error) - assertNotNull(accountRepository.getOrNull()?.biometricWrappedArk) - assertTrue(KeyId.BiometricVaultKek in keyStoreManager.keys) - } - - @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.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/build.gradle.kts b/core/security/build.gradle.kts index 56c6f1190..72802d022 100644 --- a/core/security/build.gradle.kts +++ b/core/security/build.gradle.kts @@ -12,7 +12,6 @@ android { } dependencies { - implementation(libs.androidx.biometric) implementation(libs.androidx.lifecycle.process) implementation(projects.core.item) diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/KeyStoreManagerImpl.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/KeyStoreManagerImpl.kt index c5775d723..507c21235 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/KeyStoreManagerImpl.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/KeyStoreManagerImpl.kt @@ -129,7 +129,7 @@ internal class KeyStoreManagerImpl : KeyStoreManager { } } -internal fun keyStoreManagerErrorFrom(throwable: Throwable): KeyStoreManagerError { +fun keyStoreManagerErrorFrom(throwable: Throwable): KeyStoreManagerError { val causes = generateSequence(throwable) { current -> current.cause?.takeIf { it !== current } } .take(MAX_CAUSE_DEPTH) .toList() diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/CiphertextData.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/CiphertextData.kt deleted file mode 100644 index 7834fd74e..000000000 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/CiphertextData.kt +++ /dev/null @@ -1,24 +0,0 @@ -package de.davis.keygo.core.security.domain.model - -data class CiphertextData( - val bytes: ByteArray, - val iv: ByteArray -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as CiphertextData - - if (!bytes.contentEquals(other.bytes)) return false - if (!iv.contentEquals(other.iv)) return false - - return true - } - - override fun hashCode(): Int { - var result = bytes.contentHashCode() - result = 31 * result + iv.contentHashCode() - return result - } -} \ No newline at end of file diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/presentation/BiometricCryptoController.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/presentation/BiometricCryptoController.kt deleted file mode 100644 index d38060cab..000000000 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/presentation/BiometricCryptoController.kt +++ /dev/null @@ -1,37 +0,0 @@ -package de.davis.keygo.core.security.presentation - -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.util.Result -import java.security.Key -import javax.crypto.Cipher - -interface BiometricCryptoController { - - suspend fun requestCipher( - keyId: KeyId, - mode: CryptographicMode, - policy: BiometricPolicy = BiometricPolicy.Default - ): Result - - suspend fun requestUnwrap( - keyId: KeyId, - ciphertextData: CiphertextData, - policy: BiometricPolicy = BiometricPolicy.Default - ): Result - - suspend fun requestEncryption( - keyId: KeyId, - byteArray: ByteArray, - policy: BiometricPolicy = BiometricPolicy.Default - ): Result - - suspend fun requestDecryption( - keyId: KeyId, - ciphertextData: CiphertextData, - policy: BiometricPolicy = BiometricPolicy.Default - ): Result -} \ No newline at end of file diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/presentation/BiometricCryptoControllerImpl.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/presentation/BiometricCryptoControllerImpl.kt deleted file mode 100644 index 4dec77eee..000000000 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/presentation/BiometricCryptoControllerImpl.kt +++ /dev/null @@ -1,196 +0,0 @@ -package de.davis.keygo.core.security.presentation - -import android.util.Log -import androidx.activity.compose.LocalActivity -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricPrompt -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.fragment.app.FragmentActivity -import de.davis.keygo.core.security.data.keyStoreManagerErrorFrom -import de.davis.keygo.core.security.data.resolve -import de.davis.keygo.core.security.domain.KeyStoreManager -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.domain.model.KeyStoreManagerError -import de.davis.keygo.core.util.Result -import de.davis.keygo.core.util.getOrNull -import de.davis.keygo.core.util.onFailure -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.asExecutor -import kotlinx.coroutines.suspendCancellableCoroutine -import org.koin.compose.koinInject -import java.security.Key -import javax.crypto.Cipher -import kotlin.coroutines.resume - -internal class BiometricCryptoControllerImpl( - private val activity: FragmentActivity, - private val keyStoreManager: KeyStoreManager -) : BiometricCryptoController { - - private val biometricManager by lazy { - BiometricManager.from(activity) - } - - override suspend fun requestCipher( - keyId: KeyId, - mode: CryptographicMode, - policy: BiometricPolicy, - ): Result = request( - keyId = keyId, - policy = policy, - mode = mode - ) { it } - - @OptIn(ExperimentalCoroutinesApi::class) - override suspend fun requestUnwrap( - keyId: KeyId, - ciphertextData: CiphertextData, - policy: BiometricPolicy - ): Result = request( - keyId = keyId, - policy = policy, - mode = CryptographicMode.Unwrap, - iv = ciphertextData.iv - ) { it.unwrap(ciphertextData.bytes, "AES", Cipher.SECRET_KEY) } - - override suspend fun requestEncryption( - keyId: KeyId, - byteArray: ByteArray, - policy: BiometricPolicy - ): Result = request( - keyId = keyId, - policy = policy, - mode = CryptographicMode.Encrypt - ) { CiphertextData(it.doFinal(byteArray), it.iv) } - - override suspend fun requestDecryption( - keyId: KeyId, - ciphertextData: CiphertextData, - policy: BiometricPolicy - ): Result = request( - keyId = keyId, - policy = policy, - mode = CryptographicMode.Decrypt, - iv = ciphertextData.iv - ) { it.doFinal(ciphertextData.bytes) } - - private suspend fun request( - keyId: KeyId, - policy: BiometricPolicy, - mode: CryptographicMode, - iv: ByteArray? = null, - onSuccess: (Cipher) -> T - ): Result = suspendCancellableCoroutine { c -> - when (val code = biometricManager.canAuthenticate(AUTHENTICATORS)) { - BiometricManager.BIOMETRIC_SUCCESS -> {} - - else -> { - c.resume(Result.Failure(BiometricAuthError.CanNotAuthenticate(code))) - return@suspendCancellableCoroutine - } - } - - val prompt = BiometricPrompt( - activity, - Dispatchers.Main.asExecutor(), - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - val cipher = result.cryptoObject?.cipher ?: return c.resume( - Result.Failure(BiometricAuthError.NoCipher) - ) - - runCatching { onSuccess(cipher) }.fold( - onSuccess = { c.resume(Result.Success(it)) }, - onFailure = { - Log.e(TAG, "Cipher operation failed after authentication succeeded", it) - c.resume(Result.Failure(cipherFailureToBiometricAuthError(it))) - }, - ) - } - - override fun onAuthenticationError( - errorCode: Int, - errString: CharSequence - ) { - c.resume(Result.Failure(biometricAuthErrorFrom(errorCode, errString))) - } - - override fun onAuthenticationFailed() { - // We do not resume, as this causes the coroutine to be finished and we cannot - // handle further attempts. The Android framework may still send further events, - // which we could handle. - } - } - ) - - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle(policy.title.resolve(activity)) - .setNegativeButtonText(policy.negativeButton.resolve(activity)) - .setAllowedAuthenticators(AUTHENTICATORS) - .build() - - val cipher = keyStoreManager.getOrCreateCipherFor(keyId, mode, iv).onFailure { - c.resume(Result.Failure(it.toBiometricAuthError())) - }.getOrNull() ?: return@suspendCancellableCoroutine - - val cryptoObj = BiometricPrompt.CryptoObject(cipher) - prompt.authenticate(promptInfo, cryptoObj) - - c.invokeOnCancellation { - prompt.cancelAuthentication() - } - } - - companion object { - private const val TAG = "BiometricCryptoController" - private const val AUTHENTICATORS = BiometricManager.Authenticators.BIOMETRIC_STRONG - } -} - -internal fun cipherFailureToBiometricAuthError(throwable: Throwable): BiometricAuthError = - keyStoreManagerErrorFrom(throwable).toBiometricAuthError() - -internal fun KeyStoreManagerError.toBiometricAuthError(): BiometricAuthError = when (this) { - KeyStoreManagerError.KeyInvalidated -> BiometricAuthError.KeyInvalidated - KeyStoreManagerError.AuthenticationRequired -> BiometricAuthError.CryptoFailed - KeyStoreManagerError.Unknown -> BiometricAuthError.CryptoFailed -} - -/** - * Classify a [BiometricPrompt] error code into a semantic [BiometricAuthError] once, at the source, - * so consumers branch on meaning instead of re-interpreting raw androidx error codes. - */ -internal fun biometricAuthErrorFrom( - errorCode: Int, - errString: CharSequence, -): BiometricAuthError = when (errorCode) { - BiometricPrompt.ERROR_NEGATIVE_BUTTON -> BiometricAuthError.Declined - - BiometricPrompt.ERROR_LOCKOUT, - BiometricPrompt.ERROR_LOCKOUT_PERMANENT, - -> BiometricAuthError.LockedOut - - BiometricPrompt.ERROR_USER_CANCELED, - BiometricPrompt.ERROR_CANCELED, - -> BiometricAuthError.Canceled - - else -> BiometricAuthError.Unknown(errorCode, errString.toString()) -} - -@Composable -fun rememberBiometricCryptoController(): BiometricCryptoController { - val activity = LocalActivity.current as? FragmentActivity - requireNotNull(activity) { "rememberBiometricCryptoController must be used within a FragmentActivity context." } - - val keyStoreManager = koinInject() - - return remember(activity, keyStoreManager) { - BiometricCryptoControllerImpl(activity, keyStoreManager) - } -} \ No newline at end of file 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 deleted file mode 100644 index 3724ef6fe..000000000 --- a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeBiometricCryptoController.kt +++ /dev/null @@ -1,43 +0,0 @@ -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) - - var cipherResult: Result = - Result.Failure(BiometricAuthError.NoCipher) - - override suspend fun requestCipher( - keyId: KeyId, - mode: CryptographicMode, - policy: BiometricPolicy, - ): Result = cipherResult - - 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/util/build.gradle.kts b/core/util/build.gradle.kts index c7775fa8f..44d3fe709 100644 --- a/core/util/build.gradle.kts +++ b/core/util/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { testImplementation(libs.okhttp.jvm) testFixturesImplementation(libs.kotlin.test) + testFixturesImplementation(libs.kotlinx.coroutines.core) testFixturesImplementation(project.dependencies.platform(libs.androidx.compose.bom)) testFixturesImplementation(libs.androidx.compose.runtime) { because("https://issuetracker.google.com/issues/259523353#comment32") diff --git a/core/util/src/main/kotlin/de/davis/keygo/core/util/Result.kt b/core/util/src/main/kotlin/de/davis/keygo/core/util/Result.kt index 58280c5d2..568571a69 100644 --- a/core/util/src/main/kotlin/de/davis/keygo/core/util/Result.kt +++ b/core/util/src/main/kotlin/de/davis/keygo/core/util/Result.kt @@ -88,7 +88,7 @@ class ResultBinding { } } - fun Result.bind(mapError: (F) -> E): S { + inline fun Result.bind(mapError: (F) -> E): S { return when (this) { is Result.Success -> success is Result.Failure -> throw Abort(mapError(error)) diff --git a/core/util/src/testFixtures/kotlin/de/davis/keygo/core/util/FakeSnackbarManager.kt b/core/util/src/testFixtures/kotlin/de/davis/keygo/core/util/FakeSnackbarManager.kt new file mode 100644 index 000000000..53955df7e --- /dev/null +++ b/core/util/src/testFixtures/kotlin/de/davis/keygo/core/util/FakeSnackbarManager.kt @@ -0,0 +1,21 @@ +package de.davis.keygo.core.util + +import de.davis.keygo.core.util.domain.model.snackbar.SnackbarMessage +import de.davis.keygo.core.util.domain.snackbar.SnackbarManager +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow + +class FakeSnackbarManager : SnackbarManager { + + val messages: MutableList = mutableListOf() + + private val channel = Channel(Channel.UNLIMITED) + + override val oneShotEvents: Flow = channel.receiveAsFlow() + + override fun sendMessage(message: SnackbarMessage) { + messages += message + channel.trySend(message) + } +} diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts index c643b870d..63a75ac50 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -8,6 +8,7 @@ android { } dependencies { + implementation(projects.core.biometrics) implementation(projects.core.identity) implementation(projects.core.item) implementation(projects.core.ui) @@ -15,6 +16,7 @@ dependencies { testImplementation(projects.rust) testImplementation(libs.robolectric) + testImplementation(testFixtures(projects.core.biometrics)) testImplementation(testFixtures(projects.core.identity)) testImplementation(testFixtures(projects.core.item)) testImplementation(testFixtures(projects.core.security)) diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt index 36fdf1954..c69db890e 100644 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt +++ b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt @@ -1,18 +1,10 @@ package de.davis.keygo.feature.auth.presentation -import android.util.Log import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.lifecycle.compose.collectAsStateWithLifecycle -import de.davis.keygo.core.identity.presentation.rememberBiometricUnlockAdapter -import de.davis.keygo.core.identity.presentation.useAdapter -import de.davis.keygo.core.security.domain.model.KeyId -import de.davis.keygo.core.security.presentation.rememberBiometricCryptoController -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.model.BiometricRequest import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf @@ -27,35 +19,6 @@ fun AuthScreen(route: AuthRoute, onSuccess: () -> Unit) { currentOnSuccess() } - val biometricCryptoController = rememberBiometricCryptoController() - val biometricUnlockAdapter = rememberBiometricUnlockAdapter() - - ObserveAsEvents(viewModel.biometricFlow) { request -> - when (request) { - is BiometricRequest.CreateAccess -> { - biometricCryptoController.requestCipher( - keyId = KeyId.BiometricVaultKek, - mode = request.cryptoMode - ).onSuccess { - viewModel.executeCreateAccess(request.password, it) - }.onFailure { - Log.e("AuthScreen", "Failed to create cipher for biometric access: $it") - // TODO: show error - } - } - - BiometricRequest.Login -> { - biometricUnlockAdapter.useAdapter { - biometricCryptoController.requestUnlockVault() - }.onSuccess { - viewModel.onSessionEstablished() - }.onFailure { - viewModel.onBiometricUnlockFailed(it) - } - } - } - - } AuthContent( state = state, onEvent = viewModel::onEvent, diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModel.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModel.kt index cf90dad6a..3d0dc5561 100644 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModel.kt +++ b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModel.kt @@ -4,18 +4,18 @@ import androidx.compose.foundation.text.input.TextFieldState 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.identity.domain.model.UnlockableByBiometricsResult +import de.davis.keygo.core.identity.domain.model.hasHardware import de.davis.keygo.core.identity.domain.usecase.CreateAccessUseCase +import de.davis.keygo.core.identity.domain.usecase.UnlockWithBiometricsUseCase import de.davis.keygo.core.identity.domain.usecase.UnlockWithPasswordUseCase -import de.davis.keygo.core.security.domain.repository.BiometricAvailabilityRepository +import de.davis.keygo.core.identity.domain.usecase.UnlockableByBiometricsUseCase import de.davis.keygo.core.ui.model.UiFieldError import de.davis.keygo.core.util.Result -import de.davis.keygo.core.util.asResult import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.onSuccess import de.davis.keygo.feature.auth.presentation.model.AuthState import de.davis.keygo.feature.auth.presentation.model.AuthUIEvent -import de.davis.keygo.feature.auth.presentation.model.BiometricRequest import de.davis.keygo.legacy_migration.domain.model.MigrationResult import de.davis.keygo.legacy_migration.domain.usecase.HasMainPasswordUseCase import de.davis.keygo.legacy_migration.domain.usecase.RunPendingMigrationUseCase @@ -29,13 +29,11 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.annotation.InjectedParam import org.koin.core.annotation.KoinViewModel -import javax.crypto.Cipher @KoinViewModel internal class AuthViewModel( @InjectedParam private val authRoute: AuthRoute, - biometricAvailabilityRepository: BiometricAvailabilityRepository, - accountRepository: AccountRepository, + unlockableByBiometrics: UnlockableByBiometricsUseCase, // ---- Migration ---- private val hasV1MainPassword: HasMainPasswordUseCase, @@ -43,12 +41,10 @@ internal class AuthViewModel( private val runPendingMigration: RunPendingMigrationUseCase, // ------------------- + private val unlockWithBiometrics: UnlockWithBiometricsUseCase, private val unlockWithPassword: UnlockWithPasswordUseCase, private val createAllAccesses: CreateAccessUseCase, ) : ViewModel() { - private val biometricChannel = Channel(Channel.BUFFERED) - val biometricFlow = biometricChannel.receiveAsFlow() - val hasPendingTotpImport: Boolean = authRoute.uri != null private val passwordTextFieldState = TextFieldState() @@ -58,24 +54,20 @@ internal class AuthViewModel( init { viewModelScope.launch { - val activeAccount = accountRepository.getOrNull() - val hasAccess = activeAccount != null - val shouldMigrate = if (!hasAccess) hasV1MainPassword() else false - - val isBiometricHardwareAvailable = biometricAvailabilityRepository.availability() - val isBiometricCryptoSetupAvailable = - hasAccess && activeAccount.biometricWrappedArk != null - - val biometricsUsable = isBiometricHardwareAvailable && isBiometricCryptoSetupAvailable - if (biometricsUsable && authRoute.showBiometricPromptIfPossible) requestBiometricLogin() + val unlockableByBiometrics = unlockableByBiometrics() + val shouldMigrate = + if (unlockableByBiometrics is UnlockableByBiometricsResult.NoAccount) + hasV1MainPassword() + else false + val biometricsUsable = unlockableByBiometrics == UnlockableByBiometricsResult.Available _uiState.update { when { shouldMigrate -> { AuthState.Migrating( passwordTextFieldState = passwordTextFieldState, - biometricsAvailable = isBiometricHardwareAvailable, + biometricsAvailable = unlockableByBiometrics.hasHardware(), ) } @@ -85,6 +77,8 @@ internal class AuthViewModel( ) } } + + if (biometricsUsable && authRoute.showBiometricPromptIfPossible) requestBiometricLogin() } } @@ -94,24 +88,10 @@ internal class AuthViewModel( private var migrationJob: Job? = null private var authJob: Job? = null - fun onBiometricUnlockFailed(error: UnlockError) { - if (error != UnlockError.BiometricEnrollmentReset) return - - _uiState.update { state -> - when (state) { - is AuthState.Login -> state.copy( - biometricAuthenticationAvailable = false, - showBiometricResetNotice = true, - ) - - else -> state - } - } - } - fun onEvent(event: AuthUIEvent) { when (event) { - is AuthUIEvent.RequestBiometricAuthentication -> if (uiState.value is AuthState.Login) requestBiometricLogin() + is AuthUIEvent.RequestBiometricAuthentication -> + if (uiState.value is AuthState.Login) requestBiometricLogin() AuthUIEvent.Submit -> { val state = _uiState.value as? AuthState.Interactable ?: return @@ -129,18 +109,21 @@ internal class AuthViewModel( is AuthState.Migrating -> { loading { - validateMainPassword(password).asResult(Unit) - .onFailure { - // Through the scope rather than straight to _uiState: loading - // writes the scope's state back when the block returns, so a - // direct write here would be overwritten and the user would see - // the spinner stop with no error against the field. - updateState { - copyDefaultState(passwordError = UiFieldError.Incorrect) - } - }.onSuccess { - createPasswordOrBiometricAccess(state, password) + if (!validateMainPassword(password)) { + // Through the scope rather than straight to _uiState: loading + // writes the scope's state back when the block returns, so a + // direct write here would be overwritten and the user would see + // the spinner stop with no error against the field. + updateState { + copyDefaultState(passwordError = UiFieldError.Incorrect) } + return@loading + } + + createAllAccesses( + password = password, + withBiometrics = state.biometricsAvailable && state.useBiometrics, + ).handleAuthenticationResult() } } } @@ -165,47 +148,12 @@ internal class AuthViewModel( } } - AuthUIEvent.RetryMigration -> onSessionEstablished() + AuthUIEvent.RetryMigration -> performMigrationIfNeeded() AuthUIEvent.ContinueAfterMigration -> navigationEventChannel.trySend(Unit) } } - /** - * Runs inside the caller's [loading] rather than starting a second one, so the screen stays - * loading until the account actually exists. A nested [loading] returned as soon as it had - * launched, which wrote `loading = false` back while key derivation was still running and - * re-enabled Submit for the whole of it. - */ - private suspend fun LoadingScope.createPasswordOrBiometricAccess( - authState: AuthState.Migrating, - password: String, - ) { - if (authState.biometricsAvailable && authState.useBiometrics) { - // Handed to the prompt. AuthScreen starts a fresh run with the cipher once the user has - // answered, and by then this one has finished, so the guard in loading does not eat it. - // - // That ordering is worth stating, because it is not obvious and it is not local. The - // collector observing this channel runs on Dispatchers.Main.immediate, so it resumes - // inline inside trySend and AuthScreen's handler begins running while this job is still - // active. What saves it is that requestCipher suspends until the user answers, and its - // one synchronous return is a failure that never reaches executeCreateAccess. A fast - // path added there that returned a cipher without suspending would be dropped by the - // guard, and the user would sit on the migrate screen with no account. - biometricChannel.trySend(BiometricRequest.CreateAccess(password)) - return - } - - createAllAccesses( - password = password, - biometricCipher = null, - ).handleAuthenticationResult() - } - - private fun requestBiometricLogin() { - biometricChannel.trySend(BiometricRequest.Login) - } - private fun loading( setLoading: Boolean = true, block: suspend LoadingScope.() -> Unit, @@ -238,7 +186,33 @@ internal class AuthViewModel( scope.updatedState.copyDefaultState(loading = false) } - if (sessionEstablished) onSessionEstablished() + if (sessionEstablished) performMigrationIfNeeded() + } + } + + + private fun requestBiometricLogin() { + viewModelScope.launch { + unlockWithBiometrics().onFailure { + onBiometricUnlockFailed(it) + }.onSuccess { + performMigrationIfNeeded() + } + } + } + + private fun onBiometricUnlockFailed(error: UnlockError) { + if (error != UnlockError.BiometricEnrollmentReset) return + + _uiState.update { state -> + when (state) { + is AuthState.Login -> state.copy( + biometricAuthenticationAvailable = false, + showBiometricResetNotice = true, + ) + + else -> state + } } } @@ -249,7 +223,7 @@ internal class AuthViewModel( * The marker is read here as well as inside the use case so the common case, an install with no * v1 migration pending, never flips the screen into an import it is not going to run. */ - fun onSessionEstablished() { + private fun performMigrationIfNeeded() { // Retry is a button on a screen the user reaches after a failure, so it can be tapped twice // before the first run has published anything. Two concurrent imports would both read the // same v1 rows and both write them, so a tap that lands while one is running is dropped. @@ -275,18 +249,6 @@ internal class AuthViewModel( } } } - - fun executeCreateAccess( - password: String, - cipher: Cipher? = null, - ) { - loading { - createAllAccesses( - password = password, - biometricCipher = cipher, - ).handleAuthenticationResult() - } - } } private class LoadingScope( diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/model/BiometricRequest.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/model/BiometricRequest.kt deleted file mode 100644 index d7672808c..000000000 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/model/BiometricRequest.kt +++ /dev/null @@ -1,18 +0,0 @@ -package de.davis.keygo.feature.auth.presentation.model - -import de.davis.keygo.core.security.domain.model.CryptographicMode - -internal sealed interface BiometricRequest { - - val cryptoMode: CryptographicMode - - data class CreateAccess(val password: String) : BiometricRequest { - override val cryptoMode: CryptographicMode - get() = CryptographicMode.Wrap - } - - data object Login : BiometricRequest { - override val cryptoMode: CryptographicMode - get() = CryptographicMode.Unwrap - } -} \ No newline at end of file diff --git a/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelTest.kt b/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelTest.kt index df9ad21c5..0323ede19 100644 --- a/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelTest.kt +++ b/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelTest.kt @@ -1,19 +1,25 @@ package de.davis.keygo.feature.auth.presentation import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import de.davis.keygo.core.biometrics.FakeBiometricAvailabilityRepository +import de.davis.keygo.core.biometrics.FakeBiometricCrypto +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError 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.identity.domain.usecase.CreateAccessUseCase +import de.davis.keygo.core.identity.domain.usecase.DisableBiometricsUseCase +import de.davis.keygo.core.identity.domain.usecase.EnableBiometricsUseCase +import de.davis.keygo.core.identity.domain.usecase.UnlockWithBiometricsUseCase import de.davis.keygo.core.identity.domain.usecase.UnlockWithPasswordUseCase +import de.davis.keygo.core.identity.domain.usecase.UnlockableByBiometricsUseCase 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.domain.model.BiometricAuthError +import de.davis.keygo.core.security.crypto.FakeKeyStoreManager +import de.davis.keygo.core.security.domain.model.CryptographicMode +import de.davis.keygo.core.security.domain.model.KeyId import de.davis.keygo.core.ui.model.UiFieldError +import de.davis.keygo.core.util.isSuccess import de.davis.keygo.feature.auth.presentation.model.AuthState import de.davis.keygo.feature.auth.presentation.model.AuthUIEvent import de.davis.keygo.legacy_migration.FakeMainPasswordRepository @@ -38,19 +44,20 @@ import kotlinx.coroutines.test.setMain import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -import java.util.UUID -import javax.crypto.Cipher -import javax.crypto.KeyGenerator import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue /** * Regression tests for the v1-password retry lockout fixed in `97b15f3c`. * - * [AuthViewModel.executeCreateAccess] used to clear the v1 migration password as soon as the + * `AuthViewModel.executeCreateAccess` used to clear the v1 migration password as soon as the * password was validated, before the account was actually created. If account creation then * failed for any reason - most notably a failed/declined biometric prompt - the v1 password was * already gone, so `HasMainPasswordUseCase` reported no pending migration and the user had no way @@ -68,13 +75,24 @@ class AuthViewModelTest { private val vaultRepository = FakeVaultRepository() private val vaultContextRepository = FakeVaultContextRepository() private val session = FakeSession() - private val biometricAvailability = FakeBiometricAvailabilityRepository() + private val keyStoreManager = FakeKeyStoreManager() + private val biometricCrypto = FakeBiometricCrypto(keyStoreManager) private val mainPasswordRepository = FakeMainPasswordRepository() + private val biometricAvailability = FakeBiometricAvailabilityRepository().apply { + isAvailable = true + } + private val createAllAccesses = CreateAccessUseCase( accountRepository = accountRepository, vaultRepository = vaultRepository, vaultContextRepository = vaultContextRepository, + enableBiometrics = EnableBiometricsUseCase( + accountRepository = accountRepository, + session = session, + keyStoreManager = keyStoreManager, + biometricCrypto = biometricCrypto, + ), session = session, ) @@ -83,6 +101,18 @@ class AuthViewModelTest { accountRepository = accountRepository, ) + private val unlockWithBiometrics = UnlockWithBiometricsUseCase( + session = session, + accountRepository = accountRepository, + biometricCrypto = biometricCrypto, + disableBiometrics = DisableBiometricsUseCase(accountRepository, keyStoreManager), + ) + + private val unlockableByBiometrics = UnlockableByBiometricsUseCase( + accountRepository = accountRepository, + biometricAvailabilityRepository = biometricAvailability, + ) + // Real use cases, wired to mainPasswordRepository via factories - HasMainPasswordUseCase and // ValidateMainPasswordUseCase have an internal constructor scoped to the migration module, so // they can't be built here directly the way a plain fake dependency would be. @@ -103,16 +133,17 @@ class AuthViewModelTest { * resolve into `AuthState.Migrating`. */ private fun TestScope.viewModel( + authRoute: AuthRoute = AuthRoute(), runPendingMigration: RunPendingMigrationUseCase = runPendingMigrationUseCase(backgroundScope, mainPasswordRepository), ): AuthViewModel { val vm = AuthViewModel( - authRoute = AuthRoute(), - biometricAvailabilityRepository = biometricAvailability, - accountRepository = accountRepository, + authRoute = authRoute, + unlockableByBiometrics = unlockableByBiometrics, hasV1MainPassword = hasV1MainPassword, validateMainPassword = validateMainPassword, runPendingMigration = runPendingMigration, + unlockWithBiometrics = unlockWithBiometrics, unlockWithPassword = unlockWithPassword, createAllAccesses = createAllAccesses, ) @@ -120,6 +151,14 @@ class AuthViewModelTest { return vm } + private fun TestScope.loginViewModel( + runPendingMigration: RunPendingMigrationUseCase = + runPendingMigrationUseCase(backgroundScope, mainPasswordRepository), + ) = viewModel( + authRoute = AuthRoute(showBiometricPromptIfPossible = false), + runPendingMigration = runPendingMigration, + ) + /** * Key derivation inside [CreateAccessUseCase] hops to `Dispatchers.Default`, which the test * scheduler can't see, so wait for the loading flag to flip back rather than @@ -129,24 +168,103 @@ class AuthViewModelTest { uiState.first { it is AuthState.Migrating && !it.loading } } + private fun AuthViewModel.submitMigration( + password: String = V1_PASSWORD, + useBiometrics: Boolean = false, + ) { + onEvent(AuthUIEvent.ToggleUseBiometrics(checked = useBiometrics)) + assertIs(uiState.value) + .passwordTextFieldState + .setTextAndPlaceCursorAtEnd(password) + onEvent(AuthUIEvent.Submit) + } + /** An account whose vault can be opened by the biometric key, so the button is offered. */ - private fun seedEnrolledAccount() { - biometricAvailability.isAvailable = true - 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), - ), - ) + private suspend fun seedEnrolledAccount(withBiometrics: Boolean = true) { + assertTrue(createAllAccesses(ACCOUNT_PASSWORD, withBiometrics = withBiometrics).isSuccess()) + session.endSession() + biometricCrypto.prompts.clear() + } + + @Test + fun `an enrolled account opens the prompt on arrival and continues once unlocked`() = + runTest(dispatcher) { + seedEnrolledAccount() + + val vm = viewModel() + vm.navigationEvent.first() + + assertTrue(session.isActive.value) + assertEquals(CryptographicMode.Unwrap, biometricCrypto.prompts.single().mode) + } + + @Test + fun `the prompt waits for the user when the route asks it to`() = runTest(dispatcher) { + seedEnrolledAccount() + + val vm = loginViewModel() + + assertEquals( + true, + assertIs(vm.uiState.value).biometricAuthenticationAvailable, ) + assertTrue(biometricCrypto.prompts.isEmpty()) + assertFalse(session.isActive.value) + } + + @Test + fun `asking for biometrics unlocks and continues`() = runTest(dispatcher) { + seedEnrolledAccount() + val vm = loginViewModel() + + vm.onEvent(AuthUIEvent.RequestBiometricAuthentication) + vm.navigationEvent.first() + + assertTrue(session.isActive.value) + } + + @Test + fun `biometrics are neither offered nor prompted without a usable sensor`() = + runTest(dispatcher) { + seedEnrolledAccount() + biometricAvailability.isAvailable = false + + val vm = viewModel() + + assertEquals( + false, + assertIs(vm.uiState.value).biometricAuthenticationAvailable, + ) + assertTrue(biometricCrypto.prompts.isEmpty()) + } + + @Test + fun `biometrics are neither offered nor prompted for an account that never enrolled`() = + runTest(dispatcher) { + seedEnrolledAccount(withBiometrics = false) + + val vm = viewModel() + + assertEquals( + false, + assertIs(vm.uiState.value).biometricAuthenticationAvailable, + ) + assertTrue(biometricCrypto.prompts.isEmpty()) + } + + @Test + fun `a declined prompt leaves the login form as it was`() = runTest(dispatcher) { + seedEnrolledAccount() + biometricCrypto.promptFailure = BiometricAuthError.Declined + val vm = loginViewModel() + + vm.onEvent(AuthUIEvent.RequestBiometricAuthentication) + runCurrent() + + val login = assertIs(vm.uiState.value) + assertEquals(true, login.biometricAuthenticationAvailable) + assertEquals(false, login.showBiometricResetNotice) + assertFalse(session.isActive.value) } /** @@ -157,24 +275,29 @@ class AuthViewModelTest { @Test fun `a reset enrollment is announced rather than quietly disappearing`() = runTest(dispatcher) { seedEnrolledAccount() - val vm = viewModel() + keyStoreManager.deleteKey(KeyId.BiometricVaultKek) + val vm = loginViewModel() assertEquals( true, assertIs(vm.uiState.value).biometricAuthenticationAvailable, ) - vm.onBiometricUnlockFailed(UnlockError.BiometricEnrollmentReset) + vm.onEvent(AuthUIEvent.RequestBiometricAuthentication) + runCurrent() val login = assertIs(vm.uiState.value) assertEquals(false, login.biometricAuthenticationAvailable) assertEquals(true, login.showBiometricResetNotice) + assertNull(accountRepository.getOrNull()?.biometricWrappedArk) } @Test fun `dismissing the notice leaves the enrollment gone`() = runTest(dispatcher) { seedEnrolledAccount() - val vm = viewModel() - vm.onBiometricUnlockFailed(UnlockError.BiometricEnrollmentReset) + keyStoreManager.deleteKey(KeyId.BiometricVaultKek) + val vm = loginViewModel() + vm.onEvent(AuthUIEvent.RequestBiometricAuthentication) + runCurrent() vm.onEvent(AuthUIEvent.DismissBiometricResetNotice) @@ -187,67 +310,104 @@ class AuthViewModelTest { fun `a retryable biometric failure announces nothing and keeps the button`() = runTest(dispatcher) { seedEnrolledAccount() - val vm = viewModel() + biometricCrypto.promptFailure = BiometricAuthError.CryptoFailed + val vm = loginViewModel() - vm.onBiometricUnlockFailed( - UnlockError.BiometricFailed(BiometricAuthError.CryptoFailed), - ) + vm.onEvent(AuthUIEvent.RequestBiometricAuthentication) + runCurrent() val login = assertIs(vm.uiState.value) assertEquals(false, login.showBiometricResetNotice) assertEquals(true, login.biometricAuthenticationAvailable) + assertNotNull(accountRepository.getOrNull()?.biometricWrappedArk) + } + + @Test + fun `a pending migration offers biometrics when the sensor is usable`() = runTest(dispatcher) { + mainPasswordRepository.hash = V1_HASH + + val vm = viewModel() + + assertEquals(true, assertIs(vm.uiState.value).biometricsAvailable) + } + + @Test + fun `a pending migration is offered on a device without usable biometrics`() = + runTest(dispatcher) { + biometricAvailability.isAvailable = false + mainPasswordRepository.hash = V1_HASH + + val vm = viewModel() + + val migrating = assertIs(vm.uiState.value) + assertEquals(false, migrating.biometricsAvailable) } + /** + * Biometrics are optional on top of the v1 password the user just confirmed. A declined prompt + * used to throw the freshly derived account away without a word, so the migration is expected + * to carry on with a password-only account instead. + */ @Test - fun `failed biometric wrapping leaves the v1 password intact so migration can be retried`() = + fun `a declined biometric prompt still creates the account and runs the migration`() = runTest(dispatcher) { - mainPasswordRepository.hash = "original-v1-hash" + mainPasswordRepository.hash = V1_HASH + biometricCrypto.promptFailure = BiometricAuthError.Declined val vm = viewModel() - // An uninitialized cipher throws IllegalStateException on wrap(), mirroring a failed - // biometric crypto operation surfacing as CreateAccessError.WrappingFailed - the exact - // failure mode described in the bug report. - val failingCipher = Cipher.getInstance("AES/GCM/NoPadding") - vm.executeCreateAccess(password = "correct-password", cipher = failingCipher) - vm.awaitIdle() + vm.submitMigration(useBiometrics = true) + vm.navigationEvent.first() - assertEquals("original-v1-hash", mainPasswordRepository.hash) + assertNull(assertNotNull(accountRepository.getOrNull()).biometricWrappedArk) + assertEquals("", mainPasswordRepository.hash) } @Test - fun `successful biometric account creation clears the v1 password`() = runTest(dispatcher) { - mainPasswordRepository.hash = "original-v1-hash" - val vm = viewModel() - val biometricKek = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() - val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply { - init(Cipher.WRAP_MODE, biometricKek) + fun `successful biometric account creation enrolls and clears the v1 password`() = + runTest(dispatcher) { + mainPasswordRepository.hash = V1_HASH + val vm = viewModel() + + vm.submitMigration(useBiometrics = true) + vm.navigationEvent.first() + + assertEquals("", mainPasswordRepository.hash) + assertNotNull(accountRepository.getOrNull()?.biometricWrappedArk) + assertEquals(CryptographicMode.Wrap, biometricCrypto.prompts.single().mode) } - vm.executeCreateAccess(password = "correct-password", cipher = cipher) - vm.navigationEvent.first() + @Test + fun `migrating with biometrics switched off creates a password-only account`() = + runTest(dispatcher) { + mainPasswordRepository.hash = V1_HASH + val vm = viewModel() - assertEquals("", mainPasswordRepository.hash) - } + vm.submitMigration(useBiometrics = false) + vm.navigationEvent.first() + + assertNull(accountRepository.getOrNull()?.biometricWrappedArk) + assertTrue(biometricCrypto.prompts.isEmpty()) + } @Test fun `account persistence failure on the password-only path leaves the v1 password intact`() = runTest(dispatcher) { - mainPasswordRepository.hash = "original-v1-hash" + mainPasswordRepository.hash = V1_HASH accountRepository.setFails = true val vm = viewModel() - vm.executeCreateAccess(password = "correct-password") + vm.submitMigration() vm.awaitIdle() - assertEquals("original-v1-hash", mainPasswordRepository.hash) + assertEquals(V1_HASH, mainPasswordRepository.hash) } @Test fun `successful password-only account creation clears the v1 password`() = runTest(dispatcher) { - mainPasswordRepository.hash = "original-v1-hash" + mainPasswordRepository.hash = V1_HASH val vm = viewModel() - vm.executeCreateAccess(password = "correct-password") + vm.submitMigration() vm.navigationEvent.first() assertEquals("", mainPasswordRepository.hash) @@ -255,16 +415,10 @@ class AuthViewModelTest { @Test fun `the migrate submit stays loading until the account exists`() = runTest(dispatcher) { - // Hex of a real bcrypt 2a hash of "password". The use case hex-decodes before verifying. - mainPasswordRepository.hash = "2432612431302471776e45776767315a6c5176435a58336450614a7a2e" + - "31494351504a334e6d4a64566b4251686577564655745363646665366d4847" + mainPasswordRepository.hash = V1_HASH val vm = viewModel() - vm.onEvent(AuthUIEvent.ToggleUseBiometrics(checked = false)) - - val migrating = assertIs(vm.uiState.value) - migrating.passwordTextFieldState.setTextAndPlaceCursorAtEnd("password") - vm.onEvent(AuthUIEvent.Submit) + vm.submitMigration() runCurrent() // Key derivation is still running on a dispatcher the scheduler cannot see. The screen must @@ -287,14 +441,14 @@ class AuthViewModelTest { @Test fun `a second account creation started while one is in flight is dropped`() = runTest(dispatcher) { - mainPasswordRepository.hash = "original-v1-hash" + mainPasswordRepository.hash = V1_HASH val vm = viewModel() - vm.executeCreateAccess(password = "correct-password") + vm.submitMigration() // Leaves the first run suspended inside key derivation, which hops to a dispatcher the // scheduler cannot see, so it cannot complete until something pumps the test one. runCurrent() - vm.executeCreateAccess(password = "correct-password") + vm.onEvent(AuthUIEvent.Submit) vm.navigationEvent.first() @@ -303,28 +457,23 @@ class AuthViewModelTest { @Test fun `a rejected v1 main password leaves an error on the field`() = runTest(dispatcher) { - // Hex of a real bcrypt 2a hash of "password". The use case hex-decodes the stored hash - // before handing it to bcrypt, so a non-hex placeholder throws instead of returning false. - mainPasswordRepository.hash = "243261243130244e39716f38754c4f69636b6778325a4d525a6f4d7965" + - "496a5a416763666c377039326c644778616436384c4a5a644c31376c685779" + mainPasswordRepository.hash = V1_HASH val vm = viewModel() - val migrating = assertIs(vm.uiState.value) - migrating.passwordTextFieldState.setTextAndPlaceCursorAtEnd("the-wrong-password") - - vm.onEvent(AuthUIEvent.Submit) + vm.submitMigration(password = "the-wrong-password") // Bcrypt runs on Dispatchers.Default, which the scheduler cannot see, so wait on the // loading flag for the same reason awaitIdle does. vm.awaitIdle() val after = assertIs(vm.uiState.value) assertEquals(UiFieldError.Incorrect, after.passwordError) + assertNull(accountRepository.getOrNull()) } @Test fun `a failed import leaves the v1 password in place and offers a retry`() = runTest(dispatcher) { - mainPasswordRepository.hash = "original-v1-hash" + mainPasswordRepository.hash = V1_HASH val vm = viewModel( runPendingMigration = runPendingMigrationUseCase( scope = backgroundScope, @@ -333,15 +482,15 @@ class AuthViewModelTest { ), ) - vm.executeCreateAccess(password = "correct-password") + vm.submitMigration() vm.uiState.first { it is AuthState.MigrationFailed } - assertEquals("original-v1-hash", mainPasswordRepository.hash) + assertEquals(V1_HASH, mainPasswordRepository.hash) } @Test fun `retrying a failed import runs it again`() = runTest(dispatcher) { - mainPasswordRepository.hash = "original-v1-hash" + mainPasswordRepository.hash = V1_HASH var runs = 0 var stateDuringImport: AuthState? = null // Sampled from inside the import because uiState is conflated: ImportingLegacyData is @@ -360,7 +509,7 @@ class AuthViewModelTest { ) underTest = vm - vm.executeCreateAccess(password = "correct-password") + vm.submitMigration() vm.uiState.first { it is AuthState.MigrationFailed } assertEquals(1, runs) assertEquals(AuthState.ImportingLegacyData, stateDuringImport) @@ -374,15 +523,15 @@ class AuthViewModelTest { assertEquals(2, runs) assertIs(vm.uiState.value) - assertEquals("original-v1-hash", mainPasswordRepository.hash) + assertEquals(V1_HASH, mainPasswordRepository.hash) } /** * The write that ends a loading run puts back a snapshot taken before `block()` suspended, so * it has to be guarded the same way the one that starts the run is. * - * `onSessionEstablished` is reachable from outside `loading`: AuthScreen calls it straight from - * the BiometricRequest.Login success handler and nothing gates it on `authJob`. So the user + * `performMigrationIfNeeded` is reachable from outside `loading`: a biometric unlock started from + * the button calls it on success and nothing gates it on `authJob`. So the user * submits the password form, the biometric prompt they already triggered comes back, the import * starts, and an unguarded write here would drop the live login form back on top of it - a form * they can submit again while the import runs behind it. @@ -392,12 +541,10 @@ class AuthViewModelTest { // An account plus a marker still on disk: what the user is left with the moment they tap // Continue on MigrationFailed. Every unlock after that is a login form over a migration // that is still pending. - val first = viewModel() - first.executeCreateAccess(password = "correct-password") - first.navigationEvent.first() - mainPasswordRepository.hash = "original-v1-hash" + seedEnrolledAccount() + mainPasswordRepository.hash = V1_HASH - val vm = viewModel( + val vm = loginViewModel( runPendingMigration = runPendingMigrationUseCase( scope = backgroundScope, repository = mainPasswordRepository, @@ -405,7 +552,12 @@ class AuthViewModelTest { ), ) val login = assertIs(vm.uiState.value) - login.passwordTextFieldState.setTextAndPlaceCursorAtEnd("correct-password") + login.passwordTextFieldState.setTextAndPlaceCursorAtEnd(ACCOUNT_PASSWORD) + + val prompt = CompletableDeferred() + biometricCrypto.pendingPrompt = prompt + vm.onEvent(AuthUIEvent.RequestBiometricAuthentication) + runCurrent() // Holds the unlock at its account read, which is the last point before it hops to a // dispatcher the scheduler cannot see. @@ -415,7 +567,7 @@ class AuthViewModelTest { runCurrent() assertEquals(true, assertIs(vm.uiState.value).loading) - vm.onSessionEstablished() + prompt.complete(Unit) runCurrent() assertIs(vm.uiState.value) @@ -429,7 +581,7 @@ class AuthViewModelTest { @Test fun `an import that skipped rows reports them before navigating`() = runTest(dispatcher) { - mainPasswordRepository.hash = "original-v1-hash" + mainPasswordRepository.hash = V1_HASH val vm = viewModel( runPendingMigration = runPendingMigrationUseCase( scope = backgroundScope, @@ -446,10 +598,20 @@ class AuthViewModelTest { ), ) - vm.executeCreateAccess(password = "correct-password") + vm.submitMigration() val state = vm.uiState.first { it is AuthState.MigrationSummary } assertEquals(2, (state as AuthState.MigrationSummary).skippedItems) assertEquals("", mainPasswordRepository.hash) } + + private companion object { + const val ACCOUNT_PASSWORD = "correct-password" + + const val V1_PASSWORD = "password" + + // Hex of a real bcrypt 2a hash of "password". The use case hex-decodes before verifying. + const val V1_HASH = "2432612431302471776e45776767315a6c5176435a58336450614a7a2e" + + "31494351504a334e6d4a64566b4251686577564655745363646665366d4847" + } } diff --git a/feature/autofill/build.gradle.kts b/feature/autofill/build.gradle.kts index 801c137a3..a50330bfd 100644 --- a/feature/autofill/build.gradle.kts +++ b/feature/autofill/build.gradle.kts @@ -42,6 +42,8 @@ dependencies { implementation(projects.feature.auth) implementation(projects.feature.listScreen) + testImplementation(testFixtures(projects.core.biometrics)) + testImplementation(testFixtures(projects.core.identity)) testImplementation(testFixtures(projects.core.item)) testImplementation(testFixtures(projects.core.util)) testImplementation(testFixtures(projects.core.security)) diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/data/repository/ChromeAutofillRepositoryImpl.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/data/repository/ChromeAutofillRepositoryImpl.kt index 5d0dd2904..d1802ecc9 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/data/repository/ChromeAutofillRepositoryImpl.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/data/repository/ChromeAutofillRepositoryImpl.kt @@ -9,6 +9,7 @@ import android.util.Log import de.davis.keygo.core.security.domain.SystemHandoff import de.davis.keygo.core.security.domain.forRoundTrip import de.davis.keygo.core.util.onFailure +import de.davis.keygo.feature.autofill.domain.model.ChromeAutofillState import de.davis.keygo.feature.autofill.domain.repository.ChromeAutofillRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -45,15 +46,14 @@ internal class ChromeAutofillRepositoryImpl( } } - override suspend fun isAvailable(): Boolean = useQueryThirdPartyMode { true } == true - - override suspend fun isAutofillEnabled(): Boolean = useQueryThirdPartyMode { cursor -> - if (!cursor.moveToFirst()) return@useQueryThirdPartyMode false + override suspend fun autofillState(): ChromeAutofillState = useQueryThirdPartyMode { cursor -> + if (!cursor.moveToFirst()) return@useQueryThirdPartyMode ChromeAutofillState.Disabled val thirdPartyModeState = cursor.getInt(cursor.getColumnIndexOrThrow(THIRD_PARTY_MODE_COLUMN)) - thirdPartyModeState == 1 // 1 means third-party autofill is enabled. - } == true + // 1 means third-party autofill is enabled. + if (thirdPartyModeState == 1) ChromeAutofillState.Enabled else ChromeAutofillState.Disabled + } ?: ChromeAutofillState.Unavailable override fun openChromeAutofillSettings() { val intent = Intent(Intent.ACTION_APPLICATION_PREFERENCES).apply { diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/model/AutofillActivationStatus.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/model/AutofillActivationStatus.kt new file mode 100644 index 000000000..e1de716bc --- /dev/null +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/model/AutofillActivationStatus.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.autofill.domain.model + +data class AutofillActivationStatus( + val systemAutofillEnabled: Boolean = false, + val chromeAvailable: Boolean = false, + val chromeAutofillEnabled: Boolean = false, +) diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/model/ChromeAutofillState.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/model/ChromeAutofillState.kt new file mode 100644 index 000000000..62e455f85 --- /dev/null +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/model/ChromeAutofillState.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.autofill.domain.model + +enum class ChromeAutofillState { + Unavailable, + Disabled, + Enabled, +} diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/repository/ChromeAutofillRepository.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/repository/ChromeAutofillRepository.kt index 20f853bf1..fbca188f1 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/repository/ChromeAutofillRepository.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/repository/ChromeAutofillRepository.kt @@ -1,15 +1,10 @@ package de.davis.keygo.feature.autofill.domain.repository -interface ChromeAutofillRepository { +import de.davis.keygo.feature.autofill.domain.model.ChromeAutofillState - /** - * Whether Chrome is installed and exposes third party autofill mode. Callers that offer to open - * Chrome's settings should check this first: without the provider there is nothing to read and - * nothing for the user to turn on. - */ - suspend fun isAvailable(): Boolean +interface ChromeAutofillRepository { - suspend fun isAutofillEnabled(): Boolean + suspend fun autofillState(): ChromeAutofillState fun openChromeAutofillSettings() } diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/usecase/AutofillActivationStatusUseCase.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/usecase/AutofillActivationStatusUseCase.kt new file mode 100644 index 000000000..9025672c5 --- /dev/null +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/usecase/AutofillActivationStatusUseCase.kt @@ -0,0 +1,24 @@ +package de.davis.keygo.feature.autofill.domain.usecase + +import de.davis.keygo.feature.autofill.domain.model.AutofillActivationStatus +import de.davis.keygo.feature.autofill.domain.model.ChromeAutofillState +import de.davis.keygo.feature.autofill.domain.repository.AutofillServiceRepository +import de.davis.keygo.feature.autofill.domain.repository.ChromeAutofillRepository +import org.koin.core.annotation.Single + +@Single +class AutofillActivationStatusUseCase( + private val autofillServiceRepository: AutofillServiceRepository, + private val chromeAutofillRepository: ChromeAutofillRepository, +) { + + suspend operator fun invoke(): AutofillActivationStatus { + val chrome = chromeAutofillRepository.autofillState() + + return AutofillActivationStatus( + systemAutofillEnabled = autofillServiceRepository.isEnabled(), + chromeAvailable = chrome != ChromeAutofillState.Unavailable, + chromeAutofillEnabled = chrome == ChromeAutofillState.Enabled, + ) + } +} diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt index 7601b76c0..fd74502a6 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt @@ -18,15 +18,10 @@ import androidx.compose.ui.res.stringResource import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.rememberNavBackStack -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.presentation.rememberBiometricCryptoController import de.davis.keygo.core.security.presentation.rememberHandoffLauncher import de.davis.keygo.core.ui.clipboard.setText 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.autofill.presentation.activity.component.AssociationDialog @@ -71,9 +66,6 @@ internal class AutofillActivity : FragmentActivity() { val suspicionDialogVisibility = uiState.suspicionDialogVisibility val linkCheckDialogVisibility = uiState.linkCheckDialogVisibility - val biometricCryptoController = rememberBiometricCryptoController() - val biometricUnlockAdapter = rememberBiometricUnlockAdapter() - val clipboard = LocalClipboard.current val context = LocalContext.current val passwordLabel = stringResource(CoreItemR.string.password) @@ -113,21 +105,6 @@ internal class AutofillActivity : FragmentActivity() { } } - ObserveAsEvents(viewModel.biometricFlow) { request -> - biometricUnlockAdapter.useAdapter { - biometricCryptoController.requestUnlockVault( - policy = BiometricPolicy( - title = request.title, - negativeButton = request.negativeButton - ) - ) - }.onSuccess { - viewModel.onBiometricLoginSucceeded() - }.onFailure { - viewModel.onBiometricLoginFailed(it) - } - } - LaunchedEffect(Unit) { viewModel.start() } diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModel.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModel.kt index 400dbca2f..f31adce93 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModel.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModel.kt @@ -5,7 +5,11 @@ import androidx.core.util.PatternsCompat import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError +import de.davis.keygo.core.biometrics.domain.model.BiometricPolicy +import de.davis.keygo.core.biometrics.domain.model.BiometricString import de.davis.keygo.core.identity.domain.model.UnlockError +import de.davis.keygo.core.identity.domain.usecase.UnlockWithBiometricsUseCase import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.repository.ItemRepository @@ -16,7 +20,6 @@ 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.model.WrappedVaultKeyInformation import de.davis.keygo.core.security.domain.crypto.wrappedItemKeyInformation -import de.davis.keygo.core.security.domain.model.BiometricAuthError import de.davis.keygo.core.util.getOrNull import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.onSuccess @@ -26,7 +29,6 @@ import de.davis.keygo.feature.autofill.domain.usecase.DoesItemHaveDomainReferenc import de.davis.keygo.feature.autofill.domain.usecase.IsAppLinkedToWebsiteUseCase import de.davis.keygo.feature.autofill.presentation.AutofillDatasetProvider import de.davis.keygo.feature.autofill.presentation.activity.model.AssociationDialogVisibility -import de.davis.keygo.feature.autofill.presentation.activity.model.AutofillBiometricRequest import de.davis.keygo.feature.autofill.presentation.activity.model.AutofillEvent import de.davis.keygo.feature.autofill.presentation.activity.model.AutofillUiEvent import de.davis.keygo.feature.autofill.presentation.activity.model.LinkCheckDialogVisibility @@ -69,15 +71,13 @@ internal class AutofillViewModel( private val doesItemHaveDomainReferences: DoesItemHaveDomainReferencesUseCase, private val addRegistrableDomainToLogin: AddRegistrableDomainsToLoginUseCase, private val isAppLinkedToWebsite: IsAppLinkedToWebsiteUseCase, + private val unlockWithBiometrics: UnlockWithBiometricsUseCase, private val totpGenerator: TotpGenerator, ) : ViewModel() { private val requestData = savedStateHandle.get(KEY_AUTOFILL_INFORMATION) ?: throw IllegalArgumentException("Extraction must not be null") - private val biometricChannel = Channel() - val biometricFlow = biometricChannel.receiveAsFlow() - private val eventChannel = Channel() val events = eventChannel.receiveAsFlow() @@ -194,7 +194,16 @@ internal class AutofillViewModel( val itemName = itemRepository.getItemName(suggestionInfo.vaultId) ?: throw IllegalArgumentException("Name for vaultId=${suggestionInfo.vaultId} not found") - biometricChannel.send(AutofillBiometricRequest.UnlockItem(itemName)) + unlockWithBiometrics( + policy = BiometricPolicy( + title = BiometricString.Title.UnlockItem(itemName), + negativeButton = BiometricString.NegativeButton.Password + ) + ).onSuccess { + sendFillEvent(suggestionInfo.vaultId) + }.onFailure { + onBiometricLoginFailed(it) + } } private suspend fun handleSmsOtpRequest(smsOtpInfo: FillRequestData.SmsOtp) { @@ -272,27 +281,16 @@ internal class AutofillViewModel( ) } - fun onBiometricLoginFailed(error: UnlockError) { - viewModelScope.launch { - when (error) { - is UnlockError.BiometricFailed -> { - when (error.error) { - BiometricAuthError.Canceled -> eventChannel.send(AutofillEvent.Abort) - else -> _uiState.update { it.copy(request = Request.JustAuthenticateWithPwd) } - } + private suspend fun onBiometricLoginFailed(error: UnlockError) { + when (error) { + is UnlockError.BiometricFailed -> { + when (error.error) { + BiometricAuthError.Canceled -> eventChannel.send(AutofillEvent.Abort) + else -> _uiState.update { it.copy(request = Request.JustAuthenticateWithPwd) } } - - else -> _uiState.update { it.copy(request = Request.JustAuthenticateWithPwd) } } - } - } - fun onBiometricLoginSucceeded() { - viewModelScope.launch { - if (requestData is FillRequestData.Suggestion) { - sendFillEvent(requestData.vaultId) - return@launch - } + else -> _uiState.update { it.copy(request = Request.JustAuthenticateWithPwd) } } } diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/model/AutofillBiometricRequest.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/model/AutofillBiometricRequest.kt deleted file mode 100644 index ba1689a47..000000000 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/model/AutofillBiometricRequest.kt +++ /dev/null @@ -1,13 +0,0 @@ -package de.davis.keygo.feature.autofill.presentation.activity.model - -import de.davis.keygo.core.security.domain.model.BiometricString - -internal sealed interface AutofillBiometricRequest { - val title: BiometricString.Title - val negativeButton: BiometricString.NegativeButton - - data class UnlockItem(val itemName: String) : AutofillBiometricRequest { - override val title = BiometricString.Title.UnlockItem(itemName) - override val negativeButton = BiometricString.NegativeButton.Password - } -} \ No newline at end of file diff --git a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/AutofillActivationStatusUseCaseTest.kt b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/AutofillActivationStatusUseCaseTest.kt new file mode 100644 index 000000000..06d1be0d7 --- /dev/null +++ b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/AutofillActivationStatusUseCaseTest.kt @@ -0,0 +1,63 @@ +package de.davis.keygo.feature.autofill.domain.usecase + +import de.davis.keygo.core.feature.autofill.FakeAutofillServiceRepository +import de.davis.keygo.core.feature.autofill.FakeChromeAutofillRepository +import de.davis.keygo.feature.autofill.domain.model.AutofillActivationStatus +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class AutofillActivationStatusUseCaseTest { + + private val autofillServiceRepository = FakeAutofillServiceRepository() + private val chromeAutofillRepository = FakeChromeAutofillRepository() + + private val activationStatus = AutofillActivationStatusUseCase( + autofillServiceRepository = autofillServiceRepository, + chromeAutofillRepository = chromeAutofillRepository, + ) + + @Test + fun `a fresh device with Chrome reports nothing turned on yet`() = runTest { + assertEquals( + AutofillActivationStatus( + systemAutofillEnabled = false, + chromeAvailable = true, + chromeAutofillEnabled = false, + ), + activationStatus(), + ) + } + + @Test + fun `reports KeyGo selected as the system autofill service`() = runTest { + autofillServiceRepository.enabled = true + + assertEquals(true, activationStatus().systemAutofillEnabled) + } + + @Test + fun `reports Chrome handing autofill to KeyGo`() = runTest { + chromeAutofillRepository.enabled = true + + val status = activationStatus() + + assertEquals(true, status.chromeAvailable) + assertEquals(true, status.chromeAutofillEnabled) + } + + @Test + fun `a device without Chrome reports Chrome as neither available nor enabled`() = runTest { + chromeAutofillRepository.available = false + autofillServiceRepository.enabled = true + + assertEquals( + AutofillActivationStatus( + systemAutofillEnabled = true, + chromeAvailable = false, + chromeAutofillEnabled = false, + ), + activationStatus(), + ) + } +} diff --git a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt index 813e3fcc0..3537892b6 100644 --- a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt +++ b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt @@ -4,6 +4,9 @@ import android.app.PendingIntent import android.content.Intent import android.content.IntentSender import androidx.lifecycle.SavedStateHandle +import de.davis.keygo.core.biometrics.FakeBiometricCrypto +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError +import de.davis.keygo.core.biometrics.domain.model.BiometricString import de.davis.keygo.core.feature.autofill.FakeAutofillDatasetProvider import de.davis.keygo.core.feature.autofill.FakeDigitalAssetLinkRepository import de.davis.keygo.core.feature.autofill.FakeSignatureInfoProvider @@ -11,6 +14,12 @@ import de.davis.keygo.core.feature.autofill.FakeSmsCodeRepository import de.davis.keygo.core.feature.autofill.FakeTotpGenerator import de.davis.keygo.core.feature.autofill.FakeTotpRepository import de.davis.keygo.core.feature.autofill.autofillId +import de.davis.keygo.core.identity.FakeAccountRepository +import de.davis.keygo.core.identity.domain.mapper.toBiometricWrappedArk +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.usecase.DisableBiometricsUseCase +import de.davis.keygo.core.identity.domain.usecase.UnlockWithBiometricsUseCase import de.davis.keygo.core.item.FakeItemRepository import de.davis.keygo.core.item.FakeLoginRepository import de.davis.keygo.core.item.FakeVaultRepository @@ -23,9 +32,13 @@ import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.domain.model.Totp +import de.davis.keygo.core.security.FakeSession import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.core.security.crypto.FakeKeyStoreManager +import de.davis.keygo.core.security.domain.model.KeyId import de.davis.keygo.core.util.FakeRegistrableDomainResolver import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.assertSuccess import de.davis.keygo.feature.autofill.domain.usecase.AddRegistrableDomainsToLoginUseCase import de.davis.keygo.feature.autofill.domain.usecase.DoesItemHaveDomainReferencesUseCase import de.davis.keygo.feature.autofill.domain.usecase.IsAppLinkedToWebsiteUseCase @@ -63,6 +76,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config +import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -84,6 +98,10 @@ internal class AutofillViewModelTest { private lateinit var dalRepo: FakeDigitalAssetLinkRepository private lateinit var totpGenerator: FakeTotpGenerator private lateinit var smsCodeRepo: FakeSmsCodeRepository + private lateinit var session: FakeSession + private lateinit var accountRepo: FakeAccountRepository + private lateinit var keyStoreManager: FakeKeyStoreManager + private lateinit var biometricCrypto: FakeBiometricCrypto @OptIn(ExperimentalCoroutinesApi::class) @Before @@ -110,6 +128,10 @@ internal class AutofillViewModelTest { dalRepo = FakeDigitalAssetLinkRepository() totpGenerator = FakeTotpGenerator() smsCodeRepo = FakeSmsCodeRepository() + session = FakeSession() + accountRepo = FakeAccountRepository() + keyStoreManager = FakeKeyStoreManager() + biometricCrypto = FakeBiometricCrypto(keyStoreManager) } private fun buildVm(requestData: RequestData): AutofillViewModel { @@ -127,6 +149,12 @@ internal class AutofillViewModelTest { doesItemHaveDomainReferences = DoesItemHaveDomainReferencesUseCase(loginRepo, resolver), addRegistrableDomainToLogin = AddRegistrableDomainsToLoginUseCase(loginRepo, resolver), isAppLinkedToWebsite = IsAppLinkedToWebsiteUseCase(dalRepo, signatureProvider), + unlockWithBiometrics = UnlockWithBiometricsUseCase( + session = session, + accountRepository = accountRepo, + biometricCrypto = biometricCrypto, + disableBiometrics = DisableBiometricsUseCase(accountRepo, keyStoreManager), + ), totpGenerator = totpGenerator, ) } @@ -183,6 +211,37 @@ internal class AutofillViewModelTest { secret = Totp.Secret(EncryptedPayload.EMPTY), ) + private suspend fun seedBiometricAccount() { + accountRepo.seed( + Account( + id = UUID.randomUUID(), + displayName = "Test", + passwordWrappedArk = PasswordWrappedArk( + key = byteArrayOf(1), + keyIV = byteArrayOf(2), + salt = byteArrayOf(3), + ), + biometricWrappedArk = biometricCrypto + .requestWrap(KeyId.BiometricVaultKek) { seal -> + seal(ByteArray(32) { it.toByte() }) + } + .assertSuccess() + .toBiometricWrappedArk(), + ), + ) + biometricCrypto.prompts.clear() + } + + private fun suggestionFor(login: Login) = FillRequestData.Suggestion( + form( + fields = listOf(credField(FieldType.Credentials.Username, viewId = 1)), + url = "https://example.com", + isSuspicious = false, + ), + vaultId = login.id, + index = 0, + ) + private fun matchingDomainInfo(loginId: ItemId? = null) = DomainInfo( loginId = loginId, value = "https://example.com", @@ -510,18 +569,105 @@ internal class AutofillViewModelTest { val theForm = form(fields = fields, url = "https://example.com", isSuspicious = false) val requestData = FillRequestData.Suggestion(theForm, vaultId = login.id, index = 0) val vm = buildVm(requestData) - - val biometricDeferred = async { vm.biometricFlow.first() } vm.start() + assertEquals(Request.JustAuthenticateWithPwd, vm.uiState.value.request) val eventDeferred = async { vm.events.first() } vm.onEvent(AutofillUiEvent.OnAuthenticated) val event = eventDeferred.await() assertIs(event) - biometricDeferred.cancel() } + @Test + fun `a suggestion unlocks with biometrics and fills without asking for the password`() = + runTest { + seedBiometricAccount() + val login = testLogin(username = "carol", name = "Carol's mail") + loginRepo.seed(login) + val vm = buildVm(suggestionFor(login)) + + val eventDeferred = async { vm.events.first() } + vm.start() + val event = eventDeferred.await() + + assertIs(event) + assertTrue(datasetProvider.getFillingDatasetCalls.last().any { it.value == "carol" }) + assertTrue(session.isActive.value) + assertEquals(Request.None, vm.uiState.value.request) + } + + @Test + fun `the suggestion prompt names the item and offers the password as the way out`() = + runTest { + seedBiometricAccount() + biometricCrypto.promptFailure = BiometricAuthError.Declined + val login = testLogin(username = "carol", name = "Carol's mail") + loginRepo.seed(login) + val vm = buildVm(suggestionFor(login)) + + vm.start() + + val policy = biometricCrypto.prompts.single().policy + assertEquals(BiometricString.Title.UnlockItem("Carol's mail"), policy.title) + assertEquals(BiometricString.NegativeButton.Password, policy.negativeButton) + } + + @Test + fun `declining the suggestion prompt asks for the password instead`() = runTest { + seedBiometricAccount() + biometricCrypto.promptFailure = BiometricAuthError.Declined + val login = testLogin(username = "carol") + loginRepo.seed(login) + val vm = buildVm(suggestionFor(login)) + + vm.start() + + assertEquals(Request.JustAuthenticateWithPwd, vm.uiState.value.request) + assertFalse(session.isActive.value) + } + + @Test + fun `a prompt that fails on its own asks for the password instead`() = runTest { + seedBiometricAccount() + biometricCrypto.promptFailure = BiometricAuthError.LockedOut + val login = testLogin(username = "carol") + loginRepo.seed(login) + val vm = buildVm(suggestionFor(login)) + + vm.start() + + assertEquals(Request.JustAuthenticateWithPwd, vm.uiState.value.request) + } + + @Test + fun `canceling the suggestion prompt aborts the fill`() = runTest { + seedBiometricAccount() + biometricCrypto.promptFailure = BiometricAuthError.Canceled + val login = testLogin(username = "carol") + loginRepo.seed(login) + val vm = buildVm(suggestionFor(login)) + + val eventDeferred = async { vm.events.first() } + vm.start() + + assertEquals(AutofillEvent.Abort, eventDeferred.await()) + assertEquals(Request.None, vm.uiState.value.request) + } + + @Test + fun `a suggestion for an account without biometrics asks for the password without prompting`() = + runTest { + val login = testLogin(username = "carol") + loginRepo.seed(login) + val vm = buildVm(suggestionFor(login)) + + vm.start() + + assertEquals(Request.JustAuthenticateWithPwd, vm.uiState.value.request) + assertTrue(biometricCrypto.prompts.isEmpty()) + } + @Test fun `authenticating without suggestion sends Abort`() = runTest { val requestData = FillRequestData.App(form(isSuspicious = false)) diff --git a/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeChromeAutofillRepository.kt b/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeChromeAutofillRepository.kt index 4d8d95ab0..c9f195a6a 100644 --- a/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeChromeAutofillRepository.kt +++ b/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeChromeAutofillRepository.kt @@ -1,20 +1,23 @@ package de.davis.keygo.core.feature.autofill +import de.davis.keygo.feature.autofill.domain.model.ChromeAutofillState import de.davis.keygo.feature.autofill.domain.repository.ChromeAutofillRepository class FakeChromeAutofillRepository : ChromeAutofillRepository { // Chrome is present and exposes third party autofill mode. Flip it to model a device with no - // Chrome, where the enabled read can never come back true. + // Chrome, where the state reads as unavailable whatever enabled says. var available: Boolean = true var enabled: Boolean = false var openCalled: Boolean = false - override suspend fun isAvailable(): Boolean = available - - override suspend fun isAutofillEnabled(): Boolean = available && enabled + override suspend fun autofillState(): ChromeAutofillState = when { + !available -> ChromeAutofillState.Unavailable + enabled -> ChromeAutofillState.Enabled + else -> ChromeAutofillState.Disabled + } override fun openChromeAutofillSettings() { openCalled = true 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 1a7bdd575..5f3c31313 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 @@ -17,8 +17,8 @@ import de.davis.keygo.feature.backup.domain.model.ExportDetails import de.davis.keygo.feature.backup.domain.model.FileFormat import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch -import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlin.test.Test @@ -46,12 +46,14 @@ class BackupProvisioningSerializationTest { // Provisioning parks here after saving B's escrow but before B's record is written. private val gate = CompletableDeferred() + private val provisioningScheduler = FakeBackupScheduler( + jobRepository = jobRepository, + gate = gate, + oneTimeWorkId = "B", + ) + private val finish = FinishExportWizardUseCase( - backupScheduler = FakeBackupScheduler( - jobRepository = jobRepository, - gate = gate, - oneTimeWorkId = "B", - ), + backupScheduler = provisioningScheduler, destinationResolver = FakeBackupDestinationResolver(), keyStoreManager = keyStoreManager, persistableUriManager = uriManager, @@ -94,7 +96,10 @@ class BackupProvisioningSerializationTest { ), ) } - runCurrent() + // Not runCurrent: wrapping the passphrase and escrowing the ARK encrypt on + // Dispatchers.Default, which the test scheduler does not drive, so only the gate itself + // can say provisioning got this far. + provisioningScheduler.parkedAtGate.await() // Concurrent cleanup of the already-finished A. With the lock held it must block and tear // nothing down: B's escrow and both shared aliases must still be intact. @@ -108,10 +113,8 @@ class BackupProvisioningSerializationTest { // Release provisioning: it writes B's live record and drops the lock, then cleanup runs and, // seeing B live, spares the escrow and both aliases. gate.complete(Unit) - advanceUntilIdle() + joinAll(provisioning, cleaning) - assertTrue(provisioning.isCompleted) - assertTrue(cleaning.isCompleted) assertNotNull(arkKeyStore.load()) assertTrue(KeyId.BackupArkKey in keyStoreManager.keys) assertTrue(KeyId.BackupPassphraseKey in keyStoreManager.keys) diff --git a/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt index a1ad5feed..dba072c23 100644 --- a/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt +++ b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt @@ -13,7 +13,8 @@ import kotlinx.coroutines.CompletableDeferred * record - mirroring [de.davis.keygo.feature.backup.data.BackupSchedulerImpl] which calls * `backupJobRepository.putJob(...)` before enqueueing - so a cleanup reading the same repository * can observe the job as "live". Pass [gate] to park inside scheduling *before* that record is - * written, reproducing the TOCTOU window between escrow provisioning and the record write. + * written, reproducing the TOCTOU window between escrow provisioning and the record write. Await + * [parkedAtGate] to know a call has actually reached it. */ class FakeBackupScheduler( private val jobRepository: FakeBackupJobRepository? = null, @@ -30,6 +31,13 @@ class FakeBackupScheduler( private val scheduled = mutableSetOf() private val abandoned = mutableSetOf() + /** + * Completes once a scheduling call is parked at [gate]. The work leading up to it may hop to a + * real dispatcher (cipher work runs on `Dispatchers.Default`), so `runCurrent` alone cannot + * promise the call got this far. + */ + val parkedAtGate = CompletableDeferred() + /** When set, [outstandingWorkIds] throws - the "scheduler unreadable" case. */ var outstandingFailure: Throwable? = null @@ -69,7 +77,10 @@ class FakeBackupScheduler( // Mirror BackupSchedulerImpl: on success the record is written (putJob), on failure it is not, // so a failed schedule leaves no record - exactly the case the URI-grant release compensates. private suspend fun persist(workId: WorkId, job: BackupJob): Result { - gate?.await() + if (gate != null) { + parkedAtGate.complete(Unit) + gate.await() + } if (result is Result.Failure) return result jobRepository?.putJob(workId, job) scheduled += workId 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 index 86c284ca7..eedc97f05 100644 --- 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 @@ -1,7 +1,7 @@ package de.davis.keygo.feature.credentials.presentation.auth +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError 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 @@ -16,12 +16,14 @@ internal fun mapUnlockError(error: UnlockError): UnlockOutcome = when (error) { BiometricAuthError.Canceled, BiometricAuthError.NoCipher -> UnlockOutcome.Abort + BiometricAuthError.NoPromptHost, BiometricAuthError.Declined, BiometricAuthError.LockedOut, BiometricAuthError.CryptoFailed, BiometricAuthError.KeyInvalidated, + BiometricAuthError.BiometricsNotAvailable, is BiometricAuthError.Unknown, - is BiometricAuthError.CanNotAuthenticate -> UnlockOutcome.NeedsPassword + -> UnlockOutcome.NeedsPassword } UnlockError.BiometricEnrollmentReset, 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 f22cfbcee..2a879c5c8 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 @@ -35,17 +35,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack -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.navigation.KeyGoNavDisplay import de.davis.keygo.core.ui.text.htmlStringResource 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.authEntries @@ -162,24 +155,6 @@ internal class CreatePasskeyActivity : FragmentActivity() { ) } - 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 -> { 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 add08a097..161620338 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 @@ -3,18 +3,22 @@ package de.davis.keygo.feature.credentials.presentation.create.activity import android.util.Log 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.biometrics.domain.model.BiometricPolicy +import de.davis.keygo.core.biometrics.domain.model.BiometricString +import de.davis.keygo.core.identity.domain.model.UnlockableByBiometricsResult +import de.davis.keygo.core.identity.domain.usecase.UnlockWithBiometricsUseCase +import de.davis.keygo.core.identity.domain.usecase.UnlockableByBiometricsUseCase 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.repository.PasskeyRepository import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider import de.davis.keygo.core.security.domain.crypto.encrypt -import de.davis.keygo.core.security.domain.repository.BiometricAvailabilityRepository import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.fold import de.davis.keygo.core.util.getOrNull +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 @@ -36,8 +40,8 @@ internal class CreatePasskeyViewModel( private val passkeyRepository: PasskeyRepository, private val cryptographicScopeProvider: CryptographicScopeProvider, private val passkeyManager: PasskeyManager, - private val accountRepository: AccountRepository, - private val biometricAvailabilityRepository: BiometricAvailabilityRepository, + private val unlockableByBiometrics: UnlockableByBiometricsUseCase, + private val unlockWithBiometrics: UnlockWithBiometricsUseCase, ) : ViewModel() { private val _event = Channel(Channel.BUFFERED) @@ -49,9 +53,6 @@ internal class CreatePasskeyViewModel( private val _excluded = MutableStateFlow(false) val excluded = _excluded.asStateFlow() - private val biometricChannel = Channel(Channel.BUFFERED) - val biometricFlow = biometricChannel.receiveAsFlow() - /** * The request's own `rp.id`, which the relying party may leave empty, until registration * replaces it with the id the authenticator resolved. @@ -113,13 +114,23 @@ internal class CreatePasskeyViewModel( } private suspend fun requestUnlock() { - val biometricUsable = biometricAvailabilityRepository.availability() - && accountRepository.getOrNull()?.biometricWrappedArk != null - - if (biometricUsable) { - _authState.update { SessionAuthState.TryBiometric } - biometricChannel.send(Unit) - } else _authState.update { SessionAuthState.NeedsPassword } + val biometricUsable = unlockableByBiometrics() == UnlockableByBiometricsResult.Available + if (!biometricUsable) return _authState.update { SessionAuthState.NeedsPassword } + _authState.update { SessionAuthState.TryBiometric } + + unlockWithBiometrics( + policy = BiometricPolicy( + title = BiometricString.Title.Authenticate, + negativeButton = BiometricString.NegativeButton.Password, + ) + ).onSuccess { + onUnlocked() + }.onFailure { + when (mapUnlockError(it)) { + UnlockOutcome.Abort -> viewModelScope.launch { abort("biometric $it") } + UnlockOutcome.NeedsPassword -> _authState.update { SessionAuthState.NeedsPassword } + } + } } private suspend fun storeAndFinish(response: RegistrationResponse, itemId: ItemId) { @@ -146,13 +157,6 @@ internal class CreatePasskeyViewModel( unlocked.complete(Unit) } - fun onUnlockFailed(error: UnlockError) { - when (mapUnlockError(error)) { - UnlockOutcome.Abort -> viewModelScope.launch { abort("biometric $error") } - UnlockOutcome.NeedsPassword -> _authState.update { SessionAuthState.NeedsPassword } - } - } - /** * Names the login the passkey belongs to. Only the first call counts: a second tap landing * before the dialog recomposes away would otherwise store the credential twice. 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 3492970a8..93b16ba97 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 @@ -15,15 +15,8 @@ import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack -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.navigation.KeyGoNavDisplay 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.authEntries @@ -60,24 +53,6 @@ internal class ProvidePasskeyActivity : FragmentActivity() { } } - 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 -> { 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 bb750cb58..8f79a35c6 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,12 +4,14 @@ 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.biometrics.domain.model.BiometricPolicy +import de.davis.keygo.core.biometrics.domain.model.BiometricString +import de.davis.keygo.core.identity.domain.model.UnlockableByBiometricsResult +import de.davis.keygo.core.identity.domain.usecase.UnlockWithBiometricsUseCase +import de.davis.keygo.core.identity.domain.usecase.UnlockableByBiometricsUseCase import de.davis.keygo.core.item.domain.repository.PasskeyRepository 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.repository.BiometricAvailabilityRepository import de.davis.keygo.core.util.fold import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.onSuccess @@ -22,6 +24,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel @@ -30,8 +33,8 @@ internal class ProvidePasskeyViewModel( private val passkeyRepository: PasskeyRepository, private val cryptographicScopeProvider: CryptographicScopeProvider, private val passkeyManager: PasskeyManager, - private val accountRepository: AccountRepository, - private val biometricAvailabilityRepository: BiometricAvailabilityRepository, + private val unlockableByBiometrics: UnlockableByBiometricsUseCase, + private val unlockWithBiometrics: UnlockWithBiometricsUseCase, ) : ViewModel() { private val _event = Channel(Channel.BUFFERED) @@ -40,9 +43,6 @@ internal class ProvidePasskeyViewModel( private val _authState = MutableStateFlow(SessionAuthState.TryBiometric) val authState = _authState.asStateFlow() - private val biometricChannel = Channel(Channel.BUFFERED) - val biometricFlow = biometricChannel.receiveAsFlow() - private data class PendingRequest( val option: GetPublicKeyCredentialOption, val credentialId: ByteArray, @@ -52,15 +52,23 @@ internal class ProvidePasskeyViewModel( 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 + val biometricUsable = unlockableByBiometrics() == UnlockableByBiometricsResult.Available + if (!biometricUsable) return@launch _authState.update { SessionAuthState.NeedsPassword } + + _authState.update { SessionAuthState.TryBiometric } + unlockWithBiometrics( + policy = BiometricPolicy( + title = BiometricString.Title.Authenticate, + negativeButton = BiometricString.NegativeButton.Password, + ) + ).onSuccess { + onUnlocked() + }.onFailure { + when (mapUnlockError(it)) { + UnlockOutcome.Abort -> viewModelScope.launch { abort("biometric: $it") } + UnlockOutcome.NeedsPassword -> _authState.update { SessionAuthState.NeedsPassword } + } + } } } @@ -69,17 +77,10 @@ internal class ProvidePasskeyViewModel( } fun onUnlocked() { - _authState.value = SessionAuthState.Authenticated + _authState.update { SessionAuthState.Authenticated } runOperation(pendingRequest) } - fun onUnlockFailed(error: UnlockError) { - when (mapUnlockError(error)) { - UnlockOutcome.Abort -> viewModelScope.launch { abort() } - UnlockOutcome.NeedsPassword -> _authState.value = SessionAuthState.NeedsPassword - } - } - private fun runOperation(req: PendingRequest) { viewModelScope.launch { val clientDataHash = req.option.clientDataHash diff --git a/feature/credentials/src/test/kotlin/de/davis/keygo/feature/credentials/presentation/auth/MapUnlockErrorTest.kt b/feature/credentials/src/test/kotlin/de/davis/keygo/feature/credentials/presentation/auth/MapUnlockErrorTest.kt new file mode 100644 index 000000000..3fdbacc88 --- /dev/null +++ b/feature/credentials/src/test/kotlin/de/davis/keygo/feature/credentials/presentation/auth/MapUnlockErrorTest.kt @@ -0,0 +1,65 @@ +package de.davis.keygo.feature.credentials.presentation.auth + +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError +import de.davis.keygo.core.identity.domain.model.UnlockError +import kotlin.test.Test +import kotlin.test.assertEquals + +class MapUnlockErrorTest { + + private fun biometric(error: BiometricAuthError) = + mapUnlockError(UnlockError.BiometricFailed(error)) + + @Test + fun `the user backing out of the prompt gives the request back`() { + assertEquals(UnlockOutcome.Abort, biometric(BiometricAuthError.Canceled)) + } + + @Test + fun `a prompt that authenticated without a cipher gives the request back`() { + assertEquals(UnlockOutcome.Abort, biometric(BiometricAuthError.NoCipher)) + } + + @Test + fun `a prompt with nothing to show on offers the password form`() { + assertEquals(UnlockOutcome.NeedsPassword, biometric(BiometricAuthError.NoPromptHost)) + } + + @Test + fun `asking for the password offers the password form`() { + assertEquals(UnlockOutcome.NeedsPassword, biometric(BiometricAuthError.Declined)) + } + + @Test + fun `biometrics that cannot get the user in offer the password form`() { + listOf( + BiometricAuthError.LockedOut, + BiometricAuthError.CryptoFailed, + BiometricAuthError.KeyInvalidated, + BiometricAuthError.BiometricsNotAvailable, + BiometricAuthError.Unknown(errorCode = 3, errString = "timed out"), + ).forEach { + assertEquals(UnlockOutcome.NeedsPassword, biometric(it), "$it") + } + } + + @Test + fun `an account the password can still open offers the password form`() { + assertEquals( + UnlockOutcome.NeedsPassword, + mapUnlockError(UnlockError.BiometricEnrollmentReset), + ) + assertEquals(UnlockOutcome.NeedsPassword, mapUnlockError(UnlockError.WrappedKeyNotFound)) + } + + @Test + fun `failures a password cannot fix give the request back`() { + listOf( + UnlockError.UnwrappingFailed, + UnlockError.DerivationFailed, + UnlockError.ActiveAccountNotFound, + ).forEach { + assertEquals(UnlockOutcome.Abort, mapUnlockError(it), "$it") + } + } +} diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts index 758b4ec27..8fab926c8 100644 --- a/feature/onboarding/build.gradle.kts +++ b/feature/onboarding/build.gradle.kts @@ -17,4 +17,12 @@ dependencies { implementation(projects.core.identity) implementation(projects.feature.backup) implementation(projects.feature.autofill) + + testImplementation(libs.robolectric) + testImplementation(testFixtures(projects.core.biometrics)) + testImplementation(testFixtures(projects.core.identity)) + testImplementation(testFixtures(projects.core.item)) + testImplementation(testFixtures(projects.core.security)) + testImplementation(testFixtures(projects.rust)) + testImplementation(testFixtures(projects.feature.autofill)) } diff --git a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/EnableAutofillContent.kt b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/EnableAutofillContent.kt index 226f560b9..0c13e434a 100644 --- a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/EnableAutofillContent.kt +++ b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/EnableAutofillContent.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import de.davis.keygo.feature.autofill.domain.model.AutofillActivationStatus import de.davis.keygo.feature.onboarding.R import de.davis.keygo.feature.onboarding.presentation.component.OnboardingScaffold import de.davis.keygo.feature.onboarding.presentation.component.SmallIconContainer @@ -134,7 +135,9 @@ private fun EnableAutofillContentPreview() { MaterialTheme { Surface(modifier = Modifier.fillMaxSize()) { EnableAutofillContent( - state = OnboardingUiState.EnableAutofill(chromeAvailable = true) + state = OnboardingUiState.EnableAutofill( + activationStatus = AutofillActivationStatus(chromeAvailable = true), + ) ) } } @@ -147,8 +150,10 @@ private fun EnableAutofillContentChromePendingPreview() { Surface(modifier = Modifier.fillMaxSize()) { EnableAutofillContent( state = OnboardingUiState.EnableAutofill( - systemAutofillEnabled = true, - chromeAvailable = true, + activationStatus = AutofillActivationStatus( + systemAutofillEnabled = true, + chromeAvailable = true, + ), ) ) } diff --git a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingScreen.kt b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingScreen.kt index 83bce977c..e308ff9dc 100644 --- a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingScreen.kt +++ b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingScreen.kt @@ -55,12 +55,8 @@ import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle -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.rememberBiometricCryptoController import de.davis.keygo.core.security.presentation.rememberHandoffLauncher 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.backup.presentation.import.ImportWizardScreen import de.davis.keygo.feature.backup.presentation.import.rememberImportFilePicker @@ -87,19 +83,6 @@ fun OnboardingScreen(route: OnboardingRoute, onSuccess: () -> Unit) { viewModel.onPreviousStep() } - val biometricCryptoController = rememberBiometricCryptoController() - ObserveAsEvents(viewModel.biometricFlow) { - biometricCryptoController.requestCipher( - keyId = KeyId.BiometricVaultKek, - mode = CryptographicMode.Wrap - ).onSuccess { - viewModel.performCreateAccess(it) - }.onFailure { - Log.e("OnboardingScreen", "Failed to create cipher for biometric access: $it") - viewModel.performCreateAccess() //TODO: maybe show error msg to user - } - } - ObserveAsEvents(viewModel.finishedFlow) { onSuccess() } diff --git a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingViewModel.kt b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingViewModel.kt index 656b8d194..73b75a2d9 100644 --- a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingViewModel.kt +++ b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingViewModel.kt @@ -1,16 +1,18 @@ package de.davis.keygo.feature.onboarding.presentation +import android.util.Log import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import de.davis.keygo.core.biometrics.domain.repository.BiometricAvailabilityRepository import de.davis.keygo.core.identity.domain.usecase.CreateAccessUseCase import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator -import de.davis.keygo.core.security.domain.repository.BiometricAvailabilityRepository import de.davis.keygo.core.ui.model.UiFieldError +import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.onSuccess -import de.davis.keygo.feature.autofill.domain.repository.AutofillServiceRepository import de.davis.keygo.feature.autofill.domain.repository.ChromeAutofillRepository +import de.davis.keygo.feature.autofill.domain.usecase.AutofillActivationStatusUseCase import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri import de.davis.keygo.feature.onboarding.presentation.model.AutofillSetupAction import de.davis.keygo.feature.onboarding.presentation.model.OnboardingStep @@ -36,15 +38,14 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.annotation.InjectedParam import org.koin.core.annotation.KoinViewModel -import javax.crypto.Cipher import kotlin.time.Duration.Companion.milliseconds @KoinViewModel internal class OnboardingViewModel( @InjectedParam private val onboardingRoute: OnboardingRoute, private val biometricAvailabilityRepository: BiometricAvailabilityRepository, - private val autofillServiceRepository: AutofillServiceRepository, private val chromeAutofillRepository: ChromeAutofillRepository, + private val autofillActivationStatus: AutofillActivationStatusUseCase, private val passwordStrengthEstimator: PasswordStrengthEstimator, private val createAccess: CreateAccessUseCase, @@ -56,8 +57,7 @@ internal class OnboardingViewModel( private fun calculateStepsToSkip() { viewModelScope.launch { - val autofill = readAutofillState() - _enableAutofillState.update { autofill } + val autofill = fetchAndUpdateAutofillState() val skipSteps = buildSet { if (!biometricAvailabilityRepository.availability()) add(OnboardingStep.EnableBiometrics) @@ -70,20 +70,17 @@ internal class OnboardingViewModel( } } - private suspend fun readAutofillState(): OnboardingUiState.EnableAutofill { - val chromeAvailable = chromeAutofillRepository.isAvailable() - return OnboardingUiState.EnableAutofill( - systemAutofillEnabled = autofillServiceRepository.isEnabled(), - chromeAvailable = chromeAvailable, - chromeAutofillEnabled = chromeAvailable && chromeAutofillRepository.isAutofillEnabled(), - ) + fun refreshAutofillState() { + viewModelScope.launch { fetchAndUpdateAutofillState() } } - fun refreshAutofillState() { - viewModelScope.launch { - val autofill = readAutofillState() - _enableAutofillState.update { autofill } - } + private suspend fun fetchAndUpdateAutofillState(): OnboardingUiState.EnableAutofill { + // Read before the update, not inside it: update retries its lambda whenever another write + // lands first, which would repeat the cross-process reads. + val autofill = + OnboardingUiState.EnableAutofill(activationStatus = autofillActivationStatus()) + _enableAutofillState.update { autofill } + return autofill } private val passwordTextFieldState = TextFieldState() @@ -109,9 +106,6 @@ internal class OnboardingViewModel( calculateStepsToSkip() } - private val biometricChannel = Channel(Channel.BUFFERED) - val biometricFlow = biometricChannel.receiveAsFlow() - private val autofillPickerChannel = Channel(Channel.BUFFERED) val autofillPickerFlow = autofillPickerChannel.receiveAsFlow() @@ -219,8 +213,8 @@ internal class OnboardingViewModel( } OnboardingStep.EnableBiometrics -> { - biometricChannel.trySend(Unit) - return // wait for biometric result before proceeding to next step + // return because performCreateAccess already skips internally on success + return performCreateAccess(withBiometrics = true) } OnboardingStep.EnableAutofillService -> when (_enableAutofillState.value.nextAction) { @@ -244,19 +238,6 @@ internal class OnboardingViewModel( internalSkip() } - fun performCreateAccess(cipher: Cipher? = null) { - viewModelScope.launch { - loading { - createAccess( - password = passwordTextFieldState.text.toString(), - biometricCipher = cipher - ).onSuccess { - internalSkip() - } - } - } - } - fun onSkip() { if (_step.value == OnboardingStep.EnableBiometrics) return performCreateAccess() @@ -278,6 +259,17 @@ internal class OnboardingViewModel( */ fun onImportFinished() = internalSkip() + private fun performCreateAccess(withBiometrics: Boolean = false) = loading { + createAccess( + password = passwordTextFieldState.text.toString(), + withBiometrics = withBiometrics, + ).onSuccess { + internalSkip() + }.onFailure { + Log.e(TAG, "Failed to create access: $it") + } + } + private fun internalSkip() { val nextStep = _step.value.nextStep(stepsToSkip.value) ?: return finishUp() _step.update { nextStep } @@ -287,12 +279,22 @@ internal class OnboardingViewModel( finishedChannel.trySend(Unit) } - private suspend fun loading(block: suspend () -> R): R { - _loading.update { true } - try { - return block() - } finally { - _loading.update { false } + private fun loading(block: suspend () -> Unit) { + // One run at a time: a second tap that lands while one is still going finds the flag set + // and is dropped. Two account creations would mint two accounts and the second would + // overwrite the first. + if (!_loading.compareAndSet(expect = false, update = true)) return + + viewModelScope.launch { + try { + block() + } finally { + _loading.update { false } + } } } + + companion object { + private const val TAG = "OnboardingViewModel" + } } diff --git a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/model/AutofillSetup.kt b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/model/AutofillSetup.kt index 8c768a0b8..98af24a10 100644 --- a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/model/AutofillSetup.kt +++ b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/model/AutofillSetup.kt @@ -27,9 +27,9 @@ internal enum class AutofillSetupStatus { */ internal fun OnboardingUiState.EnableAutofill.setupSteps(): List> { val steps = listOfNotNull( - AutofillSetupStep.OpenSystemSettings to systemAutofillEnabled, - AutofillSetupStep.ChooseKeyGo to systemAutofillEnabled, - (AutofillSetupStep.EnableInChrome to chromeAutofillEnabled).takeIf { chromeAvailable }, + AutofillSetupStep.OpenSystemSettings to activationStatus.systemAutofillEnabled, + AutofillSetupStep.ChooseKeyGo to activationStatus.systemAutofillEnabled, + (AutofillSetupStep.EnableInChrome to activationStatus.chromeAutofillEnabled).takeIf { activationStatus.chromeAvailable }, ) val currentIndex = steps.indexOfFirst { (_, done) -> !done } diff --git a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/model/OnboardingUiState.kt b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/model/OnboardingUiState.kt index c551a33ff..f4d1ac3a3 100644 --- a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/model/OnboardingUiState.kt +++ b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/model/OnboardingUiState.kt @@ -3,6 +3,7 @@ package de.davis.keygo.feature.onboarding.presentation.model import androidx.compose.foundation.text.input.TextFieldState import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.ui.model.UiFieldError +import de.davis.keygo.feature.autofill.domain.model.AutofillActivationStatus import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri internal sealed interface OnboardingUiState { @@ -26,21 +27,13 @@ internal sealed interface OnboardingUiState { data class ImportData(val fileUri: BackupDestinationUri? = null) : OnboardingUiState data class EnableAutofill( - val systemAutofillEnabled: Boolean = false, - val chromeAvailable: Boolean = false, - val chromeAutofillEnabled: Boolean = false, + val activationStatus: AutofillActivationStatus = AutofillActivationStatus(), ) : OnboardingUiState { - /** - * Single source of truth for the primary button: the ViewModel reads it to decide what to - * do, the screen reads it to decide what to say. Driven by state rather than a counter, so - * a device that already has Chrome on but KeyGo unselected still starts at the picker and - * then goes straight to done. - */ val nextAction: AutofillSetupAction get() = when { - !systemAutofillEnabled -> AutofillSetupAction.OpenSystemSettings - chromeAvailable && !chromeAutofillEnabled -> AutofillSetupAction.OpenChromeSettings + !activationStatus.systemAutofillEnabled -> AutofillSetupAction.OpenSystemSettings + activationStatus.chromeAvailable && !activationStatus.chromeAutofillEnabled -> AutofillSetupAction.OpenChromeSettings else -> AutofillSetupAction.Finish } } diff --git a/feature/onboarding/src/test/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingViewModelTest.kt b/feature/onboarding/src/test/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingViewModelTest.kt new file mode 100644 index 000000000..152d0c95d --- /dev/null +++ b/feature/onboarding/src/test/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingViewModelTest.kt @@ -0,0 +1,214 @@ +package de.davis.keygo.feature.onboarding.presentation + +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import de.davis.keygo.core.biometrics.FakeBiometricAvailabilityRepository +import de.davis.keygo.core.biometrics.FakeBiometricCrypto +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError +import de.davis.keygo.core.feature.autofill.FakeAutofillServiceRepository +import de.davis.keygo.core.feature.autofill.FakeChromeAutofillRepository +import de.davis.keygo.core.identity.FakeAccountRepository +import de.davis.keygo.core.identity.domain.usecase.CreateAccessUseCase +import de.davis.keygo.core.identity.domain.usecase.EnableBiometricsUseCase +import de.davis.keygo.core.item.FakeVaultContextRepository +import de.davis.keygo.core.item.FakeVaultRepository +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.domain.model.CryptographicMode +import de.davis.keygo.feature.autofill.domain.usecase.AutofillActivationStatusUseCase +import de.davis.keygo.feature.onboarding.presentation.model.OnboardingUiState +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@OptIn(ExperimentalCoroutinesApi::class) +class OnboardingViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private val accountRepository = FakeAccountRepository() + private val session = FakeSession() + private val biometricCrypto = FakeBiometricCrypto() + private val autofillServiceRepository = FakeAutofillServiceRepository() + private val chromeAutofillRepository = FakeChromeAutofillRepository() + + private val biometricAvailability = FakeBiometricAvailabilityRepository().apply { + isAvailable = true + } + + @BeforeTest + fun setUp() = Dispatchers.setMain(dispatcher) + + @AfterTest + fun tearDown() = Dispatchers.resetMain() + + private fun TestScope.viewModel() = OnboardingViewModel( + onboardingRoute = OnboardingRoute(), + biometricAvailabilityRepository = biometricAvailability, + chromeAutofillRepository = chromeAutofillRepository, + autofillActivationStatus = AutofillActivationStatusUseCase( + autofillServiceRepository = autofillServiceRepository, + chromeAutofillRepository = chromeAutofillRepository, + ), + passwordStrengthEstimator = object : PasswordStrengthEstimator { + override suspend fun estimate(password: String): PasswordScore = PasswordScore.None + }, + createAccess = CreateAccessUseCase( + accountRepository = accountRepository, + vaultRepository = FakeVaultRepository(), + vaultContextRepository = FakeVaultContextRepository(), + enableBiometrics = EnableBiometricsUseCase( + accountRepository = accountRepository, + session = session, + keyStoreManager = biometricCrypto.keyStoreManager, + biometricCrypto = biometricCrypto, + ), + session = session, + ), + ).also { + it.state.launchIn(backgroundScope) + runCurrent() + } + + private suspend fun TestScope.enterMainPassword(vm: OnboardingViewModel) { + vm.onNextStep() + runCurrent() + val form = vm.state.first { it is OnboardingUiState.SetMainPassword } + as OnboardingUiState.SetMainPassword + form.passwordTextFieldState.setTextAndPlaceCursorAtEnd(PASSWORD) + form.confirmPasswordTextFieldState.setTextAndPlaceCursorAtEnd(PASSWORD) + } + + private suspend fun awaitStepAfterPasswordForm(vm: OnboardingViewModel): OnboardingUiState = + vm.state.first { it !is OnboardingUiState.SetMainPassword } + + private suspend fun TestScope.reachBiometricsStep(vm: OnboardingViewModel) { + enterMainPassword(vm) + vm.onNextStep() + assertEquals(OnboardingUiState.EnableBiometrics, awaitStepAfterPasswordForm(vm)) + } + + @Test + fun `enabling biometrics creates an account enrolled for them and moves on`() = + runTest(dispatcher) { + val vm = viewModel() + reachBiometricsStep(vm) + + vm.onNextStep() + advanceUntilIdle() + + assertIs(vm.state.value) + assertNotNull(accountRepository.getOrNull()?.biometricWrappedArk) + assertEquals(CryptographicMode.Wrap, biometricCrypto.prompts.single().mode) + assertTrue(session.isActive.value) + } + + @Test + fun `skipping biometrics creates a password-only account without prompting`() = + runTest(dispatcher) { + val vm = viewModel() + reachBiometricsStep(vm) + + vm.onSkip() + advanceUntilIdle() + + assertIs(vm.state.value) + val account = assertNotNull(accountRepository.getOrNull()) + assertNull(account.biometricWrappedArk) + assertTrue(biometricCrypto.prompts.isEmpty()) + } + + /** + * Biometrics are optional on top of the password the user just chose. Before, a cancelled prompt + * threw the finished key derivation away and left the user on this step with no feedback. + */ + @Test + fun `a failed prompt still creates a password-only account and moves on`() = + runTest(dispatcher) { + biometricCrypto.promptFailure = BiometricAuthError.Declined + val vm = viewModel() + reachBiometricsStep(vm) + + vm.onNextStep() + advanceUntilIdle() + + assertIs(vm.state.value) + assertNull(assertNotNull(accountRepository.getOrNull()).biometricWrappedArk) + assertFalse(vm.loading.value) + assertTrue(session.isActive.value) + } + + @Test + fun `a second tap while the account is being created is dropped`() = runTest(dispatcher) { + val vm = viewModel() + reachBiometricsStep(vm) + + vm.onSkip() + vm.onSkip() + advanceUntilIdle() + + assertEquals(1, accountRepository.setCount) + } + + @Test + fun `the biometrics step reads as loading while the prompt is open`() = runTest(dispatcher) { + val prompt = CompletableDeferred() + biometricCrypto.pendingPrompt = prompt + val vm = viewModel() + reachBiometricsStep(vm) + + vm.onNextStep() + runCurrent() + assertTrue(vm.loading.value) + assertEquals(OnboardingUiState.EnableBiometrics, vm.state.value) + + prompt.complete(Unit) + advanceUntilIdle() + + assertFalse(vm.loading.value) + assertIs(vm.state.value) + } + + @Test + fun `without usable biometrics the account is created straight from the password step`() = + runTest(dispatcher) { + biometricAvailability.isAvailable = false + val vm = viewModel() + enterMainPassword(vm) + + vm.onNextStep() + advanceUntilIdle() + + val account = assertNotNull(accountRepository.getOrNull()) + assertIs(awaitStepAfterPasswordForm(vm)) + assertNull(account.biometricWrappedArk) + assertTrue(biometricCrypto.prompts.isEmpty()) + } + + private companion object { + const val PASSWORD = "correct horse battery staple" + } +} diff --git a/feature/onboarding/src/test/kotlin/de/davis/keygo/feature/onboarding/presentation/model/AutofillSetupTest.kt b/feature/onboarding/src/test/kotlin/de/davis/keygo/feature/onboarding/presentation/model/AutofillSetupTest.kt index aa6020efe..e7c2022d1 100644 --- a/feature/onboarding/src/test/kotlin/de/davis/keygo/feature/onboarding/presentation/model/AutofillSetupTest.kt +++ b/feature/onboarding/src/test/kotlin/de/davis/keygo/feature/onboarding/presentation/model/AutofillSetupTest.kt @@ -1,5 +1,6 @@ package de.davis.keygo.feature.onboarding.presentation.model +import de.davis.keygo.feature.autofill.domain.model.AutofillActivationStatus import kotlin.test.Test import kotlin.test.assertEquals @@ -8,9 +9,11 @@ class AutofillSetupTest { @Test fun `next action opens system settings while KeyGo is not the autofill service`() { val state = OnboardingUiState.EnableAutofill( - systemAutofillEnabled = false, - chromeAvailable = true, - chromeAutofillEnabled = false, + activationStatus = AutofillActivationStatus( + systemAutofillEnabled = false, + chromeAvailable = true, + chromeAutofillEnabled = false, + ), ) assertEquals(AutofillSetupAction.OpenSystemSettings, state.nextAction) @@ -19,9 +22,11 @@ class AutofillSetupTest { @Test fun `next action opens system settings even when chrome is already on`() { val state = OnboardingUiState.EnableAutofill( - systemAutofillEnabled = false, - chromeAvailable = true, - chromeAutofillEnabled = true, + activationStatus = AutofillActivationStatus( + systemAutofillEnabled = false, + chromeAvailable = true, + chromeAutofillEnabled = true, + ), ) assertEquals(AutofillSetupAction.OpenSystemSettings, state.nextAction) @@ -30,9 +35,11 @@ class AutofillSetupTest { @Test fun `next action opens chrome settings once the system service is set`() { val state = OnboardingUiState.EnableAutofill( - systemAutofillEnabled = true, - chromeAvailable = true, - chromeAutofillEnabled = false, + activationStatus = AutofillActivationStatus( + systemAutofillEnabled = true, + chromeAvailable = true, + chromeAutofillEnabled = false, + ), ) assertEquals(AutofillSetupAction.OpenChromeSettings, state.nextAction) @@ -41,9 +48,11 @@ class AutofillSetupTest { @Test fun `next action finishes when both are enabled`() { val state = OnboardingUiState.EnableAutofill( - systemAutofillEnabled = true, - chromeAvailable = true, - chromeAutofillEnabled = true, + activationStatus = AutofillActivationStatus( + systemAutofillEnabled = true, + chromeAvailable = true, + chromeAutofillEnabled = true, + ), ) assertEquals(AutofillSetupAction.Finish, state.nextAction) @@ -52,9 +61,11 @@ class AutofillSetupTest { @Test fun `next action finishes when the system service is set and chrome is unavailable`() { val state = OnboardingUiState.EnableAutofill( - systemAutofillEnabled = true, - chromeAvailable = false, - chromeAutofillEnabled = false, + activationStatus = AutofillActivationStatus( + systemAutofillEnabled = true, + chromeAvailable = false, + chromeAutofillEnabled = false, + ), ) assertEquals(AutofillSetupAction.Finish, state.nextAction) @@ -63,9 +74,11 @@ class AutofillSetupTest { @Test fun `setup steps start with the first row current and the rest upcoming`() { val state = OnboardingUiState.EnableAutofill( - systemAutofillEnabled = false, - chromeAvailable = true, - chromeAutofillEnabled = false, + activationStatus = AutofillActivationStatus( + systemAutofillEnabled = false, + chromeAvailable = true, + chromeAutofillEnabled = false, + ), ) assertEquals( @@ -81,9 +94,11 @@ class AutofillSetupTest { @Test fun `setup steps mark both system rows done together and chrome current`() { val state = OnboardingUiState.EnableAutofill( - systemAutofillEnabled = true, - chromeAvailable = true, - chromeAutofillEnabled = false, + activationStatus = AutofillActivationStatus( + systemAutofillEnabled = true, + chromeAvailable = true, + chromeAutofillEnabled = false, + ), ) assertEquals( @@ -99,9 +114,11 @@ class AutofillSetupTest { @Test fun `setup steps omit the chrome row when chrome is unavailable`() { val state = OnboardingUiState.EnableAutofill( - systemAutofillEnabled = false, - chromeAvailable = false, - chromeAutofillEnabled = false, + activationStatus = AutofillActivationStatus( + systemAutofillEnabled = false, + chromeAvailable = false, + chromeAutofillEnabled = false, + ), ) assertEquals( @@ -116,9 +133,11 @@ class AutofillSetupTest { @Test fun `setup steps mark every row done once both are enabled`() { val state = OnboardingUiState.EnableAutofill( - systemAutofillEnabled = true, - chromeAvailable = true, - chromeAutofillEnabled = true, + activationStatus = AutofillActivationStatus( + systemAutofillEnabled = true, + chromeAvailable = true, + chromeAutofillEnabled = true, + ), ) assertEquals( @@ -134,9 +153,11 @@ class AutofillSetupTest { @Test fun `setup steps show chrome already done while the system rows are still pending`() { val state = OnboardingUiState.EnableAutofill( - systemAutofillEnabled = false, - chromeAvailable = true, - chromeAutofillEnabled = true, + activationStatus = AutofillActivationStatus( + systemAutofillEnabled = false, + chromeAvailable = true, + chromeAutofillEnabled = true, + ), ) assertEquals( @@ -152,9 +173,11 @@ class AutofillSetupTest { @Test fun `setup steps have no current row when only the chrome row is missing`() { val state = OnboardingUiState.EnableAutofill( - systemAutofillEnabled = true, - chromeAvailable = false, - chromeAutofillEnabled = false, + activationStatus = AutofillActivationStatus( + systemAutofillEnabled = true, + chromeAvailable = false, + chromeAutofillEnabled = false, + ), ) assertEquals( diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 5602ae39b..f48db9797 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -22,8 +22,10 @@ dependencies { implementation(projects.feature.autofill) implementation(projects.feature.backup) + testImplementation(testFixtures(projects.core.biometrics)) testImplementation(testFixtures(projects.core.identity)) testImplementation(testFixtures(projects.core.security)) + testImplementation(testFixtures(projects.core.util)) testImplementation(testFixtures(projects.rust)) testImplementation(testFixtures(projects.feature.autofill)) testImplementation(testFixtures(projects.feature.backup)) diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt index a941e8d4c..07e6f7acf 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt @@ -72,6 +72,7 @@ internal fun SettingsContent( supporting = ResourceString(R.string.settings_use_biometrics_description), colors = defaultColors, checked = state.biometricsEnabled, + enabled = !state.biometricsUpdating, onCheckedChange = { onEvent(SettingsUiEvent.SetBiometrics(it)) }, ) diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt index 6f4ae48eb..97012c1bc 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt @@ -5,7 +5,6 @@ internal sealed interface SettingsEvent { data object NavigateToLibraries : SettingsEvent data object NavigateToChangePassword : SettingsEvent data object OpenAutofillSelection : SettingsEvent - data class EnableBiometric(val enable: Boolean) : SettingsEvent data object ReportIssue : SettingsEvent data object NavigateToBackup : SettingsEvent diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt index d754035b6..c289487f6 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt @@ -11,18 +11,9 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.core.net.toUri import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle -import de.davis.keygo.core.identity.domain.model.BiometricEnrollmentError -import de.davis.keygo.core.identity.presentation.rememberBiometricEnrollmentAdapter -import de.davis.keygo.core.identity.presentation.useEnrollmentAdapter -import de.davis.keygo.core.security.domain.model.BiometricAuthError -import de.davis.keygo.core.security.presentation.rememberBiometricCryptoController import de.davis.keygo.core.security.presentation.rememberHandoffLauncher -import de.davis.keygo.core.util.domain.model.snackbar.SnackbarMessage import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.presentation.ObserveAsEvents -import de.davis.keygo.core.util.presentation.UIText.Companion.ResourceString -import de.davis.keygo.core.util.presentation.snackbar.LocalSnackbarManager -import de.davis.keygo.feature.settings.R import org.koin.androidx.compose.koinViewModel private const val TAG = "SettingsScreen" @@ -36,9 +27,6 @@ fun SettingsScreen( val viewModel = koinViewModel() val state by viewModel.state.collectAsStateWithLifecycle() - val biometricController = rememberBiometricCryptoController() - val enrollmentAdapter = rememberBiometricEnrollmentAdapter() - val enableAutofillLauncher = rememberHandoffLauncher(ActivityResultContracts.StartActivityForResult()) {} @@ -51,7 +39,6 @@ fun SettingsScreen( val urlHandler = LocalUriHandler.current val context = LocalContext.current - val snackbarManager = LocalSnackbarManager.current ObserveAsEvents(viewModel.event) { when (it) { SettingsEvent.NavigateToLibraries -> showLibraries() @@ -68,24 +55,6 @@ fun SettingsScreen( } } - is SettingsEvent.EnableBiometric -> { - val result = when { - it.enable -> enrollmentAdapter.useEnrollmentAdapter { - biometricController.requestEnableBiometric() - } - - else -> enrollmentAdapter.disableBiometric() - } - - result.onFailure { error -> - if (!error.isUserDismissal()) snackbarManager.sendMessage( - SnackbarMessage( - message = ResourceString(R.string.settings_biometric_update_failed), - ), - ) - } - } - SettingsEvent.ReportIssue -> urlHandler.openUri(ISSUES_URL) SettingsEvent.NavigateToBackup -> onOpenBackup() @@ -98,9 +67,4 @@ fun SettingsScreen( ) } -/** The user backing out of the prompt is not an error worth a snackbar. */ -private fun BiometricEnrollmentError.isUserDismissal(): Boolean = - this is BiometricEnrollmentError.BiometricFailed && - (error == BiometricAuthError.Declined || error == BiometricAuthError.Canceled) - -private const val ISSUES_URL = "https://github.com/OffRange/KeyGo/issues/new" \ No newline at end of file +private const val ISSUES_URL = "https://github.com/OffRange/KeyGo/issues/new" diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt index ec73c5965..f80dfc76e 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt @@ -7,6 +7,8 @@ internal data class SettingsUiState( val chromeAutofillEnabled: Boolean = false, val biometricsAvailable: Boolean = false, val biometricsEnabled: Boolean = false, + /** An enrollment or removal is running, so the toggle takes no further input until it ends. */ + val biometricsUpdating: Boolean = false, val version: String = "2.0.0", /** When the newest successful backup finished, or `null` while none has. */ val lastBackupAt: Long? = null, diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt index d2bff1d7d..467cd7b40 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt @@ -2,13 +2,23 @@ package de.davis.keygo.feature.settings.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import de.davis.keygo.core.biometrics.domain.repository.BiometricAvailabilityRepository +import de.davis.keygo.core.identity.domain.model.isUserDismissal import de.davis.keygo.core.identity.domain.repository.AccountRepository -import de.davis.keygo.core.security.domain.repository.BiometricAvailabilityRepository +import de.davis.keygo.core.identity.domain.usecase.DisableBiometricsUseCase +import de.davis.keygo.core.identity.domain.usecase.EnableBiometricsUseCase import de.davis.keygo.core.security.domain.repository.LockInfoRepository import de.davis.keygo.core.util.combine +import de.davis.keygo.core.util.domain.model.snackbar.SnackbarMessage +import de.davis.keygo.core.util.domain.snackbar.SnackbarManager +import de.davis.keygo.core.util.onFailure +import de.davis.keygo.core.util.presentation.UIText.Companion.ResourceString +import de.davis.keygo.feature.autofill.domain.model.AutofillActivationStatus import de.davis.keygo.feature.autofill.domain.repository.AutofillServiceRepository import de.davis.keygo.feature.autofill.domain.repository.ChromeAutofillRepository +import de.davis.keygo.feature.autofill.domain.usecase.AutofillActivationStatusUseCase import de.davis.keygo.feature.backup.domain.usecase.ObserveLastBackupUseCase +import de.davis.keygo.feature.settings.R import de.davis.keygo.feature.settings.domain.repository.AppVersionRepository import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -24,7 +34,11 @@ internal class SettingsViewModel( private val biometricAvailabilityRepository: BiometricAvailabilityRepository, private val autofillServiceRepository: AutofillServiceRepository, private val chromeAutofillRepository: ChromeAutofillRepository, + private val autofillActivationStatus: AutofillActivationStatusUseCase, private val lockInfoRepository: LockInfoRepository, + private val enableBiometrics: EnableBiometricsUseCase, + private val disableBiometrics: DisableBiometricsUseCase, + private val snackbarManager: SnackbarManager, accountRepository: AccountRepository, appVersionRepository: AppVersionRepository, observeLastBackup: ObserveLastBackupUseCase, @@ -32,9 +46,6 @@ internal class SettingsViewModel( private val versionName = appVersionRepository.versionName - // Buffered (not rendezvous): the screen handles events in a suspend collector (e.g. while the - // biometric enrollment prompt is open), and a rendezvous trySend would silently drop any tap - // made in the meantime. private val _event = Channel(Channel.BUFFERED) val event = _event.receiveAsFlow() @@ -42,22 +53,24 @@ internal class SettingsViewModel( // resume via refreshSystemState(). Autofill also gets an optimistic write on in-app disable // (see onEvent), since that action doesn't trigger a resume. private val biometricsAvailable = MutableStateFlow(false) - private val autofillEnabled = MutableStateFlow(false) - private val chromeAutofillEnabled = MutableStateFlow(false) + private val autofillStatus = MutableStateFlow(AutofillActivationStatus()) + + private val biometricsUpdating = MutableStateFlow(false) val state = combine( accountRepository.observe(), lockInfoRepository.observeLockInfo(), - autofillEnabled, - chromeAutofillEnabled, + autofillStatus, biometricsAvailable, + biometricsUpdating, observeLastBackup(), - ) { account, lockInfo, autofill, chromeAutofill, biometrics, lastBackup -> + ) { account, lockInfo, autofill, biometrics, updatingBiometrics, lastBackup -> SettingsUiState( - autofillEnabled = autofill, - chromeAutofillEnabled = chromeAutofill, + autofillEnabled = autofill.systemAutofillEnabled, + chromeAutofillEnabled = autofill.chromeAutofillEnabled, biometricsAvailable = biometrics, biometricsEnabled = biometrics && account?.biometricWrappedArk != null, + biometricsUpdating = updatingBiometrics, version = versionName, lastBackupAt = lastBackup?.finishedAt, lockTimeout = lockInfo.autoLockTimeout, @@ -72,15 +85,26 @@ internal class SettingsViewModel( biometricsAvailable.update { biometricAvailabilityRepository.availability() } // Re-read on resume: the autofill selection changes in the system picker/settings, which // run in a separate activity, so this is where we learn KeyGo was enabled or disabled. - autofillEnabled.update { autofillServiceRepository.isEnabled() } viewModelScope.launch { - chromeAutofillEnabled.update { chromeAutofillRepository.isAutofillEnabled() } + val status = autofillActivationStatus() + autofillStatus.update { status } } } fun onEvent(event: SettingsUiEvent) { when (event) { - is SettingsUiEvent.SetBiometrics -> _event.trySend(SettingsEvent.EnableBiometric(event.enabled)) + is SettingsUiEvent.SetBiometrics -> updatingBiometrics { + when { + event.enabled -> enableBiometrics() + else -> disableBiometrics() + }.onFailure { error -> + if (error.isUserDismissal()) return@onFailure + + snackbarManager.sendMessage( + SnackbarMessage(message = ResourceString(R.string.settings_biometric_update_failed)) + ) + } + } is SettingsUiEvent.SetAutoLockTimeout -> viewModelScope.launch { lockInfoRepository.setAutoLockTimeout(event.timeout) @@ -93,7 +117,7 @@ internal class SettingsViewModel( // disable() propagates through the system server asynchronously and this action // doesn't trigger a resume, so reflect the intent immediately; the next resume // re-read confirms it. - autofillEnabled.update { false } + autofillStatus.update { it.copy(systemAutofillEnabled = false) } } } @@ -107,4 +131,18 @@ internal class SettingsViewModel( SettingsUiEvent.ReportIssue -> _event.trySend(SettingsEvent.ReportIssue) } } + + private fun updatingBiometrics(block: suspend () -> Unit) { + // One update at a time: a toggle that lands while the prompt is still open would otherwise + // run against an account the first update has not written yet. + if (!biometricsUpdating.compareAndSet(expect = false, update = true)) return + + viewModelScope.launch { + try { + block() + } finally { + biometricsUpdating.update { false } + } + } + } } diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordScreen.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordScreen.kt index 6cd146b81..1b5332a6b 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordScreen.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordScreen.kt @@ -31,7 +31,6 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -44,11 +43,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.davis.keygo.core.item.presentation.StrengthIndicator -import de.davis.keygo.core.security.domain.model.BiometricPolicy -import de.davis.keygo.core.security.domain.model.BiometricString -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.rememberBiometricCryptoController import de.davis.keygo.core.ui.components.VisibilityButton import de.davis.keygo.core.ui.model.UiFieldError import de.davis.keygo.core.ui.model.error @@ -57,7 +51,6 @@ import de.davis.keygo.core.util.presentation.ObserveAsEvents import de.davis.keygo.core.util.presentation.UIText.Companion.ResourceString import de.davis.keygo.core.util.presentation.snackbar.LocalSnackbarManager import de.davis.keygo.feature.settings.R -import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel @Composable @@ -65,8 +58,6 @@ internal fun ChangePasswordScreen(onUp: () -> Unit) { val viewModel = koinViewModel() val state by viewModel.state.collectAsStateWithLifecycle() - val controller = rememberBiometricCryptoController() - val scope = rememberCoroutineScope() val snackbarManager = LocalSnackbarManager.current ObserveAsEvents(viewModel.event) { event -> @@ -75,20 +66,6 @@ internal fun ChangePasswordScreen(onUp: () -> Unit) { ChangePasswordEvent.GenericError -> snackbarManager.sendMessage( SnackbarMessage(message = ResourceString(R.string.change_password_failed)), ) - - ChangePasswordEvent.LaunchBiometricPrompt -> { - val ciphertext = state.biometricCiphertext ?: return@ObserveAsEvents - scope.launch { - val result = controller.requestUnwrap( - keyId = KeyId.BiometricVaultKek, - ciphertextData = ciphertext, - policy = BiometricPolicy( - negativeButton = BiometricString.NegativeButton.Password, - ), - ) - viewModel.onBiometricResult(result) - } - } } } @@ -96,7 +73,7 @@ internal fun ChangePasswordScreen(onUp: () -> Unit) { state = state, onUp = onUp, onSubmit = viewModel::onSubmit, - onSubmitWithPassword = viewModel::submitWithPassword, + onSubmitWithPassword = { viewModel.onSubmit(forcePasswordPath = true) }, onDismissReauthDialog = viewModel::dismissReauthDialog, ) } @@ -141,7 +118,7 @@ internal fun ChangePasswordContent( .nestedScroll(scrollBehavior.nestedScrollConnection), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - if (!state.canUseBiometric) CurrentPasswordField( + if (!state.biometricAvailable) CurrentPasswordField( state = state.currentPassword, error = state.currentPasswordError, ) @@ -201,14 +178,14 @@ internal fun ChangePasswordContent( modifier = Modifier.fillMaxWidth(), enabled = !state.loading, ) { - if (state.canUseBiometric) { + if (state.biometricAvailable) { Icon(Icons.Default.Fingerprint, contentDescription = null) Spacer(Modifier.width(ButtonDefaults.IconSpacing)) } Text(stringResource(R.string.change_password_action)) } - if (state.canUseBiometric) Text( + if (state.biometricAvailable) Text( text = stringResource(R.string.biometric_confirm_helper), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -275,11 +252,9 @@ private fun CurrentPasswordField( private class ChangePasswordStateProvider : PreviewParameterProvider { - private val previewBiometricCiphertext = CiphertextData(bytes = ByteArray(0), iv = ByteArray(0)) - override val values = sequenceOf( ChangePasswordState(), - ChangePasswordState(biometricCiphertext = previewBiometricCiphertext), + ChangePasswordState(biometricAvailable = true), ChangePasswordState( currentPasswordError = UiFieldError.Incorrect, newPasswordError = UiFieldError.Empty, @@ -287,11 +262,11 @@ private class ChangePasswordStateProvider : PreviewParameterProvider) { - when (result) { - is Result.Success -> { - // requestUnwrap returns a software SecretKeySpec (AES), so raw bytes always exist. - val recoveredArk = checkNotNull(result.success.encoded) - submitWithBiometric(recoveredArk) - } - - is Result.Failure -> when (result.error) { - BiometricAuthError.Declined, - BiometricAuthError.LockedOut, - BiometricAuthError.NoCipher, - BiometricAuthError.CryptoFailed, - BiometricAuthError.KeyInvalidated, - is BiometricAuthError.CanNotAuthenticate, - -> _state.update { it.copy(showReauthDialog = true) } - - // Transient dismissal (back press, system cancel, timeout): leave the form as-is. - BiometricAuthError.Canceled, - is BiometricAuthError.Unknown, - -> Unit - } - } - } - private fun validateNewPasswords(): Boolean { val new = _state.value.newPassword.text.toString() val confirm = _state.value.confirmPassword.text.toString() @@ -187,10 +111,21 @@ internal class ChangePasswordViewModel( return true } - private fun change(reauth: Reauthentication) { + private fun change(withPassword: Boolean = false) { + if (!validateNewPasswords()) return + + val current = _state.value.currentPassword.text.toString() + if (withPassword && current.isBlank()) { + _state.update { it.copy(currentPasswordError = UiFieldError.Empty) } + return + } + + val reauthentication = if (withPassword) Reauthentication.Password(current) + else Reauthentication.Biometric + _state.update { it.copy(loading = true) } viewModelScope.launch { - changePassword(reauth, _state.value.newPassword.text.toString()) + changePassword(reauthentication, _state.value.newPassword.text.toString()) .onSuccess { _event.trySend(ChangePasswordEvent.Success) } .onFailure(::handleFailure) _state.update { it.copy(loading = false) } @@ -202,6 +137,13 @@ internal class ChangePasswordViewModel( ChangePasswordError.IncorrectPassword -> _state.update { it.copy(currentPasswordError = UiFieldError.Incorrect) } + // Any prompt that did not hand back the live ARK falls back to the master password. + ChangePasswordError.BiometricDeclined, + ChangePasswordError.BiometricAuthFailed, + -> _state.update { it.copy(showReauthDialog = true) } + + ChangePasswordError.BiometricCanceled -> Unit + else -> _event.trySend(ChangePasswordEvent.GenericError) } } diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDsl.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDsl.kt index 3a889ce50..37968b576 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDsl.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDsl.kt @@ -30,6 +30,7 @@ internal class SectionScope { colors: ListItemColors, icon: ImageVector? = null, supporting: UIText? = null, + enabled: Boolean = true, ) { entries += SettingsEntry.Toggle( title = title, @@ -37,6 +38,7 @@ internal class SectionScope { supporting = supporting, colors = colors, checked = checked, + enabled = enabled, onCheckedChange = onCheckedChange, ) } diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsEntry.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsEntry.kt index f4f5e733f..2cc018046 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsEntry.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsEntry.kt @@ -21,6 +21,7 @@ internal sealed interface SettingsEntry { override val supporting: UIText? = null, override val colors: ListItemColors, val checked: Boolean, + val enabled: Boolean = true, val onCheckedChange: (Boolean) -> Unit, ) : SettingsEntry diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt index 734d77549..ab4fad0b7 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt @@ -124,6 +124,7 @@ private fun SettingsEntryRow( is SettingsEntry.Toggle -> SegmentedListItem( onClick = { entry.onCheckedChange(!entry.checked) }, shapes = shapes, + enabled = entry.enabled, colors = colors, leadingContent = leadingContent, supportingContent = supportingContent, @@ -132,6 +133,7 @@ private fun SettingsEntryRow( Switch( checked = entry.checked, onCheckedChange = null, + enabled = entry.enabled, thumbContent = { Icon( imageVector = when { diff --git a/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModelTest.kt b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModelTest.kt index 32e22a9ff..acd581601 100644 --- a/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModelTest.kt +++ b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModelTest.kt @@ -1,30 +1,55 @@ +@file:OptIn(ExportArk::class) + package de.davis.keygo.feature.settings.presentation +import de.davis.keygo.core.biometrics.FakeBiometricAvailabilityRepository +import de.davis.keygo.core.biometrics.FakeBiometricCrypto +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError import de.davis.keygo.core.feature.autofill.FakeAutofillServiceRepository import de.davis.keygo.core.feature.autofill.FakeChromeAutofillRepository import de.davis.keygo.core.feature.settings.FakeAppVersionRepository import de.davis.keygo.core.identity.FakeAccountRepository +import de.davis.keygo.core.identity.domain.mapper.toBiometricWrappedArk +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.usecase.DisableBiometricsUseCase +import de.davis.keygo.core.identity.domain.usecase.EnableBiometricsUseCase import de.davis.keygo.core.security.FakeLockInfoRepository -import de.davis.keygo.core.security.crypto.FakeBiometricAvailabilityRepository +import de.davis.keygo.core.security.FakeSession +import de.davis.keygo.core.security.crypto.FakeKeyStoreManager +import de.davis.keygo.core.security.domain.ExportArk +import de.davis.keygo.core.security.domain.model.KeyId import de.davis.keygo.core.security.domain.model.LockInfo +import de.davis.keygo.core.util.FakeSnackbarManager +import de.davis.keygo.core.util.assertSuccess +import de.davis.keygo.core.util.domain.model.snackbar.SnackbarMessage +import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.core.util.presentation.UIText +import de.davis.keygo.feature.autofill.domain.usecase.AutofillActivationStatusUseCase import de.davis.keygo.feature.backup.FakeBackupJobRepository import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri import de.davis.keygo.feature.backup.domain.model.BackupJob import de.davis.keygo.feature.backup.domain.model.BackupResult import de.davis.keygo.feature.backup.domain.model.FileFormat import de.davis.keygo.feature.backup.domain.usecase.ObserveLastBackupUseCase +import de.davis.keygo.feature.settings.R +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import java.util.UUID import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -40,6 +65,10 @@ class SettingsViewModelTest { private val appVersionRepository = FakeAppVersionRepository() private val backupJobRepository = FakeBackupJobRepository() private val lockInfoRepository = FakeLockInfoRepository() + private val session = FakeSession(startUnlocked = true) + private val keyStoreManager = FakeKeyStoreManager() + private val biometricCrypto = FakeBiometricCrypto(keyStoreManager) + private val snackbarManager = FakeSnackbarManager() @BeforeTest fun setUp() = Dispatchers.setMain(dispatcher) @@ -51,12 +80,49 @@ class SettingsViewModelTest { biometricAvailabilityRepository = biometricAvailability, autofillServiceRepository = autofillServiceRepository, chromeAutofillRepository = chromeAutofillRepository, + autofillActivationStatus = AutofillActivationStatusUseCase( + autofillServiceRepository = autofillServiceRepository, + chromeAutofillRepository = chromeAutofillRepository, + ), lockInfoRepository = lockInfoRepository, + enableBiometrics = EnableBiometricsUseCase( + accountRepository = accountRepository, + session = session, + keyStoreManager = keyStoreManager, + biometricCrypto = biometricCrypto, + ), + disableBiometrics = DisableBiometricsUseCase(accountRepository, keyStoreManager), + snackbarManager = snackbarManager, accountRepository = accountRepository, appVersionRepository = appVersionRepository, observeLastBackup = ObserveLastBackupUseCase(backupJobRepository), ) + private val biometricUpdateFailed = + SnackbarMessage(message = UIText.ResourceString(R.string.settings_biometric_update_failed)) + + private suspend fun seedAccount(enrolled: Boolean) { + biometricAvailability.isAvailable = true + val ark = checkNotNull(session.exportArk().getOrNull()) + accountRepository.seed( + Account( + id = UUID.randomUUID(), + displayName = "Test", + passwordWrappedArk = PasswordWrappedArk( + key = ByteArray(48) { 1 }, + keyIV = ByteArray(12) { 2 }, + salt = ByteArray(16) { 3 }, + ), + biometricWrappedArk = if (enrolled) { + biometricCrypto.requestWrap(KeyId.BiometricVaultKek) { seal -> seal(ark) } + .assertSuccess() + .toBiometricWrappedArk() + } else null, + ), + ) + biometricCrypto.prompts.clear() + } + @Test fun `requesting to enable autofill emits OpenAutofillSelection and does not disable the service`() = runTest(dispatcher) { @@ -100,15 +166,124 @@ class SettingsViewModelTest { } @Test - fun `toggling biometrics forwards the requested value as an EnableBiometric event`() = + fun `switching biometrics on enrolls the account`() = runTest(dispatcher) { + seedAccount(enrolled = false) + val vm = viewModel() + vm.refreshSystemState() + + vm.onEvent(SettingsUiEvent.SetBiometrics(enabled = true)) + + vm.state.first { it.biometricsEnabled } + assertNotNull(accountRepository.getOrNull()?.biometricWrappedArk) + assertEquals(1, biometricCrypto.prompts.size) + assertTrue(snackbarManager.messages.isEmpty()) + } + + @Test + fun `switching biometrics off drops the enrollment without prompting`() = runTest(dispatcher) { + seedAccount(enrolled = true) + val vm = viewModel() + vm.refreshSystemState() + vm.state.first { it.biometricsEnabled } + + vm.onEvent(SettingsUiEvent.SetBiometrics(enabled = false)) + + vm.state.first { !it.biometricsEnabled } + assertNull(accountRepository.getOrNull()?.biometricWrappedArk) + assertFalse(KeyId.BiometricVaultKek in keyStoreManager.keys) + assertTrue(biometricCrypto.prompts.isEmpty()) + assertTrue(snackbarManager.messages.isEmpty()) + } + + /** + * The switch only moves once the stored enrollment does, so a second tap while the prompt is + * open reads as the opposite request. Run alongside the enable, a disable deletes the key the + * prompt is bound to. + */ + @Test + fun `a biometrics toggle made while an enrollment is running is dropped`() = runTest(dispatcher) { + seedAccount(enrolled = false) + val prompt = CompletableDeferred() + biometricCrypto.pendingPrompt = prompt val vm = viewModel() + vm.state.launchIn(backgroundScope) vm.onEvent(SettingsUiEvent.SetBiometrics(enabled = true)) + advanceUntilIdle() + assertTrue(vm.state.value.biometricsUpdating) + + vm.onEvent(SettingsUiEvent.SetBiometrics(enabled = false)) + advanceUntilIdle() + prompt.complete(Unit) + advanceUntilIdle() - assertEquals(SettingsEvent.EnableBiometric(enable = true), vm.event.first()) + assertEquals(1, accountRepository.setCount) + assertNotNull(accountRepository.getOrNull()?.biometricWrappedArk) + assertFalse(vm.state.value.biometricsUpdating) } + @Test + fun `backing out of the enrollment prompt is not reported`() = runTest(dispatcher) { + listOf(BiometricAuthError.Declined, BiometricAuthError.Canceled).forEach { dismissal -> + seedAccount(enrolled = false) + biometricCrypto.promptFailure = dismissal + val vm = viewModel() + + vm.onEvent(SettingsUiEvent.SetBiometrics(enabled = true)) + advanceUntilIdle() + + assertNull(accountRepository.getOrNull()?.biometricWrappedArk, "$dismissal") + assertTrue(snackbarManager.messages.isEmpty(), "$dismissal") + } + } + + @Test + fun `an enrollment the prompt could not complete is reported`() = runTest(dispatcher) { + seedAccount(enrolled = false) + biometricCrypto.promptFailure = BiometricAuthError.LockedOut + val vm = viewModel() + + vm.onEvent(SettingsUiEvent.SetBiometrics(enabled = true)) + advanceUntilIdle() + + assertEquals(listOf(biometricUpdateFailed), snackbarManager.messages) + assertNull(accountRepository.getOrNull()?.biometricWrappedArk) + } + + @Test + fun `a disable that cannot be saved is reported and leaves the enrollment on`() = + runTest(dispatcher) { + seedAccount(enrolled = true) + accountRepository.setFails = true + val vm = viewModel() + vm.state.launchIn(backgroundScope) + vm.refreshSystemState() + + vm.onEvent(SettingsUiEvent.SetBiometrics(enabled = false)) + advanceUntilIdle() + + assertEquals(listOf(biometricUpdateFailed), snackbarManager.messages) + assertTrue(vm.state.value.biometricsEnabled) + assertTrue(KeyId.BiometricVaultKek in keyStoreManager.keys) + } + + @Test + fun `biometrics read as off once the device can no longer use them`() = runTest(dispatcher) { + seedAccount(enrolled = true) + val vm = viewModel() + vm.state.launchIn(backgroundScope) + vm.refreshSystemState() + advanceUntilIdle() + assertTrue(vm.state.value.biometricsEnabled) + + biometricAvailability.isAvailable = false + vm.refreshSystemState() + advanceUntilIdle() + + assertFalse(vm.state.value.biometricsEnabled) + } + @Test fun `opening backup emits NavigateToBackup`() = runTest(dispatcher) { val vm = viewModel() 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 5877b126c..f97865005 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 @@ -4,24 +4,30 @@ package de.davis.keygo.feature.settings.presentation.changepassword import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.runtime.snapshots.Snapshot +import de.davis.keygo.core.biometrics.FakeBiometricAvailabilityRepository +import de.davis.keygo.core.biometrics.FakeBiometricCrypto +import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError +import de.davis.keygo.core.biometrics.domain.model.BiometricString import de.davis.keygo.core.identity.FakeAccountRepository +import de.davis.keygo.core.identity.domain.mapper.toBiometricWrappedArk 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.usecase.ChangePasswordUseCase +import de.davis.keygo.core.identity.domain.usecase.UnlockableByBiometricsUseCase 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.domain.ExportArk -import de.davis.keygo.core.security.domain.model.BiometricAuthError +import de.davis.keygo.core.security.domain.model.KeyId import de.davis.keygo.core.ui.model.UiFieldError -import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.assertSuccess import de.davis.keygo.core.util.getOrNull +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -29,14 +35,13 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain -import java.security.Key -import javax.crypto.spec.SecretKeySpec import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class ChangePasswordViewModelTest { @@ -45,6 +50,7 @@ class ChangePasswordViewModelTest { private val accountRepository = FakeAccountRepository() private val biometricAvailability = FakeBiometricAvailabilityRepository() + private val biometricCrypto = FakeBiometricCrypto() private val session = FakeSession() // The screen starts out on an unlocked session, so the account has to exist up front. @@ -53,10 +59,9 @@ class ChangePasswordViewModelTest { private val estimator = object : PasswordStrengthEstimator { override suspend fun estimate(password: String): PasswordScore = PasswordScore.None } - private val changePassword = ChangePasswordUseCase(accountRepository, session) - - /** The live ARK, which is what a successful biometric prompt hands back to the screen. */ - private val ark: ByteArray get() = checkNotNull(session.exportArk().getOrNull()) + private val changePassword = ChangePasswordUseCase(biometricCrypto, accountRepository, session) + private val unlockableByBiometrics = + UnlockableByBiometricsUseCase(accountRepository, biometricAvailability) @BeforeTest fun setUp() { @@ -81,15 +86,17 @@ class ChangePasswordViewModelTest { /** Re-seed the account with a biometric-wrapped ARK and mark hardware available. */ private suspend fun enableBiometric() { biometricAvailability.isAvailable = true + val ark = checkNotNull(session.exportArk().getOrNull()) val current = accountRepository.getOrNull()!! accountRepository.seed( current.copy( - biometricWrappedArk = BiometricWrappedArk( - key = ByteArray(48) { it.toByte() }, - keyIV = ByteArray(12) { it.toByte() }, - ) + biometricWrappedArk = biometricCrypto + .requestWrap(KeyId.BiometricVaultKek) { seal -> seal(ark) } + .assertSuccess() + .toBiometricWrappedArk() ) ) + biometricCrypto.prompts.clear() } /** @@ -97,19 +104,28 @@ class ChangePasswordViewModelTest { * test reads `vm.state.value`, so the subscription belongs here rather than in each test. */ private fun TestScope.viewModel() = ChangePasswordViewModel( - accountRepository = accountRepository, - biometricAvailabilityRepository = biometricAvailability, + unlockableByBiometrics = unlockableByBiometrics, passwordStrengthEstimator = estimator, changePassword = changePassword, session = session, ).also { it.state.launchIn(backgroundScope) } + private fun TestScope.eventsOf(vm: ChangePasswordViewModel): List = + mutableListOf().also { events -> + vm.event.onEach { events += it }.launchIn(backgroundScope) + } + + private fun ChangePasswordViewModel.fillNewPassword(new: String = "brand-new") { + state.value.newPassword.edit { append(new) } + state.value.confirmPassword.edit { append(new) } + } + @Test fun `blank new password sets Empty error and does not change password`() = runTest(dispatcher) { val vm = viewModel() vm.state.value.currentPassword.edit { append("old") } - vm.submitWithPassword() + vm.onSubmit(forcePasswordPath = true) advanceUntilIdle() assertEquals(UiFieldError.Empty, vm.state.value.newPasswordError) @@ -122,20 +138,30 @@ class ChangePasswordViewModelTest { vm.state.value.newPassword.edit { append("brand-new") } vm.state.value.confirmPassword.edit { append("different") } - vm.submitWithPassword() + vm.onSubmit(forcePasswordPath = true) advanceUntilIdle() assertEquals(UiFieldError.Mismatch, vm.state.value.confirmPasswordError) } + @Test + fun `blank current password on the password path sets Empty error`() = runTest(dispatcher) { + val vm = viewModel() + vm.fillNewPassword() + + vm.onSubmit(forcePasswordPath = true) + advanceUntilIdle() + + assertEquals(UiFieldError.Empty, vm.state.value.currentPasswordError) + } + @Test fun `wrong current password sets Incorrect error`() = runTest(dispatcher) { val vm = viewModel() vm.state.value.currentPassword.edit { append("wrong") } - vm.state.value.newPassword.edit { append("brand-new") } - vm.state.value.confirmPassword.edit { append("brand-new") } + vm.fillNewPassword() - vm.submitWithPassword() + vm.onSubmit(forcePasswordPath = true) // Await rather than advanceUntilIdle: key derivation hops to Dispatchers.Default, // which the test scheduler cannot see. @@ -147,28 +173,49 @@ class ChangePasswordViewModelTest { fun `valid password change emits Success`() = runTest(dispatcher) { val vm = viewModel() vm.state.value.currentPassword.edit { append("old") } - vm.state.value.newPassword.edit { append("brand-new") } - vm.state.value.confirmPassword.edit { append("brand-new") } + vm.fillNewPassword() - vm.submitWithPassword() + vm.onSubmit(forcePasswordPath = true) advanceUntilIdle() assertEquals(ChangePasswordEvent.Success, vm.event.first()) } @Test - fun `onSubmit with biometric available and valid passwords emits LaunchBiometricPrompt`() = + fun `biometric verification is offered only to an enrolled account on a usable sensor`() = + runTest(dispatcher) { + biometricAvailability.isAvailable = true + val unenrolled = viewModel() + advanceUntilIdle() + assertFalse(unenrolled.state.value.biometricAvailable) + + enableBiometric() + val enrolled = viewModel() + advanceUntilIdle() + assertTrue(enrolled.state.value.biometricAvailable) + + biometricAvailability.isAvailable = false + val noSensor = viewModel() + advanceUntilIdle() + assertFalse(noSensor.state.value.biometricAvailable) + } + + @Test + fun `onSubmit with biometric available verifies through the prompt and emits Success`() = runTest(dispatcher) { enableBiometric() val vm = viewModel() advanceUntilIdle() // let resolveBiometricAvailability() populate biometricCiphertext - vm.state.value.newPassword.edit { append("brand-new") } - vm.state.value.confirmPassword.edit { append("brand-new") } + vm.fillNewPassword() vm.onSubmit() advanceUntilIdle() - assertEquals(ChangePasswordEvent.LaunchBiometricPrompt, vm.event.first()) + assertEquals(ChangePasswordEvent.Success, vm.event.first()) + assertEquals( + BiometricString.NegativeButton.Password, + biometricCrypto.prompts.single().policy.negativeButton, + ) } @Test @@ -182,20 +229,21 @@ class ChangePasswordViewModelTest { advanceUntilIdle() assertEquals(UiFieldError.Empty, vm.state.value.newPasswordError) + assertTrue(biometricCrypto.prompts.isEmpty()) } @Test fun `onSubmit without biometric and valid passwords emits Success`() = runTest(dispatcher) { - val vm = - viewModel() // setUp seeds an account with no biometric ARK; availability defaults false + // setUp seeds an account with no biometric ARK; availability defaults false + val vm = viewModel() vm.state.value.currentPassword.edit { append("old") } - vm.state.value.newPassword.edit { append("brand-new") } - vm.state.value.confirmPassword.edit { append("brand-new") } + vm.fillNewPassword() vm.onSubmit() advanceUntilIdle() assertEquals(ChangePasswordEvent.Success, vm.event.first()) + assertTrue(biometricCrypto.prompts.isEmpty()) } @Test @@ -211,19 +259,21 @@ class ChangePasswordViewModelTest { advanceUntilIdle() assertEquals(UiFieldError.Mismatch, vm.state.value.confirmPasswordError) + assertTrue(biometricCrypto.prompts.isEmpty()) } @Test fun `dismissReauthDialog hides dialog and clears current password error`() = runTest(dispatcher) { enableBiometric() + biometricCrypto.promptFailure = BiometricAuthError.Declined val vm = viewModel() advanceUntilIdle() - vm.onBiometricResult(Result.Failure(BiometricAuthError.Declined)) // opens the dialog - vm.state.value.newPassword.edit { append("brand-new") } - vm.state.value.confirmPassword.edit { append("brand-new") } + vm.fillNewPassword() + vm.onSubmit() // opens the dialog + advanceUntilIdle() vm.state.value.currentPassword.edit { append("wrong") } - vm.submitWithPassword() + vm.onSubmit(forcePasswordPath = true) // Await rather than advanceUntilIdle: key derivation hops to Dispatchers.Default, // which the test scheduler cannot see. The Incorrect error below is load-bearing. vm.state.first { it.currentPasswordError == UiFieldError.Incorrect } @@ -239,14 +289,15 @@ class ChangePasswordViewModelTest { fun `dialog confirm with wrong current password keeps dialog open with Incorrect error`() = runTest(dispatcher) { enableBiometric() + biometricCrypto.promptFailure = BiometricAuthError.Declined val vm = viewModel() advanceUntilIdle() - vm.onBiometricResult(Result.Failure(BiometricAuthError.Declined)) // opens the dialog - vm.state.value.newPassword.edit { append("brand-new") } - vm.state.value.confirmPassword.edit { append("brand-new") } + vm.fillNewPassword() + vm.onSubmit() // opens the dialog + advanceUntilIdle() vm.state.value.currentPassword.edit { append("wrong") } - vm.submitWithPassword() // dialog Confirm action + vm.onSubmit(forcePasswordPath = true) // dialog Confirm action // Await rather than advanceUntilIdle: key derivation hops to Dispatchers.Default, // which the test scheduler cannot see. @@ -258,86 +309,102 @@ class ChangePasswordViewModelTest { @Test fun `dialog confirm with correct current password emits Success`() = runTest(dispatcher) { enableBiometric() + biometricCrypto.promptFailure = BiometricAuthError.Declined val vm = viewModel() advanceUntilIdle() - vm.onBiometricResult(Result.Failure(BiometricAuthError.Declined)) // opens the dialog - vm.state.value.newPassword.edit { append("brand-new") } - vm.state.value.confirmPassword.edit { append("brand-new") } + vm.fillNewPassword() + vm.onSubmit() // opens the dialog + advanceUntilIdle() vm.state.value.currentPassword.edit { append("old") } - vm.submitWithPassword() // dialog Confirm action + vm.onSubmit(forcePasswordPath = true) // dialog Confirm action advanceUntilIdle() assertEquals(ChangePasswordEvent.Success, vm.event.first()) + assertEquals(1, biometricCrypto.prompts.size) } @Test - fun `onBiometricResult with a recovered key changes password and emits Success`() = - runTest(dispatcher) { - enableBiometric() - val vm = viewModel() - advanceUntilIdle() - vm.state.value.newPassword.edit { append("brand-new") } - vm.state.value.confirmPassword.edit { append("brand-new") } - val recovered: Result = - Result.Success(SecretKeySpec(ark.copyOf(), "AES")) - - vm.onBiometricResult(recovered) - advanceUntilIdle() - - assertEquals(ChangePasswordEvent.Success, vm.event.first()) - } - - @Test - fun `onBiometricResult failure opens the reauth dialog`() = runTest(dispatcher) { + fun `a failed prompt opens the reauth dialog`() = runTest(dispatcher) { enableBiometric() + biometricCrypto.promptFailure = BiometricAuthError.NoCipher val vm = viewModel() advanceUntilIdle() - val failure: Result = Result.Failure(BiometricAuthError.NoCipher) + vm.fillNewPassword() - vm.onBiometricResult(failure) + vm.onSubmit() advanceUntilIdle() assertEquals(true, vm.state.value.showReauthDialog) } @Test - fun `onBiometricResult CryptoFailed opens the reauth dialog`() = runTest(dispatcher) { + fun `a CryptoFailed prompt opens the reauth dialog`() = runTest(dispatcher) { enableBiometric() + biometricCrypto.promptFailure = BiometricAuthError.CryptoFailed val vm = viewModel() advanceUntilIdle() - val failure: Result = - Result.Failure(BiometricAuthError.CryptoFailed) + vm.fillNewPassword() - vm.onBiometricResult(failure) + vm.onSubmit() advanceUntilIdle() assertEquals(true, vm.state.value.showReauthDialog) } @Test - fun `onBiometricResult Declined opens the reauth dialog`() = runTest(dispatcher) { + fun `a declined prompt opens the reauth dialog without reporting an error`() = + runTest(dispatcher) { + enableBiometric() + biometricCrypto.promptFailure = BiometricAuthError.Declined + val vm = viewModel() + val events = eventsOf(vm) + advanceUntilIdle() + vm.fillNewPassword() + + vm.onSubmit() + advanceUntilIdle() + + assertEquals(true, vm.state.value.showReauthDialog) + assertEquals(false, vm.state.value.loading) + assertTrue(events.isEmpty()) + } + + @Test + fun `a canceled prompt leaves the form untouched`() = runTest(dispatcher) { enableBiometric() + biometricCrypto.promptFailure = BiometricAuthError.Canceled val vm = viewModel() + val events = eventsOf(vm) advanceUntilIdle() - val failure: Result = Result.Failure(BiometricAuthError.Declined) + vm.fillNewPassword() - vm.onBiometricResult(failure) + vm.onSubmit() advanceUntilIdle() - assertEquals(true, vm.state.value.showReauthDialog) + assertEquals(false, vm.state.value.showReauthDialog) + assertEquals(false, vm.state.value.loading) + assertTrue(events.isEmpty()) } @Test - fun `onBiometricResult Canceled leaves the form untouched`() = runTest(dispatcher) { + fun `the form reads as loading while the prompt is open`() = runTest(dispatcher) { enableBiometric() + val prompt = CompletableDeferred() + biometricCrypto.pendingPrompt = prompt val vm = viewModel() advanceUntilIdle() - val failure: Result = Result.Failure(BiometricAuthError.Canceled) + vm.fillNewPassword() - vm.onBiometricResult(failure) + vm.onSubmit() + advanceUntilIdle() + assertEquals(true, vm.state.value.loading) - assertEquals(false, vm.state.value.showReauthDialog) + prompt.complete(Unit) + advanceUntilIdle() + + assertEquals(false, vm.state.value.loading) + assertEquals(ChangePasswordEvent.Success, vm.event.first()) } @OptIn(ExperimentalFoundationApi::class) @@ -365,13 +432,18 @@ class ChangePasswordViewModelTest { // The errors and the dialog all describe input the clear just removed. Left standing, the // user comes back from the unlock to a re-auth dialog over three emptied fields, or to // "this field is empty" on a form they did fill in. + enableBiometric() + biometricCrypto.promptFailure = BiometricAuthError.Declined val vm = viewModel() - vm.onBiometricResult(Result.Failure(BiometricAuthError.Declined)) - vm.submitWithPassword() + advanceUntilIdle() + vm.fillNewPassword() + vm.onSubmit() + advanceUntilIdle() + vm.onSubmit(forcePasswordPath = true) advanceUntilIdle() assertEquals(true, vm.state.value.showReauthDialog) - assertEquals(UiFieldError.Empty, vm.state.value.newPasswordError) + assertEquals(UiFieldError.Empty, vm.state.value.currentPasswordError) session.endSession() advanceUntilIdle() @@ -383,13 +455,14 @@ class ChangePasswordViewModelTest { } @Test - fun `ordinary use does not clear the fields while the session stays active`() = runTest(dispatcher) { - val vm = viewModel() - vm.state.value.currentPassword.edit { append("old-pw") } - advanceUntilIdle() + fun `ordinary use does not clear the fields while the session stays active`() = + runTest(dispatcher) { + val vm = viewModel() + vm.state.value.currentPassword.edit { append("old-pw") } + advanceUntilIdle() - assertEquals("old-pw", vm.state.value.currentPassword.text.toString()) - } + assertEquals("old-pw", vm.state.value.currentPassword.text.toString()) + } @Test fun `the strength meter still tracks the new password after a clear`() = runTest(dispatcher) { @@ -397,8 +470,7 @@ class ChangePasswordViewModelTest { // swapping in fresh instances would leave it watching an abandoned one that is never // mutated again - freezing the score for the rest of the ViewModel's life. val vm = ChangePasswordViewModel( - accountRepository = accountRepository, - biometricAvailabilityRepository = biometricAvailability, + unlockableByBiometrics = unlockableByBiometrics, passwordStrengthEstimator = object : PasswordStrengthEstimator { override suspend fun estimate(password: String) = PasswordScore(password.length.coerceAtMost(5)) @@ -406,6 +478,7 @@ class ChangePasswordViewModelTest { changePassword = changePassword, session = session, ).also { it.state.launchIn(backgroundScope) } + // No Recomposer drives the frame clock here, so snapshotFlow is told about writes by hand. // The first advance is what lets the session-ended collector do its write in the first // place; the notification has to come after it, and the debounce after that. diff --git a/scripts/new-module.sh b/scripts/new-module.sh index 531612585..33defb54e 100755 --- a/scripts/new-module.sh +++ b/scripts/new-module.sh @@ -14,6 +14,7 @@ set -euo pipefail # # Generates: # //build.gradle.kts convention plugin applied +# //consumer-rules.pro Android modules only # //src/main/kotlin//di/Module.kt Koin DI module # //src/main/kotlin//{domain,data,presentation}/ # include("::") in settings.gradle.kts @@ -448,6 +449,10 @@ android { dependencies { } GRADLE + # AndroidLibraryConventionPlugin sets consumerProguardFiles("consumer-rules.pro") + # unconditionally; every Android module needs the file to exist or the build + # (and CodeQL's autobuild) fails on mergeConsumerProguardFiles. + touch "$MODULE_DIR/consumer-rules.pro" else # keygo.kotlin.jvm doesn't wire Koin - add what the DI module needs. cat > "$MODULE_DIR/build.gradle.kts" < underscore in namespace", 'namespace = "de.davis.keygo.core.proto_store"' in build, build) + check("consumer-rules.pro created for Android module", + os.path.isfile(os.path.join(mod, "consumer-rules.pro"))) pkg = os.path.join(mod, "src", "main", "kotlin", "de", "davis", "keygo", "core", "proto_store") check("PascalCase DI class from dashed name", diff --git a/settings.gradle.kts b/settings.gradle.kts index f5b0fe299..57c645075 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,6 +34,7 @@ include(":legacy-migration") include(":core:item") include(":core:util") include(":core:security") +include(":core:biometrics") include(":core:ui") include(":rust") include(":feature:credentials")