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 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 13:48:35 -05:00
co-authored by Claude Opus 4.8
parent 551a2df66f
commit bfe5d46654
11 changed files with 795 additions and 51 deletions
@@ -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<Context>()`, 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<Context>()
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<DatabaseKeyStore>()
private val settingsRepository = mockk<SettingsRepository>()
private val migrator = mockk<AccountDataMigrator>()
// 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<Unit> {
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<Any>(), database.accountDao().getAll())
} finally {
database.close()
}
}
}
@@ -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<ComponentActivity>()
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 }
}
}