feat(sqlite): support SQLCipher encrypted databases

This commit is contained in:
t8y2 2026-07-06 14:24:28 +08:00
parent 92ef1ac3cf
commit e84fb12fcc
6 changed files with 139 additions and 9 deletions

11
Cargo.lock generated
View File

@ -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",
]

View File

@ -3759,6 +3759,10 @@ function openExternalUrl(url: string) {
</p>
</div>
</div>
<div v-if="form.db_type === 'sqlite'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.sqliteCipherKey") }}</Label>
<PasswordInput v-model="form.password" class="col-span-3" :placeholder="t('connection.sqliteCipherKeyPlaceholder')" />
</div>
<div v-if="form.db_type === 'sqlite'" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.sqliteExtensions") }}</Label>
<div class="col-span-3 space-y-1">

View File

@ -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"] }

View File

@ -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 => {

View File

@ -40,34 +40,50 @@ impl SqliteHandle {
}
pub async fn connect_path(path: &str) -> Result<SqliteHandle, String> {
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<SqliteExtensionSpec>,
) -> Result<SqliteHandle, String> {
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<SqliteExtensionSpec>,
) -> Result<SqliteHandle, String> {
connect_path_with_options(path, false, sqlite_cipher_key(cipher_key), extensions).await
}
pub async fn connect_path_create_if_missing(path: &str) -> Result<SqliteHandle, String> {
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<SqliteExtensionSpec>,
) -> Result<SqliteHandle, String> {
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<SqliteHandle, String> {
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<String>,
extensions: Vec<SqliteExtensionSpec>,
) -> Result<SqliteHandle, String> {
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<String>,
extensions: Vec<SqliteExtensionSpec>,
) -> Result<SqliteHandle, String> {
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<String> {
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";

View File

@ -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 = [] }