Biometric and PIN app lock #34
Labels
No Label
P0
P1
P2
release-blocker
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: null/Privacy-Period-Tracker#34
Loading…
Reference in New Issue
No description provided.
Delete Branch "%!s(<nil>)"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The app opens straight to cycle history on any unlocked phone. §45 requires biometric/PIN gating and it is the single most-requested privacy control in this category of app.
What is true now
Nothing gates launch.
MainActivityandnavigation/PeriodApp.ktroute from splash to onboarding or to the four tabs with nothing in between.UserPreferences.biometricLockEnabledexists incore/datastoreand no code outside that module reads it — the flag was added with the settings store and never wired.There is no biometric dependency in
gradle/libs.versions.toml, and noBiometricPromptor Keystore use anywhere in the tree.What it costs
This is the control that decides what a person sitting next to the user can see. Notification privacy already protects the lock screen; this protects the app once the phone is open, which is the other half of the same threat.
What to do, in order
androidx.biometric, checking the current stable version against its official source rather than trusting a number in this issue.Traps
FLAG_SECUREor a recents placeholder is part of this, not a follow-up.minSdkis 26 andBiometricPromptbehaviour differs across the range — this needs a run onPeriodMinSdk26, not only a modern emulator.Why filed and not fixed
It needs the Settings screen to exist first, and the recovery-path question is a product decision rather than an implementation detail.
Verify: With the lock enabled, a cold start and a return from recents both require biometric or PIN before any cycle data is visible; the PIN is not recoverable from DataStore or any file readable with
adb shell run-as; and the recents thumbnail shows no cycle data. Driven onPeriodMinSdk26and on API 36.Recovery decision: none, taken by the owner 2026-08-19
This issue was filed with the recovery story open — "decide the recovery story
before building the entry field" — and it is now decided.
A forgotten PIN is not recoverable. No reset, no escrow, no security
question, no support bypass. The reasoning is the same reasoning that makes the
lock worth having: a backdoor into a period tracker's lock is a backdoor, and
whoever the lock was protecting the user from is exactly who would use it.
This unblocks the issue. Recording what the decision implies, because these are
now requirements rather than options:
1. A forgotten PIN must not brick the app
No recovery means no route back to the data. It cannot mean no route back to
the app, or a forgotten PIN leaves an installed app that can never be opened
again. So the lock screen carries an explicit destructive escape: erase
everything and start over.
That is destruction, not recovery — it grants access to nothing. And it hands an
attacker no capability they lacked, because Android's own Settings → Clear
storage is available to anyone holding the phone regardless. What it adds is
honesty: the user finds out from us, in plain words, rather than by discovering
their history is gone.
2. Erasing must clear the lock too
The trap this creates: if the escape hatch runs
deleteAllHealthData()andleaves the Keystore key and PIN state behind, the user erases their entire
history and is still locked out. That is the worst outcome this issue can
produce and it must be covered by a test, not by care.
3. No Keystore configuration that a third party can invalidate
With no recovery, any key invalidation is permanent data loss. A verifier key
configured with
setInvalidatedByBiometricEnrollment— or otherwise invalidatedby a device-passcode change — would mean enrolling a new fingerprint silently
destroys access to your own history. Under a recovery policy that is a support
ticket; under this one it is unrecoverable. The safe configuration is being
established before any code is written.
4. Wrong attempts back off; they do not wipe
SECURITY.md's threat model names an intimate partner with physical access. Awipe-after-N-wrong-attempts policy would hand that exact person a way to destroy
someone's history from the lock screen without knowing anything. Exponential
backoff, never automatic destruction.
5. Informed consent at setup, not a surprise later
"No recovery" is only defensible if it is said plainly when the PIN is set,
in the app's own voice, before the user commits — not discovered on the day they
forget.
Design research is running now across the Keystore construction,
androidx.biometricacross API 26–36, where the gate sits relative to the nav host, recents/
FLAG_SECUREleakage, and the documents this fires. Implementation follows that.
Built in
1d8d7cc, with a follow-up infa1cfe5.What landed
New
:core:securitymodule — the verifier, the lockout policy and the lock's own DataStore, withemptySet()dependencies so it can never see a cycle date. The erase runs in:appfor exactly that reason.AppLockGatewraps the whole composition rather than being a screen inside it. Today, Calendar and Insights each start collecting fromCycleRepositorythe moment they compose, so a lock as a nav destination would have read the history before anybody proved anything.Evidence
244 JVM tests, 0 failures, 0 skipped.
checkModuleBoundaries(9 modules),checkNoHealthLogging(63 files — the count went up, so the new module really is being scanned) andcheckPermissionsall exit 0, the last against a freshly built release manifest.KeystoreVerifierTest, 5 tests, on both emulators:That covers the two things only a device can answer:
PBKDF2WithHmacSHA256genuinely exists at API 26 — the one algorithm choice here with no margin — and the Keystore key is not auth-bound at either level.Guard proved to fail, per GUARDS.md §1. Removing the
macs.deleteKey()line from the erase path:That is the catastrophic case: without it a user erases everything and is still locked out.
On device (API 36), driven through the real UI:
am killthen reopenadb exec-out screencapwhile locked1080x2400 mean=0 stddev=0— entirely blackThat last one is
FLAG_SECUREproved with a number rather than asserted.Decisions worth keeping
BIOMETRIC_STRONGonly, neverDEVICE_CREDENTIAL. In this threat model a partner very often knows the phone's PIN; accepting it would make this lock exactly as strong as the lock it sits behind. It also throws atbuild()on API 28/29.rememberSaveablewould restore a background-killed app already unlocked.Three defects found while building, all by testing rather than review
AppLockRepositoryTest.hasPinturned true. Found on the emulator, not in a test.AppLockViewModel.ktbinary to git. It compiled and every test passed; the only symptom wasBin 0 -> 7069 bytesin the commit summary. A source file git treats as binary produces no diff, so the file deciding whether the app is locked would have been unreviewable forever. Fixed infa1cfe5.Not verified, stated rather than implied
From this issue's own device list, these were not run and should be before release: a fingerprint enrolled on API 26/27 (
FingerprintDialogFragment), rotation and fontScale 2.0 while unlocked, a reminder action tapped while locked on a real device, an OEM biometric overlay that stops the activity, aFLAG_SECUREflicker on first launch, and TalkBack on the lock screen. The rows for them are inSECURITY_CHECKLIST.mdso they are a release gate rather than a memory.Documents updated in the same commit
architecture/README.md(module row, and why the third guard row fails silently),SECURITY.md(the "not built" claim was false; the permission set changed),SECURITY_CHECKLIST.md(five rows),design/README.md(the lock screen as the one semi-public surface),README.mdstatus.doc-claims.shexits 0 at 295 claimed paths.androidx.biometricis at 1.1.0, the newest stable — 1.4.0 is alpha-only andbiometric-ktxnever shipped a stable release, both confirmed against Google's Maven index rather than recalled. It mergesUSE_BIOMETRICandUSE_FINGERPRINT, which failedcheckPermissionsuntil they were allowed deliberately, and it draggedfragmentto 1.5.1 — pinned to 1.9.0, sinceMainActivityis now aFragmentActivity.