diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 345c70e..96346ef 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -146,6 +146,7 @@ dependencies { implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) ksp(libs.androidx.room.compiler) + implementation(libs.sqlcipher.android) testImplementation(libs.junit) testImplementation(libs.kotlin.test) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 105d796..5b4301b 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -17,3 +17,16 @@ # AppAuth (de)serializes its models (AuthState, token responses) reflectively. -keep class net.openid.appauth.** { *; } -dontwarn net.openid.appauth.** + +# --- Strip debug/verbose logging from release builds --- +# Drops Log.d/Log.v calls (and the evaluation of their arguments) so nothing like an +# account address is ever written to logcat in a release build. +-assumenosideeffects class android.util.Log { + public static *** d(...); + public static *** v(...); +} + +# --- SQLCipher (opt-in encrypted cache) --- +# JNI-bound classes referenced by the native library; keep them intact. +-keep class net.zetetic.database.** { *; } +-dontwarn net.zetetic.database.** diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseEncryptionTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseEncryptionTest.kt new file mode 100644 index 0000000..bf0cb7a --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseEncryptionTest.kt @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.io.File +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import net.zetetic.database.sqlcipher.SupportOpenHelperFactory +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.local.entity.MessageEntity + +/** + * Round-trips the cache database through [DatabaseEncryption] (plaintext → encrypted → plaintext), + * asserting the data and Room's schema version survive and the on-disk file is no longer readable + * as plaintext once encrypted. Requires a device/emulator — it loads SQLCipher's native library. + */ +@RunWith(AndroidJUnit4::class) +class DatabaseEncryptionTest { + + private val context = ApplicationProvider.getApplicationContext() + private val dbName = "enc_roundtrip_test.db" + private val dbFile: File get() = context.getDatabasePath(dbName) + + // 64 hex chars == a 32-byte SQLCipher passphrase. + private val passphrase = "0123456789abcdef".repeat(4) + + @Before + @After + fun clean() { + context.deleteDatabase(dbName) + dbFile.parentFile?.listFiles { f -> f.name.startsWith(dbName) }?.forEach { it.delete() } + } + + @Test + fun encryptsDecryptsAndPreservesData() = runBlocking { + // Start with a plaintext Room database holding one row. + openPlaintext().apply { + messageDao().insertNew(listOf(message("acct:1"))) + close() + } + assertFalse("freshly created DB is plaintext", DatabaseEncryption.isEncrypted(dbFile)) + + // Encrypt in place, then open through SQLCipher and confirm the row + schema survived. + DatabaseEncryption.ensureEncrypted(dbFile, passphrase) + assertTrue("file must not read as plaintext once encrypted", DatabaseEncryption.isEncrypted(dbFile)) + openEncrypted().apply { + assertEquals(listOf("acct:1"), messageDao().observeAll().first().map { it.id }) + close() + } + + // Decrypt back to plaintext and confirm the row is still there. + DatabaseEncryption.ensurePlaintext(dbFile, passphrase) + assertFalse("file must be plaintext again after decrypt", DatabaseEncryption.isEncrypted(dbFile)) + openPlaintext().apply { + assertEquals(listOf("acct:1"), messageDao().observeAll().first().map { it.id }) + close() + } + } + + private fun openPlaintext(): LibreMailDatabase = + Room.databaseBuilder(context, LibreMailDatabase::class.java, dbName).build() + + private fun openEncrypted(): LibreMailDatabase = + Room.databaseBuilder(context, LibreMailDatabase::class.java, dbName) + .openHelperFactory(SupportOpenHelperFactory(passphrase.toByteArray(Charsets.US_ASCII), null, false)) + .build() + + private fun message(id: String) = MessageEntity( + id = id, + accountId = "acct", + sender = "Ada", + senderEmail = "ada@example.org", + subject = "Hi", + snippet = "", + body = "", + timestampMillis = 1_000L, + isRead = false, + isStarred = false, + ) +} diff --git a/app/src/main/kotlin/org/libremail/data/local/DatabaseEncryption.kt b/app/src/main/kotlin/org/libremail/data/local/DatabaseEncryption.kt new file mode 100644 index 0000000..eefdbae --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/DatabaseEncryption.kt @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import android.util.Log +import java.io.File +import net.zetetic.database.sqlcipher.SQLiteDatabase + +/** + * Converts the Room database file between plaintext and SQLCipher-encrypted form, in place and + * idempotently. It is meant to run at startup — before Room opens the file — so there is never an + * open connection to race with. The on-disk form is detected from the file header (a plaintext + * SQLite file starts with the 16-byte magic "SQLite format 3"; an encrypted one does not), + * so the conversion self-heals: it re-runs after an interrupted attempt and no-ops when already in + * the desired form. + */ +object DatabaseEncryption { + + /** True when the file exists and is NOT a plaintext SQLite database (i.e. it is encrypted). */ + fun isEncrypted(dbFile: File): Boolean = + dbFile.exists() && dbFile.length() >= SQLITE_HEADER.size && !startsWithSqliteHeader(dbFile) + + /** Ensures [dbFile] is SQLCipher-encrypted with [passphrase]; converts an existing plaintext DB. */ + fun ensureEncrypted(dbFile: File, passphrase: String) { + if (!dbFile.exists() || dbFile.length() == 0L) return // fresh DB: the factory creates it encrypted + if (!startsWithSqliteHeader(dbFile)) return // already encrypted + migrate(dbFile, sourcePassphrase = "", targetPassphrase = passphrase) + } + + /** Ensures [dbFile] is plaintext; decrypts an existing SQLCipher DB using [passphrase]. */ + fun ensurePlaintext(dbFile: File, passphrase: String) { + if (!dbFile.exists() || dbFile.length() == 0L) return + if (startsWithSqliteHeader(dbFile)) return // already plaintext + migrate(dbFile, sourcePassphrase = passphrase, targetPassphrase = "") + } + + /** + * Copies the database into a sibling temp file in the target form via `sqlcipher_export`, then + * atomically swaps it into place. An empty passphrase means "no encryption" on that side. Room's + * schema version (`PRAGMA user_version`) is carried across manually — `sqlcipher_export` copies + * 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) { + ensureLibraryLoaded() + val dir = dbFile.parentFile ?: error("database file has no parent directory") + val tmp = File(dir, dbFile.name + ".migrate").apply { delete() } + + val userVersion: Int + val source = SQLiteDatabase.openOrCreateDatabase( + dbFile.absolutePath, + sourcePassphrase.toByteArray(Charsets.US_ASCII), + null, // no CursorFactory + null, // no DatabaseErrorHandler + ) + try { + userVersion = source.version // PRAGMA user_version — Room's schema version + source.rawExecSQL("PRAGMA journal_mode = DELETE;") // fold any WAL back into the main file + val keyLiteral = targetPassphrase.replace("'", "''") + source.rawExecSQL("ATTACH DATABASE '${tmp.absolutePath}' AS target KEY '$keyLiteral';") + source.rawExecSQL("SELECT sqlcipher_export('target');") + source.rawExecSQL("DETACH DATABASE target;") + } finally { + source.close() + } + + // `sqlcipher_export` copies tables but not PRAGMA user_version; carry Room's schema version + // onto the exported file (on its own main schema — a schema-qualified PRAGMA is rejected) so + // Room doesn't see version 0 and attempt a bogus migration. + check(tmp.length() > 0L) { "sqlcipher_export produced no output database" } + val target = SQLiteDatabase.openOrCreateDatabase( + tmp.absolutePath, + targetPassphrase.toByteArray(Charsets.US_ASCII), + null, + null, + ) + try { + target.rawExecSQL("PRAGMA journal_mode = DELETE;") // no WAL sidecars to orphan on swap + target.version = userVersion + } finally { + target.close() + } + + // Swap the converted file into place; drop any stale WAL/SHM sidecars from either file first. + listOf(dbFile.name, tmp.name).forEach { base -> + File(dir, "$base-wal").delete() + File(dir, "$base-shm").delete() + } + if (!tmp.renameTo(dbFile)) { + tmp.copyTo(dbFile, overwrite = true) + tmp.delete() + } + Log.d(TAG, "local cache database converted") + } + + private fun startsWithSqliteHeader(dbFile: File): Boolean { + val head = ByteArray(SQLITE_HEADER.size) + val read = dbFile.inputStream().use { it.read(head) } + return read == SQLITE_HEADER.size && head.contentEquals(SQLITE_HEADER) + } + + @Volatile private var libraryLoaded = false + private fun ensureLibraryLoaded() { + if (libraryLoaded) return + synchronized(this) { + if (!libraryLoaded) { + System.loadLibrary("sqlcipher") + libraryLoaded = true + } + } + } + + private const val TAG = "LibreMailDbCrypto" + + // The 16-byte magic that opens every plaintext SQLite file: "SQLite format 3" + a NUL terminator. + // Spelled out as bytes to keep the trailing NUL unambiguous. + private val SQLITE_HEADER = byteArrayOf( + 0x53, 0x51, 0x4C, 0x69, 0x74, 0x65, 0x20, 0x66, + 0x6F, 0x72, 0x6D, 0x61, 0x74, 0x20, 0x33, 0x00, + ) +} diff --git a/app/src/main/kotlin/org/libremail/data/security/DatabaseKeyStore.kt b/app/src/main/kotlin/org/libremail/data/security/DatabaseKeyStore.kt new file mode 100644 index 0000000..356ae5f --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/security/DatabaseKeyStore.kt @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.security + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import java.security.SecureRandom +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +private val Context.dbKeyDataStore: DataStore by preferencesDataStore(name = "libremail_dbkey") + +/** + * Supplies the SQLCipher passphrase for the opt-in encrypted cache. A random 256-bit key is + * generated once and persisted only as ciphertext — sealed by the non-exportable Android Keystore + * key via [KeystoreCrypto] — so the key that protects the cache is itself protected at rest. The + * passphrase is returned as a 64-character hex string and used directly as the SQLCipher passphrase. + * + * Stored in its own DataStore (not the Room database it protects) to avoid a chicken-and-egg cycle. + */ +@Singleton +class DatabaseKeyStore @Inject constructor( + @ApplicationContext private val context: Context, + private val crypto: KeystoreCrypto, +) { + private val generationLock = Mutex() + + /** Returns the cache passphrase, generating and sealing it on first use. */ + suspend fun passphrase(): String { + existing()?.let { return it } + return generationLock.withLock { + // Re-check inside the lock so a concurrent first-caller doesn't generate a second key + // (which would leave a DB encrypted under a key we then overwrite and can't reproduce). + existing() ?: generateAndStore() + } + } + + private suspend fun existing(): String? = + context.dbKeyDataStore.data.first()[SEALED_KEY]?.let { crypto.decrypt(it) } + + private suspend fun generateAndStore(): String { + val raw = ByteArray(KEY_BYTES).also { SecureRandom().nextBytes(it) } + val hex = raw.joinToString("") { "%02x".format(it) } + context.dbKeyDataStore.edit { it[SEALED_KEY] = crypto.encrypt(hex) } + return hex + } + + private companion object { + val SEALED_KEY = stringPreferencesKey("sealed_db_key") + const val KEY_BYTES = 32 + } +} diff --git a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt index 885bb28..a14585d 100644 --- a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt +++ b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt @@ -23,6 +23,7 @@ data class AppSettings( val pushIdle: Boolean = true, val allowStartTls: Boolean = false, val loadRemoteImages: Boolean = false, + val encryptCache: Boolean = false, ) @Singleton @@ -36,6 +37,7 @@ class SettingsRepository @Inject constructor( pushIdle = prefs[PUSH_IDLE] ?: true, allowStartTls = prefs[ALLOW_STARTTLS] ?: false, loadRemoteImages = prefs[LOAD_REMOTE_IMAGES] ?: false, + encryptCache = prefs[ENCRYPT_CACHE] ?: false, ) } @@ -48,6 +50,7 @@ class SettingsRepository @Inject constructor( suspend fun setPushIdle(value: Boolean) = put(PUSH_IDLE, value) suspend fun setAllowStartTls(value: Boolean) = put(ALLOW_STARTTLS, value) suspend fun setLoadRemoteImages(value: Boolean) = put(LOAD_REMOTE_IMAGES, value) + suspend fun setEncryptCache(value: Boolean) = put(ENCRYPT_CACHE, value) private suspend fun put(key: Preferences.Key, value: Boolean) { context.settingsDataStore.edit { it[key] = value } @@ -59,5 +62,6 @@ class SettingsRepository @Inject constructor( val PUSH_IDLE = booleanPreferencesKey("push_idle") val ALLOW_STARTTLS = booleanPreferencesKey("allow_starttls") val LOAD_REMOTE_IMAGES = booleanPreferencesKey("load_remote_images") + val ENCRYPT_CACHE = booleanPreferencesKey("encrypt_cache") } } diff --git a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt index c6facf3..e237c63 100644 --- a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt +++ b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt @@ -9,6 +9,10 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import net.zetetic.database.sqlcipher.SupportOpenHelperFactory +import org.libremail.data.local.DatabaseEncryption import org.libremail.data.local.LibreMailDatabase import org.libremail.data.local.MIGRATION_1_2 import org.libremail.data.local.MIGRATION_2_3 @@ -22,6 +26,8 @@ import org.libremail.data.local.dao.CredentialDao import org.libremail.data.local.dao.DraftDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.dao.OutboxDao +import org.libremail.data.security.DatabaseKeyStore +import org.libremail.data.settings.SettingsRepository @Module @InstallIn(SingletonComponent::class) @@ -29,8 +35,12 @@ object DatabaseModule { @Provides @Singleton - fun provideDatabase(@ApplicationContext context: Context): LibreMailDatabase = - Room.databaseBuilder(context, LibreMailDatabase::class.java, "libremail.db") + fun provideDatabase( + @ApplicationContext context: Context, + keyStore: DatabaseKeyStore, + settingsRepository: SettingsRepository, + ): LibreMailDatabase { + val builder = Room.databaseBuilder(context, LibreMailDatabase::class.java, DB_NAME) .addMigrations( MIGRATION_1_2, MIGRATION_2_3, @@ -39,10 +49,26 @@ object DatabaseModule { MIGRATION_5_6, MIGRATION_6_7, ) - // No destructive fallback: the migration chain is complete, and silently dropping the - // accounts/credentials/mail tables would lose stored secrets. A missing migration should - // fail loudly in testing instead. - .build() + // No destructive fallback: the migration chain is complete, and silently dropping the + // accounts/credentials/mail tables would lose stored secrets. A missing migration should + // fail loudly in testing instead. + + // Opt-in at-rest encryption of the local cache (off by default). The conversion runs here — + // before the database is opened — so it never races an open connection; toggling the setting + // therefore takes effect on the next app start. The passphrase is sealed by the Keystore. + val dbFile = context.getDatabasePath(DB_NAME) + if (runBlocking { settingsRepository.settings.first().encryptCache }) { + val passphrase = runBlocking { keyStore.passphrase() } + DatabaseEncryption.ensureEncrypted(dbFile, passphrase) + builder.openHelperFactory( + SupportOpenHelperFactory(passphrase.toByteArray(Charsets.US_ASCII), null, false), + ) + } else if (DatabaseEncryption.isEncrypted(dbFile)) { + // Encryption was turned back off — decrypt so the default (unkeyed) open succeeds. + DatabaseEncryption.ensurePlaintext(dbFile, runBlocking { keyStore.passphrase() }) + } + return builder.build() + } @Provides fun provideMessageDao(database: LibreMailDatabase): MessageDao = database.messageDao() @@ -61,4 +87,6 @@ object DatabaseModule { @Provides fun provideDraftDao(database: LibreMailDatabase): DraftDao = database.draftDao() + + private const val DB_NAME = "libremail.db" } diff --git a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt index 105f41d..b60d623 100644 --- a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt +++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt @@ -223,7 +223,7 @@ class ImapClient @Inject constructor() { runCatching { store.close() } throw e } - Log.d(TAG, "IDLE connected for ${params.username}") + Log.d(TAG, "IDLE connected") val pushes = Channel(Channel.CONFLATED) inbox.addMessageCountListener(object : MessageCountAdapter() { @@ -353,6 +353,10 @@ class ImapClient @Inject constructor() { put("mail.$protocol.starttls.enable", "true") put("mail.$protocol.starttls.required", params.strictStartTls.toString()) } + // Verify the server certificate matches the host whenever TLS is used. Angus already + // defaults this to true; set it explicitly so a future library-default change can't + // silently disable hostname checking and expose us to MITM. (No-op for MailSecurity.NONE.) + put("mail.$protocol.ssl.checkserveridentity", "true") if (params.useXoauth2) { put("mail.$protocol.auth.mechanisms", "XOAUTH2") } diff --git a/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt b/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt index 1ed7ec7..d7cea75 100644 --- a/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt +++ b/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt @@ -45,6 +45,9 @@ class SmtpSender @Inject constructor() { put("mail.$protocol.starttls.enable", "true") put("mail.$protocol.starttls.required", params.strictStartTls.toString()) } + // Verify the server certificate matches the host whenever TLS is used (explicit so a + // future Angus default change can't silently disable it). No-op for MailSecurity.NONE. + put("mail.$protocol.ssl.checkserveridentity", "true") if (params.useXoauth2) { put("mail.$protocol.auth.mechanisms", "XOAUTH2") } diff --git a/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt b/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt index 83ceb4a..4eae29f 100644 --- a/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt +++ b/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt @@ -3,6 +3,7 @@ package org.libremail.notifications import android.Manifest import android.annotation.SuppressLint +import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent @@ -95,7 +96,11 @@ class MailNotifier @Inject constructor( CHANNEL_ID, context.getString(R.string.notif_channel_new_mail), NotificationManager.IMPORTANCE_DEFAULT, - ) + ).apply { + // Redact sender/subject on a secure lock screen (the system shows a generic placeholder + // instead). Applies on fresh installs; Android ignores channel changes after creation. + lockscreenVisibility = Notification.VISIBILITY_PRIVATE + } NotificationManagerCompat.from(context).createNotificationChannel(channel) } diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt index 5164c9e..5806157 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt @@ -192,7 +192,10 @@ private fun SecuritySelector(label: String, selected: MailSecurity, onSelect: (M Text(label, style = MaterialTheme.typography.labelLarge) Spacer(Modifier.height(6.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - MailSecurity.entries.forEach { security -> + // NONE (no transport security) is deliberately not offered in the UI: selecting it would + // send the account password/token in cleartext. It stays in the enum only for local + // test servers, which are configured in tests rather than through this screen. + MailSecurity.entries.filter { it != MailSecurity.NONE }.forEach { security -> FilterChip( selected = selected == security, onClick = { onSelect(security) }, diff --git a/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt b/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt index 2e6b74b..7f5a993 100644 --- a/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt +++ b/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt @@ -82,6 +82,7 @@ private fun wrapHtml(body: String): String = +