Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ Android password manager using Clean Architecture per module:
| Module | Purpose |
|--------------------------|--------------------------------------------------------------------------|
| `:app` | Navigation, app-level DI, dashboard |
| `:core:security` | Crypto, biometrics, Android Keystore |
| `:core:security` | Crypto, Android Keystore |
| `:core:biometrics` | Biometric prompt (`BiometricCrypto`), biometric availability |
| `:core:identity` | Key wrapping, auth data, proto schemas (`core/identity/src/main/proto/`) |
| `:core:item` | Room database, login/item entities |
| `:core:ui` | Shared composables and UI utilities |
Expand Down Expand Up @@ -70,7 +71,7 @@ Composition root: `app/di/Koin.kt`. Wire dependencies in the most local owning m

## Security

`KeyStoreManager`, `BiometricCryptoController`, `Session` (active DEK). Wrapped keys in proto
`KeyStoreManager`, `BiometricCrypto`, `Session` (active DEK). Wrapped keys in proto
DataStore: `biometric_key_data.pb`, `password_key_data.pb`. Do not change key lifecycle, wrapping,
prompt flow, or persistence semantics without explicit instruction.

Expand Down
29 changes: 29 additions & 0 deletions core/biometrics/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
plugins {
alias(libs.plugins.keygo.android.library)
}

android {
namespace = "de.davis.keygo.core.biometrics"

testFixtures {
enable = true
}

testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}

dependencies {
api(projects.core.security)
implementation(libs.androidx.biometric)

testImplementation(libs.robolectric)
testImplementation(testFixtures(projects.core.security))
testImplementation(testFixtures(projects.core.util))

testFixturesApi(testFixtures(projects.core.security))
testFixturesImplementation(libs.kotlinx.coroutines.core)
}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package de.davis.keygo.core.biometrics.data

import android.app.Activity
import android.app.Application
import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.fragment.app.FragmentActivity
import de.davis.keygo.core.biometrics.domain.BiometricCrypto
import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError
import de.davis.keygo.core.biometrics.domain.model.BiometricPolicy
import de.davis.keygo.core.biometrics.domain.repository.BiometricAvailabilityRepository
import de.davis.keygo.core.security.data.keyStoreManagerErrorFrom
import de.davis.keygo.core.security.domain.KeyStoreManager
import de.davis.keygo.core.security.domain.crypto.model.CryptographicData
import de.davis.keygo.core.security.domain.model.CryptographicMode
import de.davis.keygo.core.security.domain.model.KeyId
import de.davis.keygo.core.security.domain.model.KeyStoreManagerError
import de.davis.keygo.core.util.Result
import de.davis.keygo.core.util.asResult
import de.davis.keygo.core.util.resultBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.annotation.Single
import java.security.Key
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
import kotlin.coroutines.resume
import kotlin.time.Duration.Companion.milliseconds

@Single(createdAtStart = true, binds = [BiometricCrypto::class])
internal class BiometricCryptoImpl(
context: Context,
private val biometricAvailabilityRepository: BiometricAvailabilityRepository,
private val keyStoreManager: KeyStoreManager,
) : BiometricCrypto, Application.ActivityLifecycleCallbacks {

private val host = MutableStateFlow<FragmentActivity?>(null)

private val promptLock = Mutex()

init {
(context.applicationContext as Application).registerActivityLifecycleCallbacks(this)
}

override fun onActivityResumed(activity: Activity) {
if (activity is FragmentActivity) host.update { activity }
}

override fun onActivityPaused(activity: Activity) {
host.update { if (it === activity) null else it }
}

override fun onActivityCreated(p0: Activity, p1: Bundle?) = Unit
override fun onActivityDestroyed(p0: Activity) = Unit
override fun onActivitySaveInstanceState(p0: Activity, p1: Bundle) = Unit
override fun onActivityStarted(p0: Activity) = Unit
override fun onActivityStopped(p0: Activity) = Unit

private suspend fun awaitHost(): FragmentActivity? = withTimeoutOrNull(250.milliseconds) {
host.filterNotNull().first { !it.isFinishing }
}

override suspend fun <T> requestWrap(
keyId: KeyId,
policy: BiometricPolicy,
wrap: (seal: (key: ByteArray) -> CryptographicData) -> T,
): Result<T, BiometricAuthError> = request(
keyId = keyId,
policy = policy,
mode = CryptographicMode.Wrap,
) { cipher ->
wrap { key ->
CryptographicData(
data = cipher.wrap(SecretKeySpec(key, 0, key.size, "AES")),
iv = cipher.iv,
)
}
}

override suspend fun requestUnwrap(
keyId: KeyId,
cryptographicData: CryptographicData,
policy: BiometricPolicy,
): Result<Key, BiometricAuthError> = request(
keyId = keyId,
policy = policy,
mode = CryptographicMode.Unwrap,
iv = cryptographicData.iv,
) { it.unwrap(cryptographicData.data, "AES", Cipher.SECRET_KEY) }

private suspend fun <T> request(
keyId: KeyId,
policy: BiometricPolicy,
mode: CryptographicMode,
iv: ByteArray? = null,
onSuccess: (Cipher) -> T,
): Result<T, BiometricAuthError> = promptLock.withLock {
resultBinding {
biometricAvailabilityRepository.availability()
.asResult(BiometricAuthError.BiometricsNotAvailable)
.bind()

val activity = awaitHost()
.asResult(BiometricAuthError.NoPromptHost)
.bind()

val cipher = keyStoreManager.getOrCreateCipherFor(keyId, mode, iv)
.bind { it.toBiometricAuthError() }

activity.authenticate(policy, cipher, onSuccess).bind()
}
}

private suspend fun <T> FragmentActivity.authenticate(
policy: BiometricPolicy,
cipher: Cipher,
onSuccess: (Cipher) -> T,
): Result<T, BiometricAuthError> = suspendCancellableCoroutine { c ->
// The prompt that ran before this one removes its fragment in a transaction that has not
// run yet. A new prompt would reuse that fragment, which shows nothing and never calls back.
supportFragmentManager.executePendingTransactions()

val prompt = BiometricPrompt(
this,
Dispatchers.Main.asExecutor(),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
val authenticated = result.cryptoObject?.cipher ?: return c.resume(
Result.Failure(BiometricAuthError.NoCipher)
)

runCatching { onSuccess(authenticated) }.fold(
onSuccess = { c.resume(Result.Success(it)) },
onFailure = {
Log.e(
TAG,
"Cipher operation failed after authentication succeeded",
it,
)
c.resume(Result.Failure(cipherFailureToBiometricAuthError(it)))
},
)
}

override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
c.resume(Result.Failure(biometricAuthErrorFrom(errorCode, errString)))
}

override fun onAuthenticationFailed() {
// Not an outcome. A rejected attempt leaves the prompt open for another one,
// and how it ends still arrives through the callbacks above.
}
},
)

val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(policy.title.resolve(this))
.setNegativeButtonText(policy.negativeButton.resolve(this))
.setAllowedAuthenticators(AUTHENTICATORS)
.build()

prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))

c.invokeOnCancellation { prompt.cancelAuthentication() }
}

companion object {

private const val AUTHENTICATORS = BiometricManager.Authenticators.BIOMETRIC_STRONG
private const val TAG = "BiometricCryptoImpl"
}
}

internal fun cipherFailureToBiometricAuthError(throwable: Throwable): BiometricAuthError =
keyStoreManagerErrorFrom(throwable).toBiometricAuthError()

internal fun KeyStoreManagerError.toBiometricAuthError(): BiometricAuthError = when (this) {
KeyStoreManagerError.KeyInvalidated -> BiometricAuthError.KeyInvalidated
KeyStoreManagerError.AuthenticationRequired -> BiometricAuthError.CryptoFailed
KeyStoreManagerError.Unknown -> BiometricAuthError.CryptoFailed
}

internal fun biometricAuthErrorFrom(
errorCode: Int,
errString: CharSequence,
): BiometricAuthError = when (errorCode) {
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> BiometricAuthError.Declined

BiometricPrompt.ERROR_LOCKOUT,
BiometricPrompt.ERROR_LOCKOUT_PERMANENT,
-> BiometricAuthError.LockedOut

BiometricPrompt.ERROR_USER_CANCELED,
BiometricPrompt.ERROR_CANCELED,
-> BiometricAuthError.Canceled

else -> BiometricAuthError.Unknown(errorCode, errString.toString())
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package de.davis.keygo.core.security.data
package de.davis.keygo.core.biometrics.data

import android.content.Context
import de.davis.keygo.core.security.R
import de.davis.keygo.core.security.domain.model.BiometricString
import de.davis.keygo.core.biometrics.R
import de.davis.keygo.core.biometrics.domain.model.BiometricString

internal fun BiometricString.resolve(context: Context) = when (this) {
fun BiometricString.resolve(context: Context) = when (this) {
is BiometricString.Title.Authenticate -> context.getString(
R.string.authenticate
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package de.davis.keygo.core.security.data.repository
package de.davis.keygo.core.biometrics.data.repository

import android.content.Context
import androidx.biometric.BiometricManager
import de.davis.keygo.core.security.domain.repository.BiometricAvailabilityRepository
import de.davis.keygo.core.biometrics.domain.repository.BiometricAvailabilityRepository
import org.koin.core.annotation.Single

@Single
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package de.davis.keygo.core.biometrics.di

import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Configuration
import org.koin.core.annotation.Module

@Module
@Configuration
@ComponentScan("de.davis.keygo.core.biometrics")
object CoreBiometricsModule
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package de.davis.keygo.core.biometrics.domain

import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError
import de.davis.keygo.core.biometrics.domain.model.BiometricPolicy
import de.davis.keygo.core.security.domain.crypto.model.CryptographicData
import de.davis.keygo.core.security.domain.model.KeyId
import de.davis.keygo.core.util.Result
import java.security.Key

interface BiometricCrypto {

suspend fun <T> requestWrap(
keyId: KeyId,
policy: BiometricPolicy = BiometricPolicy.Default,
wrap: (seal: (key: ByteArray) -> CryptographicData) -> T,
): Result<T, BiometricAuthError>

suspend fun requestUnwrap(
keyId: KeyId,
cryptographicData: CryptographicData,
policy: BiometricPolicy = BiometricPolicy.Default,
): Result<Key, BiometricAuthError>
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package de.davis.keygo.core.security.domain.model
package de.davis.keygo.core.biometrics.domain.model


sealed interface BiometricAuthError {
data object NoPromptHost : BiometricAuthError


/** User canceled the prompt by pressing the negative button. */
data object Declined : BiometricAuthError
data object LockedOut : BiometricAuthError
Expand All @@ -11,7 +14,7 @@ sealed interface BiometricAuthError {
data class Unknown(val errorCode: Int, val errString: String) : BiometricAuthError

/** Biometrics cannot be used at all (no hardware, none enrolled, etc.). */
data class CanNotAuthenticate(val code: Int) : BiometricAuthError
data object BiometricsNotAvailable : BiometricAuthError
data object NoCipher : BiometricAuthError

data object CryptoFailed : BiometricAuthError
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package de.davis.keygo.core.security.domain.model
package de.davis.keygo.core.biometrics.domain.model

data class BiometricPolicy(
val title: BiometricString.Title = BiometricString.Title.Authenticate,
val negativeButton: BiometricString.NegativeButton = BiometricString.NegativeButton.Cancel
val negativeButton: BiometricString.NegativeButton = BiometricString.NegativeButton.Cancel,
) {
companion object {
val Default = BiometricPolicy()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package de.davis.keygo.core.security.domain.model
package de.davis.keygo.core.biometrics.domain.model

sealed interface BiometricString {

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package de.davis.keygo.core.security.domain.repository
package de.davis.keygo.core.biometrics.domain.repository

interface BiometricAvailabilityRepository {

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package de.davis.keygo.core.security.presentation
package de.davis.keygo.core.biometrics.data

import androidx.biometric.BiometricPrompt
import de.davis.keygo.core.security.domain.model.BiometricAuthError
import de.davis.keygo.core.biometrics.domain.model.BiometricAuthError
import kotlin.test.Test
import kotlin.test.assertEquals

Expand Down
Loading
Loading