diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/DmRedactionLevel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/DmRedactionLevel.kt new file mode 100644 index 0000000000..45a383cae0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/DmRedactionLevel.kt @@ -0,0 +1,42 @@ +/* + * 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 + +/** + * Two-level redaction policy for DM notification surfaces. + * + * `Full` shows sender name + message preview. `Generic` collapses to + * "Amethyst: New message" and strips MessagingStyle history + inline-reply + * `RemoteInput` so notification-listener apps and lock-screen banners cannot + * exfiltrate DM bodies. Tied to the privacy lock toggle — when the lock is + * off, `Full` is forced regardless of any prior user choice. + */ +enum class DmRedactionLevel { + Generic, + Full, + ; + + companion object { + val DEFAULT = Full + + fun fromOrdinal(ordinal: Int): DmRedactionLevel = entries.getOrNull(ordinal) ?: DEFAULT + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/InactivityTimer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/InactivityTimer.kt new file mode 100644 index 0000000000..fe02fbbb16 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/InactivityTimer.kt @@ -0,0 +1,38 @@ +/* + * 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 + +enum class InactivityTimer( + val millis: Long?, +) { + OneMin(60_000L), + FiveMin(5L * 60_000L), + FifteenMin(15L * 60_000L), + OneHour(60L * 60_000L), + Never(null), + ; + + companion object { + val DEFAULT = FiveMin + + fun fromOrdinal(ordinal: Int): InactivityTimer = entries.getOrNull(ordinal) ?: DEFAULT + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/LockState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/LockState.kt new file mode 100644 index 0000000000..51712318b0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/LockState.kt @@ -0,0 +1,32 @@ +/* + * 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 + +sealed interface LockState { + /** Privacy lock is turned off entirely. Gate is transparent. */ + data object Disabled : LockState + + /** Lock is enabled and currently engaged. Gate intercepts Messages route. */ + data object Locked : LockState + + /** Lock is enabled and currently unlocked. Gate is transparent until next trigger. */ + data object Unlocked : LockState +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockState.kt new file mode 100644 index 0000000000..a4cfab66e6 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockState.kt @@ -0,0 +1,169 @@ +/* + * 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 + +import androidx.compose.runtime.compositionLocalOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +/** + * App-global state holder for the Messages privacy lock. + * + * - 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( + private val settings: PrivacyLockSettings, + private val scope: CoroutineScope, +) { + private val seed: LockState = + if (settings.lockEnabled.value) LockState.Locked else LockState.Disabled + + private val mutableState = MutableStateFlow(seed) + val state: StateFlow = mutableState.asStateFlow() + + private var idleTimerJob: Job? = null + + init { + settings.lockEnabled + .onEach { enabled -> + if (!enabled) { + cancelIdleTimer() + mutableState.value = LockState.Disabled + } else if (mutableState.value is LockState.Disabled) { + mutableState.value = LockState.Locked + } + }.launchIn(scope) + + combine(settings.lockEnabled, settings.inactivityTimer) { enabled, timer -> enabled to timer } + .onEach { _ -> + if (mutableState.value is LockState.Unlocked) restartIdleTimer() + }.launchIn(scope) + } + + /** Resets the inactivity timer. No-op unless currently Unlocked. */ + fun onUserInteraction() { + if (mutableState.value !is LockState.Unlocked) return + restartIdleTimer() + } + + /** Re-lock immediately on route exit or account switch. Idempotent. */ + fun onLeaveRoute() { + if (mutableState.value is LockState.Unlocked) { + cancelIdleTimer() + mutableState.value = LockState.Locked + } + } + + /** + * 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). + * No-op if already [LockState.Unlocked]. Starts the idle timer. + */ + fun onUnlockSuccess() { + if (mutableState.value !is LockState.Unlocked) { + mutableState.value = LockState.Unlocked + restartIdleTimer() + } + // Always clear failed-attempt state on any authenticated flow — even + // when transitioning from Disabled (banner "enable" path). + onUnlockAttemptResetToZero() + } + + /** + * Triggered when biometric / OS credential is permanently unavailable. + * Auto-disables the lock so the user can keep accessing Messages. + */ + fun onCredentialUnavailable() { + cancelIdleTimer() + settings.setLockEnabled(false) + mutableState.value = LockState.Disabled + } + + /** + * Record a failed unlock attempt. Applies exponential backoff after + * [PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES] failures: base 30 s, + * doubling each further failure, capped at 5 min. + * + * @param nowMs current epoch millis (injected for testability). + * @return the new [PrivacyLockSettings.lockedUntilEpochMs] value, or + * null when no lockout yet applies. + */ + fun onFailedUnlockAttempt(nowMs: Long): Long? { + val next = settings.failedUnlockAttempts.value + 1 + settings.setFailedUnlockAttempts(next) + val overshoot = next - PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES + if (overshoot < 0) { + settings.setLockedUntilEpochMs(null) + return null + } + val duration = + (PrivacyLockSettings.LOCKOUT_BASE_MS shl overshoot) + .coerceAtMost(PrivacyLockSettings.LOCKOUT_MAX_MS) + val until = nowMs + duration + settings.setLockedUntilEpochMs(until) + return until + } + + /** Clear the failed-attempt counter and any active lockout. */ + fun onUnlockAttemptResetToZero() { + settings.setFailedUnlockAttempts(0) + settings.setLockedUntilEpochMs(null) + } + + private fun restartIdleTimer() { + cancelIdleTimer() + val millis = settings.inactivityTimer.value.millis ?: return + idleTimerJob = + scope.launch { + delay(millis) + if (mutableState.value is LockState.Unlocked) { + mutableState.value = LockState.Locked + } + } + } + + private fun cancelIdleTimer() { + idleTimerJob?.cancel() + idleTimerJob = null + } +} + +/** Provided once at the App composition root. */ +val LocalMessagesLockState = + compositionLocalOf { + error("LocalMessagesLockState not provided — wrap App() with CompositionLocalProvider") + } 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 new file mode 100644 index 0000000000..39901fd790 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockSettings.kt @@ -0,0 +1,102 @@ +/* + * 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 + +import androidx.compose.runtime.Stable +import kotlinx.coroutines.flow.StateFlow + +/** + * User-tunable settings for the Messages privacy lock. + * + * Device-global per the brainstorm decision (no NIP-78 sync, no per-account + * variants). Platform implementations back this with `java.util.prefs` + * (Desktop) or `SharedPreferences` (Android). Initial values must be read + * synchronously from storage in the constructor so the first `setContent { }` + * sees the seeded state — required to close the deep-link race fix (security + * hardening H1 in the plan). + */ +@Stable +interface PrivacyLockSettings { + val lockEnabled: StateFlow + val inactivityTimer: StateFlow + val redactionLevel: StateFlow + val firstRunCardSeen: StateFlow + + /** + * Non-null when the user has set a password on this device. Value is + * either `salt$hash` (legacy, 100k PBKDF2 iterations) or `v1$salt$hash` + * (current, 600k iterations) — never a raw password. + * Platforms may use this differently: Android does not use it today + * (biometric is authoritative); Desktop uses it as the unlock gate. + */ + val passwordHashed: StateFlow + + /** + * Number of consecutive failed unlock attempts. Reset to 0 on successful + * unlock. Persisted across app restarts so a reboot cannot reset the + * exponential backoff (see [lockedUntilEpochMs]). + */ + val failedUnlockAttempts: StateFlow + + /** + * Epoch millis until which the user is locked out from attempting to + * unlock. `null` when no lockout is active. Persists across app + * restarts. Trip point is 5 consecutive failures; schedule doubles + * each subsequent failure and caps at 5 minutes. + */ + val lockedUntilEpochMs: StateFlow + + fun setLockEnabled(enabled: Boolean) + + fun setInactivityTimer(timer: InactivityTimer) + + fun setRedactionLevel(level: DmRedactionLevel) + + fun setFirstRunCardSeen(seen: Boolean) + + /** Store a versioned or legacy hash string; pass `null` to clear. */ + fun setPasswordHashed(saltAndHash: String?) + + fun setFailedUnlockAttempts(count: Int) + + fun setLockedUntilEpochMs(millis: Long?) + + companion object { + const val DEFAULT_LOCK_ENABLED = false + const val NODE_NAME = "com/vitorpamplona/amethyst/privacylock" + const val KEY_LOCK_ENABLED = "lock_enabled" + const val KEY_INACTIVITY_TIMER = "inactivity_timer_ordinal" + const val KEY_REDACTION_LEVEL = "redaction_level_ordinal" + const val KEY_FIRST_RUN_CARD_SEEN = "first_run_card_seen" + const val KEY_PASSWORD_HASHED = "password_hashed" + const val KEY_FAILED_UNLOCK_ATTEMPTS = "failed_unlock_attempts" + const val KEY_LOCKED_UNTIL_EPOCH_MS = "locked_until_epoch_ms" + + /** Threshold at which exponential backoff begins. */ + const val LOCKOUT_TRIP_AFTER_FAILURES = 5 + + /** Base lockout on the [LOCKOUT_TRIP_AFTER_FAILURES]th failure. Doubles each subsequent failure. */ + const val LOCKOUT_BASE_MS = 30_000L + + /** Max lockout duration after repeated failures. */ + const val LOCKOUT_MAX_MS = 5L * 60_000L + } +} 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 new file mode 100644 index 0000000000..a0c57d50a7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/CredentialPrompter.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.commons.ui.privacylock + +import androidx.compose.runtime.compositionLocalOf + +/** + * Platform-agnostic credential prompt for the Messages privacy lock. + * + * Implementations: + * - Android: BiometricPrompt(STRONG | DEVICE_CREDENTIAL). + * - macOS: Touch ID via libAmethystTouchID.dylib (with OS password fallback). + * - Windows: CredUIPromptForCredentials (OS password — Windows Hello deferred to v2). + * - Linux: disabled — `available` returns false; toggle is hidden in settings. + */ +interface CredentialPrompter { + /** True when the platform credential surface can be invoked. */ + val available: Boolean + + /** + * Prompt the user for a credential. Returns one of [PromptResult]. + * Hardcoded localized reason on Kotlin side — never accept user-supplied + * strings (security finding #14: avoid OS-prompt spoofing surface). + */ + suspend fun prompt(): PromptResult +} + +enum class PromptResult { + /** User authenticated successfully. */ + Success, + + /** User dismissed the prompt or pressed cancel. Stay locked, no toast. */ + UserCanceled, + + /** Temporary lockout (e.g. too many biometric attempts). */ + TemporaryLockout, + + /** + * Credential surface permanently unavailable on this device — caller + * should invoke [com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState.onCredentialUnavailable]. + */ + Unavailable, + + /** Wrong password / authentication failed. */ + Failed, +} + +val LocalCredentialPrompter = + compositionLocalOf { + error("LocalCredentialPrompter not provided — wrap App() with platform CredentialPrompter") + } 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 new file mode 100644 index 0000000000..91adb428a2 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/IdleTimerModifier.kt @@ -0,0 +1,46 @@ +/* + * 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.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState + +/** + * Observes pointer events on the Initial pass — does NOT consume them, so + * underlying scroll / click handlers behave normally. Every gesture resets + * the idle timer in [state]. Apply ONCE at the route root to avoid scattering + * reset calls across every child composable (per architecture review). + * + * Incoming DM events (background flow updates) DO NOT trigger this modifier + * since they're not user input — preserves the "walked-away-from-desk" + * protection per brainstorm resolved Q. + */ +fun Modifier.resetIdleOnInteraction(state: MessagesLockState): Modifier = + this.pointerInput(state) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent(PointerEventPass.Initial) + state.onUserInteraction() + } + } + } 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 new file mode 100644 index 0000000000..ebc43a69a8 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/MessagesLockGate.kt @@ -0,0 +1,143 @@ +/* + * 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.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.LockState +import kotlinx.coroutines.launch + +/** + * Wraps the Messages route and gates entry behind the credential prompt. + * + * Branch selection happens SYNCHRONOUSLY in composition — no + * [LaunchedEffect] guard — so the chat content composable never enters + * composition while [LockState.Locked]. Closes the deep-link race + * (plan §Security Hardening H1). + * + * The gate is an overlay, NOT a wrapper that disposes content. While + * locked, the [content] lambda is not invoked at all; on unlock, the + * lambda is invoked fresh. This means TextField drafts that use + * `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. + */ +@Composable +fun MessagesLockGate(content: @Composable () -> Unit) { + val lockState = LocalMessagesLockState.current + val current by lockState.state.collectAsState() + + DisposableEffect(lockState) { + onDispose { lockState.onLeaveRoute() } + } + + when (current) { + is LockState.Locked -> LockScreen() + 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/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockStateTest.kt new file mode 100644 index 0000000000..d3828ab2e6 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockStateTest.kt @@ -0,0 +1,242 @@ +/* + * 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 + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class MessagesLockStateTest { + private class FakeSettings( + lockEnabled: Boolean = false, + timer: InactivityTimer = InactivityTimer.OneMin, + ) : 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 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 firstRunCardSeen: StateFlow = mutableFirstRunSeen.asStateFlow() + override val passwordHashed: StateFlow = mutablePasswordHashed.asStateFlow() + override val failedUnlockAttempts: StateFlow = mutableFailedAttempts.asStateFlow() + override val lockedUntilEpochMs: StateFlow = mutableLockedUntil.asStateFlow() + + override fun setLockEnabled(enabled: Boolean) { + mutableLockEnabled.value = enabled + } + + override fun setInactivityTimer(timer: InactivityTimer) { + mutableTimer.value = timer + } + + override fun setRedactionLevel(level: DmRedactionLevel) { + mutableRedaction.value = level + } + + override fun setFirstRunCardSeen(seen: Boolean) { + mutableFirstRunSeen.value = seen + } + + override fun setPasswordHashed(saltAndHash: String?) { + mutablePasswordHashed.value = saltAndHash + } + + override fun setFailedUnlockAttempts(count: Int) { + mutableFailedAttempts.value = count + } + + override fun setLockedUntilEpochMs(millis: Long?) { + mutableLockedUntil.value = millis + } + } + + @Test + fun cold_start_with_lock_enabled_seeds_to_locked() = + runTest { + val settings = FakeSettings(lockEnabled = true) + val state = MessagesLockState(settings, backgroundScope) + assertEquals(LockState.Locked, state.state.value) + } + + @Test + fun cold_start_with_lock_disabled_seeds_to_disabled() = + runTest { + val settings = FakeSettings(lockEnabled = false) + val state = MessagesLockState(settings, backgroundScope) + assertEquals(LockState.Disabled, state.state.value) + } + + @Test + fun unlock_success_transitions_to_unlocked_and_idle_timer_fires() = + runTest { + val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneMin) + val state = MessagesLockState(settings, backgroundScope) + state.onUnlockSuccess() + assertEquals(LockState.Unlocked, state.state.value) + advanceTimeBy(InactivityTimer.OneMin.millis!! + 1_000L) + assertEquals(LockState.Locked, state.state.value) + } + + @Test + fun leave_route_locks_immediately() = + runTest { + val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneHour) + val state = MessagesLockState(settings, backgroundScope) + state.onUnlockSuccess() + assertEquals(LockState.Unlocked, state.state.value) + state.onLeaveRoute() + assertEquals(LockState.Locked, state.state.value) + } + + @Test + fun toggling_lock_off_transitions_to_disabled() = + runTest(UnconfinedTestDispatcher()) { + val settings = FakeSettings(lockEnabled = true) + val state = MessagesLockState(settings, backgroundScope) + state.onUnlockSuccess() + assertEquals(LockState.Unlocked, state.state.value) + settings.setLockEnabled(false) + assertEquals(LockState.Disabled, state.state.value) + } + + @Test + fun never_timer_does_not_fire() = + runTest { + val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.Never) + val state = MessagesLockState(settings, backgroundScope) + state.onUnlockSuccess() + advanceTimeBy(InactivityTimer.OneHour.millis!! * 2) + assertEquals(LockState.Unlocked, state.state.value) + } + + @Test + fun user_interaction_resets_idle_timer() = + runTest { + val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneMin) + val state = MessagesLockState(settings, backgroundScope) + state.onUnlockSuccess() + advanceTimeBy(InactivityTimer.OneMin.millis!! - 1_000L) + state.onUserInteraction() + advanceTimeBy(InactivityTimer.OneMin.millis!! - 1_000L) + assertTrue(state.state.value is LockState.Unlocked) + advanceTimeBy(2_000L) + assertEquals(LockState.Locked, state.state.value) + } + + @Test + fun credential_unavailable_disables_lock() = + runTest { + val settings = FakeSettings(lockEnabled = true) + val state = MessagesLockState(settings, backgroundScope) + state.onCredentialUnavailable() + assertEquals(LockState.Disabled, state.state.value) + assertEquals(false, settings.lockEnabled.value) + } + + @Test + 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 + // screen right after enabling. + val settings = FakeSettings(lockEnabled = false) + val state = MessagesLockState(settings, backgroundScope) + assertEquals(LockState.Disabled, state.state.value) + state.onUnlockSuccess() + assertEquals(LockState.Unlocked, state.state.value) + } + + @Test + fun failed_attempts_below_threshold_do_not_trip_lockout() = + runTest { + val settings = FakeSettings(lockEnabled = true) + val state = MessagesLockState(settings, backgroundScope) + val now = 1_000_000L + repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES - 1) { + assertEquals(null, state.onFailedUnlockAttempt(now)) + } + assertEquals(null, settings.lockedUntilEpochMs.value) + assertEquals( + PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES - 1, + settings.failedUnlockAttempts.value, + ) + } + + @Test + fun fifth_failure_trips_base_lockout() = + runTest { + val settings = FakeSettings(lockEnabled = true) + val state = MessagesLockState(settings, backgroundScope) + val now = 1_000_000L + repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) { + state.onFailedUnlockAttempt(now) + } + val expected = now + PrivacyLockSettings.LOCKOUT_BASE_MS + assertEquals(expected, settings.lockedUntilEpochMs.value) + } + + @Test + fun lockout_doubles_and_caps_at_maximum() = + runTest { + val settings = FakeSettings(lockEnabled = true) + val state = MessagesLockState(settings, backgroundScope) + val now = 1_000_000L + // 5th failure → base (30s) + repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) { state.onFailedUnlockAttempt(now) } + val base = settings.lockedUntilEpochMs.value!! - now + assertEquals(PrivacyLockSettings.LOCKOUT_BASE_MS, base) + // 6th → doubles to 60s + state.onFailedUnlockAttempt(now) + assertEquals(PrivacyLockSettings.LOCKOUT_BASE_MS * 2, settings.lockedUntilEpochMs.value!! - now) + // Enough further failures to hit the cap + repeat(20) { state.onFailedUnlockAttempt(now) } + assertEquals(PrivacyLockSettings.LOCKOUT_MAX_MS, settings.lockedUntilEpochMs.value!! - now) + } + + @Test + fun unlock_success_clears_backoff_state() = + runTest { + val settings = FakeSettings(lockEnabled = true) + val state = MessagesLockState(settings, backgroundScope) + val now = 1_000_000L + repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) { state.onFailedUnlockAttempt(now) } + assertTrue(settings.lockedUntilEpochMs.value != null) + assertTrue(settings.failedUnlockAttempts.value > 0) + state.onUnlockSuccess() + assertEquals(null, settings.lockedUntilEpochMs.value) + assertEquals(0, settings.failedUnlockAttempts.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 new file mode 100644 index 0000000000..5622ee9863 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PreferencesPrivacyLockSettings.kt @@ -0,0 +1,114 @@ +/* + * 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 + +import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.Companion.DEFAULT_LOCK_ENABLED +import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.Companion.KEY_FAILED_UNLOCK_ATTEMPTS +import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.Companion.KEY_FIRST_RUN_CARD_SEEN +import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.Companion.KEY_INACTIVITY_TIMER +import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.Companion.KEY_LOCKED_UNTIL_EPOCH_MS +import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.Companion.KEY_LOCK_ENABLED +import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.Companion.KEY_PASSWORD_HASHED +import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.Companion.KEY_REDACTION_LEVEL +import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.Companion.NODE_NAME +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.prefs.Preferences + +/** + * Desktop JVM-platform implementation backed by [java.util.prefs.Preferences]. + * + * Default node `com/vitorpamplona/amethyst/privacylock` is shared between + * the Desktop app and `amy` CLI running as the same OS user — both observe + * the same setting without extra plumbing. `Preferences` auto-flushes on + * shutdown and periodically; no explicit `flush()` calls needed. + * + * Initial values are read synchronously in the constructor — required so + * the first composition sees seeded state without flashing content. + */ +class PreferencesPrivacyLockSettings( + private val prefs: Preferences = Preferences.userRoot().node(NODE_NAME), +) : PrivacyLockSettings { + private val mutableEnabled = MutableStateFlow(prefs.getBoolean(KEY_LOCK_ENABLED, DEFAULT_LOCK_ENABLED)) + private val mutableTimer = + MutableStateFlow(InactivityTimer.fromOrdinal(prefs.getInt(KEY_INACTIVITY_TIMER, InactivityTimer.DEFAULT.ordinal))) + private val mutableRedaction = + MutableStateFlow(DmRedactionLevel.fromOrdinal(prefs.getInt(KEY_REDACTION_LEVEL, DmRedactionLevel.DEFAULT.ordinal))) + private val mutableFirstRunSeen = MutableStateFlow(prefs.getBoolean(KEY_FIRST_RUN_CARD_SEEN, false)) + private val mutablePasswordHashed = MutableStateFlow(prefs.get(KEY_PASSWORD_HASHED, null)) + private val mutableFailedAttempts = MutableStateFlow(prefs.getInt(KEY_FAILED_UNLOCK_ATTEMPTS, 0)) + private val mutableLockedUntil = + MutableStateFlow( + prefs.getLong(KEY_LOCKED_UNTIL_EPOCH_MS, -1L).takeIf { it > 0 }, + ) + + override val lockEnabled: StateFlow = mutableEnabled.asStateFlow() + override val inactivityTimer: StateFlow = mutableTimer.asStateFlow() + override val redactionLevel: StateFlow = mutableRedaction.asStateFlow() + override val firstRunCardSeen: StateFlow = mutableFirstRunSeen.asStateFlow() + override val passwordHashed: StateFlow = mutablePasswordHashed.asStateFlow() + override val failedUnlockAttempts: StateFlow = mutableFailedAttempts.asStateFlow() + override val lockedUntilEpochMs: StateFlow = mutableLockedUntil.asStateFlow() + + override fun setLockEnabled(enabled: Boolean) { + mutableEnabled.value = enabled + prefs.putBoolean(KEY_LOCK_ENABLED, enabled) + // First time the user enables the lock, auto-bump redaction to Generic + // unless they've explicitly chosen Full (deepen review — closes the + // "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) + } + } + + override fun setInactivityTimer(timer: InactivityTimer) { + mutableTimer.value = timer + prefs.putInt(KEY_INACTIVITY_TIMER, timer.ordinal) + } + + override fun setRedactionLevel(level: DmRedactionLevel) { + mutableRedaction.value = level + prefs.putInt(KEY_REDACTION_LEVEL, level.ordinal) + prefs.putBoolean("redaction_user_set", true) + } + + override fun setFirstRunCardSeen(seen: Boolean) { + mutableFirstRunSeen.value = seen + prefs.putBoolean(KEY_FIRST_RUN_CARD_SEEN, seen) + } + + override fun setPasswordHashed(saltAndHash: String?) { + mutablePasswordHashed.value = saltAndHash + if (saltAndHash == null) prefs.remove(KEY_PASSWORD_HASHED) else prefs.put(KEY_PASSWORD_HASHED, saltAndHash) + } + + override fun setFailedUnlockAttempts(count: Int) { + mutableFailedAttempts.value = count + prefs.putInt(KEY_FAILED_UNLOCK_ATTEMPTS, count) + } + + override fun setLockedUntilEpochMs(millis: Long?) { + mutableLockedUntil.value = millis + if (millis == null) prefs.remove(KEY_LOCKED_UNTIL_EPOCH_MS) else prefs.putLong(KEY_LOCKED_UNTIL_EPOCH_MS, millis) + } +} 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 94e84c0b3a..dfc908106b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -294,6 +294,10 @@ fun main() { // Callback set by App() for single pane navigation from MenuBar var navigateToScreen by remember { mutableStateOf<((DeckColumnType) -> Unit)?>(null) } + // Messages privacy lock CompositionLocals are provided inside App() + // itself (see App() around line ~700) so tests that call App() + // directly — bypassing this Main.kt Window shell — still get them. + // Window title-bar / taskbar thumbnail icon. On macOS the source logo // is wrapped in a squircle so it matches every other dock icon; on // other platforms the raw transparent logo is used as-is. @@ -682,6 +686,85 @@ fun App( val singlePaneState = remember { SinglePaneState() } val pinnedNavBarState = remember { PinnedNavBarState(workspaceManager).also { it.loadFromWorkspace() } } + // Messages privacy lock — app-global settings + state holder, scoped to + // App() so they survive appRestartKey rebuilds but rebuild on genuine app + // restart. Provided as CompositionLocals right here so both production + // (called from application { Window { App() } }) and tests (which call + // App() directly, bypassing outer providers) see them. + val appScope = rememberCoroutineScope() + val privacyLockSettings = + remember { + com.vitorpamplona.amethyst.commons.privacylock + .PreferencesPrivacyLockSettings() + } + val messagesLockState = + remember(privacyLockSettings) { + com.vitorpamplona.amethyst.commons.privacylock + .MessagesLockState(privacyLockSettings, appScope) + } + + CompositionLocalProvider( + com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState provides messagesLockState, + com.vitorpamplona.amethyst.desktop.security.LocalPrivacyLockSettings provides privacyLockSettings, + ) { + AppInner( + layoutMode = layoutMode, + onLayoutModeChange = onLayoutModeChange, + deckState = deckState, + workspaceManager = workspaceManager, + accountManager = accountManager, + showComposeDialog = showComposeDialog, + showAppDrawer = showAppDrawer, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onDismissComposeDialog = onDismissComposeDialog, + onDismissAppDrawer = onDismissAppDrawer, + onShowAppDrawer = onShowAppDrawer, + replyToNote = replyToNote, + showImportFollowListDialog = showImportFollowListDialog, + onShowImportFollowListDialog = onShowImportFollowListDialog, + onDismissImportFollowListDialog = onDismissImportFollowListDialog, + onRestartApp = onRestartApp, + torManager = torManager, + torTypeFlow = torTypeFlow, + externalPortFlow = externalPortFlow, + initialTorSettings = initialTorSettings, + onNavigateToScreen = onNavigateToScreen, + testOverrides = testOverrides, + singlePaneState = singlePaneState, + pinnedNavBarState = pinnedNavBarState, + ) + } +} + +@Composable +private fun AppInner( + layoutMode: LayoutMode, + onLayoutModeChange: (LayoutMode) -> Unit, + deckState: DeckState, + workspaceManager: WorkspaceManager, + accountManager: AccountManager, + showComposeDialog: Boolean, + showAppDrawer: Boolean, + onShowComposeDialog: () -> Unit, + onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onDismissComposeDialog: () -> Unit, + onDismissAppDrawer: () -> Unit, + onShowAppDrawer: () -> Unit, + replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?, + showImportFollowListDialog: Boolean, + onShowImportFollowListDialog: () -> Unit, + onDismissImportFollowListDialog: () -> Unit, + onRestartApp: () -> Unit, + torManager: com.vitorpamplona.amethyst.commons.tor.ITorManager, + torTypeFlow: kotlinx.coroutines.flow.MutableStateFlow, + externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow, + initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings, + onNavigateToScreen: ((DeckColumnType) -> Unit) -> Unit, + testOverrides: LaunchTestOverrides?, + singlePaneState: SinglePaneState, + pinnedNavBarState: PinnedNavBarState, +) { // Register single pane navigation callback for MenuBar shortcuts LaunchedEffect(singlePaneState) { onNavigateToScreen { screen -> singlePaneState.navigate(screen) } @@ -2088,6 +2171,13 @@ fun RelaySettingsScreen( Spacer(Modifier.height(16.dp)) } + // Privacy lock section + com.vitorpamplona.amethyst.desktop.ui.settings + .PrivacyLockSettingsScreen() + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + // Content Filters section — hashtag-spam filter and future // content-moderation toggles. Text( 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 new file mode 100644 index 0000000000..e4337d986e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopMessagesLockGate.kt @@ -0,0 +1,217 @@ +/* + * 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.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.LockState +import kotlinx.coroutines.delay + +/** + * Desktop equivalent of `MessagesLockGate`. Uses password verification + * synchronously — no async CredentialPrompter round-trip needed. + * + * Renders content when Disabled / Unlocked; renders an inline password + * input when Locked. If no password has been set, prompts the user to set + * one first (this fires from the settings toggle in normal flow, so the + * fallback exists only as a safety net). + * + * 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. + */ +@Composable +fun DesktopMessagesLockGate(content: @Composable () -> Unit) { + val lockState = LocalMessagesLockState.current + val current by lockState.state.collectAsState() + + DisposableEffect(lockState) { + onDispose { lockState.onLeaveRoute() } + } + + when (current) { + is LockState.Locked -> DesktopLockScreen() + 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/LocalPrivacyLockSettings.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/LocalPrivacyLockSettings.kt new file mode 100644 index 0000000000..6950bb366c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/LocalPrivacyLockSettings.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.desktop.security + +import androidx.compose.runtime.compositionLocalOf +import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings + +/** Provided once at the Desktop App root alongside LocalMessagesLockState. */ +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 new file mode 100644 index 0000000000..6af1cd1efa --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/MessagesFirstRunBanner.kt @@ -0,0 +1,130 @@ +/* + * 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.LocalMessagesLockState + +/** + * One-time discovery banner at the top of the Desktop Messages column. + * Nudges users who haven't enabled the privacy lock yet. Modeled on + * `OfflineBanner.kt` (AnimatedVisibility + Surface + Row). + * + * Visibility: `!lockEnabled && !firstRunCardSeen`. Dismissal is sticky + * per the `firstRunCardSeen` flag — the banner does NOT reappear if + * the user later disables the lock. + * + * Renders nothing when the gate is Locked (implicit — the gate replaces + * content, so this composable never composes in that case). + */ +@Composable +fun MessagesFirstRunBanner(onSaved: (String) -> Unit = {}) { + val settings = LocalPrivacyLockSettings.current + val lockState = LocalMessagesLockState.current + 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 Messages tab?", + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = "Require a password before Messages shows. Feed and profile 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/security/PasswordHasher.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/PasswordHasher.kt new file mode 100644 index 0000000000..3283027f3c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/PasswordHasher.kt @@ -0,0 +1,137 @@ +/* + * 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 java.security.SecureRandom +import java.util.Base64 +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec + +/** + * PBKDF2 password hashing for the Desktop privacy-lock password. + * + * Same primitive family as [com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage] + * uses for its master-password key derivation. + * + * ## Versioned hash format + * + * Stored form is `$$`: + * + * | Version | KDF | Iterations | Notes | + * |---|---|---|---| + * | (no prefix) | PBKDF2-HmacSHA256 | 100_000 | Legacy — verify-only for backward compat with hashes created before the OWASP 2023 bump. | + * | `v1` | PBKDF2-HmacSHA256 | 600_000 | Current — matches OWASP Password Storage Cheat Sheet (2023). | + * + * [hash] always produces `v1$…` format. [verify] handles both legacy + * bare-format and versioned inputs. Callers who want to opportunistically + * upgrade an old hash should re-call [hash] after a successful verify and + * persist the result. + * + * ## Cost tuning + * + * v1 at 600k iterations takes ~250 ms on a modern laptop (M1/M2 or + * Intel 12th gen). Users unlock a handful of times per session, so 250 ms + * is well within the tolerable range. At 20 attempts/second (verify + * throughput) an attacker with the on-disk hash needs ~150M attempts + * to exhaust a 6-char mixed-alpha space — orders of magnitude slower + * than the legacy 100k value. + */ +object PasswordHasher { + private const val ALGORITHM = "PBKDF2WithHmacSHA256" + private const val KEY_LENGTH_BITS = 256 + private const val SALT_LENGTH_BYTES = 16 + + private const val V1_PREFIX = "v1" + private const val V1_ITERATIONS = 600_000 + private const val LEGACY_ITERATIONS = 100_000 + + private const val SEP = '$' + + fun hash(password: CharArray): String { + val salt = ByteArray(SALT_LENGTH_BYTES).also { SecureRandom().nextBytes(it) } + val hash = pbkdf2(password, salt, V1_ITERATIONS) + val encoder = Base64.getEncoder() + return "$V1_PREFIX$SEP${encoder.encodeToString(salt)}$SEP${encoder.encodeToString(hash)}" + } + + fun verify( + password: CharArray, + stored: String, + ): Boolean { + val (iterations, saltB64, hashB64) = parse(stored) ?: return false + val decoder = Base64.getDecoder() + val salt = runCatching { decoder.decode(saltB64) }.getOrNull() ?: return false + val expected = runCatching { decoder.decode(hashB64) }.getOrNull() ?: return false + val computed = pbkdf2(password, salt, iterations) + return constantTimeEquals(computed, expected) + } + + /** + * True when [stored] is in the legacy pre-v1 format. Callers may opt to + * re-hash and persist after a successful [verify] to migrate the user + * to v1 transparently. + */ + fun isLegacyFormat(stored: String): Boolean { + val parts = stored.split(SEP) + return parts.size == 2 + } + + private data class ParsedHash( + val iterations: Int, + val saltB64: String, + val hashB64: String, + ) + + private fun parse(stored: String): ParsedHash? { + val parts = stored.split(SEP) + return when (parts.size) { + 2 -> ParsedHash(LEGACY_ITERATIONS, parts[0], parts[1]) + 3 -> { + if (parts[0] != V1_PREFIX) return null + ParsedHash(V1_ITERATIONS, parts[1], parts[2]) + } + else -> null + } + } + + private fun pbkdf2( + password: CharArray, + salt: ByteArray, + iterations: Int, + ): ByteArray { + val spec = PBEKeySpec(password, salt, iterations, KEY_LENGTH_BITS) + try { + return SecretKeyFactory.getInstance(ALGORITHM).generateSecret(spec).encoded + } finally { + spec.clearPassword() + } + } + + private fun constantTimeEquals( + a: ByteArray, + b: ByteArray, + ): Boolean { + if (a.size != b.size) return false + var diff = 0 + for (i in a.indices) diff = diff or (a[i].toInt() xor b[i].toInt()) + return diff == 0 + } +} 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 new file mode 100644 index 0000000000..80840cd6d6 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/SetPasswordDialog.kt @@ -0,0 +1,428 @@ +/* + * 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.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.foundation.layout.width +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +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.input.VisualTransformation +import androidx.compose.ui.unit.dp +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 kotlinx.coroutines.delay + +/** Enforced minimum length for a new/rotated password. */ +const val PRIVACY_LOCK_MIN_PASSWORD_LENGTH = 6 + +/** + * Set or change the privacy-lock password. + * + * Pass [existingHash] = null when the user hasn't set a password yet + * (first-run banner path, or a fresh Settings toggle). In that case the + * dialog shows a single New password field with a reveal toggle. Pass a + * real hash to force verification of the current password before letting + * the user rotate — the dialog then shows a Current password field + * followed by the New password field, each with its own reveal toggle. + * + * On successful validation, [onConfirm] is invoked with a fresh + * `salt$hash` string ready for [com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.setPasswordHashed]. + * + * UX: auto-focus the first field on open, Enter submits, Escape cancels. + * Real-time checklist under the New password field shows a green ✓ once + * the length threshold is reached. Reveal toggles are per-field + * independent so revealing Current does not reveal New. + */ +@Composable +fun SetPasswordDialog( + existingHash: String?, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + val isChange = existingHash != null + var current by remember { mutableStateOf("") } + var new by remember { mutableStateOf("") } + var currentError by remember { mutableStateOf(null) } + var newError by remember { mutableStateOf(null) } + + val firstFieldFocus = remember { FocusRequester() } + + val submit: () -> Unit = { + val currentOk = + !isChange || + (existingHash != null && PasswordHasher.verify(current.toCharArray(), existingHash)) + when { + !currentOk -> { + currentError = "Wrong password" + newError = null + } + new.length < PRIVACY_LOCK_MIN_PASSWORD_LENGTH -> { + currentError = null + newError = "Must be at least $PRIVACY_LOCK_MIN_PASSWORD_LENGTH characters" + } + else -> { + onConfirm(PasswordHasher.hash(new.toCharArray())) + } + } + } + + LaunchedEffect(Unit) { + firstFieldFocus.requestFocus() + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false), + ) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + modifier = Modifier.width(440.dp), + ) { + Column( + modifier = Modifier.padding(24.dp).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + DialogHeader(title = if (isChange) "Change password" else "Set a password") + + if (!isChange) { + Text( + text = "Choose a password to lock the Messages tab. You'll enter it to unlock later.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (isChange) { + PasswordField( + value = current, + onValueChange = { + current = it + currentError = null + }, + label = "Current password", + errorMessage = currentError, + modifier = Modifier.focusRequester(firstFieldFocus), + imeAction = ImeAction.Next, + onImeAction = { /* Tab handled by focus system */ }, + ) + } + + PasswordField( + value = new, + onValueChange = { + new = it + newError = null + }, + label = "New password", + errorMessage = newError, + modifier = + if (isChange) Modifier else Modifier.focusRequester(firstFieldFocus), + imeAction = ImeAction.Done, + onImeAction = { submit() }, + ) + + RequirementChecklistRow(satisfied = new.length >= PRIVACY_LOCK_MIN_PASSWORD_LENGTH) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { Text("Cancel") } + Button( + onClick = submit, + enabled = new.length >= PRIVACY_LOCK_MIN_PASSWORD_LENGTH, + ) { + Text("Save") + } + } + } + } + } +} + +@Composable +private fun DialogHeader(title: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + symbol = MaterialSymbols.Lock, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + ) + } +} + +/** + * Verify the user's current password before removing the privacy lock. + * + * On successful verification, [onConfirm] is invoked with no arguments. + * The caller is responsible for clearing `passwordHashed` and disabling + * `lockEnabled` — this dialog only proves possession of the current + * password. + * + * Deliberate design choice: users cannot disable the lock without + * demonstrating they know the password, matching the security posture + * of Signal PIN, WhatsApp Chat Lock, and macOS FileVault disable. + */ +@Composable +fun RemovePasswordDialog( + existingHash: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + val lockState = LocalMessagesLockState.current + val settings = LocalPrivacyLockSettings.current + val lockedUntil by settings.lockedUntilEpochMs.collectAsState() + + var current by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + var remainingMs by remember { mutableStateOf(lockoutRemainingMs(lockedUntil)) } + + val firstFieldFocus = remember { FocusRequester() } + + LaunchedEffect(lockedUntil) { + while (true) { + val r = lockoutRemainingMs(lockedUntil) + remainingMs = r + if (r <= 0) break + delay(500) + } + } + + val submit: () -> Unit = { + if (remainingMs <= 0) { + if (PasswordHasher.verify(current.toCharArray(), existingHash)) { + lockState.onUnlockSuccess() // clears failed-attempt state + onConfirm() + } else { + error = "Wrong password" + lockState.onFailedUnlockAttempt(System.currentTimeMillis()) + } + } + } + + LaunchedEffect(Unit) { + firstFieldFocus.requestFocus() + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false), + ) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + modifier = Modifier.width(440.dp), + ) { + Column( + modifier = Modifier.padding(24.dp).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + DialogHeader(title = "Remove password") + + Text( + text = + "Enter your current password to remove the lock. " + + "You'll need to set a new password if you turn the lock back on later.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + val effectiveError = + when { + remainingMs > 0 -> "Too many attempts. Try again in ${formatCountdownMs(remainingMs)}." + else -> error + } + PasswordField( + value = current, + onValueChange = { + current = it + error = null + }, + label = "Current password", + errorMessage = effectiveError, + modifier = Modifier.focusRequester(firstFieldFocus), + imeAction = ImeAction.Done, + onImeAction = { submit() }, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { Text("Cancel") } + Button( + onClick = submit, + enabled = current.isNotEmpty() && remainingMs <= 0, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text("Remove") + } + } + } + } + } +} + +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 formatCountdownMs(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" +} + +@Composable +private fun PasswordField( + value: String, + onValueChange: (String) -> Unit, + label: String, + errorMessage: String?, + modifier: Modifier = Modifier, + imeAction: ImeAction = ImeAction.Done, + onImeAction: () -> Unit = {}, +) { + var revealed by remember { mutableStateOf(false) } + + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + modifier = + modifier + .fillMaxWidth() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.key == Key.Enter && imeAction == ImeAction.Done) { + onImeAction() + true + } else { + false + } + }, + singleLine = true, + visualTransformation = if (revealed) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = imeAction, + ), + keyboardActions = KeyboardActions(onDone = { onImeAction() }, onNext = { onImeAction() }), + trailingIcon = { + IconButton(onClick = { revealed = !revealed }) { + Icon( + symbol = if (revealed) MaterialSymbols.VisibilityOff else MaterialSymbols.Visibility, + contentDescription = if (revealed) "Hide password" else "Show password", + ) + } + }, + isError = errorMessage != null, + supportingText = + errorMessage?.let { + { + Text(it, color = MaterialTheme.colorScheme.error) + } + }, + ) +} + +@Composable +private fun RequirementChecklistRow(satisfied: Boolean) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = if (satisfied) MaterialSymbols.CheckCircle else MaterialSymbols.Circle, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = + if (satisfied) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Text( + text = "Min $PRIVACY_LOCK_MIN_PASSWORD_LENGTH characters", + style = MaterialTheme.typography.bodySmall, + color = + if (satisfied) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index 768e7f1bff..8b89c582db 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -23,14 +23,18 @@ package com.vitorpamplona.amethyst.desktop.ui.chats import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -63,8 +67,10 @@ import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.security.MessagesFirstRunBanner import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import java.awt.Cursor private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") @@ -126,31 +132,47 @@ fun DesktopMessagesScreen( } } - if (compactMode) { - CompactMessagesContent( - selectedRoom = selectedRoom, - listState = listState, - account = account, - cacheProvider = cacheProvider, - scope = scope, - onNavigateToProfile = onNavigateToProfile, - listFocusRequester = listFocusRequester, - onShowNewDm = { showNewDmDialog = true }, - onShowRelayPicker = { showDmRelayPicker = true }, - keyHandler = keyHandler, - ) - } else { - SplitMessagesContent( - selectedRoom = selectedRoom, - listState = listState, - account = account, - cacheProvider = cacheProvider, - scope = scope, - onNavigateToProfile = onNavigateToProfile, - listFocusRequester = listFocusRequester, - onShowNewDm = { showNewDmDialog = true }, - onShowRelayPicker = { showDmRelayPicker = true }, - keyHandler = keyHandler, + val snackbarHostState = remember { SnackbarHostState() } + val snackbarScope = rememberCoroutineScope() + + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + MessagesFirstRunBanner(onSaved = { msg -> + snackbarScope.launch { snackbarHostState.showSnackbar(msg) } + }) + Box(modifier = Modifier.weight(1f)) { + if (compactMode) { + CompactMessagesContent( + selectedRoom = selectedRoom, + listState = listState, + account = account, + cacheProvider = cacheProvider, + scope = scope, + onNavigateToProfile = onNavigateToProfile, + listFocusRequester = listFocusRequester, + onShowNewDm = { showNewDmDialog = true }, + onShowRelayPicker = { showDmRelayPicker = true }, + keyHandler = keyHandler, + ) + } else { + SplitMessagesContent( + selectedRoom = selectedRoom, + listState = listState, + account = account, + cacheProvider = cacheProvider, + scope = scope, + onNavigateToProfile = onNavigateToProfile, + listFocusRequester = listFocusRequester, + onShowNewDm = { showNewDmDialog = true }, + onShowRelayPicker = { showDmRelayPicker = true }, + keyHandler = keyHandler, + ) + } + } + } + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp), ) } 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 ee85ce8122..6f4affcabc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -361,14 +361,16 @@ internal fun RootContent( } DeckColumnType.Messages -> { - DesktopMessagesScreen( - account = iAccount, - cacheProvider = localCache, - relayManager = relayManager, - localCache = localCache, - compactMode = compactMode, - onNavigateToProfile = onNavigateToProfile, - ) + com.vitorpamplona.amethyst.desktop.security.DesktopMessagesLockGate { + DesktopMessagesScreen( + account = iAccount, + cacheProvider = localCache, + relayManager = relayManager, + localCache = localCache, + compactMode = compactMode, + onNavigateToProfile = onNavigateToProfile, + ) + } } DeckColumnType.Search -> { 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 new file mode 100644 index 0000000000..113c90debb --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt @@ -0,0 +1,326 @@ +/* + * 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.ui.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +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.rememberCoroutineScope +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.privacylock.DmRedactionLevel +import com.vitorpamplona.amethyst.commons.privacylock.InactivityTimer +import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings +import com.vitorpamplona.amethyst.desktop.security.LocalPrivacyLockSettings +import com.vitorpamplona.amethyst.desktop.security.RemovePasswordDialog +import com.vitorpamplona.amethyst.desktop.security.SetPasswordDialog +import kotlinx.coroutines.launch + +/** + * Desktop privacy-lock settings pane. Column + Card layout (no Scaffold) — + * matches `LocalRelaySettingsScreen`. + */ +@Composable +fun PrivacyLockSettingsScreen() { + val settings = LocalPrivacyLockSettings.current + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + Box(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + LockToggleCard( + settings = settings, + onSaved = { message -> + scope.launch { snackbarHostState.showSnackbar(message) } + }, + ) + InactivityCard(settings) + RedactionCard(settings) + LimitationsCard() + } + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp), + ) + } +} + +@Composable +private fun LockToggleCard( + settings: PrivacyLockSettings, + onSaved: (String) -> Unit, +) { + val enabled by settings.lockEnabled.collectAsState() + val stored by settings.passwordHashed.collectAsState() + var showSetPassword by remember { mutableStateOf(false) } + var showRemovePassword by remember { mutableStateOf(false) } + var pendingEnable by remember { mutableStateOf(false) } + + SettingsCard(title = "Lock the Messages tab") { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = + "Require a password before the Messages column shows. " + + "The rest of the app stays open.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Switch( + checked = enabled, + onCheckedChange = { on -> + if (on) { + if (stored == null) { + pendingEnable = true + showSetPassword = true + } else { + settings.setLockEnabled(true) + } + } else { + // Disabling requires the current password. If none is set + // (corner case — user cleared prefs manually), just + // disable directly. + if (stored != null) { + showRemovePassword = true + } else { + settings.setLockEnabled(false) + } + } + }, + ) + } + if (enabled && stored != null) { + Row { + OutlinedButton(onClick = { showSetPassword = true }) { + Text("Change password") + } + } + } + } + + if (showSetPassword) { + val wasFirstSet = stored == null + SetPasswordDialog( + existingHash = stored, + onDismiss = { + showSetPassword = false + pendingEnable = false + }, + onConfirm = { newHash -> + settings.setPasswordHashed(newHash) + if (pendingEnable) settings.setLockEnabled(true) + showSetPassword = false + pendingEnable = false + onSaved(if (wasFirstSet) "Privacy lock enabled" else "Password updated") + }, + ) + } + + stored?.let { hash -> + if (showRemovePassword) { + RemovePasswordDialog( + existingHash = hash, + onDismiss = { showRemovePassword = false }, + onConfirm = { + settings.setLockEnabled(false) + settings.setPasswordHashed(null) + showRemovePassword = false + onSaved("Privacy lock removed") + }, + ) + } + } +} + +@Composable +private fun InactivityCard(settings: PrivacyLockSettings) { + val enabled by settings.lockEnabled.collectAsState() + val timer by settings.inactivityTimer.collectAsState() + if (!enabled) return + + SettingsCard(title = "Auto-lock after") { + var expanded by remember { mutableStateOf(false) } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Re-lock Messages after this much inactivity.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Button(onClick = { expanded = true }) { + Text(timer.label()) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + InactivityTimer.entries.forEach { entry -> + DropdownMenuItem( + text = { Text(entry.label()) }, + onClick = { + settings.setInactivityTimer(entry) + expanded = false + }, + ) + } + } + } + } +} + +@Composable +private fun RedactionCard(settings: PrivacyLockSettings) { + val enabled by settings.lockEnabled.collectAsState() + val level by settings.redactionLevel.collectAsState() + if (!enabled) return + + SettingsCard(title = "DM notification preview") { + var expanded by remember { mutableStateOf(false) } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = + "When lock is on, DM notifications hide sender + message. " + + "Change to Full to show them.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Button(onClick = { expanded = true }) { + Text(level.label()) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DmRedactionLevel.entries.forEach { entry -> + DropdownMenuItem( + text = { Text(entry.label()) }, + onClick = { + settings.setRedactionLevel(entry) + expanded = false + }, + ) + } + } + } + } +} + +@Composable +private fun LimitationsCard() { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "What this lock does not protect against", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = + "This lock hides the Messages column on an unattended device. " + + "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.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun SettingsCard( + title: String, + content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + content(this) + } + } +} + +private fun InactivityTimer.label(): String = + when (this) { + InactivityTimer.OneMin -> "1 min" + InactivityTimer.FiveMin -> "5 min" + InactivityTimer.FifteenMin -> "15 min" + InactivityTimer.OneHour -> "1 hour" + InactivityTimer.Never -> "Never" + } + +private fun DmRedactionLevel.label(): String = + when (this) { + DmRedactionLevel.Generic -> "Hidden" + DmRedactionLevel.Full -> "Full" + } diff --git a/docs/plans/2026-06-30-feat-messaging-privacy-lock-plan.md b/docs/plans/2026-06-30-feat-messaging-privacy-lock-plan.md new file mode 100644 index 0000000000..49583f0dee --- /dev/null +++ b/docs/plans/2026-06-30-feat-messaging-privacy-lock-plan.md @@ -0,0 +1,951 @@ +--- +title: Messaging Privacy Lock +type: feat +status: active +date: 2026-06-30 +origin: docs/brainstorms/2026-06-30-feat-messaging-privacy-lock-brainstorm.md +--- + +# Messaging Privacy Lock + +Route-scoped lock on the Messages destination + companion defenses +(notification redaction, screen-capture block, blur-on-unfocus) that prevent +an attentive snooper from reading DMs on an unattended-but-unlocked device. +Industry baseline = Signal screen-lock + WhatsApp Chat Lock, collapsed to +*Messages route only*, OS-credential-only (no Amethyst PIN), device-global, +off-by-default with a one-time prompt. + +> All decisions traced back to the brainstorm +> (see brainstorm: `docs/brainstorms/2026-06-30-feat-messaging-privacy-lock-brainstorm.md`). + +## Enhancement Summary + +**Deepened on:** 2026-06-30 +**Reviewers:** security-sentinel, architecture-strategist, +code-simplicity-reviewer, performance-oracle, spec-flow-analyzer, +pattern-recognition-specialist + +### Key changes incorporated + +1. **Security hardening** — six previously-missed content-leak paths + added to scope: crash-report scrubbing, DM-content log scrub, + MessagingStyle history flush on first enable, NotificationListener + payload redaction (main builder, not just `setPublicVersion`), + bunker decrypt queue drained on re-lock, deep-link race fixed by + synchronous initial-state read. +2. **Architecture corrections** — `CompositionLocal` instead of + `commons/jvmMain → desktopApp` import (broke dependency arrow); + `PrivacyLockPreferences` moved to `commons/jvmAndroid` source set + (was breaking iOS); native binaries moved to canonical + `appResources/{macos,linux,windows}/`; `SecureScreenEffect` + relegated to Android-only (no JVM equivalent). +3. **Performance** — gate uses + `state.map { it is Locked }.distinctUntilChanged()` to keep chat + subtree off the recomposition path; `SharingStarted.Eagerly`; + Activity-level FLAG_SECURE so cold-resume thumbnail is blanked + from frame 0; blur radius 16dp on overlay layer (not LazyColumn). +4. **Scope cuts** — **Windows Hello native helper deferred** (v1 uses + `CredUIPromptForCredentials` = OS password, biometric in v2); + **Linux entirely deferred** (lock toggle shows "Not available on + Linux yet" banner in v1); 2-level notification (Generic / Full), + no chooser dialog; first-run card buttons collapsed to Enable / Not + now (no "Don't ask" persistence). +5. **Edge cases formalized as ACs** — empty-Messages first-run card, + account-switch = leave-route, BiometricPrompt-up + background + cancellation, draft persistence via overlay (not unmount), media + playback does NOT reset idle timer, in-app DM banner suppressed on + Messages route while locked. +6. **Naming aligned** — `PreferencesPrivacyLockSettings` (mirrors + `PreferencesHashtagSpamSettings`); JNA under + `desktopApp/.../service/security/` (mirrors `service/media/`). + +## Overview + +Five user-visible deliverables, gated behind a single device-global toggle: + +1. **Messages-route gate** — biometric / OS-credential prompt before any + DM UI renders. Re-locks on inactivity (default 5 min, configurable + Immediate–Never) and on leaving the route. +2. **Notification preview redaction** — 3-level (Full / Sender-only / + Generic). Auto-bumps to *Generic* on first lock-enable with a chooser. +3. **Screen-capture block** — Android `FLAG_SECURE`, macOS + `NSWindow.sharingType = .none`, Windows + `SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE)`. Linux: not + available; documented limitation. +4. **Desktop blur-on-unfocus** — Messages content renders blurred while + the window is unfocused; no PIN re-entry on focus regain. +5. **First-run inline card** — top-of-Messages prompt offering Enable / + Not now / Don't ask. + +## Problem Statement + +Amethyst currently has zero protection against snooping on an unattended +device. Anyone who walks past a logged-in install can read DM history, +including NIP-04 + NIP-17 private chats + Marmot group chats. The +existing `SecurityFiltersScreen` is a misnomer — it gates spam/content +filters, not privacy. Notification banners on Android show the sender's +name in the title even when the message body is generic. Recents +thumbnails and screen-sharing tools capture chat content unrestricted. + +The intended user is a person who: +- Leaves their device unlocked for short periods (coffee shop, family + living room, shared workstation). +- Wants reasonable, inconvenient-but-tolerable friction guarding the + Messages tab specifically. +- Does *not* expect protection against a fully hostile device-possessor + (rooted Android, attached debugger, filesystem-level access). + +## Proposed Solution + +A device-global toggle (`PrivacyLockPreferences.lockEnabled`) drives a +`MessagesLockState` StateFlow consumed by a shared `MessagesLockGate` +composable. The gate wraps every Messages-route entry composable on both +Android and Desktop. When state is Locked, the gate intercepts and shows +a lock screen with a single "Unlock" affordance. Tapping the affordance +invokes the platform-specific `CredentialPrompter`, which routes to: + +| Platform | Primary | Fallback | +|---|---|---| +| Android | `BiometricPrompt(STRONG \| DEVICE_CREDENTIAL)` | Device PIN/Pattern (built into the prompt) | +| macOS | `LAContext.evaluatePolicy(.deviceOwnerAuthentication)` via ~80-line Swift `.dylib` shim + JNA | OS password (built into LAPolicy) | +| Windows | `CredUIPromptForCredentials` via JNA (User32) → OS password only in v1 | Same | +| Linux | **Lock disabled in v1** — settings shows "Not available on Linux yet" banner | — | + +**Why Windows Hello + Linux were deferred** (per deepen-plan +simplicity review): no maintained JVM WinRT projection (would require +a native `.exe` helper, code-signing, MSI bundling — heavy v1 cost +for biometric vs. OS password); polkit Linux flow needs root-installed +policy that breaks Flatpak distribution. Both tracked under Future +Considerations. + +On success the gate transitions to Unlocked; an idle timer drives the +re-lock; navigation away from the route flips state to Locked +immediately. Companion defenses (FLAG_SECURE, sharing block, +blur-on-unfocus, notification redaction) are all conditioned on +`lockEnabled` and applied automatically — no extra UI knobs in v1. + +## Technical Approach + +### Architecture + +``` + ┌─────────────────────────────────┐ + │ PrivacyLockPreferences │ java.util.prefs (jvm) + │ ─ lockEnabled: Boolean │ + SharedPreferences (Android) + │ ─ inactivityMillis: Long │ + │ ─ notificationLevel: … │ + │ ─ firstRunPromptDismissed │ + │ ─ disabledByMissingCredential │ + └────────────┬────────────────────┘ + │ + ┌────────────▼────────────────────┐ + │ MessagesLockState │ StateFlow + │ ─ Disabled / Locked / Unlocked │ + │ ─ onUserInteraction() │ + │ ─ onLeaveRoute() │ + │ ─ onUnlockSuccess() │ + │ ─ onCredentialUnavailable() │ + └────────────┬────────────────────┘ + │ + ┌──────────────────┴──────────────────┐ + │ │ + ┌────────▼─────────┐ ┌──────────▼────────┐ + │ Android UI │ │ Desktop UI │ + │ (amethyst/) │ │ (desktopApp/) │ + ├───────────────────┤ ├────────────────────┤ + │ MessagesLockGate │ │ MessagesLockGate │ + │ (commons/common) │ │ (commons/common) │ + │ ↓ │ │ ↓ │ + │ AndroidCredential│ │ Mac / Win / Linux │ + │ Prompter │ │ CredentialPrompter│ + │ (expect/actual) │ │ (expect/actual) │ + │ │ │ │ + │ FLAG_SECURE │ │ NSWindowSharingNone│ + │ on chat screens │ │ / WDA_EXCLUDE… │ + │ │ │ │ + │ Notification │ │ WindowFocus → blur│ + │ redaction │ │ │ + └───────────────────┘ └────────────────────┘ +``` + +Single state holder, two UIs subscribed. Mirrors the +`LocalRelayStore` / `LocalRelayMaintenance` pattern already established in +the embedded-relay work +(see `desktopApp/plans/2026-05-09-embedded-local-relay-plan.md`). + +### Module map + +| Concern | Module / Source set | Path | +|---|---|---| +| `PreferencesPrivacyLockSettings` (mirrors `PreferencesHashtagSpamSettings`; node `com/vitorpamplona/amethyst/privacylock`) | `commons/jvmAndroid` (shared by Android + Desktop, NOT iOS — per `commons/ARCHITECTURE.md` §3) | `commons/.../privacylock/PreferencesPrivacyLockSettings.kt` | +| `MessagesLockState` (StateFlow + idle timer) | `commons/commonMain` | `commons/.../privacylock/MessagesLockState.kt` | +| `DmRedactionLevel` enum (`Generic` / `Full`) + redaction policy (CLI-safe data) | `commons/commonMain` | `commons/.../privacylock/DmRedactionPolicy.kt` | +| `MessagesLockGate` composable + lock-screen UI | `commons/commonMain` | `commons/.../ui/privacylock/MessagesLockGate.kt` | +| `interface CredentialPrompter` + `LocalCredentialPrompter` CompositionLocal (no expect/actual — supplied per platform at App root) | `commons/commonMain` | `commons/.../ui/privacylock/CredentialPrompter.kt` | +| Android `BiometricCredentialPrompter` | `amethyst/.../security/` | `BiometricCredentialPrompter.kt` | +| macOS Touch ID Swift shim source | `desktopApp/src/jvmMain/native/macos/` | `TouchIDShim.swift` | +| macOS Touch ID compiled binary | `desktopApp/src/jvmMain/appResources/macos/` (Compose canonical native-resource root, picked up automatically by `nativeDistributions`) | `libAmethystTouchID.dylib` | +| Windows OS password prompter (v1 — no Hello) | `desktopApp/.../service/security/` | `WindowsPasswordPrompter.kt` (JNA → `User32.CredUIPromptForCredentials`) | +| macOS Touch ID prompter (Kotlin side) | `desktopApp/.../service/security/` | `MacTouchIdPrompter.kt` (JNA → `libAmethystTouchID.dylib`) | +| Desktop `CredentialPrompter` provider (selects Mac/Win/no-op) | `desktopApp/.../service/security/` | `DesktopCredentialPrompterFactory.kt` (lives in `desktopApp`, NOT `commons/jvmMain` — `PlatformInfo` ownership stays where it is) | +| `FrameWindowScope.applyWindowCaptureBlock(enabled)` | `desktopApp/.../platform/` | `WindowCaptureBlock.kt` (alongside `applyNativeWindowChrome` in `PlatformTheme.kt`) | +| Desktop window-focus StateFlow + `LocalWindowFocus` | `desktopApp/.../platform/` | `WindowFocusOwner.kt` | +| `LocalMessagesLockState` CompositionLocal (provided once at App root on both platforms) | `commons/commonMain` | `commons/.../privacylock/MessagesLockState.kt` (companion) | +| Android settings UI + first-run card | `amethyst/.../settings/` + `amethyst/.../chats/` | `PrivacyLockSettingsScreen.kt`, `MessagesFirstRunCard.kt` | +| Desktop settings UI (Column + Card pattern from `LocalRelaySettingsScreen`, NO Scaffold) + first-run card | `desktopApp/.../settings/` + `desktopApp/.../chats/` | `PrivacyLockSettingsScreen.kt`, `MessagesFirstRunCard.kt` | +| Notification redaction call site (Android-only — `NotificationUtils.kt` doesn't exist on Desktop) | `amethyst/.../service/notifications/` | edit `NotificationUtils.kt:370-510` to consume `DmRedactionPolicy` from commons | +| Idle-timer modifier (`Modifier.resetIdleOnInteraction()` — single root attach, not per-composable) | `commons/commonMain` | `commons/.../ui/privacylock/IdleTimerModifier.kt` | +| FLAG_SECURE wrapper (**Android-only** — no expect/actual; desktop uses window-level `WindowCaptureBlock`) | `amethyst/.../security/` | `SecureScreenEffect.kt` | +| Crash-report DM scrubber (NEW — security hardening) | `amethyst/.../service/crashreports/` | edit `ReportAssembler.kt` to strip `Throwable.message` when origin package is `nip04Dm` / `nip17Dm` / `marmot` | +| DM-content log audit (NEW — security hardening) | `amethyst/.../service/notifications/` + `commons/.../bunker/` | grep + edit: `EventNotificationConsumer.kt:349,392,435,491,549,695`, `RemoteSignerManager.kt:53` — strip body fields from `Log.d` | + +`commons/ARCHITECTURE.md` governs package taxonomy — new package +`commons/.../privacylock/` for state + prefs + policy, +`commons/.../ui/privacylock/` for Compose surfaces. + +**Pattern note (new):** `LocalMessagesLockState` + `LocalCredentialPrompter` +introduce app-global CompositionLocals provided once at the App root. +Mirrors the existing precedent `LocalRelayStore`-style provision in +`desktopApp/Main.kt` (single instantiation site, app-global, not +per-Window — required so multi-window desktop in the future doesn't +bypass the lock). + +### Implementation Phases + +#### Phase 1: Foundation (state + preferences + tests) + +Lay the cross-platform spine before touching either UI. Output: a +testable, headless state machine with unit-test coverage. + +**Critical requirement (per deepen perf finding):** initial `LockState` +value MUST be derived synchronously from `prefs.lockEnabled` BEFORE +the first composition runs. `setContent {}` must see `Locked` as the +seed value the first time it composes — never `Unlocked` followed by +an async update. This is the foundation of the deep-link race fix +(see Security Hardening below). + +- New files: + - `commons/src/commonMain/.../privacylock/MessagesLockState.kt` + - `sealed class LockState { Disabled; Locked; Unlocked }` + - `class MessagesLockState(prefs, clock, scope)` + - `val state: StateFlow` — backed by + `MutableStateFlow(seedFromPrefsSynchronously())` + - Uses `SharingStarted.Eagerly` (NOT `WhileSubscribed` — needed + because the notification path reads `lockEnabled` outside any + UI collector, per deepen perf finding #4) + - `fun onUserInteraction()` — idempotent + - `fun onLeaveRoute()` — idempotent; transitions Unlocked→Locked + - `fun onUnlockSuccess()` — transitions Locked→Unlocked, restarts idle timer + - `fun onCredentialUnavailable()` — single-arg (no reason variants + per simplicity review); transitions to Disabled, persists flag, + no-op if already Disabled + - `companion object { val LocalMessagesLockState: ProvidableCompositionLocal }` + - `commons/src/commonMain/.../privacylock/InactivityTimer.kt` + - `enum class InactivityTimer(val millis: Long?)`: + `OneMin`, `FiveMin`, `FifteenMin`, `OneHour`, `Never`. + Default `FiveMin`. **`Immediate` dropped** (per simplicity review + — redundant with leave-route trigger.) + - `commons/src/commonMain/.../privacylock/DmRedactionPolicy.kt` + - `enum class DmRedactionLevel { Generic; Full }` (2 levels, not 3 + — per simplicity review; "Sender only" was niche middle ground) + - `fun resolveLevel(lockEnabled: Boolean, userChoice: DmRedactionLevel?): DmRedactionLevel` + — when `lockEnabled` and user hasn't explicitly chosen, returns + `Generic`. When `!lockEnabled`, returns `Full`. + - `commons/src/jvmAndroid/.../privacylock/PreferencesPrivacyLockSettings.kt` + - Mirrors `PreferencesHashtagSpamSettings` shape (class taking + `prefs`, exposes `StateFlow` mutators). + - Backing storage: java.util.prefs on Desktop / + SharedPreferences on Android (each platform sets up its own + Preferences instance and hands it in — `jvmAndroid` source set + doesn't pick the storage backend). + - Keys: `lockEnabled`, `inactivityTimerOrdinal`, + `redactionLevelOrdinal`, `firstRunCardSeen`. **Removed:** + `disabledByMissingCredential` (per simplicity review — re-check + via `BiometricManager.canAuthenticate()` on settings render). + - `commons/src/commonTest/.../privacylock/MessagesLockStateTest.kt` + - 5 unit tests (down from 12 per simplicity review — start small): + 1. Idle expiry fires lock + 2. Leave-route locks immediately + 3. Cold-start with `lockEnabled=true` seeds to Locked + 4. Settings cascade — toggle off transitions Locked→Disabled + 5. Never timer doesn't fire + +- Acceptance: + - [ ] `./gradlew :commons:jvmTest --tests "*MessagesLockStateTest*"` green + - [ ] State machine is idempotent on duplicate triggers + - [ ] No new deps required; uses kotlinx.coroutines.flow only + - [ ] Initial value seeded synchronously (no flash on cold start) + +#### Phase 2: Android lock + UI + notification redaction + leak audit + +Wire the gate, settings, and notification redaction on Android. This +phase incorporates **the security-hardening leak fixes** surfaced +during deepen review (crash, logs, MessagingStyle history, +NotificationListener payload). + +- New files: + - `commons/.../ui/privacylock/MessagesLockGate.kt` (commonMain) — + gate hosts the lock overlay ONLY; content lambda passed in + unchanged. State read uses + `state.map { it is Locked }.distinctUntilChanged().collectAsStateWithLifecycle(initialValue = state.value is Locked)` + so the chat subtree never recomposes on idle-tick non-flips. + Gate selects branch SYNCHRONOUSLY in composition + (`when { locked -> LockOverlay(); else -> content() }`) — no + `LaunchedEffect` guard (per security finding #1). + - `commons/.../ui/privacylock/CredentialPrompter.kt` (commonMain) — + `interface CredentialPrompter { suspend fun prompt(reason: String): PromptResult }`, + `LocalCredentialPrompter` CompositionLocal. + - `amethyst/.../security/BiometricCredentialPrompter.kt` — wraps + `BiometricPrompt(STRONG | DEVICE_CREDENTIAL)`. Reuse pattern from + `UpdateZapAmountDialog.kt:428-479`. Provided into + `LocalCredentialPrompter` at the `AmethystApp` composition root. + Error mapping (single-reason variant per simplicity review): + - `ERROR_USER_CANCELED` → stay locked, no toast + - `ERROR_LOCKOUT` → "Try again in 30 sec" toast, stay locked + - `ERROR_LOCKOUT_PERMANENT` / `ERROR_NO_HARDWARE` / + `ERROR_NONE_ENROLLED` → `state.onCredentialUnavailable()` + - `amethyst/.../security/SecureScreenEffect.kt` (Android-only — no + expect/actual per architecture review). Toggles + `WindowManager.LayoutParams.FLAG_SECURE`. + - `commons/.../ui/privacylock/IdleTimerModifier.kt` (commonMain) — + single `Modifier.resetIdleOnInteraction(state)` using + `Modifier.pointerInput(Unit) { awaitPointerEventScope { while(true){ awaitPointerEvent(PointerEventPass.Initial); state.onUserInteraction() } } }`. + Initial pass = observe without consuming. Per-keypress TextField + reset wired via a separate `onValueChange` adapter at the compose + sites that have text fields. + +- Edited files: + - **`amethyst/.../MainActivity.kt`** — set + `Window.addFlags(FLAG_SECURE)` early in `onCreate` (window-level, + not per-screen DisposableEffect) when `prefs.lockEnabled == true`. + This closes the recents cold-reattach thumbnail leak window + (security finding #6 + perf finding #6). Per-screen + `SecureScreenEffect` stays as belt-and-suspenders for when + chat is visible but lock toggle was just flipped on. + - `amethyst/.../ui/screen/loggedIn/chats/rooms/MessagesScreen.kt` + — wrap top-level composable in `MessagesLockGate`; attach + `Modifier.resetIdleOnInteraction(state)` once at the route root + (per architecture review's "single root attach" recommendation). + - `amethyst/.../ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt` + — wrap content in `SecureScreenEffect(enabled = lockEnabled)`; + treated as inside-gate so deep-link from notification still + transits the parent-route gate. + - `amethyst/.../ui/screen/loggedIn/chats/marmotGroup/MarmotGroupScreen.kt` + — same. Verify gate wraps at route level (not deep inside the + composable tree), per architecture review. + - `amethyst/.../service/notifications/NotificationUtils.kt` — + consume `DmRedactionPolicy` from commons. **Redact the MAIN + builder, not just `setPublicVersion`** (security finding #5 — + NotificationListener apps see the main `Notification.extras`): + - When `level == Generic`: `setContentTitle("Amethyst")`, + `setContentText("New message")`, do NOT add + `MessagingStyle.addMessage(body, …)` (which persists across + updates and leaks pre-enable history). + - Disable inline-reply `RemoteInput` when level=Generic (would + otherwise let the system quote the original message — security + finding #13). + - On first lock-enable, call + `NotificationManagerCompat.cancel(DM_GROUP_KEY)` to flush + pre-enable MessagingStyle history (security finding #4). + - Bypass `Log.d("$content $title …")` style logging in DM paths + (security finding #3) — survey lines 349, 392, 435, 491, 549, + 695 in `EventNotificationConsumer.kt` and strip body fields. + - **`amethyst/.../service/crashreports/ReportAssembler.kt`** — NEW + security hardening (finding #2). Strip `Throwable.message` to + class-name-only when origin package is `nip04Dm`, `nip17Dm`, or + `marmot`. Always-on (not gated on `lockEnabled` — DM plaintext in + crash reports is never a feature). + - **`commons/.../bunker/RemoteSignerManager.kt`** — on + `MessagesLockState` transition Unlocked→Locked, drain the bunker + decrypt response queue (security finding #7). Cancel in-flight + `Channel` subscriptions for DM decrypt requests. +- New settings: + - `amethyst/.../ui/screen/loggedIn/settings/PrivacyLockSettingsScreen.kt` + - Toggle: Lock Messages (calls `BiometricManager.canAuthenticate()` + on render — if unavailable, toggle disabled + banner) + - Spinner: Lock after `[1 min / 5 min / 15 min / 1 hour / Never]` + (5 values, default 5 min — per simplicity review, `Immediate` + dropped as redundant with leave-route) + - Toggle: Hide notification preview (2-level: On = Generic, Off = + Full — chooser dialog dropped per simplicity review) + - Inline hint: "Notifications hide content while lock is on." + - Banner (when canAuthenticate returns NO_HARDWARE / NONE_ENROLLED): + "Lock disabled — no biometric or device credential available." + - Copy: "Lock applies to Messages only. Other parts of the app + stay open. Does NOT protect against rooted devices, debuggers, + filesystem access, apps with notification-listener permission, + or screen-recording apps you've granted access — your nsec is + still stored as it is today." (Honest threat-model copy per + security finding #15.) + - Add link from existing `SecurityFiltersScreen.kt` to + `PrivacyLockSettingsScreen`. Keep `SecurityFiltersScreen` name to + minimize churn; add a "Privacy" sub-link clearly labeled. +- First-run card: + - `amethyst/.../ui/screen/loggedIn/chats/rooms/MessagesFirstRunCard.kt` + — inline `Card` at top of `MessagesScreen`, structurally modeled + on `OfflineBanner.kt` from the embedded-relay work (per pattern + review). Two buttons (per simplicity review): **Enable** / + **Not now**. Auto-suppress after `firstRunCardSeen` is set OR after + `lockEnabled` flips to true. (No "Don't ask" plumbing.) Renders + independently of DM list state — visible even on empty Messages + (per spec-flow gap #1). + +- Acceptance: + - [ ] `./gradlew :amethyst:assembleDebug` succeeds + - [ ] Manual: gate appears, biometric unlocks, idle timer locks, + navigation away locks immediately + - [ ] Recents-screen thumbnail blank when in Messages (verify on + BOTH cold cold-start and warm resume — security finding #6) + - [ ] Notification preview hidden by default after enable + - [ ] **First-enable flushes MessagingStyle history** (security AC) + - [ ] **NotificationListenerService apps see redacted main builder** + when level=Generic (verify with a side-loaded NL app) + - [ ] **Crash report from an injected DM-decode error contains + no plaintext** (security AC) + - [ ] **`logcat | grep -i 'dm\|chat'` produces no plaintext** while + DMs flow (security AC) + - [ ] First-run card displays on empty Messages tab + - [ ] **Deep-link from notification: chat content NEVER paints + before gate** (security AC — write Compose-test screenshot + diff on the transition frame) + +#### Phase 3: Desktop lock + UI + macOS Touch ID + Windows OS password + +**Scope cut per deepen simplicity review**: Linux is fully deferred +to v2 (toggle disabled with "Not available on Linux yet" banner); +Windows ships with OS password only in v1 (Windows Hello deferred — +no JVM-native WinRT projection). macOS gets Touch ID via the Swift +shim — small enough to justify (~80 LOC). + +- New files: + - `desktopApp/.../service/security/DesktopCredentialPrompterFactory.kt` + — selects `MacTouchIdPrompter` on macOS, + `WindowsPasswordPrompter` on Windows, returns a no-op disabled + prompter on Linux. Provided into `LocalCredentialPrompter` at the + `App()` root in `Main.kt`. + - `desktopApp/.../service/security/MacTouchIdPrompter.kt` — + JNA into `libAmethystTouchID.dylib.amethyst_touchid_authenticate`. + - `desktopApp/.../service/security/WindowsPasswordPrompter.kt` — + JNA into `User32.CredUIPromptForCredentials`. **No Windows Hello + in v1** — drops native helper + signing + MSI bundling cost. + Documented in copy: "Windows uses your account password until we + add Windows Hello support." + - `desktopApp/src/jvmMain/native/macos/TouchIDShim.swift` — source, + ~80 lines: + ```swift + import LocalAuthentication + + @_cdecl("amethyst_touchid_authenticate") + public func authenticate(reason: UnsafePointer) -> Int32 { + let ctx = LAContext() + var err: NSError? + guard ctx.canEvaluatePolicy(.deviceOwnerAuthentication, error: &err) + else { return -1 } + let sem = DispatchSemaphore(value: 0) + var ok = false + ctx.evaluatePolicy(.deviceOwnerAuthentication, + localizedReason: String(cString: reason)) { success, _ in + ok = success + sem.signal() + } + sem.wait() + return ok ? 0 : 1 + } + ``` + Hardcode `reason` string on the Kotlin side (security finding #14 + — no user-controlled strings to the OS prompt). Compiled with + `swiftc -emit-library -o libAmethystTouchID.dylib` on macOS hosts. + - `desktopApp/src/jvmMain/appResources/macos/libAmethystTouchID.dylib` + — committed pre-built artifact. Compose Desktop's + `appResourcesRootDir` is the canonical location (already used by + VLC bundles per `desktopApp/build.gradle.kts:107,173-175`), so + `nativeDistributions` picks it up automatically. **No new Gradle + tasks required** (per architecture review #8). Recompile recipe + documented separately at + `desktopApp/plans/2026-06-30-privacy-lock-native-shims-recipe.md`. + - `desktopApp/.../platform/WindowFocusOwner.kt` — + `LocalWindowFocus: ProvidableCompositionLocal>`. + Sourced from `window.addWindowFocusListener` events emitted into + a `MutableStateFlow`. + - `desktopApp/.../platform/WindowCaptureBlock.kt` — + `fun FrameWindowScope.applyWindowCaptureBlock(enabled: StateFlow)`: + - macOS: JNA into `NSWindow.setSharingType(0)` — extract JNA + pattern from existing `applyNativeWindowChrome()` in + `PlatformTheme.kt:87`. Apply via + `LaunchedEffect(enabled) { ... }` so JNA call fires only on flip, + not per recomposition (perf finding #5). Use `DisposableEffect` + to restore `sharingType = normal` on disable. + - Windows: JNA into + `User32.SetWindowDisplayAffinity(hwnd, WDA_EXCLUDEFROMCAPTURE)`. + Same effect plumbing. + - Linux: no-op. +- Edited files: + - `desktopApp/.../Main.kt` — inside `Window { ... }` block (line 316), + invoke `applyWindowCaptureBlock(lockEnabledFlow)` next to existing + `applyNativeWindowChrome()`. Construct the app-global + `MessagesLockState` ONCE here and provide both + `LocalMessagesLockState` and `LocalCredentialPrompter` at the App + root. Multi-window correctness (architecture review #5). + - `desktopApp/.../ui/chats/DesktopMessagesScreen.kt`, + `ChatPane.kt`, `ConversationListPane.kt` — wrap in + `MessagesLockGate`. Apply `Modifier.resetIdleOnInteraction(state)` + at the route root only. + - `desktopApp/.../ui/chats/ChatPane.kt` — apply + `Modifier.blur(16.dp, BlurredEdgeTreatment.Unbounded)` on an + OVERLAY layer (not on the LazyColumn), driven by + `LocalWindowFocus`. Cap at 16dp (perf finding #2). On macOS 15+ + and Linux, default blur radius even when focused (because the + capture block is broken/absent — partial compensation per + security findings #9 and #10). + - `desktopApp/build.gradle.kts` — only addition is the macOS + `infoPlist` block to add `NSFaceIDUsageDescription`. Native + binaries auto-bundled via `appResources/macos/`. + - `desktopApp/.../ui/settings/PrivacyLockSettingsScreen.kt` — + follows `LocalRelaySettingsScreen` shape (Column + Card, NO + Scaffold — pattern review). Same toggles/spinners as Android. + On Linux, the whole settings group is replaced by a banner: "Not + available on Linux yet." On macOS 15+, an info row: "Screen + capture protection is limited on macOS 15+." On Windows, info row: + "Windows uses your account password — Hello support coming later." + +- Acceptance: + - [ ] `./gradlew :desktopApp:run` on macOS: Touch ID prompt appears, + success unlocks, cancel keeps locked + - [ ] `./gradlew :desktopApp:run` on Windows: OS-password prompt + appears (CredUI dialog), success unlocks + - [ ] `./gradlew :desktopApp:run` on Linux: lock toggle in settings + is disabled with banner; Messages route shows no gate + - [ ] DMG / MSI builds succeed (no new MSI signing required since + Hello helper is deferred) + +#### Phase 4: Desktop companion defenses + blur + first-run + +- Edited files: + - `desktopApp/.../ui/chats/DesktopMessagesScreen.kt` — apply + `Modifier.blur(...)` driven by `LocalWindowFocus`. + - `desktopApp/.../ui/chats/MessagesFirstRunCard.kt` — new inline card + composable mirroring Android. +- Window-focus wiring: + - `Main.kt` Window block: emit window-focus events into + `LocalWindowFocus` StateFlow via + `window.addWindowFocusListener { ... }`. +- macOS Sequoia/Tahoe caveat: + - `PrivacyLockSettingsScreen` shows an info row when running on macOS + 15+: "Screen sharing protection is limited on macOS 15+ due to a + system-level change — relying on this for hostile environments is + not advised." +- Linux caveat: + - On Linux, the same row reads: "Screen capture protection is not + available on Linux. Lock + blur + notification redaction still + apply." + +- Acceptance: + - [ ] Alt-tab away from window → Messages content blurs + - [ ] Alt-tab back → blur clears, no re-prompt + - [ ] macOS: screenshot via ⌘+Shift+3 produces blank on the Messages + window (best-effort on 15+ — verify and document) + - [ ] Windows: PrtScn + clipboard paste shows blank Messages window + - [ ] Linux: row says "not available" + +#### Phase 5: Polish, l10n, docs, manual testing + +- Translation strings — add ~20 strings to Android + `amethyst/src/main/res/values/strings.xml` and desktop + `desktopApp/.../resources/messages.properties`: + - lock_messages_title, lock_messages_subtitle, lock_after, + immediate/1min/5min/15min/1hour/never, notification_preview, + full/sender_only/hidden, lock_screen_title, lock_screen_unlock_button, + first_run_card_title, first_run_card_enable, first_run_card_not_now, + first_run_card_dont_ask, lock_disabled_no_credential_banner, + capture_protection_linux_unavailable, + capture_protection_macos15_caveat +- Crowdin auto-sync handles propagation (PR #3142 just merged + translations — verify pipeline) +- Final `./gradlew spotlessApply` +- Update `commons/ARCHITECTURE.md` with new `privacylock/` package +- Update `MEMORY.md` index entry for this work +- Hand-written manual testing sheet (post-implementation deliverable) + +## Security Hardening Additions (from deepen review) + +Six previously-missed leak paths now in scope. Each maps to a concrete +edit elsewhere in the plan; this section is the punch list. + +| # | Leak path | Severity | Mitigation | Status in plan | +|---|---|---|---|---| +| H1 | Deep-link race — `LaunchedEffect` guard insufficient; chat content can flash 1 frame before gate paints | HIGH | Gate selects branch SYNCHRONOUSLY in composition; `MessagesLockState.state` seeded synchronously from prefs before `setContent`; Compose-test screenshot diff asserts no-flash invariant | Phase 1 (seed) + Phase 2 (gate composable + AC) | +| H2 | Crash reports serialize `Throwable.message` containing decrypted DM plaintext (Jackson parse errors echo input; NIP-44 errors echo bytes) | HIGH | `ReportAssembler.kt` strips `Throwable.message` to class-name-only when origin package is `nip04Dm`, `nip17Dm`, or `marmot`. Always-on. | Phase 2 (new edit) | +| H3 | `Log.d` calls in DM notification path echo decrypted content; Proguard does not reliably strip in release; visible via `adb logcat` and NotificationListener apps with logging plugins | HIGH | Audit lines 349, 392, 435, 491, 549, 695 in `EventNotificationConsumer.kt` and `RemoteSignerManager.kt:53`; strip body fields, keep only event ID + length | Phase 2 (new edit) | +| H4 | `NotificationCompat.MessagingStyle.addMessage(...)` persists pre-enable history in system NotificationManager; lockscreen redaction does not affect it | HIGH | On first lock-enable, call `NotificationManagerCompat.cancel(DM_GROUP_KEY)` to flush; do not call `addMessage(body, …)` when level=Generic | Phase 2 (edit `NotificationUtils.kt`) | +| H5 | NotificationListenerService apps see the full `Notification` payload (`extras.text`, MessagingStyle); `setPublicVersion` only affects lockscreen | HIGH | Redact the MAIN builder when level=Generic — `setContentText("New message")`, omit `MessagingStyle.addMessage`, disable inline-reply `RemoteInput` (which would otherwise let the system quote the original message — finding #13) | Phase 2 (edit `NotificationUtils.kt`) | +| H6 | NIP-46 bunker decrypt responses queued on a channel; on re-lock, in-flight responses can deliver plaintext to a recomposed (now-locked) chat scope | MEDIUM | On `MessagesLockState` Unlocked→Locked transition, cancel in-flight bunker decrypt request subscriptions and clear the response channel | Phase 2 (edit `RemoteSignerManager.kt`) | + +These are NOT optional polish — without them the v1 lock fails to +deliver even its narrow "cosmetic shoulder-surf" promise honestly. + +## Alternative Approaches Considered + +1. **App-wide lock** (not just Messages route). Rejected during brainstorm + — user explicitly chose Messages-only. Higher friction for low gain. +2. **In-app Amethyst PIN**. Rejected — OS device credentials are better + UX and better security (no PIN reuse, no recovery to build). +3. **Per-conversation lock** (WhatsApp Chat Lock-style). Rejected for + v1 as scope creep. Could layer on later as a per-`Chatroom` flag. +4. **nsec encryption-at-rest with PIN-derived key**. Recognized as the + cryptographic upgrade that would close the "device-possessor" + threat. Explicitly deferred to Phase 2 (separate brainstorm) until + v1 ships and we have user feedback. +5. **Background / system-lock as trigger**. User chose to skip — the + inactivity timer (default 5 min) subsumes it within the same window. +6. **Rococoa for macOS Touch ID** (vs Swift `.dylib` shim). Rejected — + shim is smaller (~80 LOC) and avoids pulling in a 5MB framework + dependency. JNA + a single `.dylib` matches the existing + `MacOsVlcDiscoverer.kt` JNA pattern. + +## System-Wide Impact + +### Interaction Graph + +User taps Messages tab → +`NavController.navigate("messages")` → +`MessagesScreen` composes → +`MessagesLockGate` consumes `MessagesLockState.state` → +- If `Disabled` or `Unlocked` → render content + apply + `SecureScreenEffect` + (desktop) `Modifier.blur(focusState)` +- If `Locked` → render lock screen with "Unlock" button → + user taps → `CredentialPrompter.prompt(...)` → + (Android) `BiometricPrompt.authenticate(...)` → + (macOS) JNA → `libAmethystTouchID.dylib.amethyst_touchid_authenticate(...)` → + `LAContext.evaluatePolicy(...)` → + callback success → `MessagesLockState.onUnlockSuccess()` → + state flips to Unlocked → recomposition → content renders. + +User receives DM while app foregrounded but on a different route → +`EventNotificationConsumer.consume(event)` → +- If `lockEnabled` → `sendDMNotification(level = current)` → + `NotificationCompat.Builder.setPublicVersion(...)` → + user sees redacted preview on lock screen. + +User taps DM notification → +`MainActivity.onNewIntent(intent)` → +NavController deep-link to chatroom → +`MessagesScreen` route entered → gate intercepts (because state was Locked +from a prior leave-route event) → unlock prompt → on success → chatroom +shows. + +### Error Propagation + +| Origin | Error | Handled at | Result | +|---|---|---|---| +| BiometricPrompt | `ERROR_USER_CANCELED` | `CredentialPrompter.android.kt` | Swallowed; stay locked | +| BiometricPrompt | `ERROR_LOCKOUT` | same | Toast "Try again in 30 sec"; stay locked | +| BiometricPrompt | `ERROR_LOCKOUT_PERMANENT` | same | `onCredentialUnavailable(LockoutPermanent)` → state → Disabled + banner | +| BiometricPrompt | `ERROR_NO_HARDWARE` | same | `onCredentialUnavailable(NoCredential)` → same | +| TouchIDShim | `canEvaluatePolicy = false` | `MacTouchIdPrompter` | `onCredentialUnavailable(NoCredential)` | +| TouchIDShim | `evaluate → false` (user cancel) | same | Stay locked, no toast | +| WindowsHello helper | `ProcessBuilder` non-zero exit | `WindowsHelloPrompter` | If exit code = 2 (WinRT unavailable) → fall back to `CredUIPromptForCredentials` | +| Linux password | wrong password | `LinuxPasswordPrompter` | Toast "Wrong password"; stay locked. Throttle: 5s lockout after 3 attempts. | +| `MessagesLockState` | preferences write fails | `PrivacyLockPreferences` | Log + propagate (caller treats as best-effort) | +| Notification redaction | level mismatched on platform change | `NotificationUtils` | Falls back to Generic — safe default | + +### State Lifecycle Risks + +| Risk | Mitigation | +|---|---| +| App killed mid-unlock leaves state inconsistent | State is in-memory; cold start re-reads `lockEnabled` from prefs → if enabled, starts in `Locked`. Fail-safe by default. | +| Idle timer fires while user is typing a message | Timer resets on `keyboardCharType` interaction events; types via `Modifier.pointerInput` + composing-text interaction. | +| Switching Nostr accounts mid-session | Setting is device-global → state persists. Users expect this. | +| Settings change "disable lock" while screen is Locked | State holder receives prefs flow update → transitions Locked → Disabled. Gate transparently shows content. | +| Deep-link from notification while locked | Notification intent → MainActivity → NavController.handleDeepLink → MessagesScreen route → gate intercepts. Critical: ensure no chat content is rendered before gate paints (use `LaunchedEffect` initial composition guard). | +| Restoring app from recents (Android) while Locked | Lock state persists in `MessagesLockState`. Recents thumbnail is FLAG_SECURE-blanked. | +| Window-focus oscillation (rapid alt-tab) | Debounce blur transition by 100ms to avoid visual flicker. | + +### API Surface Parity + +| Surface | Affected? | Notes | +|---|---|---| +| `amy` CLI | No | CLI has no Compose UI. `PrivacyLockPreferences` is exposed but CLI commands don't gate by it. Future: `amy lock status` for debugging. | +| Android Quick-Tile widgets | No widgets today | +| Wear OS companion | None today | +| Search (global) | Yes — verify | Audit `MessagesSearchScreen` / global search results: when locked, must NOT surface DM hits. Add filter `isMessagesLocked` to search result aggregation. | +| Notifications service | Yes | `EventNotificationConsumer` reads `lockEnabled` + redaction level. | +| Marmot group chats | Yes | Same gate via commons composable. | + +### Integration Test Scenarios + +1. **Lock + deep link**: Tap DM notification while app cold, lock + enabled. Expected: Messages route opens, gate intercepts BEFORE + chatroom content visible, unlock → chatroom shows. Failure mode: + chatroom flashes content during navigation transition. +2. **Lock + spec drift**: User toggles lock → toggles inactivity to + Never → leaves Messages → returns. Expected: Locked (because + leave-route is a separate trigger). Failure: stays Unlocked because + timer = Never. +3. **Lock + credential revocation**: User removes fingerprint in + Android settings while app open. Next gate entry expected: prompt + appears, fails with NONE_ENROLLED → state → Disabled + banner + shows on settings. +4. **Lock + multi-window (Android tablet)**: Lock active, Messages in + left pane, Feed in right. Expected: only left pane shows lock. +5. **Lock + spam tab**: Locked + user navigates Known→New tab inside + Messages. Expected: no re-prompt within same Messages route session. +6. **Lock + send-receive parity**: Lock active, user unlocks, sends + a DM, recipient sees it normally. Outbound path unaffected. +7. **Notification preview level after lock disable**: User enables + lock → Generic notifications. Disables lock → notifications + revert to Full (no sticky Generic). + +## Acceptance Criteria + +### Functional + +- [ ] Messages route gated by `MessagesLockGate` on Android + Desktop +- [ ] Idle timer (default 5 min) auto-locks while in Messages +- [ ] Navigation away from Messages triggers immediate re-lock +- [ ] Account switch counts as leave-route (force re-lock — spec-flow #2) +- [ ] Inactivity timer ignores incoming DMs (per resolved Q) +- [ ] Inactivity timer resets on user input (scroll/tap/keypress) but + NOT on media playback (spec-flow #13) +- [ ] Marmot group chats inherit the gate (per resolved Q) +- [ ] Android: `BiometricPrompt(STRONG | DEVICE_CREDENTIAL)` succeeds +- [ ] macOS: Touch ID via Swift shim succeeds; password fallback works +- [ ] Windows: `CredUIPromptForCredentials` (OS password) succeeds + — **no Windows Hello in v1** +- [ ] Linux: lock toggle disabled with "Not available on Linux yet" + banner — **lock entirely deferred in v1** +- [ ] Activity-level FLAG_SECURE applied at MainActivity.onCreate when + `lockEnabled = true` (closes cold-resume recents window) +- [ ] Per-screen FLAG_SECURE belt-and-suspenders via SecureScreenEffect +- [ ] macOS NSWindow sharingType set when lock enabled (with macOS 15+ + caveat documented in-app — both ScreenCaptureKit and + `screencapture(1)` ignore it on Sequoia/Tahoe) +- [ ] Windows SetWindowDisplayAffinity set when lock enabled +- [ ] Desktop blur-on-unfocus active while Messages visible (16dp, + overlay layer not LazyColumn) +- [ ] macOS 15+ and Linux: blur applied even while focused as partial + capture-block compensation +- [ ] Notification preview redaction respects level (Generic / Full — + 2 levels per simplicity review) +- [ ] **Notification main builder redacted when level=Generic** (not + just `setPublicVersion`) — security H5 +- [ ] **Inline-reply `RemoteInput` disabled when level=Generic** — security H13 +- [ ] **MessagingStyle history flushed on first lock-enable** — security H4 +- [ ] **Crash reports strip `Throwable.message` for DM-origin packages** — security H2 +- [ ] **No `Log.d` calls echo decrypted DM content** (verified by + `logcat | grep` audit during testing) — security H3 +- [ ] **Bunker decrypt response queue drained on re-lock** — security H6 +- [ ] First-run inline card appears once; auto-suppresses after Enable + or after the card is shown (2 buttons: Enable / Not now — no + "Don't ask" persistence per simplicity review) +- [ ] First-run card renders on empty Messages tab (spec-flow #1) +- [ ] Fallback: credential-unavailable → toggle disabled + banner + (no persisted flag — re-checked via `BiometricManager.canAuthenticate()`) +- [ ] Settings UI on both platforms (Android Scaffold, Desktop Column + + Card per pattern review) +- [ ] Global search does not surface DM content while locked + (`SearchBarViewModel` + `AdvancedSearchBarState` + Desktop + search panel — filter aggregation on kinds 4 / 14 / 1059 / 443) +- [ ] In-app DM banner suppressed on Messages route while Locked + (spec-flow #7) +- [ ] Draft persistence — text TextFields keep their value across a + lock cycle (gate overlays content, never disposes it — spec-flow #8) +- [ ] BiometricPrompt dismissed + state reset on + `Lifecycle.STOP` while prompt is up (spec-flow #4) +- [ ] Disable-while-locked: toggle off in settings → state + Locked → Disabled within one frame (spec-flow #9) +- [ ] Concurrent triggers (leave-route + idle-timer same tick) are + idempotent (spec-flow #5) +- [ ] **Deep-link from notification: chat content NEVER paints + before gate** — security H1; Compose-test asserts this + +### Non-Functional + +- [ ] No measurable startup-time regression (≤ +20ms cold start) +- [ ] Gate composition adds ≤1 recomposition per state change +- [ ] No new ANRs / dropped frames during lock transitions (verified + with macrobenchmark on Android, frame-rate inspection on desktop) +- [ ] FLAG_SECURE applied via DisposableEffect, removed on screen exit + (no permanent secure-flag leak) +- [ ] No credentials cached in memory beyond the unlock callback +- [ ] All new code paths spotless-clean and covered where applicable + +### Quality Gates + +- [ ] `./gradlew :commons:jvmTest --tests "*privacylock*"` green +- [ ] `./gradlew :amethyst:assembleDebug` green +- [ ] `./gradlew :desktopApp:compileKotlin` green +- [ ] `./gradlew :desktopApp:packageDmg` green on macOS host +- [ ] `./gradlew :desktopApp:packageMsi` green on Windows host +- [ ] `./gradlew :desktopApp:packageDeb` green on Linux host +- [ ] `./gradlew spotlessApply` clean +- [ ] Manual testing sheet executed and signed off + +## Success Metrics + +- **Adoption proxy**: prefs node read counts (if telemetry exists; if + not, GitHub issue volume tagged `privacy-lock`) +- **Stability proxy**: zero "stuck-locked" support reports during the + first 30 days post-release +- **Discoverability proxy**: < 10% of users dismiss the first-run card + with "Don't ask" without first trying Enable (intent: card is clear + enough that 'Don't ask' is informed, not annoyance-driven) + +## Dependencies & Prerequisites + +- `androidx.biometric.ktx 1.2.0-alpha05` (already bundled) +- `com.sun.jna:jna` (already a transitive dep via existing JNA usage + in `MacOsVlcDiscoverer.kt`) +- Compose Multiplatform 1.11.0 — `Modifier.blur` supported +- macOS dev host with `swiftc` (only when changing Touch ID shim source) +- Windows dev host with `dotnet` SDK (only when changing Hello helper) +- Pre-built `.dylib` / `.exe` committed to repo so non-mac/non-win devs + can still build + +## Risk Analysis & Mitigation + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| macOS 15+ NSWindowSharingNone is ignored by ScreenCaptureKit | Verified | Medium | In-app caveat row; document in settings + release notes | +| Touch ID Swift shim breaks under future macOS update | Low | Medium | Shim is tiny; fix is < 30 LOC. Fallback: password via `LAPolicy.deviceOwnerAuthentication` works without biometric | +| Windows Hello helper.exe gets flagged by AV | Medium | Medium | Sign the helper with same code-sign cert as the main MSI; ship as `AmethystHello.exe` inside the app bundle (not standalone) | +| Linux distro permutations (Wayland vs X11) — no portable capture block | Verified | Low | Documented limitation; lock + blur + notif redaction still work | +| Notification deep-link races the gate (chat flash) | Medium | High (privacy leak) | LaunchedEffect initial-composition guard; integration test | +| BiometricPrompt + Compose lifecycle bug under config-change | Low | Medium | Use existing pattern from `UpdateZapAmountDialog.kt:428-479`; add lifecycle-aware launch | +| User disables OS device credentials → permanently locked out | Verified | Low | `onCredentialUnavailable` → disable + warn banner — no permanent lockout | +| Spotless config doesn't know about Swift / C# files | Low | Low | Add file globs to `.editorconfig` only — spotless is Kotlin-scoped | +| ProGuard strips `BiometricPrompt` reflection | Medium | Medium | Existing `compose-rules.pro` already has broad keepnames; add explicit `-keep class androidx.biometric.**` if needed | +| Marmot UI extraction blocked / delayed | Low | Low | Gate wraps Marmot Android screen today; Desktop inherits when Marmot extracts. No coupling. | + +## Future Considerations + +- **Windows Hello in v2.** Native helper `.exe` (C++/WinRT or C#) ~60 + LOC over stdio, parented to the JVM `HWND` via + `IUserConsentVerifierInterop`. Requires the helper to be signed with + the same cert as the main MSI to avoid AV flags. Add once the v1 + flow has user feedback. (See deepen-plan external research output.) +- **Linux lock in v2.** Bitwarden's polkit pattern is the proven path + but requires a root-installed policy file at + `/usr/share/polkit-1/actions/com.amethyst.privacy-lock.policy`, + which breaks Flatpak distribution. Alternative: in-app password + prompt stored in libsecret. Decide at v2. +- **Phase 2 — nsec encryption-at-rest with PIN-derived key**. + Separate brainstorm. Closes the cosmetic-vs-cryptographic gap. Would + re-encrypt `accounts.json.enc` with `PRF(nsec, PIN-derived key)` on + lock; decrypt only at unlock. Compatible with NIP-46 bunker (signer + never holds nsec). Compatible with NIP-55 (external signer). +- **Per-conversation lock** (WhatsApp Chat Lock parity). Add + `Chatroom.locked: Boolean` flag and a second gate over individual + ChatroomScreen entries. +- **Wallet (NWC) gate** — reuse the same `MessagesLockGate` plumbing + to gate the Wallet deck column. Already on the feature backlog. +- **amy CLI integration** — `amy messages status` could refuse to + print DMs while `lockEnabled = true` to enforce parity even from the + headless side. Useful when running amy on a shared machine. +- **Hide app from app-switcher entirely (Android)** — instead of + FLAG_SECURE blanking, fully omit the app from the recents list while + Messages is foregrounded. More aggressive UX; opt-in. + +## Documentation Plan + +- `commons/ARCHITECTURE.md` — add `privacylock/` package entry +- `desktopApp/CLAUDE.md` (if exists) — mention Touch ID shim build step +- New `desktopApp/plans/2026-06-30-privacy-lock-native-shims-recipe.md` + capturing the macOS `.dylib` / Windows `.exe` build & signing + procedure (so future contributors don't rediscover it) +- Release notes section "Privacy & Security" highlighting the new + toggle + caveats (macOS 15+, Linux capture) +- `docs/manual-testing-privacy-lock.md` — the testing sheet (delivered + as part of the final step) + +## 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) + — Key decisions carried forward: (a) Messages-only route gate (not + app-wide), (b) OS device credentials only (no Amethyst PIN), (c) + device-global setting, (d) off by default + inline first-run card, + (e) inactivity-timer + leave-route as the two re-lock triggers, + (f) auto-bump notification preview to Generic on first enable. + +### Internal References + +- Existing biometric pattern: + `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt:428-479` +- DM notification path: + `amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt:370-510` +- Existing JNA usage: + `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt` +- macOS window-chrome extension pattern: + `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt:87` +- Desktop window creation: + `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:316` +- Settings storage on desktop (java.util.prefs precedent): + `commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt` +- DM messaging UI (commons + Android + Desktop) — surveyed in brainstorm research. + +### External References + +- macOS Touch ID via Swift shim: [classycodeoss/java-touchid](https://github.com/classycodeoss/java-touchid) +- Rococoa (Java↔Cocoa bridge, used by Cyberduck): [iterate-ch/rococoa](https://github.com/iterate-ch/rococoa) +- Apple `LAContext.evaluatePolicy`: [developer.apple.com](https://developer.apple.com/documentation/LocalAuthentication/LAContext/evaluatePolicy(_:localizedReason:reply:)) +- Microsoft `UserConsentVerifier`: [learn.microsoft.com](https://learn.microsoft.com/en-us/uwp/api/windows.security.credentials.ui.userconsentverifier) +- `IUserConsentVerifierInterop` (HWND attach): [Microsoft sdk-api](https://github.com/MicrosoftDocs/sdk-api/blob/docs/sdk-api-src/content/userconsentverifierinterop/nn-userconsentverifierinterop-iuserconsentverifierinterop.md) +- Bitwarden Linux polkit pattern (rejected for v1): [bitwarden/clients PR #4586](https://github.com/bitwarden/clients/pull/4586) +- macOS 15+ `sharingType` regression: [Tauri #14200](https://github.com/tauri-apps/tauri/issues/14200) +- Windows `SetWindowDisplayAffinity`: [learn.microsoft.com](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setwindowdisplayaffinity) +- NIST 800-63B session timeouts: [pages.nist.gov](https://pages.nist.gov/800-63-4/sp800-63b.html) +- WhatsApp Chat Lock: [about.fb.com](https://about.fb.com/news/2023/05/whatsapp-chat-lock/) +- Signal Screen Lock: [support.signal.org](https://support.signal.org/hc/en-us/articles/360007059572-Screen-Lock) + +### Related Work + +- Embedded local relay plan (precedent for `commons/jvmMain` state + holder + StateFlow + `LocalXyzStore` pattern): + `desktopApp/plans/2026-05-09-embedded-local-relay-plan.md` +- Account security hardening (concurrent, deals with nsec storage and + forced logout): `docs/plans/2026-05-14-fix-account-security-hardening-plan.md` +- Hashtag spam filter (precedent for global preferences node): + `docs/plans/2026-06-29-feat-desktop-hashtag-spam-filter-plan.md` + +## Open Questions (post-brainstorm + post-deepen) + +All brainstorm open questions are resolved. Original planning +questions reordered + deepen-review additions integrated: + +1. **macOS code-signing for Touch ID shim.** Current desktop build's + `nativeDistributions { macOS { ... } }` block has only `bundleID` + and `iconFile`. No `signing` block, no notarization. Touch ID + *works* on unsigned dev builds (research confirms), but for the + release DMG we need a Developer ID + notarization. Verify whether + the existing DMG release is signed elsewhere; if not, that's a + separate workstream (out of this plan's scope but a release-day + blocker). +2. **ProGuard rules.** Compose Desktop release uses ProGuard. Verify + `compose-rules.pro` keeps the new JNA classes, biometric reflection + surfaces, and Compose composables. Add + `-keep class androidx.biometric.** { *; }` if not already covered. +3. **Search audit — concrete files** (refined from spec-flow review): + `SearchBarViewModel.kt` (Android), `AdvancedSearchBarState.kt` + (commons), `AdvancedSearchPanel.kt` (desktop). Filter aggregation + on kinds 4 / 14 / 1059 / 443 when `MessagesLockState.state == Locked`. + Phase 5 deliverable, gated by an acceptance criterion below. +4. **`commons/jvmAndroid` source set name** — verify the exact name + `commons/ARCHITECTURE.md` uses (could be `jvmAndroidMain` or + `desktopAndroidMain`). Source set must exist already (used by + shared StateFlow / persistence patterns); confirm during Phase 1. +5. **`CredentialPrompter` interface vs `expect class`** — per pattern + review the codebase has no precedent for `@Composable expect fun`, + so the plan uses a plain interface + CompositionLocal. If a wider + refactor ever wants `expect class CredentialPrompter` (matching + `SecureKeyStorage`), follow that idiom — out of scope now. +6. **`firstRunCardSeen` scope** — device-global (single flag, not + per-npub) per spec-flow gap #2. Acceptable for v1. +7. **`MessagesLockState.LocalMessagesLockState` instantiation site on + Android** — needs a single owner equivalent to the Desktop `Main.kt` + instantiation. Likely `AmethystApp` / Application class. Confirm + during Phase 2 wiring. +8. **Telemetry?** Amethyst has no telemetry today. Success metrics + above are inferred from issue volume — keep as-is, no telemetry + shipped. diff --git a/docs/plans/2026-06-30-privacy-lock-manual-testing.md b/docs/plans/2026-06-30-privacy-lock-manual-testing.md new file mode 100644 index 0000000000..6d391cb473 --- /dev/null +++ b/docs/plans/2026-06-30-privacy-lock-manual-testing.md @@ -0,0 +1,277 @@ +--- +title: Messaging Privacy Lock — Desktop Manual Testing Sheet +type: test +status: active +date: 2026-06-30 +--- + +# Messaging Privacy Lock — Desktop Manual Testing Sheet + +Companion to `2026-06-30-feat-messaging-privacy-lock-plan.md` and +`2026-06-30-feat-messaging-privacy-lock-brainstorm.md`. + +## What ships on the `worktree-brainstorm-messaging-privacy-lock` branch + +- ✅ **Commons foundation** — headless cross-platform state machine, + preferences with password-hashed field, gate composable primitives, + idle-timer modifier, 8 unit tests all green + (`./gradlew :commons:jvmTest --tests "*MessagesLockStateTest*"`). +- ✅ **Desktop wiring** — `DesktopMessagesLockGate` wrapping the + Messages deck column; PBKDF2 password unlock (100k iterations, + 16-byte salt); `PrivacyLockSettingsScreen` embedded in the Settings + pane; app-global CompositionLocals provided in `Main.kt`. + +## What is NOT in this branch + +- **Android app module changes** — reverted. Foundation code in + `commons/` still compiles for Android; wiring is a separate future + workstream. +- **macOS Touch ID / Windows Hello / Linux biometrics** — Desktop v1 + uses a PBKDF2 password gate. Native biometric shims (Swift + `LAContext`, Windows Hello, polkit) remain in Future Considerations + per the plan. +- **Notification redaction call site** — the `DmRedactionLevel` policy + exists in `commons/.../privacylock/`; the actual write into + Android's `NotificationUtils.kt` or a desktop notification path is + deferred (no desktop notifications wired today). +- **Screen-capture window blocks** — `NSWindowSharingNone` / + `SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE)` deferred. macOS 15+ + broke `sharingType` anyway; Windows can revisit as a v1.1 patch. +- **Blur-on-unfocus** — Desktop deferred to v1.1; the gate's + onLeaveRoute-fires-on-column-navigation already covers the "left + Messages" scenario. +- **Marmot group chats** — Marmot UI is Android-only in this repo. + Desktop only gates the primary Messages column. +- **Security hardening H2–H6** — crash-report scrubber, DM-content + log audit, MessagingStyle history flush, NotificationListener + redaction of the main builder, bunker decrypt queue drain. Each is + independent scope; see the plan's §Security Hardening Additions. + +## Prerequisites + +- macOS host recommended (the primary Amethyst Desktop dev target). + Also runs on Windows and Linux (untested for this feature). +- Java 17+ (Compose Desktop bundles its own JBR). +- A Nostr account with at least one DM conversation for meaningful + gating. + +## Automated verification + +```bash +# Unit tests for the state machine + settings interface +./gradlew :commons:jvmTest --tests "com.vitorpamplona.amethyst.commons.privacylock.MessagesLockStateTest" + +# Full commons + desktop compile +./gradlew :commons:compileKotlinJvm +./gradlew :desktopApp:compileKotlin + +# Formatter +./gradlew spotlessApply +``` + +Expected: green. + +## Manual testing paths + +### Launch + +```bash +./gradlew :desktopApp:run +``` + +Log in with an account that has one or more DM conversations. +Add a Messages column to your deck via the "+" button → Messages, if +not already present. + +### Path A — Enable the lock + +1. Click **Settings** in the sidebar. +2. Scroll to the **"Lock the Messages tab"** card. + - ✅ Card shows a description and a **Switch**. + - ✅ Switch is OFF by default. +3. Click the Switch to ON. + - ✅ A **Set a password** dialog appears (because no password is + set yet). + - ✅ Dialog has: "New password" field, "Confirm new password" + field, Cancel + Save buttons. +4. Enter a password shorter than 4 characters, click Save. + - ✅ Error: "New password must be at least 4 characters". +5. Enter mismatched passwords, click Save. + - ✅ Error: "Passwords don't match". +6. Enter matching passwords ≥ 4 chars, click Save. + - ✅ Dialog closes. + - ✅ Switch is now ON. + - ✅ Below the toggle: **"Change password"** button appears. + - ✅ Two new cards appear: **"Auto-lock after"** (default: 5 min) + and **"DM notification preview"** (default: Full). + +### Path B — Lock behavior + +1. Lock enabled with a password set. Navigate to the Messages deck + column. + - ✅ **Lock screen** renders with a padlock icon, "Messages + locked" title, "Enter your privacy-lock password" subtitle, a + password field, and an Unlock button. + - ✅ The chat list is NOT visible behind the lock screen. +2. Click Unlock without typing. + - ✅ Button is disabled. +3. Type a wrong password, press Enter (or click Unlock). + - ✅ "Wrong password" error under the field. + - ✅ Chat still not visible. +4. Type the correct password, press Enter. + - ✅ Lock screen disappears. + - ✅ Full Messages column visible with conversations + chat pane. + +### Path C — Auto re-lock triggers + +1. Unlocked. Set inactivity timer to **1 min** via Settings → + Privacy lock → Auto-lock after. +2. Return to Messages, don't interact for 60 seconds. + - ✅ Column re-locks; password field reappears. +3. Unlock. Scroll a chat back and forth for 30 seconds. + - ✅ Column stays unlocked (user interaction resets timer). +4. Unlock. Navigate to Feed or Discover column. + - ✅ Return to Messages → **re-locked immediately** (leave-route + trigger via `DisposableEffect(onDispose)`). +5. Set timer to **Never**. + - ✅ Wait 2 minutes without input → column stays unlocked. + - ✅ Navigating away still re-locks (leave-route independent). + +### Path D — Deep-link race (security H1) + +Not directly testable in this branch — Desktop deck columns navigate +via the sidebar, so there's no true "cold-start-to-chatroom deep link" +flow. However, the invariant still applies: the gate reads +`MessagesLockState.state` synchronously in composition (no +`LaunchedEffect` guard). Verify by: + +1. Enable lock, quit the app. +2. Cold-start (`./gradlew :desktopApp:run`). +3. Add the Messages column immediately. + - ✅ Lock screen appears **from the first frame** — never see chat + content flash before the lock overlay paints. + +### Path E — Change / clear password + +1. Lock enabled. Settings → Privacy lock → **Change password**. +2. Dialog opens with: **Current password** field, New password, + Confirm. +3. Enter wrong current password. + - ✅ "Current password is wrong" error. +4. Enter correct current, new, confirm → Save. + - ✅ Old password no longer works on the lock screen. + - ✅ New password unlocks. +5. Toggle lock OFF, then back ON. + - ✅ Password persists — you're NOT re-prompted to set one, since + the hash is still stored (`passwordHashed` is not cleared on + disable). + +### Path F — Fallback (no password set) + +Rare: user manually clears `passwordHashed` from +`~/.java/.userPrefs/com/vitorpamplona/amethyst/privacylock/` while +`lockEnabled = true`. To simulate: + +1. Quit the app. +2. Open the prefs file for the node and remove + `password_hashed=` while keeping `lock_enabled=true`. +3. Restart. +4. Navigate to Messages. + - ✅ Lock screen shows: "No password is set yet. Open Settings → + Privacy lock to set one." + a **Disable lock** button. +5. Click **Disable lock**. + - ✅ Lock is disabled globally; Messages column visible. + +### Path G — Multi-account switch + +1. Lock enabled, unlocked. In Messages, viewing a conversation. +2. Switch account via the sidebar / account switcher. + - ✅ Return to Messages → column re-locked (account switch counts + as leave-route because the deck column composable is disposed + and re-composed). + +### Path H — Regression sweep + +1. Feed / Discover / Wallet / Videos columns still function normally, + no gate anywhere else. +2. Sending a DM (after unlock) works — the compose pane is inside the + gated content, so unlock allows send. +3. Settings pane still shows all other sections (Media servers, Local + Relay, Namecoin, Logout). +4. Long-press-and-drag column reordering still works. + +### Path J — First-run discovery banner + +Added by `docs/plans/2026-07-01-feat-messages-first-run-lock-banner-plan.md`. + +1. Fresh state — quit app, delete + `~/.java/.userPrefs/com/vitorpamplona/amethyst/privacylock/prefs.xml` + (or edit to clear `first_run_card_seen` + `lock_enabled`). +2. Launch app, open Messages column. + - ✅ Inline banner at top of Messages column: padlock icon + + **"Lock the Messages tab?"** + description + **Not now** + + **Enable** buttons. + - ✅ Conversation list + chat pane still visible below (banner is + ~50dp, doesn't dominate). +3. Click **Not now**. + - ✅ Banner animates out (shrinkVertically + fadeOut). + - ✅ Navigate away and back → banner does NOT return. + - ✅ Quit + relaunch → banner still does not return. +4. Reset state again. Click **Enable** on the banner. + - ✅ **Set a password** dialog opens (same dialog as Settings). + - ✅ Enter matching password ≥ 4 chars → Save. + - ✅ Banner animates out. + - ✅ Column STAYS interactive — **no lock-screen flash** after + save (verifies the `onUnlockSuccess()` leniency). +5. Continue browsing chats without unlock prompt. +6. Navigate to Feed → back to Messages. + - ✅ Lock screen appears (leave-route trigger fired after enable). + - ✅ Unlock with the password you just set. +7. Reset state. In Settings, enable the lock first (via the toggle + card). Then open Messages. + - ✅ Banner does NOT appear (`lockEnabled == true` suppresses it). +8. Reset state. Show the banner. Dismiss with **Not now**. Now + enable the lock via Settings. Later disable it via Settings. + Return to Messages. + - ✅ Banner does NOT reappear (dismissal is sticky — the + `firstRunCardSeen` flag persists across enable/disable cycles). + +### Path I — Cross-run persistence + +1. Set a password, enable lock, set timer to 15 min, redaction to + Hidden. +2. Quit the app. +3. Restart via `./gradlew :desktopApp:run`. + - ✅ Lock is still enabled; navigating to Messages requires + password. + - ✅ Timer setting persists. + - ✅ Redaction persists. + +## Known-honest limitations + +Copy in the Settings pane's **Limitations** card matches the actual +threat model: + +- Filesystem-level attacker can read the `java.util.prefs` node and + see the *hash* — cannot recover the password without brute-force, + but 4-char passwords are weak. Recommend ≥ 8 chars, but v1 min is 4. +- Memory dumps expose the plaintext password briefly during + verification. Not defended. +- The Nostr private key (`nsec`) is stored via `SecureKeyStorage` as + it is today — the lock does not touch it. + +## Sign-off checklist + +- [ ] Paths A–I executed on macOS +- [ ] `./gradlew :commons:jvmTest` green +- [ ] `./gradlew :desktopApp:compileKotlin` green +- [ ] `./gradlew spotlessApply` clean +- [ ] Follow-up issues filed for: + - Windows / Linux manual QA + - Screen-capture window blocks (macOS 15+ caveat) + - Notification redaction call site (desktop notifications don't + exist yet — deferred to when they do) + - Native biometrics (Touch ID / Windows Hello / polkit) + - Security hardening H2–H6 (crash scrub, log audit, MessagingStyle + history, NotificationListener redaction, bunker queue drain) + - Marmot group chat gating (once Marmot desktop UI lands) diff --git a/docs/plans/2026-07-01-feat-messages-first-run-lock-banner-plan.md b/docs/plans/2026-07-01-feat-messages-first-run-lock-banner-plan.md new file mode 100644 index 0000000000..3c0a3fc4ff --- /dev/null +++ b/docs/plans/2026-07-01-feat-messages-first-run-lock-banner-plan.md @@ -0,0 +1,381 @@ +--- +title: Messages First-Run Privacy Lock Banner (Desktop) +type: feat +status: completed +date: 2026-07-01 +--- + +# Messages First-Run Privacy Lock Banner (Desktop) + +## Overview + +Inline discovery banner at the top of the Desktop Messages deck column +that appears when: + +1. The privacy lock is **not enabled**, AND +2. The user has **not dismissed** it before (`firstRunCardSeen == false`). + +Two actions: **Enable** (opens the existing password-set dialog inline) +or **Not now** (sets `firstRunCardSeen = true` and hides forever). One +composable, no new state, no new settings. + +Small follow-up to the shipped privacy-lock feature at +[`docs/plans/2026-06-30-feat-messaging-privacy-lock-plan.md`](2026-06-30-feat-messaging-privacy-lock-plan.md). + +## Problem Statement / Motivation + +The privacy lock exists and works (Desktop v1 password gate + auto +re-lock + settings pane), but it has **zero in-app discovery**. A user +who never opens Settings will never find it. Yet the Messages column +is exactly the surface where the feature's value is felt — anyone who +opens it is by definition a DM user. + +Existing users are the sharper case: they can install this update and +never learn the feature exists unless they read the release notes. +A one-time banner solves that with negligible UX cost. + +## Proposed Solution + +Add a `MessagesFirstRunBanner` composable rendered at the top of +`DesktopMessagesScreen`, above the two-pane / single-pane layout. It +sits inside the `DesktopMessagesLockGate` content lambda, so the +banner is only ever visible in Unlocked / Disabled states — never +over the lock screen. + +Uses `AnimatedVisibility(expandVertically + fadeIn / shrinkVertically ++ fadeOut)` for the show/hide transition — same pattern as +`desktopApp/.../ui/components/OfflineBanner.kt:49-56` (the embedded +local-relay work). Same visual treatment (Surface + Row + Icon + Text ++ TextButton). + +The dialog behind the **Enable** action is the existing +`SetPasswordDialog` from `PrivacyLockSettingsScreen.kt:270`. That dialog +is currently `private`. Extract it to a new shared file so both the +Settings pane and the banner point at the same composable. + +On successful password save from the banner: + +1. `settings.setPasswordHashed(hash)` +2. `settings.setLockEnabled(true)` +3. `settings.setFirstRunCardSeen(true)` (dismiss banner) +4. `messagesLockState.onUnlockSuccess()` — keep the user Unlocked so + they don't immediately hit a lock screen after enabling. + +The last call requires a tiny leniency change to `onUnlockSuccess()` +so it works from `LockState.Disabled` as well as `Locked` (see +Technical Considerations below). + +## Technical Considerations + +### Architecture + +- **New file**: `desktopApp/.../security/MessagesFirstRunBanner.kt` + — the banner composable + interaction logic. +- **New file**: `desktopApp/.../security/SetPasswordDialog.kt` — + extracted from `PrivacyLockSettingsScreen.kt`. Public visibility so + the banner can reuse it. +- **Edit**: `PrivacyLockSettingsScreen.kt` — delete the private + `SetPasswordDialog` body, add an import for the new shared version. +- **Edit**: `DesktopMessagesScreen.kt` — insert the banner as the + first child inside the existing root `Column` (or wrap the current + content in a Column if the top-level isn't already one). Passes + through in single-pane and compact modes identically. +- **Edit**: `MessagesLockState.kt` — relax `onUnlockSuccess()` to + accept `Disabled` as a valid previous state (transitions to + `Unlocked`). + +### Visibility logic + +```kotlin +// MessagesFirstRunBanner.kt +@Composable +fun MessagesFirstRunBanner() { + val settings = LocalPrivacyLockSettings.current + val lockState = LocalMessagesLockState.current + 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, + 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), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Lock the Messages tab?", + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = "Require a password before Messages shows. Feed and profile 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) + lockState.onUnlockSuccess() // stay Unlocked; don't force user through the gate immediately + showDialog = false + }, + ) + } +} +``` + +### Placement inside DesktopMessagesScreen + +The banner goes at the top of the composable's root layout. In +`DesktopMessagesScreen.kt` the current `@Composable fun +DesktopMessagesScreen(...)` returns either a two-pane or single-pane +layout. Wrap in a Column: + +```kotlin +Column(modifier = Modifier.fillMaxSize()) { + MessagesFirstRunBanner() + // existing two-pane / single-pane content, weight(1f) +} +``` + +Both pane modes need `Modifier.weight(1f)` on the pane container so +they consume the remaining space. + +### State transitions + +Enabling from the banner drives this sequence: + +| Step | Action | LockState | +|---|---|---| +| Before click | User on Messages, lock off | `Disabled` | +| `setPasswordHashed(hash)` | Persist | `Disabled` | +| `setLockEnabled(true)` | Persist + StateFlow emit | `Disabled` → eventually `Locked` via init collector | +| `setFirstRunCardSeen(true)` | Persist | (no state change) | +| `onUnlockSuccess()` | Force state = `Unlocked` | `Unlocked` | + +Race: the `settings.lockEnabled` collector on `windowScope` runs +whenever it's next scheduled. If it fires between step 3 and step 4, +state briefly is `Locked`. If it fires after step 4, state is +`Unlocked` and the collector's `else if (state is Disabled)` branch +skips (state isn't Disabled anymore). Either ordering ends at +`Unlocked`. The gate re-reads `state` each frame, so no lock-screen +flash — `Unlocked` reaches the UI within the same recomposition batch. + +### `onUnlockSuccess()` leniency + +Current signature only transitions from `Locked`: + +```kotlin +fun onUnlockSuccess() { + if (mutableState.value is LockState.Locked) { + mutableState.value = LockState.Unlocked + restartIdleTimer() + } +} +``` + +Change to: + +```kotlin +fun onUnlockSuccess() { + if (mutableState.value !is LockState.Unlocked) { + mutableState.value = LockState.Unlocked + restartIdleTimer() + } +} +``` + +New semantics: "the caller has authenticated the user; go to Unlocked +regardless of previous state." No behavior change for any existing +call site (they only ever call this from `Locked`). + +### Performance + +- Banner is inside a `Column` that's already the root layout — one + extra `AnimatedVisibility` composable. +- Reads two `StateFlow` via `collectAsState`. Both are hot + (backed by `MutableStateFlow`) so no cost when not changing. +- When `firstRunCardSeen` flips true or `lockEnabled` flips true, + `AnimatedVisibility` runs its exit animation (~300ms) and unmounts + the banner. One-time cost per session. +- No new coroutine, no new dispatcher. + +### Accessibility + +- Icon has `contentDescription = null` (decorative — title conveys + the meaning). +- Text uses semantic `titleSmall` / `bodySmall` styles for screen + readers. +- Buttons have explicit text labels. + +## System-Wide Impact + +- **Interaction graph**: `MessagesFirstRunBanner` → reads + `LocalPrivacyLockSettings` + `LocalMessagesLockState` → on Enable + action → `SetPasswordDialog` → onConfirm → 4 sequential settings + writes + `onUnlockSuccess()` → `MessagesLockState.state` transitions + → gate recomposes → user sees banner disappear + column stays + interactive. +- **Error propagation**: none new. Password validation stays inside + `SetPasswordDialog` (existing errors: length, mismatch, wrong + current). Password hash write to `java.util.prefs` is best-effort + (the underlying `prefs.put(...)` swallows IO errors — matches the + existing settings pane). +- **State lifecycle risks**: none. The banner only writes; it never + reads then re-writes. Dismissal is idempotent (`setFirstRunCardSeen(true)` + is safe to call twice). Enabling from banner is atomic-ish (see + race analysis above — end state is deterministic). +- **API surface parity**: `SetPasswordDialog` becomes a shared + composable. Verify both call sites (banner + settings pane) render + the same behavior after extraction. +- **Integration test scenarios** (manual — no test infra for this): + 1. Fresh install → open Messages → banner visible; enable → password + dialog → set + save → banner disappears, column stays interactive, + lock is on for next session. + 2. Fresh install → open Messages → banner visible; **Not now** → + banner disappears, does not reappear on subsequent Messages entries + even after restart. + 3. Existing user with lock already enabled from Settings pane → + `!enabled` is false → banner never renders. + 4. User dismisses banner, later disables the lock from Settings → + banner does NOT reappear (dismissal is sticky by design). + 5. Cold-start with lock enabled → gate takes over immediately → + banner never renders. + +## Acceptance Criteria + +- [x] `MessagesFirstRunBanner` composable exists at + `desktopApp/.../security/MessagesFirstRunBanner.kt` +- [x] `SetPasswordDialog` extracted to + `desktopApp/.../security/SetPasswordDialog.kt` and reused by + both `PrivacyLockSettingsScreen` and the new banner (single + source of truth) +- [x] `PrivacyLockSettingsScreen` still opens the same dialog after + the extraction (visual + behavior identical) +- [x] Banner appears at the top of the Desktop Messages column when + `!lockEnabled && !firstRunCardSeen` +- [x] Banner never appears when the gate is Locked (implicit — the + gate replaces content) +- [x] "Enable" button opens the password set dialog inline +- [x] After successful password save from banner: lock is enabled, + hash stored, banner dismissed, column stays Unlocked (no + lock-screen flash) +- [x] "Not now" dismisses the banner permanently across restarts +- [x] Banner does NOT reappear after the user later disables the lock + from Settings (dismissal is sticky per `firstRunCardSeen` + semantics) +- [x] `MessagesLockState.onUnlockSuccess()` accepts `Disabled` as a + valid previous state (Unit-tested) +- [x] `./gradlew :commons:jvmTest` green (all 8 existing tests + + any new coverage for the leniency change) +- [x] `./gradlew :desktopApp:compileKotlin` green +- [x] `./gradlew spotlessApply` clean +- [x] Manual testing sheet updated with a new Path (banner discovery) + +## Alternative Approaches Considered + +1. **Modal dialog on first Messages entry** — rejected in the original + brainstorm as too intrusive. Same rejection applies here. +2. **Sidebar/menu badge on the Messages icon** — subtle but obscure; + users don't associate a lock-glyph badge with the concept "you can + lock this." Also requires editing the sidebar composable, more + surface area. +3. **Toast/snackbar on Messages open** — dismissive; easily missed; + auto-fades. Terrible for a discovery affordance. +4. **Onboarding flow** — Amethyst has no first-run onboarding today + and adding one for this feature is disproportionate. +5. **Inline chip inside the empty-state view** — misses users who + already have chats (skips the empty-state). +6. **No banner, rely on release notes** — status quo. Fails the + existing-user discovery case. + +Chosen: **top-of-column banner** — visible to all Messages users, +non-blocking, dismissable. + +## Success Metrics + +Amethyst has no telemetry. Qualitative signals only: + +- Zero GitHub issues about "banner won't go away" within 30 days. +- One or more community reports of users discovering + enabling the + lock via the banner (Nostr threads, Reddit, Discord). +- No reports of a lock-screen flash after enable (validates the + `onUnlockSuccess()` leniency + state-transition ordering). + +## Dependencies & Risks + +- **Depends on** the shipped privacy-lock feature (Phase 1 + Phase 5 + Desktop wiring already committed on this branch). Nothing new to + import. +- **Depends on** `firstRunCardSeen` field already in + `PrivacyLockSettings` — no new persistence needed. +- **Risk (low)**: extraction of `SetPasswordDialog` accidentally + changes behavior in the Settings pane. Mitigation: extract as + literal copy, replace call site, no logic change. +- **Risk (low)**: `onUnlockSuccess()` leniency affects existing + callers. Mitigation: only one existing caller + (`DesktopLockScreen`) and it calls this from `Locked`, so the new + semantics are backward-compatible. +- **Risk (very low)**: state race between the `setLockEnabled` init + collector and `onUnlockSuccess()`. Analysis above shows the end + state is deterministic; no lock-screen flash reaches the user + because `Unlocked` is set within the same UI recomposition batch. + +## Sources & References + +### Origin + +- **Parent plan**: + [`docs/plans/2026-06-30-feat-messaging-privacy-lock-plan.md`](2026-06-30-feat-messaging-privacy-lock-plan.md) + — the shipped feature this banner promotes. +- **Parent brainstorm**: + [`docs/brainstorms/2026-06-30-feat-messaging-privacy-lock-brainstorm.md`](../brainstorms/2026-06-30-feat-messaging-privacy-lock-brainstorm.md) + — the resolved-Q "First-run prompt = inline dismissable card at top + of Messages on first entry" that this banner implements. + +### Internal References + +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/components/OfflineBanner.kt` + — structural template (AnimatedVisibility + Surface + Row). +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt:270` + — the `SetPasswordDialog` to extract. +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt:82` + — the insertion point. +- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockState.kt` + — file for the `onUnlockSuccess()` leniency edit. +- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockSettings.kt:41,57` + — `firstRunCardSeen` + setter already exist. + +### Related Work + +- Manual testing sheet at + `docs/plans/2026-06-30-privacy-lock-manual-testing.md` — will need + a Path J (banner discovery) added post-implementation. diff --git a/docs/plans/2026-07-01-privacy-lock-security-review.md b/docs/plans/2026-07-01-privacy-lock-security-review.md new file mode 100644 index 0000000000..fd9a1c634c Binary files /dev/null and b/docs/plans/2026-07-01-privacy-lock-security-review.md differ