Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
bee8401
refactor(rust): reorganize project structure
OffRange Sep 6, 2026
b600c8b
feat(rust): introduce ark session
OffRange Sep 7, 2026
a4f1821
feat(rust): give ArkSession the credential flows
OffRange Sep 9, 2026
895916f
fix(rust): address Task 1 ArkSession review findings
OffRange Sep 9, 2026
693bc30
feat(rust): expose ArkSession credential flows over uniffi
OffRange Sep 9, 2026
e9ed025
feat(rust): derive the backup key from the session
OffRange Sep 9, 2026
aa46166
feat(security): add the Rust-backed Session class
OffRange Sep 9, 2026
e7df3fe
fix(security): address the Task 4 review findings
OffRange Sep 9, 2026
fbd8212
test(security): pin the session error mapping and cross-session unwrap
OffRange Sep 9, 2026
2938357
refactor(security): move every call site onto the Rust-backed session
OffRange Sep 9, 2026
b652706
fix(security): cover the ARK wipes and stop leaving keys resident
OffRange Sep 10, 2026
3f036c2
fix(security): close the last-write ARK leak and the vacuous assertions
OffRange Sep 10, 2026
426b306
refactor(rust): drop the ARK-level FFI surface
OffRange Sep 10, 2026
50aa6a1
fix(rust): address task 6 review findings
OffRange Sep 10, 2026
672a9a0
style(rust): restore rustfmt formatting the reorganization changed
OffRange Sep 10, 2026
381f354
fix(security): stop a backup blocking auto-lock and losing its retry
OffRange Sep 10, 2026
9cc77fd
refactor: make session clear akr when not in use
OffRange Sep 10, 2026
338bfe2
refactor: remove unused session and ark session classes, introduce Se…
OffRange Sep 11, 2026
508afbd
rust: check verify_ark and verify_password against the live ARK, not …
OffRange Sep 11, 2026
4487e9e
core/security: surface Locked and Rust's KeyWrap cause through Session
OffRange Sep 11, 2026
9f16697
core/identity: check reauthentication against the live ARK in ChangeP…
OffRange Sep 11, 2026
141ad44
core/security: add SessionFactory for throwaway sessions outside the …
OffRange Sep 11, 2026
3b4142c
feature/backup: run scheduled backup under a throwaway session, not t…
OffRange Sep 11, 2026
a976d64
refactor: cleanup
OffRange Sep 12, 2026
13dc581
Merge branch 'v2' into refactor/rust-session
OffRange Sep 12, 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
17 changes: 12 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,12 +155,19 @@ carries its own rules:
- Do not use mocks as the default way to model dependencies when a fake or testFixture exists
- Run broader tests for cross-module or security changes
- **Rust fakes** — `:rust` uses UniFFI (not raw JNI) to generate Kotlin bindings. UniFFI emits
`KeyDeriverInterface`/`KeyWrapperInterface`/`AccountManagerInterface`/`ItemManagerInterface`/
`VaultManagerInterface`/`CardFormatterInterface`/`CsvBackupManagerInterface`/
`JsonBackupManagerInterface`/`RustPasskeyInterface`/`TotpServiceInterface` for test seams; fakes
live in `:rust` testFixtures (`de.davis.keygo.rust`).
Never instantiate the real UniFFI classes (`KeyDeriver()`, `KeyWrapper()`, etc.) in JVM unit
`KeyWrapperInterface`/`ItemManagerInterface`/`VaultManagerInterface`/`CardFormatterInterface`/
`CsvBackupManagerInterface`/`JsonBackupManagerInterface`/`RustPasskeyInterface`/
`TotpServiceInterface` for test seams; fakes live in `:rust` testFixtures
(`de.davis.keygo.rust`).
Never instantiate the real UniFFI classes (`KeyWrapper()`, etc.) in JVM unit
tests — their default constructors require the native Rust library at runtime.
`ArkCredential(NoHandle)` is uniffi's own test constructor: it sets the handle to 0 and allocates
no Rust object, which is how `FakeArkCredential` extends the generated class without touching the
native library.
- **Session fakes**: the app reaches the Rust session only through the `Session` interface
(`SessionImpl` wraps the UniFFI `ArkSessionInterface`). Its fakes live in `:core:security`
testFixtures (`de.davis.keygo.core.security`): `FakeSession`, `FakeArkCredential`, and
`FakeSessionFactory` for code that opens a throwaway session through `SessionFactory`.
- **testFixtures + Compose plugin** — Any module with `kotlin.compose` that enables testFixtures
must add `testFixturesImplementation(libs.androidx.compose.runtime)` to avoid "Compose Runtime
not on classpath" compile errors. See `:core:item` for the canonical pattern.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,17 @@ import de.davis.keygo.core.identity.domain.model.ChangePasswordError
import de.davis.keygo.core.identity.domain.model.PasswordWrappedArk
import de.davis.keygo.core.identity.domain.model.Reauthentication
import de.davis.keygo.core.identity.domain.repository.AccountRepository
import de.davis.keygo.core.security.domain.Session
import de.davis.keygo.core.security.domain.SessionError
import de.davis.keygo.core.util.Result
import de.davis.keygo.core.util.resultBinding
import de.davis.keygo.rust.derive.KeyDeriver
import de.davis.keygo.rust.derive.deriveRootKekFromPasswordWithResult
import de.davis.keygo.rust.wrap.KeyWrapper
import de.davis.keygo.rust.wrap.unwrapAccountRootKeyWithResult
import de.davis.keygo.rust.wrap.wrapAccountRootKeyWithResult
import de.davisalessandro.keygo.rust.WrappedKeyBlob
import org.koin.core.annotation.Single

@Single
class ChangePasswordUseCase(
private val accountRepository: AccountRepository,
private val keyDeriver: KeyDeriver,
private val keyWrapper: KeyWrapper,
private val session: Session,
) {

suspend operator fun invoke(
Expand All @@ -39,63 +35,48 @@ class ChangePasswordUseCase(
val account = accountRepository.getOrNull()
?: return Result.Failure(ChangePasswordError.ActiveAccountNotFound)

val ark = when (reauthentication) {
is Reauthentication.Password -> {
val kek = keyDeriver.deriveRootKekFromPasswordWithResult(
password = reauthentication.currentPassword,
salt = account.passwordWrappedArk.salt,
).bind { ChangePasswordError.KeyDerivationFailed }

try {
keyWrapper.unwrapAccountRootKeyWithResult(
kek = kek,
wrapped = WrappedKeyBlob(
ciphertext = account.passwordWrappedArk.key,
nonce = account.passwordWrappedArk.keyIV,
),
userId = account.id,
).bind { ChangePasswordError.IncorrectPassword }
} finally {
kek.fill(0)
when (reauthentication) {
is Reauthentication.Password -> session.verifyPassword(
password = reauthentication.currentPassword,
salt = account.passwordWrappedArk.salt,
wrapped = WrappedKeyBlob(
ciphertext = account.passwordWrappedArk.key,
nonce = account.passwordWrappedArk.keyIV,
),
userId = account.id,
).bind {
when (it) {
is SessionError.Derivation -> ChangePasswordError.KeyDerivationFailed
SessionError.Locked -> ChangePasswordError.ActiveAccountNotFound
else -> ChangePasswordError.IncorrectPassword
}
}

is Reauthentication.Biometric -> {
account.biometricWrappedArk
?: return Result.Failure(ChangePasswordError.BiometricNotEnrolled)
reauthentication.recoveredArk
val matches = session.verifyArk(reauthentication.recoveredArk)
.bind { ChangePasswordError.ActiveAccountNotFound }
if (!matches) return Result.Failure(ChangePasswordError.IncorrectPassword)
}
}

try {
val newSalt = keyDeriver.generateSalt()
val newKek = keyDeriver.deriveRootKekFromPasswordWithResult(
password = newPassword,
salt = newSalt,
).bind { ChangePasswordError.KeyDerivationFailed }

val rewrapped = try {
keyWrapper.wrapAccountRootKeyWithResult(
kek = newKek,
ark = ark,
userId = account.id,
).bind { ChangePasswordError.WrappingFailed }
} finally {
newKek.fill(0)
val rewrapped = session.rewrapForNewPassword(newPassword, account.id).bind {
when (it) {
is SessionError.Derivation -> ChangePasswordError.KeyDerivationFailed
SessionError.Locked -> ChangePasswordError.ActiveAccountNotFound
else -> ChangePasswordError.WrappingFailed
}
}

accountRepository.set(
account.copy(
passwordWrappedArk = PasswordWrappedArk(
key = rewrapped.ciphertext,
keyIV = rewrapped.nonce,
salt = newSalt,
),
accountRepository.set(
account.copy(
passwordWrappedArk = PasswordWrappedArk(
key = rewrapped.wrapped.ciphertext,
keyIV = rewrapped.wrapped.nonce,
salt = rewrapped.salt,
),
).bind { ChangePasswordError.PersistenceFailed }
} finally {
// Scrub the in-memory ARK on success *and* on every failure path after unwrap.
ark.fill(0)
}
),
).bind { ChangePasswordError.PersistenceFailed }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,43 +9,32 @@ import de.davis.keygo.core.item.domain.model.Vault
import de.davis.keygo.core.item.domain.repository.VaultContextRepository
import de.davis.keygo.core.item.domain.repository.VaultRepository
import de.davis.keygo.core.security.domain.Session
import de.davis.keygo.core.security.domain.SessionError
import de.davis.keygo.core.security.domain.useArk
import de.davis.keygo.core.util.Result
import de.davis.keygo.core.util.asResult
import de.davis.keygo.core.util.getOrNull
import de.davis.keygo.core.util.isFailure
import de.davis.keygo.core.util.resultBinding
import de.davis.keygo.rust.account.AccountManager
import de.davis.keygo.rust.derive.KeyDeriver
import de.davis.keygo.rust.derive.deriveRootKekFromPasswordWithResult
import de.davis.keygo.rust.wrap.KeyWrapper
import de.davis.keygo.rust.wrap.wrapAccountRootKeyWithResult
import de.davis.keygo.rust.wrap.wrapVaultKeyWithResult
import de.davisalessandro.keygo.rust.AccountRootKey
import de.davisalessandro.keygo.rust.RootKek
import org.koin.core.annotation.Single
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
import de.davisalessandro.keygo.rust.Account as RustAccount


@Single
class CreateAccessUseCase(
private val keyDeriver: KeyDeriver,
private val keyWrapper: KeyWrapper,
private val accountManager: AccountManager,
private val accountRepository: AccountRepository,
private val vaultRepository: VaultRepository,
private val vaultContextRepository: VaultContextRepository,
private val session: Session
private val session: Session,
) {

/**
* Use case to create access by generating a new account and vault, which are then wrapped
* with a Key Encryption Key (KEK) derived from the user's password. Optionally, the ARK
* (AccountRootKey) can also be wrapped with a KEK derived from biometric data.
* Use case to create access by generating a new account and vault. The session mints the ARK
* in Rust, wraps it under a KEK derived from the user's password, and keeps custody of it, so
* the caller is left unlocked without the key ever reaching the JVM heap. Optionally, a second
* copy of the ARK is wrapped with a biometric-backed Keystore cipher.
*
* The generated ARK is stored in the session for immediate use. The password-wrapped ARK and,
* if applicable, the biometric-wrapped ARK are stored in the [AccountRepository] for future
* retrieval.
* The password-wrapped ARK and, if applicable, the biometric-wrapped ARK are stored in the
* [AccountRepository] for future retrieval.
*
* @param password The user's password used to derive the KEK for wrapping the ARK.
* @param biometricCipher An optional [Cipher] initialized for wrapping the ARK with biometric data.
Expand All @@ -55,33 +44,47 @@ class CreateAccessUseCase(
biometricCipher: Cipher? = null,
vaultName: String = "Default Vault",
accountDisplayName: String = "Default Account",
): Result<Unit, CreateAccessError> = resultBinding {
val salt = keyDeriver.generateSalt()
val derivedKek = keyDeriver.deriveRootKekFromPasswordWithResult(
password = password,
salt = salt,
).getOrNull() ?: return Result.Failure(CreateAccessError.KeyDerivationFailed)

val accountHolder = accountManager.createAccount()

val passwordWrappedArk =
getPasswordWrappedArk(accountHolder.account, derivedKek, salt).bind()

val wrappedVaultKey = accountHolder.defaultVault.wrap(accountHolder.account.ark)
.bind { CreateAccessError.WrappingFailed }
): Result<Unit, CreateAccessError> {
var handBack = true
try {
val result = create(password, biometricCipher, vaultName, accountDisplayName)
handBack = result.isFailure()
return result
} finally {
if (handBack) session.endSession()
}
}

val biometricWrappedArk = biometricCipher?.let {
getBiometricWrappedArk(accountHolder.account, it).bind()
private suspend fun create(
password: String,
biometricCipher: Cipher?,
vaultName: String,
accountDisplayName: String,
): Result<Unit, CreateAccessError> = resultBinding {
val created = session.createAccount(password)
.bind {
if (it is SessionError.Derivation) CreateAccessError.KeyDerivationFailed
else CreateAccessError.WrappingFailed
}

val biometricWrappedArk = biometricCipher?.let { cipher ->
session.useArk { ark ->
wrapArk(ark, cipher).asResult(CreateAccessError.WrappingFailed).bind()
}.bind { CreateAccessError.WrappingFailed }
}

// Persist the account before the vault: the vault is encrypted under the account's
// ARK, so a vault row without a recoverable account is dead weight. If the vault
// write fails after this, the half-state is recoverable on retry, since `set` overwrites.
accountRepository.set(
Account(
id = accountHolder.account.id,
id = created.userId,
displayName = accountDisplayName,
passwordWrappedArk = passwordWrappedArk,
passwordWrappedArk = PasswordWrappedArk(
key = created.passwordWrappedArk.ciphertext,
keyIV = created.passwordWrappedArk.nonce,
salt = created.salt,
),
biometricWrappedArk = biometricWrappedArk,
)
).bind { CreateAccessError.AccountPersistenceFailed }
Expand All @@ -90,52 +93,22 @@ class CreateAccessUseCase(
runCatching {
vaultRepository.createVault(
Vault(
id = accountHolder.defaultVault.id,
id = created.vaultId,
name = vaultName,
wrappedVaultKey = wrappedVaultKey.ciphertext,
vaultKeyNonce = wrappedVaultKey.nonce,
wrappedVaultKey = created.wrappedVaultKey.ciphertext,
vaultKeyNonce = created.wrappedVaultKey.nonce,
icon = Vault.Icon.Default,
)
)
}.onFailure { return Result.Failure(CreateAccessError.VaultPersistenceFailed(it)) }

vaultContextRepository.setContextAndLastInteracted(accountHolder.defaultVault.id)

session.startSession(accountHolder.account.ark)
vaultContextRepository.setContextAndLastInteracted(created.vaultId)
}

private fun getPasswordWrappedArk(
account: RustAccount,
derivedKek: RootKek,
salt: ByteArray
) = account.wrap(derivedKek)
.getOrNull()
?.let { wrappedKey ->
PasswordWrappedArk(
key = wrappedKey.ciphertext,
keyIV = wrappedKey.nonce,
salt = salt
)
}.asResult(CreateAccessError.WrappingFailed)

private fun getBiometricWrappedArk(
account: RustAccount,
biometricCipher: Cipher
) = account.wrapUsingCipher(biometricCipher)
?.let { (wrappedKey, iv) ->
BiometricWrappedArk(
key = wrappedKey,
keyIV = iv
)
}.asResult(CreateAccessError.WrappingFailed)

private fun de.davisalessandro.keygo.rust.Vault.wrap(ark: AccountRootKey) =
keyWrapper.wrapVaultKeyWithResult(ark, vaultKey, id)

private fun RustAccount.wrap(kek: RootKek) =
keyWrapper.wrapAccountRootKeyWithResult(kek, ark, id)

private fun RustAccount.wrapUsingCipher(cipher: Cipher) = runCatching {
cipher.wrap(SecretKeySpec(ark, 0, ark.size, "AES")) to cipher.iv
private fun wrapArk(ark: ByteArray, cipher: Cipher): BiometricWrappedArk? = runCatching {
BiometricWrappedArk(
key = cipher.wrap(SecretKeySpec(ark, 0, ark.size, "AES")),
keyIV = cipher.iv,
)
}.getOrNull()
}
Original file line number Diff line number Diff line change
@@ -1,52 +1,33 @@
package de.davis.keygo.core.identity.domain.usecase

import de.davis.keygo.core.identity.domain.model.PasswordWrappedArk
import de.davis.keygo.core.identity.domain.model.UnlockError
import de.davis.keygo.core.identity.domain.repository.AccountRepository
import de.davis.keygo.core.security.domain.Session
import de.davis.keygo.core.security.domain.SessionError
import de.davis.keygo.core.util.Result
import de.davis.keygo.core.util.resultBinding
import de.davis.keygo.rust.derive.KeyDeriver
import de.davis.keygo.rust.derive.deriveRootKekFromPasswordWithResult
import de.davis.keygo.rust.wrap.KeyWrapper
import de.davis.keygo.rust.wrap.unwrapAccountRootKeyWithResult
import de.davisalessandro.keygo.rust.AccountRootKey
import de.davisalessandro.keygo.rust.KeyWrapException
import de.davisalessandro.keygo.rust.RootKek
import de.davisalessandro.keygo.rust.WrappedKeyBlob
import org.koin.core.annotation.Single
import java.util.UUID

@Single
class UnlockWithPasswordUseCase(
private val session: Session,
private val accountRepository: AccountRepository,
private val keyDeriver: KeyDeriver,
private val keyWrapper: KeyWrapper,
) {

suspend operator fun invoke(password: String): Result<Unit, UnlockError> = resultBinding {
val account = accountRepository.getOrNull()
?: return Result.Failure(UnlockError.ActiveAccountNotFound)

val wrappedKey = account.passwordWrappedArk
val derivedKey = keyDeriver.deriveRootKekFromPasswordWithResult(
session.unlockWithPassword(
password = password,
salt = wrappedKey.salt,
).bind { UnlockError.DerivationFailed }

val key = wrappedKey.unwrapUsing(derivedKey, account.id)
.bind { UnlockError.UnwrappingFailed }

session.startSession(key)
wrapped = WrappedKeyBlob(ciphertext = wrappedKey.key, nonce = wrappedKey.keyIV),
userId = account.id,
).bind {
if (it is SessionError.Derivation) UnlockError.DerivationFailed
else UnlockError.UnwrappingFailed
}
}

private fun PasswordWrappedArk.unwrapUsing(
kek: RootKek,
userId: UUID,
): Result<AccountRootKey, KeyWrapException> = keyWrapper.unwrapAccountRootKeyWithResult(
kek = kek,
wrapped = WrappedKeyBlob(ciphertext = this.key, nonce = this.keyIV),
userId = userId,
)
}
Loading
Loading