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:
+28
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user