Merge branch 'main' into ci-mergify-autoqueue-fix

This commit is contained in:
Jason Ross
2026-07-07 21:16:53 -05:00
committed by GitHub
8 changed files with 527 additions and 5 deletions
@@ -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.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.
@@ -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.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
@@ -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
* [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<List<DebugReport>>(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:"
}
}
@@ -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"
}
}