diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4466c4b1..37b49684 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,6 +10,8 @@ plugins { alias(libs.plugins.koin.compiler) alias(libs.plugins.git.semantic.versioning) alias(libs.plugins.google.protobuf) + + alias(libs.plugins.mikepenz.aboutlibraries) } versioning { @@ -97,10 +99,11 @@ dependencies { implementation(libs.koin.androidx.compose) implementation(libs.koin.annotations) + implementation(libs.aboutlibraries.compose.m3) + implementation(projects.rust) implementation(projects.core.item) implementation(projects.core.identity) - implementation(projects.core.security) implementation(projects.core.ui) implementation(projects.feature.auth) implementation(projects.feature.listScreen) @@ -112,6 +115,7 @@ dependencies { implementation(projects.feature.totp) implementation(projects.feature.creditCard) implementation(projects.feature.autofill) + implementation(projects.feature.settings) implementation(projects.migrationCreateAccess) implementation(libs.androidx.core.ktx) diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/AppDestinations.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/AppDestinations.kt index 6d9a75db..b7a83645 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/AppDestinations.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/AppDestinations.kt @@ -8,9 +8,11 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.ui.graphics.vector.ImageVector import de.davis.keygo.R import de.davis.keygo.core.presentation.model.RouteDestination +import de.davis.keygo.core.ui.RouteDestination as UiRouteDestination +import de.davis.keygo.feature.settings.presentation.SettingsGraphRoute enum class AppDestinations( - val route: RouteDestination, + val route: UiRouteDestination, @StringRes val label: Int, val icon: ImageVector, @StringRes val contentDescription: Int @@ -23,7 +25,7 @@ enum class AppDestinations( R.string.connectivity ), SETTINGS( - RouteDestination.Settings, + SettingsGraphRoute, R.string.settings, Icons.Default.Settings, R.string.settings diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt index bea71aad..746f4374 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt @@ -3,6 +3,8 @@ package de.davis.keygo.app.presentation import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text @@ -14,6 +16,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Wallpapers import androidx.fragment.app.FragmentActivity @@ -25,6 +28,8 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.dialog import androidx.navigation.compose.rememberNavController import androidx.navigation.navigation +import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries +import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer import de.davis.keygo.app.presentation.component.KeyGoNavigationWrapper import de.davis.keygo.core.presentation.model.RouteDestination import de.davis.keygo.core.ui.theme.KeyGoTheme @@ -35,6 +40,8 @@ import de.davis.keygo.dashboard.presentation.DetailType import de.davis.keygo.dashboard.presentation.dashboardGraph import de.davis.keygo.feature.auth.presentation.AuthRoute import de.davis.keygo.feature.auth.presentation.authGraph +import de.davis.keygo.feature.settings.presentation.ChangePasswordRoute +import de.davis.keygo.feature.settings.presentation.settingsGraph import de.davis.keygo.item.dialog.SelectItemContent import kotlinx.coroutines.launch import org.koin.compose.koinInject @@ -155,11 +162,27 @@ private fun App() { dashboardGraph(listNavigator = listNavigator) } + settingsGraph( + onOpenChangePassword = { navController.navigate(ChangePasswordRoute) }, + onShowLibraries = { navController.navigate(RouteDestination.Libraries) }, + onUp = { navController.navigateUp() }, + ) + composable { Text("CONNECTIVITY") } - composable { - Text("SETTINGS") + } + + composable { + Scaffold( + modifier = Modifier.fillMaxSize() + ) { innerPadding -> + val libs by produceLibraries() + LibrariesContainer( + libraries = libs, + modifier = Modifier.fillMaxSize(), + contentPadding = innerPadding + ) } } } diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/component/NavigationWrapper.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/component/NavigationWrapper.kt index 50282c64..deba4ca0 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/component/NavigationWrapper.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/component/NavigationWrapper.kt @@ -99,7 +99,7 @@ import de.davis.keygo.R import de.davis.keygo.app.presentation.AppDestinations import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.item.generated.presentation.presentation -import de.davis.keygo.core.presentation.model.RouteDestination +import de.davis.keygo.core.ui.RouteDestination import kotlinx.coroutines.launch import kotlin.math.roundToInt import de.davis.keygo.core.ui.R as CoreUiR diff --git a/app/src/main/kotlin/de/davis/keygo/core/presentation/model/RouteDestination.kt b/app/src/main/kotlin/de/davis/keygo/core/presentation/model/RouteDestination.kt index ae250fb1..fb3e4bb3 100644 --- a/app/src/main/kotlin/de/davis/keygo/core/presentation/model/RouteDestination.kt +++ b/app/src/main/kotlin/de/davis/keygo/core/presentation/model/RouteDestination.kt @@ -1,10 +1,11 @@ package de.davis.keygo.core.presentation.model +import de.davis.keygo.core.ui.RouteDestination as UiRouteDestination import kotlinx.serialization.Serializable -sealed interface RouteDestination { +sealed interface RouteDestination : UiRouteDestination { - val graphDest: RouteDestination + override val graphDest: RouteDestination get() = this @Serializable @@ -32,8 +33,8 @@ sealed interface RouteDestination { } @Serializable - data object Settings : RouteDestination { + data object Libraries : RouteDestination { override val graphDest: RouteDestination - get() = Settings + get() = Libraries } } \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 4b918167..2a6973f7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,4 +10,5 @@ plugins { alias(libs.plugins.google.ksp) apply false alias(libs.plugins.google.protobuf) apply false alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.mikepenz.aboutlibraries) apply false } \ No newline at end of file diff --git a/core/identity/build.gradle.kts b/core/identity/build.gradle.kts index 36d81fcc..d17b65b0 100644 --- a/core/identity/build.gradle.kts +++ b/core/identity/build.gradle.kts @@ -12,7 +12,7 @@ android { } dependencies { - implementation(projects.core.security) + api(projects.core.security) implementation(projects.core.item) implementation(projects.rust) diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/repository/AccountRepositoryImpl.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/repository/AccountRepositoryImpl.kt index ba1be8b0..7b5f7ab6 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/repository/AccountRepositoryImpl.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/repository/AccountRepositoryImpl.kt @@ -8,7 +8,9 @@ import de.davis.keygo.core.identity.di.annotation.AccountRegistryQualifier import de.davis.keygo.core.identity.domain.model.Account import de.davis.keygo.core.identity.domain.repository.AccountRepository import de.davis.keygo.core.util.Result -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map import org.koin.core.annotation.Single @Single @@ -16,8 +18,9 @@ internal class AccountRepositoryImpl( @param:AccountRegistryQualifier private val dataStore: DataStore, ) : AccountRepository { + override fun observe(): Flow = dataStore.data.map { it.toDomain() } - override suspend fun getOrNull(): Account? = dataStore.data.first().toDomain() + override suspend fun getOrNull(): Account? = observe().firstOrNull() override suspend fun set(account: Account): Result = runCatching { dataStore.updateData { account.toProto() } 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 new file mode 100644 index 00000000..5f34bc1b --- /dev/null +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/BiometricEnrollmentError.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.core.identity.domain.model + +import de.davis.keygo.core.security.domain.model.BiometricAuthError + +sealed interface BiometricEnrollmentError { + data object NoActiveAccount : BiometricEnrollmentError + data object WrappingFailed : BiometricEnrollmentError + data object PersistenceFailed : BiometricEnrollmentError + data class BiometricFailed(val error: BiometricAuthError) : BiometricEnrollmentError +} \ No newline at end of file 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 new file mode 100644 index 00000000..5c673279 --- /dev/null +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/ChangePasswordError.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.core.identity.domain.model + +sealed interface ChangePasswordError { + + data object ActiveAccountNotFound : ChangePasswordError + data object IncorrectPassword : ChangePasswordError + data object BiometricNotEnrolled : ChangePasswordError + data object KeyDerivationFailed : ChangePasswordError + data object WrappingFailed : ChangePasswordError + data object PersistenceFailed : 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 new file mode 100644 index 00000000..a6f5717d --- /dev/null +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/Reauthentication.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.core.identity.domain.model + +sealed interface Reauthentication { + + data class Password(val currentPassword: String) : Reauthentication + + class Biometric(val recoveredArk: ByteArray) : Reauthentication +} diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/repository/AccountRepository.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/repository/AccountRepository.kt index e90ba0b7..34a4b905 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/repository/AccountRepository.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/repository/AccountRepository.kt @@ -2,6 +2,7 @@ package de.davis.keygo.core.identity.domain.repository import de.davis.keygo.core.identity.domain.model.Account import de.davis.keygo.core.util.Result +import kotlinx.coroutines.flow.Flow /** * Persists account identity metadata and associated cryptographic key-wrapping data. @@ -18,6 +19,12 @@ import de.davis.keygo.core.util.Result */ interface AccountRepository { + /** + * Emits the currently active account (or null) and re-emits whenever the + * backing registry changes. + */ + fun observe(): Flow + /** * Returns the currently active account, or null if no account is registered. */ 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 new file mode 100644 index 00000000..f12b4214 --- /dev/null +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCase.kt @@ -0,0 +1,101 @@ +package de.davis.keygo.core.identity.domain.usecase + +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.util.Result +import de.davis.keygo.core.util.resultBinding +import de.davis.keygo.rust.derive.KeyDeriver +import de.davis.keygo.rust.derive.deriveRootKekFromPasswordWithResult +import de.davis.keygo.rust.wrap.KeyWrapper +import de.davis.keygo.rust.wrap.unwrapAccountRootKeyWithResult +import de.davis.keygo.rust.wrap.wrapAccountRootKeyWithResult +import de.davisalessandro.keygo.rust.WrappedKeyBlob +import org.koin.core.annotation.Single + +@Single +class ChangePasswordUseCase( + private val accountRepository: AccountRepository, + private val keyDeriver: KeyDeriver, + private val keyWrapper: KeyWrapper, +) { + + 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) + + val ark = when (reauthentication) { + is Reauthentication.Password -> { + val kek = keyDeriver.deriveRootKekFromPasswordWithResult( + password = reauthentication.currentPassword, + salt = account.passwordWrappedArk.salt, + ).bind { ChangePasswordError.KeyDerivationFailed } + + try { + keyWrapper.unwrapAccountRootKeyWithResult( + kek = kek, + wrapped = WrappedKeyBlob( + ciphertext = account.passwordWrappedArk.key, + nonce = account.passwordWrappedArk.keyIV, + ), + userId = account.id, + ).bind { ChangePasswordError.IncorrectPassword } + } finally { + kek.fill(0) + } + } + + is Reauthentication.Biometric -> { + account.biometricWrappedArk + ?: return Result.Failure(ChangePasswordError.BiometricNotEnrolled) + reauthentication.recoveredArk + } + } + + try { + val newSalt = keyDeriver.generateSalt() + val newKek = keyDeriver.deriveRootKekFromPasswordWithResult( + password = newPassword, + salt = newSalt, + ).bind { ChangePasswordError.KeyDerivationFailed } + + val rewrapped = try { + keyWrapper.wrapAccountRootKeyWithResult( + kek = newKek, + ark = ark, + userId = account.id, + ).bind { ChangePasswordError.WrappingFailed } + } finally { + newKek.fill(0) + } + + accountRepository.set( + account.copy( + passwordWrappedArk = PasswordWrappedArk( + key = rewrapped.ciphertext, + keyIV = rewrapped.nonce, + salt = newSalt, + ), + ), + ).bind { ChangePasswordError.PersistenceFailed } + } finally { + // Scrub the in-memory ARK on success *and* on every failure path after unwrap. + ark.fill(0) + } + } +} 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 new file mode 100644 index 00000000..c050ff0f --- /dev/null +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricEnrollmentAdapter.kt @@ -0,0 +1,19 @@ +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 new file mode 100644 index 00000000..cf97e04e --- /dev/null +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricEnrollmentAdapterImpl.kt @@ -0,0 +1,64 @@ +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.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.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, +) : BiometricEnrollmentAdapter { + + override suspend fun BiometricCryptoController.requestEnableBiometric( + policy: BiometricPolicy + ): Result = resultBinding { + val account = accountRepository.getOrNull() + .asResult(BiometricEnrollmentError.NoActiveAccount).bind() + + val cipher = requestCipher(KeyId.BiometricVaultKek, CryptographicMode.Wrap, policy) + .bind { BiometricEnrollmentError.BiometricFailed(it) } + + + val wrapped = wrapArk(session.ark, cipher) + .asResult(BiometricEnrollmentError.WrappingFailed).bind() + + 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 } + } + + 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/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 new file mode 100644 index 00000000..7aad6a56 --- /dev/null +++ b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCaseTest.kt @@ -0,0 +1,196 @@ +package de.davis.keygo.core.identity.domain.usecase + +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.ChangePasswordError +import de.davis.keygo.core.identity.domain.model.PasswordWrappedArk +import de.davis.keygo.core.identity.domain.model.Reauthentication +import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.core.util.isFailure +import de.davis.keygo.core.util.isSuccess +import de.davis.keygo.rust.FakeKeyDeriver +import de.davis.keygo.rust.FakeKeyWrapper +import de.davisalessandro.keygo.rust.WrappedKeyBlob +import kotlinx.coroutines.test.runTest +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ChangePasswordUseCaseTest { + + private val accountRepository = FakeAccountRepository() + private val keyDeriver = FakeKeyDeriver() + private val keyWrapper = FakeKeyWrapper() + + private val useCase = ChangePasswordUseCase( + accountRepository = accountRepository, + keyDeriver = keyDeriver, + keyWrapper = keyWrapper, + ) + + private val accountId = UUID.randomUUID() + private val ark = ByteArray(32) { (it + 1).toByte() } + + private fun seedAccount( + password: String, + withBiometric: Boolean = false, + ): Account { + val salt = keyDeriver.generateSalt() + val kek = keyDeriver.deriveRootKekFromPassword(password, salt) + val wrapped = keyWrapper.wrapAccountRootKey(kek, ark, accountId) + val account = Account( + id = accountId, + displayName = "Test", + passwordWrappedArk = PasswordWrappedArk( + key = wrapped.ciphertext, + keyIV = wrapped.nonce, + salt = salt, + ), + biometricWrappedArk = if (withBiometric) { + BiometricWrappedArk(key = ByteArray(48) { it.toByte() }, keyIV = ByteArray(12) { it.toByte() }) + } else null, + ) + accountRepository.seed(account) + return account + } + + /** Unwraps the stored password-wrapped ARK with [password]; returns null if it doesn't unwrap. */ + private suspend fun unwrapStoredArkWith(password: String): ByteArray? { + val stored = accountRepository.getOrNull()!!.passwordWrappedArk + val kek = keyDeriver.deriveRootKekFromPassword(password, stored.salt) + return runCatching { + keyWrapper.unwrapAccountRootKey( + kek = kek, + wrapped = WrappedKeyBlob(ciphertext = stored.key, nonce = stored.keyIV), + userId = accountId, + ) + }.getOrNull() + } + + @Test + fun `returns ActiveAccountNotFound when no account is registered`() = runTest { + val result = useCase(Reauthentication.Password("old"), "new") + + assertTrue(result.isFailure()) + assertEquals(ChangePasswordError.ActiveAccountNotFound, result.error) + } + + @Test + fun `returns IncorrectPassword when current password is wrong`() = runTest { + seedAccount("old") + + val result = useCase(Reauthentication.Password("wrong"), "new") + + assertTrue(result.isFailure()) + assertEquals(ChangePasswordError.IncorrectPassword, result.error) + } + + @Test + fun `password path re-wraps ARK so new password unwraps and old fails`() = runTest { + seedAccount("old") + + val result = useCase(Reauthentication.Password("old"), "new") + + assertTrue(result.isSuccess()) + assertContentEquals(ark, unwrapStoredArkWith("new")) + assertEquals(null, unwrapStoredArkWith("old")) + } + + @Test + fun `password change rotates the salt`() = runTest { + val before = seedAccount("old").passwordWrappedArk.salt.copyOf() + + useCase(Reauthentication.Password("old"), "new") + + val after = accountRepository.getOrNull()!!.passwordWrappedArk.salt + assertFalse(before.contentEquals(after)) + } + + @Test + fun `password change leaves the biometric-wrapped ARK untouched`() = runTest { + val before = seedAccount("old", withBiometric = true).biometricWrappedArk!! + + useCase(Reauthentication.Password("old"), "new") + + val after = accountRepository.getOrNull()!!.biometricWrappedArk!! + assertContentEquals(before.key, after.key) + assertContentEquals(before.keyIV, after.keyIV) + } + + @Test + fun `biometric path re-wraps the supplied ARK under the new password`() = runTest { + seedAccount("old", withBiometric = true) + + val result = useCase(Reauthentication.Biometric(ark.copyOf()), "new") + + assertTrue(result.isSuccess()) + assertContentEquals(ark, unwrapStoredArkWith("new")) + } + + @Test + fun `returns BiometricNotEnrolled when biometric proof given but none enrolled`() = runTest { + seedAccount("old", withBiometric = false) + + val result = useCase(Reauthentication.Biometric(ark.copyOf()), "new") + + assertTrue(result.isFailure()) + assertEquals(ChangePasswordError.BiometricNotEnrolled, result.error) + } + + @Test + fun `returns KeyDerivationFailed when derivation fails`() = runTest { + seedAccount("old") + keyDeriver.failDerivation = true + + val result = useCase(Reauthentication.Password("old"), "new") + + assertTrue(result.isFailure()) + assertEquals(ChangePasswordError.KeyDerivationFailed, result.error) + } + + @Test + fun `returns PersistenceFailed when the account cannot be saved`() = runTest { + seedAccount("old") + accountRepository.setFails = true + + val result = useCase(Reauthentication.Password("old"), "new") + + assertTrue(result.isFailure()) + assertEquals(ChangePasswordError.PersistenceFailed, result.error) + } + + @Test + fun `scrubs the supplied biometric ARK after a successful change`() = runTest { + seedAccount("old", withBiometric = true) + val recovered = ark.copyOf() + + useCase(Reauthentication.Biometric(recovered), "new") + + assertContentEquals(ByteArray(recovered.size), recovered) + } + + @Test + fun `scrubs the supplied biometric ARK when persistence fails`() = runTest { + seedAccount("old", withBiometric = true) + accountRepository.setFails = true + val recovered = ark.copyOf() + + useCase(Reauthentication.Biometric(recovered), "new") + + assertContentEquals(ByteArray(recovered.size), recovered) + } + + @Test + fun `scrubs the supplied biometric ARK when biometric reauth is not enrolled`() = runTest { + seedAccount("old", withBiometric = false) + val recovered = ark.copyOf() + + useCase(Reauthentication.Biometric(recovered), "new") + + assertContentEquals(ByteArray(recovered.size), recovered) + } +} diff --git a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCaseTest.kt b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCaseTest.kt index 134d8731..b4be406d 100644 --- a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCaseTest.kt +++ b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCaseTest.kt @@ -12,6 +12,7 @@ import de.davis.keygo.rust.FakeKeyWrapper import kotlinx.coroutines.test.runTest import java.util.UUID import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -90,6 +91,6 @@ class UnlockWithPasswordUseCaseTest { assertTrue(result.isSuccess()) assertTrue(session.startSessionCalled) - assertEquals(ark, session.ark) + assertContentEquals(ark, session.ark) } } diff --git a/core/identity/src/testFixtures/kotlin/de/davis/keygo/core/identity/FakeAccountRepository.kt b/core/identity/src/testFixtures/kotlin/de/davis/keygo/core/identity/FakeAccountRepository.kt index 31e0694e..20d84af6 100644 --- a/core/identity/src/testFixtures/kotlin/de/davis/keygo/core/identity/FakeAccountRepository.kt +++ b/core/identity/src/testFixtures/kotlin/de/davis/keygo/core/identity/FakeAccountRepository.kt @@ -3,22 +3,28 @@ package de.davis.keygo.core.identity import de.davis.keygo.core.identity.domain.model.Account import de.davis.keygo.core.identity.domain.repository.AccountRepository import de.davis.keygo.core.util.Result +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update class FakeAccountRepository : AccountRepository { - private var account: Account? = null + private val account = MutableStateFlow(null) var setFails: Boolean = false fun seed(account: Account) { - this.account = account + this.account.update { account } } - override suspend fun getOrNull(): Account? = account + override suspend fun getOrNull(): Account? = account.value + + override fun observe(): Flow = account.asStateFlow() override suspend fun set(account: Account): Result { if (setFails) return Result.Failure(Unit) - this.account = account + this.account.update { account } return Result.Success(Unit) } } diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/BiometricAuthError.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/BiometricAuthError.kt index 482bc255..ab243437 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/BiometricAuthError.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/BiometricAuthError.kt @@ -2,14 +2,14 @@ package de.davis.keygo.core.security.domain.model sealed interface BiometricAuthError { + data object Declined : BiometricAuthError + data object LockedOut : BiometricAuthError + data object Canceled : BiometricAuthError - @ConsistentCopyVisibility - data class BiometricError internal constructor( - val errorCode: Int, - val errString: String - ) : BiometricAuthError + /** Any other prompt error (timeout, vendor-specific, hardware). [errString] is user-facing. */ + 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 NoCipher : BiometricAuthError -} \ 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 index 9cd03961..7adaabe5 100644 --- 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 @@ -107,14 +107,7 @@ internal class BiometricCryptoControllerImpl( errorCode: Int, errString: CharSequence ) { - c.resume( - Result.Failure( - BiometricAuthError.BiometricError( - errorCode, - errString.toString() - ) - ) - ) + c.resume(Result.Failure(biometricAuthErrorFrom(errorCode, errString))) } override fun onAuthenticationFailed() { @@ -146,6 +139,27 @@ internal class BiometricCryptoControllerImpl( } } +/** + * 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 diff --git a/core/security/src/test/kotlin/de/davis/keygo/core/security/presentation/BiometricAuthErrorFromTest.kt b/core/security/src/test/kotlin/de/davis/keygo/core/security/presentation/BiometricAuthErrorFromTest.kt new file mode 100644 index 00000000..56cf14c8 --- /dev/null +++ b/core/security/src/test/kotlin/de/davis/keygo/core/security/presentation/BiometricAuthErrorFromTest.kt @@ -0,0 +1,57 @@ +package de.davis.keygo.core.security.presentation + +import androidx.biometric.BiometricPrompt +import de.davis.keygo.core.security.domain.model.BiometricAuthError +import kotlin.test.Test +import kotlin.test.assertEquals + +class BiometricAuthErrorFromTest { + + @Test + fun `negative button maps to Declined`() { + assertEquals( + BiometricAuthError.Declined, + biometricAuthErrorFrom(BiometricPrompt.ERROR_NEGATIVE_BUTTON, "use password"), + ) + } + + @Test + fun `lockout maps to LockedOut`() { + assertEquals( + BiometricAuthError.LockedOut, + biometricAuthErrorFrom(BiometricPrompt.ERROR_LOCKOUT, "too many attempts"), + ) + } + + @Test + fun `permanent lockout maps to LockedOut`() { + assertEquals( + BiometricAuthError.LockedOut, + biometricAuthErrorFrom(BiometricPrompt.ERROR_LOCKOUT_PERMANENT, "locked out"), + ) + } + + @Test + fun `user cancel maps to Canceled`() { + assertEquals( + BiometricAuthError.Canceled, + biometricAuthErrorFrom(BiometricPrompt.ERROR_USER_CANCELED, "canceled"), + ) + } + + @Test + fun `system cancel maps to Canceled`() { + assertEquals( + BiometricAuthError.Canceled, + biometricAuthErrorFrom(BiometricPrompt.ERROR_CANCELED, "canceled"), + ) + } + + @Test + fun `timeout maps to Unknown carrying code and message`() { + assertEquals( + BiometricAuthError.Unknown(BiometricPrompt.ERROR_TIMEOUT, "timed out"), + biometricAuthErrorFrom(BiometricPrompt.ERROR_TIMEOUT, "timed out"), + ) + } +} diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts index f73293e1..0122bec5 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.keygo.android.compose) + alias(libs.plugins.kotlin.serialization) } android { @@ -7,7 +8,6 @@ android { } dependencies { - implementation(projects.core.security) implementation(projects.core.identity) implementation(projects.core.item) implementation(projects.core.ui) diff --git a/feature/autofill/build.gradle.kts b/feature/autofill/build.gradle.kts index 951fdb7e..39daa741 100644 --- a/feature/autofill/build.gradle.kts +++ b/feature/autofill/build.gradle.kts @@ -28,7 +28,6 @@ dependencies { implementation(projects.core.util) implementation(projects.core.ui) implementation(projects.core.identity) - implementation(projects.core.security) implementation(projects.feature.item.core) implementation(projects.feature.item.create) implementation(projects.feature.totp) diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/data/repository/AutofillServiceRepositoryImpl.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/data/repository/AutofillServiceRepositoryImpl.kt new file mode 100644 index 00000000..7fc4b3b2 --- /dev/null +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/data/repository/AutofillServiceRepositoryImpl.kt @@ -0,0 +1,15 @@ +package de.davis.keygo.feature.autofill.data.repository + +import android.view.autofill.AutofillManager +import de.davis.keygo.feature.autofill.domain.repository.AutofillServiceRepository +import org.koin.core.annotation.Single + +@Single +internal class AutofillServiceRepositoryImpl( + private val autofillManager: AutofillManager, +) : AutofillServiceRepository { + + override fun isEnabled(): Boolean = autofillManager.hasEnabledAutofillServices() + + override fun disable() = autofillManager.disableAutofillServices() +} \ No newline at end of file diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/di/AutofillModule.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/di/AutofillModule.kt index 6977da1d..2ff2923f 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/di/AutofillModule.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/di/AutofillModule.kt @@ -2,6 +2,8 @@ package de.davis.keygo.feature.autofill.di import android.content.Context import android.os.Build +import android.view.autofill.AutofillManager +import androidx.core.content.getSystemService import de.davis.keygo.core.identity.di.CoreIdentityModule import de.davis.keygo.core.util.di.CoreUtilModule import de.davis.keygo.feature.autofill.presentation.dataset.DatasetBuilderApi33Impl @@ -34,4 +36,8 @@ object AutofillModule { ) ) .build() + + @Single + internal fun provideAutofillManager(applicationContext: Context) = + applicationContext.getSystemService() } \ No newline at end of file diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/repository/AutofillServiceRepository.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/repository/AutofillServiceRepository.kt new file mode 100644 index 00000000..6061bee7 --- /dev/null +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/repository/AutofillServiceRepository.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.autofill.domain.repository + +interface AutofillServiceRepository { + + fun isEnabled(): Boolean + fun disable() +} \ No newline at end of file diff --git a/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeAutofillServiceRepository.kt b/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeAutofillServiceRepository.kt new file mode 100644 index 00000000..e6ba0133 --- /dev/null +++ b/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeAutofillServiceRepository.kt @@ -0,0 +1,18 @@ +package de.davis.keygo.core.feature.autofill + +import de.davis.keygo.feature.autofill.domain.repository.AutofillServiceRepository + +class FakeAutofillServiceRepository : AutofillServiceRepository { + + // Mirrors the OS: disableAutofillServices() propagates asynchronously, so + // isEnabled() can keep reporting true right after disable(). Tests control + // `enabled` explicitly instead of disable() flipping it synchronously. + var enabled: Boolean = false + var disableCalled: Boolean = false + + override fun isEnabled(): Boolean = enabled + + override fun disable() { + disableCalled = true + } +} diff --git a/feature/credentials/build.gradle.kts b/feature/credentials/build.gradle.kts index 2cd20f65..19dcda2f 100644 --- a/feature/credentials/build.gradle.kts +++ b/feature/credentials/build.gradle.kts @@ -19,7 +19,6 @@ dependencies { implementation(projects.rust) implementation(projects.core.identity) - implementation(projects.core.security) implementation(projects.core.item) implementation(projects.core.ui) implementation(projects.feature.item.create) 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 38ac891c..93dd90ca 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,6 +1,5 @@ package de.davis.keygo.feature.credentials.presentation.auth -import androidx.biometric.BiometricPrompt import de.davis.keygo.core.identity.domain.model.UnlockError import de.davis.keygo.core.security.domain.model.BiometricAuthError @@ -13,20 +12,14 @@ internal sealed interface SessionAuthState { internal enum class UnlockOutcome { Abort, NeedsPassword } internal fun mapUnlockError(error: UnlockError): UnlockOutcome = when (error) { - is UnlockError.BiometricFailed -> when (val biometricError = error.error) { - is BiometricAuthError.BiometricError -> when (biometricError.errorCode) { - BiometricPrompt.ERROR_USER_CANCELED, - BiometricPrompt.ERROR_CANCELED -> UnlockOutcome.Abort - - BiometricPrompt.ERROR_NEGATIVE_BUTTON, - BiometricPrompt.ERROR_LOCKOUT, - BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> UnlockOutcome.NeedsPassword - - else -> UnlockOutcome.NeedsPassword - } + is UnlockError.BiometricFailed -> when (error.error) { + BiometricAuthError.Canceled, + BiometricAuthError.NoCipher -> UnlockOutcome.Abort + BiometricAuthError.Declined, + BiometricAuthError.LockedOut, + is BiometricAuthError.Unknown, is BiometricAuthError.CanNotAuthenticate -> UnlockOutcome.NeedsPassword - BiometricAuthError.NoCipher -> UnlockOutcome.Abort } UnlockError.WrappedKeyNotFound -> UnlockOutcome.NeedsPassword diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts new file mode 100644 index 00000000..85508c57 --- /dev/null +++ b/feature/settings/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + alias(libs.plugins.keygo.android.compose) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "de.davis.keygo.feature.settings" + + defaultConfig { + missingDimensionStrategy("store", "playStore") + } + + testFixtures { + enable = true + } +} + +dependencies { + implementation(projects.core.ui) + implementation(projects.core.identity) + implementation(projects.core.item) + implementation(projects.feature.autofill) + + implementation(libs.androidx.navigation.compose) + + testImplementation(testFixtures(projects.core.identity)) + testImplementation(testFixtures(projects.core.security)) + testImplementation(testFixtures(projects.rust)) + testImplementation(testFixtures(projects.feature.autofill)) + + testFixturesImplementation(project.dependencies.platform(libs.androidx.compose.bom)) + testFixturesImplementation(libs.androidx.compose.runtime) { + because("https://issuetracker.google.com/issues/259523353#comment32") + } +} diff --git a/feature/settings/consumer-rules.pro b/feature/settings/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/data/repository/AppVersionRepositoryImpl.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/data/repository/AppVersionRepositoryImpl.kt new file mode 100644 index 00000000..bc8f0b05 --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/data/repository/AppVersionRepositoryImpl.kt @@ -0,0 +1,20 @@ +package de.davis.keygo.feature.settings.data.repository + +import android.content.Context +import de.davis.keygo.feature.settings.domain.repository.AppVersionRepository +import org.koin.core.annotation.Single + +@Single +internal class AppVersionRepositoryImpl( + private val applicationContext: Context, +) : AppVersionRepository { + + private val packageInfo by lazy { + applicationContext.packageManager + .getPackageInfo(applicationContext.packageName, 0) + } + + override val versionName: String by lazy { + packageInfo.versionName.orEmpty() + } +} diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/di/FeatureSettingsModule.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/di/FeatureSettingsModule.kt new file mode 100644 index 00000000..9566bc0f --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/di/FeatureSettingsModule.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.settings.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.feature.settings") +object FeatureSettingsModule \ No newline at end of file diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/domain/model/OsState.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/domain/model/OsState.kt new file mode 100644 index 00000000..519350f1 --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/domain/model/OsState.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.settings.domain.model + +/** Snapshot of OS-owned settings that can only be polled, not observed. */ +data class OsState( + val autofillEnabled: Boolean = false, + val biometricsAvailable: Boolean = false, +) diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/domain/repository/AppVersionRepository.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/domain/repository/AppVersionRepository.kt new file mode 100644 index 00000000..1b0df992 --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/domain/repository/AppVersionRepository.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.settings.domain.repository + +interface AppVersionRepository { + + val versionName: String +} 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 new file mode 100644 index 00000000..67836d39 --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt @@ -0,0 +1,117 @@ +package de.davis.keygo.feature.settings.presentation + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Backup +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Fingerprint +import androidx.compose.material.icons.filled.LockReset +import androidx.compose.material.icons.filled.Password +import androidx.compose.material.icons.filled.SettingsBackupRestore +import androidx.compose.material.icons.filled.Update +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.davis.keygo.feature.settings.R +import de.davis.keygo.feature.settings.presentation.component.SettingsList + +@Composable +internal fun SettingsContent( + state: SettingsUiState, + onEvent: (SettingsUiEvent) -> Unit, +) { + Scaffold( + modifier = Modifier.fillMaxSize(), + ) { innerPadding -> + SettingsList( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp), + contentPadding = innerPadding, + ) { + section(title = R.string.settings_security) { + action( + title = R.string.settings_reset_password, + icon = Icons.Default.LockReset, + supporting = R.string.settings_reset_password_description, + onClick = { onEvent(SettingsUiEvent.ResetPassword) }, + ) + + if (state.biometricsAvailable) toggle( + title = R.string.settings_use_biometrics, + icon = Icons.Default.Fingerprint, + supporting = R.string.settings_use_biometrics_description, + checked = state.biometricsEnabled, + onCheckedChange = { onEvent(SettingsUiEvent.SetBiometrics(it)) }, + ) + + toggle( + title = R.string.settings_autofill, + icon = Icons.Default.Password, + supporting = R.string.settings_autofill_description, + checked = state.autofillEnabled, + onCheckedChange = { onEvent(SettingsUiEvent.SetAutofill(it)) }, + ) + } + + section(title = R.string.settings_backup) { + action( + title = R.string.settings_export_data, + icon = Icons.Default.Backup, + supporting = R.string.settings_export_data_description, + onClick = { onEvent(SettingsUiEvent.ExportData) }, + ) + + action( + title = R.string.settings_import_data, + icon = Icons.Default.SettingsBackupRestore, + supporting = R.string.settings_import_data_description, + onClick = { onEvent(SettingsUiEvent.ImportData) }, + ) + } + + section(title = R.string.settings_about) { + action( + title = R.string.settings_3rd_party_licenses, + icon = Icons.Default.Code, + onClick = { onEvent(SettingsUiEvent.LibrariesClicked) }, + ) + + action( + title = R.string.settings_report_issue, + icon = Icons.Default.BugReport, + navigationIcon = Icons.AutoMirrored.Default.OpenInNew, + onClick = { onEvent(SettingsUiEvent.ReportIssue) } + ) + + value( + title = R.string.settings_version, + value = state.version, + icon = Icons.Default.Update, + ) + } + } + } +} + +@Preview +@Composable +private fun SettingsContentPreview() { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + ) { + SettingsContent( + state = SettingsUiState(), + onEvent = {}, + ) + } + } +} 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 new file mode 100644 index 00000000..e9cb1b7d --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt @@ -0,0 +1,13 @@ +package de.davis.keygo.feature.settings.presentation + +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 ExportData : SettingsEvent + data object ImportData : SettingsEvent +} \ No newline at end of file diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsRoutes.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsRoutes.kt new file mode 100644 index 00000000..b3e1f318 --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsRoutes.kt @@ -0,0 +1,41 @@ +package de.davis.keygo.feature.settings.presentation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.navigation +import de.davis.keygo.core.ui.RouteDestination +import de.davis.keygo.feature.settings.presentation.changepassword.ChangePasswordScreen +import kotlinx.serialization.Serializable + +@Serializable +object SettingsGraphRoute : RouteDestination + +@Serializable +internal object SettingsHomeRoute : RouteDestination { + override val graphDest: RouteDestination get() = SettingsGraphRoute +} + +@Serializable +object ChangePasswordRoute : RouteDestination { + override val graphDest: RouteDestination get() = SettingsGraphRoute +} + +fun NavGraphBuilder.settingsGraph( + onOpenChangePassword: () -> Unit, + onShowLibraries: () -> Unit, + onUp: () -> Unit, +) = navigation(startDestination = SettingsHomeRoute) { + composable { + SettingsScreen( + showLibraries = onShowLibraries, + onOpenChangePassword = onOpenChangePassword, + + // TODO: #51 + onExportDataClicked = {}, + onImportDataClicked = {} + ) + } + composable { + ChangePasswordScreen(onUp = onUp) + } +} 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 new file mode 100644 index 00000000..a232a34f --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt @@ -0,0 +1,103 @@ +package de.davis.keygo.feature.settings.presentation + +import android.content.Intent +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +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.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 + +@Composable +fun SettingsScreen( + showLibraries: () -> Unit, + onExportDataClicked: () -> Unit, + onImportDataClicked: () -> Unit, + onOpenChangePassword: () -> Unit, +) { + val viewModel = koinViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + + val biometricController = rememberBiometricCryptoController() + val enrollmentAdapter = rememberBiometricEnrollmentAdapter() + + val enableAutofillLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {} + + // OS-owned state (autofill / biometric availability) can change while the user is + // in a system screen; re-read it whenever we come back to the foreground. + LifecycleResumeEffect(Unit) { + viewModel.refreshSystemState() + onPauseOrDispose {} + } + + val urlHandler = LocalUriHandler.current + val context = LocalContext.current + val snackbarManager = LocalSnackbarManager.current + ObserveAsEvents(viewModel.event) { + when (it) { + SettingsEvent.NavigateToLibraries -> showLibraries() + + SettingsEvent.NavigateToChangePassword -> onOpenChangePassword() + + SettingsEvent.OpenAutofillSelection -> { + enableAutofillLauncher.launch( + Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply { + data = "package:${context.packageName}".toUri() + } + ) + } + + 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.ExportData -> onExportDataClicked() + SettingsEvent.ImportData -> onImportDataClicked() + } + } + + SettingsContent( + state = state, + onEvent = viewModel::onEvent + ) +} + +/** 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 diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiEvent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiEvent.kt new file mode 100644 index 00000000..a5a7930c --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiEvent.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.feature.settings.presentation + +internal sealed interface SettingsUiEvent { + data class SetBiometrics(val enabled: Boolean) : SettingsUiEvent + data class SetAutofill(val enabledRequest: Boolean) : SettingsUiEvent + data object ResetPassword : SettingsUiEvent + data object ExportData : SettingsUiEvent + data object ImportData : SettingsUiEvent + data object ReportIssue : SettingsUiEvent + data object LibrariesClicked : SettingsUiEvent +} 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 new file mode 100644 index 00000000..0f89aa61 --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.settings.presentation + +internal data class SettingsUiState( + val autofillEnabled: Boolean = false, + val biometricsAvailable: Boolean = false, + val biometricsEnabled: Boolean = false, + val version: String = "2.0.0", +) 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 new file mode 100644 index 00000000..f5ef2ee5 --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt @@ -0,0 +1,87 @@ +package de.davis.keygo.feature.settings.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.davis.keygo.core.identity.domain.repository.AccountRepository +import de.davis.keygo.core.security.domain.repository.BiometricAvailabilityRepository +import de.davis.keygo.feature.autofill.domain.repository.AutofillServiceRepository +import de.davis.keygo.feature.settings.domain.model.OsState +import de.davis.keygo.feature.settings.domain.repository.AppVersionRepository +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +internal class SettingsViewModel( + private val biometricAvailabilityRepository: BiometricAvailabilityRepository, + private val autofillServiceRepository: AutofillServiceRepository, + accountRepository: AccountRepository, + appVersionRepository: AppVersionRepository, +) : ViewModel() { + + 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() + + // OS-owned state (autofill / biometric availability) has no observable stream of + // its own; we snapshot it on lifecycle resume via refreshSystemState(). + private val osState = MutableStateFlow(OsState()) + + val state = combine( + accountRepository.observe(), + osState, + ) { account, os -> + SettingsUiState( + autofillEnabled = os.autofillEnabled, + biometricsAvailable = os.biometricsAvailable, + biometricsEnabled = os.biometricsAvailable && account?.biometricWrappedArk != null, + version = versionName, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = SettingsUiState(version = versionName), + ) + + fun refreshSystemState() { + osState.update { + OsState( + autofillEnabled = autofillServiceRepository.isEnabled(), + biometricsAvailable = biometricAvailabilityRepository.availability(), + ) + } + } + + fun onEvent(event: SettingsUiEvent) { + when (event) { + is SettingsUiEvent.SetBiometrics -> _event.trySend(SettingsEvent.EnableBiometric(event.enabled)) + is SettingsUiEvent.SetAutofill -> when { + event.enabledRequest -> _event.trySend(SettingsEvent.OpenAutofillSelection) + else -> { + autofillServiceRepository.disable() + // disableAutofillServices() propagates through the system server + // asynchronously, so re-polling isEnabled() here can still read true. + // The outcome is deterministic — update the snapshot directly. + osState.update { it.copy(autofillEnabled = false) } + } + } + + SettingsUiEvent.ResetPassword -> _event.trySend(SettingsEvent.NavigateToChangePassword) + + SettingsUiEvent.ExportData -> _event.trySend(SettingsEvent.ExportData) + SettingsUiEvent.ImportData -> _event.trySend(SettingsEvent.ImportData) + + SettingsUiEvent.LibrariesClicked -> _event.trySend(SettingsEvent.NavigateToLibraries) + SettingsUiEvent.ReportIssue -> _event.trySend(SettingsEvent.ReportIssue) + } + } +} 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 new file mode 100644 index 00000000..2d52c41a --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordScreen.kt @@ -0,0 +1,326 @@ +package de.davis.keygo.feature.settings.presentation.changepassword + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.TextObfuscationMode +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Fingerprint +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedSecureTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +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 +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +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.util.domain.model.snackbar.SnackbarMessage +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 +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 -> + when (event) { + ChangePasswordEvent.Success -> onUp() + 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) + } + } + } + } + + ChangePasswordContent( + state = state, + onUp = onUp, + onSubmit = viewModel::onSubmit, + onSubmitWithPassword = viewModel::submitWithPassword, + onDismissReauthDialog = viewModel::dismissReauthDialog, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ChangePasswordContent( + state: ChangePasswordState, + onUp: () -> Unit, + onSubmit: () -> Unit, + onSubmitWithPassword: () -> Unit, + onDismissReauthDialog: () -> Unit, + modifier: Modifier = Modifier, +) { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + Column { + TopAppBar( + title = { Text(stringResource(R.string.change_password_title)) }, + navigationIcon = { + IconButton(onClick = onUp, enabled = !state.loading) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + scrollBehavior = scrollBehavior + ) + AnimatedVisibility( + visible = state.loading && !state.showReauthDialog, + ) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 16.dp, vertical = 8.dp) + .nestedScroll(scrollBehavior.nestedScrollConnection), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (!state.canUseBiometric) CurrentPasswordField( + state = state.currentPassword, + error = state.currentPasswordError, + ) + + var newHidden by rememberSaveable { mutableStateOf(true) } + var forceCompact by rememberSaveable { mutableStateOf(false) } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedSecureTextField( + state = state.newPassword, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { + forceCompact = !it.isFocused + }, + label = { Text(stringResource(R.string.new_password)) }, + isError = state.newPasswordError !is FieldError.None, + supportingText = supportingTextFor(state.newPasswordError), + textObfuscationMode = obfuscation(newHidden), + trailingIcon = { + VisibilityButton( + isHidden = newHidden, + onClick = { newHidden = !newHidden }, + ) + }, + ) + + StrengthIndicator( + passwordScore = state.passwordScore, + forceCompact = forceCompact + ) + } + + var confirmHidden by rememberSaveable { mutableStateOf(true) } + OutlinedSecureTextField( + state = state.confirmPassword, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.confirm_password)) }, + isError = state.confirmPasswordError !is FieldError.None, + supportingText = supportingTextFor(state.confirmPasswordError), + textObfuscationMode = obfuscation(confirmHidden), + trailingIcon = { + VisibilityButton( + isHidden = confirmHidden, + onClick = { confirmHidden = !confirmHidden }, + ) + }, + ) + + Button( + onClick = onSubmit, + modifier = Modifier.fillMaxWidth(), + enabled = !state.loading, + ) { + if (state.canUseBiometric) { + Icon(Icons.Default.Fingerprint, contentDescription = null) + Spacer(Modifier.width(ButtonDefaults.IconSpacing)) + } + Text(stringResource(R.string.change_password_action)) + } + + if (state.canUseBiometric) Text( + text = stringResource(R.string.biometric_confirm_helper), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (state.showReauthDialog) { + AlertDialog( + onDismissRequest = onDismissReauthDialog, + title = { Text(stringResource(R.string.reauth_dialog_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + CurrentPasswordField( + state = state.currentPassword, + error = state.currentPasswordError, + ) + AnimatedVisibility( + visible = state.loading, + ) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } + }, + confirmButton = { + Button(onClick = onSubmitWithPassword, enabled = !state.loading) { + Text(stringResource(R.string.change_password_action)) + } + }, + dismissButton = { + TextButton(onClick = onDismissReauthDialog, enabled = !state.loading) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + } +} + +private fun obfuscation(hidden: Boolean): TextObfuscationMode = + if (hidden) TextObfuscationMode.RevealLastTyped else TextObfuscationMode.Visible + +@Composable +private fun supportingTextFor(error: FieldError): (@Composable () -> Unit)? = when (error) { + FieldError.None -> null + FieldError.Empty -> { + { Text(stringResource(R.string.password_blank)) } + } + + FieldError.Incorrect -> { + { Text(stringResource(R.string.incorrect_password)) } + } + + FieldError.Mismatch -> { + { Text(stringResource(R.string.passwords_do_not_match)) } + } +} + +@Composable +private fun CurrentPasswordField( + state: TextFieldState, + error: FieldError, + modifier: Modifier = Modifier, +) { + var hidden by rememberSaveable { mutableStateOf(true) } + OutlinedSecureTextField( + state = state, + modifier = modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.current_password)) }, + isError = error !is FieldError.None, + supportingText = supportingTextFor(error), + textObfuscationMode = obfuscation(hidden), + trailingIcon = { + VisibilityButton(isHidden = hidden, onClick = { hidden = !hidden }) + }, + ) +} + +private class ChangePasswordStateProvider : PreviewParameterProvider { + + + private val previewBiometricCiphertext = CiphertextData(bytes = ByteArray(0), iv = ByteArray(0)) + + override val values = sequenceOf( + ChangePasswordState(), + ChangePasswordState(biometricCiphertext = previewBiometricCiphertext), + ChangePasswordState( + currentPasswordError = FieldError.Incorrect, + newPasswordError = FieldError.Empty, + confirmPasswordError = FieldError.Mismatch, + ), + ChangePasswordState(loading = true), + ChangePasswordState( + biometricCiphertext = previewBiometricCiphertext, + showReauthDialog = true, + ), + ChangePasswordState( + biometricCiphertext = previewBiometricCiphertext, + showReauthDialog = true, + loading = true, + ), + ) +} + +@Preview +@Composable +private fun ChangePasswordPreviewContainer( + @PreviewParameter(ChangePasswordStateProvider::class) state: ChangePasswordState +) { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + ) { + ChangePasswordContent( + state = state, + onUp = {}, + onSubmit = {}, + onSubmitWithPassword = {}, + onDismissReauthDialog = {}, + ) + } + } +} diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordState.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordState.kt new file mode 100644 index 00000000..1c541edb --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordState.kt @@ -0,0 +1,39 @@ +package de.davis.keygo.feature.settings.presentation.changepassword + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Stable +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.security.domain.model.CiphertextData + +@Stable +internal data class ChangePasswordState( + val currentPassword: TextFieldState = TextFieldState(), + val newPassword: TextFieldState = TextFieldState(), + val confirmPassword: TextFieldState = TextFieldState(), + val passwordScore: PasswordScore = PasswordScore.None, + val currentPasswordError: FieldError = FieldError.None, + val newPasswordError: FieldError = FieldError.None, + val confirmPasswordError: FieldError = FieldError.None, + /** Non-null when biometric verification is offered; carries the wrapped biometric ARK. */ + val biometricCiphertext: CiphertextData? = null, + /** True while the master-password fallback dialog is shown (biometric users). */ + val showReauthDialog: Boolean = false, + val loading: Boolean = false, +) { + val canUseBiometric: Boolean get() = biometricCiphertext != null +} + +internal sealed interface FieldError { + data object None : FieldError + data object Empty : FieldError + data object Incorrect : FieldError + data object Mismatch : FieldError +} + +internal sealed interface ChangePasswordEvent { + data object Success : ChangePasswordEvent + data object GenericError : ChangePasswordEvent + + /** Ask the screen to launch the biometric prompt (it owns the controller). */ + data object LaunchBiometricPrompt : ChangePasswordEvent +} diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModel.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModel.kt new file mode 100644 index 00000000..1ae7fd0a --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModel.kt @@ -0,0 +1,188 @@ +package de.davis.keygo.feature.settings.presentation.changepassword + +import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.davis.keygo.core.identity.domain.model.ChangePasswordError +import de.davis.keygo.core.identity.domain.model.Reauthentication +import de.davis.keygo.core.identity.domain.repository.AccountRepository +import de.davis.keygo.core.identity.domain.usecase.ChangePasswordUseCase +import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator +import de.davis.keygo.core.security.domain.model.BiometricAuthError +import de.davis.keygo.core.security.domain.model.CiphertextData +import de.davis.keygo.core.security.domain.repository.BiometricAvailabilityRepository +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.onFailure +import de.davis.keygo.core.util.onSuccess +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel +import java.security.Key +import kotlin.time.Duration.Companion.milliseconds + +@KoinViewModel +internal class ChangePasswordViewModel( + private val accountRepository: AccountRepository, + private val biometricAvailabilityRepository: BiometricAvailabilityRepository, + private val passwordStrengthEstimator: PasswordStrengthEstimator, + private val changePassword: ChangePasswordUseCase, +) : ViewModel() { + + private val _state = MutableStateFlow(ChangePasswordState()) + val state = _state.asStateFlow() + + // Buffered (not rendezvous): Success/GenericError are emitted from a background coroutine that + // may complete before a collector subscribes; a one-shot navigation/error signal must not drop. + private val _event = Channel(Channel.BUFFERED) + val event = _event.receiveAsFlow() + + init { + resolveBiometricAvailability() + observePasswordStrength() + } + + private fun resolveBiometricAvailability() { + viewModelScope.launch { + val wrapped = accountRepository.getOrNull()?.biometricWrappedArk + val available = biometricAvailabilityRepository.availability() + if (wrapped == null || !available) return@launch + _state.update { + it.copy( + biometricCiphertext = CiphertextData( + bytes = wrapped.key, + iv = wrapped.keyIV + ) + ) + } + } + } + + @OptIn(FlowPreview::class) + private fun observePasswordStrength() { + snapshotFlow { _state.value.newPassword.text } + .debounce(150.milliseconds) + .distinctUntilChanged() + .onEach { text -> + val score = passwordStrengthEstimator(text.toString()) + _state.update { it.copy(passwordScore = score) } + } + .launchIn(viewModelScope) + } + + /** + * Primary "Change password" action. Validates the new passwords first so we never fire a + * biometric prompt for an invalid form, then routes to biometric (if available) or the + * typed-password path. + */ + fun onSubmit() { + if (_state.value.canUseBiometric) { + if (!validateNewPasswords()) return + _event.trySend(ChangePasswordEvent.LaunchBiometricPrompt) + return + } + submitWithPassword() + } + + /** Verify with the typed current password, then change. */ + fun submitWithPassword() { + val state = _state.value + val current = state.currentPassword.text.toString() + if (!validateNewPasswords()) return + if (current.isBlank()) { + _state.update { it.copy(currentPasswordError = FieldError.Empty) } + return + } + change(Reauthentication.Password(current)) + } + + /** Dismiss the fallback dialog and clear any stale current-password error. */ + fun dismissReauthDialog() { + _state.update { it.copy(showReauthDialog = false, currentPasswordError = FieldError.None) } + } + + /** Verify with biometric: [recoveredArk] was unwrapped by the screen via requestUnwrap. */ + private fun submitWithBiometric(recoveredArk: ByteArray) { + if (!validateNewPasswords()) { + recoveredArk.fill(0) + return + } + change(Reauthentication.Biometric(recoveredArk)) + } + + /** + * Route the outcome of the screen's biometric prompt. The screen owns the prompt (it needs an + * Activity), but interpreting the result is ours: a recovered key changes the password, while a + * declined / locked-out / failed prompt falls back to the master-password dialog. + */ + fun onBiometricResult(result: Result) { + 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, + 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() + _state.update { + it.copy( + currentPasswordError = FieldError.None, + newPasswordError = FieldError.None, + confirmPasswordError = FieldError.None, + ) + } + if (new.isBlank()) { + _state.update { it.copy(newPasswordError = FieldError.Empty) } + return false + } + if (new != confirm) { + _state.update { it.copy(confirmPasswordError = FieldError.Mismatch) } + return false + } + return true + } + + private fun change(reauth: Reauthentication) { + _state.update { it.copy(loading = true) } + viewModelScope.launch { + changePassword(reauth, _state.value.newPassword.text.toString()) + .onSuccess { _event.trySend(ChangePasswordEvent.Success) } + .onFailure(::handleFailure) + _state.update { it.copy(loading = false) } + } + } + + private fun handleFailure(error: ChangePasswordError) { + when (error) { + ChangePasswordError.IncorrectPassword -> + _state.update { it.copy(currentPasswordError = FieldError.Incorrect) } + + 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 new file mode 100644 index 00000000..d3daa967 --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDsl.kt @@ -0,0 +1,73 @@ +package de.davis.keygo.feature.settings.presentation.component + +import androidx.annotation.StringRes +import androidx.compose.ui.graphics.vector.ImageVector + +@DslMarker +internal annotation class SettingsDsl + +@SettingsDsl +internal class SettingsScope { + private val sections = mutableListOf() + + fun section(@StringRes title: Int, block: SectionScope.() -> Unit) { + sections += SettingsSection(title, SectionScope().apply(block).build()) + } + + fun build(): List = sections.toList() +} + +@SettingsDsl +internal class SectionScope { + private val entries = mutableListOf() + + fun toggle( + @StringRes title: Int, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + icon: ImageVector? = null, + @StringRes supporting: Int? = null, + ) { + entries += SettingsEntry.Toggle( + title = title, + icon = icon, + supporting = supporting, + checked = checked, + onCheckedChange = onCheckedChange, + ) + } + + fun action( + @StringRes title: Int, + onClick: () -> Unit, + icon: ImageVector? = null, + navigationIcon: ImageVector? = null, + @StringRes supporting: Int? = null, + ) { + entries += SettingsEntry.Action( + title = title, + icon = icon, + supporting = supporting, + navigationIcon = navigationIcon, + onClick = onClick, + ) + } + + fun value( + @StringRes title: Int, + value: String, + icon: ImageVector? = null, + @StringRes supporting: Int? = null, + onClick: (() -> Unit)? = null, + ) { + entries += SettingsEntry.Value( + title = title, + icon = icon, + supporting = supporting, + value = value, + onClick = onClick, + ) + } + + fun build(): List = entries.toList() +} 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 new file mode 100644 index 00000000..e48ba4e1 --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsEntry.kt @@ -0,0 +1,42 @@ +package de.davis.keygo.feature.settings.presentation.component + +import androidx.annotation.StringRes +import androidx.compose.ui.graphics.vector.ImageVector + +internal sealed interface SettingsEntry { + @get:StringRes + val title: Int + val icon: ImageVector? + + @get:StringRes + val supporting: Int? + + data class Toggle( + @param:StringRes override val title: Int, + override val icon: ImageVector? = null, + @param:StringRes override val supporting: Int? = null, + val checked: Boolean, + val onCheckedChange: (Boolean) -> Unit, + ) : SettingsEntry + + data class Action( + @param:StringRes override val title: Int, + override val icon: ImageVector? = null, + @param:StringRes override val supporting: Int? = null, + val navigationIcon: ImageVector? = null, + val onClick: () -> Unit, + ) : SettingsEntry + + data class Value( + @param:StringRes override val title: Int, + override val icon: ImageVector? = null, + @param:StringRes override val supporting: Int? = null, + val value: String, + val onClick: (() -> Unit)? = null, + ) : SettingsEntry +} + +internal data class SettingsSection( + @param:StringRes val title: Int, + val entries: List, +) 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 new file mode 100644 index 00000000..d0ae373c --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt @@ -0,0 +1,176 @@ +package de.davis.keygo.feature.settings.presentation.component + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.ListItemShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun SettingsList( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), + content: SettingsScope.() -> Unit, +) { + val sections = SettingsScope().apply(content).build() + + LazyColumn( + modifier = modifier, + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + sections.forEach { section -> + stickyHeader(key = "header_${section.title}") { + SettingsSectionHeader( + title = section.title, + modifier = Modifier.animateItem() + ) + } + + itemsIndexed( + items = section.entries, + key = { _, entry -> "${section.title}_${entry.title}" }, + ) { index, entry -> + SettingsEntryRow( + entry = entry, + shapes = ListItemDefaults.segmentedShapes(index, section.entries.size), + colors = ListItemDefaults.segmentedColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.animateItem() + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun SettingsEntryRow( + entry: SettingsEntry, + shapes: ListItemShapes, + colors: ListItemColors, + verticalAlignment: Alignment.Vertical, + modifier: Modifier = Modifier, +) { + val leadingContent: (@Composable () -> Unit)? = entry.icon?.let { icon -> + { + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + shape = CircleShape, + modifier = Modifier.size(40.dp), + ) { + Box( + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + ) + } + } + } + } + val supportingContent: (@Composable () -> Unit)? = entry.supporting?.let { res -> + { Text(text = stringResource(res)) } + } + val headlineContent: @Composable () -> Unit = { Text(text = stringResource(entry.title)) } + + when (entry) { + is SettingsEntry.Toggle -> SegmentedListItem( + onClick = { entry.onCheckedChange(!entry.checked) }, + shapes = shapes, + colors = colors, + leadingContent = leadingContent, + supportingContent = supportingContent, + verticalAlignment = verticalAlignment, + trailingContent = { + Switch( + checked = entry.checked, + onCheckedChange = null, + thumbContent = { + Icon( + imageVector = when { + entry.checked -> Icons.Default.Check + else -> Icons.Default.Close + }, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize), + ) + } + ) + }, + modifier = modifier, + content = headlineContent, + ) + + is SettingsEntry.Action -> SegmentedListItem( + onClick = entry.onClick, + shapes = shapes, + colors = colors, + leadingContent = leadingContent, + supportingContent = supportingContent, + verticalAlignment = verticalAlignment, + trailingContent = if (entry.navigationIcon != null) { + { + Icon( + imageVector = entry.navigationIcon, + contentDescription = null, + ) + } + } else null, + modifier = modifier, + content = headlineContent, + ) + + is SettingsEntry.Value -> SegmentedListItem( + onClick = entry.onClick ?: {}, + shapes = shapes, + colors = colors, + leadingContent = leadingContent, + supportingContent = supportingContent, + verticalAlignment = verticalAlignment, + trailingContent = { Text(text = entry.value) }, + modifier = modifier, + content = headlineContent, + ) + } +} + +@Composable +private fun SettingsSectionHeader( + @StringRes title: Int, + modifier: Modifier = Modifier, +) { + Text( + text = stringResource(title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = modifier.padding(top = 8.dp, bottom = 4.dp), + ) +} diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml new file mode 100644 index 00000000..cf81c365 --- /dev/null +++ b/feature/settings/src/main/res/values/strings.xml @@ -0,0 +1,35 @@ + + + Security + Use Biometrics + Unlock KeyGo with your fingerprint or face + Reset Password + Change your main password that protects your data + Autofill + Fill in your logins in other apps and browsers + Could not update biometric unlock + + Backup + Export Data + Save an encrypted backup of your vaults to a file + Import Data + Restore your vaults from a backup file + + About + Report an issue + 3rd Party Licenses + Version + + Change password + Current password + New password + Confirm new password + Change password + You\'ll confirm with your fingerprint + Enter your current password + Password must not be blank + Incorrect password + Passwords do not match + Password changed + Could not change password + 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 new file mode 100644 index 00000000..da0fb665 --- /dev/null +++ b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModelTest.kt @@ -0,0 +1,79 @@ +package de.davis.keygo.feature.settings.presentation + +import de.davis.keygo.core.feature.autofill.FakeAutofillServiceRepository +import de.davis.keygo.core.feature.settings.FakeAppVersionRepository +import de.davis.keygo.core.identity.FakeAccountRepository +import de.davis.keygo.core.security.crypto.FakeBiometricAvailabilityRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private val accountRepository = FakeAccountRepository() + private val biometricAvailability = FakeBiometricAvailabilityRepository() + private val autofillServiceRepository = FakeAutofillServiceRepository() + private val appVersionRepository = FakeAppVersionRepository() + + @BeforeTest + fun setUp() = Dispatchers.setMain(dispatcher) + + @AfterTest + fun tearDown() = Dispatchers.resetMain() + + private fun viewModel() = SettingsViewModel( + biometricAvailabilityRepository = biometricAvailability, + autofillServiceRepository = autofillServiceRepository, + accountRepository = accountRepository, + appVersionRepository = appVersionRepository, + ) + + @Test + fun `requesting to enable autofill emits OpenAutofillSelection and does not disable the service`() = + runTest(dispatcher) { + autofillServiceRepository.enabled = false + val vm = viewModel() + + vm.onEvent(SettingsUiEvent.SetAutofill(enabledRequest = true)) + + assertEquals(SettingsEvent.OpenAutofillSelection, vm.event.first()) + assertFalse(autofillServiceRepository.disableCalled) + } + + @Test + fun `requesting to disable autofill disables the service and updates the ui`() = + runTest(dispatcher) { + autofillServiceRepository.enabled = true + val vm = viewModel() + vm.refreshSystemState() + vm.state.first { it.autofillEnabled } + + vm.onEvent(SettingsUiEvent.SetAutofill(enabledRequest = false)) + + assertTrue(autofillServiceRepository.disableCalled) + vm.state.first { !it.autofillEnabled } + } + + @Test + fun `toggling biometrics forwards the requested value as an EnableBiometric event`() = + runTest(dispatcher) { + val vm = viewModel() + + vm.onEvent(SettingsUiEvent.SetBiometrics(enabled = true)) + + assertEquals(SettingsEvent.EnableBiometric(enable = true), vm.event.first()) + } +} 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 new file mode 100644 index 00000000..7b8d7823 --- /dev/null +++ b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModelTest.kt @@ -0,0 +1,304 @@ +package de.davis.keygo.feature.settings.presentation.changepassword + +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.usecase.ChangePasswordUseCase +import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.security.crypto.FakeBiometricAvailabilityRepository +import de.davis.keygo.core.security.domain.model.BiometricAuthError +import de.davis.keygo.core.util.Result +import de.davis.keygo.rust.FakeKeyDeriver +import de.davis.keygo.rust.FakeKeyWrapper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +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.security.Key +import java.util.UUID +import javax.crypto.spec.SecretKeySpec +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class ChangePasswordViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private val accountRepository = FakeAccountRepository() + private val biometricAvailability = FakeBiometricAvailabilityRepository() + private val keyDeriver = FakeKeyDeriver() + private val keyWrapper = FakeKeyWrapper() + private val estimator = object : PasswordStrengthEstimator { + override suspend fun estimate(password: String): PasswordScore = PasswordScore.None + } + private val changePassword = ChangePasswordUseCase(accountRepository, keyDeriver, keyWrapper) + + private val accountId = UUID.randomUUID() + private val ark = ByteArray(32) { (it + 1).toByte() } + + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + val salt = keyDeriver.generateSalt() + val kek = keyDeriver.deriveRootKekFromPassword("old", salt) + val wrapped = keyWrapper.wrapAccountRootKey(kek, ark, accountId) + accountRepository.seed( + Account( + id = accountId, + displayName = "Test", + passwordWrappedArk = PasswordWrappedArk(wrapped.ciphertext, wrapped.nonce, salt), + biometricWrappedArk = null, + ) + ) + } + + @AfterTest + fun tearDown() = Dispatchers.resetMain() + + /** Re-seed the account with a biometric-wrapped ARK and mark hardware available. */ + private suspend fun enableBiometric() { + biometricAvailability.isAvailable = true + val current = accountRepository.getOrNull()!! + accountRepository.seed( + current.copy( + biometricWrappedArk = BiometricWrappedArk( + key = ByteArray(48) { it.toByte() }, + keyIV = ByteArray(12) { it.toByte() }, + ) + ) + ) + } + + private fun viewModel() = ChangePasswordViewModel( + accountRepository = accountRepository, + biometricAvailabilityRepository = biometricAvailability, + passwordStrengthEstimator = estimator, + changePassword = changePassword, + ) + + @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() + advanceUntilIdle() + + assertEquals(FieldError.Empty, vm.state.value.newPasswordError) + } + + @Test + fun `mismatched confirmation sets Mismatch error`() = 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("different") } + + vm.submitWithPassword() + advanceUntilIdle() + + assertEquals(FieldError.Mismatch, vm.state.value.confirmPasswordError) + } + + @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.submitWithPassword() + + // Await rather than advanceUntilIdle: key derivation hops to Dispatchers.Default, + // which the test scheduler cannot see. + vm.state.first { it.currentPasswordError != FieldError.None } + assertEquals(FieldError.Incorrect, vm.state.value.currentPasswordError) + } + + @Test + 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.submitWithPassword() + advanceUntilIdle() + + assertEquals(ChangePasswordEvent.Success, vm.event.first()) + } + + @Test + fun `onSubmit with biometric available and valid passwords emits LaunchBiometricPrompt`() = + 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.onSubmit() + advanceUntilIdle() + + assertEquals(ChangePasswordEvent.LaunchBiometricPrompt, vm.event.first()) + } + + @Test + fun `onSubmit with biometric available and blank new password sets Empty and does not prompt`() = + runTest(dispatcher) { + enableBiometric() + val vm = viewModel() + advanceUntilIdle() + + vm.onSubmit() + advanceUntilIdle() + + assertEquals(FieldError.Empty, vm.state.value.newPasswordError) + } + + @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 + vm.state.value.currentPassword.edit { append("old") } + vm.state.value.newPassword.edit { append("brand-new") } + vm.state.value.confirmPassword.edit { append("brand-new") } + + vm.onSubmit() + advanceUntilIdle() + + assertEquals(ChangePasswordEvent.Success, vm.event.first()) + } + + @Test + fun `onSubmit with biometric available and mismatched passwords sets Mismatch and does not prompt`() = + runTest(dispatcher) { + enableBiometric() + val vm = viewModel() + advanceUntilIdle() + vm.state.value.newPassword.edit { append("aaa") } + vm.state.value.confirmPassword.edit { append("bbb") } + + vm.onSubmit() + advanceUntilIdle() + + assertEquals(FieldError.Mismatch, vm.state.value.confirmPasswordError) + } + + @Test + fun `dismissReauthDialog hides dialog and clears current password error`() = runTest(dispatcher) { + enableBiometric() + 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.state.value.currentPassword.edit { append("wrong") } + vm.submitWithPassword() + // 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 == FieldError.Incorrect } + + vm.dismissReauthDialog() + + assertEquals(false, vm.state.value.showReauthDialog) + assertEquals(FieldError.None, vm.state.value.currentPasswordError) + } + + @Test + fun `dialog confirm with wrong current password keeps dialog open with Incorrect error`() = + runTest(dispatcher) { + enableBiometric() + 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.state.value.currentPassword.edit { append("wrong") } + + vm.submitWithPassword() // dialog Confirm action + + // Await rather than advanceUntilIdle: key derivation hops to Dispatchers.Default, + // which the test scheduler cannot see. + vm.state.first { it.currentPasswordError != FieldError.None } + assertEquals(FieldError.Incorrect, vm.state.value.currentPasswordError) + assertEquals(true, vm.state.value.showReauthDialog) + } + + @Test + fun `dialog confirm with correct current password emits Success`() = runTest(dispatcher) { + enableBiometric() + 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.state.value.currentPassword.edit { append("old") } + + vm.submitWithPassword() // dialog Confirm action + advanceUntilIdle() + + assertEquals(ChangePasswordEvent.Success, vm.event.first()) + } + + @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) { + enableBiometric() + val vm = viewModel() + advanceUntilIdle() + val failure: Result = Result.Failure(BiometricAuthError.NoCipher) + + vm.onBiometricResult(failure) + + assertEquals(true, vm.state.value.showReauthDialog) + } + + @Test + fun `onBiometricResult Declined opens the reauth dialog`() = runTest(dispatcher) { + enableBiometric() + val vm = viewModel() + advanceUntilIdle() + val failure: Result = Result.Failure(BiometricAuthError.Declined) + + vm.onBiometricResult(failure) + + assertEquals(true, vm.state.value.showReauthDialog) + } + + @Test + fun `onBiometricResult Canceled leaves the form untouched`() = runTest(dispatcher) { + enableBiometric() + val vm = viewModel() + advanceUntilIdle() + val failure: Result = Result.Failure(BiometricAuthError.Canceled) + + vm.onBiometricResult(failure) + + assertEquals(false, vm.state.value.showReauthDialog) + } +} diff --git a/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDslTest.kt b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDslTest.kt new file mode 100644 index 00000000..18b8d8a8 --- /dev/null +++ b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDslTest.kt @@ -0,0 +1,84 @@ +package de.davis.keygo.feature.settings.presentation.component + +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SettingsDslTest { + + @Test + fun `builder groups entries into sections in declaration order`() { + val sections = SettingsScope().apply { + section(title = 1) { + toggle(title = 10, checked = true, onCheckedChange = {}) + action(title = 11, onClick = {}) + } + section(title = 2) { + value(title = 20, value = "2.1") + } + }.build() + + assertEquals(2, sections.size) + assertEquals(1, sections[0].title) + assertEquals(listOf(10, 11), sections[0].entries.map { it.title }) + assertEquals(2, sections[1].title) + assertEquals(listOf(20), sections[1].entries.map { it.title }) + } + + @Test + fun `toggle entry captures checked state and callback`() { + var observed = false + val entry = SectionScope().apply { + toggle(title = 10, checked = true, onCheckedChange = { observed = it }) + }.build().single() + + val toggle = assertIs(entry) + assertTrue(toggle.checked) + toggle.onCheckedChange(true) + assertTrue(observed) + } + + @Test + fun `action entry carries the navigation icon when provided`() { + val icon = testIcon() + val entry = SectionScope().apply { + action(title = 11, onClick = {}, navigationIcon = icon) + }.build().single() + + val action = assertIs(entry) + assertEquals(icon, action.navigationIcon) + } + + @Test + fun `action entry has no navigation icon by default`() { + val entry = SectionScope().apply { + action(title = 11, onClick = {}) + }.build().single() + + val action = assertIs(entry) + assertNull(action.navigationIcon) + } + + @Test + fun `conditional rows are excluded when condition is false`() { + val show = false + val entries = SectionScope().apply { + action(title = 11, onClick = {}) + if (show) action(title = 12, onClick = {}) + }.build() + + assertEquals(listOf(11), entries.map { it.title }) + } + + private fun testIcon(): ImageVector = ImageVector.Builder( + name = "test", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).build() +} diff --git a/feature/settings/src/testFixtures/kotlin/de/davis/keygo/core/feature/settings/FakeAppVersionRepository.kt b/feature/settings/src/testFixtures/kotlin/de/davis/keygo/core/feature/settings/FakeAppVersionRepository.kt new file mode 100644 index 00000000..bc79924d --- /dev/null +++ b/feature/settings/src/testFixtures/kotlin/de/davis/keygo/core/feature/settings/FakeAppVersionRepository.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.core.feature.settings + +import de.davis.keygo.feature.settings.domain.repository.AppVersionRepository + +class FakeAppVersionRepository( + override val versionName: String = "2.0.0-test", +) : AppVersionRepository diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3ac15f4b..ab0391e8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -38,6 +38,7 @@ nbvcxz = "1.5.1" passGen = "0.1.0-beta" robolectric = "4.16" emvnfccard = "3.1.0" +aboutlibraries = "14.2.1" [libraries] google-protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protoc" } @@ -110,6 +111,8 @@ gms-mlkit-barcode-scanning = { group = "com.google.android.gms", name = "play-se zxing-barcode-scanning = { group = "com.google.zxing", name = "core", version.ref = "zxingBarcodeScanning" } devnied-emvnfccard = { group = "com.github.devnied.emvnfccard", name = "library", version.ref = "emvnfccard" } +aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutlibraries" } + android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } koin-gradlePlugin = { group = "io.insert-koin", name = "koin-compiler-gradle-plugin", version.ref = "koin-plugin" } @@ -133,6 +136,7 @@ kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" } git-semantic-versioning = { id = "io.github.offrange.git-semantic-versioning", version.ref = "semver" } android-library = { id = "com.android.library", version.ref = "agp" } +mikepenz-aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutlibraries" } keygo-android-compose = { id = "keygo.android.compose" } keygo-android-library = { id = "keygo.android.library" } diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/derive/KeyDeriver.kt b/rust/src/main/kotlin/de/davis/keygo/rust/derive/KeyDeriver.kt index 7cf421b5..66601ddc 100644 --- a/rust/src/main/kotlin/de/davis/keygo/rust/derive/KeyDeriver.kt +++ b/rust/src/main/kotlin/de/davis/keygo/rust/derive/KeyDeriver.kt @@ -4,15 +4,19 @@ import de.davis.keygo.core.util.Result import de.davisalessandro.keygo.rust.KeyDerivationException import de.davisalessandro.keygo.rust.KeyDeriverInterface import de.davisalessandro.keygo.rust.RootKek +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext typealias KeyDeriver = KeyDeriverInterface -fun KeyDeriver.deriveRootKekFromPasswordWithResult( +suspend fun KeyDeriver.deriveRootKekFromPasswordWithResult( password: String, salt: ByteArray, -): Result = runCatching { - deriveRootKekFromPassword(password, salt) -}.fold( - onSuccess = { Result.Success(it) }, - onFailure = { Result.Failure(it as KeyDerivationException) } -) \ No newline at end of file +): Result = withContext(Dispatchers.Default) { + runCatching { + deriveRootKekFromPassword(password, salt) + }.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it as KeyDerivationException) } + ) +} diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt index 3a0fb6d8..afc322bf 100644 --- a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt @@ -79,14 +79,19 @@ class FakeKeyWrapper : KeyWrapperInterface { private fun wrap(outerKey: ByteArray, innerKey: ByteArray, id: UUID): WrappedKeyBlob { val nonce = ByteArray(12).also { SecureRandom().nextBytes(it) } val ciphertext = xorStream(innerKey, outerKey, id, nonce) - wrapRecord[Triple(outerKey.toList(), ciphertext.toList(), id)] = innerKey + // Store a copy: the real UniFFI wrapper copies the key across the FFI boundary, so callers + // are free to scrub the array they passed in. Aliasing it here would let a caller's later + // `fill(0)` zero the recorded key material. + wrapRecord[Triple(outerKey.toList(), ciphertext.toList(), id)] = innerKey.copyOf() return WrappedKeyBlob(ciphertext = ciphertext, nonce = nonce) } private fun unwrap(outerKey: ByteArray, wrapped: WrappedKeyBlob, id: UUID): ByteArray { val recorded = wrapRecord[Triple(outerKey.toList(), wrapped.ciphertext.toList(), id)] ?: throw KeyWrapException.UnwrapFailed() - return recorded + // Return a fresh array, mirroring the real wrapper: scrubbing the unwrapped key must not + // corrupt the recorded ciphertext so a subsequent unwrap still round-trips. + return recorded.copyOf() } private fun xorStream( diff --git a/settings.gradle.kts b/settings.gradle.kts index fea9b97d..6a41b2b9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -47,3 +47,4 @@ include(":feature:vault") include(":feature:auth") include(":feature:autofill") include(":feature:credit-card") +include(":feature:settings")