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)
|
||||
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)
|
||||
|
|
|
|||
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) {
|
||||
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) }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Reference in a new issue