Biometric and PIN app lock #34

Closed
opened 2026-08-18 20:37:15 -05:00 by null · 2 comments
Owner

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. MainActivity and navigation/PeriodApp.kt route from splash to onboarding or to the four tabs with nothing in between. UserPreferences.biometricLockEnabled exists in core/datastore and 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 no BiometricPrompt or 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

  1. Add androidx.biometric, checking the current stable version against its official source rather than trusting a number in this issue.
  2. Gate the composition, not just a screen — the lock has to survive process death and re-entry from recents, so it belongs above the nav host.
  3. PIN as the fallback for devices with no enrolled biometric, with the PIN verifier Android Keystore-backed. §45 is explicit: never a value in DataStore.
  4. Surface both under §36's Privacy & Security.

Traps

  • A PIN stored in DataStore is not a lock, it is a string on the device. Keystore-backed, and never the PIN itself.
  • Recents screenshots leak. A locked app that shows its last frame in the task switcher has not locked anything; FLAG_SECURE or a recents placeholder is part of this, not a follow-up.
  • Do not lock the user out of their own data. A forgotten PIN with no recovery path and an irreversible-delete button next to it is a support nightmare; decide the recovery story before building the entry field.
  • minSdk is 26 and BiometricPrompt behaviour differs across the range — this needs a run on PeriodMinSdk26, 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 on PeriodMinSdk26 and on API 36.

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. `MainActivity` and `navigation/PeriodApp.kt` route from splash to onboarding or to the four tabs with nothing in between. `UserPreferences.biometricLockEnabled` exists in `core/datastore` and **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 no `BiometricPrompt` or 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 1. Add `androidx.biometric`, checking the current stable version against its official source rather than trusting a number in this issue. 2. Gate the composition, not just a screen — the lock has to survive process death and re-entry from recents, so it belongs above the nav host. 3. PIN as the fallback for devices with no enrolled biometric, with the PIN verifier **Android Keystore-backed**. §45 is explicit: never a value in DataStore. 4. Surface both under §36's Privacy & Security. ## Traps - **A PIN stored in DataStore is not a lock**, it is a string on the device. Keystore-backed, and never the PIN itself. - **Recents screenshots leak.** A locked app that shows its last frame in the task switcher has not locked anything; `FLAG_SECURE` or a recents placeholder is part of this, not a follow-up. - **Do not lock the user out of their own data.** A forgotten PIN with no recovery path and an irreversible-delete button next to it is a support nightmare; decide the recovery story before building the entry field. - `minSdk` is 26 and `BiometricPrompt` behaviour differs across the range — this needs a run on `PeriodMinSdk26`, 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 on `PeriodMinSdk26` and on API 36.
null added this to the Batch 06 — Privacy and Security milestone 2026-08-18 20:37:15 -05:00
null added the
P1
label 2026-08-18 20:37:15 -05:00
Author
Owner

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() and
leaves 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 invalidated
by 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. A
wipe-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.

"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.biometric
across API 26–36, where the gate sits relative to the nav host, recents/FLAG_SECURE
leakage, and the documents this fires. Implementation follows that.

## 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()` and leaves 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 invalidated by 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. A wipe-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.biometric` across API 26–36, where the gate sits relative to the nav host, recents/`FLAG_SECURE` leakage, and the documents this fires. Implementation follows that.
null closed this issue 2026-08-19 04:02:52 -05:00
Author
Owner

Built in 1d8d7cc, with a follow-up in fa1cfe5.

What landed

New :core:security module — the verifier, the lockout policy and the lock's own DataStore, with emptySet() dependencies so it can never see a cycle date. The erase runs in :app for exactly that reason.

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 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) and checkPermissions all exit 0, the last against a freshly built release manifest.

KeystoreVerifierTest, 5 tests, on both emulators:

Starting 5 tests on PeriodMinSdk26(AVD) - 8.0.0   tests=5 failures=0 errors=0
Starting 5 tests on PeriodQA(AVD) - 16            tests=5 failures=0 errors=0

That covers the two things only a device can answer: PBKDF2WithHmacSHA256 genuinely 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:

AppLockRepositoryTest > clearing the lock destroys the key, not just the record FAILED
1 test completed, 1 failed        →  prove-guard exit 0

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:

Check Result
Wrong PIN "That is not the PIN."
Correct PIN opens to Today with the forecast
am kill then reopen lands on the lock screen
Turning the lock off requires the current PIN
adb exec-out screencap while locked 1080x2400 mean=0 stddev=0 — entirely black

That last one is FLAG_SECURE proved with a number rather than asserted.

Decisions worth keeping

  • The lock is on exactly when a PIN exists. No second "enabled" flag — two records of one fact disagree eventually, and both ways of disagreeing are bad.
  • BIOMETRIC_STRONG only, never DEVICE_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 at build() on API 28/29.
  • Backoff never wipes. Four free attempts, then 30s→15m capped forever. Under no-recovery an auto-wipe would let a partner, a child or a pocket destroy a history while knowing nothing.
  • The unlock flag is never saved state. rememberSaveable would restore a background-killed app already unlocked.
  • Notification actions are parked until unlock. "Not yet" writes to the health record and sits on the phone's own lock screen.

Three defects found while building, all by testing rather than review

  1. 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. Caught by AppLockRepositoryTest.
  2. Setting a PIN locked you out of the session you set it in — the gate shut the instant hasPin turned true. Found on the emulator, not in a test.
  3. A stray NUL byte made AppLockViewModel.kt binary to git. It compiled and every test passed; the only symptom was Bin 0 -> 7069 bytes in 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 in fa1cfe5.

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, a FLAG_SECURE flicker on first launch, and TalkBack on the lock screen. The rows for them are in SECURITY_CHECKLIST.md so 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.md status. doc-claims.sh exits 0 at 295 claimed paths.

androidx.biometric is at 1.1.0, the newest stable — 1.4.0 is alpha-only and biometric-ktx never shipped a stable release, both confirmed against Google's Maven index rather than recalled. It merges USE_BIOMETRIC and USE_FINGERPRINT, which failed checkPermissions until they were allowed deliberately, and it dragged fragment to 1.5.1 — pinned to 1.9.0, since MainActivity is now a FragmentActivity.

Built in `1d8d7cc`, with a follow-up in `fa1cfe5`. ## What landed New `:core:security` module — the verifier, the lockout policy and the lock's own DataStore, with `emptySet()` dependencies so it can never see a cycle date. The erase runs in `:app` for exactly that reason. `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 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) and `checkPermissions` all exit 0, the last against a freshly built **release** manifest. **`KeystoreVerifierTest`, 5 tests, on both emulators:** ``` Starting 5 tests on PeriodMinSdk26(AVD) - 8.0.0 tests=5 failures=0 errors=0 Starting 5 tests on PeriodQA(AVD) - 16 tests=5 failures=0 errors=0 ``` That covers the two things only a device can answer: `PBKDF2WithHmacSHA256` genuinely 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: ``` AppLockRepositoryTest > clearing the lock destroys the key, not just the record FAILED 1 test completed, 1 failed → prove-guard exit 0 ``` 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:** | Check | Result | | --- | --- | | Wrong PIN | "That is not the PIN." | | Correct PIN | opens to Today with the forecast | | `am kill` then reopen | lands on the lock screen | | Turning the lock off | requires the current PIN | | `adb exec-out screencap` while locked | `1080x2400 mean=0 stddev=0` — entirely black | That last one is `FLAG_SECURE` proved with a number rather than asserted. ## Decisions worth keeping - **The lock is on exactly when a PIN exists.** No second "enabled" flag — two records of one fact disagree eventually, and both ways of disagreeing are bad. - **`BIOMETRIC_STRONG` only, never `DEVICE_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 at `build()` on API 28/29. - **Backoff never wipes.** Four free attempts, then 30s→15m capped forever. Under no-recovery an auto-wipe would let a partner, a child or a pocket destroy a history while knowing nothing. - **The unlock flag is never saved state.** `rememberSaveable` would restore a background-killed app already unlocked. - **Notification actions are parked until unlock.** "Not yet" writes to the health record and sits on the phone's own lock screen. ## Three defects found while building, all by testing rather than review 1. **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. Caught by `AppLockRepositoryTest`. 2. **Setting a PIN locked you out of the session you set it in** — the gate shut the instant `hasPin` turned true. Found on the emulator, not in a test. 3. **A stray NUL byte made `AppLockViewModel.kt` binary to git.** It compiled and every test passed; the only symptom was `Bin 0 -> 7069 bytes` in 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 in `fa1cfe5`. ## 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, a `FLAG_SECURE` flicker on first launch, and TalkBack on the lock screen. The rows for them are in `SECURITY_CHECKLIST.md` so 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.md` status. `doc-claims.sh` exits 0 at 295 claimed paths. `androidx.biometric` is at **1.1.0**, the newest **stable** — 1.4.0 is alpha-only and `biometric-ktx` never shipped a stable release, both confirmed against Google's Maven index rather than recalled. It merges `USE_BIOMETRIC` and `USE_FINGERPRINT`, which failed `checkPermissions` until they were allowed deliberately, and it dragged `fragment` to 1.5.1 — pinned to 1.9.0, since `MainActivity` is now a `FragmentActivity`.
Sign in to join this conversation.
No Label
P0
P1
P2
release-blocker
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: null/Privacy-Period-Tracker#34
No description provided.