Skip to main content

walletkit_sqlite/
cipher.rs

1//! `sqlite3mc` encryption configuration.
2//!
3//! # Encryption flow
4//!
5//! This crate uses `sqlite3mc` (`SQLite3` Multiple Ciphers) to encrypt
6//! `SQLite` databases at rest. The encryption is transparent to SQL -- once a
7//! database is opened and keyed, all reads and writes are automatically
8//! encrypted/decrypted by the `SQLite` pager layer.
9//!
10//! The flow when opening a database is:
11//!
12//! 1. **Open** -- `sqlite3_open_v2` creates or opens the database file.
13//!    At this point the file is opaque (encrypted) and no data can be read.
14//!
15//! 2. **Configure cipher** -- `PRAGMA cipher = 'chacha20'` fixes the on-disk
16//!    cipher before the key activates it.
17//!
18//! 3. **Key** -- `PRAGMA key = "x'<hex>'"` passes the 32-byte
19//!    `K_intermediate` (hex-encoded) to `sqlite3mc` as a raw key. The `x'...'`
20//!    syntax tells `sqlite3mc` to use the bytes directly as the page-encryption
21//!    key, bypassing the passphrase KDF (PBKDF2-SHA256) that a plain-string
22//!    key would otherwise be run through. After this point, every page read
23//!    from disk is decrypted and every page written to disk is encrypted.
24//!
25//! 4. **Verify** -- We immediately read from `sqlite_master` to confirm
26//!    the key is correct. If the key is wrong, `sqlite3mc` returns
27//!    `SQLITE_NOTADB` because the decrypted page header won't match the
28//!    expected `SQLite` magic bytes. We surface this as a clear error.
29//!
30//! 5. **Configure connection** -- The target-specific journal mode and every
31//!    connection-level invariant are set and verified.
32//!
33//! The default cipher is **ChaCha20-Poly1305** (authenticated encryption).
34//! All crypto is built into the `sqlite3mc` amalgamation -- no OpenSSL or
35//! other external crypto library is needed on any platform.
36
37use 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
51/// Opens a database, applies the encryption key, and configures the connection.
52///
53/// This is the standard open sequence for encrypted databases: open -> select
54/// cipher -> key and verify -> configure connection policy.
55///
56/// See the [module-level documentation](self) for the full encryption flow.
57///
58/// # Errors
59///
60/// Returns `Error` if opening, keying, or configuring the connection fails.
61pub 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
74/// Configures durable journal settings, foreign keys, and secure deletion.
75///
76/// - Native uses WAL for concurrent readers during writes.
77/// - WASM uses a rollback journal because SAH-pool has no WAL shared-memory
78///   methods; WAL would require exclusive locking and provide no concurrency.
79/// - `synchronous = FULL` -- maximizes crash consistency by flushing required
80///   journal writes before the transaction is reported as committed.
81/// - `foreign_keys = ON` -- enforces referential integrity constraints.
82/// - `secure_delete = ON` -- overwrites deleted content with zeroes so
83///   sensitive data does not linger in free pages.
84fn 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    // SAH-pool does not expose WAL shared-memory methods. WAL would therefore
94    // require locking_mode=EXCLUSIVE before the first database access and
95    // provide no concurrency benefit, so WASM deliberately uses the rollback
96    // journal until benchmarks justify that extra complexity.
97    #[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
107/// Selects and verifies the on-disk cipher before the key activates it.
108///
109/// Pinning the cipher prevents a future compile-time default change from
110/// silently creating or interpreting databases with a different format.
111fn 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
126/// Applies the `sqlite3mc` encryption key to an open connection.
127///
128/// The 32-byte `k_intermediate` is hex-encoded and passed as a raw key via
129/// `PRAGMA key = "x'<64-hex-chars>'"`. `sqlite3mc` interprets the `x'...'`
130/// prefix as a raw key (as opposed to a passphrase that would be run through
131/// a KDF first).
132///
133/// After keying, a lightweight read (`SELECT count(*) FROM sqlite_master`)
134/// verifies the key is correct. If it's wrong, `sqlite3mc` fails with
135/// `SQLITE_NOTADB` on the first page read.
136fn apply_key(conn: &Connection, k_intermediate: &SecretBox<[u8; 32]>) -> DbResult<()> {
137    // Hex-encode the key and build the PRAGMA. Both are zeroized on drop.
138    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    // execute_batch_zeroized ensures the internal CString copy of the PRAGMA
142    // (which contains the hex key) is zeroized after the FFI call returns.
143    conn.execute_batch_zeroized(&pragma)?;
144
145    // Touch a page to verify the key works. On failure this produces a clear
146    // error rather than a confusing "not a database" later during schema setup.
147    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    // k_intermediate, key_hex, and pragma are all Zeroizing — zeroed on drop
159    // regardless of which exit path we took.
160    Ok(())
161}
162
163/// Ensures the target-specific journal policy actually took effect.
164///
165/// Assigning `journal_mode` returns the effective mode because `SQLite` may
166/// retain the previous mode when the requested transition is unavailable.
167fn 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
184/// Enables foreign-key enforcement for every connection.
185///
186/// `SQLite` defaults this setting to off and may silently ignore the assignment
187/// inside a transaction or when foreign-key support was omitted at build time.
188fn 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
204/// Uses `SQLite`'s strongest ordinary durability policy.
205///
206/// `FULL` ensures `SQLite` flushes journal content before reporting a transaction
207/// as committed, reducing the risk of corruption after a crash or power loss.
208fn 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
224/// Overwrites deleted content instead of leaving it in reusable database pages.
225///
226/// This limits plaintext remnants while the encrypted database is open and
227/// accessible with its key.
228fn 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
244/// Keeps temporary tables and indices in memory.
245///
246/// `sqlite3mc` does not encrypt temporary databases, so allowing temporary
247/// storage to spill to a filesystem could expose plaintext at rest.
248fn 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
264/// Creates a plaintext (unencrypted) copy of an already-open encrypted database.
265///
266/// The copy is produced by `ATTACH`-ing a new unencrypted database and copying
267/// the caller-specified tables via `CREATE TABLE ... AS SELECT *`. The
268/// destination file must not already exist.
269///
270/// We use `ATTACH` + SQL instead of the `sqlite3_backup` API because
271/// `sqlite3mc` requires both source and destination to share the same
272/// encryption configuration. Since the destination is unencrypted, the
273/// backup API cannot be used.
274///
275/// # Errors
276///
277/// Returns `Error` if the `ATTACH`, copy, or `DETACH` fails.
278pub 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    // Always detach, even if the copy failed.
301    let detach_result = conn.execute_batch("DETACH DATABASE backup;");
302
303    result?;
304    detach_result?;
305    Ok(())
306}
307
308/// Imports data from a plaintext (unencrypted) database into an already-open
309/// encrypted database.
310///
311/// The source database is `ATTACH`ed with an empty key and its contents are
312/// copied into the main (empty) encrypted database.
313///
314/// See [`export_plaintext_copy`] for why `ATTACH` + SQL is used instead of
315/// the `sqlite3_backup` API.
316///
317/// **Schema migration:** The import uses `SELECT *`, so column changes are
318/// handled automatically as long as both sides share the same schema. If a
319/// caller's schema evolves (e.g. new columns with `NOT NULL` constraints),
320/// restoring an older backup into a newer schema will fail. When that happens,
321/// the caller needs version-aware import logic.
322///
323/// # Errors
324///
325/// Returns `Error` if the `ATTACH`, copy, or `DETACH` fails.
326pub 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    // Verify the destination tables are empty before importing. Importing into
346    // a non-empty destination could silently merge data if primary keys don't
347    // collide.
348    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        // Wrap in a transaction so the restore is atomic — if any INSERT
363        // fails, everything is rolled back and the destination stays empty for
364        // a retry.
365        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    // Always detach, even if the import failed.
375    let detach_result = conn.execute_batch("DETACH DATABASE backup;");
376
377    result?;
378    detach_result?;
379    Ok(())
380}
381
382/// Runs `PRAGMA integrity_check` and returns whether the database is healthy.
383///
384/// # Errors
385///
386/// Returns `Error` if the integrity check query fails.
387pub 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        // Create and write
412        {
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        // Re-open with correct key
423        {
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        // Wrong key should fail
434        {
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}