Privacy-Period-Tracker/core/security/build.gradle.kts

36 lines
1.1 KiB
Plaintext
Raw Normal View History

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
plugins {
alias(libs.plugins.android.library)
}
android {
namespace = "dev.privacyllc.period.core.security"
compileSdk = 37
defaultConfig {
minSdk = 26
// AndroidKeyStore has no Robolectric shadow — 4.16.1 ships
// ShadowBiometricManager and ShadowKeyguardManager and no crypto shadows
// at all. So the key itself can only be tested on a device, and this
// module needs a runner where core/datastore does not.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
api(libs.androidx.datastore.preferences)
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.test.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(libs.kotlinx.coroutines.test)
}