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. **Detect and encrypt or unlock** -- A read from `sqlite_master` succeeds
19//!    for a plaintext (or new) database. Such a database is moved out of WAL
20//!    mode and atomically encrypted with `PRAGMA rekey`. Existing databases
21//!    with a fully encrypted header are unlocked using their old settings and
22//!    migrated in place. Both `key` and `rekey` receive the 32-byte
23//!    `K_intermediate` as a raw hex key, bypassing the passphrase KDF.
24//!
25//! 4. **Verify** -- We read from `sqlite_master` after rekeying or keying. A
26//!    wrong key returns `SQLITE_NOTADB` because the decrypted page header does
27//!    not match the expected `SQLite` magic bytes.
28//!
29//! 5. **Configure connection** -- The target-specific journal mode and every
30//!    connection-level invariant are set and verified.
31//!
32//! The default cipher is **ChaCha20-Poly1305** (authenticated encryption).
33//! All crypto is built into the `sqlite3mc` amalgamation -- no OpenSSL or
34//! other external crypto library is needed on any platform.
35//!
36//! The first 32 bytes of every database header remain plaintext. This gives
37//! all targets one on-disk format and lets iOS recognize shared-container
38//! databases in WAL mode. Existing databases with fully encrypted headers are
39//! migrated in place on their first successful open.
40
41use std::path::Path;
42
43use secrecy::{ExposeSecret, SecretBox};
44use zeroize::Zeroizing;
45
46use super::connection::Connection;
47use super::error::{DbResult, Error};
48
49const CIPHER_CHACHA20: &str = "chacha20";
50const PLAINTEXT_HEADER_SIZE: i64 = 32;
51const SQLITE_ERROR: i32 = 1;
52const SQLITE_CORRUPT: i32 = 11;
53const FOREIGN_KEYS_ON: i64 = 1;
54const SYNCHRONOUS_FULL: i64 = 2;
55const SECURE_DELETE_ON: i64 = 1;
56const TEMP_STORE_MEMORY: i64 = 2;
57
58/// Opens a writable database, applies the encryption key, and configures the connection.
59///
60/// This is the standard open sequence for databases: open -> select cipher ->
61/// encrypt plaintext or unlock encrypted data -> verify -> configure policy.
62///
63/// See the [module-level documentation](self) for the full encryption flow.
64///
65/// # Errors
66///
67/// Returns `Error` if opening, keying, or configuring the connection fails.
68pub fn open_encrypted(
69    path: &Path,
70    k_intermediate: &SecretBox<[u8; 32]>,
71) -> DbResult<Connection> {
72    #[cfg(not(target_arch = "wasm32"))]
73    let conn = Connection::open(path, false)?;
74    #[cfg(target_arch = "wasm32")]
75    let conn = Connection::open_with_opfs_vfs(path, false)?;
76    configure_connection(&conn, k_intermediate)?;
77    Ok(conn)
78}
79
80/// Configures durable journal settings, foreign keys, and secure deletion.
81///
82/// - Native uses WAL for concurrent readers during writes.
83/// - WASM uses a rollback journal because SAH-pool has no WAL shared-memory
84///   methods; WAL would require exclusive locking and provide no concurrency.
85/// - `synchronous = FULL` -- maximizes crash consistency by flushing required
86///   journal writes before the transaction is reported as committed.
87/// - `foreign_keys = ON` -- enforces referential integrity constraints.
88/// - `secure_delete = ON` -- overwrites deleted content with zeroes so
89///   sensitive data does not linger in free pages.
90fn configure_connection(
91    conn: &Connection,
92    k_intermediate: &SecretBox<[u8; 32]>,
93) -> DbResult<()> {
94    ensure_cipher(conn)?;
95    encrypt_or_unlock(conn, k_intermediate)?;
96
97    #[cfg(not(target_arch = "wasm32"))]
98    ensure_journal_mode(conn, "WAL")?;
99    // SAH-pool does not expose WAL shared-memory methods. WAL would therefore
100    // require locking_mode=EXCLUSIVE before the first database access and
101    // provide no concurrency benefit, so WASM deliberately uses the rollback
102    // journal until benchmarks justify that extra complexity.
103    #[cfg(target_arch = "wasm32")]
104    ensure_journal_mode(conn, "DELETE")?;
105
106    ensure_foreign_keys(conn)?;
107    ensure_synchronous_full(conn)?;
108    ensure_secure_delete(conn)?;
109    ensure_temp_store_memory(conn)?;
110    Ok(())
111}
112
113/// Encrypts or unlocks a database with a plaintext `SQLite` header.
114///
115/// Earlier `WalletKit` versions encrypted the header completely. Those databases
116/// must first be opened with the old settings, moved out of WAL mode, and rekeyed
117/// after configuring the plaintext header. Databases that already start with the
118/// `SQLite` magic bytes are either plaintext or already use the new format;
119/// probing the schema before applying the key distinguishes the two cases.
120fn encrypt_or_unlock(
121    conn: &Connection,
122    k_intermediate: &SecretBox<[u8; 32]>,
123) -> DbResult<()> {
124    match verify_schema_readable(conn) {
125        Ok(()) => {
126            // Plaintext (or newly-created) database. Configure the new format
127            // before the first rekey so it is never written with an encrypted
128            // header.
129            ensure_journal_mode(conn, "DELETE")?;
130            ensure_plaintext_header(conn)?;
131            apply_rekey(conn, k_intermediate)?;
132            verify_encryption(conn, "plaintext database encryption verification failed")
133        }
134        Err(error) if is_plaintext_header_probe_error(&error) => {
135            // The SQLite header is visible but the schema is not readable
136            // without the codec: this is already the plaintext-header format.
137            ensure_plaintext_header(conn)?;
138            apply_key(conn, k_intermediate)
139        }
140        Err(error) if error.code.0 & 0xff == super::ffi::SQLITE_NOTADB => {
141            // Legacy WalletKit format. Unlock it with the encrypted-header
142            // settings, checkpoint WAL, then atomically rekey with the same raw
143            // key after selecting the common plaintext-header format.
144            apply_key(conn, k_intermediate)?;
145            ensure_journal_mode(conn, "DELETE")?;
146            ensure_plaintext_header(conn)?;
147            apply_rekey(conn, k_intermediate)?;
148            verify_encryption(conn, "plaintext-header migration verification failed")
149        }
150        Err(error) => Err(error),
151    }
152}
153
154/// A plaintext-header encrypted page exposes format fields that vanilla
155/// `SQLite` tries to parse before the codec is configured. Depending on the
156/// encrypted page bytes, that probe can fail either while validating the
157/// header fields or while parsing the page body.
158fn is_plaintext_header_probe_error(error: &Error) -> bool {
159    let primary_code = error.code.0 & 0xff;
160    (primary_code == SQLITE_ERROR && error.message == "unsupported file format")
161        || (primary_code == SQLITE_CORRUPT
162            && error.message == "database disk image is malformed")
163}
164
165fn ensure_plaintext_header(conn: &Connection) -> DbResult<()> {
166    conn.execute_batch(&format!(
167        "PRAGMA plaintext_header_size = {PLAINTEXT_HEADER_SIZE};"
168    ))?;
169    let actual = conn.query_row("PRAGMA plaintext_header_size;", &[], |row| {
170        Ok(row.column_i64(0))
171    })?;
172    if actual == PLAINTEXT_HEADER_SIZE {
173        Ok(())
174    } else {
175        Err(Error::new(
176            -1,
177            format!(
178                "could not ensure plaintext header size {PLAINTEXT_HEADER_SIZE}: SQLite selected {actual}"
179            ),
180        ))
181    }
182}
183
184/// Encrypts or unlocks a database using `WalletKit`'s legacy fully encrypted
185/// header format. Kept only to construct migration fixtures.
186#[cfg(test)]
187fn encrypt_or_unlock_fully_encrypted(
188    conn: &Connection,
189    k_intermediate: &SecretBox<[u8; 32]>,
190) -> DbResult<()> {
191    match verify_schema_readable(conn) {
192        Ok(()) => {
193            ensure_journal_mode(conn, "DELETE")?;
194            apply_rekey(conn, k_intermediate)?;
195            verify_encryption(conn, "plaintext database encryption verification failed")
196        }
197        Err(error) if error.code.0 & 0xff == super::ffi::SQLITE_NOTADB => {
198            apply_key(conn, k_intermediate)
199        }
200        Err(error) => Err(error),
201    }
202}
203
204fn verify_encryption(conn: &Connection, context: &str) -> DbResult<()> {
205    verify_schema_readable(conn).map_err(|error| {
206        Error::new(error.code.0, format!("{context}: {}", error.message))
207    })
208}
209
210/// Selects and verifies the on-disk cipher before the key activates it.
211///
212/// Pinning the cipher prevents a future compile-time default change from
213/// silently creating or interpreting databases with a different format.
214fn ensure_cipher(conn: &Connection) -> DbResult<()> {
215    conn.execute_batch(&format!("PRAGMA cipher = '{CIPHER_CHACHA20}';"))?;
216    let actual = conn.query_row("PRAGMA cipher;", &[], |row| Ok(row.column_text(0)))?;
217    if actual.eq_ignore_ascii_case(CIPHER_CHACHA20) {
218        Ok(())
219    } else {
220        Err(Error::new(
221            -1,
222            format!(
223                "could not ensure sqlite3mc cipher {CIPHER_CHACHA20}: SQLite selected {actual}"
224            ),
225        ))
226    }
227}
228
229/// Applies the `sqlite3mc` encryption key to an open connection.
230///
231/// The 32-byte `k_intermediate` is hex-encoded and passed as a raw key via
232/// `PRAGMA key = "x'<64-hex-chars>'"`. `sqlite3mc` interprets the `x'...'`
233/// prefix as a raw key (as opposed to a passphrase that would be run through
234/// a KDF first).
235///
236/// After keying, a lightweight read (`SELECT count(*) FROM sqlite_master`)
237/// verifies the key is correct. If it's wrong, `sqlite3mc` fails with
238/// `SQLITE_NOTADB` on the first page read.
239fn apply_key(conn: &Connection, k_intermediate: &SecretBox<[u8; 32]>) -> DbResult<()> {
240    let pragma = raw_key_pragma("key", k_intermediate);
241
242    // execute_batch_zeroized ensures the internal CString copy of the PRAGMA
243    // (which contains the hex key) is zeroized after the FFI call returns.
244    conn.execute_batch_zeroized(&pragma)?;
245
246    // Touch a page to verify the key works. On failure this produces a clear
247    // error rather than a confusing "not a database" later during schema setup.
248    verify_schema_readable(conn).map_err(|e| {
249        Error::new(
250            e.code.0,
251            format!(
252                "encryption key verification failed (is the key correct?): {}",
253                e.message
254            ),
255        )
256    })?;
257
258    // k_intermediate and pragma are zeroized on drop regardless of which exit
259    // path we took. raw_key_pragma zeroizes its temporary hex buffer too.
260    Ok(())
261}
262
263/// Encrypts a readable plaintext database in place with the supplied raw key.
264fn apply_rekey(
265    conn: &Connection,
266    k_intermediate: &SecretBox<[u8; 32]>,
267) -> DbResult<()> {
268    let pragma = raw_key_pragma("rekey", k_intermediate);
269    conn.execute_batch_zeroized(&pragma).map_err(|e| {
270        Error::new(
271            e.code.0,
272            format!("failed to encrypt plaintext database: {}", e.message),
273        )
274    })
275}
276
277fn raw_key_pragma(
278    operation: &str,
279    k_intermediate: &SecretBox<[u8; 32]>,
280) -> Zeroizing<String> {
281    let key_hex = Zeroizing::new(hex::encode(k_intermediate.expose_secret()));
282    Zeroizing::new(format!("PRAGMA {operation} = \"x'{}'\";", key_hex.as_str()))
283}
284
285fn verify_schema_readable(conn: &Connection) -> DbResult<()> {
286    conn.execute_batch("SELECT count(*) FROM sqlite_master;")
287}
288
289/// Ensures the target-specific journal policy actually took effect.
290///
291/// Assigning `journal_mode` returns the effective mode because `SQLite` may
292/// retain the previous mode when the requested transition is unavailable.
293fn ensure_journal_mode(conn: &Connection, requested: &str) -> DbResult<()> {
294    let actual =
295        conn.query_row(&format!("PRAGMA journal_mode = {requested};"), &[], |row| {
296            Ok(row.column_text(0))
297        })?;
298    if actual.eq_ignore_ascii_case(requested) {
299        Ok(())
300    } else {
301        Err(Error::new(
302            -1,
303            format!(
304                "could not ensure journal mode {requested}: SQLite selected {actual}"
305            ),
306        ))
307    }
308}
309
310/// Enables foreign-key enforcement for every connection.
311///
312/// `SQLite` defaults this setting to off and may silently ignore the assignment
313/// inside a transaction or when foreign-key support was omitted at build time.
314fn ensure_foreign_keys(conn: &Connection) -> DbResult<()> {
315    conn.execute_batch("PRAGMA foreign_keys = ON;")?;
316    let actual =
317        conn.query_row("PRAGMA foreign_keys;", &[], |row| Ok(row.column_i64(0)))?;
318    if actual == FOREIGN_KEYS_ON {
319        Ok(())
320    } else {
321        Err(Error::new(
322            -1,
323            format!(
324                "could not ensure PRAGMA foreign_keys = ON: expected {FOREIGN_KEYS_ON}, got {actual}"
325            ),
326        ))
327    }
328}
329
330/// Uses `SQLite`'s strongest ordinary durability policy.
331///
332/// `FULL` ensures `SQLite` flushes journal content before reporting a transaction
333/// as committed, reducing the risk of corruption after a crash or power loss.
334fn ensure_synchronous_full(conn: &Connection) -> DbResult<()> {
335    conn.execute_batch("PRAGMA synchronous = FULL;")?;
336    let actual =
337        conn.query_row("PRAGMA synchronous;", &[], |row| Ok(row.column_i64(0)))?;
338    if actual == SYNCHRONOUS_FULL {
339        Ok(())
340    } else {
341        Err(Error::new(
342            -1,
343            format!(
344                "could not ensure PRAGMA synchronous = FULL: expected {SYNCHRONOUS_FULL}, got {actual}"
345            ),
346        ))
347    }
348}
349
350/// Overwrites deleted content instead of leaving it in reusable database pages.
351///
352/// This limits plaintext remnants while the encrypted database is open and
353/// accessible with its key.
354fn ensure_secure_delete(conn: &Connection) -> DbResult<()> {
355    conn.execute_batch("PRAGMA secure_delete = ON;")?;
356    let actual =
357        conn.query_row("PRAGMA secure_delete;", &[], |row| Ok(row.column_i64(0)))?;
358    if actual == SECURE_DELETE_ON {
359        Ok(())
360    } else {
361        Err(Error::new(
362            -1,
363            format!(
364                "could not ensure PRAGMA secure_delete = ON: expected {SECURE_DELETE_ON}, got {actual}"
365            ),
366        ))
367    }
368}
369
370/// Keeps temporary tables and indices in memory.
371///
372/// `sqlite3mc` does not encrypt temporary databases, so allowing temporary
373/// storage to spill to a filesystem could expose plaintext at rest.
374///
375/// # Errors
376/// Returns an error if the setting cannot be applied or verified.
377pub fn ensure_temp_store_memory(conn: &Connection) -> DbResult<()> {
378    conn.execute_batch("PRAGMA temp_store = MEMORY;")?;
379    let actual =
380        conn.query_row("PRAGMA temp_store;", &[], |row| Ok(row.column_i64(0)))?;
381    if actual == TEMP_STORE_MEMORY {
382        Ok(())
383    } else {
384        Err(Error::new(
385            -1,
386            format!(
387                "could not ensure PRAGMA temp_store = MEMORY: expected {TEMP_STORE_MEMORY}, got {actual}"
388            ),
389        ))
390    }
391}
392
393/// Creates a plaintext (unencrypted) copy of an already-open encrypted database.
394///
395/// The copy is produced by `ATTACH`-ing a new unencrypted database and copying
396/// the caller-specified tables via `CREATE TABLE ... AS SELECT *`. The
397/// destination file must not already exist.
398///
399/// We use `ATTACH` + SQL instead of the `sqlite3_backup` API because
400/// `sqlite3mc` requires both source and destination to share the same
401/// encryption configuration. Since the destination is unencrypted, the
402/// backup API cannot be used.
403///
404/// # Errors
405///
406/// Returns `Error` if the `ATTACH`, copy, or `DETACH` fails.
407pub fn export_plaintext_copy(
408    conn: &Connection,
409    dest_path: &Path,
410    tables: &[&str],
411) -> DbResult<()> {
412    let dest_str = dest_path.to_string_lossy();
413    let attach_sql = format!(
414        "ATTACH DATABASE '{}' AS backup KEY '';",
415        dest_str.replace('\'', "''")
416    );
417    conn.execute_batch(&attach_sql)?;
418
419    let result = (|| {
420        let tx = conn.transaction()?;
421        for table in tables {
422            tx.execute_batch(&format!(
423                "CREATE TABLE backup.{table} AS SELECT * FROM {table};"
424            ))?;
425        }
426        tx.commit()
427    })();
428
429    // Always detach, even if the copy failed.
430    let detach_result = conn.execute_batch("DETACH DATABASE backup;");
431
432    result?;
433    detach_result?;
434    Ok(())
435}
436
437/// Imports data from a plaintext (unencrypted) database into an already-open
438/// encrypted database.
439///
440/// The source database is `ATTACH`ed with an empty key and its contents are
441/// copied into the main (empty) encrypted database.
442///
443/// See [`export_plaintext_copy`] for why `ATTACH` + SQL is used instead of
444/// the `sqlite3_backup` API.
445///
446/// **Schema migration:** The import uses `SELECT *`, so column changes are
447/// handled automatically as long as both sides share the same schema. If a
448/// caller's schema evolves (e.g. new columns with `NOT NULL` constraints),
449/// restoring an older backup into a newer schema will fail. When that happens,
450/// the caller needs version-aware import logic.
451///
452/// # Errors
453///
454/// Returns `Error` if the `ATTACH`, copy, or `DETACH` fails.
455pub fn import_plaintext_copy(
456    conn: &Connection,
457    source_path: &Path,
458    tables: &[&str],
459) -> DbResult<()> {
460    if !source_path.exists() {
461        return Err(Error::new(
462            -1,
463            format!("backup file does not exist: {}", source_path.display()),
464        ));
465    }
466
467    let source_str = source_path.to_string_lossy();
468    let attach_sql = format!(
469        "ATTACH DATABASE '{}' AS backup KEY '';",
470        source_str.replace('\'', "''")
471    );
472    conn.execute_batch(&attach_sql)?;
473
474    // Verify the destination tables are empty before importing. Importing into
475    // a non-empty destination could silently merge data if primary keys don't
476    // collide.
477    let result = (|| {
478        for table in tables {
479            let count: i64 =
480                conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), &[], |row| {
481                    Ok(row.column_i64(0))
482                })?;
483            if count > 0 {
484                return Err(Error::new(
485                    -1,
486                    format!("cannot import into non-empty table: {table}"),
487                ));
488            }
489        }
490
491        // Wrap in a transaction so the restore is atomic — if any INSERT
492        // fails, everything is rolled back and the destination stays empty for
493        // a retry.
494        let tx = conn.transaction()?;
495        for table in tables {
496            tx.execute_batch(&format!(
497                "INSERT INTO {table} SELECT * FROM backup.{table};"
498            ))?;
499        }
500        tx.commit()
501    })();
502
503    // Always detach, even if the import failed.
504    let detach_result = conn.execute_batch("DETACH DATABASE backup;");
505
506    result?;
507    detach_result?;
508    Ok(())
509}
510
511/// Runs `PRAGMA integrity_check` and returns whether the database is healthy.
512///
513/// # Errors
514///
515/// Returns `Error` if the integrity check query fails.
516pub fn integrity_check(conn: &Connection) -> DbResult<bool> {
517    let result = conn.query_row("PRAGMA integrity_check;", &[], |stmt| {
518        Ok(stmt.column_text(0))
519    })?;
520    Ok(result.trim() == "ok")
521}
522
523/// Raw key for the pre-plaintext-header format. `WalletKit` releases before the
524/// plaintext-header change encrypted headers with this key, so it also decrypts
525/// [`LEGACY_ENCRYPTED_HEADER_FIXTURE`].
526#[cfg(test)]
527pub(crate) const LEGACY_FIXTURE_KEY: [u8; 32] = [0x61; 32];
528
529/// Database written by a pre-plaintext-header `WalletKit` release, frozen so
530/// compatibility is measured against real old bytes rather than whatever the
531/// current codec happens to produce. Regenerate only when the legacy format
532/// itself must change:
533/// `cargo test -p walletkit-sqlite --lib -- --ignored regenerate_legacy_encrypted_header_fixture`.
534#[cfg(test)]
535pub(crate) const LEGACY_ENCRYPTED_HEADER_FIXTURE: &[u8] =
536    include_bytes!("../tests/fixtures/legacy_encrypted_header.sqlite");
537
538/// Opens a database in the pre-plaintext-header format for migration tests.
539///
540/// Matching the target's historical journal policy matters: native databases
541/// used WAL while WASM databases used a rollback journal, and the OPFS SAH-pool
542/// cannot open a WAL database at all.
543#[cfg(test)]
544pub(crate) fn open_fully_encrypted(
545    path: &Path,
546    key: &SecretBox<[u8; 32]>,
547) -> DbResult<Connection> {
548    #[cfg(not(target_arch = "wasm32"))]
549    let conn = Connection::open(path, false)?;
550    #[cfg(target_arch = "wasm32")]
551    let conn = Connection::open_with_opfs_vfs(path, false)?;
552    ensure_cipher(&conn)?;
553    encrypt_or_unlock_fully_encrypted(&conn, key)?;
554
555    #[cfg(not(target_arch = "wasm32"))]
556    ensure_journal_mode(&conn, "WAL")?;
557    #[cfg(target_arch = "wasm32")]
558    ensure_journal_mode(&conn, "DELETE")?;
559    Ok(conn)
560}
561
562#[cfg(test)]
563mod tests {
564    use super::{
565        ensure_temp_store_memory, export_plaintext_copy, import_plaintext_copy,
566        integrity_check, is_plaintext_header_probe_error, open_encrypted,
567        open_fully_encrypted, Error, LEGACY_ENCRYPTED_HEADER_FIXTURE,
568        LEGACY_FIXTURE_KEY, SQLITE_CORRUPT, SQLITE_ERROR,
569    };
570    use crate::params;
571    use crate::test_utils::init_sqlite;
572    use crate::Connection;
573    use secrecy::SecretBox;
574
575    #[test]
576    fn test_temp_store_memory_overrides_file_storage() {
577        init_sqlite();
578        let conn = Connection::open_in_memory().unwrap();
579        conn.execute_batch("PRAGMA temp_store = FILE;").unwrap();
580        ensure_temp_store_memory(&conn).unwrap();
581        let temp_store = conn
582            .query_row("PRAGMA temp_store;", &[], |row| Ok(row.column_i64(0)))
583            .unwrap();
584        assert_eq!(temp_store, 2);
585    }
586
587    #[test]
588    fn test_plaintext_header_probe_errors() {
589        assert!(is_plaintext_header_probe_error(&Error::new(
590            SQLITE_ERROR,
591            "unsupported file format",
592        )));
593        assert!(is_plaintext_header_probe_error(&Error::new(
594            SQLITE_CORRUPT,
595            "database disk image is malformed",
596        )));
597        assert!(!is_plaintext_header_probe_error(&Error::new(
598            SQLITE_CORRUPT,
599            "database or disk is full",
600        )));
601    }
602
603    #[test]
604    fn test_cipher_encrypted_round_trip() {
605        init_sqlite();
606        let dir = tempfile::tempdir().expect("create temp dir");
607        let path = dir.path().join("cipher-test.sqlite");
608        let key = SecretBox::init_with(|| [0xABu8; 32]);
609
610        // Create and write
611        {
612            let conn = open_encrypted(&path, &key).expect("open encrypted");
613            conn.execute_batch(
614                "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);",
615            )
616            .expect("create table");
617            conn.execute("INSERT INTO secret (id, val) VALUES (1, 'top-secret')", &[])
618                .expect("insert");
619        }
620
621        // Re-open with correct key
622        {
623            let conn = open_encrypted(&path, &key).expect("reopen encrypted");
624            let val = conn
625                .query_row("SELECT val FROM secret WHERE id = 1", &[], |stmt| {
626                    Ok(stmt.column_text(0))
627                })
628                .expect("query");
629            assert_eq!(val, "top-secret");
630        }
631
632        // Wrong key should fail
633        {
634            let wrong_key = SecretBox::init_with(|| [0xCDu8; 32]);
635            let result = open_encrypted(&path, &wrong_key);
636            assert!(result.is_err(), "wrong key should fail");
637        }
638    }
639
640    #[test]
641    fn test_plaintext_wal_database_migrates_to_plaintext_header() {
642        init_sqlite();
643        let dir = tempfile::tempdir().expect("create temp dir");
644        let path = dir.path().join("plaintext.sqlite");
645        let key = SecretBox::init_with(|| [0x42u8; 32]);
646
647        {
648            let conn = Connection::open(&path, false).expect("open plaintext");
649            let mode = conn
650                .query_row("PRAGMA journal_mode = WAL", &[], |row| {
651                    Ok(row.column_text(0))
652                })
653                .expect("enable plaintext WAL");
654            assert_eq!(mode.to_ascii_lowercase(), "wal");
655            conn.execute_batch(
656                "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);\
657                 INSERT INTO secret VALUES (1, 'preserve-me');",
658            )
659            .expect("write plaintext data");
660        }
661        assert!(
662            std::fs::read(&path)
663                .expect("read plaintext")
664                .starts_with(b"SQLite format 3\0"),
665            "fixture must start as plaintext SQLite"
666        );
667
668        {
669            let conn = open_encrypted(&path, &key).expect("migrate plaintext");
670            let value = conn
671                .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
672                    Ok(row.column_text(0))
673                })
674                .expect("read migrated data");
675            assert_eq!(value, "preserve-me");
676        }
677
678        let encrypted_bytes = std::fs::read(&path).expect("read encrypted");
679        assert!(
680            encrypted_bytes.starts_with(b"SQLite format 3\0"),
681            "migration must retain the plaintext SQLite header"
682        );
683        assert_eq!(encrypted_bytes[18], 2, "database must use WAL read mode");
684        assert_eq!(encrypted_bytes[19], 2, "database must use WAL write mode");
685        assert_eq!(
686            encrypted_bytes[20], 32,
687            "header must advertise the cipher's reserved bytes"
688        );
689        assert!(
690            !encrypted_bytes
691                .windows("preserve-me".len())
692                .any(|window| window == b"preserve-me"),
693            "database contents must be encrypted"
694        );
695
696        {
697            let conn = open_encrypted(&path, &key).expect("reopen migrated database");
698            let value = conn
699                .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
700                    Ok(row.column_text(0))
701                })
702                .expect("read migrated data after reopen");
703            assert_eq!(value, "preserve-me");
704        }
705
706        let wrong_key = SecretBox::init_with(|| [0x43u8; 32]);
707        assert!(
708            open_encrypted(&path, &wrong_key).is_err(),
709            "migrated database must reject the wrong key"
710        );
711        assert_eq!(
712            std::fs::read(&path).expect("read after wrong-key open"),
713            encrypted_bytes,
714            "wrong-key open must not modify migrated data"
715        );
716    }
717
718    #[test]
719    fn test_plaintext_header_encrypted_round_trip() {
720        init_sqlite();
721        let dir = tempfile::tempdir().expect("create temp dir");
722        let path = dir.path().join("plaintext-header.sqlite");
723        let key = SecretBox::init_with(|| [0x51_u8; 32]);
724
725        {
726            let conn =
727                open_encrypted(&path, &key).expect("create plaintext-header database");
728            conn.execute_batch(
729                "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);\
730                 INSERT INTO secret VALUES (1, 'visible-header');",
731            )
732            .expect("write encrypted data");
733        }
734
735        let encrypted_bytes = std::fs::read(&path).expect("read encrypted database");
736        assert!(
737            encrypted_bytes.starts_with(b"SQLite format 3\0"),
738            "the SQLite file header must remain visible"
739        );
740        assert_eq!(encrypted_bytes[18], 2, "database must use WAL read mode");
741        assert_eq!(encrypted_bytes[19], 2, "database must use WAL write mode");
742        assert_eq!(
743            encrypted_bytes[20], 32,
744            "header must advertise the cipher's reserved bytes"
745        );
746        assert!(
747            !encrypted_bytes
748                .windows("visible-header".len())
749                .any(|window| window == b"visible-header"),
750            "database contents must remain encrypted"
751        );
752
753        {
754            let conn =
755                open_encrypted(&path, &key).expect("reopen plaintext-header database");
756            let value = conn
757                .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
758                    Ok(row.column_text(0))
759                })
760                .expect("read encrypted data");
761            assert_eq!(value, "visible-header");
762        }
763
764        let wrong_key = SecretBox::init_with(|| [0x52_u8; 32]);
765        assert!(
766            open_encrypted(&path, &wrong_key).is_err(),
767            "plaintext-header database must reject the wrong key"
768        );
769        assert_eq!(
770            std::fs::read(&path).expect("read after wrong-key open"),
771            encrypted_bytes,
772            "wrong-key open must not modify encrypted data"
773        );
774    }
775
776    #[test]
777    #[ignore = "regenerates the checked-in legacy migration fixture"]
778    fn regenerate_legacy_encrypted_header_fixture() {
779        init_sqlite();
780        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
781            .join("tests/fixtures/legacy_encrypted_header.sqlite");
782        std::fs::create_dir_all(path.parent().expect("fixture has a parent"))
783            .expect("create fixtures directory");
784        let _ = std::fs::remove_file(&path);
785        let key = SecretBox::init_with(|| LEGACY_FIXTURE_KEY);
786
787        {
788            let conn =
789                open_fully_encrypted(&path, &key).expect("create legacy fixture");
790            conn.execute_batch(
791                "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);\
792                 INSERT INTO secret VALUES (1, 'preserve-me');",
793            )
794            .expect("write legacy fixture");
795        }
796
797        let bytes = std::fs::read(&path).expect("read regenerated fixture");
798        assert!(
799            !bytes.starts_with(b"SQLite format 3\0"),
800            "regenerated fixture must use the fully-encrypted legacy header"
801        );
802    }
803
804    #[test]
805    fn test_frozen_legacy_encrypted_header_database_migrates() {
806        init_sqlite();
807        let dir = tempfile::tempdir().expect("create temp dir");
808        let path = dir.path().join("legacy-encrypted-header.sqlite");
809        let key = SecretBox::init_with(|| LEGACY_FIXTURE_KEY);
810        std::fs::write(&path, LEGACY_ENCRYPTED_HEADER_FIXTURE)
811            .expect("write fixture copy");
812
813        assert!(
814            !LEGACY_ENCRYPTED_HEADER_FIXTURE.starts_with(b"SQLite format 3\0"),
815            "fixture must use the fully-encrypted legacy header"
816        );
817        assert!(
818            !LEGACY_ENCRYPTED_HEADER_FIXTURE
819                .windows("preserve-me".len())
820                .any(|window| window == b"preserve-me"),
821            "fixture contents must be encrypted"
822        );
823
824        let wrong_key = SecretBox::init_with(|| [0x62u8; 32]);
825        assert!(
826            open_encrypted(&path, &wrong_key).is_err(),
827            "legacy fixture must reject the wrong key before migration"
828        );
829        assert_eq!(
830            std::fs::read(&path)
831                .expect("read fixture after wrong-key open")
832                .as_slice(),
833            LEGACY_ENCRYPTED_HEADER_FIXTURE,
834            "wrong-key open must not migrate or modify the frozen fixture"
835        );
836
837        {
838            let conn = open_encrypted(&path, &key).expect("migrate legacy fixture");
839            let value = conn
840                .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
841                    Ok(row.column_text(0))
842                })
843                .expect("read migrated data");
844            assert_eq!(value, "preserve-me");
845        }
846
847        let migrated_bytes = std::fs::read(&path).expect("read migrated database");
848        assert!(
849            migrated_bytes.starts_with(b"SQLite format 3\0"),
850            "migration must expose the SQLite header"
851        );
852        assert_eq!(migrated_bytes[18], 2, "database must use WAL read mode");
853        assert_eq!(migrated_bytes[19], 2, "database must use WAL write mode");
854        assert_eq!(
855            migrated_bytes[20], 32,
856            "header must advertise the cipher's reserved bytes"
857        );
858        assert_ne!(
859            migrated_bytes.as_slice(),
860            LEGACY_ENCRYPTED_HEADER_FIXTURE,
861            "migration must rewrite the encrypted on-disk format"
862        );
863        assert!(
864            !migrated_bytes
865                .windows("preserve-me".len())
866                .any(|window| window == b"preserve-me"),
867            "migrated database contents must remain encrypted"
868        );
869
870        {
871            let conn = open_encrypted(&path, &key).expect("reopen migrated database");
872            let value = conn
873                .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
874                    Ok(row.column_text(0))
875                })
876                .expect("read migrated data after reopen");
877            assert_eq!(value, "preserve-me");
878        }
879
880        assert!(
881            open_encrypted(&path, &wrong_key).is_err(),
882            "migrated database must reject the wrong key"
883        );
884        assert_eq!(
885            std::fs::read(&path).expect("read migrated database after wrong-key open"),
886            migrated_bytes,
887            "wrong-key open must not modify migrated data"
888        );
889    }
890
891    #[test]
892    fn test_integrity_check() {
893        init_sqlite();
894        let conn = Connection::open_in_memory().expect("open in-memory db");
895        let ok = integrity_check(&conn).expect("check");
896        assert!(ok);
897    }
898
899    #[test]
900    fn test_cipher_plaintext_export_import_roundtrip() {
901        init_sqlite();
902        let dir = tempfile::tempdir().expect("create temp dir");
903        let src_path = dir.path().join("source.sqlite");
904        let dest_path = dir.path().join("backup.plain.sqlite");
905        let restore_path = dir.path().join("restore.sqlite");
906        let key = SecretBox::init_with(|| [0x11u8; 32]);
907
908        {
909            let conn = open_encrypted(&src_path, &key).expect("open src");
910            conn.execute_batch(
911                "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
912            )
913            .expect("create table");
914            conn.execute(
915                "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
916                params![1_i64, "alpha"],
917            )
918            .expect("insert");
919            conn.execute(
920                "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
921                params![2_i64, "beta"],
922            )
923            .expect("insert");
924
925            export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export");
926        }
927
928        {
929            let conn = open_encrypted(&restore_path, &key).expect("open restore");
930            conn.execute_batch(
931                "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
932            )
933            .expect("create table");
934            import_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("import");
935
936            let count: i64 = conn
937                .query_row("SELECT COUNT(*) FROM widgets", &[], |row| {
938                    Ok(row.column_i64(0))
939                })
940                .expect("count");
941            assert_eq!(count, 2);
942
943            let val = conn
944                .query_row("SELECT val FROM widgets WHERE id = 2", &[], |row| {
945                    Ok(row.column_text(0))
946                })
947                .expect("query");
948            assert_eq!(val, "beta");
949        }
950    }
951
952    #[test]
953    fn test_cipher_import_rejects_non_empty_destination() {
954        init_sqlite();
955        let dir = tempfile::tempdir().expect("create temp dir");
956        let src_path = dir.path().join("source.sqlite");
957        let dest_path = dir.path().join("backup.plain.sqlite");
958        let restore_path = dir.path().join("restore.sqlite");
959        let key = SecretBox::init_with(|| [0x22u8; 32]);
960
961        {
962            let conn = open_encrypted(&src_path, &key).expect("open src");
963            conn.execute_batch(
964                "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
965            )
966            .expect("create table");
967            conn.execute(
968                "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
969                params![1_i64, "alpha"],
970            )
971            .expect("insert");
972            export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export");
973        }
974
975        let conn = open_encrypted(&restore_path, &key).expect("open restore");
976        conn.execute_batch(
977            "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
978        )
979        .expect("create table");
980        conn.execute(
981            "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
982            params![99_i64, "preexisting"],
983        )
984        .expect("insert");
985
986        let err = import_plaintext_copy(&conn, &dest_path, &["widgets"])
987            .expect_err("import should refuse non-empty destination");
988        assert!(
989            err.to_string().contains("non-empty table"),
990            "expected non-empty-table error, got: {err}"
991        );
992    }
993}