From 921681812ca88170c4db2b212de6e50c6818cb29 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sun, 5 Jul 2026 19:26:05 -0500 Subject: [PATCH 1/4] fix(data): degrade encrypted cache to plaintext on SQLCipher native-load failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../DatabaseProvisionerInstrumentedTest.kt | 28 ++++++++++++ .../data/local/DatabaseProvisioner.kt | 43 +++++++++++++++++++ .../data/local/DatabaseProvisionerTest.kt | 42 ++++++++++++++++++ 3 files changed, 113 insertions(+) diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseProvisionerInstrumentedTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseProvisionerInstrumentedTest.kt index 58f53dc..ee49502 100644 --- a/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseProvisionerInstrumentedTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseProvisionerInstrumentedTest.kt @@ -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 { + 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 { every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = false, appLock = false)) diff --git a/app/src/main/kotlin/org/libremail/data/local/DatabaseProvisioner.kt b/app/src/main/kotlin/org/libremail/data/local/DatabaseProvisioner.kt index 24ec574..490c51f 100644 --- a/app/src/main/kotlin/org/libremail/data/local/DatabaseProvisioner.kt +++ b/app/src/main/kotlin/org/libremail/data/local/DatabaseProvisioner.kt @@ -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" + } } diff --git a/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt b/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt index 6265d05..942522e 100644 --- a/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt +++ b/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt @@ -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(), any(), 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)) From 551a2df66f09ba1bfc3bd91d4c49182dbe5f5217 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 6 Jul 2026 13:46:03 -0500 Subject: [PATCH 2/4] feat(security): back cache-key Keystore keys with StrongBox, fall back to TEE (#359) Bind the non-exportable AES-256-GCM keys that seal the SQLCipher cache passphrase to the hardware StrongBox secure element when the device has one. Applied in the single shared place, AesGcmKeystoreCipher, so it covers both the master (KeystoreCrypto) and auth-bound (DatabaseKeyCipher) keys. Devices without StrongBox throw StrongBoxUnavailableException at KeyGenerator.generateKey(); a new generate-with-fallback path catches it and regenerates a TEE-backed key so key creation still succeeds everywhere. Guarded on API 28+ (minSdk is 29). Framing, seal/unseal, and the missing-key policies are unchanged; the passphrase is still never plaintext at rest and never logged. Adds a JVM regression test for the StrongBox->TEE fallback via the existing test seams (existingKey / a new generateKey seam). Co-Authored-By: Claude Opus 4.8 --- .../data/security/AesGcmKeystoreCipher.kt | 43 ++++++++++++-- .../data/security/DatabaseKeyCipher.kt | 4 +- .../libremail/data/security/KeystoreCrypto.kt | 2 +- .../data/security/AesGcmKeystoreCipherTest.kt | 57 ++++++++++++++++++- 4 files changed, 97 insertions(+), 9 deletions(-) diff --git a/app/src/main/kotlin/org/libremail/data/security/AesGcmKeystoreCipher.kt b/app/src/main/kotlin/org/libremail/data/security/AesGcmKeystoreCipher.kt index be37397..61e94be 100644 --- a/app/src/main/kotlin/org/libremail/data/security/AesGcmKeystoreCipher.kt +++ b/app/src/main/kotlin/org/libremail/data/security/AesGcmKeystoreCipher.kt @@ -1,9 +1,12 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.data.security +import android.os.Build import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties +import android.security.keystore.StrongBoxUnavailableException import android.util.Base64 +import org.libremail.reporting.AppLog import java.security.GeneralSecurityException import java.security.KeyStore import javax.crypto.AEADBadTagException @@ -115,23 +118,52 @@ abstract class AesGcmKeystoreCipher(private val alias: String, private val gener // Synchronized so two concurrent first-run encrypts can't both generate a key under the same // alias — the second would overwrite the first, leaving the first secret undecryptable. protected open fun getOrCreateKey(): SecretKey = synchronized(keyLock) { - existingKey()?.let { return it } + existingKey() ?: generateKeyWithStrongBoxFallback() + } + + /** + * Mint the key, preferring the hardware **StrongBox** secure element (a dedicated tamper-resistant + * chip) so the non-exportable key is bound to the strongest keystore available. Devices without + * StrongBox report [StrongBoxUnavailableException] at generation time; we then regenerate a + * TEE-backed key so key creation still succeeds on every device. Both the master ([KeystoreCrypto]) + * and auth-bound ([DatabaseKeyCipher]) keys inherit this through the shared base. + */ + private fun generateKeyWithStrongBoxFallback(): SecretKey = try { + generateKey(strongBox = true) + } catch (e: StrongBoxUnavailableException) { + // Expected on devices with no StrongBox — not an error. PII-free (a device-capability fact). + AppLog.i(TAG, "StrongBox unavailable for Keystore alias '$alias'; using a TEE-backed key: ${e.message}") + generateKey(strongBox = false) + } + + /** + * Test seam over the raw Android Keystore key generation for a given [strongBox] preference. The + * real [KeyGenerator] is device-only, so JVM unit tests override this to exercise the StrongBox + * fallback in [generateKeyWithStrongBoxFallback] without a Keystore. + */ + protected open fun generateKey(strongBox: Boolean): SecretKey { val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) - generator.init(keySpec()) - generator.generateKey() + generator.init(keySpec(strongBox)) + return generator.generateKey() } /** The alias-bound [KeyGenParameterSpec] for this key; subclasses extend [keySpecBuilder]. */ - protected abstract fun keySpec(): KeyGenParameterSpec + protected abstract fun keySpec(strongBox: Boolean): KeyGenParameterSpec /** The common AES-256-GCM builder (encrypt + decrypt, GCM, no padding, 256-bit) to extend. */ - protected fun keySpecBuilder(): KeyGenParameterSpec.Builder = KeyGenParameterSpec.Builder( + protected fun keySpecBuilder(strongBox: Boolean): KeyGenParameterSpec.Builder = KeyGenParameterSpec.Builder( alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, ) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setKeySize(AES_KEY_SIZE_BITS) + .apply { + // Bind the key to the StrongBox secure element when requested and supported (API 28+; minSdk + // is 29, so the guard is defensive). If the device has no StrongBox, generateKey() catches + // StrongBoxUnavailableException and retries with strongBox = false for a TEE-backed key. + if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) setIsStrongBoxBacked(true) + } private companion object { const val ANDROID_KEYSTORE = "AndroidKeyStore" @@ -139,5 +171,6 @@ abstract class AesGcmKeystoreCipher(private val alias: String, private val gener const val IV_LENGTH = 12 const val TAG_BITS = 128 const val AES_KEY_SIZE_BITS = 256 + const val TAG = "AesGcmKeystoreCipher" } } diff --git a/app/src/main/kotlin/org/libremail/data/security/DatabaseKeyCipher.kt b/app/src/main/kotlin/org/libremail/data/security/DatabaseKeyCipher.kt index 1d51852..9642132 100644 --- a/app/src/main/kotlin/org/libremail/data/security/DatabaseKeyCipher.kt +++ b/app/src/main/kotlin/org/libremail/data/security/DatabaseKeyCipher.kt @@ -82,8 +82,8 @@ class DatabaseKeyCipher @Inject constructor() : /** A missing auth-bound key means it was invalidated; surface that instead of regenerating. */ override fun onMissingDecryptionKey(): Nothing = error("auth-bound database key is missing") - override fun keySpec(): KeyGenParameterSpec { - val builder = keySpecBuilder() + override fun keySpec(strongBox: Boolean): KeyGenParameterSpec { + val builder = keySpecBuilder(strongBox) .setUserAuthenticationRequired(true) .setInvalidatedByBiometricEnrollment(true) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { diff --git a/app/src/main/kotlin/org/libremail/data/security/KeystoreCrypto.kt b/app/src/main/kotlin/org/libremail/data/security/KeystoreCrypto.kt index 8844203..8f3dcea 100644 --- a/app/src/main/kotlin/org/libremail/data/security/KeystoreCrypto.kt +++ b/app/src/main/kotlin/org/libremail/data/security/KeystoreCrypto.kt @@ -18,7 +18,7 @@ import javax.inject.Singleton @Singleton class KeystoreCrypto @Inject constructor() : AesGcmKeystoreCipher(alias = KEY_ALIAS, generateKeyOnDecrypt = true) { - override fun keySpec(): KeyGenParameterSpec = keySpecBuilder().build() + override fun keySpec(strongBox: Boolean): KeyGenParameterSpec = keySpecBuilder(strongBox).build() private companion object { const val KEY_ALIAS = "libremail.master.key" diff --git a/app/src/test/kotlin/org/libremail/data/security/AesGcmKeystoreCipherTest.kt b/app/src/test/kotlin/org/libremail/data/security/AesGcmKeystoreCipherTest.kt index b6438e8..e91eee8 100644 --- a/app/src/test/kotlin/org/libremail/data/security/AesGcmKeystoreCipherTest.kt +++ b/app/src/test/kotlin/org/libremail/data/security/AesGcmKeystoreCipherTest.kt @@ -2,6 +2,11 @@ package org.libremail.data.security import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.StrongBoxUnavailableException +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic import org.junit.Test import java.security.GeneralSecurityException import javax.crypto.AEADBadTagException @@ -70,6 +75,27 @@ class AesGcmKeystoreCipherTest { assertTrue(error.message!!.contains("test.alias")) } + @Test + fun `key generation falls back to a TEE-backed key when StrongBox is unavailable`() { + // AppLog.i (breadcrumb on the fallback) forwards to android.util.Log, a no-op stub under plain JVM + // tests; mock it (fully-qualified — a raw android.util.Log import is detekt-forbidden, epic #324). + mockkStatic(android.util.Log::class) + every { android.util.Log.i(any(), any()) } returns 0 + try { + val teeKey = newAesKey() + val cipher = StrongBoxFakeCipher(teeKey) + + val key = cipher.createKey() + + // StrongBox is attempted first; its StrongBoxUnavailableException triggers a single retry with + // strongBox = false, and that TEE-backed key is returned — so generation succeeds everywhere. + assertSame(teeKey, key) + assertEquals(listOf(true, false), cipher.attempts) + } finally { + unmockkStatic(android.util.Log::class) + } + } + @Test fun `a non-AEAD failure propagates unwrapped so key invalidation still surfaces`() { // Only AEADBadTagException is remapped; every other cipher failure — including the @@ -110,7 +136,36 @@ class AesGcmKeystoreCipherTest { return onDecrypt(key, encoded) } - override fun keySpec(): KeyGenParameterSpec = error("keySpec is not exercised in the JVM base test") + override fun keySpec(strongBox: Boolean): KeyGenParameterSpec = + error("keySpec is not exercised in the JVM base test") + } + + /** + * A JVM-only cipher that exercises the REAL [getOrCreateKey]/StrongBox-fallback control flow (unlike + * [FakeCipher], which stubs [getOrCreateKey] out). [existingKey] returns null so a key is generated, + * and the [generateKey] seam simulates the device: a StrongBox attempt fails, the TEE attempt yields + * [teeKey]. [attempts] records the `strongBox` value of each generation attempt in order. + */ + private class StrongBoxFakeCipher(private val teeKey: SecretKey) : + AesGcmKeystoreCipher(alias = "test.alias", generateKeyOnDecrypt = true) { + + val attempts = mutableListOf() + + override fun existingKey(): SecretKey? = null + + override fun generateKey(strongBox: Boolean): SecretKey { + attempts += strongBox + // Objenesis-instantiated (no stubbed-constructor call) so it is throwable under the android.jar + // stub; it is still a StrongBoxUnavailableException, so the production catch clause matches. + if (strongBox) throw mockk(relaxed = true) + return teeKey + } + + override fun keySpec(strongBox: Boolean): KeyGenParameterSpec = + error("keySpec is bypassed because generateKey is overridden") + + /** Invokes the protected [getOrCreateKey] so the StrongBox fallback runs without touching Base64. */ + fun createKey(): SecretKey = getOrCreateKey() } private companion object { From bfe5d466548586a33eaacd36050162c010e03828 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 6 Jul 2026 13:46:20 -0500 Subject: [PATCH 3/4] fix(security): fail closed on cache-encryption load failure with an error gate (#359) Reworks #367. When SQLCipher native library loading fails while the opt-in encrypted cache is enabled, the app previously degraded to a plaintext cache (a silent fail-open that defeats the feature). Now it FAILS CLOSED. DatabaseProvisioner raises a distinct CacheEncryptionUnavailableException instead of degrading: it does NOT open plaintext, NOT wipe the on-disk ciphertext, and NOT write the encryptCache setting. The throw is not memoized, so a later launch re-attempts and recovers automatically if the library loads. A new CacheEncryptionGate wraps the app inside AppLockGateHost (so the passphrase is already unlocked), probes prepareCache() before any DB-backed screen composes, and on failure shows CacheEncryptionErrorScreen with the exact message "Error - decryption could not proceed. Native decryption library load failure." plus a "Report a problem" action. That action generates an EPHEMERAL PII-free report via the existing DiagnosticsCollector (never written to ReportStore, since encryption is unavailable in that moment) for on-screen review and explicit Copy/Save; the copy says so. The plaintext AccountDatabase tolerates the exception so accounts stay readable for the error gate and the report. The encryptCache setting is now written by exactly one caller: the user Settings toggle. Tests: fail-closed raises the signal with no plaintext open / no wipe / no setting write / not memoized; the gate VM resolves Ready vs Unavailable and builds the ephemeral report; an instrumented error-screen UI test and an AccountDatabase-resilience instrumented test. Co-Authored-By: Claude Opus 4.8 --- .../AccountDatabaseModuleInstrumentedTest.kt | 109 +++++++ .../CacheEncryptionErrorScreenTest.kt | 55 ++++ .../main/kotlin/org/libremail/MainActivity.kt | 19 +- .../CacheEncryptionUnavailableException.kt | 24 ++ .../data/local/DatabaseProvisioner.kt | 41 ++- .../org/libremail/di/AccountDatabaseModule.kt | 17 +- .../ui/security/CacheEncryptionGate.kt | 265 ++++++++++++++++++ .../security/CacheEncryptionGateViewModel.kt | 111 ++++++++ app/src/main/res/values/strings.xml | 12 + .../data/local/DatabaseProvisionerTest.kt | 62 ++-- .../CacheEncryptionGateViewModelTest.kt | 131 +++++++++ 11 files changed, 795 insertions(+), 51 deletions(-) create mode 100644 app/src/androidTest/kotlin/org/libremail/di/AccountDatabaseModuleInstrumentedTest.kt create mode 100644 app/src/androidTest/kotlin/org/libremail/ui/security/CacheEncryptionErrorScreenTest.kt create mode 100644 app/src/main/kotlin/org/libremail/data/local/CacheEncryptionUnavailableException.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/security/CacheEncryptionGate.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/security/CacheEncryptionGateViewModel.kt create mode 100644 app/src/test/kotlin/org/libremail/ui/security/CacheEncryptionGateViewModelTest.kt diff --git a/app/src/androidTest/kotlin/org/libremail/di/AccountDatabaseModuleInstrumentedTest.kt b/app/src/androidTest/kotlin/org/libremail/di/AccountDatabaseModuleInstrumentedTest.kt new file mode 100644 index 0000000..072858a --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/di/AccountDatabaseModuleInstrumentedTest.kt @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.di + +import android.content.Context +import android.content.ContextWrapper +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.data.local.AccountDataMigrator +import org.libremail.data.local.DatabaseEncryption +import org.libremail.data.local.DatabaseFiles +import org.libremail.data.local.DatabaseProvisioner +import org.libremail.data.security.DatabaseKeyStore +import org.libremail.data.settings.AppSettings +import org.libremail.data.settings.SettingsRepository +import java.io.File + +/** + * Pins the fail-closed contract's ONE resilience exception (issue #359): the plaintext account store is + * never encrypted and never uses SQLCipher, so a cache-encryption native-load failure — which the + * provisioner surfaces as `CacheEncryptionUnavailableException` — must NOT brick it. If it did, the app + * couldn't read accounts to render the encryption error gate or assemble the PII-free problem report. + * + * Mirrors [DatabaseModuleInstrumentedTest]'s style: MockK collaborators, a real [ContextWrapper] (never + * `mockk()`, which trips an ART parameter-annotation mismatch on API 31/32), and a real + * [DatabaseProvisioner] whose encryption gate is forced to fail via a spied [DatabaseEncryption]. + */ +@RunWith(AndroidJUnit4::class) +class AccountDatabaseModuleInstrumentedTest { + + private val appContext = ApplicationProvider.getApplicationContext() + private val cacheDbName = "accountmodule_cache_test.db" + private val accountsDbName = "accountmodule_accounts_test.db" + private val cacheFile: File get() = appContext.getDatabasePath(cacheDbName) + private val accountsFile: File get() = appContext.getDatabasePath(accountsDbName) + + // 64 hex chars == a 32-byte SQLCipher passphrase. + private val passphrase = "0123456789abcdef".repeat(4) + + private val keyStore = mockk() + private val settingsRepository = mockk() + private val migrator = mockk() + + // Route the provisioner's cache lookup and Room's account-store lookup to this test's private files, + // never the app's real databases. + private val context: Context = object : ContextWrapper(appContext) { + override fun getDatabasePath(name: String): File = when (name) { + DatabaseFiles.NAME -> cacheFile + DatabaseFiles.ACCOUNTS_NAME -> accountsFile + else -> super.getDatabasePath(name) + } + } + + @Before + fun setUp() { + clean() + coEvery { keyStore.isClearPending() } returns false + coEvery { keyStore.resolvePassphrase(any()) } returns passphrase + coEvery { migrator.migrateIfNeeded() } just Runs + } + + @After + fun tearDown() { + unmockkAll() + clean() + } + + private fun clean() { + listOf(cacheDbName, accountsDbName).forEach { name -> + appContext.deleteDatabase(name) + appContext.getDatabasePath(name).parentFile?.listFiles { f -> f.name.startsWith(name) } + ?.forEach { it.delete() } + } + } + + private fun provisioner() = DatabaseProvisioner(context, keyStore, settingsRepository, migrator, Dispatchers.IO) + + @Test + fun accountStoreStillOpensWhenTheCacheEncryptionLibraryFailsToLoad() = runBlocking { + every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false)) + mockkObject(DatabaseEncryption) // spy: real impls run except the forced failure below + // Fault injection: the encrypted-cache gate can't load SQLCipher, so prepareCache() fails closed + // with CacheEncryptionUnavailableException — the exact condition provideAccountDatabase tolerates. + every { DatabaseEncryption.ensureNativeLibraryLoaded() } throws + UnsatisfiedLinkError("dlopen failed: libsqlcipher.so is not loadable") + + val database = AccountDatabaseModule.provideAccountDatabase(context, provisioner()) + try { + // The plaintext account store opens and a query succeeds despite the cache-encryption failure. + assertEquals(emptyList(), database.accountDao().getAll()) + } finally { + database.close() + } + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/security/CacheEncryptionErrorScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/security/CacheEncryptionErrorScreenTest.kt new file mode 100644 index 0000000..4d5d77d --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/security/CacheEncryptionErrorScreenTest.kt @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.security + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.ui.theme.LibreMailTheme + +/** + * UI coverage for the fail-closed encrypted-cache error screen (issue #359). [CacheEncryptionErrorScreen] + * is presentational (its report action is wired by the caller), so it is exercised in isolation: the + * verbatim error message shows, and tapping "Report a problem" reports back. + */ +@RunWith(AndroidJUnit4::class) +class CacheEncryptionErrorScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + + private fun setContent(onReportProblem: () -> Unit = {}) { + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + CacheEncryptionErrorScreen(onReportProblem = onReportProblem) + } + } + } + + @Test + fun showsTheVerbatimErrorMessageAndReportAction() { + setContent() + + // The exact maintainer-specified message must render, unchanged. + composeTestRule.onNodeWithText(string(R.string.cache_encryption_error_message)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.cache_encryption_report_action)).assertIsDisplayed() + } + + @Test + fun tappingReportProblem_invokesCallback() { + var reported = false + setContent(onReportProblem = { reported = true }) + + composeTestRule.onNodeWithText(string(R.string.cache_encryption_report_action)).performClick() + + composeTestRule.waitUntil(5_000) { reported } + } +} diff --git a/app/src/main/kotlin/org/libremail/MainActivity.kt b/app/src/main/kotlin/org/libremail/MainActivity.kt index fc65324..4a09b81 100644 --- a/app/src/main/kotlin/org/libremail/MainActivity.kt +++ b/app/src/main/kotlin/org/libremail/MainActivity.kt @@ -21,6 +21,7 @@ import org.libremail.ui.LibreMailApp import org.libremail.ui.compose.ComposePrefill import org.libremail.ui.compose.IntentComposeParser import org.libremail.ui.lock.AppLockGateHost +import org.libremail.ui.security.CacheEncryptionGate import org.libremail.ui.theme.LibreMailTheme import javax.inject.Inject @@ -73,12 +74,18 @@ class MainActivity : FragmentActivity() { // Gate the whole app behind the screen-lock when app-lock is enabled. When it is off // the gate resolves straight to the content, so this is a no-op for most users. AppLockGateHost { - LibreMailApp( - pendingCompose = pendingCompose.value, - onComposeHandled = { pendingCompose.value = null }, - pendingOpenMessageId = pendingOpenMessageId.value, - onOpenMessageHandled = { pendingOpenMessageId.value = null }, - ) + // Inside the app-lock gate (so the auth-bound passphrase is already unlocked): fail + // closed if the encrypted cache's SQLCipher library won't load (#359), showing the + // error gate instead of ever opening the cache unencrypted. Resolves straight to the + // content when the cache is openable, so it is a no-op for most users. + CacheEncryptionGate { + LibreMailApp( + pendingCompose = pendingCompose.value, + onComposeHandled = { pendingCompose.value = null }, + pendingOpenMessageId = pendingOpenMessageId.value, + onOpenMessageHandled = { pendingOpenMessageId.value = null }, + ) + } } } } diff --git a/app/src/main/kotlin/org/libremail/data/local/CacheEncryptionUnavailableException.kt b/app/src/main/kotlin/org/libremail/data/local/CacheEncryptionUnavailableException.kt new file mode 100644 index 0000000..a73a30a --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/CacheEncryptionUnavailableException.kt @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +/** + * Raised by [DatabaseProvisioner] when the opt-in encrypted cache cannot be opened because SQLCipher's + * native library failed to load or link on this device (issue #359 — e.g. an `.so` the platform + * rejects, surfacing as an `UnsatisfiedLinkError`/`LinkageError` at `SQLiteConnection.nativeOpen` or + * from [DatabaseEncryption.ensureNativeLibraryLoaded]). + * + * The app must **fail closed**: it must NOT fall back to an unencrypted cache (that would silently + * defeat the user's opt-in encryption), NOT wipe the on-disk ciphertext, and NOT touch the + * `encryptCache` setting. Instead this distinct, expected signal is surfaced so the startup UI + * (`CacheEncryptionGate`) can show the encryption error gate — not the mailbox, and not a crash. + * + * Deliberately a dedicated type (not a bare [LinkageError]) so only this precise condition is treated + * as "encryption unavailable"; any other failure still propagates. The provisioner never memoizes it, + * so a later launch — where the library may load, e.g. after an app update — re-attempts and recovers + * automatically. + */ +class CacheEncryptionUnavailableException(cause: Throwable) : + Exception( + "Encrypted cache unavailable: the SQLCipher native library failed to load on this device", + cause, + ) diff --git a/app/src/main/kotlin/org/libremail/data/local/DatabaseProvisioner.kt b/app/src/main/kotlin/org/libremail/data/local/DatabaseProvisioner.kt index 490c51f..e301c02 100644 --- a/app/src/main/kotlin/org/libremail/data/local/DatabaseProvisioner.kt +++ b/app/src/main/kotlin/org/libremail/data/local/DatabaseProvisioner.kt @@ -121,11 +121,21 @@ class DatabaseProvisioner internal constructor( 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) + // FAIL CLOSED (issue #359, security rework of #367). SQLCipher's native library could not be + // loaded/linked (e.g. UnsatisfiedLinkError at SQLiteConnection.nativeOpen or from + // ensureNativeLibraryLoaded), so the encrypted cache cannot be opened OR converted. We must + // NOT silently degrade to an unencrypted cache (that would defeat the user's opt-in + // encryption), so we deliberately do NOT: open plaintext, wipe the on-disk ciphertext, or + // write the encryptCache setting. Instead raise a distinct signal the startup UI catches to + // show the encryption error gate. This throw is NOT memoized (it skips prepareCache's + // `.also { prepared = it }`), so the next launch re-attempts and recovers automatically if + // the library later loads. + AppLog.w( + TAG, + "SQLCipher native library failed to load; failing closed (encrypted cache unavailable)", + nativeLoadFailure, + ) + throw CacheEncryptionUnavailableException(nativeLoadFailure) } } @@ -133,7 +143,8 @@ class DatabaseProvisioner internal constructor( * 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]. + * caught in one place — see [runStartupSequence]'s handler, which fails closed by raising + * [CacheEncryptionUnavailableException] rather than degrading to an unencrypted cache. */ private suspend fun resolveOpenMode(settings: AppSettings, dbFile: File): CacheOpenMode { val appLock = settings.appLock @@ -162,24 +173,6 @@ class DatabaseProvisioner internal constructor( } } - /** - * 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" } diff --git a/app/src/main/kotlin/org/libremail/di/AccountDatabaseModule.kt b/app/src/main/kotlin/org/libremail/di/AccountDatabaseModule.kt index 7e44640..620434e 100644 --- a/app/src/main/kotlin/org/libremail/di/AccountDatabaseModule.kt +++ b/app/src/main/kotlin/org/libremail/di/AccountDatabaseModule.kt @@ -12,6 +12,7 @@ import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.runBlocking import org.libremail.data.local.ACCOUNT_MIGRATION_1_2 import org.libremail.data.local.AccountDatabase +import org.libremail.data.local.CacheEncryptionUnavailableException import org.libremail.data.local.DatabaseFiles.ACCOUNTS_NAME import org.libremail.data.local.DatabaseProvisioner import org.libremail.data.local.DeferredOpenHelperFactory @@ -19,6 +20,7 @@ import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.AccountSettingsDao import org.libremail.data.local.dao.CredentialDao import org.libremail.data.local.dao.SignatureDao +import org.libremail.reporting.AppLog import javax.inject.Singleton /** @@ -37,6 +39,13 @@ object AccountDatabaseModule { * the migrate-before-open ordering the old construction-time dependency on `LibreMailDatabase` * enforced, now moved OFF the injection path (issue #93). This store always opens unkeyed, so it * ignores the returned cache open-mode and only awaits the shared sequence. + * + * This store is plaintext and never uses SQLCipher, so a cache-encryption native-load failure + * (issue #359, surfaced as [CacheEncryptionUnavailableException]) must NOT brick it: the app still + * needs accounts/credentials to render the encryption error gate and let the user file a PII-free + * problem report. The wipe + migrate steps run BEFORE the encryption gate that can throw, so the + * migrate-before-open ordering still holds when we tolerate that one specific failure here; any + * other failure still propagates. */ @Provides @Singleton @@ -47,7 +56,11 @@ object AccountDatabaseModule { .addMigrations(ACCOUNT_MIGRATION_1_2) .openHelperFactory( DeferredOpenHelperFactory { configuration -> - runBlocking { provisioner.prepareCache() } + try { + runBlocking { provisioner.prepareCache() } + } catch (e: CacheEncryptionUnavailableException) { + AppLog.w(TAG, "cache encryption unavailable; opening the plaintext account store anyway", e) + } FrameworkSQLiteOpenHelperFactory().create(configuration) }, ) @@ -64,4 +77,6 @@ object AccountDatabaseModule { @Provides fun provideSignatureDao(database: AccountDatabase): SignatureDao = database.signatureDao() + + private const val TAG = "AccountDatabaseModule" } diff --git a/app/src/main/kotlin/org/libremail/ui/security/CacheEncryptionGate.kt b/app/src/main/kotlin/org/libremail/ui/security/CacheEncryptionGate.kt new file mode 100644 index 0000000..23c73b8 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/security/CacheEncryptionGate.kt @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.security + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.libremail.R +import org.libremail.ui.reporting.copyReportPayloadToClipboard + +/** + * Fail-closed gate for the opt-in encrypted cache (issue #359). Wraps the whole app: while + * [CacheEncryptionGateViewModel] resolves whether the encrypted cache can be opened it shows a blank + * cover, and [content] — the real app — composes only once the gate reports + * [CacheEncryptionGateState.Ready]. If SQLCipher's native library will not load, the gate shows + * [CacheEncryptionErrorScreen] instead of ever opening the cache unencrypted or reaching the mailbox. + * + * Hosted INSIDE the app-lock gate (`AppLockGateHost`), so when app-lock is on the passphrase is already + * unlocked before the probe runs. + */ +@Composable +fun CacheEncryptionGate(viewModel: CacheEncryptionGateViewModel = hiltViewModel(), content: @Composable () -> Unit) { + val state by viewModel.state.collectAsStateWithLifecycle() + when (state) { + CacheEncryptionGateState.Checking -> GateCover() + CacheEncryptionGateState.Ready -> content() + CacheEncryptionGateState.Unavailable -> CacheEncryptionErrorFlow(viewModel) + } +} + +/** Opaque cover shown while the gate resolves, so no DB-backed screen is visible before the decision. */ +@Composable +private fun GateCover() { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {} +} + +/** Error screen ↔ ephemeral report review, kept off the app's NavHost (the app never composed here). */ +@Composable +private fun CacheEncryptionErrorFlow(viewModel: CacheEncryptionGateViewModel) { + var reviewing by rememberSaveable { mutableStateOf(false) } + if (reviewing) { + val payload by viewModel.reportPayload.collectAsStateWithLifecycle() + EphemeralReportReviewScreen( + payload = payload, + onBack = { + viewModel.dismissReport() + reviewing = false + }, + ) + } else { + CacheEncryptionErrorScreen( + onReportProblem = { + viewModel.prepareReport() + reviewing = true + }, + ) + } +} + +/** + * Full-screen fail-closed notice shown when the encrypted cache cannot be opened. Presentational: the + * verbatim error message, a reassurance that nothing was lost, and a "Report a problem" action wired by + * the caller. Deliberately renders no mailbox content. + */ +@Composable +fun CacheEncryptionErrorScreen(onReportProblem: () -> Unit) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(R.string.cache_encryption_error_title), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + // The exact maintainer-specified message; do not reword. + Text( + text = stringResource(R.string.cache_encryption_error_message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(R.string.cache_encryption_error_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(24.dp)) + Button(onClick = onReportProblem) { + Text(stringResource(R.string.cache_encryption_report_action)) + } + } + } +} + +/** + * Ephemeral review of the PII-free diagnostic report generated from the error gate. The report is held + * only in memory (never written to disk); the copy states that plainly. The user can Copy it to the + * clipboard or Save it to a file they choose — the only ways it leaves this screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EphemeralReportReviewScreen(payload: String?, onBack: () -> Unit) { + val context = LocalContext.current + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + val copiedMessage = stringResource(R.string.report_copied) + val savedMessage = stringResource(R.string.report_saved) + + val saveLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json"), + ) { uri -> + val text = payload + if (uri != null && text != null) { + scope.launch { + withContext(Dispatchers.IO) { + runCatching { + context.contentResolver.openOutputStream(uri)?.use { it.write(text.toByteArray()) } + } + } + snackbarHostState.showSnackbar(savedMessage) + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.report_review_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.action_back), + ) + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> + Column( + Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + Text( + stringResource(R.string.cache_encryption_report_ephemeral_notice), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + if (payload == null) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + CircularProgressIndicator() + } + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.cache_encryption_report_generating), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + SelectionContainer { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = payload, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + modifier = Modifier.padding(12.dp), + ) + } + } + Spacer(Modifier.height(16.dp)) + Row(Modifier.fillMaxWidth()) { + TextButton( + onClick = { + scope.launch { + copyReportPayloadToClipboard(clipboard, payload) + snackbarHostState.showSnackbar(copiedMessage) + } + }, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.report_copy)) + } + Spacer(Modifier.width(8.dp)) + TextButton( + onClick = { saveLauncher.launch("libremail-report.json") }, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.report_save)) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/security/CacheEncryptionGateViewModel.kt b/app/src/main/kotlin/org/libremail/ui/security/CacheEncryptionGateViewModel.kt new file mode 100644 index 0000000..774d65a --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/security/CacheEncryptionGateViewModel.kt @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.security + +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.libremail.data.local.CacheEncryptionUnavailableException +import org.libremail.data.local.DatabaseProvisioner +import org.libremail.reporting.AppLog +import org.libremail.reporting.DiagnosticsCollector +import javax.inject.Inject + +/** State of the fail-closed encrypted-cache gate that wraps the app (issue #359). */ +sealed interface CacheEncryptionGateState { + /** Resolving whether the encrypted cache can be opened — render a blank cover, never the app. */ + data object Checking : CacheEncryptionGateState + + /** The cache is openable (encrypted-and-ready, or not encrypted): show the app content. */ + data object Ready : CacheEncryptionGateState + + /** + * SQLCipher's native library will not load, so the encrypted cache cannot be opened. FAIL CLOSED: + * show the error gate instead of the mailbox — never an unencrypted cache. + */ + data object Unavailable : CacheEncryptionGateState +} + +/** + * Drives the fail-closed encrypted-cache gate. On first composition it proactively runs the shared + * startup sequence ([DatabaseProvisioner.prepareCache]) so the cache's open mode is resolved BEFORE any + * DB-backed screen composes. If that raises [CacheEncryptionUnavailableException] — SQLCipher's native + * library failed to load (issue #359) — the gate goes to [CacheEncryptionGateState.Unavailable] and the + * host shows the encryption error screen; otherwise it goes [CacheEncryptionGateState.Ready] and the app + * renders. The failure is never memoized by the provisioner, so a fresh process (e.g. after an app + * update that ships a loadable library) re-probes and recovers automatically. + * + * From the error screen the user can generate an **ephemeral** PII-free diagnostic report + * ([prepareReport]) reusing the app's existing [DiagnosticsCollector]. Because encryption is + * unavailable in this exact moment the report cannot be encrypted at rest, so it is deliberately NOT + * written to `ReportStore` — it lives only in memory for on-screen review and the user's explicit + * Copy/Save. + * + * This VM must be hosted only AFTER the app-lock gate unlocks (see `AppLockGateHost`), so when app-lock + * is on the auth-bound passphrase is already in [org.libremail.data.security.PassphraseSession] and + * `prepareCache()` does not park waiting for authentication. + */ +@HiltViewModel +class CacheEncryptionGateViewModel @Inject constructor( + private val provisioner: DatabaseProvisioner, + private val diagnostics: DiagnosticsCollector, +) : ViewModel() { + + // Injectable so the report collection (which touches DataStore + the account store) is pushed off + // the main thread in production yet runs on the test scheduler in unit tests. prepareCache() already + // switches to its own IO dispatcher internally, so the probe does not need this. + @VisibleForTesting + internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO + + private val _state = MutableStateFlow(CacheEncryptionGateState.Checking) + val state: StateFlow = _state.asStateFlow() + + // The ephemeral report payload, or null before it has been generated / after dismissal. Never + // persisted — it exists only for on-screen review and the user's explicit Copy/Save. + private val _reportPayload = MutableStateFlow(null) + val reportPayload: StateFlow = _reportPayload.asStateFlow() + + init { + probe() + } + + private fun probe() { + viewModelScope.launch { + _state.value = try { + provisioner.prepareCache() + CacheEncryptionGateState.Ready + } catch (e: CacheEncryptionUnavailableException) { + AppLog.w(TAG, "encrypted cache unavailable; showing the fail-closed encryption gate", e) + CacheEncryptionGateState.Unavailable + } + } + } + + /** + * Generate the ephemeral PII-free diagnostic report for on-screen review (idempotent while one is + * already prepared). Reuses [DiagnosticsCollector.collectManual] — the same PII-free assembly the + * normal "Report a problem" flow uses — but the result is held only in memory here, never saved. + */ + fun prepareReport() { + if (_reportPayload.value != null) return + viewModelScope.launch { + _reportPayload.value = withContext(ioDispatcher) { diagnostics.collectManual().toSubmissionPayload() } + } + } + + /** Drop the in-memory report (on leaving the review screen). */ + fun dismissReport() { + _reportPayload.value = null + } + + private companion object { + const val TAG = "CacheEncryptionGate" + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1b8b8c5..f02be49 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -390,6 +390,18 @@ Online submission isn\'t available in this build. Use Copy or Save to share the report. Copied to clipboard Saved + + Encrypted mail unavailable + + Error - decryption could not proceed. Native decryption library load failure. + A required security component could not be loaded, so your encrypted mail can\'t be opened on this device right now. Nothing was changed or deleted — your mail is still on your server. LibreMail will try again the next time you open it. + Report a problem + + This report contains only diagnostic information — no email addresses, message content, or passwords. It is prepared just for you to review now and is NOT saved to this device unless you tap Save. + Preparing report… LibreMail closed unexpectedly A problem report from the last crash is ready for you to review. Nothing is sent automatically. Review diff --git a/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt b/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt index 942522e..7695bb4 100644 --- a/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt +++ b/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt @@ -29,7 +29,9 @@ import org.libremail.data.settings.SettingsRepository import java.io.File import java.util.concurrent.Executors import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertTrue /** * [DatabaseProvisioner] holds the one-time startup sequence that `DatabaseModule.provideDatabase` used @@ -105,39 +107,59 @@ class DatabaseProvisionerTest { } @Test - fun `a native-library load failure degrades an encrypted cache to a plaintext open`() = runTest { + fun `a native-library load failure fails closed without opening plaintext or touching the setting`() = 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. + // Issue #359: the SQLCipher `.so` fails to load, throwing UnsatisfiedLinkError (a LinkageError) at + // the keyed open. The provisioner must FAIL CLOSED — raise a distinct signal, never silently + // degrade to an unencrypted cache (which would defeat the user's opt-in encryption). val nativeLoadFailure = UnsatisfiedLinkError("dlopen failed: libsqlcipher.so is not 16 KB aligned") every { DatabaseEncryption.ensureNativeLibraryLoaded() } throws nativeLoadFailure - val mode = provisioner().prepareCache() + val error = assertFailsWith { 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. + // The distinct signal preserves the underlying native LinkageError in its cause chain (coroutine + // stack-trace recovery may re-wrap the exception across withContext, so assert the chain rather + // than exact instance identity), and NONE of the old fail-open side effects run: the setting is + // never flipped, nothing is wiped, and no seal is reset. + assertTrue( + generateSequence(error.cause) { it.cause }.any { it is LinkageError }, + "the native-load LinkageError must be preserved as the cause", + ) + coVerify(exactly = 0) { settingsRepository.setEncryptCache(any()) } verify(exactly = 0) { DatabaseFiles.clear(any()) } + coVerify(exactly = 0) { keyStore.resetSealedPassphrase() } } @Test - fun `a native-library load failure wipes an already-encrypted cache and resets its seals`() = runTest { + fun `a native-library load failure never wipes an already-encrypted cache`() = 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 + every { DatabaseEncryption.ensureEncrypted(any(), any()) } throws + UnsatisfiedLinkError("dlopen failed: libsqlcipher.so is not 16 KB aligned") - val mode = provisioner().prepareCache() + assertFailsWith { 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() } + // The ciphertext the plaintext opener can't parse is deliberately PRESERVED (it may become + // readable again once the library loads on a later launch), the seals stay intact, and the + // setting is untouched — the opposite of the rejected degrade-and-wipe behaviour. + verify(exactly = 0) { DatabaseFiles.clear(any()) } + coVerify(exactly = 0) { keyStore.resetSealedPassphrase() } + coVerify(exactly = 0) { settingsRepository.setEncryptCache(any()) } + } + + @Test + fun `a native-library load failure is not memoized and retries on the next open`() = runTest { + every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false)) + every { DatabaseEncryption.ensureNativeLibraryLoaded() } throws + UnsatisfiedLinkError("dlopen failed: libsqlcipher.so is not 16 KB aligned") + val provisioner = provisioner() + + assertFailsWith { provisioner.prepareCache() } + assertFailsWith { provisioner.prepareCache() } + + // A failure is NOT memoized (unlike a success): each open re-runs the whole sequence, so the + // migrator ran on BOTH attempts. That is what lets a later launch recover once the library loads. + coVerify(exactly = 2) { accountDataMigrator.migrateIfNeeded() } } @Test diff --git a/app/src/test/kotlin/org/libremail/ui/security/CacheEncryptionGateViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/security/CacheEncryptionGateViewModelTest.kt new file mode 100644 index 0000000..72039c0 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/security/CacheEncryptionGateViewModelTest.kt @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.security + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.data.local.CacheEncryptionUnavailableException +import org.libremail.data.local.CacheOpenMode +import org.libremail.data.local.DatabaseProvisioner +import org.libremail.reporting.DebugReport +import org.libremail.reporting.DiagnosticsCollector +import org.libremail.reporting.ReportKind +import kotlin.test.assertEquals + +/** + * Unit coverage for the fail-closed encrypted-cache gate (issue #359). Pins the gate's two security + * outcomes — that a normal start reaches [CacheEncryptionGateState.Ready] and that a + * [CacheEncryptionUnavailableException] from the provisioner resolves to + * [CacheEncryptionGateState.Unavailable] WITHOUT rethrowing/crashing — plus that the report it offers is + * ephemeral: assembled from the existing [DiagnosticsCollector] and never routed through a [ReportStore] + * (the VM has no such dependency to persist through). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CacheEncryptionGateViewModelTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun viewModel( + provisioner: DatabaseProvisioner, + diagnostics: DiagnosticsCollector = mockk(relaxed = true), + ) = CacheEncryptionGateViewModel(provisioner, diagnostics).also { it.ioDispatcher = dispatcher } + + private fun debugReport() = DebugReport( + id = "gate-report", + createdAtMillis = 1L, + kind = ReportKind.MANUAL, + appVersionName = "0.1.0", + appVersionCode = 1, + androidRelease = "14", + androidSdkInt = 34, + deviceManufacturer = "Google", + deviceModel = "Pixel", + stackTrace = null, + settings = mapOf("encryptCache" to "true"), + logs = listOf("W/DatabaseProvisioner: SQLCipher native library failed to load"), + ) + + @Test + fun `the gate reaches Ready when the cache can be opened`() = runTest(dispatcher) { + val provisioner = mockk() + coEvery { provisioner.prepareCache() } returns CacheOpenMode.Plaintext + + val vm = viewModel(provisioner) + + assertEquals(CacheEncryptionGateState.Ready, vm.state.value) + } + + @Test + fun `the gate fails closed to Unavailable when the encrypted cache cannot be opened`() = runTest(dispatcher) { + // The probe logs the failure via AppLog.w -> android.util.Log (a no-op stub in JVM tests); mock it + // (fully-qualified — a raw android.util.Log import is detekt-forbidden, epic #324). + mockkStatic(android.util.Log::class) + every { android.util.Log.w(any(), any(), any()) } returns 0 + try { + val provisioner = mockk() + coEvery { provisioner.prepareCache() } throws + CacheEncryptionUnavailableException(UnsatisfiedLinkError("dlopen failed: libsqlcipher.so")) + + val vm = viewModel(provisioner) + + // Fail closed: the error gate is shown (never the mailbox), and the VM did NOT rethrow/crash. + assertEquals(CacheEncryptionGateState.Unavailable, vm.state.value) + } finally { + unmockkStatic(android.util.Log::class) + } + } + + @Test + fun `prepareReport builds an ephemeral payload from the diagnostics collector, never persisting`() = + runTest(dispatcher) { + val provisioner = mockk() + coEvery { provisioner.prepareCache() } returns CacheOpenMode.Plaintext + val diagnostics = mockk() + val report = debugReport() + coEvery { diagnostics.collectManual() } returns report + + val vm = viewModel(provisioner, diagnostics) + vm.prepareReport() + advanceUntilIdle() + + // The payload is exactly the reused DiagnosticsCollector rendering — and this VM depends on no + // ReportStore, so it structurally cannot write the report to disk (ephemeral by construction). + assertEquals(report.toSubmissionPayload(), vm.reportPayload.value) + coVerify(exactly = 1) { diagnostics.collectManual() } + } + + @Test + fun `prepareReport is idempotent while a report is already prepared`() = runTest(dispatcher) { + val provisioner = mockk() + coEvery { provisioner.prepareCache() } returns CacheOpenMode.Plaintext + val diagnostics = mockk() + coEvery { diagnostics.collectManual() } returns debugReport() + + val vm = viewModel(provisioner, diagnostics) + vm.prepareReport() + advanceUntilIdle() + vm.prepareReport() // second tap while one is already held + advanceUntilIdle() + + coVerify(exactly = 1) { diagnostics.collectManual() } + } +} From 042b50116c07fd69d0c07e4892bccfeca609c3fd Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 6 Jul 2026 14:19:22 -0500 Subject: [PATCH 4/4] build(jacoco): scope the fail-closed encryption UI out of the JVM coverage surface (#359) CacheEncryptionGate.kt (the gate composable, blank cover, error screen, and ephemeral report-review screen added for #359) is pure Compose render code, structurally unreachable from a JVM unit test the same way every other Screen file in jacocoNonJvmTestableSurface is. Left in scope, it dragged the whole-app line ratio to 0.78, just under the 0.79 no-regression floor. Excluded it via "**/CacheEncryptionGateKt*" rather than the usual bare "**/CacheEncryptionGate*" pattern this list otherwise uses, because CacheEncryptionGateViewModel is named with "CacheEncryptionGate" as a literal prefix - the bare wildcard would also have swallowed the already JVM-tested, 94%-covered ViewModel and its sealed CacheEncryptionGateState. CacheEncryptionGateViewModel and CacheEncryptionUnavailableException stay in scope unchanged. Verified locally: testDebugUnitTest + jacocoTestCoverageVerification now pass, with the line ratio recovered to about 0.807 (5,044 covered / 6,249 total lines) - the same 5,044 covered lines as before, just a smaller, honestly-JVM-testable denominator. Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ef0c692..946b482 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -311,6 +311,15 @@ val jacocoNonJvmTestableSurface = listOf( "**/SettingsComponents*", "**/SignatureEditScreen*", "**/SignaturesScreen*", + // CacheEncryptionGate.kt (issue #359/#367 fail-closed encryption gate) is pure render: the gate + // composable, its blank cover, the error screen, and the ephemeral report-review screen — no plain + // top-level logic. Spelled out to "...GateKt*" (the file's compiled facade class), NOT the bare + // "**/CacheEncryptionGate*" this list otherwise uses, because unlike every Screen/ViewModel pair + // above, CacheEncryptionGateViewModel's name literally starts with "CacheEncryptionGate" — a bare + // wildcard would also swallow the (94%-covered, dedicated-tested) ViewModel and its sealed + // CacheEncryptionGateState. CacheEncryptionGateViewModel and CacheEncryptionUnavailableException + // stay in scope (both have JVM tests: CacheEncryptionGateViewModelTest, DatabaseProvisionerTest). + "**/CacheEncryptionGateKt*", // --- Android framework entry points (OS-instantiated). NB: Workers are intentionally NOT here // --- (they are unit-tested — see the KEPT IN SCOPE note above). "**/*Activity*",