From 1c0141aba15e7de3f0d4697a94162fcafc6bdd2f Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 1 Jul 2026 10:17:21 +0300 Subject: [PATCH] =?UTF-8?q?feat(privacylock):=20Desktop=20wiring=20?= =?UTF-8?q?=E2=80=94=20gate,=20password=20unlock,=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 (Desktop-only). Wraps the Messages deck column behind a PBKDF2-hashed password gate; drops the Android-app slice. - PrivacyLockSettings gains passwordHashed field + setter (salt$hash, base64). Backed by java.util.prefs on desktop. - PasswordHasher: PBKDF2-HmacSHA256, 100k iterations, 16-byte salt, 256-bit key, constant-time compare. Same primitive family as SecureKeyStorage. - DesktopMessagesLockGate: synchronous branch select in composition (no LaunchedEffect guard) — closes the deep-link race per plan §Security Hardening H1. Renders content when Disabled/Unlocked; renders inline password TextField when Locked. Fires MessagesLockState.onLeaveRoute() in DisposableEffect onDispose so navigating away from the Messages column re-locks immediately. - DesktopMessagesLockGate handles the "no password set" edge case with a Disable-lock affordance. - LocalPrivacyLockSettings CompositionLocal + LocalMessagesLockState (from commons) both provided once at the App composition root in Main.kt. Constructed with the existing windowScope so the state holder's idle timer coroutines are lifecycle-scoped to the Window. - DeckColumnContainer: DesktopMessagesScreen wrapped in DesktopMessagesLockGate for the Messages column. - Desktop PrivacyLockSettingsScreen: Column + Card layout matching LocalRelaySettingsScreen (no Scaffold). Toggle, "Change password" affordance with a full set/change dialog (old + new + confirm), inactivity timer dropdown (1m / 5m / 15m / 1h / Never), redaction level dropdown (Hidden / Full), honest limitations copy. Auto-opens the set-password dialog if user toggles ON with no password set. - Slotted into the existing Settings pane in Main.kt right after LocalRelaySettings. --- .../privacylock/PrivacyLockSettings.kt | 12 + .../privacylock/MessagesLockStateTest.kt | 6 + .../PreferencesPrivacyLockSettings.kt | 8 + .../vitorpamplona/amethyst/desktop/Main.kt | 24 ++ .../security/DesktopMessagesLockGate.kt | 177 +++++++++ .../security/LocalPrivacyLockSettings.kt | 30 ++ .../desktop/security/PasswordHasher.kt | 88 +++++ .../desktop/ui/deck/DeckColumnContainer.kt | 18 +- .../ui/settings/PrivacyLockSettingsScreen.kt | 360 ++++++++++++++++++ 9 files changed, 715 insertions(+), 8 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopMessagesLockGate.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/LocalPrivacyLockSettings.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/PasswordHasher.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockSettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockSettings.kt index c8420ca2e3..b3381c2563 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockSettings.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockSettings.kt @@ -40,6 +40,14 @@ interface PrivacyLockSettings { val redactionLevel: StateFlow val firstRunCardSeen: StateFlow + /** + * Non-null when the user has set a password on this device. Value is + * `salt$hash` (both base64) — 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 + fun setLockEnabled(enabled: Boolean) fun setInactivityTimer(timer: InactivityTimer) @@ -48,6 +56,9 @@ interface PrivacyLockSettings { fun setFirstRunCardSeen(seen: Boolean) + /** Store a `salt$hash` combined string; pass `null` to clear. */ + fun setPasswordHashed(saltAndHash: String?) + companion object { const val DEFAULT_LOCK_ENABLED = false const val NODE_NAME = "com/vitorpamplona/amethyst/privacylock" @@ -55,5 +66,6 @@ interface PrivacyLockSettings { 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" } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockStateTest.kt index 484fab4c73..8e59ed7a67 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockStateTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockStateTest.kt @@ -41,11 +41,13 @@ class MessagesLockStateTest { private val mutableTimer = MutableStateFlow(timer) private val mutableRedaction = MutableStateFlow(DmRedactionLevel.DEFAULT) private val mutableFirstRunSeen = MutableStateFlow(false) + private val mutablePasswordHashed = MutableStateFlow(null) override val lockEnabled: StateFlow = mutableLockEnabled.asStateFlow() override val inactivityTimer: StateFlow = mutableTimer.asStateFlow() override val redactionLevel: StateFlow = mutableRedaction.asStateFlow() override val firstRunCardSeen: StateFlow = mutableFirstRunSeen.asStateFlow() + override val passwordHashed: StateFlow = mutablePasswordHashed.asStateFlow() override fun setLockEnabled(enabled: Boolean) { mutableLockEnabled.value = enabled @@ -62,6 +64,10 @@ class MessagesLockStateTest { override fun setFirstRunCardSeen(seen: Boolean) { mutableFirstRunSeen.value = seen } + + override fun setPasswordHashed(saltAndHash: String?) { + mutablePasswordHashed.value = saltAndHash + } } @Test diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PreferencesPrivacyLockSettings.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PreferencesPrivacyLockSettings.kt index de988dbd60..0e42b58667 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PreferencesPrivacyLockSettings.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PreferencesPrivacyLockSettings.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.Compan 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_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 @@ -51,11 +52,13 @@ class PreferencesPrivacyLockSettings( private val mutableRedaction = MutableStateFlow(DmRedactionLevel.fromOrdinal(prefs.getInt(KEY_REDACTION_LEVEL, DmRedactionLevel.DEFAULT.ordinal))) private val mutableFirstRunSeen = MutableStateFlow(prefs.getBoolean(KEY_FIRST_RUN_CARD_SEEN, false)) + private val mutablePasswordHashed = MutableStateFlow(prefs.get(KEY_PASSWORD_HASHED, null)) override val lockEnabled: StateFlow = mutableEnabled.asStateFlow() override val inactivityTimer: StateFlow = mutableTimer.asStateFlow() override val redactionLevel: StateFlow = mutableRedaction.asStateFlow() override val firstRunCardSeen: StateFlow = mutableFirstRunSeen.asStateFlow() + override val passwordHashed: StateFlow = mutablePasswordHashed.asStateFlow() override fun setLockEnabled(enabled: Boolean) { mutableEnabled.value = enabled @@ -84,4 +87,9 @@ class PreferencesPrivacyLockSettings( 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) + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 1ae39ba960..cfb810b772 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -291,6 +291,21 @@ fun main() { // Callback set by App() for single pane navigation from MenuBar var navigateToScreen by remember { mutableStateOf<((DeckColumnType) -> Unit)?>(null) } + // Messages privacy lock: app-global settings + state holder. Initial + // LockState is seeded synchronously inside MessagesLockState from + // the java.util.prefs value so the first composition sees the correct + // state (deep-link race fix, plan §Security Hardening H1). + val privacyLockSettings = + remember { + com.vitorpamplona.amethyst.commons.privacylock + .PreferencesPrivacyLockSettings() + } + val messagesLockState = + remember { + com.vitorpamplona.amethyst.commons.privacylock + .MessagesLockState(privacyLockSettings, windowScope) + } + // 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. @@ -602,6 +617,8 @@ fun main() { LocalWindowState provides windowState, LocalAwtWindow provides window, LocalIsImmersiveFullscreen provides immersiveFullscreenState, + com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState provides messagesLockState, + com.vitorpamplona.amethyst.desktop.security.LocalPrivacyLockSettings provides privacyLockSettings, ) { key(appRestartKey) { CompositionLocalProvider( @@ -2074,6 +2091,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)) + val logoutScope = rememberCoroutineScope() OutlinedButton( onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopMessagesLockGate.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopMessagesLockGate.kt new file mode 100644 index 0000000000..437721041f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopMessagesLockGate.kt @@ -0,0 +1,177 @@ +/* + * 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.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 + +/** + * 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). + */ +@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 = com.vitorpamplona.amethyst.desktop.security.LocalPrivacyLockSettings.current + val stored by settings.passwordHashed.collectAsState() + + var input by remember { mutableStateOf("") } + var showError by remember { mutableStateOf(false) } + + val submit: () -> Unit = { + val ok = stored?.let { PasswordHasher.verify(input.toCharArray(), it) } == true + if (ok) { + input = "" + showError = false + lockState.onUnlockSuccess() + } else { + showError = true + } + } + + 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(), + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { submit() }), + isError = showError, + supportingText = + if (showError) { + { Text("Wrong password") } + } else { + null + }, + modifier = Modifier.widthIn(max = 320.dp), + ) + Box(modifier = Modifier.size(16.dp)) + Button(onClick = submit, enabled = input.isNotEmpty()) { + Text("Unlock") + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/LocalPrivacyLockSettings.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/LocalPrivacyLockSettings.kt new file mode 100644 index 0000000000..6950bb366c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/LocalPrivacyLockSettings.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.security + +import androidx.compose.runtime.compositionLocalOf +import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings + +/** Provided once at the Desktop App root alongside LocalMessagesLockState. */ +val LocalPrivacyLockSettings = + compositionLocalOf { + error("LocalPrivacyLockSettings not provided — wrap App() with CompositionLocalProvider") + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/PasswordHasher.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/PasswordHasher.kt new file mode 100644 index 0000000000..8ac8becb54 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/PasswordHasher.kt @@ -0,0 +1,88 @@ +/* + * 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 PIN / password. + * + * Same primitive family as [com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage] + * uses for its master-password key derivation. Stored form is + * `saltBase64$hashBase64` — never plaintext. + * + * Iteration count chosen to be light-enough for an interactive unlock (~50ms + * on modern hardware) while providing meaningful throttling on brute-force + * attempts on the on-disk hash. + */ +object PasswordHasher { + private const val ALGORITHM = "PBKDF2WithHmacSHA256" + private const val ITERATIONS = 100_000 + private const val KEY_LENGTH_BITS = 256 + private const val SALT_LENGTH_BYTES = 16 + + fun hash(password: CharArray): String { + val salt = ByteArray(SALT_LENGTH_BYTES).also { SecureRandom().nextBytes(it) } + val hash = pbkdf2(password, salt) + val encoder = Base64.getEncoder() + return "${encoder.encodeToString(salt)}\$${encoder.encodeToString(hash)}" + } + + fun verify( + password: CharArray, + stored: String, + ): Boolean { + val parts = stored.split("$") + if (parts.size != 2) return false + val decoder = Base64.getDecoder() + val salt = + runCatching { decoder.decode(parts[0]) }.getOrNull() ?: return false + val expected = + runCatching { decoder.decode(parts[1]) }.getOrNull() ?: return false + val computed = pbkdf2(password, salt) + return constantTimeEquals(computed, expected) + } + + private fun pbkdf2( + password: CharArray, + salt: ByteArray, + ): ByteArray { + val spec = PBEKeySpec(password, salt, ITERATIONS, KEY_LENGTH_BITS) + try { + return SecretKeyFactory.getInstance(ALGORITHM).generateSecret(spec).encoded + } finally { + spec.clearPassword() + } + } + + private fun constantTimeEquals( + a: ByteArray, + b: ByteArray, + ): Boolean { + if (a.size != b.size) return false + var diff = 0 + for (i in a.indices) diff = diff or (a[i].toInt() xor b[i].toInt()) + return diff == 0 + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index ee85ce8122..6f4affcabc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -361,14 +361,16 @@ internal fun RootContent( } DeckColumnType.Messages -> { - DesktopMessagesScreen( - account = iAccount, - cacheProvider = localCache, - relayManager = relayManager, - localCache = localCache, - compactMode = compactMode, - onNavigateToProfile = onNavigateToProfile, - ) + com.vitorpamplona.amethyst.desktop.security.DesktopMessagesLockGate { + DesktopMessagesScreen( + account = iAccount, + cacheProvider = localCache, + relayManager = relayManager, + localCache = localCache, + compactMode = compactMode, + onNavigateToProfile = onNavigateToProfile, + ) + } } DeckColumnType.Search -> { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt new file mode 100644 index 0000000000..520fab9e9f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt @@ -0,0 +1,360 @@ +/* + * 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.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.AlertDialog +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.OutlinedTextField +import androidx.compose.material3.Switch +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.text.input.PasswordVisualTransformation +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.PasswordHasher + +/** + * Desktop privacy-lock settings pane. Column + Card layout (no Scaffold) — + * matches `LocalRelaySettingsScreen`. + */ +@Composable +fun PrivacyLockSettingsScreen() { + val settings = LocalPrivacyLockSettings.current + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + LockToggleCard(settings) + InactivityCard(settings) + RedactionCard(settings) + LimitationsCard() + } +} + +@Composable +private fun LockToggleCard(settings: PrivacyLockSettings) { + val enabled by settings.lockEnabled.collectAsState() + val stored by settings.passwordHashed.collectAsState() + var showSetPassword 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 { + settings.setLockEnabled(false) + } + }, + ) + } + if (enabled && stored != null) { + Row { + OutlinedButton(onClick = { showSetPassword = true }) { + Text("Change password") + } + } + } + } + + if (showSetPassword) { + SetPasswordDialog( + existingHash = stored, + onDismiss = { + showSetPassword = false + pendingEnable = false + }, + onConfirm = { newHash -> + settings.setPasswordHashed(newHash) + if (pendingEnable) settings.setLockEnabled(true) + showSetPassword = false + pendingEnable = false + }, + ) + } +} + +@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) + } + } +} + +@Composable +private fun SetPasswordDialog( + existingHash: String?, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var current by remember { mutableStateOf("") } + var new1 by remember { mutableStateOf("") } + var new2 by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + + val submit: () -> Unit = { + val currentOk = + existingHash == null || + PasswordHasher.verify(current.toCharArray(), existingHash) + when { + !currentOk -> error = "Current password is wrong" + new1.length < 4 -> error = "New password must be at least 4 characters" + new1 != new2 -> error = "Passwords don't match" + else -> onConfirm(PasswordHasher.hash(new1.toCharArray())) + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(if (existingHash == null) "Set a password" else "Change password") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (existingHash != null) { + OutlinedTextField( + value = current, + onValueChange = { + current = it + error = null + }, + label = { Text("Current password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + ) + } + OutlinedTextField( + value = new1, + onValueChange = { + new1 = it + error = null + }, + label = { Text("New password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + ) + OutlinedTextField( + value = new2, + onValueChange = { + new2 = it + error = null + }, + label = { Text("Confirm new password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + ) + error?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + }, + confirmButton = { + TextButton(onClick = submit) { Text("Save") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +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" + }