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.
  • 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/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). 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.
  • 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 settings table. Salt regenerated on every PIN change so identical PINs never share a digest. constantTimeEquals prevents timing attacks.
  • Session unlockunlockedThisSession 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.
  • Brute-force lockout — after MAX_FAILED_ATTEMPTS = 5 failures, lockout for LOCKOUT_MS = 30 s (KEY_LOCKOUT_UNTIL); the lock screen counts down and blocks PIN entry.
  • Lifecycle relockAppLockManager is a DefaultLifecycleObserver registered on ProcessLifecycleOwner; leaving the foreground relocks.
  • Biometric — optional, WEAK/STRONG strength selectable, optional device credential fallback. Availability probed via BiometricManager.canAuthenticate. MainActivity hosts the BiometricPrompt; the lock ViewModel requests the prompt once per lock session.
  • Testability — depends on the SettingsStore seam and an injectable LockClock (wall-clock 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").

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