Merge pull request #3432 from nrobi144/feat/desktop-privacy-lock

feat(desktop): Privacy lock for Messages column
This commit is contained in:
Vitor Pamplona
2026-07-02 07:02:59 -04:00
committed by GitHub
23 changed files with 4021 additions and 33 deletions
@@ -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
}
}
@@ -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
}
}
@@ -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
}
@@ -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<LockState> = 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<MessagesLockState> {
error("LocalMessagesLockState not provided — wrap App() with CompositionLocalProvider")
}
@@ -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<Boolean>
val inactivityTimer: StateFlow<InactivityTimer>
val redactionLevel: StateFlow<DmRedactionLevel>
val firstRunCardSeen: StateFlow<Boolean>
/**
* 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<String?>
/**
* 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<Int>
/**
* 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<Long?>
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
}
}
@@ -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<CredentialPrompter> {
error("LocalCredentialPrompter not provided — wrap App() with platform CredentialPrompter")
}
@@ -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()
}
}
}
@@ -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")
}
}
}
}
@@ -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<String?>(null)
private val mutableFailedAttempts = MutableStateFlow(0)
private val mutableLockedUntil = MutableStateFlow<Long?>(null)
override val lockEnabled: StateFlow<Boolean> = mutableLockEnabled.asStateFlow()
override val inactivityTimer: StateFlow<InactivityTimer> = mutableTimer.asStateFlow()
override val redactionLevel: StateFlow<DmRedactionLevel> = mutableRedaction.asStateFlow()
override val firstRunCardSeen: StateFlow<Boolean> = mutableFirstRunSeen.asStateFlow()
override val passwordHashed: StateFlow<String?> = mutablePasswordHashed.asStateFlow()
override val failedUnlockAttempts: StateFlow<Int> = mutableFailedAttempts.asStateFlow()
override val lockedUntilEpochMs: StateFlow<Long?> = 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)
}
}
@@ -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<String?>(prefs.get(KEY_PASSWORD_HASHED, null))
private val mutableFailedAttempts = MutableStateFlow(prefs.getInt(KEY_FAILED_UNLOCK_ATTEMPTS, 0))
private val mutableLockedUntil =
MutableStateFlow<Long?>(
prefs.getLong(KEY_LOCKED_UNTIL_EPOCH_MS, -1L).takeIf { it > 0 },
)
override val lockEnabled: StateFlow<Boolean> = mutableEnabled.asStateFlow()
override val inactivityTimer: StateFlow<InactivityTimer> = mutableTimer.asStateFlow()
override val redactionLevel: StateFlow<DmRedactionLevel> = mutableRedaction.asStateFlow()
override val firstRunCardSeen: StateFlow<Boolean> = mutableFirstRunSeen.asStateFlow()
override val passwordHashed: StateFlow<String?> = mutablePasswordHashed.asStateFlow()
override val failedUnlockAttempts: StateFlow<Int> = mutableFailedAttempts.asStateFlow()
override val lockedUntilEpochMs: StateFlow<Long?> = 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)
}
}
@@ -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<com.vitorpamplona.amethyst.commons.tor.TorType>,
externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow<Int>,
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(
@@ -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"
}
@@ -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<PrivacyLockSettings> {
error("LocalPrivacyLockSettings not provided — wrap App() with CompositionLocalProvider")
}
@@ -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")
},
)
}
}
@@ -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>$<saltBase64>$<hashBase64>`:
*
* | 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
}
}
@@ -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<String?>(null) }
var newError by remember { mutableStateOf<String?>(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<String?>(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
},
)
}
}
@@ -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),
)
}
@@ -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 -> {
@@ -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"
}
@@ -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
ImmediateNever) 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<LockState>
│ ─ 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<LockState>` — 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<MessagesLockState> }`
- `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<Response>` 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<CChar>) -> 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<StateFlow<Boolean>>`.
Sourced from `window.addWindowFocusListener` events emitted into
a `MutableStateFlow`.
- `desktopApp/.../platform/WindowCaptureBlock.kt` —
`fun FrameWindowScope.applyWindowCaptureBlock(enabled: StateFlow<Boolean>)`:
- 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.
@@ -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 H2H6** — 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=<value>` 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 AI 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 H2H6 (crash scrub, log audit, MessagingStyle
history, NotificationListener redaction, bunker queue drain)
- Marmot group chat gating (once Marmot desktop UI lands)
@@ -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<Boolean>` 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.