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.
- No app code path performs network I/O: nothing transmits the data. The
release manifest does carry
INTERNETandACCESS_NETWORK_STATEmerged from MapLibre (ADR-0015 Amendment), plus the location permissions; those entries are unused. No analytics, no crash-reporting SDKs. - Backups and device transfer disabled (
android:allowBackup="false"; bothdata_extraction_rules.xml— explicit<cloud-backup>and<device-transfer>excludes for root/file/database/sharedpref/external — and the pre-API-31backup_rules.xmlexclude every domain). A missing<device-transfer>section would leave D2D fully enabled, soPrivacyRulesTestfails if it or a domain is removed (issue #285).
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.
-
Durable, recoverable key (issue #288) — the wrapped blob is versioned (
v1:+ base64 of IV+ciphertext; legacy unprefixed blobs still decode) and written withSharedPreferences.commit(), so the key is on disk before Room opens and a process death cannot orphan the database behind a second passphrase.DatabasePassphrase.resolve()never throws: it returnsReadyor a typedUnavailable(reason). A missing blob with an existing database (MISSING_KEY) or a blob the Keystore can no longer decrypt (KEY_INVALIDATED) is never replaced with a fresh key;DatabaseModulethen recordsDatabaseRecoveryStateand opens an in-memory database, andMainActivityrenders the recovery screen instead of crashing. That screen states plainly that the ledger cannot be restored without the key; the copy on disk is left untouched for manual recovery. -
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), and the leading bytes must match the claimed type. JPEG/PNG images are downsampled and re-encoded to remove metadata. 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. Opening a saved attachment is read-only: images stay in the in-app viewer; other supported files go through the non-exportedFileProviderwith a temporary read grant for exactly one attachment file (ADR-0086). The grant carries no write or persistable permission, and no second plaintext copy is created. - 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 app network I/O, ADR-0015 Amendment).
Input sanitization
NotificationExtractor.sanitize masks account/card numbers to ****last4
before anything is stored or hashed. It handles contiguous runs of ≥ 8
digits, card-style groups separated by spaces, dashes or dots, and non-ASCII
digit scripts (issue #303). Money is never rewritten: a run that is part of a
decimal amount (RM12345678.00) or directly follows a currency token is left
intact (issue #312). The notification key hash is computed over the
already-masked text. Text is extracted only for packages the user enabled in
tracked banks — the track gate runs before the text is read, for both posted
notifications and the 48 h replay (issue #305).
Captured-transaction notification
The optional "transaction captured" feedback notification is opt-in
(notify_on_parse defaults to off). When enabled it is posted with
VISIBILITY_PRIVATE and a generic public version ("Transaction captured" /
"Tap to review"), so the lock screen and other notification listeners never
see the amount or merchant (issue #304).
App lock
flowchart LR
subgraph AppLockManager
PIN[setPin / changePin / disable]
VER[verifyPin]
TIMEOUT[auto-lock timeout]
LOCKOUT[escalating lockout]
end
subgraph Storage
HASH[pin_hash: PBKDF2-HMAC-SHA256]
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 — iterated PBKDF2-HMAC-SHA256 digest (
KDF_ITERATIONS, 120 000, versionedpbkdf2$iterations$digest), stored in the encryptedsettingstable. Salt regenerated on every PIN change so identical PINs never share a digest.constantTimeEqualsprevents timing attacks. A digest written by an older build (single salted SHA-256) still verifies once and is transparently re-hashed with the slow KDF on the next successful unlock. - PIN length — the selector offers 6/8 digits; new PINs must be at least
MIN_PIN_LENGTH = 6. Changing the length requires the current PIN plus a new PIN of the chosen length and re-hashes both in oneAppLockManager.changePinLengthstep; writing onlyKEY_PIN_LENGTHwould leave a digest the keypad can never match and lock the owner out (issue #283). A wrong current PIN leaves the old PIN valid. Existing installs with a shorter PIN keep working but can only move to 6/8. - 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. - Monotonic clock —
LockClockis bound toSystemClock.elapsedRealtime(). Moving the device clock forward cannot skip a lockout, and moving it backward cannot disable the idle timeout. A stored deadline that cannot belong to the current boot (reboot restarts the monotonic clock) is discarded rather than treated as a multi-hour lockout. - Brute-force lockout — after
MAX_FAILED_ATTEMPTS = 5failures the lockout starts at 30 s and doubles for every further failure (30 s, 1 min, 2 min, … capped at 30 min). Ten consecutive failures therefore cost more than ten minutes. The attempt counter is a read-modify-write serialized by a mutex; the lock screen counts down and blocks PIN entry. A successful PIN verification clears the counter and deadline. - Lifecycle relock —
AppLockManageris aDefaultLifecycleObserverregistered onProcessLifecycleOwner; leaving the foreground relocks. - Fail-closed gate — content is not rendered until the lock state resolves: a cold start shows only a background surface (no navigation, no content), and a return to the foreground re-evaluates the gate on the first frame, so neither pre-resolution nor stale unlocked content can appear.
- Screenshot / Recents protection — an opt-in
KEY_SCREENSHOT_PROTECTIONsets whole-windowFLAG_SECUREfrom cold start, before any content composes (ADR-0078); off by default so the repository's screenshot/recording tooling is unaffected. It blanks screenshots, screen recordings, casting and the Recents preview on most devices, but not a photo taken with another device. - Biometric — optional, WEAK/STRONG strength selectable, optional device
credential fallback. Availability probed via
BiometricManager.canAuthenticate.MainActivityhosts theBiometricPromptusing the main-thread executor (never a per-prompt worker thread), and the lock ViewModel requests the prompt once per lock session. Success unlocks; cancel, lockout and hardware errors surface a localized message instead of leaving the lock screen looking unresponsive (#332). - Testability — depends on the
SettingsStoreseam and an injectableLockClock(monotonic elapsed-time 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").
The PIN row is a BasicTextField with PasswordVisualTransformation
(issue #370): the custom dots are the visible entry, while the editable field
exposes password semantics to assistive technology, masks its editable text,
offers no copy/cut action and announces the entered digit count through its
state description. Hardware keyboards and the on-screen keypad feed the same
length-filtered value. The remaining-attempt line is derived from
MAX_FAILED_ATTEMPTS - failedAttempts and announced as a polite live region,
so failures 1–4 report 4/3/2/1 remaining (issue #371); the fifth failure still
enters the existing escalating lockout and a successful PIN verification
clears the counter. Biometric success keeps the persisted PIN-failure counter
unchanged.
AlertDialog/DatePickerDialog/ModalBottomSheet are separate windows above
that overlay, so the lock state is also provided as LocalAppLocked
(ui/lock/LockAwareness.kt). Every window-owning surface must close on lock:
screen-owned flags are cleared with DismissOnLock, and a leaf composable that
owns its own window (ChartTooltip's Popup, the budget row's overflow
DropdownMenu, the editor's ExposedDropdownMenuBox) reads LocalAppLocked
or calls DismissOnLock itself. DialogLockGuardTest scans for all of those
constructors and fails if a new call site forgets the gate (issue #284).
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).