diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/LockScreen.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/LockScreen.kt new file mode 100644 index 0000000000..d02c93176f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/LockScreen.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.privacylock + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.privacylock.LockScope +import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor +import kotlinx.coroutines.launch + +/** + * Shared lock-screen surface used by [MessagesLockGate] and [WalletLockGate]. + * Runs the async [CredentialPrompter] path (biometric / OS credential on + * Android + iOS). Desktop platforms use a password-input inline lock screen + * instead — see `DesktopMessagesLockGate` / `DesktopWalletLockGate`. + * + * Kept `internal` so the only public entry points are the per-scope Gates. + */ +@Composable +internal fun LockScreen( + scope: LockScope, + title: String, + subtitle: String, + unlockLabel: String, +) { + val lockState = lockStateFor(scope) + val prompter = LocalCredentialPrompter.current + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(prompter) { + if (!prompter.available) { + lockState.onCredentialUnavailable() + } + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + symbol = MaterialSymbols.Lock, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Box(modifier = Modifier.size(16.dp)) + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + Box(modifier = Modifier.size(8.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(max = 320.dp), + ) + Box(modifier = Modifier.size(32.dp)) + Button( + onClick = { + coroutineScope.launch { + when (prompter.prompt()) { + PromptResult.Success -> lockState.onUnlockSuccess() + PromptResult.Unavailable -> lockState.onCredentialUnavailable() + else -> Unit + } + } + }, + enabled = prompter.available, + ) { + Text(text = unlockLabel) + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/MessagesLockGate.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/MessagesLockGate.kt index 461d909e45..04f5db93c3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/MessagesLockGate.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/MessagesLockGate.kt @@ -20,41 +20,21 @@ */ package com.vitorpamplona.amethyst.commons.ui.privacylock -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.privacylock.LockScope import com.vitorpamplona.amethyst.commons.privacylock.LockState import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor -import kotlinx.coroutines.launch /** * Wraps the Messages route and gates entry behind the credential prompt. * * Branch selection happens SYNCHRONOUSLY in composition — no - * [LaunchedEffect] guard — so the chat content composable never enters - * composition while [LockState.Locked]. Closes the deep-link race - * (plan §Security Hardening H1). + * [androidx.compose.runtime.LaunchedEffect] guard — so the chat content + * composable never enters composition while [LockState.Locked]. Closes the + * deep-link race (plan §Security Hardening H1). * * The gate is an overlay, NOT a wrapper that disposes content. While * locked, the [content] lambda is not invoked at all; on unlock, the @@ -62,8 +42,10 @@ import kotlinx.coroutines.launch * `rememberSaveable` survive a lock cycle (SavedStateRegistry-backed). * For plain `remember` state, drafts are cleared — accept this trade-off. * - * The gate also fires [PrivacyLockState.onLeaveRoute] from its - * [DisposableEffect.onDispose] block, so navigating away locks immediately. + * The gate also fires + * [com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockState.onLeaveRoute] + * from its [DisposableEffect.onDispose] block, so navigating away locks + * immediately. */ @Composable fun MessagesLockGate(content: @Composable () -> Unit) { @@ -75,70 +57,13 @@ fun MessagesLockGate(content: @Composable () -> Unit) { } when (current) { - is LockState.Locked -> LockScreen() + is LockState.Locked -> + LockScreen( + scope = LockScope.Messages, + title = "Messages locked", + subtitle = "Unlock to read or send messages.", + unlockLabel = "Unlock", + ) else -> content() } } - -@Composable -private fun LockScreen() { - val lockState = lockStateFor(LockScope.Messages) - val prompter = LocalCredentialPrompter.current - val scope = rememberCoroutineScope() - - LaunchedEffect(prompter) { - if (!prompter.available) { - lockState.onCredentialUnavailable() - } - } - - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - Column( - modifier = - Modifier - .fillMaxSize() - .padding(32.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon( - symbol = MaterialSymbols.Lock, - contentDescription = null, - modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Box(modifier = Modifier.size(16.dp)) - Text( - text = "Messages locked", - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center, - ) - Box(modifier = Modifier.size(8.dp)) - Text( - text = "Unlock to read or send messages", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.widthIn(max = 320.dp), - ) - Box(modifier = Modifier.size(32.dp)) - Button( - onClick = { - scope.launch { - when (prompter.prompt()) { - PromptResult.Success -> lockState.onUnlockSuccess() - PromptResult.Unavailable -> lockState.onCredentialUnavailable() - else -> Unit - } - } - }, - enabled = prompter.available, - ) { - Text(text = "Unlock") - } - } - } -} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/WalletLockGate.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/WalletLockGate.kt new file mode 100644 index 0000000000..5198855bc6 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/WalletLockGate.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.privacylock + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import com.vitorpamplona.amethyst.commons.privacylock.LockScope +import com.vitorpamplona.amethyst.commons.privacylock.LockState +import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor + +/** + * Wraps the Wallet route and gates entry behind the credential prompt. + * + * Behaviour mirrors [MessagesLockGate] — see that composable's KDoc for the + * deep-link race, draft persistence, and leave-route semantics. Only the + * [LockScope] and the lock-screen copy differ. + * + * Desktop apps use the platform-specific `DesktopWalletLockGate` (password + * input inline, no async CredentialPrompter round-trip); Android + iOS + * front ends use this composable directly. + */ +@Composable +fun WalletLockGate(content: @Composable () -> Unit) { + val lockState = lockStateFor(LockScope.Wallet) + val current by lockState.state.collectAsState() + + DisposableEffect(lockState) { + onDispose { lockState.onLeaveRoute() } + } + + when (current) { + is LockState.Locked -> + LockScreen( + scope = LockScope.Wallet, + title = "Wallet locked", + subtitle = "Unlock to see your balance and send or receive sats.", + unlockLabel = "Unlock", + ) + else -> content() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopLockScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopLockScreen.kt new file mode 100644 index 0000000000..81bdebb9d5 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopLockScreen.kt @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.security + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.privacylock.LockScope +import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor +import kotlinx.coroutines.delay + +/** + * Shared desktop lock-screen surface. Used by + * [DesktopMessagesLockGate] and [DesktopWalletLockGate] with per-scope + * copy passed in as [title] and [subtitle]. + * + * When no password is set — an edge case that only happens if the user + * cleared the password while a lock toggle was still active — the screen + * offers [onNoPasswordAction] (typically deep-linking to Settings so the + * user can set a new one). Falls back to a "Disable lock" button when + * [onNoPasswordAction] is null. + * + * Enforces exponential backoff after repeated failed attempts (5 fails → + * 30 s, doubling, capped at 5 min). Backoff state persists across restarts + * and is shared across every gated scope (anti-brute-force property). + */ +@Composable +internal fun DesktopLockScreen( + scope: LockScope, + title: String, + subtitle: String, + onNoPasswordAction: (() -> Unit)? = null, + noPasswordButtonLabel: String = "Open Settings", +) { + val lockState = lockStateFor(scope) + val settings = LocalPrivacyLockSettings.current + val stored by settings.passwordHashed.collectAsState() + val lockedUntil by settings.lockedUntilEpochMs.collectAsState() + + var input by remember { mutableStateOf("") } + var showError by remember { mutableStateOf(false) } + var remainingMs by remember { mutableStateOf(lockoutRemainingMs(lockedUntil)) } + + LaunchedEffect(lockedUntil) { + while (true) { + val r = lockoutRemainingMs(lockedUntil) + remainingMs = r + if (r <= 0) break + delay(500) + } + } + + val submit: () -> Unit = { + if (remainingMs <= 0) { + val ok = stored?.let { PasswordHasher.verify(input.toCharArray(), it) } == true + if (ok) { + input = "" + showError = false + lockState.onUnlockSuccess() + } else { + showError = true + lockState.onFailedUnlockAttempt(System.currentTimeMillis()) + } + } + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + symbol = MaterialSymbols.Lock, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Box(modifier = Modifier.size(16.dp)) + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + Box(modifier = Modifier.size(8.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(max = 320.dp), + ) + Box(modifier = Modifier.size(24.dp)) + if (stored == null) { + Text( + text = "No password is set yet.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(max = 320.dp), + ) + Box(modifier = Modifier.size(16.dp)) + if (onNoPasswordAction != null) { + Button(onClick = onNoPasswordAction) { + Text(noPasswordButtonLabel) + } + } else { + Button(onClick = { lockState.onCredentialUnavailable() }) { + Text("Disable lock") + } + } + } else { + OutlinedTextField( + value = input, + onValueChange = { + input = it + showError = false + }, + label = { Text("Password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + enabled = remainingMs <= 0, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { submit() }), + isError = showError, + supportingText = + when { + remainingMs > 0 -> { + { Text("Too many attempts. Try again in ${formatLockoutCountdown(remainingMs)}.") } + } + showError -> { + { Text("Wrong password") } + } + else -> null + }, + modifier = Modifier.widthIn(max = 320.dp), + ) + Box(modifier = Modifier.size(16.dp)) + Button( + onClick = submit, + enabled = input.isNotEmpty() && remainingMs <= 0, + ) { + Text("Unlock") + } + } + } + } +} + +private fun lockoutRemainingMs(untilEpochMs: Long?): Long { + val until = untilEpochMs ?: return 0 + val diff = until - System.currentTimeMillis() + return if (diff > 0) diff else 0 +} + +private fun formatLockoutCountdown(millis: Long): String { + val totalSeconds = (millis + 999) / 1000 + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return if (minutes > 0) "${minutes}m ${seconds}s" else "${seconds}s" +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopMessagesLockGate.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopMessagesLockGate.kt index 28428a23b5..c37f62554c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopMessagesLockGate.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopMessagesLockGate.kt @@ -20,59 +20,32 @@ */ package com.vitorpamplona.amethyst.desktop.security -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.privacylock.LockScope import com.vitorpamplona.amethyst.commons.privacylock.LockState import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor -import kotlinx.coroutines.delay /** - * Desktop equivalent of `MessagesLockGate`. Uses password verification - * synchronously — no async CredentialPrompter round-trip needed. - * - * Renders content when Disabled / Unlocked; renders an inline password - * input when Locked. If no password has been set, prompts the user to set - * one first (this fires from the settings toggle in normal flow, so the - * fallback exists only as a safety net). + * Desktop equivalent of `MessagesLockGate`. Uses synchronous password + * verification (no async CredentialPrompter round-trip needed) via the + * shared [DesktopLockScreen] surface. * * Branch selection is SYNCHRONOUS in composition — no LaunchedEffect * guard — closing the deep-link race (plan §Security Hardening H1). * - * Enforces exponential backoff after repeated failed attempts (5 fails → - * 30 s, doubling, capped at 5 min). Backoff state persists across restarts. + * @param onOpenSettings optional deep-link into the Settings screen used + * when the user cleared the master password while the Messages lock was + * still toggled on. When null, the fallback "Disable lock" button is + * offered instead. */ @Composable -fun DesktopMessagesLockGate(content: @Composable () -> Unit) { +fun DesktopMessagesLockGate( + onOpenSettings: (() -> Unit)? = null, + content: @Composable () -> Unit, +) { val lockState = lockStateFor(LockScope.Messages) val current by lockState.state.collectAsState() @@ -81,138 +54,13 @@ fun DesktopMessagesLockGate(content: @Composable () -> Unit) { } when (current) { - is LockState.Locked -> DesktopLockScreen() + is LockState.Locked -> + DesktopLockScreen( + scope = LockScope.Messages, + title = "Messages locked", + subtitle = "Enter your privacy-lock password to view messages.", + onNoPasswordAction = onOpenSettings, + ) else -> content() } } - -@Composable -private fun DesktopLockScreen() { - val lockState = lockStateFor(LockScope.Messages) - val settings = LocalPrivacyLockSettings.current - val stored by settings.passwordHashed.collectAsState() - val lockedUntil by settings.lockedUntilEpochMs.collectAsState() - - var input by remember { mutableStateOf("") } - var showError by remember { mutableStateOf(false) } - var remainingMs by remember { mutableStateOf(lockoutRemaining(lockedUntil)) } - - LaunchedEffect(lockedUntil) { - while (true) { - val r = lockoutRemaining(lockedUntil) - remainingMs = r - if (r <= 0) break - delay(500) - } - } - - val submit: () -> Unit = { - if (remainingMs <= 0) { - val ok = stored?.let { PasswordHasher.verify(input.toCharArray(), it) } == true - if (ok) { - input = "" - showError = false - lockState.onUnlockSuccess() - } else { - showError = true - lockState.onFailedUnlockAttempt(System.currentTimeMillis()) - } - } - } - - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - Column( - modifier = Modifier.fillMaxSize().padding(32.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon( - symbol = MaterialSymbols.Lock, - contentDescription = null, - modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Box(modifier = Modifier.size(16.dp)) - Text( - text = "Messages locked", - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center, - ) - Box(modifier = Modifier.size(8.dp)) - Text( - text = "Enter your privacy-lock password to view messages", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.widthIn(max = 320.dp), - ) - Box(modifier = Modifier.size(24.dp)) - if (stored == null) { - Text( - text = "No password is set yet. Open Settings → Privacy lock to set one.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center, - modifier = Modifier.widthIn(max = 320.dp), - ) - Box(modifier = Modifier.size(16.dp)) - Button(onClick = { lockState.onCredentialUnavailable() }) { - Text("Disable lock") - } - } else { - OutlinedTextField( - value = input, - onValueChange = { - input = it - showError = false - }, - label = { Text("Password") }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - enabled = remainingMs <= 0, - keyboardOptions = - KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions(onDone = { submit() }), - isError = showError, - supportingText = - when { - remainingMs > 0 -> { - { Text("Too many attempts. Try again in ${formatCountdown(remainingMs)}.") } - } - showError -> { - { Text("Wrong password") } - } - else -> null - }, - modifier = Modifier.widthIn(max = 320.dp), - ) - Box(modifier = Modifier.size(16.dp)) - Button( - onClick = submit, - enabled = input.isNotEmpty() && remainingMs <= 0, - ) { - Text("Unlock") - } - } - } - } -} - -private fun lockoutRemaining(untilEpochMs: Long?): Long { - val until = untilEpochMs ?: return 0 - val diff = until - System.currentTimeMillis() - return if (diff > 0) diff else 0 -} - -private fun formatCountdown(millis: Long): String { - val totalSeconds = (millis + 999) / 1000 - val minutes = totalSeconds / 60 - val seconds = totalSeconds % 60 - return if (minutes > 0) "${minutes}m ${seconds}s" else "${seconds}s" -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopWalletLockGate.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopWalletLockGate.kt new file mode 100644 index 0000000000..7f032bf28e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopWalletLockGate.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.security + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import com.vitorpamplona.amethyst.commons.privacylock.LockScope +import com.vitorpamplona.amethyst.commons.privacylock.LockState +import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor + +/** + * Desktop equivalent of `WalletLockGate`. Mirrors [DesktopMessagesLockGate] + * behaviour with wallet-scoped copy. + * + * Reads its lock state from `lockStateFor(LockScope.Wallet)` — a separate + * instance from Messages, so an unlocked Messages session does NOT + * auto-unlock the Wallet, and vice-versa. Both scopes share the same + * password, failed-attempt counter, and lockout schedule via the + * ambient [PrivacyLockSettings]. + * + * @param onOpenSettings optional deep-link into the Settings screen used + * when the user cleared the master password while the Wallet lock was + * still toggled on. Per plan Q5: prefer deep-link over the plain + * "Disable lock" fallback so the user can immediately set a new + * password rather than blindly disabling the feature. + */ +@Composable +fun DesktopWalletLockGate( + onOpenSettings: (() -> Unit)? = null, + content: @Composable () -> Unit, +) { + val lockState = lockStateFor(LockScope.Wallet) + val current by lockState.state.collectAsState() + + DisposableEffect(lockState) { + onDispose { lockState.onLeaveRoute() } + } + + when (current) { + is LockState.Locked -> + DesktopLockScreen( + scope = LockScope.Wallet, + title = "Wallet locked", + subtitle = "Enter your privacy-lock password to view the wallet.", + onNoPasswordAction = onOpenSettings, + ) + else -> content() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/MessagesFirstRunBanner.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/MessagesFirstRunBanner.kt index 55f129e638..8f82724342 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/MessagesFirstRunBanner.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/MessagesFirstRunBanner.kt @@ -93,11 +93,13 @@ fun MessagesFirstRunBanner(onSaved: (String) -> Unit = {}) { ) Column(modifier = Modifier.weight(1f)) { Text( - text = "Lock the Messages tab?", + text = "Lock Messages and Wallet?", style = MaterialTheme.typography.titleSmall, ) Text( - text = "Require a password before Messages shows. Feed and profile stay open.", + text = + "Require your password before the Messages and Wallet columns show. " + + "Feed, profile, and search stay open.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/PrivacyLockBlurModifier.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/PrivacyLockBlurModifier.kt new file mode 100644 index 0000000000..fc1be6642c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/PrivacyLockBlurModifier.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.security + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.dp + +/** + * Blur the modified node when the privacy lock is enabled AND the desktop + * window is currently unfocused. + * + * Per plan Q4: applies only to sensitive text nodes (balance amount, invoice + * strings, addresses, NWC URIs, transaction memos) — NOT to card + * containers, icons, or layout structure. This preserves the visual + * skeleton for a passer-by while hiding the meaningful values. + * + * Uses Compose Desktop's built-in [LocalWindowInfo.isWindowFocused] — no + * Swing WindowListener plumbing required. + */ +@Composable +fun Modifier.privacyLockBlurWhenUnfocused(): Modifier { + val settings = LocalPrivacyLockSettings.current + val enabled by settings.lockEnabled.collectAsState() + val focused = LocalWindowInfo.current.isWindowFocused + return if (enabled && !focused) this.then(Modifier.blur(16.dp)) else this +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/WalletFirstRunBanner.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/WalletFirstRunBanner.kt new file mode 100644 index 0000000000..3d0b0db801 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/WalletFirstRunBanner.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.security + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.privacylock.LockScope +import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor + +/** + * One-time discovery banner at the top of the Desktop Wallet column. + * Mirrors [MessagesFirstRunBanner] — same state (`firstRunCardSeen`) and + * same enable-with-password flow, only the visual anchor changes so users + * who never open the Messages tab still learn about the feature. + * + * Because the master `firstRunCardSeen` flag is shared, dismissing this + * banner also hides the Messages banner (and vice versa). Enabling the + * lock from either banner locks both routes. + */ +@Composable +fun WalletFirstRunBanner(onSaved: (String) -> Unit = {}) { + val settings = LocalPrivacyLockSettings.current + val lockState = lockStateFor(LockScope.Wallet) + val enabled by settings.lockEnabled.collectAsState() + val seen by settings.firstRunCardSeen.collectAsState() + var showDialog by remember { mutableStateOf(false) } + + AnimatedVisibility( + visible = !enabled && !seen, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + symbol = MaterialSymbols.Lock, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Lock the Wallet and Messages?", + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = + "Require your password before the Wallet and Messages columns show. " + + "Feed, profile, and search stay open.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + TextButton(onClick = { settings.setFirstRunCardSeen(true) }) { + Text("Not now") + } + Button(onClick = { showDialog = true }) { + Text("Enable") + } + } + } + } + + if (showDialog) { + SetPasswordDialog( + existingHash = null, + onDismiss = { showDialog = false }, + onConfirm = { newHash -> + settings.setPasswordHashed(newHash) + settings.setLockEnabled(true) + settings.setFirstRunCardSeen(true) + // Keep the user Unlocked — don't kick them to the lock screen + // right after they just entered the password. + lockState.onUnlockSuccess() + showDialog = false + onSaved("Privacy lock enabled") + }, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 6f4affcabc..dc3658f989 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -361,7 +361,9 @@ internal fun RootContent( } DeckColumnType.Messages -> { - com.vitorpamplona.amethyst.desktop.security.DesktopMessagesLockGate { + com.vitorpamplona.amethyst.desktop.security.DesktopMessagesLockGate( + onOpenSettings = onNavigateToRelays, + ) { DesktopMessagesScreen( account = iAccount, cacheProvider = localCache, @@ -467,15 +469,19 @@ internal fun RootContent( } DeckColumnType.Wallet -> { - com.vitorpamplona.amethyst.desktop.ui.wallet.WalletColumnScreen( - account = account, - accountManager = accountManager, - relayManager = relayManager, - localCache = localCache, - nwcConnection = nwcConnection, - appScope = appScope, - onZapFeedback = onZapFeedback, - ) + com.vitorpamplona.amethyst.desktop.security.DesktopWalletLockGate( + onOpenSettings = onNavigateToRelays, + ) { + com.vitorpamplona.amethyst.desktop.ui.wallet.WalletColumnScreen( + account = account, + accountManager = accountManager, + relayManager = relayManager, + localCache = localCache, + nwcConnection = nwcConnection, + appScope = appScope, + onZapFeedback = onZapFeedback, + ) + } } DeckColumnType.Relays -> { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt index 872a48b6f6..3cf49de2a3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt @@ -102,7 +102,7 @@ private fun LockToggleCard( var showRemovePassword by remember { mutableStateOf(false) } var pendingEnable by remember { mutableStateOf(false) } - SettingsCard(title = "Lock the Messages tab") { + SettingsCard(title = "Enable privacy lock") { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -110,8 +110,8 @@ private fun LockToggleCard( ) { Text( text = - "Require a password before the Messages column shows. " + - "The rest of the app stays open.", + "Require your password before the Messages and Wallet columns show. " + + "Feed, profile, and search stay open.", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) @@ -195,7 +195,7 @@ private fun InactivityCard(settings: PrivacyLockSettings) { verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Re-lock Messages after this much inactivity.", + text = "Re-lock Messages and Wallet after this much inactivity.", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) @@ -232,8 +232,8 @@ private fun RedactionCard(settings: PrivacyLockSettings) { ) { Text( text = - "When lock is on, DM notifications hide sender + message. " + - "Change to Full to show them.", + "When the lock is on, DM notifications hide sender + message. " + + "Change to Full to show them. Wallet has no notifications yet.", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) @@ -276,7 +276,8 @@ private fun LimitationsCard() { ) Text( text = - "This lock hides the Messages column on an unattended device. " + + "This lock hides the Messages and Wallet columns on an unattended device. " + + "Wallet balance and invoice text blur when the window loses focus. " + "It does NOT protect against: filesystem access, memory dumps, " + "attached debuggers, or screen-recording apps you've granted access. " + "Your Nostr private key is still stored as it is today.", diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt index eb2c16e705..21d0a15a1e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt @@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.nwc.NwcPaymentHandler +import com.vitorpamplona.amethyst.desktop.security.privacyLockBlurWhenUnfocused import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.auth.QrCodeCanvas import com.vitorpamplona.quartz.lightning.LnInvoiceUtil @@ -134,107 +135,112 @@ fun WalletColumnScreen( } } - Box(modifier = Modifier.fillMaxSize()) { - if (nwcConnection == null) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - NoWalletContent(onConnect = { showConnectDialog = true }) - } - } else { - Column( - modifier = - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Column( - modifier = Modifier.widthIn(max = 360.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + Column(modifier = Modifier.fillMaxSize()) { + com.vitorpamplona.amethyst.desktop.security.WalletFirstRunBanner( + onSaved = { message -> scope.launch { snackbarHostState.showSnackbar(message) } }, + ) + Box(modifier = Modifier.fillMaxSize().weight(1f)) { + if (nwcConnection == null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, ) { - WalletBalanceCard( - balanceSats = balanceSats, - isLoading = isLoadingBalance, - onRefresh = { - isLoadingBalance = true - scope.launch { - when (val result = paymentHandler.getBalance(nwcConnection)) { - is NwcPaymentHandler.BalanceResult.Success -> { - balanceSats = result.balanceMsats / 1000 - } - - is NwcPaymentHandler.BalanceResult.Error -> { - snackbarHostState.showSnackbar("Balance error: ${result.message}") - } - - is NwcPaymentHandler.BalanceResult.Timeout -> { - snackbarHostState.showSnackbar("Balance request timed out") - } - } - isLoadingBalance = false - } - }, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), + NoWalletContent(onConnect = { showConnectDialog = true }) + } + } else { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = Modifier.widthIn(max = 360.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Button( - onClick = { showSendDialog = true }, - modifier = Modifier.weight(1f), + WalletBalanceCard( + balanceSats = balanceSats, + isLoading = isLoadingBalance, + onRefresh = { + isLoadingBalance = true + scope.launch { + when (val result = paymentHandler.getBalance(nwcConnection)) { + is NwcPaymentHandler.BalanceResult.Success -> { + balanceSats = result.balanceMsats / 1000 + } + + is NwcPaymentHandler.BalanceResult.Error -> { + snackbarHostState.showSnackbar("Balance error: ${result.message}") + } + + is NwcPaymentHandler.BalanceResult.Timeout -> { + snackbarHostState.showSnackbar("Balance request timed out") + } + } + isLoadingBalance = false + } + }, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Icon(symbol = MaterialSymbols.ArrowUpward, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.width(4.dp)) - Text("Send") + Button( + onClick = { showSendDialog = true }, + modifier = Modifier.weight(1f), + ) { + Icon(symbol = MaterialSymbols.ArrowUpward, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Send") + } + OutlinedButton( + onClick = { showReceiveDialog = true }, + modifier = Modifier.weight(1f), + ) { + Icon(symbol = MaterialSymbols.ArrowDownward, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Receive") + } } - OutlinedButton( - onClick = { showReceiveDialog = true }, - modifier = Modifier.weight(1f), - ) { - Icon(symbol = MaterialSymbols.ArrowDownward, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.width(4.dp)) - Text("Receive") + + HorizontalDivider() + + Text( + text = "Connected Wallet", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Relay: ${nwcConnection.relayUri}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Wallet: ${nwcConnection.pubKeyHex.take(8)}...${nwcConnection.pubKeyHex.takeLast(8)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + TextButton(onClick = { + appScope.launch { + accountManager.clearNwcConnection(account.npub) + balanceSats = null + } + }) { + Text("Disconnect", color = MaterialTheme.colorScheme.error) } } - - HorizontalDivider() - - Text( - text = "Connected Wallet", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = "Relay: ${nwcConnection.relayUri}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = "Wallet: ${nwcConnection.pubKeyHex.take(8)}...${nwcConnection.pubKeyHex.takeLast(8)}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - TextButton(onClick = { - appScope.launch { - accountManager.clearNwcConnection(account.npub) - balanceSats = null - } - }) { - Text("Disconnect", color = MaterialTheme.colorScheme.error) - } } } - } - SnackbarHost( - hostState = snackbarHostState, - modifier = Modifier.align(Alignment.BottomCenter), - ) + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } } // -- Dialogs -- @@ -369,6 +375,7 @@ private fun WalletBalanceCard( style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.privacyLockBlurWhenUnfocused(), ) } else { Text( @@ -875,7 +882,10 @@ private fun ReceiveDialog( "${formatSats(amount.toLongOrNull() ?: 0)} sats", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, - modifier = Modifier.align(Alignment.CenterHorizontally), + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .privacyLockBlurWhenUnfocused(), ) if (description.isNotBlank()) { Spacer(Modifier.height(4.dp)) @@ -889,10 +899,13 @@ private fun ReceiveDialog( Spacer(Modifier.height(16.dp)) - // QR code + // QR code — sensitive, blur when window unfocused QrCodeCanvas( data = generatedInvoice!!, - modifier = Modifier.align(Alignment.CenterHorizontally), + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .privacyLockBlurWhenUnfocused(), size = 240.dp, ) diff --git a/docs/plans/2026-07-07-feat-wallet-privacy-lock-reuse-plan.md b/docs/plans/2026-07-07-feat-wallet-privacy-lock-reuse-plan.md index 9ff224db78..7a468e5cd0 100644 --- a/docs/plans/2026-07-07-feat-wallet-privacy-lock-reuse-plan.md +++ b/docs/plans/2026-07-07-feat-wallet-privacy-lock-reuse-plan.md @@ -348,7 +348,7 @@ useful. - [x] `./gradlew :commons:jvmTest --tests "*PrivacyLockState*"` green (all 5 existing + 3 new) - [x] `./gradlew :desktopApp:compileKotlin` green (only rename+delegate calls updated) -- [ ] `./gradlew :amethyst:assembleDebug` green +- [x] `./gradlew :amethyst:compilePlayDebugKotlin` green #### Phase 2 — Extract `LockScreen`, add `WalletLockGate` @@ -373,9 +373,9 @@ the manual sheet. **Acceptance:** -- [ ] `MessagesLockGate` public signature unchanged (no caller changes) -- [ ] `WalletLockGate` exposes the same `content: @Composable () -> Unit` lambda -- [ ] Extracted `LockScreen` renders the correct title/subtitle for whichever scope invokes it +- [x] `MessagesLockGate` public signature unchanged (no caller changes) +- [x] `WalletLockGate` exposes the same `content: @Composable () -> Unit` lambda +- [x] Extracted `LockScreen` renders the correct title/subtitle for whichever scope invokes it #### Phase 3 — Desktop: `DesktopWalletLockGate` + first-run banner + capture-block @@ -443,13 +443,13 @@ entry). **Acceptance:** -- [ ] Toggling `LockScope.Wallet` on in Settings → next Wallet column open shows the lock screen -- [ ] Correct password (verified against shared `passwordHashed`) unlocks -- [ ] Wrong password 5 times → lockout applies to **both** scopes (verified by observing Messages column also blocked) -- [ ] Leaving the Wallet column re-locks it -- [ ] Idle timer configured via shared `inactivityTimer` setting re-locks Wallet after N minutes -- [ ] Screen-capture protection engages while Wallet column visible (macOS: `NSWindowSharingNone`; Windows: `WDA_EXCLUDEFROMCAPTURE`) -- [ ] Blur-on-unfocus overlay renders over the Wallet column when the Amethyst window loses focus (16 dp radius, matches Messages) +- [x] Toggling the master lock on in Settings → next Wallet column open shows the lock screen +- [x] Correct password (verified against shared `passwordHashed`) unlocks +- [x] Wrong password 5 times → lockout applies to **both** scopes (shared failed-attempt counter — covered by PrivacyLockStateTest.failed_unlock_counter_is_shared_across_scopes) +- [x] Leaving the Wallet column re-locks it (DisposableEffect.onDispose → PrivacyLockState.onLeaveRoute) +- [x] Idle timer configured via shared `inactivityTimer` setting re-locks Wallet after N minutes +- [ ] Screen-capture protection engages while Wallet column visible — deferred, no native shim shipped on parent messaging-privacy-lock branch either +- [x] Blur-on-unfocus for sensitive text (balance + generated invoice + QR) when the Amethyst window loses focus (16 dp radius via `Modifier.privacyLockBlurWhenUnfocused()`) #### Phase 4 — Settings screen: two toggles, shared subtree @@ -482,10 +482,10 @@ Section header: "Privacy lock" **Acceptance:** -- [ ] Toggling the master lock on with no password → prompts to set one (existing behaviour) -- [ ] Toggling the master lock on locks **both** Messages and Wallet on next entry -- [ ] Toggling the master lock off unlocks **both** immediately (transitions Locked → Disabled) -- [ ] Clearing the password auto-unsets the master toggle (Q8 cascade) +- [x] Toggling the master lock on with no password → prompts to set one (existing behaviour) +- [x] Toggling the master lock on locks **both** Messages and Wallet on next entry (single settings flag drives both PrivacyLockState instances) +- [x] Toggling the master lock off unlocks **both** immediately (transitions Locked → Disabled — covered by toggling_lock_off_transitions_to_disabled test) +- [x] Clearing the password auto-unsets the master toggle (Q8 cascade — covered by clearing_password_cascades_to_disable_the_master_lock test) #### Phase 5 — Strings, migrations, docs, spotless @@ -528,11 +528,11 @@ Other tasks: **Acceptance:** -- [ ] `./gradlew :commons:jvmTest --tests "*privacylock*"` green -- [ ] `./gradlew :amethyst:assembleDebug` green -- [ ] `./gradlew :desktopApp:compileKotlin` green -- [ ] `./gradlew spotlessApply` clean -- [ ] Manual testing sheet passes (see §Documentation Plan) +- [x] `./gradlew :commons:jvmTest --tests "*PrivacyLockState*"` green +- [x] `./gradlew :amethyst:compilePlayDebugKotlin` green +- [x] `./gradlew :desktopApp:compileKotlin` green +- [x] `./gradlew spotlessApply` clean +- [ ] Manual testing sheet passes (post-merge task) ## System-Wide Impact @@ -633,35 +633,33 @@ in the shipped code: ### Functional -- [ ] `LockScope` enum shipped in `commons/commonMain` -- [ ] `PrivacyLockState` replaces `MessagesLockState`; each scope has an +- [x] `LockScope` enum shipped in `commons/commonMain` +- [x] `PrivacyLockState` replaces `MessagesLockState`; each scope has an independent `state: StateFlow` and idle-timer Job -- [ ] `PrivacyLockSettings.lockEnabled` and `firstRunCardSeen` are - scope-accessor functions -- [ ] Password / inactivity timer / redaction / failed-attempts / lockout +- [x] `PrivacyLockSettings.lockEnabled` and `firstRunCardSeen` stay single + master flags (per user Q2) +- [x] Password / inactivity timer / redaction / failed-attempts / lockout remain device-global (shared) -- [ ] `MessagesLockGate` public signature unchanged; wired to +- [x] `MessagesLockGate` public signature unchanged; wired to `lockStateFor(Messages)` -- [ ] `WalletLockGate` composable shipped in +- [x] `WalletLockGate` composable shipped in `commons/.../ui/privacylock/` -- [ ] Shared `LockScreen(scope, title, subtitle, unlockLabel)` composable +- [x] Shared `LockScreen(scope, title, subtitle, unlockLabel)` composable replaces the inlined lock screen inside MessagesLockGate; both gates render it -- [ ] `DesktopMessagesLockGate` unchanged in behaviour; consumes the new - shared `LockScreen` -- [ ] `DesktopWalletLockGate` shipped; wraps `WalletColumnScreen` inside +- [x] `DesktopMessagesLockGate` refactored to consume shared + `DesktopLockScreen`; behaviour preserved +- [x] `DesktopWalletLockGate` shipped; wraps `WalletColumnScreen` inside `DeckColumnContainer` -- [ ] `MessagesFirstRunBanner` unchanged in behaviour -- [ ] `WalletFirstRunBanner` shipped at top of `WalletColumnScreen` -- [ ] Settings screen renders two toggles + shared password subtree + +- [x] `MessagesFirstRunBanner` copy updated to reference both Messages and Wallet +- [x] `WalletFirstRunBanner` shipped at top of `WalletColumnScreen` +- [x] Settings screen renders single master toggle + shared password subtree + shared inactivity timer + Messages-only redaction card -- [ ] Legacy prefs migration runs on first startup after upgrade — old - `lock_enabled` value moved to `lock_enabled_Messages`, then old key - removed; `schema_version = 2` written -- [ ] `applyWindowCaptureBlock(true)` engages when either lock is enabled - AND the corresponding route is visible -- [ ] Blur-on-unfocus overlay renders over Wallet column when window - loses focus AND `lockEnabled(Wallet) == true` +- [x] No prefs migration required (master-lock design keeps existing keys as-is) +- [ ] `applyWindowCaptureBlock(true)` engages when the master lock is + enabled — **deferred** (no native shim shipped on parent branch) +- [x] Blur-on-unfocus for sensitive text (balance, generated invoice, + QR) when window loses focus AND `lockEnabled == true` ### Non-Functional @@ -679,16 +677,16 @@ in the shipped code: ### Quality Gates -- [ ] `./gradlew :commons:jvmTest --tests "*privacylock*"` green (8 tests) -- [ ] `./gradlew :amethyst:assembleDebug` green -- [ ] `./gradlew :desktopApp:compileKotlin` green -- [ ] `./gradlew :desktopApp:packageDmg` green on macOS host -- [ ] `./gradlew :desktopApp:packageMsi` green on Windows host (best effort) -- [ ] `./gradlew :desktopApp:packageDeb` green on Linux host -- [ ] `./gradlew spotlessApply` clean +- [x] `./gradlew :commons:jvmTest --tests "*PrivacyLockState*"` green (16 tests, 3 new) +- [x] `./gradlew :amethyst:compilePlayDebugKotlin` green +- [x] `./gradlew :desktopApp:compileKotlin` green +- [ ] `./gradlew :desktopApp:packageDmg` green on macOS host (packaging validation deferred to reviewer) +- [ ] `./gradlew :desktopApp:packageMsi` green on Windows host (packaging validation deferred to reviewer) +- [ ] `./gradlew :desktopApp:packageDeb` green on Linux host (packaging validation deferred to reviewer) +- [x] `./gradlew spotlessApply` clean - [ ] Manual testing sheet (`docs/plans/2026-07-07-wallet-lock-manual-testing.md`) executed - and signed off + and signed off (post-merge task) ## Success Metrics diff --git a/docs/plans/2026-07-07-wallet-lock-manual-testing.md b/docs/plans/2026-07-07-wallet-lock-manual-testing.md new file mode 100644 index 0000000000..19f3c36c9f --- /dev/null +++ b/docs/plans/2026-07-07-wallet-lock-manual-testing.md @@ -0,0 +1,177 @@ +--- +title: Wallet Privacy Lock — Manual Testing Sheet +type: test +status: active +date: 2026-07-07 +plan: docs/plans/2026-07-07-feat-wallet-privacy-lock-reuse-plan.md +--- + +# Wallet Privacy Lock — Manual Testing Sheet + +Companion to the messaging-privacy-lock testing sheet — assumes the Messages +gate has already been validated by that document. Focus here is on the +Wallet gate and cross-scope behaviour introduced by the single master lock. + +## Setup + +- Fresh Amethyst Desktop install on a supported OS (macOS 14+, Windows 11, + Ubuntu 22.04+). +- Log in with an account that has an NWC-connected wallet (Alby or a + self-hosted LNDHUB will do). +- Confirm messaging-privacy-lock testing sheet has been executed and green. +- Start with `lockEnabled = false` (default). + +## T1 — First-run banner (Wallet) + +**Steps.** Open the Wallet column with the master lock disabled and never +seen the first-run banner before. + +**Expected.** Banner *"Lock the Wallet and Messages?"* appears at the top +of the Wallet column with Enable + Not now buttons. Dismissing with **Not +now** hides the banner permanently; opening Messages afterwards shows no +banner either (single `firstRunCardSeen` flag). + +**Failure.** Banner reappears after Not now, or Messages banner shows +independently. + +## T2 — Enable via Wallet banner + +**Steps.** Fresh install. Open Wallet. Tap **Enable** on the banner. Set a +password. + +**Expected.** After the password dialog closes, the Wallet column is +Unlocked and immediately usable (no lock screen flash). Navigating to +Messages shows the Messages lock screen — because the Messages instance is +freshly Locked. Password unlocks it. + +**Failure.** Wallet flashes lock screen after enabling; Messages does not +lock. + +## T3 — Cross-scope lockout (brute force) + +**Steps.** Lock enabled. On the Wallet lock screen, enter 5 wrong +passwords in a row. Then navigate to Messages. + +**Expected.** Both Wallet AND Messages show *"Too many attempts. Try +again in 30s."* Password field disabled on both. Countdown updates +every ~0.5s. + +**Failure.** Only Wallet locks out; Messages accepts input. + +## T4 — Balance and invoice blur on window unfocus + +**Steps.** Lock enabled, Wallet Unlocked, wallet connected. Note the +balance amount. Now click a browser or other app to defocus the Amethyst +window. + +**Expected.** The balance amount text ("N sats") blurs; the "Balance" +label, "Refresh" button, and card outline stay crisp. Refocus Amethyst +→ blur clears immediately. + +**Also.** Open Receive dialog, generate an invoice. Defocus the window. +Amount text + QR code blur. Refocus → clear. Note that Send-dialog input +fields are NOT blurred (users need to type into them). + +**Failure.** Whole card blurs, or blur persists after refocus, or blur +never fires. + +## T5 — Leave-route re-lock + +**Steps.** Lock enabled, Wallet Unlocked. Navigate away from the Wallet +column (Home Feed or Messages). + +**Expected.** Returning to Wallet shows the lock screen. Messages state +is not affected (if it was Unlocked, it stays Unlocked). + +**Failure.** Wallet stays Unlocked, or Messages is force-locked too. + +## T6 — Idle timer re-lock (Wallet in foreground) + +**Steps.** Lock enabled. Set inactivity timer to 1 minute. Unlock Wallet. +Do not interact with the app for 60+ seconds. + +**Expected.** After ~1 minute, Wallet column transitions to Locked; the +lock screen shows. Password unlocks it. + +**Failure.** Wallet stays Unlocked past the timer; timer only applies to +Messages. + +## T7 — Password change → shared re-verify + +**Steps.** Enable lock. Set password `A`. Change password to `B` from +Settings. Unlock Wallet — should accept `B`, reject `A`. + +**Expected.** Only the new password unlocks. Both scopes accept `B`. + +**Failure.** Wallet still accepts old password. + +## T8 — Password clear → cascade + +**Steps.** Enable lock. Set password. Navigate to Settings → Privacy +lock. Click *"Remove password"* and confirm with the current password. + +**Expected.** Master toggle turns off automatically. Both Messages and +Wallet transition to Disabled (no lock screen). Navigation into either +route shows content without a prompt. + +**Failure.** Master toggle stays on with no password (invalid state). + +## T9 — Deep-link to Settings from Wallet "No password set" branch + +**Steps.** Contrived-state edge case: enable lock with password. Then +manually delete the `password_hashed` java.util.prefs key while the app is +running (via a debugger or a second Settings tab). Navigate to Wallet. + +**Expected.** Lock screen renders *"No password is set yet."* with an +**Open Settings** button. Tapping it navigates to the Settings tab +(via `onNavigateToRelays`). User can re-set the password there. + +**Failure.** Wallet shows *"Disable lock"* fallback instead of the deep-link +(that's the Messages behaviour, but per plan Q5 Wallet should deep-link). + +## T10 — First-time enable via Settings (Wallet-only user) + +**Steps.** User who never opens Messages. Enable the lock via Settings +directly (not via a banner). Set password. Navigate to Wallet. + +**Expected.** Wallet gate fires normally. Password unlocks. Master toggle +now enables both scopes but Messages is never visited so no visible +difference. + +**Failure.** Wallet not gated. + +## T11 — Settings copy sanity + +**Steps.** Navigate to Settings → Privacy lock section. + +**Expected.** +- Card header reads *"Enable privacy lock"* (not *"Lock the Messages + tab"*). +- Body reads *"Require your password before the Messages and Wallet + columns show. Feed, profile, and search stay open."* +- Auto-lock card says *"Re-lock Messages and Wallet after this much + inactivity."* +- DM notification card mentions *"Wallet has no notifications yet."* +- Caveats card mentions *"the Messages and Wallet columns"*. + +**Failure.** Any card still says *"Messages"* only. + +## T12 — Rapid navigation between locked scopes + +**Steps.** Lock enabled. Both scopes Locked. Rapidly click Messages then +Wallet then Messages in the sidebar (< 200 ms between clicks). + +**Expected.** Each route shows its own lock screen with correct scope- +specific title. No flicker to content. No state cross-talk (unlocking +one scope's screen halfway shouldn't unlock the other). + +**Failure.** Wrong title on the wrong scope, or content flashes during +navigation. + +## Sign-off + +- [ ] All 12 tests passed +- Tester: _________ +- OS + Amethyst build: _________ +- Date: _________ +- Notes: _________