mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
feat(privacylock): Desktop wiring — gate, password unlock, settings
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.
This commit is contained in:
+12
@@ -40,6 +40,14 @@ interface PrivacyLockSettings {
|
||||
val redactionLevel: StateFlow<DmRedactionLevel>
|
||||
val firstRunCardSeen: StateFlow<Boolean>
|
||||
|
||||
/**
|
||||
* 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<String?>
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -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<String?>(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 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
|
||||
|
||||
+8
@@ -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<String?>(prefs.get(KEY_PASSWORD_HASHED, null))
|
||||
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) } },
|
||||
|
||||
+177
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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")
|
||||
}
|
||||
+88
@@ -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
|
||||
}
|
||||
}
|
||||
+10
-8
@@ -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 -> {
|
||||
|
||||
+360
@@ -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<String?>(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"
|
||||
}
|
||||
Reference in New Issue
Block a user