feat(logging): DB/keystore -> AppLog + breadcrumbs (#327) #337

Merged
JMR-dev merged 2 commits from feat-327-logging-dbkeystore into main 2026-07-05 02:33:41 +00:00
5 changed files with 71 additions and 9 deletions
@@ -13,6 +13,7 @@ import kotlinx.coroutines.runBlocking
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
@@ -21,6 +22,8 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.data.local.entity.CredentialEntity
import org.libremail.reporting.AppLog
import org.libremail.reporting.RingLogBuffer
/**
* The one-time move performed by [AccountDataMigrator] (issue #111): copying accounts / credentials /
@@ -142,6 +145,26 @@ class AccountDataMigratorTest {
}
}
@Test
fun copyEmitsANonPiiAppLogBreadcrumbNamingOnlyTheMovedTables() = runBlocking<Unit> {
seedVersion14Cache()
val buffer = RingLogBuffer()
AppLog.install(buffer)
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
val entry = buffer.snapshot()
.single { it.message.startsWith("moved account tables into the account database") }
assertEquals("the migration breadcrumb is a debug line", 'D', entry.level)
listOf("accounts", "credentials", "account_settings", "signatures").forEach { table ->
assertTrue("breadcrumb must name the moved table $table", entry.message.contains(table))
}
// The breadcrumb carries only table names — never the seeded email, secret, or passphrase.
assertFalse(entry.message.contains("ada@example.org"))
assertFalse(entry.message.contains("sealed-secret"))
assertFalse(entry.message.contains(passphrase))
}
@Test
fun reRunningTheCopyIsIdempotentAndKeepsLaterEdits() = runBlocking<Unit> {
seedVersion14Cache()
@@ -17,6 +17,8 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.data.local.entity.MessageEntity
import org.libremail.reporting.AppLog
import org.libremail.reporting.RingLogBuffer
import java.io.File
/**
@@ -150,6 +152,42 @@ class DatabaseEncryptionTest {
assertEquals("Room's schema version must survive the plaintext -> encrypted conversion", 19, version)
}
@Test
fun conversionEmitsNonPiiAppLogBreadcrumbs() = runBlocking<Unit> {
val buffer = RingLogBuffer()
AppLog.install(buffer)
// The seeded row carries an email address so the PII assertions below are meaningful.
openPlaintext().apply {
messageDao().insertNew(listOf(message("acct:1")))
close()
}
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
val afterEncrypt = buffer.snapshot()
val converting = afterEncrypt.single { it.message.startsWith("converting local cache database") }
assertEquals("the start breadcrumb is informational", 'I', converting.level)
assertEquals("converting local cache database (targetEncrypted=true)", converting.message)
val convertedAfterEncrypt = afterEncrypt.single { it.message == "local cache database converted" }
assertEquals('D', convertedAfterEncrypt.level)
// Converting back to plaintext logs the same pair with the flag flipped.
buffer.clear()
DatabaseEncryption.ensurePlaintext(dbFile, passphrase)
val afterDecrypt = buffer.snapshot()
assertTrue(
afterDecrypt.any { it.message == "converting local cache database (targetEncrypted=false)" },
)
assertTrue(afterDecrypt.any { it.message == "local cache database converted" })
// Neither conversion's breadcrumbs may leak the passphrase, the on-disk path, or account PII.
(afterEncrypt + afterDecrypt).forEach { entry ->
assertFalse("must not leak the passphrase", entry.message.contains(passphrase))
assertFalse("must not leak the db file path", entry.message.contains(dbFile.absolutePath))
assertFalse("must not leak the seeded email", entry.message.contains("ada@example.org"))
}
}
private fun openPlaintext(): LibreMailDatabase =
Room.databaseBuilder(context, LibreMailDatabase::class.java, dbName).build()
@@ -2,7 +2,6 @@
package org.libremail.data.local
import android.content.Context
import android.util.Log
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
@@ -15,6 +14,7 @@ import kotlinx.coroutines.withContext
import net.zetetic.database.sqlcipher.SQLiteDatabase
import org.libremail.data.security.DatabaseKeyStore
import org.libremail.data.settings.SettingsRepository
import org.libremail.reporting.AppLog
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@@ -181,7 +181,7 @@ class AccountDataMigrator @Inject constructor(
"(SELECT COUNT(*) FROM `accounts` AS ranked WHERE ranked.`email` < `accounts`.`email`)",
)
}
Log.d(TAG, "moved account tables into the account database: $present")
AppLog.d(TAG, "moved account tables into the account database: $present")
} finally {
db.rawExecSQL("DETACH DATABASE cache;")
}
@@ -1,8 +1,8 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import android.util.Log
import net.zetetic.database.sqlcipher.SQLiteDatabase
import org.libremail.reporting.AppLog
import java.io.File
/**
@@ -40,6 +40,7 @@ object DatabaseEncryption {
* tables but not that pragma, and a reset version would make Room attempt a bogus migration.
*/
private fun migrate(dbFile: File, sourcePassphrase: String, targetPassphrase: String) {
AppLog.i(TAG, "converting local cache database (targetEncrypted=${targetPassphrase.isNotEmpty()})")
ensureNativeLibraryLoaded()
val dir = dbFile.parentFile ?: error("database file has no parent directory")
val tmp = File(dir, dbFile.name + ".migrate").apply { delete() }
@@ -88,7 +89,7 @@ object DatabaseEncryption {
tmp.copyTo(dbFile, overwrite = true)
tmp.delete()
}
Log.d(TAG, "local cache database converted")
AppLog.d(TAG, "local cache database converted")
}
private fun startsWithSqliteHeader(dbFile: File): Boolean {
@@ -5,7 +5,7 @@ import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.UserNotAuthenticatedException
import android.util.Log
import org.libremail.reporting.AppLog
import javax.inject.Inject
import javax.inject.Singleton
@@ -43,7 +43,7 @@ class DatabaseKeyCipher @Inject constructor() :
override fun encrypt(plaintext: String): String = try {
super.encrypt(plaintext)
} catch (e: KeyPermanentlyInvalidatedException) {
Log.d(TAG, "replacing invalidated auth-bound key before sealing", e)
AppLog.d(TAG, "replacing invalidated auth-bound key before sealing", e)
deleteKey()
super.encrypt(plaintext)
}
@@ -60,16 +60,16 @@ class DatabaseKeyCipher @Inject constructor() :
initEncryptCipher(key)
false
} catch (e: KeyPermanentlyInvalidatedException) {
Log.d(TAG, "auth-bound database key invalidated", e)
AppLog.d(TAG, "auth-bound database key invalidated", e)
true
} catch (e: UserNotAuthenticatedException) {
// Valid key, just outside its time-bound auth window — not invalidated.
Log.d(TAG, "auth-bound key outside its auth window; not invalidated", e)
AppLog.d(TAG, "auth-bound key outside its auth window; not invalidated", e)
false
} catch (e: Exception) {
// Never let a validity probe crash the foreground pass; a real decrypt later surfaces any
// genuine problem. Treat an unknown probe failure as "not invalidated" (don't wipe).
Log.d(TAG, "auth-bound key validity probe failed; treating as valid", e)
AppLog.d(TAG, "auth-bound key validity probe failed; treating as valid", e)
false
}
}