Files
amethyst/docs/plans/2026-07-01-privacy-lock-security-review.md
nrobi144 72c3dff870 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.
2026-07-01 12:06:45 +03:00

270 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: Privacy Lock — Password Storage Security Review
type: review
status: active
date: 2026-07-01
---
# Privacy Lock — Password Storage Security Review
Review of `PasswordHasher.kt` and the prefs-file storage that backs
`PrivacyLockSettings.passwordHashed`. Companion to the shipped feature
at [`2026-06-30-feat-messaging-privacy-lock-plan.md`](2026-06-30-feat-messaging-privacy-lock-plan.md).
## What was reviewed
- `desktopApp/.../security/PasswordHasher.kt` (88 LOC)
- `commons/.../jvmAndroid/.../PreferencesPrivacyLockSettings.kt`
— specifically the `passwordHashed` field and its persistence path
- Storage: `java.util.prefs.Preferences.userRoot().node("com/vitorpamplona/amethyst/privacylock")`
- macOS: `~/Library/Preferences/com.apple.java.util.prefs.plist`
- Linux: `~/.java/.userPrefs/com/vitorpamplona/amethyst/privacylock/prefs.xml`
- Windows: `HKEY_CURRENT_USER\Software\JavaSoft\Prefs\...`
## Threat model (from the parent plan)
Defended: shoulder-surfing on an unattended-but-unlocked device.
Explicitly NOT defended: rooted device, live RAM extraction, hostile
forensics, attacker with filesystem access + unlimited compute.
## What's good
| # | Finding |
|---|---|
| G1 | Uses PBKDF2 (a proper KDF) rather than plain SHA hashing. |
| G2 | `SecureRandom` for salt generation. |
| G3 | 16-byte (128-bit) salt — meets OWASP recommendation. |
| G4 | 256-bit output key length — collision-resistant. |
| G5 | HmacSHA256 — modern PRF; not deprecated. |
| G6 | Constant-time comparison via XOR-OR loop — protects against timing side-channels. |
| G7 | `PBEKeySpec.clearPassword()` in `finally` block — clears the char array PBEKeySpec holds internally. |
| G8 | Fresh salt generated on every `hash()` call — no cross-user salt reuse. |
| G9 | Salt embedded in hash string via `$` separator — standard practice; salt doesn't need to be secret. |
| G10 | On `RemovePasswordDialog` success, the hash is fully removed from prefs (see `PrivacyLockSettingsScreen` toggle-off path). No stale hash persistence. |
## Findings
### 🟨 M1 — PBKDF2 iteration count is below current OWASP recommendation
**Severity:** MEDIUM
**Current:** `ITERATIONS = 100_000`
**Recommended:** `600_000` (OWASP Password Storage Cheat Sheet, 2023
revision, for PBKDF2-HMAC-SHA256).
**Impact:** With 100k iterations at ~50ms per verify:
- Weak human password (lowercase 6-char, `26^6 = 308M` combos) crackable
in **~10 minutes** on a single RTX 4090 (~500K PBKDF2-SHA256 h/s).
- 6-char mixed alphanumeric (`62^6 = 56B` combos) → ~31 hours single GPU;
~1 hour on modest hash farm.
At 600k iterations both times multiply by 6× — still not unbreakable, but
buys real time. The extra UX cost is ~250 ms of unlock latency (100k =
~50 ms, 600k = ~300 ms) — well within the tolerable range for a lock
users unlock a handful of times per session.
**Fix:** One-line change:
```kotlin
private const val ITERATIONS = 600_000
```
Existing hashes remain verifiable because iteration count is embedded
neither in the stored string nor the KDF spec — the verify path uses
the same iteration count. This means **bumping the constant retroactively
invalidates existing hashes**. To avoid forcing every existing user to
re-set: either (a) accept the invalidation and prompt for a new
password, or (b) add a versioned hash format (`v2$salt$hash`) and keep
`v1` at 100k during a migration window. See M2 for the versioned format.
### 🟨 M2 — No versioned hash format blocks smooth KDF migration
**Severity:** MEDIUM (blocks future security improvements)
**Current:** Hash format is `saltB64$hashB64` — no version prefix.
**Impact:** Migrating to a stronger KDF later (Argon2id, higher PBKDF2
iterations) requires either forcing every user to re-set their password
or ambiguous parsing.
**Fix:** Change format to `v1$saltB64$hashB64`. Update `verify()` to
switch on the prefix. Old bare-format hashes gracefully migrate on
next `hash()` call (which happens on Change/Remove).
### 🟨 M3 — No app-layer rate-limiting on verify attempts
**Severity:** MEDIUM
**Current:** `DesktopMessagesLockGate.LockScreen` and
`RemovePasswordDialog` both call `PasswordHasher.verify` synchronously
with no attempt counter, backoff, or lockout.
**Impact:** A UI attacker with brief physical access can attempt
~20 passwords per second (limited only by PBKDF2 verify time). Over 5
minutes of unattended access that's 6,000 attempts — enough to try
every 4-digit PIN, every English 3-letter word, all common patterns.
**Fix:** Add exponential backoff at the state-holder level. Suggested
schedule:
| Failures | Lockout |
|---|---|
| 1–4 | 0 s |
| 5 | 30 s |
| 6 | 60 s |
| 7 | 120 s |
| 8 | 300 s (5 min) |
| 9+ | 300 s (capped) |
Store `failedAttempts: Int` + `lockedUntilEpochMs: Long?` in
`PrivacyLockSettings`. Reset counter on successful unlock. Persist
across app restarts so reboot-loop doesn't reset. Add to
`MessagesLockState` as `val remainingLockoutMs: StateFlow<Long?>`.
Lock screen shows "Try again in 27 seconds" during lockout.
### 🟨 M4 — Hash stored in unencrypted user-readable prefs file
**Severity:** MEDIUM (documented threat model already excludes this)
**Current:** The hash lives in `java.util.prefs` — an unencrypted XML
file (Linux) or system-wide plist (macOS) or registry entry (Windows),
readable by ANY process running as the current user.
**Impact:** Any other process the user launches (a browser extension,
a compromised VS Code plugin, malware installed as user, another
Amethyst-writing process) can read the hash and mount an offline
attack at CPU/GPU speed.
The parent plan's Limitations copy already acknowledges "does not
protect against filesystem access" — this is not new information. But
it is worth surfacing explicitly to the user in the Settings pane copy
and possibly a link to a doc.
**Fix:** Not blocking — matches the accepted threat model. Consider:
- Update the "Limitations" card in `PrivacyLockSettingsScreen.kt` to
mention "your password hash is stored in your OS user preferences
file without additional encryption; anyone with access to your user
account can read it. Choose a strong password (12+ characters
recommended) or trust your OS-level disk encryption (FileVault /
BitLocker / LUKS)."
- Consider migrating storage to OS keyring (already used by
`SecureKeyStorage` for the nsec). But that's a larger refactor —
the keyring wraps binary payloads well but retrieval requires the
OS credential every time, undermining the "quick unlock" UX.
### 🟩 L1 — String-based password intake
**Severity:** LOW
**Current:** Compose `TextField` returns `String`. The value lives in
composable state until GC. Converted to `CharArray` before hashing but
the immutable String remains.
**Impact:** A memory-dump attacker can recover the password from
`Snapshot`-managed String references. Matches Signal Desktop, WhatsApp
Desktop, and every other JVM password field.
**Fix:** Not fixable in Compose today without going out-of-tree.
Accepted per the threat model (memory dumps are out of scope).
### 🟩 L2 — CharArray passed to `PasswordHasher` is not caller-side-wiped
**Severity:** LOW
**Current:** `PasswordField.value.toCharArray()` at call sites. The
returned `CharArray` is passed to `PasswordHasher.hash` / `.verify`,
then dropped. The array is not explicitly zeroed.
**Impact:** Char[] contents live until GC. Same class as L1.
**Fix:** Trivial:
```kotlin
val ca = value.toCharArray()
try { PasswordHasher.verify(ca, hash) } finally { ca.fill('') }
```
Wraps at every call site. Marginal defense-in-depth. Do it.
### 🟩 L3 — Old prefs persist after app uninstall
**Severity:** LOW
**Current:** `java.util.prefs` outlives app uninstall — the file/keys
remain until the OS user account is deleted or someone runs the
Amethyst prefs cleanup manually.
**Impact:** Reinstalling the app inherits an old privacy-lock
configuration (`lockEnabled=true` + stale hash) if the user forgot to
disable before uninstalling.
**Fix:** Document in the release notes. Consider adding a "Reset
privacy-lock data" affordance in Settings.
### 🟩 L4 — `RemovePasswordDialog` "Wrong password" reveals hash existence
**Severity:** LOW-INFORMATIONAL
**Current:** The dialog only appears when a hash exists (`stored != null`)
and shows a wrong-password error inline. This reveals hash existence to
someone who opens Settings.
**Impact:** Negligible — the Switch state (`lockEnabled=true`) already
reveals the lock is active. Hash existence is not a secret.
**Fix:** None needed.
## Comparison against similar apps (2026)
| App | KDF | Params | Rate-limit | At-rest |
|---|---|---|---|---|
| **Amethyst (current)** | PBKDF2-SHA256 | 100k iter, 16B salt | None | Unencrypted prefs |
| Signal Desktop (via SVR) | Argon2id | Enclave-tuned | 7-day after 10 fails | SGX enclave |
| Bitwarden Desktop | Argon2id | 3 iter, 64 MB, 4 par | None | LocalStorage |
| KeePassXC | Argon2id / AES-KDF | Auto-tuned to 1s | None | AES-256 whole DB |
| macOS FileVault | PBKDF2-SHA512 | ~100k | Progressive backoff | AES-XTS |
| **Amethyst (with fixes)** | PBKDF2-SHA256 | 600k iter, 16B salt | Exponential backoff | Unencrypted prefs (documented) |
## Priority summary
### P0 — ✅ done in this branch
1. **M1** — ✅ Bumped `ITERATIONS` 100k → 600k (`V1_ITERATIONS` in
`PasswordHasher.kt`). Legacy 100k hashes still verify via the
version prefix parse (M2).
2. **M3** — ✅ Exponential backoff wired end-to-end.
`PrivacyLockSettings.failedUnlockAttempts` + `lockedUntilEpochMs`
are persisted. `MessagesLockState.onFailedUnlockAttempt` /
`onUnlockAttemptResetToZero` own the schedule (5 fails → 30 s,
doubling, capped at 5 min). Lock screen + `RemovePasswordDialog`
both show "Try again in Ns" countdown and disable the primary
action during lockout. 4 unit tests cover threshold, base trip,
doubling + cap, reset on success.
### P1 — ✅ M2 done, M4 deferred
3. **M2** — ✅ Versioned hash format shipped. `hash()` writes
`v1$salt$hash`; `verify()` parses both bare (legacy 100k) and
`v1` (600k) formats. `isLegacyFormat()` helper exposed for
opportunistic re-hash callers.
4. **M4** — ⏸ Copy update deferred to a follow-up. Current Limitations
card already covers "filesystem access" broadly.
### P2 — nice to have
5. **L2** — Wipe CharArray on caller side after hash/verify.
6. Future migration to Argon2id (M2 unblocks this).
7. "Reset privacy-lock data" affordance in Settings.
## Verdict
The current implementation is **honest for the stated threat model
(shoulder-surf)** but leaves meaningful hardening on the table. The
biggest lever is P0-1 (iteration bump) which is a one-line change with
250 ms of extra unlock latency. The second-biggest is P0-2 (exponential
backoff) which is a small state-holder addition. Together these lift
the effective UI-attacker attempt rate from ~20 attempts/sec to
~3 attempts/min after 5 failures — a 400× improvement.
Nothing in this review is a "block merge" — every finding was already
implicitly acknowledged by the parent plan's threat-model framing. But
the P0 items are cheap and high-value; they should be scheduled as
either follow-up commits on this branch or a v1.1 PR.