Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
6b5bc00
feat(settings): add group-entry DSL
OffRange Jun 4, 2026
3109fef
feat(app): wire SettingsScreen into the settings nav destination
OffRange Jun 4, 2026
58cafc1
fix(auth): add serialization plugin again
OffRange Jun 4, 2026
4248f2c
feat(autofill): add autofill service repo
OffRange Jun 5, 2026
0abcb37
build: api(projects.core.security)
OffRange Jun 5, 2026
f121b7f
feat(settings): implement autofill switch
OffRange Jun 5, 2026
05c5570
feat(core-identity): add account observable function
OffRange Jun 6, 2026
919bf6d
feat(settings): add biometric enrollment
OffRange Jun 6, 2026
a725ea6
fix(autofill): disable autofill
OffRange Jun 6, 2026
c1abdef
fix(settings): add 3rd party section
OffRange Jun 7, 2026
d9a85a0
fix(settings): add version section
OffRange Jun 7, 2026
b59b2f4
feat(settings): add report issues
OffRange Jun 8, 2026
b860f3c
feat(libraries): implement 3rd party licenses
OffRange Jun 8, 2026
61a6b50
feat(identity): add ChangePasswordUseCase
OffRange Jun 8, 2026
63fe611
fix(identity): scrub ARK without leaving a copy; make FakeKeyWrapper …
OffRange Jun 8, 2026
db54bf5
feat(settings): add ChangePasswordViewModel
OffRange Jun 8, 2026
435b511
feat(settings): add ChangePasswordScreen
OffRange Jun 8, 2026
cea457e
feat(settings): add module-owned settings graph with change-password …
OffRange Jun 8, 2026
3511ad4
feat(app): back Settings tab with module-owned settings graph
OffRange Jun 8, 2026
64fee37
feat(settings): biometric reauth for change password (#59)
OffRange Jun 10, 2026
401873b
feat(settings): add descriptions and refactor navigation icons
OffRange Jun 10, 2026
4db0e65
feat(settings): add export and import data actions
OffRange Jun 10, 2026
29b259c
fix(identity): zeroize transient key material during password change
OffRange Jun 10, 2026
ef2586c
fix(settings): correct autofill toggle routing and gate biometrics row
OffRange Jun 10, 2026
310cd3e
test(settings): provide ViewModel fakes via testFixtures
OffRange Jun 10, 2026
cbfd93b
fix(settings): handle async autofill disable and update biometric def…
OffRange Jun 10, 2026
14ae1e7
fix(settings): animate entries
OffRange Jun 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,7 +25,7 @@ enum class AppDestinations(
R.string.connectivity
),
SETTINGS(
RouteDestination.Settings,
SettingsGraphRoute,
R.string.settings,
Icons.Default.Settings,
R.string.settings
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -155,11 +162,27 @@ private fun App() {
dashboardGraph(listNavigator = listNavigator)
}

settingsGraph(
onOpenChangePassword = { navController.navigate(ChangePasswordRoute) },
onShowLibraries = { navController.navigate(RouteDestination.Libraries) },
onUp = { navController.navigateUp() },
)

composable<RouteDestination.Connectivity> {
Text("CONNECTIVITY")
}
composable<RouteDestination.Settings> {
Text("SETTINGS")
}

composable<RouteDestination.Libraries> {
Scaffold(
modifier = Modifier.fillMaxSize()
) { innerPadding ->
val libs by produceLibraries()
LibrariesContainer(
libraries = libs,
modifier = Modifier.fillMaxSize(),
contentPadding = innerPadding
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -32,8 +33,8 @@ sealed interface RouteDestination {
}

@Serializable
data object Settings : RouteDestination {
data object Libraries : RouteDestination {
override val graphDest: RouteDestination
get() = Settings
get() = Libraries
}
}
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
2 changes: 1 addition & 1 deletion core/identity/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ android {
}

dependencies {
implementation(projects.core.security)
api(projects.core.security)
implementation(projects.core.item)
implementation(projects.rust)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,19 @@ 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
internal class AccountRepositoryImpl(
@param:AccountRegistryQualifier
private val dataStore: DataStore<ProtoAccountState>,
) : AccountRepository {
override fun observe(): Flow<Account?> = 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<Unit, Unit> = runCatching {
dataStore.updateData { account.toProto() }
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<Account?>

/**
* Returns the currently active account, or null if no account is registered.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Unit, ChangePasswordError> = 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<Unit, ChangePasswordError> = 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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Unit, BiometricEnrollmentError>

suspend fun disableBiometric(): Result<Unit, BiometricEnrollmentError>
}

inline fun BiometricEnrollmentAdapter.useEnrollmentAdapter(
block: BiometricEnrollmentAdapter.() -> Result<Unit, BiometricEnrollmentError>,
): Result<Unit, BiometricEnrollmentError> = with(this) { block() }
Comment thread
OffRange marked this conversation as resolved.
Loading
Loading