Skip to main content

walletkit_db/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. **Key** -- `PRAGMA key = "x'<hex>'"` passes the 32-byte
16//!    `K_intermediate` (hex-encoded) to `sqlite3mc` as a raw key. The `x'...'`
17//!    syntax tells `sqlite3mc` to use the bytes directly as the page-encryption
18//!    key, bypassing the passphrase KDF (PBKDF2-SHA256) that a plain-string
19//!    key would otherwise be run through. After this point, every page read
20//!    from disk is decrypted and every page written to disk is encrypted.
21//!
22//! 3. **Verify** -- We immediately read from `sqlite_master` to confirm
23//!    the key is correct. If the key is wrong, `sqlite3mc` returns
24//!    `SQLITE_NOTADB` because the decrypted page header won't match the
25//!    expected `SQLite` magic bytes. We surface this as a clear error.
26//!
27//! 4. **Configure** -- WAL journal mode and `synchronous=FULL` are set for
28//!    crash consistency. Foreign keys are enabled.
29//!
30//! The default cipher is **ChaCha20-Poly1305** (authenticated encryption).
31//! All crypto is built into the `sqlite3mc` amalgamation -- no OpenSSL or
32//! other external crypto library is needed on any platform.
33
34use std::path::Path;
35
36use secrecy::{ExposeSecret, SecretBox};
37use zeroize::Zeroizing;
38
39use super::connection::Connection;
40use super::error::{DbResult, Error};
41
42/// Opens a database, applies the encryption key, and configures the connection.
43///
44/// This is the standard open sequence for encrypted databases: open -> key ->
45/// verify -> configure (WAL + foreign keys).
46///
47/// See the [module-level documentation](self) for the full encryption flow.
48///
49/// # Errors
50///
51/// Returns `Error` if opening, keying, or configuring the connection fails.
52pub fn open_encrypted(
53    path: &Path,
54    k_intermediate: &SecretBox<[u8; 32]>,
55    read_only: bool,
56) -> DbResult<Connection> {
57    let conn = Connection::open(path, read_only)?;
58    apply_key(&conn, k_intermediate)?;
59    configure_connection(&conn)?;
60    Ok(conn)
61}
62
63/// Applies the `sqlite3mc` encryption key to an open connection.
64///
65/// The 32-byte `k_intermediate` is hex-encoded and passed as a raw key via
66/// `PRAGMA key = "x'<64-hex-chars>'"`. `sqlite3mc` interprets the `x'...'`
67/// prefix as a raw key (as opposed to a passphrase that would be run through
68/// a KDF first).
69///
70/// After keying, a lightweight read (`SELECT count(*) FROM sqlite_master`)
71/// verifies the key is correct. If it's wrong, `sqlite3mc` fails with
72/// `SQLITE_NOTADB` on the first page read.
73fn apply_key(conn: &Connection, k_intermediate: &SecretBox<[u8; 32]>) -> DbResult<()> {
74    // Hex-encode the key and build the PRAGMA. Both are zeroized on drop.
75    let key_hex = Zeroizing::new(hex::encode(k_intermediate.expose_secret()));
76    let pragma = Zeroizing::new(format!("PRAGMA key = \"x'{}'\";", key_hex.as_str()));
77
78    // execute_batch_zeroized ensures the internal CString copy of the PRAGMA
79    // (which contains the hex key) is zeroized after the FFI call returns.
80    conn.execute_batch_zeroized(&pragma)?;
81
82    // Touch a page to verify the key works. On failure this produces a clear
83    // error rather than a confusing "not a database" later during schema setup.
84    conn.execute_batch("SELECT count(*) FROM sqlite_master;")
85        .map_err(|e| {
86            Error::new(
87                e.code.0,
88                format!(
89                    "encryption key verification failed (is the key correct?): {}",
90                    e.message
91                ),
92            )
93        })?;
94
95    // k_intermediate, key_hex, and pragma are all Zeroizing — zeroed on drop
96    // regardless of which exit path we took.
97    Ok(())
98}
99
100/// Configures durable WAL settings, foreign keys, and secure deletion.
101///
102/// - `journal_mode = WAL` -- enables concurrent readers during writes.
103/// - `synchronous = FULL` -- maximizes crash consistency (all WAL pages are
104///   fsynced before the transaction is reported as committed).
105/// - `foreign_keys = ON` -- enforces referential integrity constraints.
106/// - `secure_delete = ON` -- overwrites deleted content with zeroes so
107///   sensitive data does not linger in free pages.
108fn configure_connection(conn: &Connection) -> DbResult<()> {
109    conn.execute_batch(
110        "PRAGMA foreign_keys = ON;
111         PRAGMA journal_mode = WAL;
112         PRAGMA synchronous = FULL;
113         PRAGMA secure_delete = ON;",
114    )
115}
116
117/// Creates a plaintext (unencrypted) copy of an already-open encrypted database.
118///
119/// The copy is produced by `ATTACH`-ing a new unencrypted database and copying
120/// the caller-specified tables via `CREATE TABLE ... AS SELECT *`. The
121/// destination file must not already exist.
122///
123/// We use `ATTACH` + SQL instead of the `sqlite3_backup` API because
124/// `sqlite3mc` requires both source and destination to share the same
125/// encryption configuration. Since the destination is unencrypted, the
126/// backup API cannot be used.
127///
128/// # Errors
129///
130/// Returns `Error` if the `ATTACH`, copy, or `DETACH` fails.
131pub fn export_plaintext_copy(
132    conn: &Connection,
133    dest_path: &Path,
134    tables: &[&str],
135) -> DbResult<()> {
136    let dest_str = dest_path.to_string_lossy();
137    let attach_sql = format!(
138        "ATTACH DATABASE '{}' AS backup KEY '';",
139        dest_str.replace('\'', "''")
140    );
141    conn.execute_batch(&attach_sql)?;
142
143    let result = (|| {
144        let tx = conn.transaction()?;
145        for table in tables {
146            tx.execute_batch(&format!(
147                "CREATE TABLE backup.{table} AS SELECT * FROM {table};"
148            ))?;
149        }
150        tx.commit()
151    })();
152
153    // Always detach, even if the copy failed.
154    let detach_result = conn.execute_batch("DETACH DATABASE backup;");
155
156    result?;
157    detach_result?;
158    Ok(())
159}
160
161/// Imports data from a plaintext (unencrypted) database into an already-open
162/// encrypted database.
163///
164/// The source database is `ATTACH`ed with an empty key and its contents are
165/// copied into the main (empty) encrypted database.
166///
167/// See [`export_plaintext_copy`] for why `ATTACH` + SQL is used instead of
168/// the `sqlite3_backup` API.
169///
170/// **Schema migration:** The import uses `SELECT *`, so column changes are
171/// handled automatically as long as both sides share the same schema. If a
172/// caller's schema evolves (e.g. new columns with `NOT NULL` constraints),
173/// restoring an older backup into a newer schema will fail. When that happens,
174/// the caller needs version-aware import logic.
175///
176/// # Errors
177///
178/// Returns `Error` if the `ATTACH`, copy, or `DETACH` fails.
179pub fn import_plaintext_copy(
180    conn: &Connection,
181    source_path: &Path,
182    tables: &[&str],
183) -> DbResult<()> {
184    if !source_path.exists() {
185        return Err(Error::new(
186            -1,
187            format!("backup file does not exist: {}", source_path.display()),
188        ));
189    }
190
191    let source_str = source_path.to_string_lossy();
192    let attach_sql = format!(
193        "ATTACH DATABASE '{}' AS backup KEY '';",
194        source_str.replace('\'', "''")
195    );
196    conn.execute_batch(&attach_sql)?;
197
198    // Verify the destination tables are empty before importing. Importing into
199    // a non-empty destination could silently merge data if primary keys don't
200    // collide.
201    let result = (|| {
202        for table in tables {
203            let count: i64 =
204                conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), &[], |row| {
205                    Ok(row.column_i64(0))
206                })?;
207            if count > 0 {
208                return Err(Error::new(
209                    -1,
210                    format!("cannot import into non-empty table: {table}"),
211                ));
212            }
213        }
214
215        // Wrap in a transaction so the restore is atomic — if any INSERT
216        // fails, everything is rolled back and the destination stays empty for
217        // a retry.
218        let tx = conn.transaction()?;
219        for table in tables {
220            tx.execute_batch(&format!(
221                "INSERT INTO {table} SELECT * FROM backup.{table};"
222            ))?;
223        }
224        tx.commit()
225    })();
226
227    // Always detach, even if the import failed.
228    let detach_result = conn.execute_batch("DETACH DATABASE backup;");
229
230    result?;
231    detach_result?;
232    Ok(())
233}
234
235/// Runs `PRAGMA integrity_check` and returns whether the database is healthy.
236///
237/// # Errors
238///
239/// Returns `Error` if the integrity check query fails.
240pub fn integrity_check(conn: &Connection) -> DbResult<bool> {
241    let result = conn.query_row("PRAGMA integrity_check;", &[], |stmt| {
242        Ok(stmt.column_text(0))
243    })?;
244    Ok(result.trim() == "ok")
245}
246
247#[cfg(test)]
248mod tests {
249    use super::{
250        export_plaintext_copy, import_plaintext_copy, integrity_check, open_encrypted,
251    };
252    use crate::params;
253    use crate::sqlite::Connection;
254    use crate::test_utils::init_sqlite;
255    use secrecy::SecretBox;
256
257    #[test]
258    fn test_cipher_encrypted_round_trip() {
259        init_sqlite();
260        let dir = tempfile::tempdir().expect("create temp dir");
261        let path = dir.path().join("cipher-test.sqlite");
262        let key = SecretBox::init_with(|| [0xABu8; 32]);
263
264        // Create and write
265        {
266            let conn = open_encrypted(&path, &key, false).expect("open encrypted");
267            conn.execute_batch(
268                "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);",
269            )
270            .expect("create table");
271            conn.execute("INSERT INTO secret (id, val) VALUES (1, 'top-secret')", &[])
272                .expect("insert");
273        }
274
275        // Re-open with correct key
276        {
277            let conn = open_encrypted(&path, &key, false).expect("reopen encrypted");
278            let val = conn
279                .query_row("SELECT val FROM secret WHERE id = 1", &[], |stmt| {
280                    Ok(stmt.column_text(0))
281                })
282                .expect("query");
283            assert_eq!(val, "top-secret");
284        }
285
286        // Wrong key should fail
287        {
288            let wrong_key = SecretBox::init_with(|| [0xCDu8; 32]);
289            let result = open_encrypted(&path, &wrong_key, false);
290            assert!(result.is_err(), "wrong key should fail");
291        }
292    }
293
294    #[test]
295    fn test_integrity_check() {
296        init_sqlite();
297        let conn = Connection::open_in_memory().expect("open in-memory db");
298        let ok = integrity_check(&conn).expect("check");
299        assert!(ok);
300    }
301
302    #[test]
303    fn test_cipher_plaintext_export_import_roundtrip() {
304        init_sqlite();
305        let dir = tempfile::tempdir().expect("create temp dir");
306        let src_path = dir.path().join("source.sqlite");
307        let dest_path = dir.path().join("backup.plain.sqlite");
308        let restore_path = dir.path().join("restore.sqlite");
309        let key = SecretBox::init_with(|| [0x11u8; 32]);
310
311        {
312            let conn = open_encrypted(&src_path, &key, false).expect("open src");
313            conn.execute_batch(
314                "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
315            )
316            .expect("create table");
317            conn.execute(
318                "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
319                params![1_i64, "alpha"],
320            )
321            .expect("insert");
322            conn.execute(
323                "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
324                params![2_i64, "beta"],
325            )
326            .expect("insert");
327
328            export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export");
329        }
330
331        {
332            let conn =
333                open_encrypted(&restore_path, &key, false).expect("open restore");
334            conn.execute_batch(
335                "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
336            )
337            .expect("create table");
338            import_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("import");
339
340            let count: i64 = conn
341                .query_row("SELECT COUNT(*) FROM widgets", &[], |row| {
342                    Ok(row.column_i64(0))
343                })
344                .expect("count");
345            assert_eq!(count, 2);
346
347            let val = conn
348                .query_row("SELECT val FROM widgets WHERE id = 2", &[], |row| {
349                    Ok(row.column_text(0))
350                })
351                .expect("query");
352            assert_eq!(val, "beta");
353        }
354    }
355
356    #[test]
357    fn test_cipher_import_rejects_non_empty_destination() {
358        init_sqlite();
359        let dir = tempfile::tempdir().expect("create temp dir");
360        let src_path = dir.path().join("source.sqlite");
361        let dest_path = dir.path().join("backup.plain.sqlite");
362        let restore_path = dir.path().join("restore.sqlite");
363        let key = SecretBox::init_with(|| [0x22u8; 32]);
364
365        {
366            let conn = open_encrypted(&src_path, &key, false).expect("open src");
367            conn.execute_batch(
368                "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
369            )
370            .expect("create table");
371            conn.execute(
372                "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
373                params![1_i64, "alpha"],
374            )
375            .expect("insert");
376            export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export");
377        }
378
379        let conn = open_encrypted(&restore_path, &key, false).expect("open restore");
380        conn.execute_batch(
381            "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
382        )
383        .expect("create table");
384        conn.execute(
385            "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
386            params![99_i64, "preexisting"],
387        )
388        .expect("insert");
389
390        let err = import_plaintext_copy(&conn, &dest_path, &["widgets"])
391            .expect_err("import should refuse non-empty destination");
392        assert!(
393            err.to_string().contains("non-empty table"),
394            "expected non-empty-table error, got: {err}"
395        );
396    }
397}