ADR-0012: App lock
- Status: Accepted
- Date: 2026-08-11
- Deciders: maintainers
- Related: security.md
Context
The app holds sensitive financial history. Users want a PIN/biometric gate against casual access, with protection against brute force and relock when the app leaves the foreground.
Decision
An AppLockManager (singleton, DefaultLifecycleObserver) gates the UI:
- PIN storage — iterated PBKDF2-HMAC-SHA256 digest (120 000 iterations)
in the encrypted
settingstable; salt regenerated on every PIN change so identical PINs never share a digest; verified withconstantTimeEquals. Legacy single-round SHA-256 digests verify once and are upgraded in place. - PIN length — new PINs are at least 6 digits; the selector offers 6/8.
- Session unlock — per-process flag with an idle timeout
(
KEY_LOCK_TIMEOUT_MINUTES, default 5 min; 0 = never). - Monotonic time — idle and lockout deadlines use
SystemClock.elapsedRealtime()viaLockClock; changing the wall clock cannot skip a lockout or disable auto-lock. - Brute-force lockout — after
MAX_FAILED_ATTEMPTS = 5failures the lockout starts at 30 s and doubles per further failure, capped at 30 min; the counter is serialized so concurrent attempts cannot race it. The lock screen shows a countdown. - Lifecycle relock —
onStoprelocks (registered onProcessLifecycleOwner). - Biometric — optional, WEAK/STRONG selectable, optional device-credential
fallback; availability probed via
BiometricManager; prompt hosted inMainActivity, requested once per lock session. - Testability — depends on the
SettingsStoreseam and injectableLockClock, so security logic is unit-tested pure JVM.
Implementation note (2026-09, issue #95): the idle deadline is
re-evaluated while the app stays in the foreground by a 15 s ticker in
AppLockViewModel (viewModelScope, skipped while locked); navigation
destination changes call AppLockManager.recordActivity() so the timeout
measures inactivity, not wall time since unlock. The ticker lives in the
ViewModel because common/ stays Android-free and viewModelScope is
cancelled with the composition automatically.
Implementation note (2026-10, issues #370/#371): the custom PIN dots are
backed by BasicTextField with PasswordVisualTransformation rather than
transparent ordinary text, so assistive technology receives password
semantics and copy/cut actions are suppressed; the field also announces the
entered digit count without exposing values. Hardware and keypad entry still
share one filtered value. The retry line renders MAX_FAILED_ATTEMPTS -
failedAttempts as a polite live region, while the fifth-failure lockout
threshold and escalating duration are unchanged; a successful PIN
verification clears the counter.
The PIN/settings live in the already-encrypted DB (settings table), not in SharedPreferences.
Consequences
- Brute force is throttled; identical PINs across resets do not share digests; leaving the app relocks.
- Lock screen overlays the whole UI (rendered in
MainActivityabove the NavHost) and appears only after onboarding. - Settings expose PIN length (6/8), auto-lock interval, biometric strength, and credential fallback.
Alternatives considered
- Keystore-backed auth for PIN — rejected: needs a confirmed-credential round trip; salted hash in the encrypted table is sufficient for a per-app gate and is unit-testable.
- No lock — rejected: sensitive financial data visible on an unlocked phone.