feat(logging): DB/keystore -> AppLog + breadcrumbs (#327) #337
@@ -13,6 +13,7 @@ import kotlinx.coroutines.runBlocking
|
|||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
@@ -21,6 +22,8 @@ import org.junit.Rule
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.libremail.data.local.entity.CredentialEntity
|
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 /
|
* 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
|
@Test
|
||||||
fun reRunningTheCopyIsIdempotentAndKeepsLaterEdits() = runBlocking<Unit> {
|
fun reRunningTheCopyIsIdempotentAndKeepsLaterEdits() = runBlocking<Unit> {
|
||||||
seedVersion14Cache()
|
seedVersion14Cache()
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import org.junit.Before
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.libremail.data.local.entity.MessageEntity
|
import org.libremail.data.local.entity.MessageEntity
|
||||||
|
import org.libremail.reporting.AppLog
|
||||||
|
import org.libremail.reporting.RingLogBuffer
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,6 +152,42 @@ class DatabaseEncryptionTest {
|
|||||||
assertEquals("Room's schema version must survive the plaintext -> encrypted conversion", 19, version)
|
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 =
|
private fun openPlaintext(): LibreMailDatabase =
|
||||||
Room.databaseBuilder(context, LibreMailDatabase::class.java, dbName).build()
|
Room.databaseBuilder(context, LibreMailDatabase::class.java, dbName).build()
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
package org.libremail.data.local
|
package org.libremail.data.local
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
@@ -15,6 +14,7 @@ import kotlinx.coroutines.withContext
|
|||||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||||
import org.libremail.data.security.DatabaseKeyStore
|
import org.libremail.data.security.DatabaseKeyStore
|
||||||
import org.libremail.data.settings.SettingsRepository
|
import org.libremail.data.settings.SettingsRepository
|
||||||
|
import org.libremail.reporting.AppLog
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -181,7 +181,7 @@ class AccountDataMigrator @Inject constructor(
|
|||||||
"(SELECT COUNT(*) FROM `accounts` AS ranked WHERE ranked.`email` < `accounts`.`email`)",
|
"(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 {
|
} finally {
|
||||||
db.rawExecSQL("DETACH DATABASE cache;")
|
db.rawExecSQL("DETACH DATABASE cache;")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
package org.libremail.data.local
|
package org.libremail.data.local
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||||
|
import org.libremail.reporting.AppLog
|
||||||
import java.io.File
|
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.
|
* 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) {
|
private fun migrate(dbFile: File, sourcePassphrase: String, targetPassphrase: String) {
|
||||||
|
AppLog.i(TAG, "converting local cache database (targetEncrypted=${targetPassphrase.isNotEmpty()})")
|
||||||
ensureNativeLibraryLoaded()
|
ensureNativeLibraryLoaded()
|
||||||
val dir = dbFile.parentFile ?: error("database file has no parent directory")
|
val dir = dbFile.parentFile ?: error("database file has no parent directory")
|
||||||
val tmp = File(dir, dbFile.name + ".migrate").apply { delete() }
|
val tmp = File(dir, dbFile.name + ".migrate").apply { delete() }
|
||||||
@@ -88,7 +89,7 @@ object DatabaseEncryption {
|
|||||||
tmp.copyTo(dbFile, overwrite = true)
|
tmp.copyTo(dbFile, overwrite = true)
|
||||||
tmp.delete()
|
tmp.delete()
|
||||||
}
|
}
|
||||||
Log.d(TAG, "local cache database converted")
|
AppLog.d(TAG, "local cache database converted")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startsWithSqliteHeader(dbFile: File): Boolean {
|
private fun startsWithSqliteHeader(dbFile: File): Boolean {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import android.os.Build
|
|||||||
import android.security.keystore.KeyGenParameterSpec
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||||
import android.security.keystore.UserNotAuthenticatedException
|
import android.security.keystore.UserNotAuthenticatedException
|
||||||
import android.util.Log
|
import org.libremail.reporting.AppLog
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ class DatabaseKeyCipher @Inject constructor() :
|
|||||||
override fun encrypt(plaintext: String): String = try {
|
override fun encrypt(plaintext: String): String = try {
|
||||||
super.encrypt(plaintext)
|
super.encrypt(plaintext)
|
||||||
} catch (e: KeyPermanentlyInvalidatedException) {
|
} 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()
|
deleteKey()
|
||||||
super.encrypt(plaintext)
|
super.encrypt(plaintext)
|
||||||
}
|
}
|
||||||
@@ -60,16 +60,16 @@ class DatabaseKeyCipher @Inject constructor() :
|
|||||||
initEncryptCipher(key)
|
initEncryptCipher(key)
|
||||||
false
|
false
|
||||||
} catch (e: KeyPermanentlyInvalidatedException) {
|
} catch (e: KeyPermanentlyInvalidatedException) {
|
||||||
Log.d(TAG, "auth-bound database key invalidated", e)
|
AppLog.d(TAG, "auth-bound database key invalidated", e)
|
||||||
true
|
true
|
||||||
} catch (e: UserNotAuthenticatedException) {
|
} catch (e: UserNotAuthenticatedException) {
|
||||||
// Valid key, just outside its time-bound auth window — not invalidated.
|
// 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
|
false
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Never let a validity probe crash the foreground pass; a real decrypt later surfaces any
|
// 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).
|
// 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
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user