Done in https://github.com/badbundle/vault-app/pull/641 (merged as e5eb9168).
API: VaultUnlockService has:
unlock(password:), which returns.unlocked,.wrongPasswordor.mustWait(Duration), always at the deadline;lock() async;hasMemoryHeadroomToUnlock(), for AutoFill.
Unlocking:
- Count the attempt with
AppLockPasswordAttemptCounterbefore deriving. Nothing is tried if the result is.delayedor the count fails. - Derive off the main actor. The password is normalised to NFC through a single
passwordKey(for: String). - Try all 16 slots, with no early exit.
- If several slots open, the most recently wrapped wins.
- A wrong password opens a random decoy body, so every attempt opens exactly one body.
- Hold until the fixed deadline, zero the keys, and reset the counter when any vault opens, duress included.
The deadline:
- It's raised only by work that's the same whatever the password: the derivation and the slot trials.
- That work is timed in thread CPU time, so time spent suspended doesn't count.
- Only a finished attempt that's still wanted raises it, it's capped at 5 s, and it's never lowered.
Locking: waits for any in-flight save, switches the session to locked and purges. An unlock only switches the session if it hasn't locked since the attempt began, so a lock always wins.
Memory check: on a device, 0 from os_proc_available_memory() means no headroom. The check reads only the header and the file size.
Tests:
- Spies give real and decoy bodies different costs and prove that real, duress and wrong passwords return at the same instant, with the same work.
- The tie-break, NFC, the counter, and each deadline rule.
- Lock racing the switch, cancellation, and the memory check.
Review: a security review found one blocker (an uncapped deadline that counted suspended time) and four should-fixes. All were fixed, and a re-review confirmed them. A last fix stops the saved deadline from reflecting the largest vault ever opened.