From e84fb12fcc83613db8ee7a5ef4d3c9db3fca0e97 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 6 Jul 2026 14:24:28 +0800 Subject: [PATCH] feat(sqlite): support SQLCipher encrypted databases --- Cargo.lock | 11 ++ .../connection/ConnectionDialog.vue | 4 + crates/dbx-core/Cargo.toml | 3 +- crates/dbx-core/src/connection.rs | 7 +- crates/dbx-core/src/db/sqlite.rs | 120 +++++++++++++++++- src-tauri/Cargo.toml | 3 +- 6 files changed, 139 insertions(+), 9 deletions(-) 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 = [] }