fix(security): keep accounts/credentials out of the auth-bound cache DB (#111) #118

Merged
JMR-dev merged 6 commits from fix-accounts-out-of-cache-db into main 2026-07-02 14:33:02 +00:00
JMR-dev commented 2026-07-02 08:02:15 +00:00 (Migrated from github.com)

⚠️ REQUIRES MAINTAINER DEVICE VALIDATION BEFORE MERGE

This changes on-disk data layout and ships a one-time data migration. A botched migration would destroy real users' accounts (strictly worse than the current bug), so please do not merge on green CI alone — CI cannot exercise the SQLCipher/Keystore upgrade path. Validate these upgrade scenarios on a real device (install the pre-#111 build first, create an account, then upgrade to this build):

  • Fresh install of this build: onboarding works; accounts land in libremail-accounts.db.
  • Upgrade with app-lock OFF (plaintext cache): existing account, credential, per-account settings and signatures survive the upgrade and the user is NOT sent to onboarding. Confirm the accounts/credentials/account_settings/signatures tables are gone from libremail.db and present in libremail-accounts.db.
  • Upgrade with app-lock + encrypted cache ON: same as above, migrating out of the SQLCipher-encrypted source. Sending/receiving mail still works after.
  • Key-invalidation recovery AFTER migrating (the actual fix): with app-lock + encrypted cache on and the account already migrated, re-enroll a biometric (or remove + re-add the device lock) to invalidate the key. The cache is wiped and re-synced, but the account stays signed in (no onboarding). Then remove the lock entirely and confirm the same.
  • Mid-migration crash / resume: kill the app during first post-upgrade launch (e.g. via adb shell am force-stop right after start) and relaunch. The migration converges — no duplicated or lost accounts, no crash loop.
  • Toggle encrypted-cache on and off after migrating: still opens; accounts unaffected (they are never encrypted by the cache key now).

Problem

AccountEntity, CredentialEntity, AccountSettingsEntity and SignatureEntity lived in libremail.db, which SQLCipher encrypts under the auth-bound passphrase when app-lock + encrypted cache are on. A genuine key invalidation (biometric re-enrollment or lock removal/re-add) makes that file undecryptable, so the existing "clear + re-sync" recovery wiped the user's accounts and stored credentials along with the mail cache and dropped them into onboarding. The credentials' own sealing key (the non-auth KeystoreCrypto master key) survives the invalidation — only the SQLCipher container is lost.

This builds on the #45 mitigations already on main (sealWithMaster() deletes the orphaned auth key; KeyInvalidationPolicy no longer wipes on a merely-lapsed auth window), which removed spurious wipes. This PR makes the genuine wipe non-destructive to accounts.

Fix

Move the four account tables into a new AccountDatabase (libremail-accounts.db), a separate file that is never bound to the auth key.

  • Plaintext on disk, by design. The only secret is CredentialEntity.encryptedSecret, which is already AES-GCM ciphertext sealed at the column level by the non-auth KeystoreCrypto master key — and that key survives an auth-key invalidation. Account metadata (email, server host/port) is not a secret. Keeping the file plaintext is what makes it maximally resilient: it can always be opened with no Keystore key, so no invalidation can strand it. (Trade-off: for encrypted-cache users, account metadata is no longer under the optional SQLCipher layer; the actual secret's protection is unchanged.)
  • Cache database drops to v15 via MIGRATION_14_15, which drops the four moved tables.
  • DAOs are unchanged and re-provided from the new DB (AccountDatabaseModule), so no consumer/injection-site code changes.

Migration strategy (crash-safe, idempotent, encrypted-source-aware)

AccountDataMigrator.migrateIfNeeded() runs at startup from provideDatabase, before Room opens either database:

  1. Ordering. It runs after the clear-pending cache wipe and before builder.build() (which applies MIGRATION_14_15). AccountDatabase depends on LibreMailDatabase in Hilt purely so the copy — and the drop — complete before Room ever opens libremail-accounts.db (no two connections touch it).
  2. Encrypted source. The copy opens the plaintext libremail-accounts.db and ATTACHes the cache with the passphrase the cache provider resolves (empty when plaintext). Reading the source's sqlite_master validates the key, so a genuinely wrong key fails loudly (the same open would fail in Room) rather than losing data. The copy can't be a Room migration because SQLite forbids ATTACH inside the transaction Room wraps migrations in.
  3. Unrecoverable-key case degrades safely. If the key is invalidated, provideDatabase wipes the undecryptable cache and resets its seals first; the migrator then sees a fresh/empty cache with nothing to move (no blocking on a passphrase that can't be produced). Accounts trapped in an already-invalidated cache before ever running this build are lost regardless (the pre-existing bug) — but once migrated, no future invalidation can strand them.
  4. Crash-safety & idempotency. The source is dropped only by MIGRATION_14_15 after the copy, so a crash leaves the source intact for the next attempt. The copy uses INSERT OR IGNORE (never duplicates, never overwrites a post-migration edit). A "done" flag is set only after a successful copy; until then every start retries, and once set the account DB opens with no Keystore dependency at all.

Tests

  • app/schemas: exported AccountDatabase/1.json and LibreMailDatabase/15.json.
  • MigrationTest: the auto-discovering chain-replay now asserts the account rows + migration backfills survive to v14 and are dropped at v15, plus a dedicated 14->15 test.
  • AccountDataMigratorTest: copy out of a plaintext cache, copy out of a SQLCipher-encrypted cache, idempotent re-run that preserves a post-migration edit, a DDL-vs-exported-schema drift guard, and end-to-end survival of a simulated cache wipe (accounts + credentials still present and usable after libremail.db is deleted).
  • AccountDatabaseTest: FK cascades (settings + signatures cascade from the account) now that they live together in the new DB.

Local gate green: assembleDebug, testDebugUnitTest, lintDebug, ktlintCheck, detekt, compileDebugAndroidTestKotlin. The migration/DB tests are androidTest (emulator E2E in CI).

Closes #111

🤖 Generated with Claude Code

## ⚠️ REQUIRES MAINTAINER DEVICE VALIDATION BEFORE MERGE This changes on-disk data layout and ships a one-time data migration. A botched migration would destroy real users' accounts (strictly worse than the current bug), so **please do not merge on green CI alone** — CI cannot exercise the SQLCipher/Keystore upgrade path. Validate these upgrade scenarios on a real device (install the pre-#111 build first, create an account, then upgrade to this build): - [ ] **Fresh install** of this build: onboarding works; accounts land in `libremail-accounts.db`. - [ ] **Upgrade with app-lock OFF** (plaintext cache): existing account, credential, per-account settings and signatures survive the upgrade and the user is NOT sent to onboarding. Confirm the `accounts`/`credentials`/`account_settings`/`signatures` tables are gone from `libremail.db` and present in `libremail-accounts.db`. - [ ] **Upgrade with app-lock + encrypted cache ON**: same as above, migrating out of the SQLCipher-encrypted source. Sending/receiving mail still works after. - [ ] **Key-invalidation recovery AFTER migrating** (the actual fix): with app-lock + encrypted cache on and the account already migrated, re-enroll a biometric (or remove + re-add the device lock) to invalidate the key. The cache is wiped and re-synced, **but the account stays signed in** (no onboarding). Then remove the lock entirely and confirm the same. - [ ] **Mid-migration crash / resume**: kill the app during first post-upgrade launch (e.g. via `adb shell am force-stop` right after start) and relaunch. The migration converges — no duplicated or lost accounts, no crash loop. - [ ] **Toggle encrypted-cache on and off** after migrating: still opens; accounts unaffected (they are never encrypted by the cache key now). ## Problem `AccountEntity`, `CredentialEntity`, `AccountSettingsEntity` and `SignatureEntity` lived in `libremail.db`, which SQLCipher encrypts under the **auth-bound** passphrase when app-lock + encrypted cache are on. A genuine key invalidation (biometric re-enrollment or lock removal/re-add) makes that file undecryptable, so the existing "clear + re-sync" recovery wiped the user's accounts and stored credentials along with the mail cache and dropped them into onboarding. The credentials' own sealing key (the non-auth `KeystoreCrypto` master key) survives the invalidation — only the SQLCipher container is lost. This builds on the #45 mitigations already on `main` (`sealWithMaster()` deletes the orphaned auth key; `KeyInvalidationPolicy` no longer wipes on a merely-lapsed auth window), which removed *spurious* wipes. This PR makes the *genuine* wipe non-destructive to accounts. ## Fix Move the four account tables into a new **`AccountDatabase`** (`libremail-accounts.db`), a separate file that is **never** bound to the auth key. - **Plaintext on disk, by design.** The only secret is `CredentialEntity.encryptedSecret`, which is already AES-GCM ciphertext sealed at the column level by the non-auth `KeystoreCrypto` master key — and that key survives an auth-key invalidation. Account metadata (email, server host/port) is not a secret. Keeping the file plaintext is what makes it maximally resilient: it can always be opened with no Keystore key, so no invalidation can strand it. (Trade-off: for encrypted-cache users, account *metadata* is no longer under the optional SQLCipher layer; the actual secret's protection is unchanged.) - **Cache database** drops to **v15** via `MIGRATION_14_15`, which drops the four moved tables. - **DAOs are unchanged** and re-provided from the new DB (`AccountDatabaseModule`), so no consumer/injection-site code changes. ### Migration strategy (crash-safe, idempotent, encrypted-source-aware) `AccountDataMigrator.migrateIfNeeded()` runs at startup from `provideDatabase`, **before** Room opens either database: 1. **Ordering.** It runs *after* the clear-pending cache wipe and *before* `builder.build()` (which applies `MIGRATION_14_15`). `AccountDatabase` depends on `LibreMailDatabase` in Hilt purely so the copy — and the drop — complete before Room ever opens `libremail-accounts.db` (no two connections touch it). 2. **Encrypted source.** The copy opens the plaintext `libremail-accounts.db` and `ATTACH`es the cache with the passphrase the cache provider resolves (empty when plaintext). Reading the source's `sqlite_master` validates the key, so a genuinely wrong key fails loudly (the same open would fail in Room) rather than losing data. The copy can't be a Room migration because SQLite forbids `ATTACH` inside the transaction Room wraps migrations in. 3. **Unrecoverable-key case degrades safely.** If the key is invalidated, `provideDatabase` wipes the undecryptable cache and resets its seals *first*; the migrator then sees a fresh/empty cache with nothing to move (no blocking on a passphrase that can't be produced). Accounts trapped in an already-invalidated cache before ever running this build are lost regardless (the pre-existing bug) — but once migrated, no future invalidation can strand them. 4. **Crash-safety & idempotency.** The source is dropped only by `MIGRATION_14_15` *after* the copy, so a crash leaves the source intact for the next attempt. The copy uses `INSERT OR IGNORE` (never duplicates, never overwrites a post-migration edit). A "done" flag is set only after a successful copy; until then every start retries, and once set the account DB opens with no Keystore dependency at all. ## Tests - **`app/schemas`**: exported `AccountDatabase/1.json` and `LibreMailDatabase/15.json`. - **`MigrationTest`**: the auto-discovering chain-replay now asserts the account rows + migration backfills survive to v14 and are dropped at v15, plus a dedicated `14->15` test. - **`AccountDataMigratorTest`**: copy out of a plaintext cache, copy out of a SQLCipher-encrypted cache, idempotent re-run that preserves a post-migration edit, a DDL-vs-exported-schema drift guard, and **end-to-end survival of a simulated cache wipe** (accounts + credentials still present and usable after `libremail.db` is deleted). - **`AccountDatabaseTest`**: FK cascades (settings + signatures cascade from the account) now that they live together in the new DB. Local gate green: `assembleDebug`, `testDebugUnitTest`, `lintDebug`, `ktlintCheck`, `detekt`, `compileDebugAndroidTestKotlin`. The migration/DB tests are `androidTest` (emulator E2E in CI). Closes #111 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.