Merge branch 'main' into test-376-robolectric-richtext
This commit is contained in:
@@ -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*",
|
||||
|
||||
+28
@@ -142,6 +142,34 @@ class DatabaseProvisionerInstrumentedTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue #359: with `encryptCache` on and NO cache yet (a fresh install enabling encryption), the
|
||||
* provisioner must load SQLCipher's native library, report [CacheOpenMode.Encrypted], and a real keyed
|
||||
* open must then create and read the encrypted cache — i.e. `libsqlcipher.so` actually loads and runs.
|
||||
*
|
||||
* On a 16 KB memory-page device/image (Android 15+, and the CI API-37 preview `google_apis_ps16k`
|
||||
* E2E image) an `.so` not aligned for 16 KB pages fails exactly here with `UnsatisfiedLinkError` at
|
||||
* `SQLiteConnection.nativeOpen`. Running this on that image makes the 16 KB native-lib load a tested
|
||||
* invariant, so a dependency bump that regressed alignment is caught in CI rather than on-device.
|
||||
*/
|
||||
@Test
|
||||
fun freshEncryptOnStartLoadsThe16KbNativeLibAndOpensKeyedWithoutCrashing() = runBlocking<Unit> {
|
||||
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false))
|
||||
assertFalse("precondition: no cache file exists yet", dbFile.exists())
|
||||
|
||||
val mode = provisioner().prepareCache()
|
||||
|
||||
assertEquals(CacheOpenMode.Encrypted(passphrase), mode)
|
||||
// The keyed open must actually succeed on real SQLCipher — loading and using libsqlcipher.so on
|
||||
// whatever ABI / page size this device or emulator image uses.
|
||||
openEncrypted().apply {
|
||||
messageDao().insertNew(listOf(message("acct:1")))
|
||||
assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id })
|
||||
close()
|
||||
}
|
||||
assertTrue("the fresh cache was created in SQLCipher (encrypted) form", DatabaseEncryption.isEncrypted(dbFile))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encryptionTurnedOffDecryptsAnEncryptedCacheToPlaintext() = runBlocking<Unit> {
|
||||
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = false, appLock = false))
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>(CacheEncryptionGateState.Checking)
|
||||
val state: StateFlow<CacheEncryptionGateState> = _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<String?>(null)
|
||||
val reportPayload: StateFlow<String?> = _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"
|
||||
}
|
||||
}
|
||||
@@ -390,6 +390,18 @@
|
||||
<string name="report_submit_unavailable">Online submission isn\'t available in this build. Use Copy or Save to share the report.</string>
|
||||
<string name="report_copied">Copied to clipboard</string>
|
||||
<string name="report_saved">Saved</string>
|
||||
<!-- Fail-closed encrypted-cache gate (#359): shown when SQLCipher's native library will not load,
|
||||
so the opt-in encrypted cache cannot be opened. The app fails closed here instead of falling
|
||||
back to an unencrypted cache. -->
|
||||
<string name="cache_encryption_error_title">Encrypted mail unavailable</string>
|
||||
<!-- Verbatim maintainer-specified message; do not reword. -->
|
||||
<string name="cache_encryption_error_message">Error - decryption could not proceed. Native decryption library load failure.</string>
|
||||
<string name="cache_encryption_error_body">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.</string>
|
||||
<string name="cache_encryption_report_action">Report a problem</string>
|
||||
<!-- Ephemeral report: it is prepared in memory only, so the copy must say it is NOT saved unless
|
||||
the user explicitly saves/exports it, and that it holds no personal information. -->
|
||||
<string name="cache_encryption_report_ephemeral_notice">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.</string>
|
||||
<string name="cache_encryption_report_generating">Preparing report…</string>
|
||||
<string name="crash_prompt_title">LibreMail closed unexpectedly</string>
|
||||
<string name="crash_prompt_message">A problem report from the last crash is ready for you to review. Nothing is sent automatically.</string>
|
||||
<string name="crash_prompt_review">Review</string>
|
||||
|
||||
@@ -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<String>(), any<String>(), 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<CacheEncryptionUnavailableException> { 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<CacheEncryptionUnavailableException> { 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<CacheEncryptionUnavailableException> { provisioner.prepareCache() }
|
||||
assertFailsWith<CacheEncryptionUnavailableException> { 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))
|
||||
|
||||
@@ -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<String>(), any<String>()) } 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<Boolean>()
|
||||
|
||||
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<StrongBoxUnavailableException>(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 {
|
||||
|
||||
@@ -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<DatabaseProvisioner>()
|
||||
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<String>(), any<String>(), any()) } returns 0
|
||||
try {
|
||||
val provisioner = mockk<DatabaseProvisioner>()
|
||||
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<DatabaseProvisioner>()
|
||||
coEvery { provisioner.prepareCache() } returns CacheOpenMode.Plaintext
|
||||
val diagnostics = mockk<DiagnosticsCollector>()
|
||||
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<DatabaseProvisioner>()
|
||||
coEvery { provisioner.prepareCache() } returns CacheOpenMode.Plaintext
|
||||
val diagnostics = mockk<DiagnosticsCollector>()
|
||||
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() }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user