SECURITY.md and AndroidKeyStoreMacProvider's KDoc both described a second,
biometric-bound Keystore key taking "the opposite policy" and invalidating
on enrolment. There is no such key: grep -rn BiometricKey found only those
two sentences. A successful fingerprint flips an in-memory session flag --
no CryptoObject, no key unwrap -- so the biometric path is a gate, not a
second secret.
A security document that describes protection the code does not have is
worse than one admitting the gap, because the next reader trusts it.
The absent version is not obviously wrong, so both places now say why it
is filed rather than built (#63): binding a key to biometric enrolment
means a partner adding their own fingerprint invalidates it, which is
exactly the protection somebody would want -- but it only degrades safely
where a PIN sits behind it. In a fingerprint-only lock, a key destroyed by
an ordinary enrolment is the no-recovery policy turned against its owner.
Also adds the six device rows the development log has claimed since Batch
06 were "rows in SECURITY_CHECKLIST.md rather than memory". They were
never added: a grep for biometric, fingerprint, TalkBack, rotation,
fontScale and flicker over that file returned nothing. A release gate that
silently skips half its checks is the failure the file exists to prevent.
closes#66
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three ways the lock could fail badly, all in its storage.
hasPin had no catch, unlike the equivalent flow in
UserPreferencesRepository, and it is collected on the startup path in two
places that cannot handle a throw: the gate's stateIn, which launches
outside its own exception handler, and MainActivity's FLAG_SECURE
collector. A corrupt app_lock file crashed the app before any UI existed
to say why.
It now reads as no lock. That is a decision, not a shrug: whoever can
corrupt that file has the app's private storage and therefore reads the
database this lock does not encrypt anyway, while the alternative is an
app that can be neither opened nor erased -- because the way out is behind
the lock that is broken. It is the rule VerifierRecord.decode already
applies to a single corrupt record, extended to the file.
The store also gains a corruption handler. Without one, a single bad write
leaves DataStore unable to read OR write it: the lock can never be set
again, and the "Forgot your PIN?" erase fails too, since it writes here.
And a wrong PIN now costs time even when the counter cannot be signed.
Signing needs the Keystore, which can be briefly unavailable; the write
returned early, so guessing was free for as long as that lasted -- the one
direction this must not fail in. The unwritten counter is held in memory
and read back whenever it is the longer wait. Not persisted: it is the
delay actually earned rather than the maximum a tampered counter earns,
and it is forgotten on process death, which is the bound a reboot already
gives an attacker.
Writing that test found the honest boundary. If the key is gone entirely
the PIN cannot be checked either, so the app reports that it does not know
rather than charging for a guess it could not read; the failure worth
defending against is the counter alone failing while the verifier still
works. The fake now tells those apart by the domain-separation tag the two
uses already carry.
Proved with prove-guard, one red each: dropping the in-memory counter, and
removing the catch.
closes#64
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Change PIN asked for the current one, and then moved to "Choose a PIN"
without waiting for the answer. The check is asynchronous; the screen
reassigned its step outside the result and passed an empty callback. Any
four digits reached the replacement screen, and setPin enrolls without
verifying anything.
So anyone holding the phone while it was unlocked could change the app's
PIN. Under the no-recovery policy the owner's only way back into her own
history is to erase all of it. The file's own KDoc says this must not be
possible.
The step now advances from inside the verified callback, as "turn the
lock off" already did -- and the ViewModel refuses a replacement that no
successful check authorised. Two guards for one rule on purpose: a screen
is the kind of file that gets rewritten by somebody who has not read the
one behind it, and the place that writes the PIN is the place that has to
refuse. Cancelling withdraws the permission; a successful write spends it.
"Turn the lock off" had the same advance-before-answer shape. It was safe
-- the work was already inside the callback -- but its wrong-PIN message
landed on a screen that had gone, so ConfirmPin.wrong was dead code. Fixed
symmetrically.
Also fixes the lock-out race in the same function (#62). setPin wrote the
PIN and then unlocked the session; the gate is combine(hasPin, unlocked)
and closes on (true, false), so DataStore's emission could arrive in
between. AppLockGate disposes the whole app subtree when it closes, this
ViewModel is scoped to a destination inside it, and the unlock was
cancelled with the scope -- the user was thrown to the lock screen to type
the PIN she had chosen a second earlier. The development log records the
common case as fixed; the fix lived in the scope the race destroyed.
Unlocking first makes the bad pair unobservable: unlock() sets a
MutableStateFlow synchronously on this thread, before the write begins,
and combine always emits with the latest of both. If the write fails there
is no PIN and Unlocked is correct anyway.
The settings ViewModel had no test at all, which is how a wrong PIN
reaching the replacement screen went unnoticed. It has five now, against
the real repository over a host-JVM signing key -- core/security gains a
small public two-argument constructor for that, since AndroidKeyStore
cannot be reached off-device and faking the repository would prove nothing
about it.
Proved with scripts/prove-guard.sh, one red each: spending the
authorisation, and the old write-then-unlock order. Removing the
write-site guard entirely reddens three, which is that guard's whole
surface rather than a coincidence.
closes#60closes#62
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§45 asks for biometric/PIN gating. UserPreferences.biometricLockEnabled has
existed since Batch 01 with nothing outside its own module reading it; this
wires it, and adds the rest.
The recovery question was the reason #34 sat open, and it is decided: there is
no recovery. A backdoor into a period tracker's lock would be used by exactly
the person the lock exists to stop. Everything below follows from that.
## The gate
AppLockGate wraps the whole composition rather than being a screen inside it.
Today, Calendar and Insights each start collecting from CycleRepository the
moment they compose, so a lock implemented as a nav destination would already
have read the history before the user proved anything. content() is invoked
only in the unlocked branch.
Re-lock on ON_STOP, not ON_PAUSE — pause fires for the shade, quick settings
and a permission dialog. Two guards on top: isChangingConfigurations, or
rotation and the fontScale-2.0 pass both re-lock; and authInProgress, or an OEM
biometric overlay that stops the activity produces a lock that can never be
opened. No grace period: SECURITY.md leads with "someone who picks up an
unlocked phone", which is the window a grace period covers.
The unlock flag lives in a @Singleton, never in saved state. rememberSaveable
looks like the obvious home and would restore a background-killed app already
unlocked — the single most likely way to meet the lock screen would be the one
path that skipped it.
## What is stored is not the PIN
mac = HMAC(keystoreKey, 0x01 || salt || PBKDF2-SHA256(pin, salt, 210k))
Two layers because they defend different things. The Keystore MAC is what makes
a six-digit PIN safe at all — a million candidates is nothing to an attacker who
can compute the hash, and impossible for one who cannot get the key off the
device. PBKDF2 underneath is for the day that assumption breaks. 0x01 is a
domain-separation tag; the lockout counter is MACed under 0x02.
The key omits six builder calls and the KDoc names every one. setUserAuthenti-
cationRequired is the important absence: it would bind the key to the device
lock, so changing a passcode would destroy it — and under no-recovery that is
somebody's whole history gone for an unrelated reason. It would also be a
bypass, since SECURITY.md already names "someone who knows the unlock PIN" as
an adversary. The biometric key is separate and takes the opposite policy,
where invalidation correctly degrades to "use your PIN".
## Wrong PINs cost time, never data
Four free attempts, then 30s/1m/2m/5m/15m, capped forever. No attempt limit and
no auto-wipe: under no-recovery an auto-wipe would let a partner, a child or a
pocket destroy a history while knowing nothing. Both clock bypasses are closed —
the wait is the longer of a wall-clock and a monotonic deadline, and a reboot
re-applies it in full, detected by elapsedRealtime going backwards.
## Two writes that had to move
Tapping "Not yet" on a reminder writes a NotYetObservation. That button is on
the phone's own lock screen, reachable by anybody, so the action is now parked
in AppLockController and applied only after an unlock — dropped if the session
never unlocks. Behaviour is unchanged when the lock is off.
The erase behind "Forgot your PIN?" deletes health data, then the Keystore key,
then the lock store. Skipping the middle step leaves the user erased AND still
locked out; prove-guard mutates that line out and requires exactly one red.
## Found by testing, not by review
- A fresh install began in a 15-minute lockout: "no counter yet" and "counter
was tampered with" were the same value. They are now distinct.
- Setting a PIN locked you out of the session you set it in. Found on the
emulator, not in a test.
- Kotlin block comments nest, so `domain/*` in a KDoc opens one. Twice.
## Verified
244 JVM tests, 0 skipped. KeystoreVerifierTest runs on PeriodMinSdk26 and
PeriodQA — including that PBKDF2WithHmacSHA256 exists at API 26, the one choice
here with no margin, and that the key is not auth-bound on either.
On device: wrong PIN refused, correct PIN opens, am kill then reopen lands on
the lock screen, turning the lock off requires the current PIN, and
`adb exec-out screencap` returns mean=0 stddev=0 — FLAG_SECURE is real.
androidx.biometric 1.1.0 is the newest stable (1.4.0 is alpha; biometric-ktx
never shipped one). It merges USE_BIOMETRIC and USE_FINGERPRINT, which failed
checkPermissions until they were allowed on purpose, and it drags fragment to
1.5.1 — pinned to 1.9.0 since MainActivity is now a FragmentActivity.
closes#34
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>