biometric unlock for locked conversations

Hardware-enforced via Android KeyStore + CryptoObject:
- AES key in TEE/StrongBox with setUserAuthenticationRequired(true)
- BiometricPrompt receives Cipher as CryptoObject, unlocked only
  after real biometric auth (fingerprint/face), not bypassable
- Key auto-invalidated on biometric enrollment change

Flow: tap locked chat → BiometricPrompt → on success request
server unlock token (POST /api/v1/auth/unlock) → fetch messages
with X-Unlock-Token header → display conversation.

Dependencies: androidx.biometric:biometric:1.4.0-alpha02
This commit is contained in:
Bruno Deanoz 2026-06-21 23:56:57 +02:00
parent 278d98d125
commit 88559bf49e
7 changed files with 267 additions and 4 deletions

View file

@ -60,6 +60,8 @@ dependencies {
// Room offline cache (local SQLite read-cache for conversations + messages)
implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler)
// Biometric auth (fingerprint/face unlock) for locked conversations — hardware-enforced via TEE/StrongBox
implementation(libs.androidx.biometric)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)

View file

@ -0,0 +1,158 @@
package dev.kaizen.app.auth
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
import android.util.Log
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
private const val TAG = "BiometricUnlock"
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
private const val KEY_ALIAS = "kaizen_biometric_unlock"
/**
* Hardware-enforced biometric unlock using Android KeyStore + CryptoObject.
*
* A symmetric AES key is generated in the TEE/StrongBox with
* [KeyGenParameterSpec.Builder.setUserAuthenticationRequired] the key
* material never leaves the secure hardware and can only be used after
* successful biometric authentication. [BiometricPrompt] receives a
* [Cipher] backed by this key as its [BiometricPrompt.CryptoObject];
* the Cipher is unlocked by the TEE only after the biometric check
* passes. Even on rooted devices the key cannot be extracted or used
* without a real biometric event.
*
* If biometric enrollment changes (new fingerprint added), the key is
* invalidated ([setInvalidatedByBiometricEnrollment]) and automatically
* re-generated on the next attempt.
*/
object BiometricUnlock {
sealed interface Result {
data object Success : Result
data object NotAvailable : Result
data class Failed(val message: String) : Result
}
fun isAvailable(activity: FragmentActivity): Boolean {
val bm = BiometricManager.from(activity)
return bm.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
BiometricManager.BIOMETRIC_SUCCESS
}
fun authenticate(
activity: FragmentActivity,
title: String,
subtitle: String,
negativeButtonText: String,
onResult: (Result) -> Unit,
) {
if (!isAvailable(activity)) {
onResult(Result.NotAvailable)
return
}
val cipher = try {
getCipher()
} catch (e: KeyPermanentlyInvalidatedException) {
Log.w(TAG, "Key invalidated (biometric enrollment changed), regenerating")
deleteKey()
try {
getCipher()
} catch (e2: Exception) {
Log.e(TAG, "Failed to regenerate key", e2)
onResult(Result.Failed(e2.message ?: "KeyStore error"))
return
}
} catch (e: Exception) {
Log.e(TAG, "Failed to init cipher", e)
onResult(Result.Failed(e.message ?: "KeyStore error"))
return
}
val cryptoObject = BiometricPrompt.CryptoObject(cipher)
val executor = ContextCompat.getMainExecutor(activity)
val prompt = BiometricPrompt(activity, executor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
val authedCipher = result.cryptoObject?.cipher
if (authedCipher != null) {
onResult(Result.Success)
} else {
onResult(Result.Failed("CryptoObject missing after auth"))
}
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
if (errorCode == BiometricPrompt.ERROR_USER_CANCELED ||
errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON ||
errorCode == BiometricPrompt.ERROR_CANCELED
) {
onResult(Result.Failed("cancelled"))
} else {
onResult(Result.Failed(errString.toString()))
}
}
override fun onAuthenticationFailed() {
// Called on each failed attempt (wrong finger), prompt stays open.
// Only onAuthenticationError terminates the flow.
}
})
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setNegativeButtonText(negativeButtonText)
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.build()
prompt.authenticate(promptInfo, cryptoObject)
}
private fun getOrCreateKey(): SecretKey {
val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey }
val builder = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_CBC)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
.setUserAuthenticationRequired(true)
.setInvalidatedByBiometricEnrollment(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
builder.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
}
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER)
.apply { init(builder.build()) }
.generateKey()
return keyStore.getKey(KEY_ALIAS, null) as SecretKey
}
private fun getCipher(): Cipher {
val key = getOrCreateKey()
return Cipher.getInstance("${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_CBC}/${KeyProperties.ENCRYPTION_PADDING_PKCS7}")
.apply { init(Cipher.ENCRYPT_MODE, key) }
}
private fun deleteKey() {
try {
val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
keyStore.deleteEntry(KEY_ALIAS)
} catch (_: Exception) { }
}
}

View file

@ -210,11 +210,52 @@ fun ChatScreen(
}
fun openConversation(summary: ConversationSummary) {
if (summary.locked) return
val cfg = session.config ?: return
if (summary.locked) {
val activity = context as? androidx.fragment.app.FragmentActivity ?: return
dev.kaizen.app.auth.BiometricUnlock.authenticate(
activity = activity,
title = context.getString(R.string.biometric_title),
subtitle = context.getString(R.string.biometric_subtitle),
negativeButtonText = context.getString(R.string.biometric_cancel),
) { result ->
when (result) {
is dev.kaizen.app.auth.BiometricUnlock.Result.Success -> {
scope.launch {
val unlockToken = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token)
if (unlockToken == null) {
loadError = context.getString(R.string.biometric_failed)
return@launch
}
val stored = KaizenApi.fetchLockedMessages(cfg.baseUrl, cfg.token, summary.id, unlockToken)
if (stored.isNotEmpty()) {
messages.clear()
stored.forEach { m ->
messages.add(
Message(
id = nextId++,
role = if (m.role == "assistant") Role.Assistant else Role.User,
content = m.content,
wireId = m.id,
attachments = m.attachments,
reasoning = m.reasoning ?: "",
sources = m.sources ?: emptyList(),
),
)
}
conversationId = summary.id
}
}
}
is dev.kaizen.app.auth.BiometricUnlock.Result.NotAvailable -> {
loadError = context.getString(R.string.biometric_not_available)
}
is dev.kaizen.app.auth.BiometricUnlock.Result.Failed -> { /* user cancelled or failed, do nothing */ }
}
}
return
}
scope.launch {
// Instant: load from Room cache first
val cached = chat.messageRepo.observeMessages(summary.id)
messages.clear()
val cachedList = chat.messageRepo.getCached(summary.id)
cachedList.forEach { m ->
@ -231,7 +272,6 @@ fun ChatScreen(
)
}
conversationId = summary.id
// Background: refresh from server and update Room + UI
val stored = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, summary.id)
if (stored.isNotEmpty()) {
val entities = stored.mapIndexed { i, m -> m.toEntity(summary.id, i) }

View file

@ -58,6 +58,7 @@ data class ConversationSummary(
)
@Serializable private data class ConversationsResponse(val conversations: List<ConversationSummary> = emptyList())
@Serializable private data class UnlockResponse(val unlockToken: String = "")
@Serializable private data class CreateConvoRequest(val title: String? = null)
@Serializable private data class CreatedConversation(val id: String)
@ -361,6 +362,50 @@ object KaizenApi {
}
}
/** POST /api/v1/auth/unlock — get a short-lived unlock token for locked conversations. */
suspend fun requestUnlock(baseUrl: String, token: String): String? =
withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/api/v1/auth/unlock")
.post("{}".toRequestBody(jsonMedia))
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) {
Log.w(TAG, "requestUnlock: HTTP ${resp.code}")
return@use null
}
json.decodeFromString<UnlockResponse>(resp.body!!.string()).unlockToken
}
} catch (e: Exception) {
Log.w(TAG, "requestUnlock: ${e.javaClass.simpleName}: ${e.message}")
null
}
}
/** GET /api/v1/conversations/[id] with unlock token — fetch messages of a locked conversation. */
suspend fun fetchLockedMessages(baseUrl: String, token: String, id: String, unlockToken: String): List<StoredMessage> =
withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/api/v1/conversations/$id")
.header("Authorization", "Bearer $token")
.header("X-Unlock-Token", unlockToken)
.build()
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) {
Log.w(TAG, "fetchLockedMessages($id): HTTP ${resp.code}")
return@use emptyList()
}
json.decodeFromString<ConversationDetail>(resp.body!!.string()).messages
}
} catch (e: Exception) {
Log.w(TAG, "fetchLockedMessages($id): ${e.javaClass.simpleName}: ${e.message}")
emptyList()
}
}
/** POST /api/v1/conversations/[id]/title — fire-and-forget auto-title after the first exchange. */
suspend fun generateTitle(baseUrl: String, token: String, id: String) =
withContext(Dispatchers.IO) {

View file

@ -74,6 +74,14 @@
<string name="sidebar_confirm">Confirm</string>
<string name="sidebar_save">Save</string>
<!-- Biometric unlock -->
<string name="biometric_title">Unlock Chat</string>
<string name="biometric_subtitle">Confirm your identity</string>
<string name="biometric_cancel">Cancel</string>
<string name="biometric_not_available">Biometrics not available</string>
<string name="biometric_failed">Authentication failed</string>
<string name="unlock_tap_to_unlock">Tap to unlock</string>
<!-- Model sheet -->
<string name="model_sheet_title">Select Model</string>
<string name="model_search_placeholder">Search models…</string>

View file

@ -76,6 +76,14 @@
<string name="sidebar_confirm">Bestätigen</string>
<string name="sidebar_save">Speichern</string>
<!-- Biometric unlock -->
<string name="biometric_title">Chat entsperren</string>
<string name="biometric_subtitle">Bestätige deine Identität</string>
<string name="biometric_cancel">Abbrechen</string>
<string name="biometric_not_available">Biometrie nicht verfügbar</string>
<string name="biometric_failed">Authentifizierung fehlgeschlagen</string>
<string name="unlock_tap_to_unlock">Tippen zum Entsperren</string>
<!-- Model sheet -->
<string name="model_sheet_title">Modell auswählen</string>
<string name="model_search_placeholder">Modell suchen…</string>

View file

@ -13,6 +13,7 @@ okhttp = "4.12.0"
kotlinxSerialization = "1.7.3"
securityCrypto = "1.1.0-alpha06"
room = "2.8.4"
biometric = "1.4.0-alpha02"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -21,6 +22,7 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }