feat: app lock, with no way to reset a forgotten PIN
§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>
2026-08-19 04:02:47 -05:00
|
|
|
package dev.privacyllc.period.lock
|
|
|
|
|
|
|
|
|
|
import androidx.lifecycle.ViewModel
|
|
|
|
|
import androidx.lifecycle.viewModelScope
|
|
|
|
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
|
|
|
import dev.privacyllc.period.core.datastore.UserPreferencesRepository
|
|
|
|
|
import dev.privacyllc.period.core.security.AppLockRepository
|
|
|
|
|
import dev.privacyllc.period.core.security.UnlockResult
|
|
|
|
|
import dev.privacyllc.period.notifications.NotificationActionHandler
|
|
|
|
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
|
|
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
|
|
|
import kotlinx.coroutines.flow.SharingStarted
|
|
|
|
|
import kotlinx.coroutines.flow.StateFlow
|
|
|
|
|
import kotlinx.coroutines.flow.asStateFlow
|
|
|
|
|
import kotlinx.coroutines.flow.combine
|
|
|
|
|
import kotlinx.coroutines.flow.map
|
|
|
|
|
import kotlinx.coroutines.flow.stateIn
|
|
|
|
|
import kotlinx.coroutines.launch
|
|
|
|
|
import javax.inject.Inject
|
|
|
|
|
|
|
|
|
|
/** What the gate should show. */
|
|
|
|
|
sealed interface LockState {
|
|
|
|
|
/** The stores have not answered yet. Renders nothing — see [AppLockGate]. */
|
|
|
|
|
data object Unknown : LockState
|
|
|
|
|
|
|
|
|
|
data object Locked : LockState
|
|
|
|
|
|
|
|
|
|
data object Unlocked : LockState
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** What the lock screen should say right now. */
|
|
|
|
|
data class LockScreenState(
|
|
|
|
|
val checking: Boolean = false,
|
|
|
|
|
val wrong: Boolean = false,
|
|
|
|
|
val waitMillis: Long = 0L,
|
|
|
|
|
val keyUnavailable: Boolean = false,
|
|
|
|
|
val biometricOffered: Boolean = false,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The gate's state, and the one place a PIN is offered.
|
|
|
|
|
*
|
|
|
|
|
* ## The lock is on exactly when a PIN exists
|
|
|
|
|
*
|
|
|
|
|
* There is no separate "app lock enabled" flag, deliberately. Two records of one
|
|
|
|
|
* fact eventually disagree, and both ways of disagreeing are bad: a lock with no
|
|
|
|
|
* PIN can never be opened, and a PIN with the lock off protects nothing.
|
|
|
|
|
* `UserPreferences.biometricLockEnabled` survives as what its name says — whether
|
|
|
|
|
* a fingerprint may be used *instead of* the PIN — and it is meaningless without
|
|
|
|
|
* one, which is why it is combined with `hasPin` rather than read alone.
|
|
|
|
|
*/
|
|
|
|
|
@HiltViewModel
|
|
|
|
|
class AppLockViewModel @Inject constructor(
|
|
|
|
|
private val lock: AppLockRepository,
|
|
|
|
|
private val preferences: UserPreferencesRepository,
|
|
|
|
|
private val controller: AppLockController,
|
|
|
|
|
private val notificationActions: NotificationActionHandler,
|
|
|
|
|
) : ViewModel() {
|
|
|
|
|
|
|
|
|
|
/** Non-null while a notification action is waiting to be applied. */
|
|
|
|
|
val pendingNotificationAction: StateFlow<String?> = controller.pendingNotificationAction
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Apply a parked notification action, now that somebody has authenticated.
|
|
|
|
|
*
|
|
|
|
|
* Called only from the unlocked branch of the gate. Tapping "Not yet" on a
|
|
|
|
|
* reminder writes to the health record, and that button sits on the phone's
|
|
|
|
|
* own lock screen where anybody can reach it — so with an app lock on, the
|
|
|
|
|
* write waits for the unlock. A session that never unlocks never applies it.
|
|
|
|
|
*/
|
|
|
|
|
fun deliverPendingNotificationAction() {
|
|
|
|
|
val action = controller.takeNotificationAction() ?: return
|
|
|
|
|
viewModelScope.launch(handler) { notificationActions.handle(action) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private val _screen = MutableStateFlow(LockScreenState())
|
|
|
|
|
val screen: StateFlow<LockScreenState> = _screen.asStateFlow()
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* `Unknown` until both stores have answered.
|
|
|
|
|
*
|
|
|
|
|
* The initial value must not be `Unlocked` — that would flash the Today
|
|
|
|
|
* screen, with a forecast on it, before the lock had a chance to close.
|
|
|
|
|
*/
|
|
|
|
|
val state: StateFlow<LockState> = combine(
|
|
|
|
|
lock.hasPin,
|
|
|
|
|
controller.unlocked,
|
|
|
|
|
) { hasPin, unlocked ->
|
|
|
|
|
when {
|
|
|
|
|
!hasPin -> LockState.Unlocked
|
|
|
|
|
unlocked -> LockState.Unlocked
|
|
|
|
|
else -> LockState.Locked
|
|
|
|
|
}
|
|
|
|
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, LockState.Unknown)
|
|
|
|
|
|
|
|
|
|
/** True only when a fingerprint may stand in for the PIN, which needs a PIN to stand in for. */
|
|
|
|
|
val biometricAllowed: StateFlow<Boolean> = combine(
|
|
|
|
|
preferences.preferences.map { it.biometricLockEnabled },
|
|
|
|
|
lock.hasPin,
|
|
|
|
|
) { enabled, hasPin -> enabled && hasPin }
|
|
|
|
|
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
|
|
|
|
|
|
|
|
|
private val handler = CoroutineExceptionHandler { _, _ ->
|
|
|
|
|
// Never logged: `app` is in modulesSeeingHealthData and an exception on
|
|
|
|
|
// this path can carry key material. A failure here reads as "not now".
|
|
|
|
|
_screen.value = _screen.value.copy(checking = false, keyUnavailable = true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun submit(pin: CharArray) {
|
|
|
|
|
if (_screen.value.checking) return
|
|
|
|
|
_screen.value = _screen.value.copy(checking = true, wrong = false)
|
|
|
|
|
viewModelScope.launch(handler) {
|
|
|
|
|
when (val outcome = lock.check(pin)) {
|
|
|
|
|
is UnlockResult.Unlocked -> {
|
|
|
|
|
_screen.value = LockScreenState()
|
|
|
|
|
controller.unlock()
|
|
|
|
|
}
|
|
|
|
|
is UnlockResult.Wrong ->
|
|
|
|
|
_screen.value = LockScreenState(wrong = true, waitMillis = outcome.waitMillis)
|
|
|
|
|
is UnlockResult.TooSoon ->
|
|
|
|
|
_screen.value = LockScreenState(waitMillis = outcome.waitMillis)
|
|
|
|
|
is UnlockResult.KeyUnavailable ->
|
|
|
|
|
_screen.value = LockScreenState(keyUnavailable = true)
|
|
|
|
|
is UnlockResult.NoPin -> {
|
|
|
|
|
// The lock was turned off in another window; nothing to check.
|
|
|
|
|
_screen.value = LockScreenState()
|
|
|
|
|
controller.unlock()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-19 04:04:25 -05:00
|
|
|
pin.fill('\u0000')
|
feat: app lock, with no way to reset a forgotten PIN
§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>
2026-08-19 04:02:47 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Called after the biometric prompt succeeds. The PIN is not involved. */
|
|
|
|
|
fun unlockFromBiometric() {
|
|
|
|
|
_screen.value = LockScreenState()
|
|
|
|
|
controller.unlock()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Close the lock again, on leaving the app.
|
|
|
|
|
*
|
|
|
|
|
* The screen state is reset with it: a half-typed PIN and a "that is not the
|
|
|
|
|
* PIN" message must not still be on screen when the app is reopened, which
|
|
|
|
|
* would tell whoever opens it next that somebody was recently guessing.
|
|
|
|
|
*/
|
|
|
|
|
fun relock() {
|
|
|
|
|
controller.lock()
|
|
|
|
|
_screen.value = LockScreenState()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** True while the system biometric prompt is up; the gate must not re-lock under it. */
|
|
|
|
|
val authInProgress: Boolean get() = controller.authInProgress
|
|
|
|
|
|
|
|
|
|
fun beginBiometricAuth() {
|
|
|
|
|
controller.authInProgress = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun endBiometricAuth() {
|
|
|
|
|
controller.authInProgress = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Turn the fingerprint offer off, because the hardware says it cannot work.
|
|
|
|
|
*
|
|
|
|
|
* Only for `ERROR_NO_BIOMETRICS` and `ERROR_HW_NOT_PRESENT` — no enrolment
|
|
|
|
|
* left, or no sensor. A transient failure must not switch a setting the user
|
|
|
|
|
* chose; they would have to go and find it again to turn it back on.
|
|
|
|
|
*/
|
|
|
|
|
fun disableBiometric() {
|
|
|
|
|
viewModelScope.launch(handler) { preferences.setBiometricLockEnabled(false) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun refreshLockout() {
|
|
|
|
|
viewModelScope.launch(handler) {
|
|
|
|
|
val wait = lock.lockoutRemainingMillis()
|
|
|
|
|
_screen.value = _screen.value.copy(waitMillis = wait)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|