fix(cache): close SQLCipher nativeOpen crash-loop gaps beyond resolveOpenMode (#359) #425

Merged
JMR-dev merged 2 commits from fix-359-encryption-gate-coverage into main 2026-07-08 03:06:47 +00:00
JMR-dev commented 2026-07-08 02:22:01 +00:00 (Migrated from github.com)

Closes #359.

Problem

#367 made the encrypted-cache open fail closed, but its try { … } catch (LinkageError) in DatabaseProvisioner.runStartupSequence wrapped only resolveOpenMode. Three keyed-open paths reach SQLiteConnection.nativeOpen outside that handler, so an UnsatisfiedLinkError there still crash-loops on a device whose SQLCipher .so loads but won't link:

  1. Migrator — AccountDataMigrator.migrateIfNeeded() (called before the handler) does a keyed openOrCreateDatabase + ATTACH … KEY even when encryption is OFF (empty key), so every pre-#111 upgrader hit it.
  2. Room's deferred open — the real keyed nativeOpen fires in DatabaseModule's DeferredOpenHelperFactory after prepareCache() returns — outside any handler.
  3. Headless entry points — WorkManager workers + IdleService inject the cache with no UI gate (CacheEncryptionGate wraps only MainActivity).

Fix

  • Gaps 1–2: widen the fail-closed try to cover migrateIfNeeded(), and eagerly probe the real keyed open (DatabaseEncryption.probeKeyedOpen, a throwaway sibling file that never touches the real cache) inside the handler, so the deferred-open LinkageError becomes a CacheEncryptionUnavailableException before Room can crash on it. The catch stays LinkageError-only on purpose — the migrator throws a non-linkage error on unexpected copy failure ("crash-loop rather than lose data") that must still propagate uncaught.
  • Gap 3: headless tolerance via Throwable.isCacheEncryptionUnavailable() (walks the cause chain — coroutine recovery re-wraps) + a retryIfEncryptedCacheUnavailable worker guard (soft Result.retry()) and an IdleService catch that logs PII-free and stopSelf()s. A later launch re-probes and recovers automatically.

Fail-closed semantics preserved: never opens plaintext, never wipes ciphertext, never writes encryptCache; the throw is not memoized, so recovery is automatic once the library links.

Tests

  • Unit: provisioner fail-closed paths, the isCacheEncryptionUnavailable cause-chain walk, and all four worker/service guards.
  • Instrumented: DatabaseEncryptionProbeInstrumentedTest exercises probeKeyedOpen on-device.
  • Local JVM gate green (assembleDebug + testDebugUnitTest + compileDebugAndroidTestKotlin). E2E runs via CI (local emulator host is being provisioned separately).

Merge

Security-sensitive (fail-closed crypto path) — not arming auto-merge; requesting maintainer review.

Closes #359. ## Problem #367 made the encrypted-cache open fail closed, but its `try { … } catch (LinkageError)` in `DatabaseProvisioner.runStartupSequence` wrapped **only** `resolveOpenMode`. Three keyed-open paths reach `SQLiteConnection.nativeOpen` **outside** that handler, so an `UnsatisfiedLinkError` there still crash-loops on a device whose SQLCipher `.so` loads but won't link: 1. **Migrator** — `AccountDataMigrator.migrateIfNeeded()` (called before the handler) does a keyed `openOrCreateDatabase` + `ATTACH … KEY` **even when encryption is OFF** (empty key), so every pre-#111 upgrader hit it. 2. **Room's deferred open** — the real keyed `nativeOpen` fires in `DatabaseModule`'s `DeferredOpenHelperFactory` **after** `prepareCache()` returns — outside any handler. 3. **Headless entry points** — WorkManager workers + `IdleService` inject the cache with no UI gate (`CacheEncryptionGate` wraps only `MainActivity`). ## Fix - **Gaps 1–2:** widen the fail-closed `try` to cover `migrateIfNeeded()`, and eagerly **probe the real keyed open** (`DatabaseEncryption.probeKeyedOpen`, a throwaway sibling file that never touches the real cache) inside the handler, so the deferred-open `LinkageError` becomes a `CacheEncryptionUnavailableException` **before** Room can crash on it. The catch stays **`LinkageError`-only** on purpose — the migrator throws a *non*-linkage error on unexpected copy failure ("crash-loop rather than lose data") that must still propagate uncaught. - **Gap 3:** headless tolerance via `Throwable.isCacheEncryptionUnavailable()` (walks the cause chain — coroutine recovery re-wraps) + a `retryIfEncryptedCacheUnavailable` worker guard (soft `Result.retry()`) and an `IdleService` catch that logs PII-free and `stopSelf()`s. A later launch re-probes and recovers automatically. Fail-closed semantics preserved: never opens plaintext, never wipes ciphertext, never writes `encryptCache`; the throw is not memoized, so recovery is automatic once the library links. ## Tests - **Unit:** provisioner fail-closed paths, the `isCacheEncryptionUnavailable` cause-chain walk, and all four worker/service guards. - **Instrumented:** `DatabaseEncryptionProbeInstrumentedTest` exercises `probeKeyedOpen` on-device. - Local JVM gate green (`assembleDebug` + `testDebugUnitTest` + `compileDebugAndroidTestKotlin`). **E2E runs via CI** (local emulator host is being provisioned separately). ## Merge Security-sensitive (fail-closed crypto path) — **not** arming auto-merge; requesting maintainer review.
mergify[bot] commented 2026-07-08 03:06:41 +00:00 (Migrated from github.com)

Merge Queue Status

  • ✅ Entered queue — 2026-07-08 03:06 UTC · Rule: default · triggered by merge protections
  • ✅ Checks skipped · PR is already up-to-date
  • ✅ Merged — 2026-07-08 03:06 UTC · at 8cacff6b4a1b3adcb3e07f4ae6979a668bdee27e · merge

This pull request spent 8 seconds in the queue, including 1 second running CI.

Required conditions to merge
<!--- DO NOT EDIT -*- Mergify Payload -*- {"version": 1, "state": "merged", "queue_rule_name": "default", "queued_at": "2026-07-08T03:06:40.602701+00:00", "estimated_time_of_merge": null, "speculative_check_pr": null, "required_conditions": []} -*- Mergify Payload End -*- --> # Merge Queue Status - ✅ **Entered queue** — `2026-07-08 03:06 UTC` · Rule: `default` · triggered by merge protections - ✅ **Checks skipped** · PR is already up-to-date - ✅ **Merged** — `2026-07-08 03:06 UTC` · at `8cacff6b4a1b3adcb3e07f4ae6979a668bdee27e` · merge This pull request spent **8 seconds** in the queue, including **1 second** running CI. <details> <summary>Required conditions to merge</summary> - `-conflict` - [X] #425 - `-draft` - [X] #425 - [X] `base = main` - [X] `check-success = CI passed` - `github-review-approved` [🛡 GitHub repository ruleset rule `main`] - [X] #425 - `label != broken` - [X] #425 - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = Debug build` - [ ] `check-neutral = Debug build` - [ ] `check-skipped = Debug build` - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = Unit tests` - [ ] `check-neutral = Unit tests` - [ ] `check-skipped = Unit tests` - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = CI passed` - [ ] `check-neutral = CI passed` - [ ] `check-skipped = CI passed` - [X] any of [🛡 GitHub repository ruleset rule `main`]: - [X] `check-success = @github-actions/CI passed` - [ ] `check-neutral = @github-actions/CI passed` - [ ] `check-skipped = @github-actions/CI passed` </details>
Sign in to join this conversation.