diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 57bd1fad..d6328b64 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -146,6 +146,7 @@ dependencies { // Local database (Room) — message persistence implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) + implementation(libs.sqlcipher) ksp(libs.androidx.room.compiler) // Local tests: jUnit, coroutines, Android runner diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index 1382b01e..52ede98a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -115,6 +115,21 @@ object AuthManager { requirePrefs().edit().putString(KEY_SESSION_COOKIE, cookie).apply() } + // ── Database Master Password ───────────────────────────────────────── + + fun getDatabasePassword(): ByteArray { + val prefs = requirePrefs() + var dbPasswordBase64 = prefs.getString("db_password", null) + if (dbPasswordBase64 == null) { + val random = java.security.SecureRandom() + val newPassword = ByteArray(32) + random.nextBytes(newPassword) + dbPasswordBase64 = android.util.Base64.encodeToString(newPassword, android.util.Base64.NO_WRAP) + prefs.edit().putString("db_password", dbPasswordBase64).apply() + } + return android.util.Base64.decode(dbPasswordBase64, android.util.Base64.NO_WRAP) + } + // ── Connection Profiles ────────────────────────────────────────────── fun getConnectionProfiles(): List { diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt index 417bbfb6..2ecf99ef 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt @@ -4,11 +4,12 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase -import com.m57.hermescontrol.BuildConfig +import net.zetetic.database.sqlcipher.SupportOpenHelperFactory +import java.io.File @Database( entities = [ChatMessageEntity::class], - version = 1, + version = 2, exportSchema = true, ) abstract class HermesDatabase : RoomDatabase() { @@ -20,19 +21,43 @@ abstract class HermesDatabase : RoomDatabase() { fun get(context: Context): HermesDatabase = instance ?: synchronized(this) { + // SQLCipher can't open plaintext SQLite databases — if an old + // unencrypted DB exists (v1), delete it so Room + SQLCipher can + // create an encrypted replacement from scratch. + val dbFile = context.getDatabasePath("hermes_control.db") + if (dbFile.exists() && !isSqlCipherDatabase(dbFile)) { + dbFile.delete() + } + + // Load SQLCipher native library before creating the factory + System.loadLibrary("sqlcipher") + val factory = SupportOpenHelperFactory(AuthManager.getDatabasePassword()) + instance ?: Room .databaseBuilder( context.applicationContext, HermesDatabase::class.java, "hermes_control.db", - ).apply { - if (BuildConfig.DEBUG) { - fallbackToDestructiveMigration(false) - } - }.build() + ).openHelperFactory(factory) + .fallbackToDestructiveMigration() + .build() .also { instance = it } } + /** Returns true if the database file starts with the SQLCipher magic header. */ + private fun isSqlCipherDatabase(file: File): Boolean { + return try { + val header = ByteArray(16) + file.inputStream().use { it.read(header) } + // SQLCipher 4.x databases start with bytes that differ from + // the plaintext SQLite header "SQLite format 3\0" + val plaintextHeader = "SQLite format 3\u0000" + !header.contentEquals(plaintextHeader.toByteArray()) + } catch (_: Exception) { + false // if we can't read it, treat as plaintext and delete + } + } + /** For testing — inject a custom instance. */ fun setForTest(db: HermesDatabase?) { instance = db diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ef6eb18a..28ea370f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,7 @@ gson = "2.11.0" securityCrypto = "1.0.0" mockk = "1.13.12" room = "2.7.1" +sqlcipher = "4.9.0" [libraries] androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" } @@ -72,6 +73,7 @@ androidx-security-crypto = { module = "androidx.security:security-crypto", versi androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +sqlcipher = { module = "net.zetetic:sqlcipher-android", version.ref = "sqlcipher" } [plugins] @@ -79,4 +81,4 @@ android-application = { id = "com.android.application", version.ref = "androidGr compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } room = { id = "androidx.room", version.ref = "room" } -ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } \ No newline at end of file +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }