From 72c3dff870d25ec4cff10e018930554fef7ee29f Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 1 Jul 2026 12:06:45 +0300 Subject: [PATCH] =?UTF-8?q?feat(privacylock):=20P0=20security=20hardening?= =?UTF-8?q?=20=E2=80=94=20600k=20iterations=20+=20backoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the P0 items in the security review at docs/plans/2026-07-01-privacy-lock-security-review.md. ## PBKDF2 iterations 100k → 600k (M1) via versioned hash format (M2) - New PasswordHasher storage format: `v1$saltB64$hashB64` (600k iterations, matches OWASP 2023 Password Storage Cheat Sheet for PBKDF2-HMAC-SHA256). - Legacy `saltB64$hashB64` (100k iterations) format still verifies correctly — no user gets locked out by the bump. - `hash()` always produces `v1$…`; users migrate to v1 opportunistically when they Change or Set a new password. - New `PasswordHasher.isLegacyFormat()` helper for callers that want to force-migrate on next successful unlock. - Verify cost goes from ~50ms → ~250ms on a modern laptop — well within tolerable UX for a lock users open a handful of times per session. ## Exponential backoff on failed unlock (M3) - `PrivacyLockSettings` gains `failedUnlockAttempts: StateFlow` and `lockedUntilEpochMs: StateFlow`, both persisted via java.util.prefs so a reboot cannot reset the backoff. - `MessagesLockState.onFailedUnlockAttempt(nowMs)` implements the schedule: no lockout for first 4 fails, then 30s / 60s / 120s / 300s (capped at 5 min). - `MessagesLockState.onUnlockSuccess()` transparently clears the attempt counter and any active lockout (also called from the banner-enable path). - DesktopLockScreen shows a countdown ("Try again in 27s") in the supportingText, disables the password field and Unlock button during lockout, ticks every 500ms via a LaunchedEffect. - RemovePasswordDialog inherits the same protection — Settings can't bypass the throttle by disabling the lock. - 4 new unit tests cover threshold behavior, base trip, doubling + cap, reset on success. All 13 tests green. ## Not in this commit - L1/L2 (String/CharArray memory retention) — out-of-tree fix in Compose; accepted per threat model. - L3 (post-uninstall prefs) — release-notes item. - M4 (Limitations copy update) — deferred; existing "does not protect against filesystem access" line already covers. --- .../commons/privacylock/MessagesLockState.kt | 34 +++++++ .../privacylock/PrivacyLockSettings.kt | 35 +++++++- .../privacylock/MessagesLockStateTest.kt | 73 +++++++++++++++ .../PreferencesPrivacyLockSettings.kt | 19 ++++ .../security/DesktopMessagesLockGate.kt | 66 +++++++++++--- .../desktop/security/PasswordHasher.kt | 83 ++++++++++++++---- .../desktop/security/SetPasswordDialog.kt | 51 +++++++++-- ...2026-07-01-privacy-lock-security-review.md | Bin 10758 -> 11313 bytes 8 files changed, 323 insertions(+), 38 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockState.kt index 966f6c1872..a4cfab66e6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockState.kt @@ -98,6 +98,9 @@ class MessagesLockState( mutableState.value = LockState.Unlocked restartIdleTimer() } + // Always clear failed-attempt state on any authenticated flow — even + // when transitioning from Disabled (banner "enable" path). + onUnlockAttemptResetToZero() } /** @@ -110,6 +113,37 @@ class MessagesLockState( 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 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 b3381c2563..39901fd790 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 @@ -42,12 +42,28 @@ interface PrivacyLockSettings { /** * Non-null when the user has set a password on this device. Value is - * `salt$hash` (both base64) — never a raw password. + * either `salt$hash` (legacy, 100k PBKDF2 iterations) or `v1$salt$hash` + * (current, 600k iterations) — never a raw password. * Platforms may use this differently: Android does not use it today * (biometric is authoritative); Desktop uses it as the unlock gate. */ val passwordHashed: StateFlow + /** + * Number of consecutive failed unlock attempts. Reset to 0 on successful + * unlock. Persisted across app restarts so a reboot cannot reset the + * exponential backoff (see [lockedUntilEpochMs]). + */ + val failedUnlockAttempts: StateFlow + + /** + * Epoch millis until which the user is locked out from attempting to + * unlock. `null` when no lockout is active. Persists across app + * restarts. Trip point is 5 consecutive failures; schedule doubles + * each subsequent failure and caps at 5 minutes. + */ + val lockedUntilEpochMs: StateFlow + fun setLockEnabled(enabled: Boolean) fun setInactivityTimer(timer: InactivityTimer) @@ -56,9 +72,13 @@ interface PrivacyLockSettings { fun setFirstRunCardSeen(seen: Boolean) - /** Store a `salt$hash` combined string; pass `null` to clear. */ + /** 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" @@ -67,5 +87,16 @@ interface PrivacyLockSettings { const val KEY_REDACTION_LEVEL = "redaction_level_ordinal" const val KEY_FIRST_RUN_CARD_SEEN = "first_run_card_seen" const val KEY_PASSWORD_HASHED = "password_hashed" + const val KEY_FAILED_UNLOCK_ATTEMPTS = "failed_unlock_attempts" + const val KEY_LOCKED_UNTIL_EPOCH_MS = "locked_until_epoch_ms" + + /** Threshold at which exponential backoff begins. */ + const val LOCKOUT_TRIP_AFTER_FAILURES = 5 + + /** Base lockout on the [LOCKOUT_TRIP_AFTER_FAILURES]th failure. Doubles each subsequent failure. */ + const val LOCKOUT_BASE_MS = 30_000L + + /** Max lockout duration after repeated failures. */ + const val LOCKOUT_MAX_MS = 5L * 60_000L } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockStateTest.kt index 699c82f05e..d3828ab2e6 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 @@ -42,12 +42,16 @@ class MessagesLockStateTest { private val mutableRedaction = MutableStateFlow(DmRedactionLevel.DEFAULT) private val mutableFirstRunSeen = MutableStateFlow(false) private val mutablePasswordHashed = MutableStateFlow(null) + private val mutableFailedAttempts = MutableStateFlow(0) + private val mutableLockedUntil = MutableStateFlow(null) override val lockEnabled: StateFlow = mutableLockEnabled.asStateFlow() override val inactivityTimer: StateFlow = mutableTimer.asStateFlow() override val redactionLevel: StateFlow = mutableRedaction.asStateFlow() override val firstRunCardSeen: StateFlow = mutableFirstRunSeen.asStateFlow() override val passwordHashed: StateFlow = mutablePasswordHashed.asStateFlow() + override val failedUnlockAttempts: StateFlow = mutableFailedAttempts.asStateFlow() + override val lockedUntilEpochMs: StateFlow = mutableLockedUntil.asStateFlow() override fun setLockEnabled(enabled: Boolean) { mutableLockEnabled.value = enabled @@ -68,6 +72,14 @@ class MessagesLockStateTest { 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 @@ -166,4 +178,65 @@ class MessagesLockStateTest { state.onUnlockSuccess() assertEquals(LockState.Unlocked, state.state.value) } + + @Test + fun failed_attempts_below_threshold_do_not_trip_lockout() = + runTest { + val settings = FakeSettings(lockEnabled = true) + val state = MessagesLockState(settings, backgroundScope) + val now = 1_000_000L + repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES - 1) { + assertEquals(null, state.onFailedUnlockAttempt(now)) + } + assertEquals(null, settings.lockedUntilEpochMs.value) + assertEquals( + PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES - 1, + settings.failedUnlockAttempts.value, + ) + } + + @Test + fun fifth_failure_trips_base_lockout() = + runTest { + val settings = FakeSettings(lockEnabled = true) + val state = MessagesLockState(settings, backgroundScope) + val now = 1_000_000L + repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) { + state.onFailedUnlockAttempt(now) + } + val expected = now + PrivacyLockSettings.LOCKOUT_BASE_MS + assertEquals(expected, settings.lockedUntilEpochMs.value) + } + + @Test + fun lockout_doubles_and_caps_at_maximum() = + runTest { + val settings = FakeSettings(lockEnabled = true) + val state = MessagesLockState(settings, backgroundScope) + val now = 1_000_000L + // 5th failure → base (30s) + repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) { state.onFailedUnlockAttempt(now) } + val base = settings.lockedUntilEpochMs.value!! - now + assertEquals(PrivacyLockSettings.LOCKOUT_BASE_MS, base) + // 6th → doubles to 60s + state.onFailedUnlockAttempt(now) + assertEquals(PrivacyLockSettings.LOCKOUT_BASE_MS * 2, settings.lockedUntilEpochMs.value!! - now) + // Enough further failures to hit the cap + repeat(20) { state.onFailedUnlockAttempt(now) } + assertEquals(PrivacyLockSettings.LOCKOUT_MAX_MS, settings.lockedUntilEpochMs.value!! - now) + } + + @Test + fun unlock_success_clears_backoff_state() = + runTest { + val settings = FakeSettings(lockEnabled = true) + val state = MessagesLockState(settings, backgroundScope) + val now = 1_000_000L + repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) { state.onFailedUnlockAttempt(now) } + assertTrue(settings.lockedUntilEpochMs.value != null) + assertTrue(settings.failedUnlockAttempts.value > 0) + state.onUnlockSuccess() + assertEquals(null, settings.lockedUntilEpochMs.value) + assertEquals(0, settings.failedUnlockAttempts.value) + } } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PreferencesPrivacyLockSettings.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PreferencesPrivacyLockSettings.kt index 0e42b58667..5622ee9863 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 @@ -21,8 +21,10 @@ 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 @@ -53,12 +55,19 @@ class PreferencesPrivacyLockSettings( MutableStateFlow(DmRedactionLevel.fromOrdinal(prefs.getInt(KEY_REDACTION_LEVEL, DmRedactionLevel.DEFAULT.ordinal))) private val mutableFirstRunSeen = MutableStateFlow(prefs.getBoolean(KEY_FIRST_RUN_CARD_SEEN, false)) private val mutablePasswordHashed = MutableStateFlow(prefs.get(KEY_PASSWORD_HASHED, null)) + private val mutableFailedAttempts = MutableStateFlow(prefs.getInt(KEY_FAILED_UNLOCK_ATTEMPTS, 0)) + private val mutableLockedUntil = + MutableStateFlow( + prefs.getLong(KEY_LOCKED_UNTIL_EPOCH_MS, -1L).takeIf { it > 0 }, + ) override val lockEnabled: StateFlow = mutableEnabled.asStateFlow() override val inactivityTimer: StateFlow = mutableTimer.asStateFlow() override val redactionLevel: StateFlow = mutableRedaction.asStateFlow() override val firstRunCardSeen: StateFlow = mutableFirstRunSeen.asStateFlow() override val passwordHashed: StateFlow = mutablePasswordHashed.asStateFlow() + override val failedUnlockAttempts: StateFlow = mutableFailedAttempts.asStateFlow() + override val lockedUntilEpochMs: StateFlow = mutableLockedUntil.asStateFlow() override fun setLockEnabled(enabled: Boolean) { mutableEnabled.value = enabled @@ -92,4 +101,14 @@ class PreferencesPrivacyLockSettings( mutablePasswordHashed.value = saltAndHash if (saltAndHash == null) prefs.remove(KEY_PASSWORD_HASHED) else prefs.put(KEY_PASSWORD_HASHED, saltAndHash) } + + override fun setFailedUnlockAttempts(count: Int) { + mutableFailedAttempts.value = count + prefs.putInt(KEY_FAILED_UNLOCK_ATTEMPTS, count) + } + + override fun setLockedUntilEpochMs(millis: Long?) { + mutableLockedUntil.value = millis + if (millis == null) prefs.remove(KEY_LOCKED_UNTIL_EPOCH_MS) else prefs.putLong(KEY_LOCKED_UNTIL_EPOCH_MS, millis) + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopMessagesLockGate.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopMessagesLockGate.kt index 437721041f..e4337d986e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopMessagesLockGate.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopMessagesLockGate.kt @@ -36,6 +36,7 @@ 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 @@ -52,6 +53,7 @@ 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 @@ -64,6 +66,9 @@ import com.vitorpamplona.amethyst.commons.privacylock.LockState * * 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) { @@ -83,20 +88,34 @@ fun DesktopMessagesLockGate(content: @Composable () -> Unit) { @Composable private fun DesktopLockScreen() { val lockState = LocalMessagesLockState.current - val settings = com.vitorpamplona.amethyst.desktop.security.LocalPrivacyLockSettings.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 = { - val ok = stored?.let { PasswordHasher.verify(input.toCharArray(), it) } == true - if (ok) { - input = "" - showError = false - lockState.onUnlockSuccess() - } else { - showError = true + 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()) + } } } @@ -152,6 +171,7 @@ private fun DesktopLockScreen() { label = { Text("Password") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), + enabled = remainingMs <= 0, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Password, @@ -160,18 +180,38 @@ private fun DesktopLockScreen() { keyboardActions = KeyboardActions(onDone = { submit() }), isError = showError, supportingText = - if (showError) { - { Text("Wrong password") } - } else { - null + 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()) { + Button( + onClick = submit, + enabled = input.isNotEmpty() && remainingMs <= 0, + ) { Text("Unlock") } } } } } + +private fun lockoutRemaining(untilEpochMs: Long?): Long { + val until = untilEpochMs ?: return 0 + val diff = until - System.currentTimeMillis() + return if (diff > 0) diff else 0 +} + +private fun formatCountdown(millis: Long): String { + val totalSeconds = (millis + 999) / 1000 + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return if (minutes > 0) "${minutes}m ${seconds}s" else "${seconds}s" +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/PasswordHasher.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/PasswordHasher.kt index 8ac8becb54..3283027f3c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/PasswordHasher.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/PasswordHasher.kt @@ -26,49 +26,98 @@ import javax.crypto.SecretKeyFactory import javax.crypto.spec.PBEKeySpec /** - * PBKDF2 password hashing for the Desktop privacy-lock PIN / password. + * 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. Stored form is - * `saltBase64$hashBase64` — never plaintext. + * uses for its master-password key derivation. * - * 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. + * ## Versioned hash format + * + * Stored form is `$$`: + * + * | Version | KDF | Iterations | Notes | + * |---|---|---|---| + * | (no prefix) | PBKDF2-HmacSHA256 | 100_000 | Legacy — verify-only for backward compat with hashes created before the OWASP 2023 bump. | + * | `v1` | PBKDF2-HmacSHA256 | 600_000 | Current — matches OWASP Password Storage Cheat Sheet (2023). | + * + * [hash] always produces `v1$…` format. [verify] handles both legacy + * bare-format and versioned inputs. Callers who want to opportunistically + * upgrade an old hash should re-call [hash] after a successful verify and + * persist the result. + * + * ## Cost tuning + * + * v1 at 600k iterations takes ~250 ms on a modern laptop (M1/M2 or + * Intel 12th gen). Users unlock a handful of times per session, so 250 ms + * is well within the tolerable range. At 20 attempts/second (verify + * throughput) an attacker with the on-disk hash needs ~150M attempts + * to exhaust a 6-char mixed-alpha space — orders of magnitude slower + * than the legacy 100k value. */ object PasswordHasher { private const val ALGORITHM = "PBKDF2WithHmacSHA256" - private const val ITERATIONS = 100_000 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) + val hash = pbkdf2(password, salt, V1_ITERATIONS) val encoder = Base64.getEncoder() - return "${encoder.encodeToString(salt)}\$${encoder.encodeToString(hash)}" + return "$V1_PREFIX$SEP${encoder.encodeToString(salt)}$SEP${encoder.encodeToString(hash)}" } fun verify( password: CharArray, stored: String, ): Boolean { - val parts = stored.split("$") - if (parts.size != 2) return false + val (iterations, saltB64, hashB64) = parse(stored) ?: 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) + 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) + val spec = PBEKeySpec(password, salt, iterations, KEY_LENGTH_BITS) try { return SecretKeyFactory.getInstance(ALGORITHM).generateSecret(spec).encoded } finally { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/SetPasswordDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/SetPasswordDialog.kt index 2efeba3dcc..39c50c426a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/SetPasswordDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/SetPasswordDialog.kt @@ -39,6 +39,7 @@ 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 @@ -61,6 +62,8 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState +import kotlinx.coroutines.delay /** Enforced minimum length for a new/rotated password. */ const val PRIVACY_LOCK_MIN_PASSWORD_LENGTH = 6 @@ -229,16 +232,34 @@ fun RemovePasswordDialog( onDismiss: () -> Unit, onConfirm: () -> Unit, ) { + val lockState = LocalMessagesLockState.current + val settings = LocalPrivacyLockSettings.current + val lockedUntil by settings.lockedUntilEpochMs.collectAsState() + var current by remember { mutableStateOf("") } var error by remember { mutableStateOf(null) } + var remainingMs by remember { mutableStateOf(lockoutRemainingMs(lockedUntil)) } val firstFieldFocus = remember { FocusRequester() } + LaunchedEffect(lockedUntil) { + while (true) { + val r = lockoutRemainingMs(lockedUntil) + remainingMs = r + if (r <= 0) break + delay(500) + } + } + val submit: () -> Unit = { - if (PasswordHasher.verify(current.toCharArray(), existingHash)) { - onConfirm() - } else { - error = "Wrong password" + if (remainingMs <= 0) { + if (PasswordHasher.verify(current.toCharArray(), existingHash)) { + lockState.onUnlockSuccess() // clears failed-attempt state + onConfirm() + } else { + error = "Wrong password" + lockState.onFailedUnlockAttempt(System.currentTimeMillis()) + } } } @@ -270,6 +291,11 @@ fun RemovePasswordDialog( color = MaterialTheme.colorScheme.onSurfaceVariant, ) + val effectiveError = + when { + remainingMs > 0 -> "Too many attempts. Try again in ${formatCountdownMs(remainingMs)}." + else -> error + } PasswordField( value = current, onValueChange = { @@ -277,7 +303,7 @@ fun RemovePasswordDialog( error = null }, label = "Current password", - errorMessage = error, + errorMessage = effectiveError, modifier = Modifier.focusRequester(firstFieldFocus), imeAction = ImeAction.Done, onImeAction = { submit() }, @@ -290,7 +316,7 @@ fun RemovePasswordDialog( TextButton(onClick = onDismiss) { Text("Cancel") } Button( onClick = submit, - enabled = current.isNotEmpty(), + enabled = current.isNotEmpty() && remainingMs <= 0, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.error, @@ -305,6 +331,19 @@ fun RemovePasswordDialog( } } +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, diff --git a/docs/plans/2026-07-01-privacy-lock-security-review.md b/docs/plans/2026-07-01-privacy-lock-security-review.md index 41e758df4d2b0463aeb448b6afa42846b61f4980..fd9a1c634cab2ba98ceb2f2ed61245b2d610d882 100644 GIT binary patch delta 1094 zcmZWp%Wl&^6kV|MS6rnPNjQU(J4h`1N+Px&HA5q%{c2fX@}d+zFHBQ8bF7-=B{9 zeYpPo9m2AgRZ*gZf4UL+_PYP@D;+QNA;p z2Y0>4qV$x&6G*~9m|%|krA_lGB>_b^gfj3Wk1~d2ibP_8)Pj29F}CL5f6N4)^6mx_ zYaX0+iiCbsS~Mi>cFk(e`? zz^rtt5Uy^GNPWmiR*tDaaLZOch6z+gdGJV{7>J|fjSl%FEe8!J#8G;u$_GlADoYsv zuR{-vN>!S&v2he_Y;3^E=&qI1aogVkOeY{QL&rf!(RSO4@x4}@uPemngswqmUBNCj zl&jzv=N5*$y#!WHG1nj{S&lYIXulbzk{A5HEoNBMHjpn%@N6C$(AA;N>o)g-OE&-s zbi>u?-5f}KkfOHw^#N08gb#2zYOW9;y_uq%7^`DRPL+W_+7M4*{%aKq=HkKkHd(Wd3MO9+4SXGM!p1=RX z+p!<8xmjO-x?11de)zmW8@Weye!j!=(@(t7p~P*VGw%YS=}g#CiFX{5Q`-W-U|e)z zi?2?)16E52!|d5vxUe_gStI#O-&~r8&ZLe~Su7`zizCRDwJI4nP!MN4YSZkvZNUMp zah@#3;JMksh)_RBDse|Ksb|U%riq*q4v~_Wn6E1`L37*DlSkzYWl_v# zGu%~E2U=EQ4zCedQ0EYRR}>c$-}1=!C5I`UMdpWg+Z(~@W?#*d@iNcb&C5fH>tJe6 z38`lm6^=4S%80$&My-N1{c^ItJX+rmS`p#B>zz!3gocL