feat(security): encrypt persisted reports at rest when cache encryption is on (#369)
When the opt-in cache encryption (encryptCache) is ON, persist crash and "Report a problem" reports encrypted at rest, decrypting them on read; when OFF they stay plaintext exactly as before. - ReportStore gains a ReportEncryption collaborator (default None = plaintext, so existing call sites are unchanged). On write it seals the storage JSON with AES-256-GCM and tags it with a marker prefix; on read it sniffs the prefix, so pre-toggle plaintext and post-toggle sealed reports coexist. Writes FAIL CLOSED: a sealing failure drops the report rather than leaving plaintext on disk. Decrypt failures are logged (PII-free) and skipped. - KeystoreReportEncryption reuses the vetted KeystoreCrypto (non-auth master key, so a crash while the app is locked can still seal), and mirrors the encryptCache setting into a crash-safe in-memory flag warmed at startup (no DataStore read on the crashing thread). - PII-free AppLog logging at the enable/disable transition and both fallback paths; never logs report contents. Tests: JVM unit tests for the ReportStore branching (seal-on-write, plaintext when off, crash persistence, fail-closed, mixed files, decrypt-failure skip, markSurfaced re-seal) and for KeystoreReportEncryption; an instrumented test proves real Keystore ciphertext on disk + round-trip on device.
This commit is contained in:
+95
@@ -0,0 +1,95 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
package org.libremail.reporting
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.libremail.data.security.KeystoreCrypto
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On-device proof for issue #369: with at-rest encryption ON, [ReportStore] persists a report as real
|
||||||
|
* Android Keystore ciphertext — no report content in plaintext on disk — and reads it back intact;
|
||||||
|
* with it OFF the file stays plaintext JSON. This closes the gap between the JVM-tested ReportStore
|
||||||
|
* branching (which fakes the cipher) and the device-only [KeystoreCrypto] the branching drives in
|
||||||
|
* production, using the same non-auth master key that lets a crash-while-locked report still be sealed.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ReportStoreEncryptionInstrumentedTest {
|
||||||
|
|
||||||
|
private val context =
|
||||||
|
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||||
|
private val dir = File(context.cacheDir, "report-encryption-test")
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encryptedReportIsCiphertextOnDiskAndReadsBack() {
|
||||||
|
val store = store(enabled = true)
|
||||||
|
|
||||||
|
store.save(report("enc"))
|
||||||
|
|
||||||
|
val raw = File(dir, "enc.json").readText()
|
||||||
|
// The distinctive plaintext token must NOT be on disk — the report is Keystore-sealed at rest.
|
||||||
|
assertFalse("report content must not be persisted in plaintext", raw.contains(SENTINEL))
|
||||||
|
assertFalse("a sealed report is not plaintext JSON", raw.startsWith("{"))
|
||||||
|
// A fresh store over the same directory (same master key) unseals and reads it back intact.
|
||||||
|
assertEquals(SENTINEL, store(enabled = true).find("enc")?.logs?.single())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun plaintextReportWhenEncryptionOff() {
|
||||||
|
val store = store(enabled = false)
|
||||||
|
|
||||||
|
store.save(report("plain"))
|
||||||
|
|
||||||
|
val raw = File(dir, "plain.json").readText()
|
||||||
|
assertTrue("with encryption off the report stays plaintext JSON", raw.contains(SENTINEL))
|
||||||
|
assertTrue(raw.startsWith("{"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun store(enabled: Boolean): ReportStore {
|
||||||
|
val crypto = KeystoreCrypto()
|
||||||
|
val encryption = object : ReportEncryption {
|
||||||
|
override fun enabled(): Boolean = enabled
|
||||||
|
override fun encrypt(plaintext: String): String = crypto.encrypt(plaintext)
|
||||||
|
override fun decrypt(encoded: String): String = crypto.decrypt(encoded)
|
||||||
|
}
|
||||||
|
return ReportStore(dir, CoroutineScope(Dispatchers.Unconfined), encryption)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun report(id: String) = DebugReport(
|
||||||
|
id = id,
|
||||||
|
createdAtMillis = 1_000L,
|
||||||
|
kind = ReportKind.CRASH,
|
||||||
|
appVersionName = "1.0",
|
||||||
|
appVersionCode = 1L,
|
||||||
|
androidRelease = "14",
|
||||||
|
androidSdkInt = 34,
|
||||||
|
deviceManufacturer = "Test",
|
||||||
|
deviceModel = "Model",
|
||||||
|
stackTrace = SENTINEL,
|
||||||
|
settings = emptyMap(),
|
||||||
|
logs = listOf(SENTINEL),
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SENTINEL = "SENTINEL-PLAINTEXT-TOKEN-369"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.libremail.data.security.KeystoreReportEncryption
|
||||||
import org.libremail.data.settings.SettingsRepository
|
import org.libremail.data.settings.SettingsRepository
|
||||||
import org.libremail.data.sync.SyncScheduler
|
import org.libremail.data.sync.SyncScheduler
|
||||||
import org.libremail.domain.repository.AccountRepository
|
import org.libremail.domain.repository.AccountRepository
|
||||||
@@ -48,6 +49,8 @@ class LibreMailApplication :
|
|||||||
|
|
||||||
@Inject lateinit var diagnosticsCollector: DiagnosticsCollector
|
@Inject lateinit var diagnosticsCollector: DiagnosticsCollector
|
||||||
|
|
||||||
|
@Inject lateinit var reportEncryption: KeystoreReportEncryption
|
||||||
|
|
||||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
/** Whether the IDLE push service should currently be running (push enabled AND an account exists). */
|
/** Whether the IDLE push service should currently be running (push enabled AND an account exists). */
|
||||||
@@ -74,6 +77,10 @@ class LibreMailApplication :
|
|||||||
// Warm the settings cache so a later crash report can include non-PII settings without
|
// Warm the settings cache so a later crash report can include non-PII settings without
|
||||||
// touching DataStore on the crashing thread.
|
// touching DataStore on the crashing thread.
|
||||||
appScope.launch { runCatching { diagnosticsCollector.warmSettingsCache() } }
|
appScope.launch { runCatching { diagnosticsCollector.warmSettingsCache() } }
|
||||||
|
// Mirror the encryptCache setting so a crash-time report save (synchronous, on the crashing
|
||||||
|
// thread) can seal the report at rest without touching DataStore (#369). Collects for the
|
||||||
|
// process lifetime, so a mid-session toggle takes effect on the next report write.
|
||||||
|
appScope.launch { runCatching { reportEncryption.observeEncryptCacheSetting() } }
|
||||||
syncScheduler.schedulePeriodicSync()
|
syncScheduler.schedulePeriodicSync()
|
||||||
// Full-history backfill (#12) and device-only retention pruning (#13) run as their own bounded,
|
// Full-history backfill (#12) and device-only retention pruning (#13) run as their own bounded,
|
||||||
// resumable background jobs so they never block foreground sync / pull-to-refresh.
|
// resumable background jobs so they never block foreground sync / pull-to-refresh.
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
package org.libremail.data.security
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import org.libremail.data.settings.SettingsRepository
|
||||||
|
import org.libremail.reporting.AppLog
|
||||||
|
import org.libremail.reporting.ReportEncryption
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On-device [ReportEncryption]: seals a persisted report's JSON with the non-auth Keystore master key
|
||||||
|
* ([KeystoreCrypto]) so at-rest report storage honours the opt-in `encryptCache` setting (issue #369).
|
||||||
|
* Reuses the vetted AES-256-GCM crypto rather than rolling new; encryption is `Base64(iv || ciphertext)`.
|
||||||
|
*
|
||||||
|
* The **master** key (not the auth-bound cache key) is deliberate: it is usable without a user-presence
|
||||||
|
* prompt, so a crash that occurs while the app is locked can still seal and persist its report — the
|
||||||
|
* ticket requires crash reports to survive, encrypted, even then.
|
||||||
|
*
|
||||||
|
* [enabled] is answered from an in-memory mirror of the `encryptCache` setting, never a live DataStore
|
||||||
|
* read: a crash-time [org.libremail.reporting.ReportStore.save] runs synchronously on the crashing
|
||||||
|
* thread and must not touch DataStore (#296). [observeEncryptCacheSetting], launched once at startup,
|
||||||
|
* keeps that mirror current so a mid-session toggle takes effect on the next report write. The mirror
|
||||||
|
* defaults to `false` (plaintext) until the first settings value lands — the same brief unwarmed
|
||||||
|
* startup window [org.libremail.reporting.DiagnosticsCollector] accepts for a crash report's settings.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class KeystoreReportEncryption @Inject constructor(
|
||||||
|
private val crypto: KeystoreCrypto,
|
||||||
|
private val settingsRepository: SettingsRepository,
|
||||||
|
) : ReportEncryption {
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var encryptionEnabled: Boolean = false
|
||||||
|
|
||||||
|
override fun enabled(): Boolean = encryptionEnabled
|
||||||
|
|
||||||
|
override fun encrypt(plaintext: String): String = crypto.encrypt(plaintext)
|
||||||
|
|
||||||
|
override fun decrypt(encoded: String): String = crypto.decrypt(encoded)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the `encryptCache` setting into [encryptionEnabled] for the process lifetime. Collects
|
||||||
|
* forever, so launch it once from application startup. PII-free — only the on/off state is logged.
|
||||||
|
*/
|
||||||
|
suspend fun observeEncryptCacheSetting() {
|
||||||
|
settingsRepository.settings
|
||||||
|
.map { it.encryptCache }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { enabled ->
|
||||||
|
encryptionEnabled = enabled
|
||||||
|
AppLog.i(TAG, "Report at-rest encryption is now ${if (enabled) "ON" else "OFF"}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ReportEncryption"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import dagger.hilt.InstallIn
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import org.libremail.BuildConfig
|
import org.libremail.BuildConfig
|
||||||
|
import org.libremail.data.security.KeystoreReportEncryption
|
||||||
import org.libremail.reporting.DebugReportEndpoint
|
import org.libremail.reporting.DebugReportEndpoint
|
||||||
import org.libremail.reporting.ReportStore
|
import org.libremail.reporting.ReportStore
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -17,10 +18,19 @@ import javax.inject.Singleton
|
|||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object ReportingModule {
|
object ReportingModule {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The file-backed report store. [KeystoreReportEncryption] wires in the opt-in at-rest encryption
|
||||||
|
* (issue #369): reports are sealed on disk when the `encryptCache` setting is on, plaintext when off.
|
||||||
|
*/
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideReportStore(@ApplicationContext context: Context): ReportStore =
|
fun provideReportStore(
|
||||||
ReportStore(File(context.filesDir, "debug_reports"))
|
@ApplicationContext context: Context,
|
||||||
|
reportEncryption: KeystoreReportEncryption,
|
||||||
|
): ReportStore = ReportStore(
|
||||||
|
directory = File(context.filesDir, "debug_reports"),
|
||||||
|
encryption = reportEncryption,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The debug-report ingest endpoint (empty by default — see [DebugReportEndpoint]). Provided as an
|
* The debug-report ingest endpoint (empty by default — see [DebugReportEndpoint]). Provided as an
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
package org.libremail.reporting
|
||||||
|
|
||||||
|
/**
|
||||||
|
* At-rest encryption seam for persisted [DebugReport]s (issue #369). When the opt-in cache encryption
|
||||||
|
* (`encryptCache`) is ON, [ReportStore] seals a report's storage JSON with this before writing it and
|
||||||
|
* unseals it on read; when OFF the JSON is stored plaintext exactly as before.
|
||||||
|
*
|
||||||
|
* Kept behind an interface so [ReportStore] stays a plain `java.io.File` component that JVM-tests
|
||||||
|
* without the Android Keystore. The on-device implementation
|
||||||
|
* (`org.libremail.data.security.KeystoreReportEncryption`) wraps the vetted Keystore crypto; [None] is
|
||||||
|
* the plaintext default used where encryption isn't wired and by tests that don't exercise it.
|
||||||
|
*/
|
||||||
|
interface ReportEncryption {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether reports must be encrypted at rest right now — a synchronous, crash-safe mirror of the
|
||||||
|
* `encryptCache` setting, never a live DataStore read (a crash-time [ReportStore.save] runs on the
|
||||||
|
* crashing thread). When false, [encrypt]/[decrypt] are never called.
|
||||||
|
*/
|
||||||
|
fun enabled(): Boolean
|
||||||
|
|
||||||
|
/** Seals [plaintext] to an opaque at-rest blob. Only invoked when [enabled] is true. */
|
||||||
|
fun encrypt(plaintext: String): String
|
||||||
|
|
||||||
|
/** Unseals a blob produced by [encrypt] back to the original plaintext. */
|
||||||
|
fun decrypt(encoded: String): String
|
||||||
|
|
||||||
|
/** The plaintext default: encryption disabled, so [encrypt]/[decrypt] are pass-throughs never used. */
|
||||||
|
object None : ReportEncryption {
|
||||||
|
override fun enabled(): Boolean = false
|
||||||
|
override fun encrypt(plaintext: String): String = plaintext
|
||||||
|
override fun decrypt(encoded: String): String = encoded
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,10 +22,19 @@ import java.io.File
|
|||||||
* to [scope] (IO by default). Reactive consumers (Problem Reports, the startup prompt) observe
|
* to [scope] (IO by default). Reactive consumers (Problem Reports, the startup prompt) observe
|
||||||
* [reports] and update when the scan lands; the empty window is momentary. Writes ([save] etc.)
|
* [reports] and update when the scan lands; the empty window is momentary. Writes ([save] etc.)
|
||||||
* re-scan synchronously so a crash-time save is never lost to the pending initial scan.
|
* re-scan synchronously so a crash-time save is never lost to the pending initial scan.
|
||||||
|
*
|
||||||
|
* At-rest encryption (issue #369): when [encryption] reports it is [ReportEncryption.enabled], each
|
||||||
|
* report's storage JSON is sealed before it is written and tagged with [ENCRYPTED_PREFIX]; when it is
|
||||||
|
* off the JSON is stored plaintext exactly as before. Reads sniff the prefix, so plaintext reports from
|
||||||
|
* before the setting was turned on and sealed reports written after it coexist transparently. Writes
|
||||||
|
* FAIL CLOSED: if sealing throws while encryption is on, the report is dropped rather than written in
|
||||||
|
* plaintext, so the user's opt-in encryption is never silently defeated by leaving a plaintext report
|
||||||
|
* on disk. The default [ReportEncryption.None] keeps the store plaintext (its historical behaviour).
|
||||||
*/
|
*/
|
||||||
class ReportStore(
|
class ReportStore(
|
||||||
private val directory: File,
|
private val directory: File,
|
||||||
scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
|
private val encryption: ReportEncryption = ReportEncryption.None,
|
||||||
) {
|
) {
|
||||||
private val lock = Any()
|
private val lock = Any()
|
||||||
private val _reports = MutableStateFlow<List<DebugReport>>(emptyList())
|
private val _reports = MutableStateFlow<List<DebugReport>>(emptyList())
|
||||||
@@ -41,8 +50,9 @@ class ReportStore(
|
|||||||
|
|
||||||
fun save(report: DebugReport) {
|
fun save(report: DebugReport) {
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
|
val serialized = serializeForDisk(report) ?: return
|
||||||
directory.mkdirs()
|
directory.mkdirs()
|
||||||
File(directory, fileName(report.id)).writeText(report.toStorageJson())
|
File(directory, fileName(report.id)).writeText(serialized)
|
||||||
_reports.value = scan()
|
_reports.value = scan()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,7 +68,8 @@ class ReportStore(
|
|||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
val report = _reports.value.firstOrNull { it.id == id } ?: return
|
val report = _reports.value.firstOrNull { it.id == id } ?: return
|
||||||
if (report.surfaced) return
|
if (report.surfaced) return
|
||||||
File(directory, fileName(id)).writeText(report.copy(surfaced = true).toStorageJson())
|
val serialized = serializeForDisk(report.copy(surfaced = true)) ?: return
|
||||||
|
File(directory, fileName(id)).writeText(serialized)
|
||||||
_reports.value = scan()
|
_reports.value = scan()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,13 +96,57 @@ class ReportStore(
|
|||||||
val files = directory.listFiles { file -> file.isFile && file.name.endsWith(SUFFIX) }
|
val files = directory.listFiles { file -> file.isFile && file.name.endsWith(SUFFIX) }
|
||||||
?: return emptyList()
|
?: return emptyList()
|
||||||
return files
|
return files
|
||||||
.mapNotNull { file -> runCatching { DebugReport.fromStorageJson(file.readText()) }.getOrNull() }
|
.mapNotNull { file -> readReport(file) }
|
||||||
.sortedByDescending { it.createdAtMillis }
|
.sortedByDescending { it.createdAtMillis }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders [report] for on-disk storage. With encryption OFF this is the plaintext storage JSON,
|
||||||
|
* byte-for-byte as before. With it ON the JSON is sealed and tagged with [ENCRYPTED_PREFIX]. Returns
|
||||||
|
* `null` — so the caller writes nothing — when sealing fails while encryption is ON: the report is
|
||||||
|
* deliberately NOT written in plaintext (that would defeat the user's opt-in encryption, #369), so a
|
||||||
|
* failed seal drops the report rather than leaking it. A dropped crash report still lets the original
|
||||||
|
* crash propagate to the system handler.
|
||||||
|
*/
|
||||||
|
private fun serializeForDisk(report: DebugReport): String? {
|
||||||
|
val json = report.toStorageJson()
|
||||||
|
if (!encryption.enabled()) return json
|
||||||
|
return runCatching { ENCRYPTED_PREFIX + encryption.encrypt(json) }.getOrElse { e ->
|
||||||
|
AppLog.e(TAG, "Report encryption failed; not persisting to avoid a plaintext report on disk", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads one stored report, transparently unsealing files tagged with [ENCRYPTED_PREFIX]. A file that
|
||||||
|
* cannot be decrypted (e.g. the master key was cleared) is logged and skipped rather than crashing
|
||||||
|
* the list; an unparseable plaintext file is skipped silently, as before.
|
||||||
|
*/
|
||||||
|
private fun readReport(file: File): DebugReport? {
|
||||||
|
val raw = runCatching { file.readText() }.getOrNull() ?: return null
|
||||||
|
val json = if (raw.startsWith(ENCRYPTED_PREFIX)) {
|
||||||
|
runCatching { encryption.decrypt(raw.removePrefix(ENCRYPTED_PREFIX)) }.getOrElse { e ->
|
||||||
|
AppLog.w(TAG, "Skipping a stored report that could not be decrypted", e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
raw
|
||||||
|
}
|
||||||
|
return runCatching { DebugReport.fromStorageJson(json) }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
private fun fileName(id: String) = "$id$SUFFIX"
|
private fun fileName(id: String) = "$id$SUFFIX"
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val SUFFIX = ".json"
|
const val SUFFIX = ".json"
|
||||||
|
const val TAG = "ReportStore"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a file whose body is `Base64(iv || ciphertext)` rather than plaintext JSON. Contains
|
||||||
|
* characters outside Base64's alphabet (`.`/`:`) and never matches a plaintext report's leading
|
||||||
|
* `{`, so a read tells sealed from plaintext files unambiguously — the two coexist on disk after
|
||||||
|
* the encryption setting is toggled.
|
||||||
|
*/
|
||||||
|
const val ENCRYPTED_PREFIX = "libremail.report.enc.v1:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
package org.libremail.data.security
|
||||||
|
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.unmockkAll
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.libremail.data.settings.AppSettings
|
||||||
|
import org.libremail.data.settings.SettingsRepository
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [KeystoreReportEncryption] delegates the crypto to [KeystoreCrypto] (device-bound, tested in
|
||||||
|
* `KeystoreCryptoTest`) and answers [org.libremail.reporting.ReportEncryption.enabled] from an
|
||||||
|
* in-memory mirror of the `encryptCache` setting. These pin the delegation and that the mirror tracks
|
||||||
|
* the observed setting — including the crash-safe default of "off" before the first value lands.
|
||||||
|
*/
|
||||||
|
class KeystoreReportEncryptionTest {
|
||||||
|
|
||||||
|
private val crypto = mockk<KeystoreCrypto>()
|
||||||
|
private val settingsRepository = mockk<SettingsRepository>()
|
||||||
|
private val encryption = KeystoreReportEncryption(crypto, settingsRepository)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
// observeEncryptCacheSetting breadcrumbs the on/off state through AppLog -> android.util.Log,
|
||||||
|
// a no-op stub that throws "not mocked" under plain JVM tests. 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
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() = unmockkAll()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `is disabled before the setting has been observed`() {
|
||||||
|
assertFalse(encryption.enabled())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encrypt delegates to the keystore crypto`() {
|
||||||
|
every { crypto.encrypt("report-json") } returns "cipher"
|
||||||
|
|
||||||
|
assertEquals("cipher", encryption.encrypt("report-json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decrypt delegates to the keystore crypto`() {
|
||||||
|
every { crypto.decrypt("cipher") } returns "report-json"
|
||||||
|
|
||||||
|
assertEquals("report-json", encryption.decrypt("cipher"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `enabled mirrors the latest encryptCache setting when on`() = runTest {
|
||||||
|
every { settingsRepository.settings } returns
|
||||||
|
flowOf(AppSettings(encryptCache = false), AppSettings(encryptCache = true))
|
||||||
|
|
||||||
|
encryption.observeEncryptCacheSetting()
|
||||||
|
|
||||||
|
assertTrue(encryption.enabled())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `enabled mirrors the latest encryptCache setting when off`() = runTest {
|
||||||
|
every { settingsRepository.settings } returns
|
||||||
|
flowOf(AppSettings(encryptCache = true), AppSettings(encryptCache = false))
|
||||||
|
|
||||||
|
encryption.observeEncryptCacheSetting()
|
||||||
|
|
||||||
|
assertFalse(encryption.enabled())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
package org.libremail.reporting
|
||||||
|
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.unmockkAll
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TemporaryFolder
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Base64
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* At-rest encryption of persisted reports (issue #369). Uses a reversible fake [ReportEncryption]
|
||||||
|
* (Base64, so the on-disk body genuinely scrambles the report content the way real AES-GCM does) — the
|
||||||
|
* Keystore round-trip itself is [org.libremail.data.security.KeystoreCrypto]'s device-bound concern.
|
||||||
|
* These pin [ReportStore]'s own branching: seal-on-write when enabled, plaintext when off, both formats
|
||||||
|
* readable together, and the fail-closed guarantee that a sealing failure never leaves a plaintext
|
||||||
|
* report on disk.
|
||||||
|
*/
|
||||||
|
class ReportStoreEncryptionTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val tempFolder = TemporaryFolder()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
// ReportStore's fail-closed / skip-on-failure paths breadcrumb through AppLog -> android.util.Log,
|
||||||
|
// a no-op stub that throws "not mocked" under plain JVM tests. Fully-qualified (a raw
|
||||||
|
// android.util.Log import is detekt-forbidden, epic #324).
|
||||||
|
mockkStatic(android.util.Log::class)
|
||||||
|
every { android.util.Log.e(any<String>(), any<String>(), any()) } returns 0
|
||||||
|
every { android.util.Log.w(any<String>(), any<String>(), any()) } returns 0
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() = unmockkAll()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encrypts the report at rest when enabled and reads it back`() {
|
||||||
|
val store = store(FakeEncryption(enabled = true))
|
||||||
|
|
||||||
|
store.save(report("enc"))
|
||||||
|
|
||||||
|
val raw = File(tempFolder.root, "enc.json").readText()
|
||||||
|
// Sealed at rest: the distinctive plaintext token is NOT on disk, and the file is not the JSON.
|
||||||
|
assertFalse(raw.contains(SENTINEL))
|
||||||
|
assertFalse(raw.startsWith("{"))
|
||||||
|
// Still fully readable through the store (decrypted on scan).
|
||||||
|
assertEquals(SENTINEL, store.find("enc")?.logs?.single())
|
||||||
|
// Survives a fresh instance over the same directory (the next launch decrypts and reads it).
|
||||||
|
assertEquals("enc", store(FakeEncryption(enabled = true)).find("enc")?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stores plaintext byte-for-byte when disabled`() {
|
||||||
|
val store = store(FakeEncryption(enabled = false))
|
||||||
|
|
||||||
|
store.save(report("plain"))
|
||||||
|
|
||||||
|
val raw = File(tempFolder.root, "plain.json").readText()
|
||||||
|
// Exactly the historical plaintext storage form — the token is present and it is JSON.
|
||||||
|
assertEquals(report("plain").toStorageJson(), raw)
|
||||||
|
assertTrue(raw.contains(SENTINEL))
|
||||||
|
assertEquals("plain", store.find("plain")?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `persists a crash report encrypted at rest`() {
|
||||||
|
val store = store(FakeEncryption(enabled = true))
|
||||||
|
|
||||||
|
store.save(report("crash", kind = ReportKind.CRASH))
|
||||||
|
|
||||||
|
val raw = File(tempFolder.root, "crash.json").readText()
|
||||||
|
assertFalse(raw.contains(SENTINEL))
|
||||||
|
assertEquals(ReportKind.CRASH, store.find("crash")?.kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fails closed - a sealing failure never writes a plaintext report`() {
|
||||||
|
val store = store(FakeEncryption(enabled = true, encryptor = { error("keystore unavailable") }))
|
||||||
|
|
||||||
|
store.save(report("boom"))
|
||||||
|
|
||||||
|
// Nothing was written: no plaintext leak, and the report is simply absent (crash still propagates).
|
||||||
|
assertFalse(File(tempFolder.root, "boom.json").exists())
|
||||||
|
assertTrue(store.reports.value.isEmpty())
|
||||||
|
assertNull(store.find("boom"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reads a mix of plaintext and encrypted reports on disk`() {
|
||||||
|
// An older plaintext report from before encryption was turned on, written directly.
|
||||||
|
File(tempFolder.root, "old.json").writeText(report("old", createdAt = 1L).toStorageJson())
|
||||||
|
val store = store(FakeEncryption(enabled = true))
|
||||||
|
|
||||||
|
// A newer report saved after the setting was turned on is sealed.
|
||||||
|
store.save(report("new", createdAt = 2L))
|
||||||
|
|
||||||
|
assertEquals(listOf("new", "old"), store.reports.value.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `skips a report that cannot be decrypted`() {
|
||||||
|
// Seal a report with a working store, then reopen with one whose decrypt fails (key rotated).
|
||||||
|
store(FakeEncryption(enabled = true)).save(report("sealed"))
|
||||||
|
|
||||||
|
val broken = store(FakeEncryption(enabled = true, decryptor = { error("key was cleared") }))
|
||||||
|
|
||||||
|
assertTrue(broken.reports.value.isEmpty())
|
||||||
|
assertNull(broken.find("sealed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `markSurfaced re-seals the report and persists the flag`() {
|
||||||
|
val store = store(FakeEncryption(enabled = true))
|
||||||
|
store.save(report("a"))
|
||||||
|
|
||||||
|
store.markSurfaced("a")
|
||||||
|
|
||||||
|
val raw = File(tempFolder.root, "a.json").readText()
|
||||||
|
assertFalse(raw.contains(SENTINEL))
|
||||||
|
assertTrue(store.find("a")!!.surfaced)
|
||||||
|
// A fresh instance decrypts and reads it back as surfaced.
|
||||||
|
assertTrue(store(FakeEncryption(enabled = true)).find("a")!!.surfaced)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `None encryption is a disabled plaintext pass-through`() {
|
||||||
|
assertFalse(ReportEncryption.None.enabled())
|
||||||
|
assertEquals("x", ReportEncryption.None.encrypt("x"))
|
||||||
|
assertEquals("x", ReportEncryption.None.decrypt("x"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun store(encryption: ReportEncryption) =
|
||||||
|
ReportStore(tempFolder.root, CoroutineScope(Dispatchers.Unconfined), encryption)
|
||||||
|
|
||||||
|
private fun report(id: String, createdAt: Long = 1L, kind: ReportKind = ReportKind.MANUAL) = DebugReport(
|
||||||
|
id = id,
|
||||||
|
createdAtMillis = createdAt,
|
||||||
|
kind = kind,
|
||||||
|
appVersionName = "0.1.0",
|
||||||
|
appVersionCode = 1,
|
||||||
|
androidRelease = "14",
|
||||||
|
androidSdkInt = 34,
|
||||||
|
deviceManufacturer = "Google",
|
||||||
|
deviceModel = "Pixel",
|
||||||
|
stackTrace = null,
|
||||||
|
settings = emptyMap(),
|
||||||
|
logs = listOf(SENTINEL),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reversible stand-in for the Keystore cipher: Base64 genuinely scrambles the report content on
|
||||||
|
* disk (so a "not plaintext" assertion is meaningful) while round-tripping. [encryptor]/[decryptor]
|
||||||
|
* are overridable to inject failures.
|
||||||
|
*/
|
||||||
|
private class FakeEncryption(
|
||||||
|
private val enabled: Boolean,
|
||||||
|
private val encryptor: (String) -> String = { Base64.getEncoder().encodeToString(it.toByteArray()) },
|
||||||
|
private val decryptor: (String) -> String = { String(Base64.getDecoder().decode(it)) },
|
||||||
|
) : ReportEncryption {
|
||||||
|
override fun enabled(): Boolean = enabled
|
||||||
|
override fun encrypt(plaintext: String): String = encryptor(plaintext)
|
||||||
|
override fun decrypt(encoded: String): String = decryptor(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SENTINEL = "SENTINEL-PLAINTEXT-369"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user