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