From 15f5781824c492c87263809a602eb63f32c06154 Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Mon, 22 Jun 2026 13:27:55 +0200 Subject: [PATCH] fix: close biometric bypass on locked conversations Three paths in openConversation() called unlockAndOpen() without any authentication when biometrics were unavailable. Sidebar ToggleLock also sent PATCH { locked: false } without auth. - Unified requestBiometricOrPassword() gates all unlock paths - PasswordUnlockDialog as fallback when biometrics unavailable - KaizenApi.requestUnlock() sends password to server for verification - All unlock flows (open chat + sidebar toggle) now require auth --- CLAUDE.md | 7 +- .../kaizen/app/auth/PasswordUnlockDialog.kt | 207 ++++++++++++++++++ .../java/dev/kaizen/app/chat/ChatScreen.kt | 132 ++++++++--- .../main/java/dev/kaizen/app/net/KaizenApi.kt | 6 +- app/src/main/res/values-en/strings.xml | 6 + app/src/main/res/values/strings.xml | 6 + 6 files changed, 327 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/dev/kaizen/app/auth/PasswordUnlockDialog.kt diff --git a/CLAUDE.md b/CLAUDE.md index b451897..fa2f84b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ Make the **native Android app** (`kaizen-app`) a valid daily-driver that talks t ### Package structure (`app/src/main/java/dev/kaizen/app/`) ``` -auth/ LoginScreen, BiometricUnlock (KeyStore + CryptoObject) +auth/ LoginScreen, BiometricUnlock (KeyStore + CryptoObject), PasswordUnlockDialog chat/ ChatScreen, ChatViewModel, Sidebar, ChatComponents, ChatModels, ModelSheet, Markdown, Background db/ Room offline cache: KaizenDatabase, Entities, DAOs, @@ -227,7 +227,7 @@ All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKo | `f3d8154` | **Sidebar redesign** — GlassSurface opacity 0.92/0.88, flat conversation rows, 3-dot context menu (rename/delete/pin/lock via `PATCH`/`DELETE /api/v1/conversations`), smaller avatar (30dp), logout integrated into user card | | `f3d8154` | **Markdown typography overhaul** — line-height 23→26sp (×1.625), paragraph spacing 6→12dp, heading margins 18dp/8dp, list spacing doubled. Matches Gemini readability | | `532cb8c` | **Reasoning + sources persistence** — saved to server (`SaveMessage.reasoning/sources`), loaded back (`StoredMessage`), cached in Room (`MessageEntity` + `SearchSource` TypeConverter). Reasoning rendered above content, sources below | -| `88559bf` | **Biometric unlock for locked conversations** — Android KeyStore AES key with `setUserAuthenticationRequired(true)` + CryptoObject, hardware-enforced. Server unlock token via `POST /api/v1/auth/unlock` + `X-Unlock-Token` header. Auto-lock on app background | +| `88559bf` | **Biometric unlock for locked conversations** — Android KeyStore AES key with `setUserAuthenticationRequired(true)` + CryptoObject, hardware-enforced. Server unlock token via `POST /api/v1/auth/unlock` + `X-Unlock-Token` header. Auto-lock on app background. Password fallback dialog (`PasswordUnlockDialog`) when biometrics unavailable — server verifies password via Argon2id. All unlock paths (open chat, sidebar toggle) gated behind `requestBiometricOrPassword()` | | `30d3f28` | **Search mode functional** — `webSearch: true` in ChatRequest triggers agentive web search. Sources/query parsed by StreamConsumer, rendered in collapsible SourcesBlock with clickable links (ACTION_VIEW) | | `f6dc5db` | **Reasoning presets + sampling controls** — 8-preset dropdown (Sofort→Maximal) sends `reasoningEffort`/`preferThroughput`. Sampling popover with per-parameter toggle + slider for Temperature/Top-P/Top-K | | `3ca1d2c` | **Token counter** — parses usage sentinel from stream (`inputTokens`/`outputTokens`), shows ⚡ badge in top bar. Sums tokens from server messages for loaded chats. Context length from model's `context_length` field | @@ -259,7 +259,8 @@ All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKo - **Room crash on launch** — schema hash mismatch after adding `reasoning`/`sources` fields to `MessageEntity` with version still at 1. Fix: bump to version 2, `fallbackToDestructiveMigration(true)`. - **Keyboard handling** — `imePadding()` on Compose Box/Column caused input field to float to top of screen OR be hidden behind keyboard. Fix: read `WindowInsets.ime.getBottom()` directly, apply as negative Y `offset()` on the bottom dock. `windowSoftInputMode=adjustNothing` in manifest. -- **Locked chats not opening** — `context as? FragmentActivity` silently returned null (ContextWrapper). Fix: walk ContextWrapper chain. Added fallback when biometrics unavailable. +- **Locked chats not opening** — `context as? FragmentActivity` silently returned null (ContextWrapper). Fix: walk ContextWrapper chain. +- **Locked chats bypassed biometric auth** (06-22) — three paths in `openConversation()` called `unlockAndOpen()` without any authentication when biometrics were unavailable (FragmentActivity null, `isAvailable()` false, `Result.NotAvailable`). Sidebar `ToggleLock` also sent `PATCH { locked: false }` without auth. Fix: unified `requestBiometricOrPassword()` gates all unlock paths; `PasswordUnlockDialog` as fallback when biometrics unavailable; server `POST /api/v1/auth/unlock` now optionally verifies password (Argon2id) when `{ password }` provided. - **Error banner stuck** — `loadError` never cleared after unlock failure. Fix: auto-clear after 4s, reset on navigation. - **Reasoning/sources lost** — not persisted to server or Room cache. Fix: added fields to `SaveMessage`, `StoredMessage`, `MessageEntity`, `Converters`, `Mappers`. - **Reasoning at bottom** — rendered below content like sources. Fix: moved above content in `ChatComponents`. diff --git a/app/src/main/java/dev/kaizen/app/auth/PasswordUnlockDialog.kt b/app/src/main/java/dev/kaizen/app/auth/PasswordUnlockDialog.kt new file mode 100644 index 0000000..b7f9b1e --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/auth/PasswordUnlockDialog.kt @@ -0,0 +1,207 @@ +package dev.kaizen.app.auth + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import dev.kaizen.app.R +import dev.kaizen.app.ui.effect.GlassSurface +import dev.kaizen.app.ui.effect.GlassTiers +import dev.kaizen.app.ui.shape.KaizenShapes +import dev.kaizen.app.ui.theme.LocalKaizenAccent + +@Composable +fun PasswordUnlockDialog( + onSubmit: (password: String) -> Unit, + onDismiss: () -> Unit, + error: String? = null, +) { + var password by remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { focusRequester.requestFocus() } + + val isDark = isSystemInDarkTheme() + + Dialog(onDismissRequest = onDismiss) { + GlassSurface( + shape = KaizenShapes.xl, + tintAlpha = GlassTiers.card(isDark), + ) { + Column(Modifier.padding(24.dp)) { + Text( + stringResource(R.string.unlock_password_title), + color = MaterialTheme.colorScheme.onBackground, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.size(6.dp)) + Text( + stringResource(R.string.unlock_password_subtitle), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + fontSize = 13.sp, + ) + Spacer(Modifier.size(20.dp)) + + PasswordField( + value = password, + onValueChange = { password = it }, + onImeDone = { if (password.isNotBlank()) onSubmit(password) }, + modifier = Modifier.focusRequester(focusRequester), + ) + + if (error != null) { + Spacer(Modifier.size(10.dp)) + Text(error, color = Color(0xFFEF4444), fontSize = 12.sp) + } + + Spacer(Modifier.size(20.dp)) + + Row(Modifier.fillMaxWidth()) { + DialogButton( + label = stringResource(R.string.unlock_password_cancel), + enabled = true, + primary = false, + onClick = onDismiss, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(10.dp)) + DialogButton( + label = stringResource(R.string.unlock_password_submit), + enabled = password.isNotBlank(), + primary = true, + onClick = { onSubmit(password) }, + modifier = Modifier.weight(1f), + ) + } + } + } + } +} + +@Composable +private fun PasswordField( + value: String, + onValueChange: (String) -> Unit, + onImeDone: () -> Unit, + modifier: Modifier = Modifier, +) { + val cs = MaterialTheme.colorScheme + val isDark = isSystemInDarkTheme() + val accent = LocalKaizenAccent.current + + val glassBg = if (isDark) { + Brush.verticalGradient(listOf(Color(0xFF1E293B).copy(alpha = 0.72f), Color(0xFF0F172A).copy(alpha = 0.85f))) + } else { + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.90f), Color(0xFFEEF2F6).copy(alpha = 0.80f))) + } + val glassBorder = if (isDark) { + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.18f), Color.White.copy(alpha = 0.04f))) + } else { + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.60f), Color.Black.copy(alpha = 0.06f))) + } + + Box( + Modifier + .fillMaxWidth() + .shadow(elevation = 4.dp, shape = KaizenShapes.md, clip = false) + .clip(KaizenShapes.md) + .background(glassBg) + .border(1.dp, glassBorder, KaizenShapes.md) + .heightIn(min = 48.dp) + .padding(horizontal = 16.dp, vertical = 14.dp), + contentAlignment = Alignment.CenterStart, + ) { + if (value.isEmpty()) { + Text( + stringResource(R.string.unlock_password_placeholder), + color = cs.onSurfaceVariant.copy(alpha = 0.5f), + fontSize = 15.sp, + ) + } + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = TextStyle(color = cs.onBackground, fontSize = 15.sp), + cursorBrush = SolidColor(accent.primary), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { onImeDone() }), + modifier = modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun DialogButton( + label: String, + enabled: Boolean, + primary: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val cs = MaterialTheme.colorScheme + val accent = LocalKaizenAccent.current + val fill = if (primary && enabled) { + Modifier.background(Brush.linearGradient(listOf(accent.primary, accent.primaryDeep))) + } else { + Modifier.background(cs.surface.copy(alpha = 0.6f)) + } + + Box( + modifier + .heightIn(min = 44.dp) + .shadow(elevation = if (primary) 4.dp else 2.dp, shape = KaizenShapes.md, clip = false) + .clip(KaizenShapes.md) + .then(fill) + .clickable(enabled = enabled, onClick = onClick) + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + label, + color = if (primary && enabled) accent.onPrimary else cs.onSurfaceVariant, + fontSize = 14.sp, + fontWeight = if (primary) FontWeight.SemiBold else FontWeight.Normal, + ) + } +} 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 ad0f5ed..fef36b9 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -134,6 +134,11 @@ fun ChatScreen( var loadError by remember { mutableStateOf(null) } + // Password unlock dialog state — shown when biometrics unavailable for locked chats + var passwordUnlockTarget by remember { mutableStateOf(null) } + var passwordUnlockError by remember { mutableStateOf(null) } + var passwordUnlockForToggle by remember { mutableStateOf(false) } + // Pending file attachments — uploaded immediately on pick, cleared on send. val pendingFiles = remember { mutableStateListOf() } val context = LocalContext.current @@ -229,16 +234,23 @@ fun ChatScreen( chatModel = null } - fun unlockAndOpen(summary: ConversationSummary) { + fun unlockAndOpen(summary: ConversationSummary, password: String? = null) { val cfg = session.config ?: return scope.launch { loadError = null - val unlockToken = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token) + val unlockToken = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token, password) if (unlockToken == null) { - loadError = context.getString(R.string.biometric_failed) - scope.launch { kotlinx.coroutines.delay(4000); if (loadError == context.getString(R.string.biometric_failed)) loadError = null } + if (password != null) { + passwordUnlockError = context.getString(R.string.unlock_password_wrong) + } else { + loadError = context.getString(R.string.biometric_failed) + scope.launch { kotlinx.coroutines.delay(4000); if (loadError == context.getString(R.string.biometric_failed)) loadError = null } + } return@launch } + passwordUnlockTarget = null + passwordUnlockError = null + passwordUnlockForToggle = false val stored = KaizenApi.fetchLockedMessages(cfg.baseUrl, cfg.token, summary.id, unlockToken) if (stored.isNotEmpty()) { messages.clear() @@ -261,35 +273,45 @@ fun ChatScreen( } } + fun showPasswordFallback(summary: ConversationSummary, forToggle: Boolean = false) { + passwordUnlockTarget = summary + passwordUnlockError = null + passwordUnlockForToggle = forToggle + } + + fun requestBiometricOrPassword( + summary: ConversationSummary, + forToggle: Boolean = false, + onBiometricSuccess: () -> Unit, + ) { + var activity: androidx.fragment.app.FragmentActivity? = null + var ctx = context + while (ctx is android.content.ContextWrapper) { + if (ctx is androidx.fragment.app.FragmentActivity) { activity = ctx; break } + ctx = ctx.baseContext + } + if (activity == null || !dev.kaizen.app.auth.BiometricUnlock.isAvailable(activity)) { + showPasswordFallback(summary, forToggle) + 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 -> onBiometricSuccess() + is dev.kaizen.app.auth.BiometricUnlock.Result.NotAvailable -> showPasswordFallback(summary, forToggle) + is dev.kaizen.app.auth.BiometricUnlock.Result.Failed -> { /* user cancelled */ } + } + } + } + fun openConversation(summary: ConversationSummary) { val cfg = session.config ?: return if (summary.locked) { - var activity: androidx.fragment.app.FragmentActivity? = null - var ctx = context - while (ctx is android.content.ContextWrapper) { - if (ctx is androidx.fragment.app.FragmentActivity) { activity = ctx; break } - ctx = ctx.baseContext - } - if (activity == null) { - unlockAndOpen(summary) - return - } - if (!dev.kaizen.app.auth.BiometricUnlock.isAvailable(activity)) { - unlockAndOpen(summary) - 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 -> unlockAndOpen(summary) - is dev.kaizen.app.auth.BiometricUnlock.Result.NotAvailable -> unlockAndOpen(summary) - is dev.kaizen.app.auth.BiometricUnlock.Result.Failed -> { /* user cancelled */ } - } - } + requestBiometricOrPassword(summary) { unlockAndOpen(summary) } return } viewingLockedChat = false @@ -534,8 +556,22 @@ fun ChatScreen( refreshConversations() } is SidebarAction.ToggleLock -> { - KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to action.locked)) - refreshConversations() + if (action.locked) { + // Locking — no auth needed + KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to true)) + refreshConversations() + } else { + // Unlocking — require biometric or password + val summary = conversations.find { it.id == action.id } + if (summary != null) { + requestBiometricOrPassword(summary, forToggle = true) { + scope.launch { + KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to false)) + refreshConversations() + } + } + } + } } } } @@ -800,6 +836,38 @@ fun ChatScreen( onDismiss = { showModelSheet = false }, ) } + + // Password fallback dialog for locked conversations + val pwTarget = passwordUnlockTarget + if (pwTarget != null) { + dev.kaizen.app.auth.PasswordUnlockDialog( + onSubmit = { password -> + val cfg = session.config ?: return@PasswordUnlockDialog + if (passwordUnlockForToggle) { + scope.launch { + val token = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token, password) + if (token == null) { + passwordUnlockError = context.getString(R.string.unlock_password_wrong) + } else { + KaizenApi.patchConversation(cfg.baseUrl, cfg.token, pwTarget.id, mapOf("locked" to false)) + refreshConversations() + passwordUnlockTarget = null + passwordUnlockError = null + passwordUnlockForToggle = false + } + } + } else { + unlockAndOpen(pwTarget, password) + } + }, + onDismiss = { + passwordUnlockTarget = null + passwordUnlockError = null + passwordUnlockForToggle = false + }, + error = passwordUnlockError, + ) + } } } } 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 4a2847d..74d91a3 100644 --- a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt +++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt @@ -63,6 +63,7 @@ data class ConversationSummary( ) @Serializable private data class ConversationsResponse(val conversations: List = emptyList()) +@Serializable private data class UnlockRequest(val password: String? = null) @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) @@ -371,11 +372,12 @@ object KaizenApi { } /** POST /api/v1/auth/unlock — get a short-lived unlock token for locked conversations. */ - suspend fun requestUnlock(baseUrl: String, token: String): String? = + suspend fun requestUnlock(baseUrl: String, token: String, password: String? = null): String? = withContext(Dispatchers.IO) { + val body = json.encodeToString(UnlockRequest(password)) val req = Request.Builder() .url("$baseUrl/api/v1/auth/unlock") - .post("{}".toRequestBody(jsonMedia)) + .post(body.toRequestBody(jsonMedia)) .header("Authorization", "Bearer $token") .build() try { diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 1e9dcb1..7e117b8 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -96,6 +96,12 @@ Biometrics not available Authentication failed Tap to unlock + Unlock Chat + Enter your password to unlock this chat + Password + Unlock + Cancel + Wrong password Select Model diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fef7101..bcda609 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -98,6 +98,12 @@ Biometrie nicht verfügbar Authentifizierung fehlgeschlagen Tippen zum Entsperren + Chat entsperren + Gib dein Passwort ein, um den Chat zu entsperren + Passwort + Entsperren + Abbrechen + Falsches Passwort Modell auswählen