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.
374fn ensure_temp_store_memory(conn: &Connection) -> DbResult<()> {
375    conn.execute_batch("PRAGMA temp_store = MEMORY;")?;
376    let actual =
377        conn.query_row("PRAGMA temp_store;", &[], |row| Ok(row.column_i64(0)))?;
378    if actual == TEMP_STORE_MEMORY {
379        Ok(())
380    } else {
381        Err(Error::new(
382            -1,
383            format!(
384                "could not ensure PRAGMA temp_store = MEMORY: expected {TEMP_STORE_MEMORY}, got {actual}"
385            ),
386        ))
387    }
388}
389
390/// Creates a plaintext (unencrypted) copy of an already-open encrypted database.
391///
392/// The copy is produced by `ATTACH`-ing a new unencrypted database and copying
393/// the caller-specified tables via `CREATE TABLE ... AS SELECT *`. The
394/// destination file must not already exist.
395///
396/// We use `ATTACH` + SQL instead of the `sqlite3_backup` API because
397/// `sqlite3mc` requires both source and destination to share the same
398/// encryption configuration. Since the destination is unencrypted, the
399/// backup API cannot be used.
400///
401/// # Errors
402///
403/// Returns `Error` if the `ATTACH`, copy, or `DETACH` fails.
404pub fn export_plaintext_copy(
405    conn: &Connection,
406    dest_path: &Path,
407    tables: &[&str],
408) -> DbResult<()> {
409    let dest_str = dest_path.to_string_lossy();
410    let attach_sql = format!(
411        "ATTACH DATABASE '{}' AS backup KEY '';",
412        dest_str.replace('\'', "''")
413    );
414    conn.execute_batch(&attach_sql)?;
415
416    let result = (|| {
417        let tx = conn.transaction()?;
418        for table in tables {
419            tx.execute_batch(&format!(
420                "CREATE TABLE backup.{table} AS SELECT * FROM {table};"
421            ))?;
422        }
423        tx.commit()
424    })();
425
426    // Always detach, even if the copy failed.
427    let detach_result = conn.execute_batch("DETACH DATABASE backup;");
428
429    result?;
430    detach_result?;
431    Ok(())
432}
433
434/// Imports data from a plaintext (unencrypted) database into an already-open
435/// encrypted database.
436///
437/// The source database is `ATTACH`ed with an empty key and its contents are
438/// copied into the main (empty) encrypted database.
439///
440/// See [`export_plaintext_copy`] for why `ATTACH` + SQL is used instead of
441/// the `sqlite3_backup` API.
442///
443/// **Schema migration:** The import uses `SELECT *`, so column changes are
444/// handled automatically as long as both sides share the same schema. If a
445/// caller's schema evolves (e.g. new columns with `NOT NULL` constraints),
446/// restoring an older backup into a newer schema will fail. When that happens,
447/// the caller needs version-aware import logic.
448///
449/// # Errors
450///
451/// Returns `Error` if the `ATTACH`, copy, or `DETACH` fails.
452pub fn import_plaintext_copy(
453    conn: &Connection,
454    source_path: &Path,
455    tables: &[&str],
456) -> DbResult<()> {
457    if !source_path.exists() {
458        return Err(Error::new(
459            -1,
460            format!("backup file does not exist: {}", source_path.display()),
461        ));
462    }
463
464    let source_str = source_path.to_string_lossy();
465    let attach_sql = format!(
466        "ATTACH DATABASE '{}' AS backup KEY '';",
467        source_str.replace('\'', "''")
468    );
469    conn.execute_batch(&attach_sql)?;
470
471    // Verify the destination tables are empty before importing. Importing into
472    // a non-empty destination could silently merge data if primary keys don't
473    // collide.
474    let result = (|| {
475        for table in tables {
476            let count: i64 =
477                conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), &[], |row| {
478                    Ok(row.column_i64(0))
479                })?;
480            if count > 0 {
481                return Err(Error::new(
482                    -1,
483                    format!("cannot import into non-empty table: {table}"),
484                ));
485            }
486        }
487
488        // Wrap in a transaction so the restore is atomic — if any INSERT
489        // fails, everything is rolled back and the destination stays empty for
490        // a retry.
491        let tx = conn.transaction()?;
492        for table in tables {
493            tx.execute_batch(&format!(
494                "INSERT INTO {table} SELECT * FROM backup.{table};"
495            ))?;
496        }
497        tx.commit()
498    })();
499
500    // Always detach, even if the import failed.
501    let detach_result = conn.execute_batch("DETACH DATABASE backup;");
502
503    result?;
504    detach_result?;
505    Ok(())
506}
507
508/// Runs `PRAGMA integrity_check` and returns whether the database is healthy.
509///
510/// # Errors
511///
512/// Returns `Error` if the integrity check query fails.
513pub fn integrity_check(conn: &Connection) -> DbResult<bool> {
514    let result = conn.query_row("PRAGMA integrity_check;", &[], |stmt| {
515        Ok(stmt.column_text(0))
516    })?;
517    Ok(result.trim() == "ok")
518}
519
520/// Raw key for the pre-plaintext-header format. `WalletKit` releases before the
521/// plaintext-header change encrypted headers with this key, so it also decrypts
522/// [`LEGACY_ENCRYPTED_HEADER_FIXTURE`].
523#[cfg(test)]
524pub(crate) const LEGACY_FIXTURE_KEY: [u8; 32] = [0x61; 32];
525
526/// Database written by a pre-plaintext-header `WalletKit` release, frozen so
527/// compatibility is measured against real old bytes rather than whatever the
528/// current codec happens to produce. Regenerate only when the legacy format
529/// itself must change:
530/// `cargo test -p walletkit-sqlite --lib -- --ignored regenerate_legacy_encrypted_header_fixture`.
531#[cfg(test)]
532pub(crate) const LEGACY_ENCRYPTED_HEADER_FIXTURE: &[u8] =
533    include_bytes!("../tests/fixtures/legacy_encrypted_header.sqlite");
534
535/// Opens a database in the pre-plaintext-header format for migration tests.
536///
537/// Matching the target's historical journal policy matters: native databases
538/// used WAL while WASM databases used a rollback journal, and the OPFS SAH-pool
539/// cannot open a WAL database at all.
540#[cfg(test)]
541pub(crate) fn open_fully_encrypted(
542    path: &Path,
543    key: &SecretBox<[u8; 32]>,
544) -> DbResult<Connection> {
545    #[cfg(not(target_arch = "wasm32"))]
546    let conn = Connection::open(path, false)?;
547    #[cfg(target_arch = "wasm32")]
548    let conn = Connection::open_with_opfs_vfs(path, false)?;
549    ensure_cipher(&conn)?;
550    encrypt_or_unlock_fully_encrypted(&conn, key)?;
551
552    #[cfg(not(target_arch = "wasm32"))]
553    ensure_journal_mode(&conn, "WAL")?;
554    #[cfg(target_arch = "wasm32")]
555    ensure_journal_mode(&conn, "DELETE")?;
556    Ok(conn)
557}
558
559#[cfg(test)]
560mod tests {
561    use super::{
562        export_plaintext_copy, import_plaintext_copy, integrity_check,
563        is_plaintext_header_probe_error, open_encrypted, open_fully_encrypted, Error,
564        LEGACY_ENCRYPTED_HEADER_FIXTURE, LEGACY_FIXTURE_KEY, SQLITE_CORRUPT,
565        SQLITE_ERROR,
566    };
567    use crate::params;
568    use crate::test_utils::init_sqlite;
569    use crate::Connection;
570    use secrecy::SecretBox;
571
572    #[test]
573    fn test_plaintext_header_probe_errors() {
574        assert!(is_plaintext_header_probe_error(&Error::new(
575            SQLITE_ERROR,
576            "unsupported file format",
577        )));
578        assert!(is_plaintext_header_probe_error(&Error::new(
579            SQLITE_CORRUPT,
580            "database disk image is malformed",
581        )));
582        assert!(!is_plaintext_header_probe_error(&Error::new(
583            SQLITE_CORRUPT,
584            "database or disk is full",
585        )));
586    }
587
588    #[test]
589    fn test_cipher_encrypted_round_trip() {
590        init_sqlite();
591        let dir = tempfile::tempdir().expect("create temp dir");
592        let path = dir.path().join("cipher-test.sqlite");
593        let key = SecretBox::init_with(|| [0xABu8; 32]);
594
595        // Create and write
596        {
597            let conn = open_encrypted(&path, &key).expect("open encrypted");
598            conn.execute_batch(
599                "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);",
600            )
601            .expect("create table");
602            conn.execute("INSERT INTO secret (id, val) VALUES (1, 'top-secret')", &[])
603                .expect("insert");
604        }
605
606        // Re-open with correct key
607        {
608            let conn = open_encrypted(&path, &key).expect("reopen encrypted");
609            let val = conn
610                .query_row("SELECT val FROM secret WHERE id = 1", &[], |stmt| {
611                    Ok(stmt.column_text(0))
612                })
613                .expect("query");
614            assert_eq!(val, "top-secret");
615        }
616
617        // Wrong key should fail
618        {
619            let wrong_key = SecretBox::init_with(|| [0xCDu8; 32]);
620            let result = open_encrypted(&path, &wrong_key);
621            assert!(result.is_err(), "wrong key should fail");
622        }
623    }
624
625    #[test]
626    fn test_plaintext_wal_database_migrates_to_plaintext_header() {
627        init_sqlite();
628        let dir = tempfile::tempdir().expect("create temp dir");
629        let path = dir.path().join("plaintext.sqlite");
630        let key = SecretBox::init_with(|| [0x42u8; 32]);
631
632        {
633            let conn = Connection::open(&path, false).expect("open plaintext");
634            let mode = conn
635                .query_row("PRAGMA journal_mode = WAL", &[], |row| {
636                    Ok(row.column_text(0))
637                })
638                .expect("enable plaintext WAL");
639            assert_eq!(mode.to_ascii_lowercase(), "wal");
640            conn.execute_batch(
641                "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);\
642                 INSERT INTO secret VALUES (1, 'preserve-me');",
643            )
644            .expect("write plaintext data");
645        }
646        assert!(
647            std::fs::read(&path)
648                .expect("read plaintext")
649                .starts_with(b"SQLite format 3\0"),
650            "fixture must start as plaintext SQLite"
651        );
652
653        {
654            let conn = open_encrypted(&path, &key).expect("migrate plaintext");
655            let value = conn
656                .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
657                    Ok(row.column_text(0))
658                })
659                .expect("read migrated data");
660            assert_eq!(value, "preserve-me");
661        }
662
663        let encrypted_bytes = std::fs::read(&path).expect("read encrypted");
664        assert!(
665            encrypted_bytes.starts_with(b"SQLite format 3\0"),
666            "migration must retain the plaintext SQLite header"
667        );
668        assert_eq!(encrypted_bytes[18], 2, "database must use WAL read mode");
669        assert_eq!(encrypted_bytes[19], 2, "database must use WAL write mode");
670        assert_eq!(
671            encrypted_bytes[20], 32,
672            "header must advertise the cipher's reserved bytes"
673        );
674        assert!(
675            !encrypted_bytes
676                .windows("preserve-me".len())
677                .any(|window| window == b"preserve-me"),
678            "database contents must be encrypted"
679        );
680
681        {
682            let conn = open_encrypted(&path, &key).expect("reopen migrated database");
683            let value = conn
684                .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
685                    Ok(row.column_text(0))
686                })
687                .expect("read migrated data after reopen");
688            assert_eq!(value, "preserve-me");
689        }
690
691        let wrong_key = SecretBox::init_with(|| [0x43u8; 32]);
692        assert!(
693            open_encrypted(&path, &wrong_key).is_err(),
694            "migrated database must reject the wrong key"
695        );
696        assert_eq!(
697            std::fs::read(&path).expect("read after wrong-key open"),
698            encrypted_bytes,
699            "wrong-key open must not modify migrated data"
700        );
701    }
702
703    #[test]
704    fn test_plaintext_header_encrypted_round_trip() {
705        init_sqlite();
706        let dir = tempfile::tempdir().expect("create temp dir");
707        let path = dir.path().join("plaintext-header.sqlite");
708        let key = SecretBox::init_with(|| [0x51_u8; 32]);
709
710        {
711            let conn =
712                open_encrypted(&path, &key).expect("create plaintext-header database");
713            conn.execute_batch(
714                "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);\
715                 INSERT INTO secret VALUES (1, 'visible-header');",
716            )
717            .expect("write encrypted data");
718        }
719
720        let encrypted_bytes = std::fs::read(&path).expect("read encrypted database");
721        assert!(
722            encrypted_bytes.starts_with(b"SQLite format 3\0"),
723            "the SQLite file header must remain visible"
724        );
725        assert_eq!(encrypted_bytes[18], 2, "database must use WAL read mode");
726        assert_eq!(encrypted_bytes[19], 2, "database must use WAL write mode");
727        assert_eq!(
728            encrypted_bytes[20], 32,
729            "header must advertise the cipher's reserved bytes"
730        );
731        assert!(
732            !encrypted_bytes
733                .windows("visible-header".len())
734                .any(|window| window == b"visible-header"),
735            "database contents must remain encrypted"
736        );
737
738        {
739            let conn =
740                open_encrypted(&path, &key).expect("reopen plaintext-header database");
741            let value = conn
742                .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
743                    Ok(row.column_text(0))
744                })
745                .expect("read encrypted data");
746            assert_eq!(value, "visible-header");
747        }
748
749        let wrong_key = SecretBox::init_with(|| [0x52_u8; 32]);
750        assert!(
751            open_encrypted(&path, &wrong_key).is_err(),
752            "plaintext-header database must reject the wrong key"
753        );
754        assert_eq!(
755            std::fs::read(&path).expect("read after wrong-key open"),
756            encrypted_bytes,
757            "wrong-key open must not modify encrypted data"
758        );
759    }
760
761    #[test]
762    #[ignore = "regenerates the checked-in legacy migration fixture"]
763    fn regenerate_legacy_encrypted_header_fixture() {
764        init_sqlite();
765        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
766            .join("tests/fixtures/legacy_encrypted_header.sqlite");
767        std::fs::create_dir_all(path.parent().expect("fixture has a parent"))
768            .expect("create fixtures directory");
769        let _ = std::fs::remove_file(&path);
770        let key = SecretBox::init_with(|| LEGACY_FIXTURE_KEY);
771
772        {
773            let conn =
774                open_fully_encrypted(&path, &key).expect("create legacy fixture");
775            conn.execute_batch(
776                "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);\
777                 INSERT INTO secret VALUES (1, 'preserve-me');",
778            )
779            .expect("write legacy fixture");
780        }
781
782        let bytes = std::fs::read(&path).expect("read regenerated fixture");
783        assert!(
784            !bytes.starts_with(b"SQLite format 3\0"),
785            "regenerated fixture must use the fully-encrypted legacy header"
786        );
787    }
788
789    #[test]
790    fn test_frozen_legacy_encrypted_header_database_migrates() {
791        init_sqlite();
792        let dir = tempfile::tempdir().expect("create temp dir");
793        let path = dir.path().join("legacy-encrypted-header.sqlite");
794        let key = SecretBox::init_with(|| LEGACY_FIXTURE_KEY);
795        std::fs::write(&path, LEGACY_ENCRYPTED_HEADER_FIXTURE)
796            .expect("write fixture copy");
797
798        assert!(
799            !LEGACY_ENCRYPTED_HEADER_FIXTURE.starts_with(b"SQLite format 3\0"),
800            "fixture must use the fully-encrypted legacy header"
801        );
802        assert!(
803            !LEGACY_ENCRYPTED_HEADER_FIXTURE
804                .windows("preserve-me".len())
805                .any(|window| window == b"preserve-me"),
806            "fixture contents must be encrypted"
807        );
808
809        let wrong_key = SecretBox::init_with(|| [0x62u8; 32]);
810        assert!(
811            open_encrypted(&path, &wrong_key).is_err(),
812            "legacy fixture must reject the wrong key before migration"
813        );
814        assert_eq!(
815            std::fs::read(&path)
816                .expect("read fixture after wrong-key open")
817                .as_slice(),
818            LEGACY_ENCRYPTED_HEADER_FIXTURE,
819            "wrong-key open must not migrate or modify the frozen fixture"
820        );
821
822        {
823            let conn = open_encrypted(&path, &key).expect("migrate legacy fixture");
824            let value = conn
825                .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
826                    Ok(row.column_text(0))
827                })
828                .expect("read migrated data");
829            assert_eq!(value, "preserve-me");
830        }
831
832        let migrated_bytes = std::fs::read(&path).expect("read migrated database");
833        assert!(
834            migrated_bytes.starts_with(b"SQLite format 3\0"),
835            "migration must expose the SQLite header"
836        );
837        assert_eq!(migrated_bytes[18], 2, "database must use WAL read mode");
838        assert_eq!(migrated_bytes[19], 2, "database must use WAL write mode");
839        assert_eq!(
840            migrated_bytes[20], 32,
841            "header must advertise the cipher's reserved bytes"
842        );
843        assert_ne!(
844            migrated_bytes.as_slice(),
845            LEGACY_ENCRYPTED_HEADER_FIXTURE,
846            "migration must rewrite the encrypted on-disk format"
847        );
848        assert!(
849            !migrated_bytes
850                .windows("preserve-me".len())
851                .any(|window| window == b"preserve-me"),
852            "migrated database contents must remain encrypted"
853        );
854
855        {
856            let conn = open_encrypted(&path, &key).expect("reopen migrated database");
857            let value = conn
858                .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
859                    Ok(row.column_text(0))
860                })
861                .expect("read migrated data after reopen");
862            assert_eq!(value, "preserve-me");
863        }
864
865        assert!(
866            open_encrypted(&path, &wrong_key).is_err(),
867            "migrated database must reject the wrong key"
868        );
869        assert_eq!(
870            std::fs::read(&path).expect("read migrated database after wrong-key open"),
871            migrated_bytes,
872            "wrong-key open must not modify migrated data"
873        );
874    }
875
876    #[test]
877    fn test_integrity_check() {
878        init_sqlite();
879        let conn = Connection::open_in_memory().expect("open in-memory db");
880        let ok = integrity_check(&conn).expect("check");
881        assert!(ok);
882    }
883
884    #[test]
885    fn test_cipher_plaintext_export_import_roundtrip() {
886        init_sqlite();
887        let dir = tempfile::tempdir().expect("create temp dir");
888        let src_path = dir.path().join("source.sqlite");
889        let dest_path = dir.path().join("backup.plain.sqlite");
890        let restore_path = dir.path().join("restore.sqlite");
891        let key = SecretBox::init_with(|| [0x11u8; 32]);
892
893        {
894            let conn = open_encrypted(&src_path, &key).expect("open src");
895            conn.execute_batch(
896                "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
897            )
898            .expect("create table");
899            conn.execute(
900                "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
901                params![1_i64, "alpha"],
902            )
903            .expect("insert");
904            conn.execute(
905                "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
906                params![2_i64, "beta"],
907            )
908            .expect("insert");
909
910            export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export");
911        }
912
913        {
914            let conn = open_encrypted(&restore_path, &key).expect("open restore");
915            conn.execute_batch(
916                "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
917            )
918            .expect("create table");
919            import_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("import");
920
921            let count: i64 = conn
922                .query_row("SELECT COUNT(*) FROM widgets", &[], |row| {
923                    Ok(row.column_i64(0))
924                })
925                .expect("count");
926            assert_eq!(count, 2);
927
928            let val = conn
929                .query_row("SELECT val FROM widgets WHERE id = 2", &[], |row| {
930                    Ok(row.column_text(0))
931                })
932                .expect("query");
933            assert_eq!(val, "beta");
934        }
935    }
936
937    #[test]
938    fn test_cipher_import_rejects_non_empty_destination() {
939        init_sqlite();
940        let dir = tempfile::tempdir().expect("create temp dir");
941        let src_path = dir.path().join("source.sqlite");
942        let dest_path = dir.path().join("backup.plain.sqlite");
943        let restore_path = dir.path().join("restore.sqlite");
944        let key = SecretBox::init_with(|| [0x22u8; 32]);
945
946        {
947            let conn = open_encrypted(&src_path, &key).expect("open src");
948            conn.execute_batch(
949                "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
950            )
951            .expect("create table");
952            conn.execute(
953                "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
954                params![1_i64, "alpha"],
955            )
956            .expect("insert");
957            export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export");
958        }
959
960        let conn = open_encrypted(&restore_path, &key).expect("open restore");
961        conn.execute_batch(
962            "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
963        )
964        .expect("create table");
965        conn.execute(
966            "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
967            params![99_i64, "preexisting"],
968        )
969        .expect("insert");
970
971        let err = import_plaintext_copy(&conn, &dest_path, &["widgets"])
972            .expect_err("import should refuse non-empty destination");
973        assert!(
974            err.to_string().contains("non-empty table"),
975            "expected non-empty-table error, got: {err}"
976        );
977    }
978}