Skip to content

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 settings table; salt regenerated on every PIN change so identical PINs never share a digest; verified with constantTimeEquals. 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() via LockClock; changing the wall clock cannot skip a lockout or disable auto-lock.
  • Brute-force lockout — after MAX_FAILED_ATTEMPTS = 5 failures 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 — onStop relocks (registered on ProcessLifecycleOwner).
  • Biometric — optional, WEAK/STRONG selectable, optional device-credential fallback; availability probed via BiometricManager; prompt hosted in MainActivity, requested once per lock session.
  • Testability — depends on the SettingsStore seam and injectable LockClock, 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 MainActivity above 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.