App lock: a PIN just for this app, a fingerprint or face, or either #63

Closed
opened 2026-08-20 21:22:37 -05:00 by null · 1 comment
Owner

Asked for by Kaspa: 'leave it an option to either have biometrics or a custom app pin. Let it be known that the pin is custom to the app.'

What is true now. Biometrics already ship (androidx.biometric:1.1.0, app/build.gradle.kts:101; app/.../lock/BiometricUnlock.kt), but only as a shortcut layered on a MANDATORY PIN: the lock is on exactly when a PIN verifier exists (AppLockViewModel.kt:85-94) and biometricAllowed is biometricLockEnabled && hasPin (:97-101). The fingerprint toggle is rendered only inside the has-a-PIN branch (LockSettingsScreen.kt:136-163). There is no biometrics-only path. The biometric preference also lives in UserPreferences (core/datastore/.../UserPreferences.kt:48) — the store resetToDefaults() wipes — while the PIN record lives in its own app_lock DataStore precisely to avoid that.

What to do. A LockMethod { NONE, PIN, BIOMETRIC, PIN_AND_BIOMETRIC } stored in the app_lock store, resolved against the verifier so a method that needs a PIN without one reads as off. A one-time migration (hasPin && flag → PIN_AND_BIOMETRIC; hasPin → PIN; else NONE) that can never produce BIOMETRIC. Settings offers the four choices with one-line explanations; every method change first authenticates with the current method, and any target that allows biometrics needs a successful scan in-flow before it is committed. The lock screen in biometric-only mode shows one button and no PIN field.

The PIN copy must say what Kaspa asked for, in the consent screen and beside the option: this PIN is for this app only, separate from the phone's own PIN, pattern or password — knowing one does not open the other. The biometric copy must say who it lets in: anyone whose fingerprint or face is set up on this phone, now or later.

Traps. A migration that leaves someone locked out is a P0 — recordMigratedMethod must refuse BIOMETRIC at the API and write only inside a conditional DataStore transaction. In biometric-only mode an unavailable sensor must NOT auto-disable the lock (today BiometricUnlock.kt:85-110 persists the flag off on ERROR_NO_BIOMETRICS, which in biometric-only mode would let anyone who knows the phone PIN remove the fingerprints and walk in); stay locked and point at phone settings or the erase path. DEVICE_CREDENTIAL must never be requested — SECURITY.md:64-72 forbids it and PromptInfo.build() throws for it on API 28/29. A submitted PIN must never unlock a BIOMETRIC-mode lock even if a stale verifier exists. The settings biometric prompt must set authInProgress or the gate relocks and tears down Settings under the prompt.

Verify: LockMethodMigrationTest including the migration never produces fingerprint-only across all four legacy combinations (proved with scripts/prove-guard.sh), LockSettingsViewModelTest transition tests, and LockSettingsCopyTest asserting the PIN option names both 'app' and 'phone'.

Asked for by Kaspa: 'leave it an option to either have biometrics or a custom app pin. Let it be known that the pin is custom to the app.' **What is true now.** Biometrics already ship (`androidx.biometric:1.1.0`, `app/build.gradle.kts:101`; `app/.../lock/BiometricUnlock.kt`), but only as a shortcut layered on a MANDATORY PIN: the lock is on exactly when a PIN verifier exists (`AppLockViewModel.kt:85-94`) and `biometricAllowed` is `biometricLockEnabled && hasPin` (`:97-101`). The fingerprint toggle is rendered only inside the has-a-PIN branch (`LockSettingsScreen.kt:136-163`). There is no biometrics-only path. The biometric preference also lives in `UserPreferences` (`core/datastore/.../UserPreferences.kt:48`) — the store `resetToDefaults()` wipes — while the PIN record lives in its own `app_lock` DataStore precisely to avoid that. **What to do.** A `LockMethod { NONE, PIN, BIOMETRIC, PIN_AND_BIOMETRIC }` stored in the `app_lock` store, resolved against the verifier so a method that needs a PIN without one reads as off. A one-time migration (hasPin && flag → PIN_AND_BIOMETRIC; hasPin → PIN; else NONE) that can never produce BIOMETRIC. Settings offers the four choices with one-line explanations; every method change first authenticates with the current method, and any target that allows biometrics needs a successful scan in-flow before it is committed. The lock screen in biometric-only mode shows one button and no PIN field. The PIN copy must say what Kaspa asked for, in the consent screen and beside the option: this PIN is for this app only, separate from the phone's own PIN, pattern or password — knowing one does not open the other. The biometric copy must say who it lets in: anyone whose fingerprint or face is set up on this phone, now or later. **Traps.** A migration that leaves someone locked out is a P0 — `recordMigratedMethod` must refuse BIOMETRIC at the API and write only inside a conditional DataStore transaction. In biometric-only mode an unavailable sensor must NOT auto-disable the lock (today `BiometricUnlock.kt:85-110` persists the flag off on ERROR_NO_BIOMETRICS, which in biometric-only mode would let anyone who knows the phone PIN remove the fingerprints and walk in); stay locked and point at phone settings or the erase path. `DEVICE_CREDENTIAL` must never be requested — `SECURITY.md:64-72` forbids it and `PromptInfo.build()` throws for it on API 28/29. A submitted PIN must never unlock a BIOMETRIC-mode lock even if a stale verifier exists. The settings biometric prompt must set `authInProgress` or the gate relocks and tears down Settings under the prompt. Verify: `LockMethodMigrationTest` including `the migration never produces fingerprint-only` across all four legacy combinations (proved with `scripts/prove-guard.sh`), `LockSettingsViewModelTest` transition tests, and `LockSettingsCopyTest` asserting the PIN option names both 'app' and 'phone'.
null added this to the Batch 11 — Settings and the App Lock milestone 2026-08-20 21:22:37 -05:00
Author
Owner

Shipped across three commits: 9b2b332 (storage and migration), 750fdae (state machine and lock screen), f91ba6e (the choice on screen).

LockMethod { NONE, PIN, BIOMETRIC, PIN_AND_BIOMETRIC } lives in the lock's own DataStore, not UserPreferences — resetToDefaults() there is edit { clear() }, so a fingerprint-only lock recorded beside the theme would be one settings reset from vanishing. resolve() reads a verifier with no recorded method as PIN (so an old install's gate is shut from the first frame), a PIN-requiring method with no verifier as NONE (a lock nobody can open is worse than none), and an unknown name falls back to the verifier.

The migration can never produce BIOMETRIC: recordMigratedMethod refuses it at the API, and both its conditions are evaluated inside the DataStore transaction so a migration racing an erase cannot resurrect a removed lock. LockMethodMigrationTest walks all four legacy combinations asserting it.

Three rules with tests: any change authenticates with the method in force; a fingerprint is never enabled without one succeeding on that phone; moving to fingerprint-only drops the verifier AND the Keystore key only after that scan. In fingerprint-only mode an unavailable sensor does not switch the lock off — that would make it removable by anyone who knows the phone's own PIN — the screen names re-enrolment or erase instead.

The copy says what Kaspa asked for: 'A PIN just for this app — a PIN you choose here, separate from your phone's. Nobody can reset it.' LockSettingsCopyTest asserts the PIN option names both 'app' and 'phone', the fingerprint option names 'anyone', 'this phone' and 'later', and the whole surface avoids the sensitive-word list and the words safe/secure/encrypted/protected.

Verify: ./gradlew :core:security:testDebugUnitTest :app:testDebugUnitTest — AppLockRepositoryTest 24, LockMethodMigrationTest 9, LockSettingsViewModelTest 11, LockSettingsCopyTest 5, LockCopyTest 3, LockSettingsBackTest 4, ForgotPinBackTest 1, AppLockControllerTest 6, all green. Proved with scripts/prove-guard.sh: removing the migration's BIOMETRIC refusal, and making a failed scan commit, each redden exactly one test. Confirmed on the PeriodQA emulator — the four options render, and both biometric rows are disabled with 'this phone has no fingerprint or face set up', which is true of that image.

Shipped across three commits: 9b2b332 (storage and migration), 750fdae (state machine and lock screen), f91ba6e (the choice on screen). LockMethod { NONE, PIN, BIOMETRIC, PIN_AND_BIOMETRIC } lives in the lock's own DataStore, not UserPreferences — resetToDefaults() there is edit { clear() }, so a fingerprint-only lock recorded beside the theme would be one settings reset from vanishing. resolve() reads a verifier with no recorded method as PIN (so an old install's gate is shut from the first frame), a PIN-requiring method with no verifier as NONE (a lock nobody can open is worse than none), and an unknown name falls back to the verifier. The migration can never produce BIOMETRIC: recordMigratedMethod refuses it at the API, and both its conditions are evaluated inside the DataStore transaction so a migration racing an erase cannot resurrect a removed lock. LockMethodMigrationTest walks all four legacy combinations asserting it. Three rules with tests: any change authenticates with the method in force; a fingerprint is never enabled without one succeeding on that phone; moving to fingerprint-only drops the verifier AND the Keystore key only after that scan. In fingerprint-only mode an unavailable sensor does not switch the lock off — that would make it removable by anyone who knows the phone's own PIN — the screen names re-enrolment or erase instead. The copy says what Kaspa asked for: 'A PIN just for this app — a PIN you choose here, separate from your phone's. Nobody can reset it.' LockSettingsCopyTest asserts the PIN option names both 'app' and 'phone', the fingerprint option names 'anyone', 'this phone' and 'later', and the whole surface avoids the sensitive-word list and the words safe/secure/encrypted/protected. Verify: ./gradlew :core:security:testDebugUnitTest :app:testDebugUnitTest — AppLockRepositoryTest 24, LockMethodMigrationTest 9, LockSettingsViewModelTest 11, LockSettingsCopyTest 5, LockCopyTest 3, LockSettingsBackTest 4, ForgotPinBackTest 1, AppLockControllerTest 6, all green. Proved with scripts/prove-guard.sh: removing the migration's BIOMETRIC refusal, and making a failed scan commit, each redden exactly one test. Confirmed on the PeriodQA emulator — the four options render, and both biometric rows are disabled with 'this phone has no fingerprint or face set up', which is true of that image.
null closed this issue 2026-08-21 01:53:54 -05:00
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#63
No description provided.