mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-30 07:37:38 +00:00
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.
270 lines
11 KiB
Markdown
270 lines
11 KiB
Markdown
---
|
||
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(' |