WIP: spike(security): characterize #359 SQLCipher SDK-37/16KB-page failure on-device #423

Draft
JMR-dev wants to merge 2 commits from spike-359-sqlcipher-ondevice into main
JMR-dev commented 2026-07-07 19:55:45 +00:00 (Migrated from github.com)

Spike: characterize the #359 SQLCipher UnsatisfiedLinkError on SDK 37 / 16 KB pages

Investigation only — no app/src/main crypto change. Adds one on-device harness
(SqlCipherOpenSpikeTest, androidTest) and reports a definitive finding. Draft, no auto-merge.

What the test does

Exercises the exact #359 open path in three isolated stages so a failure pinpoints the break site,
records the in-process page size (Os.sysconf(_SC_PAGESIZE)), and re-raises the full
UnsatisfiedLinkError (which .so, cause chain, stacktrace) on failure:

  • Stage A — System.loadLibrary("sqlcipher") via DatabaseEncryption.ensureNativeLibraryLoaded() (the dlopen).
  • Stage B — a keyed SQLiteDatabase.openOrCreateDatabase(...) reaching SQLiteConnection.nativeOpen, with a row round-trip.
  • Stage C — the full production path: plaintext Room → ensureEncrypted → reopen through SupportOpenHelperFactory (mirrors DatabaseModule).

On-device A/B (Pixel 8 Pro husky, real SDK 37 / Android 17, arm64-v8a)

Environment Stage A (loadLibrary) Stage B (nativeOpen) Stage C (Room path)
4 KB pages (getconf PAGE_SIZE=4096) PASS PASS PASS
16 KB pages (arm64) not testable on this device — see below — —

The 4 KB run is a clean baseline on a genuine SDK 37 device: the encrypted cache opens end-to-end,
so "real SDK 37" alone is not the cause. (In-process breadcrumbs confirmed PAGE_SIZE=4096, SDK=37, abis=arm64-v8a.)

Why the 16 KB half is blocked: the Pixel "Boot with 16 KB page size" developer toggle
(Settings.Global enable_16k_pages) is gated behind an unlocked bootloader. The device dialog reads:

"This device needs to have the bootloader unlocked before using the 16 KB developer option … All
user data and settings will be wiped when activating 16 KB mode.
… To return the device to
production mode, you would need to switch back to 4 KB mode and then OEM/bootloader lock (which
factory resets) the device."

Setting enable_16k_pages=1 + a plain adb reboot does not switch (ro.boot.hardware.cpu.pagesize
stays 4096 — init ignores the flag with a locked bootloader). Unlocking + activating would factory-wipe a
personal daily-driver — out of scope for "toggle + reboot." The device was left exactly as found
(enable_16k_pages=0, PAGE_SIZE=4096, on the launcher).

Static ELF proof — this refutes the #359 root-cause hypothesis

#359 suspected the bundled .so isn't 16 KB-aligned. Independently checked PT_LOAD p_align on every
packaged native library (16 KB pages need p_align >= 0x4000):

Library arm64-v8a armeabi-v7a x86_64 x86
libsqlcipher.so (sqlcipher-android:4.16.0) 0x4000 0x4000 0x4000 0x4000
libandroidx.graphics.path.so 0x4000 — — —
libdatastore_shared_counter.so 0x4000 — — —

Every bundled .so is already 16 KB-aligned, including arm64-v8a libsqlcipher.so. This exactly
reproduces what the repo's own docs/play-compliance.md §2 already certifies. sqlcipher has been pinned
to 4.16.0 since the original encrypted-cache commit (96c9f71), so the crashing 2026-07-03 build
shipped the same aligned library. Corroborating at runtime: CI's e2e-preview job runs the existing
SQLCipher instrumented tests on the google_apis_ps16k (16 KB) x86_64 emulator and is green — the
aligned x86_64 .so loads and opens a keyed DB on 16 KB pages.

Conclusion / fix direction

  • The "rebuild/bump sqlcipher-android for 16 KB alignment" fix implied by #359 would not fix
    anything — the lib is already correctly 16 KB-aligned on all ABIs, and AGP 9.2 already emits
    16 KB zip-aligned uncompressed libs by default (docs/play-compliance.md §2). This closes the
    alignment theory and saves that wild-goose chase.
  • The UnsatisfiedLinkError @ SQLiteConnection.nativeOpen therefore has a different root cause.
    Redirect the investigation to:
    1. Library-load ordering (prime suspect) — a path that reaches a keyed nativeOpen without a
      successful preceding System.loadLibrary("sqlcipher"). This is the #221/#367 class and has the
      identical symptom. The dropbox data_app_crash means a nativeOpen escaped #367's fail-closed
      try/catch: audit every opener of the keyed cache (WorkManager workers, AccountDatabase, the
      AccountDataMigrator legacy path, cold-open) to confirm each is preceded by
      ensureNativeLibraryLoaded() and wrapped by that handler.
    2. Delivered-artifact specifics — the crash was a release/app-bundle build on a real 16 KB arm64
      device; confirm the delivered split actually contained arm64-v8a libsqlcipher.so and that Play's
      re-generated split preserved 16 KB zip-alignment (AGP handles the build-side; Play re-signs).
    3. Capture the real stacktrace — the exact throwable + .so name from a genuine 16 KB arm64 device
      (the Pixel 10 Pro XL from the report) will disambiguate 1 vs 2 immediately.

To complete the same-device A/B (what's needed)

A 16 KB arm64 environment: either the crash-report Pixel 10 Pro XL, or an explicitly authorized
bootloader-unlock + factory-wipe of a spare/unlocked Pixel 8. SqlCipherOpenSpikeTest is ready to drop
the verdict (which stage, full stacktrace) the moment it runs there. On the two environments available
today (4 KB arm64 device; 16 KB x86_64 e2e-preview) it passes and doubles as a regression guard against
a future dependency de-aligning the .so or breaking the load-ordering path.

Informs #359 (does not fix).

## Spike: characterize the #359 SQLCipher `UnsatisfiedLinkError` on SDK 37 / 16 KB pages Investigation only — **no `app/src/main` crypto change**. Adds one on-device harness (`SqlCipherOpenSpikeTest`, androidTest) and reports a definitive finding. Draft, **no auto-merge**. ### What the test does Exercises the exact #359 open path in three isolated stages so a failure pinpoints the break site, records the in-process page size (`Os.sysconf(_SC_PAGESIZE)`), and re-raises the full `UnsatisfiedLinkError` (which `.so`, cause chain, stacktrace) on failure: - **Stage A** — `System.loadLibrary("sqlcipher")` via `DatabaseEncryption.ensureNativeLibraryLoaded()` (the `dlopen`). - **Stage B** — a keyed `SQLiteDatabase.openOrCreateDatabase(...)` reaching `SQLiteConnection.nativeOpen`, with a row round-trip. - **Stage C** — the full production path: plaintext Room → `ensureEncrypted` → reopen through `SupportOpenHelperFactory` (mirrors `DatabaseModule`). ### On-device A/B (Pixel 8 Pro `husky`, **real** SDK 37 / Android 17, arm64-v8a) | Environment | Stage A (loadLibrary) | Stage B (nativeOpen) | Stage C (Room path) | |---|---|---|---| | **4 KB pages** (`getconf PAGE_SIZE`=4096) | PASS | PASS | PASS | | **16 KB pages** (arm64) | not testable on this device — see below | — | — | The 4 KB run is a clean baseline on a **genuine** SDK 37 device: the encrypted cache opens end-to-end, so "real SDK 37" alone is **not** the cause. (In-process breadcrumbs confirmed `PAGE_SIZE=4096`, SDK=37, abis=arm64-v8a.) **Why the 16 KB half is blocked:** the Pixel "Boot with 16 KB page size" developer toggle (`Settings.Global enable_16k_pages`) is gated behind an **unlocked bootloader**. The device dialog reads: > *"This device needs to have the bootloader unlocked before using the 16 KB developer option … **All > user data and settings will be wiped when activating 16 KB mode.** … To return the device to > production mode, you would need to switch back to 4 KB mode and then OEM/bootloader lock (which > factory resets) the device."* Setting `enable_16k_pages=1` + a plain `adb reboot` does **not** switch (`ro.boot.hardware.cpu.pagesize` stays 4096 — init ignores the flag with a locked bootloader). Unlocking + activating would factory-wipe a personal daily-driver — out of scope for "toggle + reboot." The device was left exactly as found (`enable_16k_pages=0`, `PAGE_SIZE=4096`, on the launcher). ### Static ELF proof — **this refutes the #359 root-cause hypothesis** #359 suspected the bundled `.so` isn't 16 KB-aligned. Independently checked `PT_LOAD` `p_align` on every packaged native library (16 KB pages need `p_align >= 0x4000`): | Library | arm64-v8a | armeabi-v7a | x86_64 | x86 | |---|---|---|---|---| | **libsqlcipher.so** (`sqlcipher-android:4.16.0`) | `0x4000` | `0x4000` | `0x4000` | `0x4000` | | libandroidx.graphics.path.so | `0x4000` | — | — | — | | libdatastore_shared_counter.so | `0x4000` | — | — | — | **Every bundled `.so` is already 16 KB-aligned, including arm64-v8a `libsqlcipher.so`.** This exactly reproduces what the repo's own `docs/play-compliance.md` §2 already certifies. `sqlcipher` has been pinned to `4.16.0` since the original encrypted-cache commit (`96c9f71`), so the crashing 2026-07-03 build shipped the **same aligned** library. Corroborating at runtime: CI's `e2e-preview` job runs the existing SQLCipher instrumented tests on the `google_apis_ps16k` (16 KB) **x86_64** emulator and is green — the aligned x86_64 `.so` loads and opens a keyed DB on 16 KB pages. ### Conclusion / fix direction - The "rebuild/bump `sqlcipher-android` for 16 KB alignment" fix implied by #359 would **not** fix anything — the lib is already correctly 16 KB-aligned on all ABIs, and AGP 9.2 already emits 16 KB **zip**-aligned uncompressed libs by default (`docs/play-compliance.md` §2). This closes the alignment theory and saves that wild-goose chase. - The `UnsatisfiedLinkError @ SQLiteConnection.nativeOpen` therefore has a **different** root cause. Redirect the investigation to: 1. **Library-load ordering (prime suspect)** — a path that reaches a keyed `nativeOpen` without a successful preceding `System.loadLibrary("sqlcipher")`. This is the #221/#367 class and has the *identical* symptom. The dropbox `data_app_crash` means a `nativeOpen` escaped #367's fail-closed `try/catch`: audit every opener of the keyed cache (WorkManager workers, `AccountDatabase`, the `AccountDataMigrator` legacy path, cold-open) to confirm each is preceded by `ensureNativeLibraryLoaded()` **and** wrapped by that handler. 2. **Delivered-artifact specifics** — the crash was a release/app-bundle build on a real 16 KB arm64 device; confirm the delivered split actually contained arm64-v8a `libsqlcipher.so` and that Play's re-generated split preserved 16 KB zip-alignment (AGP handles the build-side; Play re-signs). 3. **Capture the real stacktrace** — the exact throwable + `.so` name from a genuine 16 KB arm64 device (the Pixel 10 Pro XL from the report) will disambiguate 1 vs 2 immediately. ### To complete the same-device A/B (what's needed) A **16 KB arm64** environment: either the crash-report Pixel 10 Pro XL, or an explicitly authorized bootloader-unlock + factory-wipe of a spare/unlocked Pixel 8. `SqlCipherOpenSpikeTest` is ready to drop the verdict (which stage, full stacktrace) the moment it runs there. On the two environments available today (4 KB arm64 device; 16 KB x86_64 `e2e-preview`) it passes and doubles as a regression guard against a future dependency de-aligning the `.so` or breaking the load-ordering path. Informs #359 (does not fix).
You are not authorized to merge this pull request.
This pull request can be merged automatically.
This pull request is marked as a work in progress.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin spike-359-sqlcipher-ondevice:spike-359-sqlcipher-ondevice
git checkout spike-359-sqlcipher-ondevice
Sign in to join this conversation.