diff --git a/app/src/androidTest/kotlin/org/libremail/reporting/ReportStoreEncryptionInstrumentedTest.kt b/app/src/androidTest/kotlin/org/libremail/reporting/ReportStoreEncryptionInstrumentedTest.kt new file mode 100644 index 0000000..699344d --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/reporting/ReportStoreEncryptionInstrumentedTest.kt @@ -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" + } +} diff --git a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt index 7828b5c..efc8fd0 100644 --- a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt +++ b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import org.libremail.data.security.KeystoreReportEncryption import org.libremail.data.settings.SettingsRepository import org.libremail.data.sync.SyncScheduler import org.libremail.domain.repository.AccountRepository @@ -48,6 +49,8 @@ class LibreMailApplication : @Inject lateinit var diagnosticsCollector: DiagnosticsCollector + @Inject lateinit var reportEncryption: KeystoreReportEncryption + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) /** 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 // touching DataStore on the crashing thread. 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() // 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. diff --git a/app/src/main/kotlin/org/libremail/data/security/KeystoreReportEncryption.kt b/app/src/main/kotlin/org/libremail/data/security/KeystoreReportEncryption.kt new file mode 100644 index 0000000..9c1515b --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/security/KeystoreReportEncryption.kt @@ -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" + } +} diff --git a/app/src/main/kotlin/org/libremail/di/ReportingModule.kt b/app/src/main/kotlin/org/libremail/di/ReportingModule.kt index aa40d09..faf49f2 100644 --- a/app/src/main/kotlin/org/libremail/di/ReportingModule.kt +++ b/app/src/main/kotlin/org/libremail/di/ReportingModule.kt @@ -8,6 +8,7 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import org.libremail.BuildConfig +import org.libremail.data.security.KeystoreReportEncryption import org.libremail.reporting.DebugReportEndpoint import org.libremail.reporting.ReportStore import java.io.File @@ -17,10 +18,19 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) 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 @Singleton - fun provideReportStore(@ApplicationContext context: Context): ReportStore = - ReportStore(File(context.filesDir, "debug_reports")) + fun provideReportStore( + @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 diff --git a/app/src/main/kotlin/org/libremail/reporting/ReportEncryption.kt b/app/src/main/kotlin/org/libremail/reporting/ReportEncryption.kt new file mode 100644 index 0000000..1416087 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/reporting/ReportEncryption.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/org/libremail/reporting/ReportStore.kt b/app/src/main/kotlin/org/libremail/reporting/ReportStore.kt index ab7cba4..3fe7b67 100644 --- a/app/src/main/kotlin/org/libremail/reporting/ReportStore.kt +++ b/app/src/main/kotlin/org/libremail/reporting/ReportStore.kt @@ -22,10 +22,19 @@ import java.io.File * 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.) * 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( private val directory: File, scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + private val encryption: ReportEncryption = ReportEncryption.None, ) { private val lock = Any() private val _reports = MutableStateFlow>(emptyList()) @@ -41,8 +50,9 @@ class ReportStore( fun save(report: DebugReport) { synchronized(lock) { + val serialized = serializeForDisk(report) ?: return directory.mkdirs() - File(directory, fileName(report.id)).writeText(report.toStorageJson()) + File(directory, fileName(report.id)).writeText(serialized) _reports.value = scan() } } @@ -58,7 +68,8 @@ class ReportStore( synchronized(lock) { val report = _reports.value.firstOrNull { it.id == id } ?: 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() } } @@ -85,13 +96,57 @@ class ReportStore( val files = directory.listFiles { file -> file.isFile && file.name.endsWith(SUFFIX) } ?: return emptyList() return files - .mapNotNull { file -> runCatching { DebugReport.fromStorageJson(file.readText()) }.getOrNull() } + .mapNotNull { file -> readReport(file) } .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 companion object { 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:" } } diff --git a/app/src/test/kotlin/org/libremail/data/security/KeystoreReportEncryptionTest.kt b/app/src/test/kotlin/org/libremail/data/security/KeystoreReportEncryptionTest.kt new file mode 100644 index 0000000..b133c3b --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/security/KeystoreReportEncryptionTest.kt @@ -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() + private val settingsRepository = mockk() + 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(), any()) } 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()) + } +} diff --git a/app/src/test/kotlin/org/libremail/reporting/ReportStoreEncryptionTest.kt b/app/src/test/kotlin/org/libremail/reporting/ReportStoreEncryptionTest.kt new file mode 100644 index 0000000..38a71ee --- /dev/null +++ b/app/src/test/kotlin/org/libremail/reporting/ReportStoreEncryptionTest.kt @@ -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(), any(), any()) } returns 0 + every { android.util.Log.w(any(), any(), 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" + } +}