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:
parent
278d98d125
commit
88559bf49e
7 changed files with 267 additions and 4 deletions
|
|
@ -60,6 +60,8 @@ dependencies {
|
||||||
// Room offline cache (local SQLite read-cache for conversations + messages)
|
// Room offline cache (local SQLite read-cache for conversations + messages)
|
||||||
implementation(libs.androidx.room.runtime)
|
implementation(libs.androidx.room.runtime)
|
||||||
ksp(libs.androidx.room.compiler)
|
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)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
|
|
||||||
158
app/src/main/java/dev/kaizen/app/auth/BiometricUnlock.kt
Normal file
158
app/src/main/java/dev/kaizen/app/auth/BiometricUnlock.kt
Normal 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) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -210,11 +210,52 @@ fun ChatScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun openConversation(summary: ConversationSummary) {
|
fun openConversation(summary: ConversationSummary) {
|
||||||
if (summary.locked) return
|
|
||||||
val cfg = session.config ?: 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 {
|
scope.launch {
|
||||||
// Instant: load from Room cache first
|
|
||||||
val cached = chat.messageRepo.observeMessages(summary.id)
|
|
||||||
messages.clear()
|
messages.clear()
|
||||||
val cachedList = chat.messageRepo.getCached(summary.id)
|
val cachedList = chat.messageRepo.getCached(summary.id)
|
||||||
cachedList.forEach { m ->
|
cachedList.forEach { m ->
|
||||||
|
|
@ -231,7 +272,6 @@ fun ChatScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
conversationId = summary.id
|
conversationId = summary.id
|
||||||
// Background: refresh from server and update Room + UI
|
|
||||||
val stored = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, summary.id)
|
val stored = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, summary.id)
|
||||||
if (stored.isNotEmpty()) {
|
if (stored.isNotEmpty()) {
|
||||||
val entities = stored.mapIndexed { i, m -> m.toEntity(summary.id, i) }
|
val entities = stored.mapIndexed { i, m -> m.toEntity(summary.id, i) }
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ data class ConversationSummary(
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable private data class ConversationsResponse(val conversations: List<ConversationSummary> = emptyList())
|
@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 CreateConvoRequest(val title: String? = null)
|
||||||
@Serializable private data class CreatedConversation(val id: String)
|
@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. */
|
/** POST /api/v1/conversations/[id]/title — fire-and-forget auto-title after the first exchange. */
|
||||||
suspend fun generateTitle(baseUrl: String, token: String, id: String) =
|
suspend fun generateTitle(baseUrl: String, token: String, id: String) =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,14 @@
|
||||||
<string name="sidebar_confirm">Confirm</string>
|
<string name="sidebar_confirm">Confirm</string>
|
||||||
<string name="sidebar_save">Save</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 -->
|
<!-- Model sheet -->
|
||||||
<string name="model_sheet_title">Select Model</string>
|
<string name="model_sheet_title">Select Model</string>
|
||||||
<string name="model_search_placeholder">Search models…</string>
|
<string name="model_search_placeholder">Search models…</string>
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,14 @@
|
||||||
<string name="sidebar_confirm">Bestätigen</string>
|
<string name="sidebar_confirm">Bestätigen</string>
|
||||||
<string name="sidebar_save">Speichern</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 -->
|
<!-- Model sheet -->
|
||||||
<string name="model_sheet_title">Modell auswählen</string>
|
<string name="model_sheet_title">Modell auswählen</string>
|
||||||
<string name="model_search_placeholder">Modell suchen…</string>
|
<string name="model_search_placeholder">Modell suchen…</string>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ okhttp = "4.12.0"
|
||||||
kotlinxSerialization = "1.7.3"
|
kotlinxSerialization = "1.7.3"
|
||||||
securityCrypto = "1.1.0-alpha06"
|
securityCrypto = "1.1.0-alpha06"
|
||||||
room = "2.8.4"
|
room = "2.8.4"
|
||||||
|
biometric = "1.4.0-alpha02"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-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-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||||
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", 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" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue