fix(data): degrade encrypted cache to plaintext on SQLCipher native-load failure

On Android 15+ / SDK 37 devices with 16 KB memory pages (e.g. Pixel 10 Pro XL,
and the API-37 `google_apis_ps16k` emulator image), a native `.so` not aligned
for 16 KB pages fails to load with `UnsatisfiedLinkError` at
`SQLiteConnection.nativeOpen`. With the opt-in SQLCipher encrypted cache on, this
crashed the app on every cold start (issue #359, x4 on-device) instead of
degrading, and encryption silently never applied.

Fix: DatabaseProvisioner's encryption gate now catches `LinkageError`
(UnsatisfiedLinkError and related native-link failures) when opening/converting
the encrypted cache and degrades cleanly instead of propagating the crash — it
turns `encryptCache` off (so the next start does not re-attempt and re-wipe),
clears any on-disk ciphertext the plaintext framework opener cannot parse
(resetting its now-useless seals), and opens the cache unencrypted. The cache is
a re-syncable copy of server mail, so clearing it loses nothing that cannot be
re-fetched. PII-free AppLog.w breadcrumb on the degrade path.

Dependency: no bump needed or available. The repo already pins the newest
SQLCipher it references, `net.zetetic:sqlcipher-android:4.16.0`, which
docs/play-compliance.md certifies (ELF p_align = 0x4000) as 16 KB-aligned on
every ABI; SQLCipher has shipped 16 KB-aligned binaries since well before it, and
the other two bundled `.so` files (Compose graphics-path, DataStore
shared-counter) are already 16 KB-aligned per that doc. The graceful-degrade
catch is therefore the actionable fix.

Tests:
- Unit (DatabaseProvisionerTest): a simulated native-load failure degrades to a
  plaintext open without crashing, turns encryptCache off, and wipes + reseals an
  already-encrypted cache.
- Instrumented (DatabaseProvisionerInstrumentedTest): a fresh encrypt-on start
  loads the real SQLCipher native library and opens the keyed cache — CI's API-37
  `google_apis_ps16k` 16 KB job exercises the actual `.so` load, catching any
  future 16 KB-alignment regression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 19:26:05 -05:00
co-authored by Claude Opus 4.8
parent 6cb179bd8b
commit 921681812c
3 changed files with 113 additions and 0 deletions
@@ -142,6 +142,34 @@ class DatabaseProvisionerInstrumentedTest {
}
}
/**
* Issue #359: with `encryptCache` on and NO cache yet (a fresh install enabling encryption), the
* provisioner must load SQLCipher's native library, report [CacheOpenMode.Encrypted], and a real keyed
* open must then create and read the encrypted cache — i.e. `libsqlcipher.so` actually loads and runs.
*
* On a 16 KB memory-page device/image (Android 15+, and the CI API-37 preview `google_apis_ps16k`
* E2E image) an `.so` not aligned for 16 KB pages fails exactly here with `UnsatisfiedLinkError` at
* `SQLiteConnection.nativeOpen`. Running this on that image makes the 16 KB native-lib load a tested
* invariant, so a dependency bump that regressed alignment is caught in CI rather than on-device.
*/
@Test
fun freshEncryptOnStartLoadsThe16KbNativeLibAndOpensKeyedWithoutCrashing() = runBlocking<Unit> {
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false))
assertFalse("precondition: no cache file exists yet", dbFile.exists())
val mode = provisioner().prepareCache()
assertEquals(CacheOpenMode.Encrypted(passphrase), mode)
// The keyed open must actually succeed on real SQLCipher — loading and using libsqlcipher.so on
// whatever ABI / page size this device or emulator image uses.
openEncrypted().apply {
messageDao().insertNew(listOf(message("acct:1")))
assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id })
close()
}
assertTrue("the fresh cache was created in SQLCipher (encrypted) form", DatabaseEncryption.isEncrypted(dbFile))
}
@Test
fun encryptionTurnedOffDecryptsAnEncryptedCacheToPlaintext() = runBlocking<Unit> {
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = false, appLock = false))
@@ -10,7 +10,10 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.libremail.data.security.DatabaseKeyStore
import org.libremail.data.settings.AppSettings
import org.libremail.data.settings.SettingsRepository
import org.libremail.reporting.AppLog
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@@ -115,6 +118,24 @@ class DatabaseProvisioner internal constructor(
// resolvePassphrase waits on PassphraseSession until the user authenticates — which is why this
// must never run on the main thread while the cache is locked (issue #93).
val settings = settingsRepository.settings.first()
return try {
resolveOpenMode(settings, dbFile)
} catch (nativeLoadFailure: LinkageError) {
// SQLCipher's native library could not be loaded/linked — most likely an `.so` not aligned
// for the 16 KB memory pages Android 15+ / SDK 37 devices use, which surfaces as an
// UnsatisfiedLinkError at SQLiteConnection.nativeOpen (issue #359). Rather than crash-loop the
// app on every cold start, degrade to an unencrypted cache.
degradeToUnencryptedCache(dbFile, nativeLoadFailure)
}
}
/**
* The encryption gate (step 3 of [runStartupSequence]): convert the on-disk cache to the form the
* `encryptCache` setting asks for and report how Room must open it. Split out so a native-library
* load failure on either the encrypt or the decrypt-on-disable path (both need SQLCipher's `.so`) is
* caught in one place — see [runStartupSequence]'s handler and [degradeToUnencryptedCache].
*/
private suspend fun resolveOpenMode(settings: AppSettings, dbFile: File): CacheOpenMode {
val appLock = settings.appLock
return when {
settings.encryptCache -> {
@@ -140,4 +161,26 @@ class DatabaseProvisioner internal constructor(
else -> CacheOpenMode.Plaintext
}
}
/**
* Fallback for issue #359 when SQLCipher's native library will not load on this device: encryption
* cannot apply, so open the cache unencrypted instead of throwing. Turns `encryptCache` off so the
* next start does not re-attempt (and re-wipe) the same failing conversion, and if the on-disk cache
* is currently ciphertext — which the plaintext framework opener cannot parse — clears it and resets
* its now-useless seals. The cache is a re-syncable copy of server mail, so clearing it loses nothing
* that cannot be re-fetched.
*/
private suspend fun degradeToUnencryptedCache(dbFile: File, cause: LinkageError): CacheOpenMode {
AppLog.w(TAG, "SQLCipher native library failed to load; opening the cache unencrypted", cause)
settingsRepository.setEncryptCache(false)
if (DatabaseEncryption.isEncrypted(dbFile)) {
DatabaseFiles.clear(context)
keyStore.resetSealedPassphrase()
}
return CacheOpenMode.Plaintext
}
private companion object {
const val TAG = "DatabaseProvisioner"
}
}
@@ -10,6 +10,7 @@ import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
@@ -56,6 +57,11 @@ class DatabaseProvisionerTest {
.asCoroutineDispatcher()
mockkObject(DatabaseEncryption)
mockkObject(DatabaseFiles)
// `android.util.Log` is a no-op stub under plain JVM unit tests; the #359 degrade path breadcrumbs
// through AppLog.w, so statically mock Log (fully-qualified — a raw android.util.Log import is
// detekt-forbidden, epic #324) so it does not throw "not mocked".
mockkStatic(android.util.Log::class)
every { android.util.Log.w(any<String>(), any<String>(), any()) } returns 0
every { context.getDatabasePath(any()) } returns File("libremail.db")
every { DatabaseFiles.clear(any()) } just Runs
@@ -98,6 +104,42 @@ class DatabaseProvisionerTest {
verify(exactly = 1) { DatabaseEncryption.ensureNativeLibraryLoaded() }
}
@Test
fun `a native-library load failure degrades an encrypted cache to a plaintext open`() = runTest {
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false))
coEvery { settingsRepository.setEncryptCache(any()) } just Runs
// Issue #359: on a 16 KB-page device the SQLCipher `.so` fails to load, throwing UnsatisfiedLinkError
// (a LinkageError) at the keyed open. The provisioner must degrade, not propagate the crash.
val nativeLoadFailure = UnsatisfiedLinkError("dlopen failed: libsqlcipher.so is not 16 KB aligned")
every { DatabaseEncryption.ensureNativeLibraryLoaded() } throws nativeLoadFailure
val mode = provisioner().prepareCache()
// Degrades to a working plaintext open and turns the setting off so the next start does not
// re-attempt the same failing conversion (matching the crash-report forensics: encryptCache=false).
assertEquals(CacheOpenMode.Plaintext, mode)
coVerify(exactly = 1) { settingsRepository.setEncryptCache(false) }
// The on-disk cache is plaintext here (isEncrypted stubbed false), so there is nothing to wipe.
verify(exactly = 0) { DatabaseFiles.clear(any()) }
}
@Test
fun `a native-library load failure wipes an already-encrypted cache and resets its seals`() = runTest {
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false))
coEvery { settingsRepository.setEncryptCache(any()) } just Runs
every { DatabaseEncryption.isEncrypted(any()) } returns true
val nativeLoadFailure = UnsatisfiedLinkError("dlopen failed: libsqlcipher.so is not 16 KB aligned")
every { DatabaseEncryption.ensureEncrypted(any(), any()) } throws nativeLoadFailure
val mode = provisioner().prepareCache()
assertEquals(CacheOpenMode.Plaintext, mode)
coVerify(exactly = 1) { settingsRepository.setEncryptCache(false) }
// Ciphertext the plaintext framework opener cannot parse is cleared, and its stale seal reset.
verify(exactly = 1) { DatabaseFiles.clear(any()) }
coVerify(exactly = 1) { keyStore.resetSealedPassphrase() }
}
@Test
fun `prepareCache suspends on the auth-bound passphrase until it resolves`() = runTest {
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = true))