Skip to content

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 INTERNET and ACCESS_NETWORK_STATE merged 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"; both data_extraction_rules.xml — explicit <cloud-backup> and <device-transfer> excludes for root/file/database/sharedpref/external — and the pre-API-31 backup_rules.xml exclude every domain). A missing <device-transfer> section would leave D2D fully enabled, so PrivacyRulesTest fails 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 with SharedPreferences.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 returns Ready or a typed Unavailable(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; DatabaseModule then records DatabaseRecoveryState and opens an in-memory database, and MainActivity renders 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/attachments directory; 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 the allowBackup=false / data_extraction_rules guards 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-exported FileProvider with 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, versioned pbkdf2$iterations$digest), stored in the encrypted settings table. Salt regenerated on every PIN change so identical PINs never share a digest. constantTimeEquals prevents 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 one AppLockManager.changePinLength step; writing only KEY_PIN_LENGTH would 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 — unlockedThisSession flag 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 in AppLockViewModel (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 — LockClock is bound to SystemClock.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 = 5 failures 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 — AppLockManager is a DefaultLifecycleObserver registered on ProcessLifecycleOwner; 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_PROTECTION sets whole-window FLAG_SECURE from 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. MainActivity hosts the BiometricPrompt using 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 SettingsStore seam and an injectable LockClock (monotonic elapsed-time source), so AppLockManagerTest runs pure JVM; the auto-lock ticker runs on viewModelScope virtual time in AppLockViewModelTest.

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).