From d0daf786b1c9ada2a932fe93225193b68650784b Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 7 Jul 2026 13:18:56 +0300 Subject: [PATCH] feat(commons): scope-parameterise PrivacyLockState for multi-route lock reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Genericises the messaging privacy-lock state holder so a single master `lockEnabled` flag can drive multiple gated routes independently: - `LockScope { Messages, Wallet }` enum added. - `MessagesLockState` → `PrivacyLockState(scope, settings, coroutineScope)`. Each scope keeps its own StateFlow + idle-timer Job; both scopes share the same `PrivacyLockSettings` so failed-attempt counters and lockout schedule stay device-global (brute-force protection). - `LocalMessagesLockState` (single instance) → `LocalPrivacyLockState` (Map) + `lockStateFor(scope)` accessor. - `redactionLevel` → `dmRedactionLevel` (Kotlin-side rename; persisted prefs key `redaction_level_ordinal` unchanged). - `setPasswordHashed(null)` cascades to `setLockEnabled(false)` so a master lock cannot stay armed without a credential to verify against. MessagesLockGate, DesktopMessagesLockGate, MessagesFirstRunBanner, SetPasswordDialog, and RedactionCard now read `lockStateFor(Messages)` — behaviour-preserving. Ships 3 new PrivacyLockStateTest cases: independent per-scope state, shared failed-attempt counter, and the password-clear cascade. Plan: docs/plans/2026-07-07-feat-wallet-privacy-lock-reuse-plan.md --- .../amethyst/commons/privacylock/LockScope.kt | 30 + .../privacylock/PrivacyLockSettings.kt | 4 +- ...ssagesLockState.kt => PrivacyLockState.kt} | 55 +- .../ui/privacylock/CredentialPrompter.kt | 2 +- .../ui/privacylock/IdleTimerModifier.kt | 4 +- .../ui/privacylock/MessagesLockGate.kt | 9 +- ...ckStateTest.kt => PrivacyLockStateTest.kt} | 102 ++- .../PreferencesPrivacyLockSettings.kt | 16 +- .../vitorpamplona/amethyst/desktop/Main.kt | 11 +- .../security/DesktopMessagesLockGate.kt | 7 +- .../security/LocalPrivacyLockSettings.kt | 2 +- .../security/MessagesFirstRunBanner.kt | 5 +- .../desktop/security/SetPasswordDialog.kt | 8 +- .../ui/settings/PrivacyLockSettingsScreen.kt | 4 +- ...-07-feat-wallet-privacy-lock-reuse-plan.md | 844 ++++++++++++++++++ 15 files changed, 1043 insertions(+), 60 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/LockScope.kt rename commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/{MessagesLockState.kt => PrivacyLockState.kt} (74%) rename commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/{MessagesLockStateTest.kt => PrivacyLockStateTest.kt} (67%) create mode 100644 docs/plans/2026-07-07-feat-wallet-privacy-lock-reuse-plan.md 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/MessagesLockGate.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/MessagesLockGate.kt index ebc43a69a8..461d909e45 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 @@ -43,8 +43,9 @@ 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 com.vitorpamplona.amethyst.commons.privacylock.lockStateFor import kotlinx.coroutines.launch /** @@ -61,12 +62,12 @@ 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 + * The gate also fires [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) { @@ -81,7 +82,7 @@ fun MessagesLockGate(content: @Composable () -> Unit) { @Composable private fun LockScreen() { - val lockState = LocalMessagesLockState.current + val lockState = lockStateFor(LockScope.Messages) val prompter = LocalCredentialPrompter.current val scope = rememberCoroutineScope() 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 dfc908106b..16228a78fc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -697,14 +697,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/DesktopMessagesLockGate.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopMessagesLockGate.kt index e4337d986e..28428a23b5 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 @@ -51,8 +51,9 @@ 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 com.vitorpamplona.amethyst.commons.privacylock.lockStateFor import kotlinx.coroutines.delay /** @@ -72,7 +73,7 @@ import kotlinx.coroutines.delay */ @Composable fun DesktopMessagesLockGate(content: @Composable () -> Unit) { - val lockState = LocalMessagesLockState.current + val lockState = lockStateFor(LockScope.Messages) val current by lockState.state.collectAsState() DisposableEffect(lockState) { @@ -87,7 +88,7 @@ fun DesktopMessagesLockGate(content: @Composable () -> Unit) { @Composable private fun DesktopLockScreen() { - val lockState = LocalMessagesLockState.current + val lockState = lockStateFor(LockScope.Messages) val settings = LocalPrivacyLockSettings.current val stored by settings.passwordHashed.collectAsState() val lockedUntil by settings.lockedUntilEpochMs.collectAsState() 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..55f129e638 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) } 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/ui/settings/PrivacyLockSettingsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt index 113c90debb..872a48b6f6 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 @@ -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") { @@ -245,7 +245,7 @@ private fun RedactionCard(settings: PrivacyLockSettings) { DropdownMenuItem( text = { Text(entry.label()) }, onClick = { - settings.setRedactionLevel(entry) + settings.setDmRedactionLevel(entry) expanded = false }, ) 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..9ff224db78 --- /dev/null +++ b/docs/plans/2026-07-07-feat-wallet-privacy-lock-reuse-plan.md @@ -0,0 +1,844 @@ +--- +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) +- [ ] `./gradlew :amethyst:assembleDebug` 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:** + +- [ ] `MessagesLockGate` public signature unchanged (no caller changes) +- [ ] `WalletLockGate` exposes the same `content: @Composable () -> Unit` lambda +- [ ] Extracted `LockScreen` renders the correct title/subtitle for whichever scope invokes it + +#### 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:** + +- [ ] Toggling `LockScope.Wallet` on in Settings → next Wallet column open shows the lock screen +- [ ] Correct password (verified against shared `passwordHashed`) unlocks +- [ ] Wrong password 5 times → lockout applies to **both** scopes (verified by observing Messages column also blocked) +- [ ] Leaving the Wallet column re-locks it +- [ ] Idle timer configured via shared `inactivityTimer` setting re-locks Wallet after N minutes +- [ ] Screen-capture protection engages while Wallet column visible (macOS: `NSWindowSharingNone`; Windows: `WDA_EXCLUDEFROMCAPTURE`) +- [ ] Blur-on-unfocus overlay renders over the Wallet column when the Amethyst window loses focus (16 dp radius, matches Messages) + +#### 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:** + +- [ ] Toggling the master lock on with no password → prompts to set one (existing behaviour) +- [ ] Toggling the master lock on locks **both** Messages and Wallet on next entry +- [ ] Toggling the master lock off unlocks **both** immediately (transitions Locked → Disabled) +- [ ] Clearing the password auto-unsets the master toggle (Q8 cascade) + +#### 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:** + +- [ ] `./gradlew :commons:jvmTest --tests "*privacylock*"` green +- [ ] `./gradlew :amethyst:assembleDebug` green +- [ ] `./gradlew :desktopApp:compileKotlin` green +- [ ] `./gradlew spotlessApply` clean +- [ ] Manual testing sheet passes (see §Documentation Plan) + +## 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 + +- [ ] `LockScope` enum shipped in `commons/commonMain` +- [ ] `PrivacyLockState` replaces `MessagesLockState`; each scope has an + independent `state: StateFlow` and idle-timer Job +- [ ] `PrivacyLockSettings.lockEnabled` and `firstRunCardSeen` are + scope-accessor functions +- [ ] Password / inactivity timer / redaction / failed-attempts / lockout + remain device-global (shared) +- [ ] `MessagesLockGate` public signature unchanged; wired to + `lockStateFor(Messages)` +- [ ] `WalletLockGate` composable shipped in + `commons/.../ui/privacylock/` +- [ ] Shared `LockScreen(scope, title, subtitle, unlockLabel)` composable + replaces the inlined lock screen inside MessagesLockGate; both + gates render it +- [ ] `DesktopMessagesLockGate` unchanged in behaviour; consumes the new + shared `LockScreen` +- [ ] `DesktopWalletLockGate` shipped; wraps `WalletColumnScreen` inside + `DeckColumnContainer` +- [ ] `MessagesFirstRunBanner` unchanged in behaviour +- [ ] `WalletFirstRunBanner` shipped at top of `WalletColumnScreen` +- [ ] Settings screen renders two toggles + shared password subtree + + shared inactivity timer + Messages-only redaction card +- [ ] Legacy prefs migration runs on first startup after upgrade — old + `lock_enabled` value moved to `lock_enabled_Messages`, then old key + removed; `schema_version = 2` written +- [ ] `applyWindowCaptureBlock(true)` engages when either lock is enabled + AND the corresponding route is visible +- [ ] Blur-on-unfocus overlay renders over Wallet column when window + loses focus AND `lockEnabled(Wallet) == true` + +### 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 + +- [ ] `./gradlew :commons:jvmTest --tests "*privacylock*"` green (8 tests) +- [ ] `./gradlew :amethyst:assembleDebug` green +- [ ] `./gradlew :desktopApp:compileKotlin` green +- [ ] `./gradlew :desktopApp:packageDmg` green on macOS host +- [ ] `./gradlew :desktopApp:packageMsi` green on Windows host (best effort) +- [ ] `./gradlew :desktopApp:packageDeb` green on Linux host +- [ ] `./gradlew spotlessApply` clean +- [ ] Manual testing sheet + (`docs/plans/2026-07-07-wallet-lock-manual-testing.md`) executed + and signed off + +## 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.