diff --git a/Cargo.lock b/Cargo.lock
index 5464091ae..f68caa03e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4283,6 +4283,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
+ "openssl-sys",
"pkg-config",
"vcpkg",
]
@@ -5234,6 +5235,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+[[package]]
+name = "openssl-src"
+version = "300.6.1+3.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
+dependencies = [
+ "cc",
+]
+
[[package]]
name = "openssl-sys"
version = "0.9.116"
@@ -5242,6 +5252,7 @@ checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
dependencies = [
"cc",
"libc",
+ "openssl-src",
"pkg-config",
"vcpkg",
]
diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue
index 780877939..1f5540798 100644
--- a/apps/desktop/src/components/connection/ConnectionDialog.vue
+++ b/apps/desktop/src/components/connection/ConnectionDialog.vue
@@ -3759,6 +3759,10 @@ function openExternalUrl(url: string) {
+
diff --git a/crates/dbx-core/Cargo.toml b/crates/dbx-core/Cargo.toml
index e2f4404d5..b8654ee9a 100644
--- a/crates/dbx-core/Cargo.toml
+++ b/crates/dbx-core/Cargo.toml
@@ -12,9 +12,10 @@ test = false
bench = false
[features]
-default = ["duckdb-bundled", "mq-admin"]
+default = ["duckdb-bundled", "mq-admin", "sqlite-sqlcipher"]
duckdb-bundled = ["duckdb/bundled"]
mq-admin = []
+sqlite-sqlcipher = ["rusqlite/bundled-sqlcipher-vendored-openssl"]
[dependencies]
serde = { version = "1.0", features = ["derive"] }
diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs
index dbd28f2ef..886aab8f1 100644
--- a/crates/dbx-core/src/connection.rs
+++ b/crates/dbx-core/src/connection.rs
@@ -973,7 +973,12 @@ impl AppState {
})
.collect();
PoolKind::Sqlite(
- db::sqlite::connect_path_with_extensions(&expand_tilde(&db_config.host), extensions).await?,
+ db::sqlite::connect_path_with_cipher_key_and_extensions(
+ &expand_tilde(&db_config.host),
+ &db_config.password,
+ extensions,
+ )
+ .await?,
)
}
DatabaseType::Rqlite => {
diff --git a/crates/dbx-core/src/db/sqlite.rs b/crates/dbx-core/src/db/sqlite.rs
index 0801fb771..6b186f6f6 100644
--- a/crates/dbx-core/src/db/sqlite.rs
+++ b/crates/dbx-core/src/db/sqlite.rs
@@ -40,34 +40,50 @@ impl SqliteHandle {
}
pub async fn connect_path(path: &str) -> Result {
- connect_path_with_options(path, false, Vec::new()).await
+ connect_path_with_options(path, false, None, Vec::new()).await
}
pub async fn connect_path_with_extensions(
path: &str,
extensions: Vec,
) -> Result {
- connect_path_with_options(path, false, extensions).await
+ connect_path_with_options(path, false, None, extensions).await
+}
+
+pub async fn connect_path_with_cipher_key_and_extensions(
+ path: &str,
+ cipher_key: &str,
+ extensions: Vec,
+) -> Result {
+ connect_path_with_options(path, false, sqlite_cipher_key(cipher_key), extensions).await
}
pub async fn connect_path_create_if_missing(path: &str) -> Result {
- connect_path_with_options(path, true, Vec::new()).await
+ connect_path_with_options(path, true, None, Vec::new()).await
}
pub async fn connect_path_create_if_missing_with_extensions(
path: &str,
extensions: Vec,
) -> Result {
- connect_path_with_options(path, true, extensions).await
+ connect_path_with_options(path, true, None, extensions).await
+}
+
+pub async fn connect_path_create_if_missing_with_cipher_key(
+ path: &str,
+ cipher_key: &str,
+) -> Result {
+ connect_path_with_options(path, true, sqlite_cipher_key(cipher_key), Vec::new()).await
}
async fn connect_path_with_options(
path: &str,
create_if_missing: bool,
+ cipher_key: Option,
extensions: Vec,
) -> Result {
let path = path.to_string();
- tokio::task::spawn_blocking(move || open_sqlite_handle(&path, create_if_missing, extensions))
+ tokio::task::spawn_blocking(move || open_sqlite_handle(&path, create_if_missing, cipher_key, extensions))
.await
.map_err(|e| e.to_string())?
}
@@ -75,9 +91,12 @@ async fn connect_path_with_options(
fn open_sqlite_handle(
path: &str,
create_if_missing: bool,
+ cipher_key: Option,
extensions: Vec,
) -> Result {
let is_memory = is_memory_database_path(path);
+ let encrypted = cipher_key.as_deref().is_some_and(|key| !key.is_empty());
+ ensure_sqlcipher_available(encrypted)?;
if !is_memory && !create_if_missing {
validate_file_path(path, is_network_path)?;
}
@@ -85,7 +104,7 @@ fn open_sqlite_handle(
if !is_memory && create_if_missing {
ensure_parent_dir(path)?;
}
- if !is_memory && !is_network_path(path) {
+ if !is_memory && !is_network_path(path) && !encrypted {
validate_existing_sqlite_file(path)?;
}
@@ -105,6 +124,7 @@ fn open_sqlite_handle(
}
};
+ apply_sqlcipher_key(&conn, cipher_key.as_deref())?;
conn.busy_timeout(std::time::Duration::from_secs(10)).map_err(|e| e.to_string())?;
load_sqlite_extensions(&conn, &extensions)?;
register_sqlite_compat_functions(&conn)?;
@@ -112,6 +132,47 @@ fn open_sqlite_handle(
Ok(SqliteHandle { conn: Arc::new(Mutex::new(conn)) })
}
+fn sqlite_cipher_key(cipher_key: &str) -> Option {
+ if cipher_key.is_empty() {
+ None
+ } else {
+ Some(cipher_key.to_string())
+ }
+}
+
+#[cfg(feature = "sqlite-sqlcipher")]
+fn ensure_sqlcipher_available(_encrypted: bool) -> Result<(), String> {
+ Ok(())
+}
+
+#[cfg(not(feature = "sqlite-sqlcipher"))]
+fn ensure_sqlcipher_available(encrypted: bool) -> Result<(), String> {
+ if encrypted {
+ Err("SQLCipher support is not compiled in this build. Rebuild with the sqlite-sqlcipher feature.".to_string())
+ } else {
+ Ok(())
+ }
+}
+
+#[cfg(feature = "sqlite-sqlcipher")]
+fn apply_sqlcipher_key(conn: &Connection, cipher_key: Option<&str>) -> Result<(), String> {
+ let Some(cipher_key) = cipher_key.filter(|key| !key.is_empty()) else {
+ return Ok(());
+ };
+
+ // SQLCipher requires the key before the first schema read; the verification
+ // query turns wrong keys into an immediate connection error.
+ conn.pragma_update(None, "key", cipher_key).map_err(|e| format!("SQLCipher key setup failed: {e}"))?;
+ conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(()))
+ .map_err(|e| format!("SQLCipher database unlock failed. Check the SQLite password/key and file type: {e}"))?;
+ Ok(())
+}
+
+#[cfg(not(feature = "sqlite-sqlcipher"))]
+fn apply_sqlcipher_key(_conn: &Connection, _cipher_key: Option<&str>) -> Result<(), String> {
+ Ok(())
+}
+
fn register_sqlite_compat_functions(conn: &Connection) -> Result<(), String> {
let flags = FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC | FunctionFlags::SQLITE_INNOCUOUS;
@@ -463,6 +524,53 @@ mod tests {
let _ = std::fs::remove_file(path);
}
+ #[cfg(feature = "sqlite-sqlcipher")]
+ #[tokio::test]
+ async fn sqlcipher_key_creates_and_reopens_encrypted_database() {
+ let path = std::env::temp_dir().join(format!("dbx-sqlcipher-{}.db", uuid::Uuid::new_v4()));
+ let key = "secret key";
+
+ {
+ let pool = connect_path_create_if_missing_with_cipher_key(path.to_str().unwrap(), key)
+ .await
+ .expect("create encrypted sqlite");
+ execute_query(&pool, "CREATE TABLE t (name TEXT); INSERT INTO t VALUES ('encrypted');")
+ .await
+ .expect("write encrypted sqlite");
+ }
+
+ assert!(!path_has_sqlite_header(&path).expect("inspect encrypted header"));
+
+ let reopened = connect_path_with_cipher_key_and_extensions(path.to_str().unwrap(), key, Vec::new())
+ .await
+ .expect("reopen encrypted sqlite");
+ let result = execute_query(&reopened, "SELECT name FROM t").await.expect("read encrypted sqlite");
+ assert_eq!(result.rows[0][0], serde_json::json!("encrypted"));
+
+ let wrong_key =
+ match connect_path_with_cipher_key_and_extensions(path.to_str().unwrap(), "wrong key", Vec::new()).await {
+ Ok(_) => panic!("wrong key must fail"),
+ Err(err) => err,
+ };
+ assert!(wrong_key.contains("SQLCipher database unlock failed"));
+
+ let _ = std::fs::remove_file(path);
+ }
+
+ #[cfg(not(feature = "sqlite-sqlcipher"))]
+ #[tokio::test]
+ async fn sqlcipher_key_requires_sqlcipher_feature() {
+ let err =
+ match connect_path_with_cipher_key_and_extensions("/tmp/dbx-missing-sqlcipher.db", "secret", Vec::new())
+ .await
+ {
+ Ok(_) => panic!("SQLCipher key should require feature support"),
+ Err(err) => err,
+ };
+
+ assert!(err.contains("SQLCipher support is not compiled"));
+ }
+
#[test]
fn sqlite_extension_specs_parse_repeated_and_multiline_url_params() {
let params = "cache=shared&sqlite_extension=%2Fopt%2Fregexp.dylib&sqlite_extensions=%2Fopt%2Ftext.dylib%7Csqlite3_text_init%0A%2Fopt%2Fcrypto.dylib";
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index ba12bcda0..22e735331 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -13,9 +13,10 @@ name = "dbx_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[features]
-default = ["duckdb-bundled", "mq-admin"]
+default = ["duckdb-bundled", "mq-admin", "sqlite-sqlcipher"]
duckdb-bundled = ["duckdb", "dbx-core/duckdb-bundled"]
mq-admin = ["dbx-core/mq-admin"]
+sqlite-sqlcipher = ["dbx-core/sqlite-sqlcipher"]
[build-dependencies]
tauri-build = { version = "2.5.6", features = [] }