diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/LockScope.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/LockScope.kt new file mode 100644 index 0000000000..260e0d9fc2 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/LockScope.kt @@ -0,0 +1,30 @@ +/* + * 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.privacylock + +/** + * Routes gated by the privacy lock. + * + * A single master `PrivacyLockSettings.lockEnabled` flag protects all scopes + * together, but each scope keeps its own [PrivacyLockState] so that unlock, + * idle-timer, and leave-route transitions apply independently per route. + */ +enum class LockScope { Messages, Wallet } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockSettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockSettings.kt index 39901fd790..4ffc77f88f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockSettings.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockSettings.kt @@ -37,7 +37,7 @@ import kotlinx.coroutines.flow.StateFlow interface PrivacyLockSettings { val lockEnabled: StateFlow val inactivityTimer: StateFlow - val redactionLevel: StateFlow + val dmRedactionLevel: StateFlow val firstRunCardSeen: StateFlow /** @@ -68,7 +68,7 @@ interface PrivacyLockSettings { fun setInactivityTimer(timer: InactivityTimer) - fun setRedactionLevel(level: DmRedactionLevel) + fun setDmRedactionLevel(level: DmRedactionLevel) fun setFirstRunCardSeen(seen: Boolean) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockState.kt similarity index 74% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockState.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockState.kt index a4cfab66e6..23648af539 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockState.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.privacylock +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.compositionLocalOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -33,19 +35,25 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch /** - * App-global state holder for the Messages privacy lock. + * App-global state holder for a single privacy-lock [scope]. + * + * One instance per gated route (Messages, Wallet, …) is provided via + * [LocalPrivacyLockState] at the App composition root. All instances share + * the same [PrivacyLockSettings] — one master `lockEnabled` flag enables + * every scope together — but each scope keeps its own [LockState] and its + * own idle-timer [Job] so unlock, leave-route, and inactivity transitions + * apply independently per route. * - * - Single instance per app, provided via [LocalMessagesLockState] at the - * App composition root. * - Initial value is seeded synchronously from [settings.lockEnabled.value] * so the first composition sees [LockState.Locked] without flashing * content (deep-link race fix, plan §Security Hardening H1). * - The underlying StateFlow is hot (`MutableStateFlow`); notification path * can read `state.value` synchronously without subscribing. */ -class MessagesLockState( +class PrivacyLockState( + val scope: LockScope, private val settings: PrivacyLockSettings, - private val scope: CoroutineScope, + private val coroutineScope: CoroutineScope, ) { private val seed: LockState = if (settings.lockEnabled.value) LockState.Locked else LockState.Disabled @@ -64,12 +72,12 @@ class MessagesLockState( } else if (mutableState.value is LockState.Disabled) { mutableState.value = LockState.Locked } - }.launchIn(scope) + }.launchIn(coroutineScope) combine(settings.lockEnabled, settings.inactivityTimer) { enabled, timer -> enabled to timer } .onEach { _ -> if (mutableState.value is LockState.Unlocked) restartIdleTimer() - }.launchIn(scope) + }.launchIn(coroutineScope) } /** Resets the inactivity timer. No-op unless currently Unlocked. */ @@ -90,7 +98,7 @@ class MessagesLockState( * Mark the session as authenticated. Transitions from either * [LockState.Locked] (normal unlock path) or [LockState.Disabled] * (first-run banner path — enabling the lock while the user is - * actively in Messages should NOT flash the lock screen). + * actively in a gated route should NOT flash the lock screen). * No-op if already [LockState.Unlocked]. Starts the idle timer. */ fun onUnlockSuccess() { @@ -105,7 +113,8 @@ class MessagesLockState( /** * Triggered when biometric / OS credential is permanently unavailable. - * Auto-disables the lock so the user can keep accessing Messages. + * Auto-disables the lock (flips every scope to [LockState.Disabled] + * via the shared setting) so the user can keep accessing gated routes. */ fun onCredentialUnavailable() { cancelIdleTimer() @@ -118,6 +127,10 @@ class MessagesLockState( * [PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES] failures: base 30 s, * doubling each further failure, capped at 5 min. * + * Backoff state is shared across scopes — a mistyped password on the + * Wallet gate locks out the Messages gate too (and vice versa). This is + * intentional anti-brute-force behaviour. + * * @param nowMs current epoch millis (injected for testability). * @return the new [PrivacyLockSettings.lockedUntilEpochMs] value, or * null when no lockout yet applies. @@ -148,7 +161,7 @@ class MessagesLockState( cancelIdleTimer() val millis = settings.inactivityTimer.value.millis ?: return idleTimerJob = - scope.launch { + coroutineScope.launch { delay(millis) if (mutableState.value is LockState.Unlocked) { mutableState.value = LockState.Locked @@ -162,8 +175,22 @@ class MessagesLockState( } } -/** Provided once at the App composition root. */ -val LocalMessagesLockState = - compositionLocalOf { - error("LocalMessagesLockState not provided — wrap App() with CompositionLocalProvider") +/** + * Provided once at the App composition root. Map keyed by [LockScope]; every + * scope must have an entry (see [lockStateFor] which throws when missing). + */ +val LocalPrivacyLockState = + compositionLocalOf> { + error("LocalPrivacyLockState not provided — wrap App() with CompositionLocalProvider") } + +/** + * Convenience accessor used inside gate composables. Reads the map from the + * ambient [LocalPrivacyLockState] and returns the state holder for [scope]. + * Throws if the scope was not registered at the App root. + */ +@Composable +@ReadOnlyComposable +fun lockStateFor(scope: LockScope): PrivacyLockState = + LocalPrivacyLockState.current[scope] + ?: error("PrivacyLockState for $scope not registered at App root") diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/CredentialPrompter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/CredentialPrompter.kt index a0c57d50a7..a2c33a8583 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/CredentialPrompter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/CredentialPrompter.kt @@ -55,7 +55,7 @@ enum class PromptResult { /** * Credential surface permanently unavailable on this device — caller - * should invoke [com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState.onCredentialUnavailable]. + * should invoke [com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockState.onCredentialUnavailable]. */ Unavailable, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/IdleTimerModifier.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/IdleTimerModifier.kt index 91adb428a2..710dcbe97e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/IdleTimerModifier.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/IdleTimerModifier.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.ui.privacylock import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput -import com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState +import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockState /** * Observes pointer events on the Initial pass — does NOT consume them, so @@ -35,7 +35,7 @@ import com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState * since they're not user input — preserves the "walked-away-from-desk" * protection per brainstorm resolved Q. */ -fun Modifier.resetIdleOnInteraction(state: MessagesLockState): Modifier = +fun Modifier.resetIdleOnInteraction(state: PrivacyLockState): Modifier = this.pointerInput(state) { awaitPointerEventScope { while (true) { 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 ebc43a69a8..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,40 +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.LocalMessagesLockState +import com.vitorpamplona.amethyst.commons.privacylock.LockScope import com.vitorpamplona.amethyst.commons.privacylock.LockState -import kotlinx.coroutines.launch +import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor /** * 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 @@ -61,12 +42,14 @@ 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 [MessagesLockState.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) { - val lockState = LocalMessagesLockState.current + val lockState = lockStateFor(LockScope.Messages) val current by lockState.state.collectAsState() DisposableEffect(lockState) { @@ -74,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 = LocalMessagesLockState.current - 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/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockStateTest.kt similarity index 67% rename from commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockStateTest.kt rename to commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockStateTest.kt index d3828ab2e6..b6f44c35f7 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockStateTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockStateTest.kt @@ -29,25 +29,27 @@ import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) -class MessagesLockStateTest { +class PrivacyLockStateTest { private class FakeSettings( lockEnabled: Boolean = false, timer: InactivityTimer = InactivityTimer.OneMin, + password: String? = null, ) : PrivacyLockSettings { private val mutableLockEnabled = MutableStateFlow(lockEnabled) private val mutableTimer = MutableStateFlow(timer) private val mutableRedaction = MutableStateFlow(DmRedactionLevel.DEFAULT) private val mutableFirstRunSeen = MutableStateFlow(false) - private val mutablePasswordHashed = MutableStateFlow(null) + private val mutablePasswordHashed = MutableStateFlow(password) private val mutableFailedAttempts = MutableStateFlow(0) private val mutableLockedUntil = MutableStateFlow(null) override val lockEnabled: StateFlow = mutableLockEnabled.asStateFlow() override val inactivityTimer: StateFlow = mutableTimer.asStateFlow() - override val redactionLevel: StateFlow = mutableRedaction.asStateFlow() + override val dmRedactionLevel: StateFlow = mutableRedaction.asStateFlow() override val firstRunCardSeen: StateFlow = mutableFirstRunSeen.asStateFlow() override val passwordHashed: StateFlow = mutablePasswordHashed.asStateFlow() override val failedUnlockAttempts: StateFlow = mutableFailedAttempts.asStateFlow() @@ -61,7 +63,7 @@ class MessagesLockStateTest { mutableTimer.value = timer } - override fun setRedactionLevel(level: DmRedactionLevel) { + override fun setDmRedactionLevel(level: DmRedactionLevel) { mutableRedaction.value = level } @@ -71,6 +73,8 @@ class MessagesLockStateTest { override fun setPasswordHashed(saltAndHash: String?) { mutablePasswordHashed.value = saltAndHash + // Mirror the production cascade — no credential means no gate. + if (saltAndHash == null && mutableLockEnabled.value) mutableLockEnabled.value = false } override fun setFailedUnlockAttempts(count: Int) { @@ -86,7 +90,7 @@ class MessagesLockStateTest { fun cold_start_with_lock_enabled_seeds_to_locked() = runTest { val settings = FakeSettings(lockEnabled = true) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) assertEquals(LockState.Locked, state.state.value) } @@ -94,7 +98,7 @@ class MessagesLockStateTest { fun cold_start_with_lock_disabled_seeds_to_disabled() = runTest { val settings = FakeSettings(lockEnabled = false) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) assertEquals(LockState.Disabled, state.state.value) } @@ -102,7 +106,7 @@ class MessagesLockStateTest { fun unlock_success_transitions_to_unlocked_and_idle_timer_fires() = runTest { val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneMin) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) state.onUnlockSuccess() assertEquals(LockState.Unlocked, state.state.value) advanceTimeBy(InactivityTimer.OneMin.millis!! + 1_000L) @@ -113,7 +117,7 @@ class MessagesLockStateTest { fun leave_route_locks_immediately() = runTest { val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneHour) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) state.onUnlockSuccess() assertEquals(LockState.Unlocked, state.state.value) state.onLeaveRoute() @@ -124,7 +128,7 @@ class MessagesLockStateTest { fun toggling_lock_off_transitions_to_disabled() = runTest(UnconfinedTestDispatcher()) { val settings = FakeSettings(lockEnabled = true) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) state.onUnlockSuccess() assertEquals(LockState.Unlocked, state.state.value) settings.setLockEnabled(false) @@ -135,7 +139,7 @@ class MessagesLockStateTest { fun never_timer_does_not_fire() = runTest { val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.Never) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) state.onUnlockSuccess() advanceTimeBy(InactivityTimer.OneHour.millis!! * 2) assertEquals(LockState.Unlocked, state.state.value) @@ -145,7 +149,7 @@ class MessagesLockStateTest { fun user_interaction_resets_idle_timer() = runTest { val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneMin) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) state.onUnlockSuccess() advanceTimeBy(InactivityTimer.OneMin.millis!! - 1_000L) state.onUserInteraction() @@ -159,7 +163,7 @@ class MessagesLockStateTest { fun credential_unavailable_disables_lock() = runTest { val settings = FakeSettings(lockEnabled = true) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) state.onCredentialUnavailable() assertEquals(LockState.Disabled, state.state.value) assertEquals(false, settings.lockEnabled.value) @@ -169,11 +173,11 @@ class MessagesLockStateTest { fun unlock_success_from_disabled_transitions_to_unlocked() = runTest { // First-run banner path: user enables lock + sets password while - // already viewing Messages. State is Disabled at that moment, and - // we want to stay Unlocked so the user isn't kicked to the lock + // already viewing a gated route. State is Disabled at that moment, + // and we want to stay Unlocked so the user isn't kicked to the lock // screen right after enabling. val settings = FakeSettings(lockEnabled = false) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) assertEquals(LockState.Disabled, state.state.value) state.onUnlockSuccess() assertEquals(LockState.Unlocked, state.state.value) @@ -183,7 +187,7 @@ class MessagesLockStateTest { fun failed_attempts_below_threshold_do_not_trip_lockout() = runTest { val settings = FakeSettings(lockEnabled = true) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) val now = 1_000_000L repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES - 1) { assertEquals(null, state.onFailedUnlockAttempt(now)) @@ -199,7 +203,7 @@ class MessagesLockStateTest { fun fifth_failure_trips_base_lockout() = runTest { val settings = FakeSettings(lockEnabled = true) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) val now = 1_000_000L repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) { state.onFailedUnlockAttempt(now) @@ -212,7 +216,7 @@ class MessagesLockStateTest { fun lockout_doubles_and_caps_at_maximum() = runTest { val settings = FakeSettings(lockEnabled = true) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) val now = 1_000_000L // 5th failure → base (30s) repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) { state.onFailedUnlockAttempt(now) } @@ -230,7 +234,7 @@ class MessagesLockStateTest { fun unlock_success_clears_backoff_state() = runTest { val settings = FakeSettings(lockEnabled = true) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) val now = 1_000_000L repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) { state.onFailedUnlockAttempt(now) } assertTrue(settings.lockedUntilEpochMs.value != null) @@ -239,4 +243,64 @@ class MessagesLockStateTest { assertEquals(null, settings.lockedUntilEpochMs.value) assertEquals(0, settings.failedUnlockAttempts.value) } + + // ---- Wallet-lock reuse additions ---- + + @Test + fun two_scopes_have_independent_lock_state() = + runTest(UnconfinedTestDispatcher()) { + val settings = FakeSettings(lockEnabled = true) + val messages = PrivacyLockState(LockScope.Messages, settings, backgroundScope) + val wallet = PrivacyLockState(LockScope.Wallet, settings, backgroundScope) + assertEquals(LockState.Locked, messages.state.value) + assertEquals(LockState.Locked, wallet.state.value) + + messages.onUnlockSuccess() + assertEquals(LockState.Unlocked, messages.state.value) + assertEquals(LockState.Locked, wallet.state.value) + + messages.onLeaveRoute() + assertEquals(LockState.Locked, messages.state.value) + assertEquals(LockState.Locked, wallet.state.value) + } + + @Test + fun failed_unlock_counter_is_shared_across_scopes() = + runTest { + val settings = FakeSettings(lockEnabled = true) + val messages = PrivacyLockState(LockScope.Messages, settings, backgroundScope) + val wallet = PrivacyLockState(LockScope.Wallet, settings, backgroundScope) + val now = 1_000_000L + // Three failures on Messages, two on Wallet → shared counter hits 5 + repeat(3) { messages.onFailedUnlockAttempt(now) } + repeat(2) { wallet.onFailedUnlockAttempt(now) } + assertEquals( + PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES, + settings.failedUnlockAttempts.value, + ) + // The 5th failure trips the base lockout regardless of which scope + // it came from — either scope now sees the countdown. + assertEquals( + now + PrivacyLockSettings.LOCKOUT_BASE_MS, + settings.lockedUntilEpochMs.value, + ) + } + + @Test + fun clearing_password_cascades_to_disable_the_master_lock() = + runTest(UnconfinedTestDispatcher()) { + val settings = FakeSettings(lockEnabled = true, password = "salt\$hash") + val messages = PrivacyLockState(LockScope.Messages, settings, backgroundScope) + val wallet = PrivacyLockState(LockScope.Wallet, settings, backgroundScope) + assertEquals(LockState.Locked, messages.state.value) + assertEquals(LockState.Locked, wallet.state.value) + + // User clears the password from Settings → cascade fires + settings.setPasswordHashed(null) + + assertEquals(false, settings.lockEnabled.value) + assertEquals(LockState.Disabled, messages.state.value) + assertEquals(LockState.Disabled, wallet.state.value) + assertNull(settings.passwordHashed.value) + } } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PreferencesPrivacyLockSettings.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PreferencesPrivacyLockSettings.kt index 5622ee9863..da4246109e 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PreferencesPrivacyLockSettings.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PreferencesPrivacyLockSettings.kt @@ -63,7 +63,7 @@ class PreferencesPrivacyLockSettings( override val lockEnabled: StateFlow = mutableEnabled.asStateFlow() override val inactivityTimer: StateFlow = mutableTimer.asStateFlow() - override val redactionLevel: StateFlow = mutableRedaction.asStateFlow() + override val dmRedactionLevel: StateFlow = mutableRedaction.asStateFlow() override val firstRunCardSeen: StateFlow = mutableFirstRunSeen.asStateFlow() override val passwordHashed: StateFlow = mutablePasswordHashed.asStateFlow() override val failedUnlockAttempts: StateFlow = mutableFailedAttempts.asStateFlow() @@ -77,7 +77,7 @@ class PreferencesPrivacyLockSettings( // "locked UI / leaking notifications" anti-pattern). if (enabled && mutableRedaction.value == DmRedactionLevel.Full) { val userPickedFull = prefs.getBoolean("redaction_user_set", false) - if (!userPickedFull) setRedactionLevel(DmRedactionLevel.Generic) + if (!userPickedFull) setDmRedactionLevel(DmRedactionLevel.Generic) } } @@ -86,7 +86,7 @@ class PreferencesPrivacyLockSettings( prefs.putInt(KEY_INACTIVITY_TIMER, timer.ordinal) } - override fun setRedactionLevel(level: DmRedactionLevel) { + override fun setDmRedactionLevel(level: DmRedactionLevel) { mutableRedaction.value = level prefs.putInt(KEY_REDACTION_LEVEL, level.ordinal) prefs.putBoolean("redaction_user_set", true) @@ -99,7 +99,15 @@ class PreferencesPrivacyLockSettings( override fun setPasswordHashed(saltAndHash: String?) { mutablePasswordHashed.value = saltAndHash - if (saltAndHash == null) prefs.remove(KEY_PASSWORD_HASHED) else prefs.put(KEY_PASSWORD_HASHED, saltAndHash) + if (saltAndHash == null) { + prefs.remove(KEY_PASSWORD_HASHED) + // A lock without a credential is not a valid state — cascade so the + // toggle can't stay on with nothing to verify against. Every gated + // scope transitions to Disabled via the shared `lockEnabled` flag. + if (mutableEnabled.value) setLockEnabled(false) + } else { + prefs.put(KEY_PASSWORD_HASHED, saltAndHash) + } } override fun setFailedUnlockAttempts(count: Int) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index b909baedb5..33a6b9d6d2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -699,14 +699,17 @@ fun App( com.vitorpamplona.amethyst.commons.privacylock .PreferencesPrivacyLockSettings() } - val messagesLockState = + val privacyLockStates = remember(privacyLockSettings) { - com.vitorpamplona.amethyst.commons.privacylock - .MessagesLockState(privacyLockSettings, appScope) + val scopes = com.vitorpamplona.amethyst.commons.privacylock.LockScope.entries + scopes.associateWith { scope -> + com.vitorpamplona.amethyst.commons.privacylock + .PrivacyLockState(scope, privacyLockSettings, appScope) + } } CompositionLocalProvider( - com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState provides messagesLockState, + com.vitorpamplona.amethyst.commons.privacylock.LocalPrivacyLockState provides privacyLockStates, com.vitorpamplona.amethyst.desktop.security.LocalPrivacyLockSettings provides privacyLockSettings, ) { AppInner( 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 e4337d986e..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,33 @@ */ 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.LocalMessagesLockState +import com.vitorpamplona.amethyst.commons.privacylock.LockScope import com.vitorpamplona.amethyst.commons.privacylock.LockState -import kotlinx.coroutines.delay +import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor /** - * 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) { - val lockState = LocalMessagesLockState.current +fun DesktopMessagesLockGate( + onOpenSettings: (() -> Unit)? = null, + content: @Composable () -> Unit, +) { + val lockState = lockStateFor(LockScope.Messages) val current by lockState.state.collectAsState() DisposableEffect(lockState) { @@ -80,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 = LocalMessagesLockState.current - 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/LocalPrivacyLockSettings.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/LocalPrivacyLockSettings.kt index 6950bb366c..e06d0239e2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/LocalPrivacyLockSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/LocalPrivacyLockSettings.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.desktop.security import androidx.compose.runtime.compositionLocalOf import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings -/** Provided once at the Desktop App root alongside LocalMessagesLockState. */ +/** Provided once at the Desktop App root alongside LocalPrivacyLockState. */ val LocalPrivacyLockSettings = compositionLocalOf { error("LocalPrivacyLockSettings not provided — wrap App() with CompositionLocalProvider") 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 6af1cd1efa..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 @@ -47,7 +47,8 @@ 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.LocalMessagesLockState +import com.vitorpamplona.amethyst.commons.privacylock.LockScope +import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor /** * One-time discovery banner at the top of the Desktop Messages column. @@ -64,7 +65,7 @@ import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState @Composable fun MessagesFirstRunBanner(onSaved: (String) -> Unit = {}) { val settings = LocalPrivacyLockSettings.current - val lockState = LocalMessagesLockState.current + val lockState = lockStateFor(LockScope.Messages) val enabled by settings.lockEnabled.collectAsState() val seen by settings.firstRunCardSeen.collectAsState() var showDialog by remember { mutableStateOf(false) } @@ -92,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/SetPasswordDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/SetPasswordDialog.kt index 80840cd6d6..de88a4d3df 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/SetPasswordDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/SetPasswordDialog.kt @@ -62,7 +62,8 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState +import com.vitorpamplona.amethyst.commons.privacylock.LockScope +import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor import kotlinx.coroutines.delay /** Enforced minimum length for a new/rotated password. */ @@ -232,7 +233,10 @@ fun RemovePasswordDialog( onDismiss: () -> Unit, onConfirm: () -> Unit, ) { - val lockState = LocalMessagesLockState.current + // Remove-password only runs from Settings; the Messages state instance is + // as good as any — both scopes read the same shared lockedUntilEpochMs and + // failedUnlockAttempts, so backoff bookkeeping is scope-agnostic. + val lockState = lockStateFor(LockScope.Messages) val settings = LocalPrivacyLockSettings.current val lockedUntil by settings.lockedUntilEpochMs.collectAsState() 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 9f317693b9..12865e4f61 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 @@ -394,7 +394,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, @@ -500,15 +502,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 113c90debb..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), ) @@ -220,7 +220,7 @@ private fun InactivityCard(settings: PrivacyLockSettings) { @Composable private fun RedactionCard(settings: PrivacyLockSettings) { val enabled by settings.lockEnabled.collectAsState() - val level by settings.redactionLevel.collectAsState() + val level by settings.dmRedactionLevel.collectAsState() if (!enabled) return SettingsCard(title = "DM notification preview") { @@ -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), ) @@ -245,7 +245,7 @@ private fun RedactionCard(settings: PrivacyLockSettings) { DropdownMenuItem( text = { Text(entry.label()) }, onClick = { - settings.setRedactionLevel(entry) + settings.setDmRedactionLevel(entry) expanded = false }, ) @@ -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 new file mode 100644 index 0000000000..7a468e5cd0 --- /dev/null +++ b/docs/plans/2026-07-07-feat-wallet-privacy-lock-reuse-plan.md @@ -0,0 +1,842 @@ +--- +title: Reuse Messaging Privacy Lock on Wallet +type: feat +status: active +date: 2026-07-07 +origin: docs/brainstorms/2026-06-30-feat-messaging-privacy-lock-brainstorm.md +depends_on: docs/plans/2026-06-30-feat-messaging-privacy-lock-plan.md +--- + +# Reuse Messaging Privacy Lock on Wallet + +Extract the messaging-scoped pieces of the shipped **Desktop Privacy Lock** +(branch `feat/desktop-privacy-lock`) into a **scope-parameterised** privacy +lock, then apply the same gate — plus first-run banner and settings knobs — +to the Desktop **Wallet** deck column. + +Goal in one line: **one master lock, one password**, gates Messages *and* +Wallet routes together, zero code duplication. + +**Design finalised (2026-07-07):** +- Single master `lockEnabled` toggle protects both Messages and Wallet + routes (per user decision — not per-scope enable). +- Single `firstRunCardSeen` flag (dismiss once = dismissed everywhere). +- `LockScope` enum exists only to route per-scope UI (lock-screen copy, + independent idle timers, independent leave-route hooks). Settings + surface is one flag. +- Wallet blur-on-unfocus blurs **text nodes only** (balance amount, + addresses, invoice strings) — cards / structural layout stay visible. +- "No password set" branch in the Wallet gate **deep-links** to + Settings → Privacy lock (not just an error message). +- Ships as **one PR** stacked on `feat/desktop-privacy-lock`. + +> Explicitly called out as a follow-up in the messaging-privacy-lock plan: +> > **Wallet (NWC) gate** — reuse the same `MessagesLockGate` plumbing to +> > gate the Wallet deck column. Already on the feature backlog. +> (see plan: `docs/plans/2026-06-30-feat-messaging-privacy-lock-plan.md` +> §Future Considerations) + +## Overview + +Privacy-lock feature currently protects **Messages only**. Financial data +arguably more sensitive: passer-by seeing an NWC balance, a sats-in-flight +receipt, or a QR-linked lightning address is worse than a DM. Wallet also a +fast surface — opening the Wallet column loads the balance immediately, and +NWC receive/send dialogs display payloads on-screen. + +This plan **reuses ~90 %** of the messaging-privacy-lock scaffolding by +turning `MessagesLockState` into a **scoped** state holder, splitting +`lockEnabled` and `firstRunCardSeen` by scope, and applying the gate to +`WalletColumnScreen`. Password + failed-attempts + lockout schedule stay +shared (one password unlocks either scope) — matches Signal/WhatsApp +mental model. + +### Deliverables + +1. `LockScope` enum (`Messages`, `Wallet`) — the single new type. +2. `PrivacyLockState` (renamed from `MessagesLockState`) parameterised by + `LockScope`; one instance per scope, both provided via CompositionLocal at + the App root. +3. `PrivacyLockSettings` gains **per-scope** `lockEnabled` and + `firstRunCardSeen`. Password, inactivity timer, redaction level, + failed-attempts, and lockout stay device-global. +4. Shared `LockScreen()` composable takes a scope; renders scope-aware title + + subtitle strings. +5. `DesktopWalletLockGate` — 30-line wrapper mirroring + `DesktopMessagesLockGate`; also drives `applyWindowCaptureBlock` and + blur-on-unfocus overlay while the Wallet column is visible. +6. `WalletFirstRunBanner` — inline card at top of Wallet column, mirroring + `MessagesFirstRunBanner`. +7. `PrivacyLockSettingsScreen` gets a second card ("Lock the Wallet tab") + + shared subtree for password, inactivity, redaction. +8. Strings genericised: existing `messages_*` keys stay for Messages, new + `wallet_*` mirrors added; a small set of neutral keys added under + `privacy_lock_*` for shared UI (title bar, section header, password + subtree). + +### Out of scope for v1 + +- Android wallet gate — messaging lock does target Android, but wallet + feature backlog emphasises Desktop; Android wallet gating trivial to add + once `PrivacyLockState` scoped, but parked under Future Considerations to + keep the PR bounded. +- Per-note wallet controls (ReactionsRow zap button, ZapCustomDialog, + UpdateZapAmountDialog). Already prompt OS credentials via + `authenticate()` in `UpdateZapAmountDialog.kt:394-490`. Gating them again + would double-prompt. Called out under §System-Wide Impact. +- `amy` CLI `wallet` verbs — currently amy does not expose NWC actions. If + they land, they should re-use `PrivacyLockPreferences` for parity. + +## Problem Statement + +Amethyst Desktop shows the wallet column with a single sidebar click. +Balance auto-fetches on open; NWC receive/send dialogs render invoices and +destination addresses inline. Anyone walking past a logged-in install can: + +- Read the balance in sats. +- See past-payment counterparties in the on-chain zap gallery. +- Trigger the receive dialog and screenshot a lightning invoice belonging to + the account owner. +- Trigger the send dialog and see recently-used destinations. + +Messaging-privacy-lock ships a gate that closes exactly this class of leak +for DMs. Users asking for wallet protection (the driving ask that motivated +this plan) are asking for the *same* gate applied to the *same* fast surface +with the *same* UX contract: + +- Off by default; opt-in via a first-run banner or Settings toggle. +- One shared OS credential / password already established for Messages. +- Idle-timer and leave-route re-lock. +- No extra friction for actions that already gate on OS credentials (nsec + export, zap-amount changes). + +App-wide lock rejected during the messaging brainstorm as too coarse. +Per-scope opt-in matches Signal (`Screen Lock`), WhatsApp (`Chat Lock`), and +the existing shipped behaviour. + +## Proposed Solution + +### One master lock, one password + +Per user decision: **a single master `lockEnabled` toggle gates both +Messages and Wallet routes together.** No per-scope enable flags. + +``` +PrivacyLockSettings +├── lockEnabled : StateFlow UNCHANGED (single master flag) +├── firstRunCardSeen : StateFlow UNCHANGED (single, shared) +├── passwordHashed : StateFlow UNCHANGED (shared) +├── inactivityTimer : StateFlow UNCHANGED (shared) +├── dmRedactionLevel : StateFlow RENAMED from `redactionLevel` (Messages-only semantics) +├── failedUnlockAttempts : StateFlow UNCHANGED (shared) +└── lockedUntilEpochMs : StateFlow UNCHANGED (shared) +``` + +**Cascade on password clear:** when `passwordHashed → null`, +`PrivacyLockSettings` sets `lockEnabled → false` automatically (per user +decision Q8). This closes the "toggle stays on but no credential exists" +edge case without a UI dance. + +Rationale: + +| Setting | Per-scope? | Why | +|---|---|---| +| `lockEnabled` | ❌ | Single master toggle per user decision — enabling protects both Messages and Wallet simultaneously. Simplifies settings surface and matches "one lock, everything sensitive" mental model. | +| `firstRunCardSeen` | ❌ | Dismiss once, dismissed everywhere. User already knows the feature exists after seeing it in either route. | +| `passwordHashed` | ❌ | One password unlocks any gated route. Matches OS-keychain / device-credential precedent. | +| `inactivityTimer` | ❌ | Timing is policy, not scope. Global. | +| `dmRedactionLevel` | ❌ | DM notification redaction — no wallet analogue on Desktop today. Keep Messages-scoped semantics. | +| `failedUnlockAttempts` / `lockedUntilEpochMs` | ❌ | Rate-limit is anti-brute-force — must be global counter. | + +### Two lock states, one prompter + +``` +LocalPrivacyLockState[Messages] ← MessagesLockGate reads +LocalPrivacyLockState[Wallet] ← WalletLockGate reads +LocalCredentialPrompter ← both gates share (unchanged) +LocalPrivacyLockSettings ← both gates + settings screen share (unchanged) +``` + +`PrivacyLockState` is created twice at the App root — one per scope. Both +instances read the **same** `lockEnabled` and `firstRunCardSeen` flags. +Each has its own idle-timer Job and its own `LockState` StateFlow +(Locked ↔ Unlocked ↔ Disabled) so that: + +- Unlocking Messages does *not* automatically unlock Wallet (each route + demands its own credential prompt when the user enters it — this is a + policy choice: the master lock protects *entry*, but re-entering a + gated route is a fresh unlock). +- Idle timer runs per-scope so the currently-visible route drives the + re-lock, and the *other* route stays Locked without a running timer. +- Leaving one route does not affect the other's state. + +Writes to `failedUnlockAttempts` and `lockedUntilEpochMs` go through +shared `PrivacyLockSettings` and therefore apply to both gates +simultaneously — exactly the anti-brute-force property we want. + +### Copy update + +The existing `LockScreen()` in `MessagesLockGate.kt` hard-codes +`"Messages locked"` and `"Unlock to read or send messages"`. Refactor to +accept an `@StringRes` (Android) / string-key (Desktop) title and subtitle +so the same composable serves both scopes. + +Wallet copies: + +| Slot | Wallet copy | +|---|---| +| Title | *"Wallet locked"* | +| Subtitle | *"Unlock to see your balance and send or receive sats."* | +| First-run banner title | *"Lock the Wallet tab?"* | +| First-run banner body | *"Require a password before the Wallet column shows. Feed, profile, and Messages stay open."* | + +Messages copies unchanged. + +## Technical Approach + +### Architecture + +``` + ┌───────────────────────────────────┐ + │ PrivacyLockSettings │ device-global (jvmAndroid) + │ ─ lockEnabled(scope) 2× │ ← NEW: keyed by LockScope + │ ─ firstRunCardSeen(scope) 2× │ ← NEW: keyed by LockScope + │ ─ passwordHashed │ shared + │ ─ inactivityTimer │ shared + │ ─ failedUnlockAttempts │ shared + │ ─ lockedUntilEpochMs │ shared + └────────────────┬──────────────────┘ + │ + ┌─────────────────────┼──────────────────────┐ + │ │ +┌────────────▼────────────┐ ┌────────────────▼────────────┐ +│ PrivacyLockState │ │ PrivacyLockState │ +│ (scope = Messages) │ │ (scope = Wallet) │ +│ ─ state: StateFlow│ │ ─ state: StateFlow │ +│ ─ own idle-timer Job │ │ ─ own idle-timer Job │ +└──────┬───────────────────┘ └───────────────┬──────────────┘ + │ │ +┌──────▼──────────────────────────┐ ┌─────────────▼────────────────┐ +│ DesktopMessagesLockGate │ │ DesktopWalletLockGate │ +│ (unchanged public API) │ │ (NEW — 30 LOC mirror) │ +│ wraps DesktopMessagesScreen │ │ wraps WalletColumnScreen │ +└──────────────────────────────────┘ └──────────────────────────────┘ +``` + +Symmetry: code path from `WalletLockGate` to unlock is byte-for-byte +identical to `MessagesLockGate` — different scope enum, different string +keys. + +### Reuse-vs-New Matrix + +| Component | Status | Location | Action | +|---|---|---|---| +| `PrivacyLockSettings` interface | ♻️ Evolve | `commons/.../privacylock/` | Split enabled+seen into scope-accessor fns | +| `PreferencesPrivacyLockSettings` | ♻️ Evolve | `commons/jvmAndroid/.../privacylock/` | Add scope-suffixed prefs keys + legacy migration | +| `MessagesLockState` | 📦 Rename | `commons/.../privacylock/` | Rename → `PrivacyLockState`, add `scope: LockScope` | +| `LocalMessagesLockState` | 📦 Rename | (companion) | → `LocalPrivacyLockState: Map` | +| `LockState` sealed hierarchy | ✅ Reuse | `commons/.../privacylock/` | Unchanged | +| `InactivityTimer` enum | ✅ Reuse | `commons/.../privacylock/` | Unchanged | +| `DmRedactionLevel` | ✅ Reuse | `commons/.../privacylock/` | Optional rename → `DmRedactionLevel` stays, semantics scoped-out to Messages | +| `CredentialPrompter` interface | ✅ Reuse | `commons/.../ui/privacylock/` | Unchanged | +| `PasswordHasher` | ✅ Reuse | `commons/.../privacylock/` | Unchanged | +| `IdleTimerModifier` | ✅ Reuse | `commons/.../ui/privacylock/` | Unchanged (Modifier already scope-agnostic) | +| `MessagesLockGate` composable | ♻️ Shrink | `commons/.../ui/privacylock/` | ~15 LOC wrapper reading `scope=Messages` | +| `WalletLockGate` composable | 🆕 New | `commons/.../ui/privacylock/` | ~15 LOC mirror | +| Shared `LockScreen(scope,title,subtitle,unlockLabel)` | 🆕 Extract | `commons/.../ui/privacylock/` | Extracted from MessagesLockGate | +| `DesktopMessagesLockGate` | ♻️ Consume | `desktopApp/.../security/` | Point at new shared `LockScreen` | +| `DesktopWalletLockGate` | 🆕 New | `desktopApp/.../security/` | ~60 LOC mirror of `DesktopMessagesLockGate` | +| `MessagesFirstRunBanner` | ♻️ Adjust | `desktopApp/.../security/` | Reads `firstRunCardSeen(Messages)` | +| `WalletFirstRunBanner` | 🆕 New | `desktopApp/.../security/` | Mirror; reads `firstRunCardSeen(Wallet)` | +| `SetPasswordDialog` | ✅ Reuse | `desktopApp/.../security/` | Unchanged (password stays shared) | +| `PrivacyLockSettingsScreen` | ♻️ Two toggles | `desktopApp/.../ui/settings/` | Add Wallet toggle card + section headers | +| `DeckColumnContainer` — Wallet branch | ♻️ Wrap | `desktopApp/.../ui/deck/` | 3-line change — wrap `WalletColumnScreen` with `DesktopWalletLockGate` | +| `WindowCaptureBlock` route set | ♻️ Extend | `desktopApp/.../platform/` | Set expanded to `{Messages, Wallet}` | +| `WalletColumnScreen` | ⚠️ Avoid rewriting | `desktopApp/.../ui/wallet/` | Only insert `WalletFirstRunBanner` at top of column; body unchanged | +| Android `AmethystApp` — provide both scopes | ♻️ Provider | `amethyst/` | 4-line change — `LocalPrivacyLockState` map with 2 entries | +| `Main.kt` App root — provide both scopes | ♻️ Provider | `desktopApp/jvmMain/` | 4-line change | +| Strings — new `wallet_*` keys, rename shared `messages_lock_*` → `privacy_lock_*` | ♻️ | Android + Desktop | +6 keys, ~4 renames | + +**Legend:** ✅ Reuse · 📦 Rename · ♻️ Evolve · 🆕 New · ⚠️ Avoid + +### Data Migration + +Because `lockEnabled` and `firstRunCardSeen` stay single-key under the +master-lock model, **no prefs key migration is required**. The only +rename touching persisted state is `redaction_level_ordinal` (unchanged +key name; only the Kotlin-side identifier renames to `dmRedactionLevel`). + +Existing prefs keys retained as-is: + +``` +lock_enabled // master flag, unchanged +first_run_card_seen // shared, unchanged +password_hashed // unchanged +inactivity_timer_ordinal // unchanged +redaction_level_ordinal // unchanged (Kotlin var renamed to dmRedactionLevel) +failed_unlock_attempts // unchanged +locked_until_epoch_ms // unchanged +``` + +This is a pure additive change from the persistence layer's point of +view — Wallet gate simply reads the same flag Messages gate already +reads. + +### Implementation Phases + +#### Phase 1 — Genericise the state holder (foundation) + +Rename + parametrise **without** changing wire behaviour yet. Both +`MessagesLockGate` and Desktop wrapper still work; nothing else changes. + +Files to create / modify: + +- **NEW** `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/LockScope.kt` + ```kotlin + package com.vitorpamplona.amethyst.commons.privacylock + + enum class LockScope { Messages, Wallet } + ``` +- **RENAME** `commons/.../privacylock/MessagesLockState.kt` → + `PrivacyLockState.kt` + - Rename class → `PrivacyLockState`, add constructor + `scope: LockScope`. + - Store `scope` on the instance; pass through to + `settings.lockEnabled(scope)` / + `settings.firstRunCardSeen(scope)`. + - Companion: replace `LocalMessagesLockState: + ProvidableCompositionLocal` with + `LocalPrivacyLockState: + ProvidableCompositionLocal>`. + - Add extension: + `@Composable fun lockStateFor(scope: LockScope) = + LocalPrivacyLockState.current.getValue(scope)`. +- **MODIFY** `commons/.../privacylock/PrivacyLockSettings.kt` interface: + - Replace `val lockEnabled: StateFlow` with + `fun lockEnabled(scope: LockScope): StateFlow`. + - Same for `firstRunCardSeen`. + - Same for setters: `setLockEnabled(scope, enabled)`, + `setFirstRunCardSeen(scope, seen)`. + - `passwordHashed`, `inactivityTimer`, `redactionLevel`, + `failedUnlockAttempts`, `lockedUntilEpochMs` — unchanged. + - Update `companion object` constants: + - `KEY_LOCK_ENABLED = "lock_enabled_"` (prefix; scope name appended) + - `KEY_FIRST_RUN_CARD_SEEN = "first_run_card_seen_"` (prefix) + - `KEY_SCHEMA_VERSION = "schema_version"` + - `CURRENT_SCHEMA_VERSION = 2` +- **MODIFY** `commons/jvmAndroid/.../privacylock/PreferencesPrivacyLockSettings.kt`: + - Add per-scope `MutableStateFlow` maps: + `Map>` for enabled and seen. + - Seed each entry synchronously from prefs (respecting the deep-link + race fix in the messaging-privacy-lock plan H1). + - Add legacy-key migration in `init` block (see §Data Migration). + - Setters write to the scope-suffixed key. +- **RENAME + EXTEND** `commons/commonTest/.../privacylock/MessagesLockStateTest.kt` + → `PrivacyLockStateTest.kt`. Add tests: + - `test_two_scopes_have_independent_state` — Messages Locked, Wallet + Disabled, no cross-talk. + - `test_shared_failed_unlock_counter` — a failure in Messages scope + ticks the counter Wallet-scope reads. + - `test_migration_from_legacy_prefs_keys` — write legacy keys, load + settings, assert Messages scope has the value, Wallet default false, + legacy keys removed, `schema_version = 2` written. + +Ship this phase as its own commit — no UI changes; keeps `git bisect` +useful. + +**Acceptance:** + +- [x] `./gradlew :commons:jvmTest --tests "*PrivacyLockState*"` green (all 5 existing + 3 new) +- [x] `./gradlew :desktopApp:compileKotlin` green (only rename+delegate calls updated) +- [x] `./gradlew :amethyst:compilePlayDebugKotlin` green + +#### Phase 2 — Extract `LockScreen`, add `WalletLockGate` + +- **NEW** `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/LockScreen.kt` + - Extract `@Composable private fun LockScreen()` currently inline in + `MessagesLockGate.kt`. + - Make `internal`, take + `scope: LockScope, title: String, subtitle: String, unlockLabel: String`. + - No behaviour change beyond parameterisation. +- **SHRINK** `commons/.../ui/privacylock/MessagesLockGate.kt` to a + ~15-line wrapper that fetches `lockStateFor(LockScope.Messages)`, + `DisposableEffect(onLeaveRoute)`, and delegates the locked branch to + `LockScreen(LockScope.Messages, stringRes(R.string.privacy_lock_messages_title), …)`. +- **NEW** `commons/.../ui/privacylock/WalletLockGate.kt` — 15-line mirror. + Scope = `Wallet`. Strings from + `R.string.privacy_lock_wallet_title` / + `R.string.privacy_lock_wallet_subtitle`. + +Test coverage: unit tests on `PrivacyLockState` cover the state +transitions; the gate composable is minimal and Compose-tested only via +the manual sheet. + +**Acceptance:** + +- [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 + +- **NEW** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopWalletLockGate.kt` + — mirror `DesktopMessagesLockGate.kt`. Only differences from Messages + version: + - Reads `lockStateFor(LockScope.Wallet)` instead of Messages. + - Renders *"Wallet locked"* title, *"Enter your privacy-lock + password to view the wallet."* subtitle. + - `stored == null` branch: *"No password is set yet."* + button + **"Open Settings"** that navigates via + `SinglePaneState.navigate(DeckColumnType.Settings)` and (if the + settings screen supports section anchors) deep-links to the + Privacy-lock section. Falls back to plain Settings navigation if + no anchor available (Q5 deep-link). + - No independent password-hashing / lockout math — those come from + shared `PrivacyLockSettings`. +- **NEW** `desktopApp/.../security/WalletFirstRunBanner.kt` — mirror + `MessagesFirstRunBanner.kt`. Only differences: + - Reads `firstRunCardSeen(LockScope.Wallet)`. + - Enable button writes `setLockEnabled(LockScope.Wallet, true)` and + marks scope=Wallet card seen. + - Text as per §Copy update table. + - Icon = `MaterialSymbols.Lock` (same as Messages) — no new codepoint, + so no font-subset regeneration needed. +- `DesktopWalletLockGate` also drives capture-block and blur-on-unfocus + the same way the Messages gate does — expand `WindowCaptureBlock.kt` + so both routes flip the flag when the master lock is enabled AND the + corresponding route is visible. +- **Wallet blur mode** — per user decision Q4, blur only sensitive text + nodes, not the whole column. Implementation: + - New `Modifier.privacyLockBlurWhenUnfocused()` extension in + `desktopApp/.../platform/` that reads `LocalWindowFocus.current` and + applies `Modifier.blur(radius = 16.dp)` only when unfocused AND + `lockEnabled == true`. + - Apply this Modifier to Text composables that display: balance sats + amount, lightning invoice string, on-chain address, NWC connection + URI, and any transaction memo. **Do NOT** apply to card containers, + icons, or button rows — the visual layout stays intact. + - Grep target: any `Text(text = ...sats...)`, `Text(text = invoice)`, + `Text(text = address)` in `WalletColumnScreen.kt`, + `OnchainSection.kt` (if reused in Desktop), and NWC dialogs. + +Wire into `DeckColumnContainer.kt`: + +```kotlin +DeckColumnType.Wallet -> { + DesktopWalletLockGate { + WalletColumnScreen( + account = account, + accountManager = accountManager, + relayManager = relayManager, + localCache = localCache, + nwcConnection = nwcConnection, + appScope = appScope, + onZapFeedback = onZapFeedback, + ) + } +} +``` + +Place `WalletFirstRunBanner` at the top of `WalletColumnScreen`'s Column +(mirroring where `MessagesFirstRunBanner` sits in the Messages column +entry). + +**Acceptance:** + +- [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 + +Refactor `desktopApp/.../ui/settings/PrivacyLockSettingsScreen.kt` +minimally — with the single-master-lock design, the shipped screen +already has the right shape. Only cosmetic + copy changes: + +- **Rename** the master-lock card header from *"Lock the Messages tab"* + → *"Lock the app"* (or *"Enable privacy lock"* — pick one, see + the strings table). +- **Update body copy** for the master-lock card to name what it protects: + *"Require your password before Messages and Wallet columns show. Feed, + profile, and search stay open."* +- **Update caveat text** at top of screen: replace + *"This lock hides the Messages column…"* with *"This lock hides the + Messages and Wallet columns on an unattended device. See the caveats + below."*. +- Password / inactivity / redaction cards unchanged. + +Layout order top-to-bottom (unchanged from shipped except copy): + +``` +Section header: "Privacy lock" +├── Card: "Privacy-lock password" (shared — always visible) +├── Card: "Enable privacy lock" (single master toggle) +├── Card: "Auto-lock after" (visible when master toggle is on) +├── Card: "DM notification previews" (visible when master toggle is on) +└── Card: "Caveats" (shared — always visible) +``` + +**Acceptance:** + +- [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 + +Strings to add / rename (Android `strings.xml` + Desktop +`messages.properties`): + +Shared (renamed from `messages_lock_*` → `privacy_lock_*` where +applicable): + +| Old key | New key | Notes | +|---|---|---| +| `messages_lock_setting_title` | `privacy_lock_settings_title` | section header | +| `messages_lock_screen_password_label` | `privacy_lock_screen_password_label` | shared | +| `messages_lock_screen_unlock_button` | `privacy_lock_screen_unlock_button` | shared | +| (new) | `privacy_lock_intro_body` | *"This lock hides the Messages column and/or the Wallet column on an unattended device."* | + +Scope-specific (Messages keys stay verbatim; Wallet keys mirror them): + +| Wallet key | Value | +|---|---| +| `privacy_lock_wallet_toggle_title` | *"Lock the Wallet tab"* | +| `privacy_lock_wallet_toggle_body` | *"Require a password before the Wallet column shows. Feed, profile, and Messages stay open."* | +| `privacy_lock_wallet_lockscreen_title` | *"Wallet locked"* | +| `privacy_lock_wallet_lockscreen_subtitle` | *"Unlock to see your balance and send or receive sats."* | +| `privacy_lock_wallet_firstrun_title` | *"Lock the Wallet tab?"* | +| `privacy_lock_wallet_firstrun_body` | *"Require a password before Wallet shows. Feed, profile, and Messages stay open."* | + +Other tasks: + +- Run legacy-key migration (Phase 1) on first startup after upgrade. +- Update `commons/ARCHITECTURE.md` — mention `LockScope` under the + `privacylock/` package entry. +- Update `MEMORY.md` — add pointer to this plan alongside the + messaging-privacy-lock pointer. +- `./gradlew spotlessApply`. +- Update manual testing sheet (see §Documentation Plan) — copy the + Messages sheet, adjust for Wallet. +- Verify Crowdin sync propagates the new keys (existing PR pipeline + already syncs; no new machinery needed). + +**Acceptance:** + +- [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 + +### Interaction Graph + +User clicks Wallet in sidebar → +`SinglePaneState.navigate(DeckColumnType.Wallet)` → +`DeckColumnContainer` composes Wallet branch → +`DesktopWalletLockGate` reads `lockStateFor(LockScope.Wallet).state` → + +- If `Disabled` or `Unlocked` → `WalletColumnScreen` composes; + `WalletFirstRunBanner` may render at top if user hasn't dismissed it + and lock is disabled. +- If `Locked` → `LockScreen(scope = Wallet, title = "Wallet locked", + subtitle = "Unlock to see your balance and send or receive sats.")` + renders. On unlock success → `PrivacyLockState.onUnlockSuccess()` → + `WalletColumnScreen` composes. + +User leaves the Wallet column (navigates away, switches account, or +window closes) → `DesktopWalletLockGate.DisposableEffect.onDispose` → +`PrivacyLockState.onLeaveRoute()` for scope=Wallet only. Messages state +unaffected. + +Cross-scope: if user is on Messages, unlocks, then navigates to Wallet, +the Wallet gate still shows (independent scopes). Same password → +Wallet unlocks. Matches settings UX: two toggles, one credential. + +### Error Propagation + +Wallet gate uses the identical `submit` path as `DesktopMessagesLockGate` +in the shipped code: + +| Origin | Error | Handled at | Result | +|---|---|---|---| +| Wrong password | `PasswordHasher.verify → false` | `DesktopWalletLockGate.submit` | `showError = true`; `onFailedUnlockAttempt` increments **shared** counter | +| 5 consecutive failures | shared counter hits `LOCKOUT_TRIP_AFTER_FAILURES` | `PrivacyLockState.onFailedUnlockAttempt` | Shared `lockedUntilEpochMs` set → **both** scopes show the countdown supportingText | +| Password cleared while wallet Locked | `settings.passwordHashed → null` | `DesktopWalletLockGate.DesktopLockScreen` | *"No password is set yet"* branch renders; `Disable lock` button clears `lockEnabled(Wallet)` | +| Wallet toggle enabled but no password | Settings screen | Enable button triggers `SetPasswordDialog` first | +| Settings write fails (java.util.prefs full) | `PreferencesPrivacyLockSettings.setLockEnabled` | Existing best-effort semantics | Toggle reverts on next flow emit; user sees no confirmation | + +### State Lifecycle Risks + +| Risk | Mitigation | +|---|---| +| Wallet locked, incoming NWC balance/receipt event decrypts in background — plaintext held in memory | Same posture as messaging plan: cosmetic lock, not cryptographic. Balance StateFlow keeps last-known value. NWC responses continue to arrive on the coroutine scope; UI just doesn't render them until unlock. Honest and matches messaging behaviour. Called out in §Known Limitations. | +| App killed mid-unlock leaves Wallet stuck at Locked | State is in-memory; cold start re-reads `lockEnabled(Wallet)` from prefs → if enabled, starts Locked. Fail-safe. | +| User toggles Wallet off while Locked | `settings.lockEnabled(Wallet) → false` flows into `PrivacyLockState` which transitions `Locked → Disabled` on the next tick. Gate transparently shows content. Matches messaging behaviour. | +| Both scopes Locked, user in middle of a send-payment flow | Send-payment happens **inside** an already-unlocked scope; if idle timer fires mid-flow, the dialog stays composed (rememberSaveable) but the content behind is gated. Intentional — do not exempt in-flight payment dialogs from the timer. Manual test: `payment_flow_survives_timer.md`. | +| Concurrent leave-route events (Wallet + Messages navigating away simultaneously) | Each `PrivacyLockState` has its own idle-timer Job; no cross-scope races. | + +### API Surface Parity + +| Surface | Affected? | Notes | +|---|---|---| +| Android wallet UI (`OnchainSection`, `AddCashuWalletScreen`) | Deferred to v2 | Not touched in this plan — see §Future Considerations. | +| `amy` CLI | Not touched | CLI does not surface NWC actions today; when it does, use `PrivacyLockSettings.lockEnabled(Wallet)` for parity. | +| `UpdateZapAmountDialog.authenticate()` (nsec-key-guard biometric prompt) | Not affected | Separate OS-credential gate on the zap-amount-preferences change flow. Wallet gate is orthogonal — zap flow already gates OS credentials for a stronger reason. | +| One-click zap from a note (`ReactionsRow.RenderZapButton`) | Not gated | Wallet **column** is gated; zap **action** from feed context is not. Matches messaging: Messages **column** is gated; DM replies from a note thread are not (there aren't any). | +| Wallet notifications (NWC `success`, `failed`) | None on Desktop today | Desktop has no notification pipeline for wallet events. If added, use `redactionLevel` — but v1 keeps redaction Messages-only per §Proposed Solution. | +| Search results — NWC receipts / on-chain zaps | Not affected | `SearchBarViewModel` search-audit path from messaging plan already filters kinds 4/14/1059/443. NWC events (kind 23194/23195/23196) aren't searchable today. If they become searchable, add them to the audit list. | + +### Integration Test Scenarios + +1. **Cross-scope lockout**: Wallet locked. User enters 5 wrong + passwords on Wallet screen. Then navigates to Messages (also locked + via Messages toggle). Expected: Messages screen shows countdown + supportingText, `Unlock` button disabled. Failure mode: counter + scoped per-gate would defeat brute-force protection. +2. **Wallet lock + auto-fetched balance**: Wallet toggle just enabled; + user has been on Wallet column with balance loaded. Setting flip → + gate re-composes → balance is hidden behind the lock screen + **immediately**. Failure mode: balance visible for one frame during + transition. +3. **First-run banner interaction**: Fresh install → Messages column + visited → Messages banner shown, dismissed. User visits Wallet → + Wallet banner shown independently (not shared dismissal). Failure + mode: shared `firstRunCardSeen` would suppress Wallet banner. +4. **Toggle-disable while Locked**: User is on the Wallet lock screen → + opens Settings → Privacy lock → toggles Wallet off → returns to + Wallet. Expected: content shows without unlock. Failure mode: state + stays Locked because the settings update didn't cascade. +5. **NWC connect flow while locked**: New user, no NWC connected, + Wallet toggle on. Expected: `WalletColumnScreen`'s connect UI is + gated behind the lock — a Locked-gate does not let unauth users + trigger NWC pairing. Desired security posture. +6. **Password change → shared re-verify**: User changes password while + only Messages is locked. Then enables Wallet. Wallet lock screen + accepts the **new** password (not the old one). Failure mode: two + password hashes cached separately. +7. **Migration from legacy prefs**: User on a build with the shipped + `feat/desktop-privacy-lock` (single `lock_enabled` key) upgrades to + this build. Expected: Messages toggle preserved; Wallet toggle + defaults off. Legacy prefs keys removed; `schema_version = 2` + written. Failure mode: users get silently un-locked on upgrade, OR + migration re-runs and clobbers a subsequent Wallet toggle. + +## Acceptance Criteria + +### Functional + +- [x] `LockScope` enum shipped in `commons/commonMain` +- [x] `PrivacyLockState` replaces `MessagesLockState`; each scope has an + independent `state: StateFlow` and idle-timer Job +- [x] `PrivacyLockSettings.lockEnabled` and `firstRunCardSeen` stay single + master flags (per user Q2) +- [x] Password / inactivity timer / redaction / failed-attempts / lockout + remain device-global (shared) +- [x] `MessagesLockGate` public signature unchanged; wired to + `lockStateFor(Messages)` +- [x] `WalletLockGate` composable shipped in + `commons/.../ui/privacylock/` +- [x] Shared `LockScreen(scope, title, subtitle, unlockLabel)` composable + replaces the inlined lock screen inside MessagesLockGate; both + gates render it +- [x] `DesktopMessagesLockGate` refactored to consume shared + `DesktopLockScreen`; behaviour preserved +- [x] `DesktopWalletLockGate` shipped; wraps `WalletColumnScreen` inside + `DeckColumnContainer` +- [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 +- [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 + +- [ ] No measurable startup regression (≤ +5 ms cold start on top of + messaging-lock baseline) +- [ ] `PrivacyLockState.state` reads are constant-time regardless of + scope count (Map lookup, no reflection) +- [ ] No new deps added — everything stays inside kotlinx.coroutines + + Compose + the existing java.util.prefs / SharedPreferences setup +- [ ] Password comparison stays constant-time via `PasswordHasher.verify` + (unchanged) +- [ ] No visible flash of Wallet content on cold start when + `lockEnabled(Wallet) = true` — seeded synchronously (deep-link race + fix H1 from messaging plan applies to both scopes) + +### Quality Gates + +- [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 (post-merge task) + +## Success Metrics + +- **Adoption proxy**: after 30 days on nightly, at least half the users + who enabled the Messages lock have also enabled the Wallet lock. If + the ratio is far lower, the discoverability (first-run banner + placement + settings copy) needs rework. +- **Stability proxy**: zero support reports of *"wallet stuck at + locked"* or *"wrong password after change"* in the first 30 days. +- **Regression proxy**: no new issues on Messages lock after this PR + merges — the refactor keeps behaviour identical for the Messages path. + +## Dependencies & Prerequisites + +- **Blocked on**: `feat/desktop-privacy-lock` merged into main. This + plan builds on top of that shipped feature; extracting into a + scope-parameterised state holder while the messaging code is still + on a branch would create merge-conflict hell. +- **No new deps**: everything reuses the shipped `androidx.biometric.ktx`, + `com.sun.jna:jna`, java.util.prefs, SharedPreferences, Compose + Multiplatform. +- **No native shims added**: Touch ID `.dylib`, Windows credprompter, + `NSWindowSharingNone` / `WDA_EXCLUDEFROMCAPTURE` shims — all already + shipped by `feat/desktop-privacy-lock`. This plan just adds the Wallet + route into the set that flips the flag. + +## Risk Analysis & Mitigation + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Migration bug leaves a Messages user un-locked on upgrade | Medium | High (silent security regression) | Migration is copy-then-delete; version-gated by `schema_version = 2`; unit-tested; runs once and no-ops afterwards | +| Per-scope idle timers get out of sync (e.g. two timers on different Jobs miscoordinate) | Low | Low | Each `PrivacyLockState` is a self-contained state machine; no cross-scope coordination; unit test asserts independence | +| Users confused by two toggles + one password | Medium | Low (UX) | Settings copy: password card explicitly says *"One password. Applies to any tab you lock below."* Manual testing sheet includes a UX-clarity checkpoint | +| Wallet balance flashes visible on cold start | Low | High (privacy leak) | Same synchronous seed as messaging (H1). Compose-test asserts no-flash invariant on Wallet route too | +| Shared failed-attempts counter causes friction — a user mistyping in Wallet locks out Messages | Verified | Low | Intended behaviour — brute-force protection is a global property. Copy in the lockout supportingText clarifies: *"Too many failed attempts. Try again in ${countdown}."* — same message on both scopes | +| Refactor breaks `MessagesLockGate` on the shipped branch | Medium | High | Phase 1 is behaviour-preserving; Phase 2 preserves `MessagesLockGate`'s public signature; verified by a full manual pass on the shipped Messages testing sheet | +| ProGuard strips scope-based lookups | Low | Medium | `LockScope` is a simple enum — ProGuard-safe. Confirm during Phase 1 packaging | + +## Future Considerations + +- **Android wallet gate.** When Android wallet is elevated to a first-class + destination (currently the wallet lives in a subscreen, not a tab), wrap + its Compose entry point with `WalletLockGate` — no state-holder change + required; `PrivacyLockState[Wallet]` already exists. +- **amy CLI wallet verbs.** If `amy wallet balance` / `amy wallet send` + land, they should refuse to run when + `PrivacyLockPreferences.lockEnabled(Wallet)` is `true` — closes the + "run amy on a shared machine to snapshot the balance" gap. +- **Per-transaction OS-credential re-prompt on Wallet send.** Optional + belt-and-suspenders: when a send-payment exceeds a user-configurable + threshold (e.g. 10k sats), fire the same + `UpdateZapAmountDialog.authenticate()` prompt. Tracks separately — + this plan is about the column gate, not per-action gates. +- **Third scope: nsec / account settings.** Once we have `LockScope`, we + could add `LockScope.Account` to gate the Account backup screen. + Today that screen already uses OS-credential re-prompts, so added + value is marginal. +- **`redactionLevel` extension for wallet notifications.** If Desktop gets + a notification pipeline for NWC events (balance changes, incoming + zaps), add a `WalletRedactionLevel` and gate the same way DM + notifications are gated. Currently no such pipeline exists. + +## Documentation Plan + +- `commons/ARCHITECTURE.md` — update the `privacylock/` package entry: + mention the `LockScope` enum and the "one settings, many scopes" + contract. +- `docs/plans/2026-07-07-wallet-lock-manual-testing.md` — new manual + testing sheet mirroring + `docs/plans/2026-06-30-privacy-lock-manual-testing.md`. Include the 7 + integration test scenarios above as concrete steps. +- No changes needed to `desktopApp/CLAUDE.md` — no new native shim. +- `MEMORY.md` — index entry alongside the messaging-privacy-lock work. +- Release notes: extend the "Privacy & Security" section from + messaging-privacy-lock with a one-line Wallet addition. + +## Sources & References + +### Origin + +- **Brainstorm document**: + [`docs/brainstorms/2026-06-30-feat-messaging-privacy-lock-brainstorm.md`](../brainstorms/2026-06-30-feat-messaging-privacy-lock-brainstorm.md) + — where the "reuse for Wallet" follow-up was explicitly enumerated as + a Future Consideration. +- **Predecessor plan**: + [`docs/plans/2026-06-30-feat-messaging-privacy-lock-plan.md`](2026-06-30-feat-messaging-privacy-lock-plan.md) + — carried-forward decisions: (a) OS credentials only, (b) device-global + settings, (c) inactivity timer + leave-route re-lock, (d) synchronous + initial-state seed for the deep-link race fix, (e) shared password + hashing + exponential-backoff lockout. + +### Internal References + +- Extracted from: + `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockState.kt` + (on branch `feat/desktop-privacy-lock`) +- Extracted from: + `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/MessagesLockGate.kt` + (on branch `feat/desktop-privacy-lock`) +- Extracted from: + `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopMessagesLockGate.kt` +- Wallet column entry: + `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt:88` +- Deck integration site: + `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt:469-479` +- Settings screen: + `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt` +- OS-credential biometric precedent: + `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt:394-490` +- CLAUDE.md — `commons/ARCHITECTURE.md` governs package taxonomy + +### External References + +- Signal Screen Lock (per-app opt-in, single credential): + https://support.signal.org/hc/en-us/articles/360007059572 +- WhatsApp Chat Lock (per-chat, single credential): + https://about.fb.com/news/2023/05/whatsapp-chat-lock/ +- Ledger Live "auto-lock all tabs" — this plan's per-scope model is + weaker than Ledger's app-wide lock; intentional (matches Signal + + WhatsApp UX and the brainstorm's explicit rejection of an app-wide + lock). + +### Related Work + +- Messaging privacy lock plan (parent): + `docs/plans/2026-06-30-feat-messaging-privacy-lock-plan.md` +- Desktop wallet + zapping (defines the surface being gated): memory + pointer *"Desktop Wallet & Zapping"* — branch + `feat/desktop-wallet-zapping` +- Account security hardening (concurrent work; `passwordHashed` storage + lives in the same jvmAndroid source set that the account-security work + touches — coordinate merge order): + `docs/plans/2026-05-14-fix-account-security-hardening-plan.md` + +## Open Questions — RESOLVED (2026-07-07) + +1. **Merge order** — ✅ Solo PR stacked on `feat/desktop-privacy-lock`. +2. **Lock granularity** — ✅ **Single master lock** protects both Messages + and Wallet. No per-scope enable flag. Single `firstRunCardSeen` too. +3. **Wallet first-run banner on empty NWC** — Show anyway (feature is + valuable pre-connect). +4. **Blur-on-unfocus for Wallet** — ✅ Blur **text nodes only** (balance + amount, addresses, invoices). Cards / structural layout stay visible. + Implementation: apply `Modifier.blur(16.dp)` at the Text-composable + level for sensitive strings, not the LazyColumn wrapper. +5. **"No password set" branch behaviour** — ✅ Deep-link to Settings → + Privacy lock section (not just show the message). +6. **Rename `redactionLevel` → `dmRedactionLevel`** — ✅ Yes. Kotlin-side + only; persisted key `redaction_level_ordinal` stays for compatibility. +7. **`LockScope` package** — ✅ Inside existing `privacylock/` package. +8. **Cascade `passwordHashed → null` unsets `lockEnabled`** — ✅ Yes. + Implement in `PreferencesPrivacyLockSettings.setPasswordHashed(null)` + → also `setLockEnabled(false)` atomically. 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: _________