From 88559bf49ef5af06f9341cc3b39af06713d3e77b Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Sun, 21 Jun 2026 23:56:57 +0200 Subject: [PATCH] biometric unlock for locked conversations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/build.gradle.kts | 2 + .../dev/kaizen/app/auth/BiometricUnlock.kt | 158 ++++++++++++++++++ .../java/dev/kaizen/app/chat/ChatScreen.kt | 48 +++++- .../main/java/dev/kaizen/app/net/KaizenApi.kt | 45 +++++ app/src/main/res/values-en/strings.xml | 8 + app/src/main/res/values/strings.xml | 8 + gradle/libs.versions.toml | 2 + 7 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/dev/kaizen/app/auth/BiometricUnlock.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 64748a0..ea90037 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/dev/kaizen/app/auth/BiometricUnlock.kt b/app/src/main/java/dev/kaizen/app/auth/BiometricUnlock.kt new file mode 100644 index 0000000..0a4537e --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/auth/BiometricUnlock.kt @@ -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) { } + } +} diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt index ad61763..ebe9ae1 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -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) } diff --git a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt index 310f547..629b4c0 100644 --- a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt +++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt @@ -58,6 +58,7 @@ data class ConversationSummary( ) @Serializable private data class ConversationsResponse(val conversations: List = 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(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 = + 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(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) { diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 84ccc9c..4c91030 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -74,6 +74,14 @@ Confirm Save + + Unlock Chat + Confirm your identity + Cancel + Biometrics not available + Authentication failed + Tap to unlock + Select Model Search models… diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1763a43..4f5d6f9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -76,6 +76,14 @@ Bestätigen Speichern + + Chat entsperren + Bestätige deine Identität + Abbrechen + Biometrie nicht verfügbar + Authentifizierung fehlgeschlagen + Tippen zum Entsperren + Modell auswählen Modell suchen… diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7cc5791..94df565 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }