feat(privacylock): P0 security hardening — 600k iterations + backoff

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<Int>`
  and `lockedUntilEpochMs: StateFlow<Long?>`, 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.
This commit is contained in:
nrobi144
2026-07-01 12:06:45 +03:00
parent 0ff4ef1e10
commit 72c3dff870
8 changed files with 323 additions and 38 deletions
@@ -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
@@ -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<String?>
/**
* Number of consecutive failed unlock attempts. Reset to 0 on successful
* unlock. Persisted across app restarts so a reboot cannot reset the
* exponential backoff (see [lockedUntilEpochMs]).
*/
val failedUnlockAttempts: StateFlow<Int>
/**
* Epoch millis until which the user is locked out from attempting to
* unlock. `null` when no lockout is active. Persists across app
* restarts. Trip point is 5 consecutive failures; schedule doubles
* each subsequent failure and caps at 5 minutes.
*/
val lockedUntilEpochMs: StateFlow<Long?>
fun setLockEnabled(enabled: Boolean)
fun setInactivityTimer(timer: InactivityTimer)
@@ -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
}
}
@@ -42,12 +42,16 @@ class MessagesLockStateTest {
private val mutableRedaction = MutableStateFlow(DmRedactionLevel.DEFAULT)
private val mutableFirstRunSeen = MutableStateFlow(false)
private val mutablePasswordHashed = MutableStateFlow<String?>(null)
private val mutableFailedAttempts = MutableStateFlow(0)
private val mutableLockedUntil = MutableStateFlow<Long?>(null)
override val lockEnabled: StateFlow<Boolean> = mutableLockEnabled.asStateFlow()
override val inactivityTimer: StateFlow<InactivityTimer> = mutableTimer.asStateFlow()
override val redactionLevel: StateFlow<DmRedactionLevel> = mutableRedaction.asStateFlow()
override val firstRunCardSeen: StateFlow<Boolean> = mutableFirstRunSeen.asStateFlow()
override val passwordHashed: StateFlow<String?> = mutablePasswordHashed.asStateFlow()
override val failedUnlockAttempts: StateFlow<Int> = mutableFailedAttempts.asStateFlow()
override val lockedUntilEpochMs: StateFlow<Long?> = mutableLockedUntil.asStateFlow()
override fun setLockEnabled(enabled: Boolean) {
mutableLockEnabled.value = enabled
@@ -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)
}
}
@@ -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<String?>(prefs.get(KEY_PASSWORD_HASHED, null))
private val mutableFailedAttempts = MutableStateFlow(prefs.getInt(KEY_FAILED_UNLOCK_ATTEMPTS, 0))
private val mutableLockedUntil =
MutableStateFlow<Long?>(
prefs.getLong(KEY_LOCKED_UNTIL_EPOCH_MS, -1L).takeIf { it > 0 },
)
override val lockEnabled: StateFlow<Boolean> = mutableEnabled.asStateFlow()
override val inactivityTimer: StateFlow<InactivityTimer> = mutableTimer.asStateFlow()
override val redactionLevel: StateFlow<DmRedactionLevel> = mutableRedaction.asStateFlow()
override val firstRunCardSeen: StateFlow<Boolean> = mutableFirstRunSeen.asStateFlow()
override val passwordHashed: StateFlow<String?> = mutablePasswordHashed.asStateFlow()
override val failedUnlockAttempts: StateFlow<Int> = mutableFailedAttempts.asStateFlow()
override val lockedUntilEpochMs: StateFlow<Long?> = mutableLockedUntil.asStateFlow()
override fun setLockEnabled(enabled: Boolean) {
mutableEnabled.value = enabled
@@ -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)
}
}
@@ -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"
}
@@ -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>$<saltBase64>$<hashBase64>`:
*
* | Version | KDF | Iterations | Notes |
* |---|---|---|---|
* | (no prefix) | PBKDF2-HmacSHA256 | 100_000 | Legacy verify-only for backward compat with hashes created before the OWASP 2023 bump. |
* | `v1` | PBKDF2-HmacSHA256 | 600_000 | Current matches OWASP Password Storage Cheat Sheet (2023). |
*
* [hash] always produces `v1$` format. [verify] handles both legacy
* bare-format and versioned inputs. Callers who want to opportunistically
* upgrade an old hash should re-call [hash] after a successful verify and
* persist the result.
*
* ## Cost tuning
*
* v1 at 600k iterations takes ~250 ms on a modern laptop (M1/M2 or
* Intel 12th gen). Users unlock a handful of times per session, so 250 ms
* is well within the tolerable range. At 20 attempts/second (verify
* throughput) an attacker with the on-disk hash needs ~150M attempts
* to exhaust a 6-char mixed-alpha space orders of magnitude slower
* than the legacy 100k value.
*/
object PasswordHasher {
private const val ALGORITHM = "PBKDF2WithHmacSHA256"
private const val 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 {
@@ -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<String?>(null) }
var remainingMs by remember { mutableStateOf(lockoutRemainingMs(lockedUntil)) }
val firstFieldFocus = remember { FocusRequester() }
LaunchedEffect(lockedUntil) {
while (true) {
val r = lockoutRemainingMs(lockedUntil)
remainingMs = r
if (r <= 0) break
delay(500)
}
}
val submit: () -> Unit = {
if (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,