Merge branch 'main' into ci-mergify-autoqueue-fix
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.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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user