Security
Scope: owns the threat model, encryption at rest, app lock, masked-content handling.
Security posture: everything stays on device, the database is encrypted, notifications are sanitized before storage, and the UI can be locked behind PIN + biometric.
Threat model (summary)
- Notification text contains amounts, merchants, account/card references — sensitive data that must not leak off-device.
- The app has no INTERNET permission — in any variant (ADR-0015): nothing can transmit the data. No analytics, no crash-reporting SDKs.
- Backups disabled (
android:allowBackup="false",data_extraction_rules,backup_rules): the encrypted DB never goes to cloud backup or device transfer.
Encryption at rest
See data-layer.md: SQLCipher-encrypted Room DB;
random 32-byte passphrase wrapped by an Android Keystore AES-GCM key
(autobudget_sqlcipher_key). Key never leaves the device; wiping app data
permanently destroys history.
- Attachments (ADR-0027): file bytes live under the app-private
filesDir/attachmentsdirectory; the encrypted DB stores only metadata. A type whitelist (images, PDF, txt/csv/xlsx/docx) and a hard 10 MB cap are enforced before anything is staged (AttachmentRepository.StageResult). App storage relies on OS file-based encryption at rest, and theallowBackup=false/data_extraction_rulesguards keep files out of cloud backup and device transfer. - Location (ADR-0026, ADR-0034): an opt-in device fix is stored in the
encrypted DB on the transaction/unmatched row. Background capture requires
the "Allow all the time" grant (
ACCESS_BACKGROUND_LOCATION, declared in the manifest but only exercised after opt-in), and location is never transmitted (no INTERNET, ADR-0015).
Input sanitization
NotificationExtractor.sanitize masks digit runs ≥ 8 to ****last4
before anything is stored or hashed. The notification key hash is computed
over the already-masked text.
App lock
flowchart LR
subgraph AppLockManager
PIN[setPin / changePin / disable]
VER[verifyPin]
TIMEOUT[auto-lock timeout]
LOCKOUT[brute-force lockout]
end
subgraph Storage
HASH[pin_hash: salted SHA-256]
SALT[pin_salt]
FAIL[failed_attempts]
UNTIL[lockout_until]
end
PIN -->|regenerates salt + hash| HASH
PIN -->|clears counters| FAIL
VER --> HASH
VER --> FAIL
VER --> UNTIL
TIMEOUT -->|expires session unlock| VER
TICK[15 s ticker + recordActivity on navigation] -->|re-evaluates| TIMEOUT
Biometric enablement lives in settings flags consumed by MainActivity; it
does not touch the PIN hash.
- PIN storage — salted SHA-256 digest stored in the encrypted
settingstable. Salt regenerated on every PIN change so identical PINs never share a digest.constantTimeEqualsprevents timing attacks. - Session unlock —
unlockedThisSessionflag with an idle timeout (KEY_LOCK_TIMEOUT_MINUTES, default 5 min; 0 = never auto-lock). The deadline is re-evaluated by a 15 s ticker inAppLockViewModel(bounded cadence, one tick of worst-case overshoot) so the app relocks while it stays in the foreground;AppLockManager.recordActivity()is called on every navigation destination change, so the timeout measures inactivity, not wall time since unlock. - Brute-force lockout — after
MAX_FAILED_ATTEMPTS = 5failures, lockout forLOCKOUT_MS = 30 s(KEY_LOCKOUT_UNTIL); the lock screen counts down and blocks PIN entry. - Lifecycle relock —
AppLockManageris aDefaultLifecycleObserverregistered onProcessLifecycleOwner; leaving the foreground relocks. - Biometric — optional, WEAK/STRONG strength selectable, optional device
credential fallback. Availability probed via
BiometricManager.canAuthenticate.MainActivityhosts theBiometricPrompt; the lock ViewModel requests the prompt once per lock session. - Testability — depends on the
SettingsStoreseam and an injectableLockClock(wall-clock source), soAppLockManagerTestruns pure JVM; the auto-lock ticker runs onviewModelScopevirtual time inAppLockViewModelTest.
Lock screen UX
LockScreen overlays the whole app (Box over AutoBudgetNavHost): PIN
dots, shake-on-error, "X of N attempts remaining", biometric button when
available+enabled, lockout panel with countdown. It appears only after
onboarding (onboarding == "true").
Masked content routing
OS-level masking (Android replaces hidden notification bodies with
placeholders like "Content hidden") is detected by
NotificationFilter.isMaskedContent and routed to the review queue with
isMasked = true instead of being dropped (ADR-0005).