1use std::path::Path;
37
38use secrecy::{ExposeSecret, SecretBox};
39use zeroize::Zeroizing;
40
41use super::connection::Connection;
42use super::error::{DbResult, Error};
43
44const CIPHER_CHACHA20: &str = "chacha20";
45const FOREIGN_KEYS_ON: i64 = 1;
46const SYNCHRONOUS_FULL: i64 = 2;
47const SECURE_DELETE_ON: i64 = 1;
48const TEMP_STORE_MEMORY: i64 = 2;
49
50pub fn open_encrypted(
61 path: &Path,
62 k_intermediate: &SecretBox<[u8; 32]>,
63) -> DbResult<Connection> {
64 #[cfg(not(target_arch = "wasm32"))]
65 let conn = Connection::open(path, false)?;
66 #[cfg(target_arch = "wasm32")]
67 let conn = Connection::open_with_opfs_vfs(path, false)?;
68 configure_connection(&conn, k_intermediate)?;
69 Ok(conn)
70}
71
72fn configure_connection(
83 conn: &Connection,
84 k_intermediate: &SecretBox<[u8; 32]>,
85) -> DbResult<()> {
86 ensure_cipher(conn)?;
87 encrypt_or_unlock(conn, k_intermediate)?;
88
89 #[cfg(not(target_arch = "wasm32"))]
90 ensure_journal_mode(conn, "WAL")?;
91 #[cfg(target_arch = "wasm32")]
96 ensure_journal_mode(conn, "DELETE")?;
97
98 ensure_foreign_keys(conn)?;
99 ensure_synchronous_full(conn)?;
100 ensure_secure_delete(conn)?;
101 ensure_temp_store_memory(conn)?;
102 Ok(())
103}
104
105fn encrypt_or_unlock(
111 conn: &Connection,
112 k_intermediate: &SecretBox<[u8; 32]>,
113) -> DbResult<()> {
114 match verify_schema_readable(conn) {
115 Ok(()) => {
116 ensure_journal_mode(conn, "DELETE")?;
120 apply_rekey(conn, k_intermediate)?;
121 verify_schema_readable(conn).map_err(|e| {
122 Error::new(
123 e.code.0,
124 format!(
125 "plaintext database encryption verification failed: {}",
126 e.message
127 ),
128 )
129 })
130 }
131 Err(error) if error.code.0 & 0xff == super::ffi::SQLITE_NOTADB => {
132 apply_key(conn, k_intermediate)
133 }
134 Err(error) => Err(error),
135 }
136}
137
138fn ensure_cipher(conn: &Connection) -> DbResult<()> {
143 conn.execute_batch(&format!("PRAGMA cipher = '{CIPHER_CHACHA20}';"))?;
144 let actual = conn.query_row("PRAGMA cipher;", &[], |row| Ok(row.column_text(0)))?;
145 if actual.eq_ignore_ascii_case(CIPHER_CHACHA20) {
146 Ok(())
147 } else {
148 Err(Error::new(
149 -1,
150 format!(
151 "could not ensure sqlite3mc cipher {CIPHER_CHACHA20}: SQLite selected {actual}"
152 ),
153 ))
154 }
155}
156
157fn apply_key(conn: &Connection, k_intermediate: &SecretBox<[u8; 32]>) -> DbResult<()> {
168 let pragma = raw_key_pragma("key", k_intermediate);
169
170 conn.execute_batch_zeroized(&pragma)?;
173
174 verify_schema_readable(conn).map_err(|e| {
177 Error::new(
178 e.code.0,
179 format!(
180 "encryption key verification failed (is the key correct?): {}",
181 e.message
182 ),
183 )
184 })?;
185
186 Ok(())
189}
190
191fn apply_rekey(
193 conn: &Connection,
194 k_intermediate: &SecretBox<[u8; 32]>,
195) -> DbResult<()> {
196 let pragma = raw_key_pragma("rekey", k_intermediate);
197 conn.execute_batch_zeroized(&pragma).map_err(|e| {
198 Error::new(
199 e.code.0,
200 format!("failed to encrypt plaintext database: {}", e.message),
201 )
202 })
203}
204
205fn raw_key_pragma(
206 operation: &str,
207 k_intermediate: &SecretBox<[u8; 32]>,
208) -> Zeroizing<String> {
209 let key_hex = Zeroizing::new(hex::encode(k_intermediate.expose_secret()));
210 Zeroizing::new(format!("PRAGMA {operation} = \"x'{}'\";", key_hex.as_str()))
211}
212
213fn verify_schema_readable(conn: &Connection) -> DbResult<()> {
214 conn.execute_batch("SELECT count(*) FROM sqlite_master;")
215}
216
217fn ensure_journal_mode(conn: &Connection, requested: &str) -> DbResult<()> {
222 let actual =
223 conn.query_row(&format!("PRAGMA journal_mode = {requested};"), &[], |row| {
224 Ok(row.column_text(0))
225 })?;
226 if actual.eq_ignore_ascii_case(requested) {
227 Ok(())
228 } else {
229 Err(Error::new(
230 -1,
231 format!(
232 "could not ensure journal mode {requested}: SQLite selected {actual}"
233 ),
234 ))
235 }
236}
237
238fn ensure_foreign_keys(conn: &Connection) -> DbResult<()> {
243 conn.execute_batch("PRAGMA foreign_keys = ON;")?;
244 let actual =
245 conn.query_row("PRAGMA foreign_keys;", &[], |row| Ok(row.column_i64(0)))?;
246 if actual == FOREIGN_KEYS_ON {
247 Ok(())
248 } else {
249 Err(Error::new(
250 -1,
251 format!(
252 "could not ensure PRAGMA foreign_keys = ON: expected {FOREIGN_KEYS_ON}, got {actual}"
253 ),
254 ))
255 }
256}
257
258fn ensure_synchronous_full(conn: &Connection) -> DbResult<()> {
263 conn.execute_batch("PRAGMA synchronous = FULL;")?;
264 let actual =
265 conn.query_row("PRAGMA synchronous;", &[], |row| Ok(row.column_i64(0)))?;
266 if actual == SYNCHRONOUS_FULL {
267 Ok(())
268 } else {
269 Err(Error::new(
270 -1,
271 format!(
272 "could not ensure PRAGMA synchronous = FULL: expected {SYNCHRONOUS_FULL}, got {actual}"
273 ),
274 ))
275 }
276}
277
278fn ensure_secure_delete(conn: &Connection) -> DbResult<()> {
283 conn.execute_batch("PRAGMA secure_delete = ON;")?;
284 let actual =
285 conn.query_row("PRAGMA secure_delete;", &[], |row| Ok(row.column_i64(0)))?;
286 if actual == SECURE_DELETE_ON {
287 Ok(())
288 } else {
289 Err(Error::new(
290 -1,
291 format!(
292 "could not ensure PRAGMA secure_delete = ON: expected {SECURE_DELETE_ON}, got {actual}"
293 ),
294 ))
295 }
296}
297
298fn ensure_temp_store_memory(conn: &Connection) -> DbResult<()> {
303 conn.execute_batch("PRAGMA temp_store = MEMORY;")?;
304 let actual =
305 conn.query_row("PRAGMA temp_store;", &[], |row| Ok(row.column_i64(0)))?;
306 if actual == TEMP_STORE_MEMORY {
307 Ok(())
308 } else {
309 Err(Error::new(
310 -1,
311 format!(
312 "could not ensure PRAGMA temp_store = MEMORY: expected {TEMP_STORE_MEMORY}, got {actual}"
313 ),
314 ))
315 }
316}
317
318pub fn export_plaintext_copy(
333 conn: &Connection,
334 dest_path: &Path,
335 tables: &[&str],
336) -> DbResult<()> {
337 let dest_str = dest_path.to_string_lossy();
338 let attach_sql = format!(
339 "ATTACH DATABASE '{}' AS backup KEY '';",
340 dest_str.replace('\'', "''")
341 );
342 conn.execute_batch(&attach_sql)?;
343
344 let result = (|| {
345 let tx = conn.transaction()?;
346 for table in tables {
347 tx.execute_batch(&format!(
348 "CREATE TABLE backup.{table} AS SELECT * FROM {table};"
349 ))?;
350 }
351 tx.commit()
352 })();
353
354 let detach_result = conn.execute_batch("DETACH DATABASE backup;");
356
357 result?;
358 detach_result?;
359 Ok(())
360}
361
362pub fn import_plaintext_copy(
381 conn: &Connection,
382 source_path: &Path,
383 tables: &[&str],
384) -> DbResult<()> {
385 if !source_path.exists() {
386 return Err(Error::new(
387 -1,
388 format!("backup file does not exist: {}", source_path.display()),
389 ));
390 }
391
392 let source_str = source_path.to_string_lossy();
393 let attach_sql = format!(
394 "ATTACH DATABASE '{}' AS backup KEY '';",
395 source_str.replace('\'', "''")
396 );
397 conn.execute_batch(&attach_sql)?;
398
399 let result = (|| {
403 for table in tables {
404 let count: i64 =
405 conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), &[], |row| {
406 Ok(row.column_i64(0))
407 })?;
408 if count > 0 {
409 return Err(Error::new(
410 -1,
411 format!("cannot import into non-empty table: {table}"),
412 ));
413 }
414 }
415
416 let tx = conn.transaction()?;
420 for table in tables {
421 tx.execute_batch(&format!(
422 "INSERT INTO {table} SELECT * FROM backup.{table};"
423 ))?;
424 }
425 tx.commit()
426 })();
427
428 let detach_result = conn.execute_batch("DETACH DATABASE backup;");
430
431 result?;
432 detach_result?;
433 Ok(())
434}
435
436pub fn integrity_check(conn: &Connection) -> DbResult<bool> {
442 let result = conn.query_row("PRAGMA integrity_check;", &[], |stmt| {
443 Ok(stmt.column_text(0))
444 })?;
445 Ok(result.trim() == "ok")
446}
447
448#[cfg(test)]
449mod tests {
450 use super::{
451 export_plaintext_copy, import_plaintext_copy, integrity_check, open_encrypted,
452 };
453 use crate::params;
454 use crate::test_utils::init_sqlite;
455 use crate::Connection;
456 use secrecy::SecretBox;
457
458 #[test]
459 fn test_cipher_encrypted_round_trip() {
460 init_sqlite();
461 let dir = tempfile::tempdir().expect("create temp dir");
462 let path = dir.path().join("cipher-test.sqlite");
463 let key = SecretBox::init_with(|| [0xABu8; 32]);
464
465 {
467 let conn = open_encrypted(&path, &key).expect("open encrypted");
468 conn.execute_batch(
469 "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);",
470 )
471 .expect("create table");
472 conn.execute("INSERT INTO secret (id, val) VALUES (1, 'top-secret')", &[])
473 .expect("insert");
474 }
475
476 {
478 let conn = open_encrypted(&path, &key).expect("reopen encrypted");
479 let val = conn
480 .query_row("SELECT val FROM secret WHERE id = 1", &[], |stmt| {
481 Ok(stmt.column_text(0))
482 })
483 .expect("query");
484 assert_eq!(val, "top-secret");
485 }
486
487 {
489 let wrong_key = SecretBox::init_with(|| [0xCDu8; 32]);
490 let result = open_encrypted(&path, &wrong_key);
491 assert!(result.is_err(), "wrong key should fail");
492 }
493 }
494
495 #[test]
496 fn test_plaintext_wal_database_is_rekeyed_in_place() {
497 init_sqlite();
498 let dir = tempfile::tempdir().expect("create temp dir");
499 let path = dir.path().join("plaintext.sqlite");
500 let key = SecretBox::init_with(|| [0x42u8; 32]);
501
502 {
503 let conn = Connection::open(&path, false).expect("open plaintext");
504 let mode = conn
505 .query_row("PRAGMA journal_mode = WAL", &[], |row| {
506 Ok(row.column_text(0))
507 })
508 .expect("enable plaintext WAL");
509 assert_eq!(mode.to_ascii_lowercase(), "wal");
510 conn.execute_batch(
511 "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);\
512 INSERT INTO secret VALUES (1, 'preserve-me');",
513 )
514 .expect("write plaintext data");
515 }
516 assert!(
517 std::fs::read(&path)
518 .expect("read plaintext")
519 .starts_with(b"SQLite format 3\0"),
520 "fixture must start as plaintext SQLite"
521 );
522
523 {
524 let conn = open_encrypted(&path, &key).expect("migrate plaintext");
525 let value = conn
526 .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
527 Ok(row.column_text(0))
528 })
529 .expect("read migrated data");
530 assert_eq!(value, "preserve-me");
531 }
532
533 let encrypted_bytes = std::fs::read(&path).expect("read encrypted");
534 assert!(
535 !encrypted_bytes.starts_with(b"SQLite format 3\0"),
536 "rekey must remove the plaintext SQLite header"
537 );
538
539 {
540 let conn = open_encrypted(&path, &key).expect("reopen migrated database");
541 let value = conn
542 .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
543 Ok(row.column_text(0))
544 })
545 .expect("read migrated data after reopen");
546 assert_eq!(value, "preserve-me");
547 }
548
549 let wrong_key = SecretBox::init_with(|| [0x43u8; 32]);
550 assert!(
551 open_encrypted(&path, &wrong_key).is_err(),
552 "migrated database must reject the wrong key"
553 );
554 assert_eq!(
555 std::fs::read(&path).expect("read after wrong-key open"),
556 encrypted_bytes,
557 "wrong-key open must not modify migrated data"
558 );
559 }
560
561 #[test]
562 fn test_integrity_check() {
563 init_sqlite();
564 let conn = Connection::open_in_memory().expect("open in-memory db");
565 let ok = integrity_check(&conn).expect("check");
566 assert!(ok);
567 }
568
569 #[test]
570 fn test_cipher_plaintext_export_import_roundtrip() {
571 init_sqlite();
572 let dir = tempfile::tempdir().expect("create temp dir");
573 let src_path = dir.path().join("source.sqlite");
574 let dest_path = dir.path().join("backup.plain.sqlite");
575 let restore_path = dir.path().join("restore.sqlite");
576 let key = SecretBox::init_with(|| [0x11u8; 32]);
577
578 {
579 let conn = open_encrypted(&src_path, &key).expect("open src");
580 conn.execute_batch(
581 "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
582 )
583 .expect("create table");
584 conn.execute(
585 "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
586 params![1_i64, "alpha"],
587 )
588 .expect("insert");
589 conn.execute(
590 "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
591 params![2_i64, "beta"],
592 )
593 .expect("insert");
594
595 export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export");
596 }
597
598 {
599 let conn = open_encrypted(&restore_path, &key).expect("open restore");
600 conn.execute_batch(
601 "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
602 )
603 .expect("create table");
604 import_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("import");
605
606 let count: i64 = conn
607 .query_row("SELECT COUNT(*) FROM widgets", &[], |row| {
608 Ok(row.column_i64(0))
609 })
610 .expect("count");
611 assert_eq!(count, 2);
612
613 let val = conn
614 .query_row("SELECT val FROM widgets WHERE id = 2", &[], |row| {
615 Ok(row.column_text(0))
616 })
617 .expect("query");
618 assert_eq!(val, "beta");
619 }
620 }
621
622 #[test]
623 fn test_cipher_import_rejects_non_empty_destination() {
624 init_sqlite();
625 let dir = tempfile::tempdir().expect("create temp dir");
626 let src_path = dir.path().join("source.sqlite");
627 let dest_path = dir.path().join("backup.plain.sqlite");
628 let restore_path = dir.path().join("restore.sqlite");
629 let key = SecretBox::init_with(|| [0x22u8; 32]);
630
631 {
632 let conn = open_encrypted(&src_path, &key).expect("open src");
633 conn.execute_batch(
634 "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
635 )
636 .expect("create table");
637 conn.execute(
638 "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
639 params![1_i64, "alpha"],
640 )
641 .expect("insert");
642 export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export");
643 }
644
645 let conn = open_encrypted(&restore_path, &key).expect("open restore");
646 conn.execute_batch(
647 "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
648 )
649 .expect("create table");
650 conn.execute(
651 "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
652 params![99_i64, "preexisting"],
653 )
654 .expect("insert");
655
656 let err = import_plaintext_copy(&conn, &dest_path, &["widgets"])
657 .expect_err("import should refuse non-empty destination");
658 assert!(
659 err.to_string().contains("non-empty table"),
660 "expected non-empty-table error, got: {err}"
661 );
662 }
663}