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}