1use std::path::Path;
38
39use secrecy::{ExposeSecret, SecretBox};
40use zeroize::Zeroizing;
41
42use super::connection::Connection;
43use super::error::{DbResult, Error};
44
45const CIPHER_CHACHA20: &str = "chacha20";
46const FOREIGN_KEYS_ON: i64 = 1;
47const SYNCHRONOUS_FULL: i64 = 2;
48const SECURE_DELETE_ON: i64 = 1;
49const TEMP_STORE_MEMORY: i64 = 2;
50
51pub fn open_encrypted(
62 path: &Path,
63 k_intermediate: &SecretBox<[u8; 32]>,
64 read_only: bool,
65) -> DbResult<Connection> {
66 #[cfg(not(target_arch = "wasm32"))]
67 let conn = Connection::open(path, read_only)?;
68 #[cfg(target_arch = "wasm32")]
69 let conn = Connection::open_with_opfs_vfs(path, read_only)?;
70 configure_connection(&conn, k_intermediate)?;
71 Ok(conn)
72}
73
74fn configure_connection(
85 conn: &Connection,
86 k_intermediate: &SecretBox<[u8; 32]>,
87) -> DbResult<()> {
88 ensure_cipher(conn)?;
89 apply_key(conn, k_intermediate)?;
90
91 #[cfg(not(target_arch = "wasm32"))]
92 ensure_journal_mode(conn, "WAL")?;
93 #[cfg(target_arch = "wasm32")]
98 ensure_journal_mode(conn, "DELETE")?;
99
100 ensure_foreign_keys(conn)?;
101 ensure_synchronous_full(conn)?;
102 ensure_secure_delete(conn)?;
103 ensure_temp_store_memory(conn)?;
104 Ok(())
105}
106
107fn ensure_cipher(conn: &Connection) -> DbResult<()> {
112 conn.execute_batch(&format!("PRAGMA cipher = '{CIPHER_CHACHA20}';"))?;
113 let actual = conn.query_row("PRAGMA cipher;", &[], |row| Ok(row.column_text(0)))?;
114 if actual.eq_ignore_ascii_case(CIPHER_CHACHA20) {
115 Ok(())
116 } else {
117 Err(Error::new(
118 -1,
119 format!(
120 "could not ensure sqlite3mc cipher {CIPHER_CHACHA20}: SQLite selected {actual}"
121 ),
122 ))
123 }
124}
125
126fn apply_key(conn: &Connection, k_intermediate: &SecretBox<[u8; 32]>) -> DbResult<()> {
137 let key_hex = Zeroizing::new(hex::encode(k_intermediate.expose_secret()));
139 let pragma = Zeroizing::new(format!("PRAGMA key = \"x'{}'\";", key_hex.as_str()));
140
141 conn.execute_batch_zeroized(&pragma)?;
144
145 conn.execute_batch("SELECT count(*) FROM sqlite_master;")
148 .map_err(|e| {
149 Error::new(
150 e.code.0,
151 format!(
152 "encryption key verification failed (is the key correct?): {}",
153 e.message
154 ),
155 )
156 })?;
157
158 Ok(())
161}
162
163fn ensure_journal_mode(conn: &Connection, requested: &str) -> DbResult<()> {
168 let actual =
169 conn.query_row(&format!("PRAGMA journal_mode = {requested};"), &[], |row| {
170 Ok(row.column_text(0))
171 })?;
172 if actual.eq_ignore_ascii_case(requested) {
173 Ok(())
174 } else {
175 Err(Error::new(
176 -1,
177 format!(
178 "could not ensure journal mode {requested}: SQLite selected {actual}"
179 ),
180 ))
181 }
182}
183
184fn ensure_foreign_keys(conn: &Connection) -> DbResult<()> {
189 conn.execute_batch("PRAGMA foreign_keys = ON;")?;
190 let actual =
191 conn.query_row("PRAGMA foreign_keys;", &[], |row| Ok(row.column_i64(0)))?;
192 if actual == FOREIGN_KEYS_ON {
193 Ok(())
194 } else {
195 Err(Error::new(
196 -1,
197 format!(
198 "could not ensure PRAGMA foreign_keys = ON: expected {FOREIGN_KEYS_ON}, got {actual}"
199 ),
200 ))
201 }
202}
203
204fn ensure_synchronous_full(conn: &Connection) -> DbResult<()> {
209 conn.execute_batch("PRAGMA synchronous = FULL;")?;
210 let actual =
211 conn.query_row("PRAGMA synchronous;", &[], |row| Ok(row.column_i64(0)))?;
212 if actual == SYNCHRONOUS_FULL {
213 Ok(())
214 } else {
215 Err(Error::new(
216 -1,
217 format!(
218 "could not ensure PRAGMA synchronous = FULL: expected {SYNCHRONOUS_FULL}, got {actual}"
219 ),
220 ))
221 }
222}
223
224fn ensure_secure_delete(conn: &Connection) -> DbResult<()> {
229 conn.execute_batch("PRAGMA secure_delete = ON;")?;
230 let actual =
231 conn.query_row("PRAGMA secure_delete;", &[], |row| Ok(row.column_i64(0)))?;
232 if actual == SECURE_DELETE_ON {
233 Ok(())
234 } else {
235 Err(Error::new(
236 -1,
237 format!(
238 "could not ensure PRAGMA secure_delete = ON: expected {SECURE_DELETE_ON}, got {actual}"
239 ),
240 ))
241 }
242}
243
244fn ensure_temp_store_memory(conn: &Connection) -> DbResult<()> {
249 conn.execute_batch("PRAGMA temp_store = MEMORY;")?;
250 let actual =
251 conn.query_row("PRAGMA temp_store;", &[], |row| Ok(row.column_i64(0)))?;
252 if actual == TEMP_STORE_MEMORY {
253 Ok(())
254 } else {
255 Err(Error::new(
256 -1,
257 format!(
258 "could not ensure PRAGMA temp_store = MEMORY: expected {TEMP_STORE_MEMORY}, got {actual}"
259 ),
260 ))
261 }
262}
263
264pub fn export_plaintext_copy(
279 conn: &Connection,
280 dest_path: &Path,
281 tables: &[&str],
282) -> DbResult<()> {
283 let dest_str = dest_path.to_string_lossy();
284 let attach_sql = format!(
285 "ATTACH DATABASE '{}' AS backup KEY '';",
286 dest_str.replace('\'', "''")
287 );
288 conn.execute_batch(&attach_sql)?;
289
290 let result = (|| {
291 let tx = conn.transaction()?;
292 for table in tables {
293 tx.execute_batch(&format!(
294 "CREATE TABLE backup.{table} AS SELECT * FROM {table};"
295 ))?;
296 }
297 tx.commit()
298 })();
299
300 let detach_result = conn.execute_batch("DETACH DATABASE backup;");
302
303 result?;
304 detach_result?;
305 Ok(())
306}
307
308pub fn import_plaintext_copy(
327 conn: &Connection,
328 source_path: &Path,
329 tables: &[&str],
330) -> DbResult<()> {
331 if !source_path.exists() {
332 return Err(Error::new(
333 -1,
334 format!("backup file does not exist: {}", source_path.display()),
335 ));
336 }
337
338 let source_str = source_path.to_string_lossy();
339 let attach_sql = format!(
340 "ATTACH DATABASE '{}' AS backup KEY '';",
341 source_str.replace('\'', "''")
342 );
343 conn.execute_batch(&attach_sql)?;
344
345 let result = (|| {
349 for table in tables {
350 let count: i64 =
351 conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), &[], |row| {
352 Ok(row.column_i64(0))
353 })?;
354 if count > 0 {
355 return Err(Error::new(
356 -1,
357 format!("cannot import into non-empty table: {table}"),
358 ));
359 }
360 }
361
362 let tx = conn.transaction()?;
366 for table in tables {
367 tx.execute_batch(&format!(
368 "INSERT INTO {table} SELECT * FROM backup.{table};"
369 ))?;
370 }
371 tx.commit()
372 })();
373
374 let detach_result = conn.execute_batch("DETACH DATABASE backup;");
376
377 result?;
378 detach_result?;
379 Ok(())
380}
381
382pub fn integrity_check(conn: &Connection) -> DbResult<bool> {
388 let result = conn.query_row("PRAGMA integrity_check;", &[], |stmt| {
389 Ok(stmt.column_text(0))
390 })?;
391 Ok(result.trim() == "ok")
392}
393
394#[cfg(test)]
395mod tests {
396 use super::{
397 export_plaintext_copy, import_plaintext_copy, integrity_check, open_encrypted,
398 };
399 use crate::params;
400 use crate::test_utils::init_sqlite;
401 use crate::Connection;
402 use secrecy::SecretBox;
403
404 #[test]
405 fn test_cipher_encrypted_round_trip() {
406 init_sqlite();
407 let dir = tempfile::tempdir().expect("create temp dir");
408 let path = dir.path().join("cipher-test.sqlite");
409 let key = SecretBox::init_with(|| [0xABu8; 32]);
410
411 {
413 let conn = open_encrypted(&path, &key, false).expect("open encrypted");
414 conn.execute_batch(
415 "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);",
416 )
417 .expect("create table");
418 conn.execute("INSERT INTO secret (id, val) VALUES (1, 'top-secret')", &[])
419 .expect("insert");
420 }
421
422 {
424 let conn = open_encrypted(&path, &key, false).expect("reopen encrypted");
425 let val = conn
426 .query_row("SELECT val FROM secret WHERE id = 1", &[], |stmt| {
427 Ok(stmt.column_text(0))
428 })
429 .expect("query");
430 assert_eq!(val, "top-secret");
431 }
432
433 {
435 let wrong_key = SecretBox::init_with(|| [0xCDu8; 32]);
436 let result = open_encrypted(&path, &wrong_key, false);
437 assert!(result.is_err(), "wrong key should fail");
438 }
439 }
440
441 #[test]
442 fn test_integrity_check() {
443 init_sqlite();
444 let conn = Connection::open_in_memory().expect("open in-memory db");
445 let ok = integrity_check(&conn).expect("check");
446 assert!(ok);
447 }
448
449 #[test]
450 fn test_cipher_plaintext_export_import_roundtrip() {
451 init_sqlite();
452 let dir = tempfile::tempdir().expect("create temp dir");
453 let src_path = dir.path().join("source.sqlite");
454 let dest_path = dir.path().join("backup.plain.sqlite");
455 let restore_path = dir.path().join("restore.sqlite");
456 let key = SecretBox::init_with(|| [0x11u8; 32]);
457
458 {
459 let conn = open_encrypted(&src_path, &key, false).expect("open src");
460 conn.execute_batch(
461 "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
462 )
463 .expect("create table");
464 conn.execute(
465 "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
466 params![1_i64, "alpha"],
467 )
468 .expect("insert");
469 conn.execute(
470 "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
471 params![2_i64, "beta"],
472 )
473 .expect("insert");
474
475 export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export");
476 }
477
478 {
479 let conn =
480 open_encrypted(&restore_path, &key, false).expect("open restore");
481 conn.execute_batch(
482 "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
483 )
484 .expect("create table");
485 import_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("import");
486
487 let count: i64 = conn
488 .query_row("SELECT COUNT(*) FROM widgets", &[], |row| {
489 Ok(row.column_i64(0))
490 })
491 .expect("count");
492 assert_eq!(count, 2);
493
494 let val = conn
495 .query_row("SELECT val FROM widgets WHERE id = 2", &[], |row| {
496 Ok(row.column_text(0))
497 })
498 .expect("query");
499 assert_eq!(val, "beta");
500 }
501 }
502
503 #[test]
504 fn test_cipher_import_rejects_non_empty_destination() {
505 init_sqlite();
506 let dir = tempfile::tempdir().expect("create temp dir");
507 let src_path = dir.path().join("source.sqlite");
508 let dest_path = dir.path().join("backup.plain.sqlite");
509 let restore_path = dir.path().join("restore.sqlite");
510 let key = SecretBox::init_with(|| [0x22u8; 32]);
511
512 {
513 let conn = open_encrypted(&src_path, &key, false).expect("open src");
514 conn.execute_batch(
515 "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
516 )
517 .expect("create table");
518 conn.execute(
519 "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
520 params![1_i64, "alpha"],
521 )
522 .expect("insert");
523 export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export");
524 }
525
526 let conn = open_encrypted(&restore_path, &key, false).expect("open restore");
527 conn.execute_batch(
528 "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
529 )
530 .expect("create table");
531 conn.execute(
532 "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
533 params![99_i64, "preexisting"],
534 )
535 .expect("insert");
536
537 let err = import_plaintext_copy(&conn, &dest_path, &["widgets"])
538 .expect_err("import should refuse non-empty destination");
539 assert!(
540 err.to_string().contains("non-empty table"),
541 "expected non-empty-table error, got: {err}"
542 );
543 }
544}