mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
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:
+34
@@ -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
|
||||
|
||||
+33
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+73
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+53
-13
@@ -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"
|
||||
}
|
||||
|
||||
+66
-17
@@ -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 {
|
||||
|
||||
+45
-6
@@ -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,
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user