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#[cfg(test)]
521mod tests {
522    use super::{
523        encrypt_or_unlock_fully_encrypted, ensure_cipher, ensure_journal_mode,
524        export_plaintext_copy, import_plaintext_copy, integrity_check,
525        is_plaintext_header_probe_error, open_encrypted, Error, SQLITE_CORRUPT,
526        SQLITE_ERROR,
527    };
528    use crate::params;
529    use crate::test_utils::init_sqlite;
530    use crate::Connection;
531    use secrecy::SecretBox;
532
533    fn open_fully_encrypted(
534        path: &std::path::Path,
535        key: &SecretBox<[u8; 32]>,
536    ) -> crate::DbResult<Connection> {
537        let conn = Connection::open(path, false)?;
538        ensure_cipher(&conn)?;
539        encrypt_or_unlock_fully_encrypted(&conn, key)?;
540        ensure_journal_mode(&conn, "WAL")?;
541        Ok(conn)
542    }
543
544    #[test]
545    fn test_plaintext_header_probe_errors() {
546        assert!(is_plaintext_header_probe_error(&Error::new(
547            SQLITE_ERROR,
548            "unsupported file format",
549        )));
550        assert!(is_plaintext_header_probe_error(&Error::new(
551            SQLITE_CORRUPT,
552            "database disk image is malformed",
553        )));
554        assert!(!is_plaintext_header_probe_error(&Error::new(
555            SQLITE_CORRUPT,
556            "database or disk is full",
557        )));
558    }
559
560    #[test]
561    fn test_cipher_encrypted_round_trip() {
562        init_sqlite();
563        let dir = tempfile::tempdir().expect("create temp dir");
564        let path = dir.path().join("cipher-test.sqlite");
565        let key = SecretBox::init_with(|| [0xABu8; 32]);
566
567        // Create and write
568        {
569            let conn = open_encrypted(&path, &key).expect("open encrypted");
570            conn.execute_batch(
571                "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);",
572            )
573            .expect("create table");
574            conn.execute("INSERT INTO secret (id, val) VALUES (1, 'top-secret')", &[])
575                .expect("insert");
576        }
577
578        // Re-open with correct key
579        {
580            let conn = open_encrypted(&path, &key).expect("reopen encrypted");
581            let val = conn
582                .query_row("SELECT val FROM secret WHERE id = 1", &[], |stmt| {
583                    Ok(stmt.column_text(0))
584                })
585                .expect("query");
586            assert_eq!(val, "top-secret");
587        }
588
589        // Wrong key should fail
590        {
591            let wrong_key = SecretBox::init_with(|| [0xCDu8; 32]);
592            let result = open_encrypted(&path, &wrong_key);
593            assert!(result.is_err(), "wrong key should fail");
594        }
595    }
596
597    #[test]
598    fn test_plaintext_wal_database_migrates_to_plaintext_header() {
599        init_sqlite();
600        let dir = tempfile::tempdir().expect("create temp dir");
601        let path = dir.path().join("plaintext.sqlite");
602        let key = SecretBox::init_with(|| [0x42u8; 32]);
603
604        {
605            let conn = Connection::open(&path, false).expect("open plaintext");
606            let mode = conn
607                .query_row("PRAGMA journal_mode = WAL", &[], |row| {
608                    Ok(row.column_text(0))
609                })
610                .expect("enable plaintext WAL");
611            assert_eq!(mode.to_ascii_lowercase(), "wal");
612            conn.execute_batch(
613                "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);\
614                 INSERT INTO secret VALUES (1, 'preserve-me');",
615            )
616            .expect("write plaintext data");
617        }
618        assert!(
619            std::fs::read(&path)
620                .expect("read plaintext")
621                .starts_with(b"SQLite format 3\0"),
622            "fixture must start as plaintext SQLite"
623        );
624
625        {
626            let conn = open_encrypted(&path, &key).expect("migrate plaintext");
627            let value = conn
628                .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
629                    Ok(row.column_text(0))
630                })
631                .expect("read migrated data");
632            assert_eq!(value, "preserve-me");
633        }
634
635        let encrypted_bytes = std::fs::read(&path).expect("read encrypted");
636        assert!(
637            encrypted_bytes.starts_with(b"SQLite format 3\0"),
638            "migration must retain the plaintext SQLite header"
639        );
640        assert_eq!(encrypted_bytes[18], 2, "database must use WAL read mode");
641        assert_eq!(encrypted_bytes[19], 2, "database must use WAL write mode");
642        assert_eq!(
643            encrypted_bytes[20], 32,
644            "header must advertise the cipher's reserved bytes"
645        );
646        assert!(
647            !encrypted_bytes
648                .windows("preserve-me".len())
649                .any(|window| window == b"preserve-me"),
650            "database contents must be encrypted"
651        );
652
653        {
654            let conn = open_encrypted(&path, &key).expect("reopen migrated database");
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 after reopen");
660            assert_eq!(value, "preserve-me");
661        }
662
663        let wrong_key = SecretBox::init_with(|| [0x43u8; 32]);
664        assert!(
665            open_encrypted(&path, &wrong_key).is_err(),
666            "migrated database must reject the wrong key"
667        );
668        assert_eq!(
669            std::fs::read(&path).expect("read after wrong-key open"),
670            encrypted_bytes,
671            "wrong-key open must not modify migrated data"
672        );
673    }
674
675    #[test]
676    fn test_plaintext_header_encrypted_round_trip() {
677        init_sqlite();
678        let dir = tempfile::tempdir().expect("create temp dir");
679        let path = dir.path().join("plaintext-header.sqlite");
680        let key = SecretBox::init_with(|| [0x51_u8; 32]);
681
682        {
683            let conn =
684                open_encrypted(&path, &key).expect("create plaintext-header database");
685            conn.execute_batch(
686                "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);\
687                 INSERT INTO secret VALUES (1, 'visible-header');",
688            )
689            .expect("write encrypted data");
690        }
691
692        let encrypted_bytes = std::fs::read(&path).expect("read encrypted database");
693        assert!(
694            encrypted_bytes.starts_with(b"SQLite format 3\0"),
695            "the SQLite file header must remain visible"
696        );
697        assert_eq!(encrypted_bytes[18], 2, "database must use WAL read mode");
698        assert_eq!(encrypted_bytes[19], 2, "database must use WAL write mode");
699        assert_eq!(
700            encrypted_bytes[20], 32,
701            "header must advertise the cipher's reserved bytes"
702        );
703        assert!(
704            !encrypted_bytes
705                .windows("visible-header".len())
706                .any(|window| window == b"visible-header"),
707            "database contents must remain encrypted"
708        );
709
710        {
711            let conn =
712                open_encrypted(&path, &key).expect("reopen plaintext-header database");
713            let value = conn
714                .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
715                    Ok(row.column_text(0))
716                })
717                .expect("read encrypted data");
718            assert_eq!(value, "visible-header");
719        }
720
721        let wrong_key = SecretBox::init_with(|| [0x52_u8; 32]);
722        assert!(
723            open_encrypted(&path, &wrong_key).is_err(),
724            "plaintext-header database must reject the wrong key"
725        );
726        assert_eq!(
727            std::fs::read(&path).expect("read after wrong-key open"),
728            encrypted_bytes,
729            "wrong-key open must not modify encrypted data"
730        );
731    }
732
733    #[test]
734    fn test_encrypted_header_database_migrates_to_plaintext_header() {
735        init_sqlite();
736        let dir = tempfile::tempdir().expect("create temp dir");
737        let path = dir.path().join("encrypted-header.sqlite");
738        let key = SecretBox::init_with(|| [0x61_u8; 32]);
739
740        {
741            let conn =
742                open_fully_encrypted(&path, &key).expect("create legacy database");
743            conn.execute_batch(
744                "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);\
745                 INSERT INTO secret VALUES (1, 'preserve-me');",
746            )
747            .expect("write legacy encrypted data");
748        }
749
750        let legacy_bytes = std::fs::read(&path).expect("read legacy database");
751        assert!(
752            !legacy_bytes.starts_with(b"SQLite format 3\0"),
753            "fixture must use the fully-encrypted legacy header"
754        );
755        assert!(
756            !legacy_bytes
757                .windows("preserve-me".len())
758                .any(|window| window == b"preserve-me"),
759            "legacy database contents must be encrypted"
760        );
761
762        let wrong_key = SecretBox::init_with(|| [0x62_u8; 32]);
763        assert!(
764            open_encrypted(&path, &wrong_key).is_err(),
765            "legacy database must reject the wrong key before migration"
766        );
767        assert_eq!(
768            std::fs::read(&path).expect("read legacy database after wrong-key open"),
769            legacy_bytes,
770            "wrong-key open must not migrate or modify the legacy database"
771        );
772
773        {
774            let conn = open_encrypted(&path, &key).expect("migrate legacy database");
775            let value = conn
776                .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
777                    Ok(row.column_text(0))
778                })
779                .expect("read migrated data");
780            assert_eq!(value, "preserve-me");
781        }
782
783        let migrated_bytes = std::fs::read(&path).expect("read migrated database");
784        assert!(
785            migrated_bytes.starts_with(b"SQLite format 3\0"),
786            "migration must expose the SQLite header"
787        );
788        assert_eq!(migrated_bytes[18], 2, "database must use WAL read mode");
789        assert_eq!(migrated_bytes[19], 2, "database must use WAL write mode");
790        assert_eq!(
791            migrated_bytes[20], 32,
792            "header must advertise the cipher's reserved bytes"
793        );
794        assert_ne!(
795            migrated_bytes, legacy_bytes,
796            "migration must rewrite the encrypted on-disk format"
797        );
798        assert!(
799            !migrated_bytes
800                .windows("preserve-me".len())
801                .any(|window| window == b"preserve-me"),
802            "migrated database contents must remain encrypted"
803        );
804
805        {
806            let conn = open_encrypted(&path, &key).expect("reopen migrated database");
807            let value = conn
808                .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
809                    Ok(row.column_text(0))
810                })
811                .expect("read migrated data after reopen");
812            assert_eq!(value, "preserve-me");
813        }
814
815        assert!(
816            open_encrypted(&path, &wrong_key).is_err(),
817            "migrated database must reject the wrong key"
818        );
819        assert_eq!(
820            std::fs::read(&path).expect("read migrated database after wrong-key open"),
821            migrated_bytes,
822            "wrong-key open must not modify migrated data"
823        );
824    }
825
826    #[test]
827    fn test_integrity_check() {
828        init_sqlite();
829        let conn = Connection::open_in_memory().expect("open in-memory db");
830        let ok = integrity_check(&conn).expect("check");
831        assert!(ok);
832    }
833
834    #[test]
835    fn test_cipher_plaintext_export_import_roundtrip() {
836        init_sqlite();
837        let dir = tempfile::tempdir().expect("create temp dir");
838        let src_path = dir.path().join("source.sqlite");
839        let dest_path = dir.path().join("backup.plain.sqlite");
840        let restore_path = dir.path().join("restore.sqlite");
841        let key = SecretBox::init_with(|| [0x11u8; 32]);
842
843        {
844            let conn = open_encrypted(&src_path, &key).expect("open src");
845            conn.execute_batch(
846                "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
847            )
848            .expect("create table");
849            conn.execute(
850                "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
851                params![1_i64, "alpha"],
852            )
853            .expect("insert");
854            conn.execute(
855                "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
856                params![2_i64, "beta"],
857            )
858            .expect("insert");
859
860            export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export");
861        }
862
863        {
864            let conn = open_encrypted(&restore_path, &key).expect("open restore");
865            conn.execute_batch(
866                "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
867            )
868            .expect("create table");
869            import_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("import");
870
871            let count: i64 = conn
872                .query_row("SELECT COUNT(*) FROM widgets", &[], |row| {
873                    Ok(row.column_i64(0))
874                })
875                .expect("count");
876            assert_eq!(count, 2);
877
878            let val = conn
879                .query_row("SELECT val FROM widgets WHERE id = 2", &[], |row| {
880                    Ok(row.column_text(0))
881                })
882                .expect("query");
883            assert_eq!(val, "beta");
884        }
885    }
886
887    #[test]
888    fn test_cipher_import_rejects_non_empty_destination() {
889        init_sqlite();
890        let dir = tempfile::tempdir().expect("create temp dir");
891        let src_path = dir.path().join("source.sqlite");
892        let dest_path = dir.path().join("backup.plain.sqlite");
893        let restore_path = dir.path().join("restore.sqlite");
894        let key = SecretBox::init_with(|| [0x22u8; 32]);
895
896        {
897            let conn = open_encrypted(&src_path, &key).expect("open src");
898            conn.execute_batch(
899                "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
900            )
901            .expect("create table");
902            conn.execute(
903                "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
904                params![1_i64, "alpha"],
905            )
906            .expect("insert");
907            export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export");
908        }
909
910        let conn = open_encrypted(&restore_path, &key).expect("open restore");
911        conn.execute_batch(
912            "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
913        )
914        .expect("create table");
915        conn.execute(
916            "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
917            params![99_i64, "preexisting"],
918        )
919        .expect("insert");
920
921        let err = import_plaintext_copy(&conn, &dest_path, &["widgets"])
922            .expect_err("import should refuse non-empty destination");
923        assert!(
924            err.to_string().contains("non-empty table"),
925            "expected non-empty-table error, got: {err}"
926        );
927    }
928}