diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ed610be..f505ed9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -337,6 +337,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*", 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/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 24ec574..e301c02 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,35 @@ 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) { + // 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) + } + } + + /** + * 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, 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 return when { settings.encryptCache -> { @@ -140,4 +172,8 @@ class DatabaseProvisioner internal constructor( else -> CacheOpenMode.Plaintext } } + + private companion object { + const val TAG = "DatabaseProvisioner" + } } 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/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 6265d05..7695bb4 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 @@ -28,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 @@ -56,6 +59,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 +106,62 @@ class DatabaseProvisionerTest { verify(exactly = 1) { DatabaseEncryption.ensureNativeLibraryLoaded() } } + @Test + 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)) + // 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 error = assertFailsWith { provisioner().prepareCache() } + + // 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 never wipes an already-encrypted cache`() = runTest { + every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false)) + every { DatabaseEncryption.isEncrypted(any()) } returns true + every { DatabaseEncryption.ensureEncrypted(any(), any()) } throws + UnsatisfiedLinkError("dlopen failed: libsqlcipher.so is not 16 KB aligned") + + assertFailsWith { provisioner().prepareCache() } + + // 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 fun `prepareCache suspends on the auth-bound passphrase until it resolves`() = runTest { every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = true)) 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 { 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() } + } +}