diff --git a/fuzz/corpus/findings/backup-aes128-legacy-small-page-short-read b/fuzz/corpus/findings/backup-aes128-legacy-small-page-short-read new file mode 100644 index 0000000..04c9ac3 Binary files /dev/null and b/fuzz/corpus/findings/backup-aes128-legacy-small-page-short-read differ diff --git a/fuzz/corpus/findings/backup-rc4-small-page-short-read b/fuzz/corpus/findings/backup-rc4-small-page-short-read new file mode 100644 index 0000000..943b561 --- /dev/null +++ b/fuzz/corpus/findings/backup-rc4-small-page-short-read @@ -0,0 +1,3 @@ +@Â�·žÕjìxã¹––Ò ´á|Ž +1\Âì9h® &€4�áëê�Ì¢Nx ¥ƒ*‡nÂ�·žÕjìxã¹––Ò ´á|Ž +PJ[áº;iÑå2�'Z Ú .|¿W¦ŒF½z sýj�¦)yIÏúÛÜO$ÃéHªáìD \ No newline at end of file diff --git a/fuzz/corpus/findings/shared-cache-wrong-key-sibling-then-vacuum b/fuzz/corpus/findings/shared-cache-wrong-key-sibling-then-vacuum new file mode 100644 index 0000000..ccf5ad6 Binary files /dev/null and b/fuzz/corpus/findings/shared-cache-wrong-key-sibling-then-vacuum differ diff --git a/fuzz/corpus/shared_cache/all-siblings-correct-key b/fuzz/corpus/shared_cache/all-siblings-correct-key new file mode 100644 index 0000000..b4ecc8c --- /dev/null +++ b/fuzz/corpus/shared_cache/all-siblings-correct-key @@ -0,0 +1,3 @@ +ªÞÔ'æL7à®HpM½[/äý¢QséΦêÂ�ˆxâC½,§› ý ‡Û³¾nR’F�=D—_�6Ìp +º+×Hšæ¨%¶ˆ‹}¡$ˆ®þqúŸí˜·¥‚è%Ü�—yýÉÆF |˜¡¤2Jy‘X1·YÅNµmÙµäYVï$€Á0³Ü®ýíÇCÑû9Ù¡<übW”K^Ô~[N@æËD—vˆ³*Ãá©Ë�„½]b¬áºÒH·&xþßô¬#WLIgYøXŽ$"€ÿI¦}d[�BÖÈ ‡œä—…<6!¥»nÄ€`Æò['Ç¿Wgžƒ ¾õ·l6\çÃï[’>¢®£HWh®íÛXüôcPŸTטA +K›ª¹ÏH˜sqio‡G6äÀ¢t•x—2ªâvꌳL|°Ï \ No newline at end of file diff --git a/fuzz/corpus/shared_cache/many-siblings b/fuzz/corpus/shared_cache/many-siblings new file mode 100644 index 0000000..0299eb4 --- /dev/null +++ b/fuzz/corpus/shared_cache/many-siblings @@ -0,0 +1 @@ +¾{Ó9ƒAo–'ñ§$¬6*6yƒÉhuøÄáÉ,ë¶\ý‡�!SÞó¡M.r‚HÓŸ‚±p3;ƹPm-ü!ñ\C‡¡úIfÉÚ²æø¡«×&º×ª‘½¨ˆáüíö4Îòfg³ä`þ¶­»¼HjÇ oq<Ê^xðƒlHIèáYÚ7'¯4GÐ1îÔ‰�ìôªc‚„Ÿ�¨]-mVñ�EÈpëš2é_«nÒ6•ùÝ7›'< ÄØ0|mèÍI®°…J~rNò.ÊD½EðXèÞFJØôñ \ No newline at end of file diff --git a/fuzz/corpus/shared_cache/mixed-correct-and-wrong b/fuzz/corpus/shared_cache/mixed-correct-and-wrong new file mode 100644 index 0000000..cc9a9a8 Binary files /dev/null and b/fuzz/corpus/shared_cache/mixed-correct-and-wrong differ diff --git a/fuzz/corpus/shared_cache/one-sibling-correct-key b/fuzz/corpus/shared_cache/one-sibling-correct-key new file mode 100644 index 0000000..18df2cb Binary files /dev/null and b/fuzz/corpus/shared_cache/one-sibling-correct-key differ diff --git a/fuzz/corpus/shared_cache/one-sibling-wrong-key b/fuzz/corpus/shared_cache/one-sibling-wrong-key new file mode 100644 index 0000000..75e9526 Binary files /dev/null and b/fuzz/corpus/shared_cache/one-sibling-wrong-key differ diff --git a/fuzz/corpus/shared_cache/sibling-has-ops b/fuzz/corpus/shared_cache/sibling-has-ops new file mode 100644 index 0000000..54b0126 Binary files /dev/null and b/fuzz/corpus/shared_cache/sibling-has-ops differ diff --git a/fuzz/examples/gen_shared_cache_seeds.rs b/fuzz/examples/gen_shared_cache_seeds.rs index da3607f..ec9046a 100644 --- a/fuzz/examples/gen_shared_cache_seeds.rs +++ b/fuzz/examples/gen_shared_cache_seeds.rs @@ -10,13 +10,17 @@ use std::path::Path; use sqlite3mc_src_fuzz::{ run_shared_cache, seedgen::{run_search, DivergencePolicy, Family, Rng}, - SharedCacheCase, + SharedCacheCase, SiblingKey, }; const fn has_content(case: &SharedCacheCase) -> bool { !case.main_ops.is_empty() } +const fn shared(key: &SiblingKey) -> bool { + matches!(key, SiblingKey::Shared) +} + fn families() -> &'static [Family] { &[ Family { @@ -25,15 +29,11 @@ fn families() -> &'static [Family] { }, Family { name: "one-sibling-correct-key", - matches: |c| { - c.siblings.len() == 1 && c.siblings[0].config == c.written && has_content(c) - }, + matches: |c| c.siblings.len() == 1 && shared(&c.siblings[0].key) && has_content(c), }, Family { name: "one-sibling-wrong-key", - matches: |c| { - c.siblings.len() == 1 && c.siblings[0].config != c.written && has_content(c) - }, + matches: |c| c.siblings.len() == 1 && !shared(&c.siblings[0].key) && has_content(c), }, Family { name: "many-siblings", @@ -47,7 +47,7 @@ fn families() -> &'static [Family] { name: "all-siblings-correct-key", matches: |c| { !c.siblings.is_empty() - && c.siblings.iter().all(|s| s.config == c.written) + && c.siblings.iter().all(|s| shared(&s.key)) && has_content(c) }, }, @@ -55,8 +55,8 @@ fn families() -> &'static [Family] { name: "mixed-correct-and-wrong", matches: |c| { c.siblings.len() >= 2 - && c.siblings.iter().any(|s| s.config == c.written) - && c.siblings.iter().any(|s| s.config != c.written) + && c.siblings.iter().any(|s| shared(&s.key)) + && c.siblings.iter().any(|s| !shared(&s.key)) && has_content(c) }, }, diff --git a/fuzz/src/input.rs b/fuzz/src/input.rs index deb2e5b..e64ea07 100644 --- a/fuzz/src/input.rs +++ b/fuzz/src/input.rs @@ -525,6 +525,39 @@ impl<'a> Arbitrary<'a> for NativeConfig { } } +impl NativeConfig { + /// Whether `other` lays pages out exactly like `self`, every setting but the key equal. + #[must_use] + pub fn same_layout(&self, other: &Self) -> bool { + let Self { + cipher, + legacy, + page_size, + plaintext_header_size, + aegis_algorithm_256, + key: _, + } = self; + *cipher == other.cipher + && *legacy == other.legacy + && *page_size == other.page_size + && *plaintext_header_size == other.plaintext_header_size + && *aegis_algorithm_256 == other.aegis_algorithm_256 + } + + /// Whether this configuration encrypts page 1's header bytes 16 to 23, so nothing on disk + /// tells SQLite the page size before the key is set: `rc4` always, and `aes128cbc`, + /// `aes256cbc` and `chacha20` in `legacy` mode unless a plaintext header covers them. + #[must_use] + pub fn hides_page_size(&self) -> bool { + let whole_page_one = match self.cipher { + Cipher::Rc4 => true, + Cipher::Aes128 | Cipher::Aes256 | Cipher::ChaCha20 => self.legacy == Some(true), + Cipher::Ascon128 | Cipher::Aegis => false, + }; + whole_page_one && self.plaintext_header_size.unwrap_or(0) < 24 + } +} + /// One native-scheme execution: write, round-trip, a wrong key, and, where authenticated, damage. #[derive(Debug, Clone, PartialEq, Arbitrary)] pub struct NativeCase { @@ -704,11 +737,14 @@ impl<'a> Arbitrary<'a> for PageSize { /// One `sqlite3_backup` execution: writes a source, optionally pre-populates a destination with /// unrelated content, then runs `sqlite3_backup_init`/`_step`/`_finish` between them. /// -/// Oracle: if SQLite3MC's own compatibility guard (`sqlite3mcIsBackupSupported`, called from -/// `sqlite3_backup_init`) lets the pair through, and every step reports success, the destination -/// must reopen cleanly with its own true key; a refused pair (`backup_init` returning `NULL`) is -/// not a defect, since this harness exists to find exactly the cases where that guard *should* -/// have refused the pair but did not. +/// SQLite3MC's own compatibility guard (`sqlite3mcIsBackupSupported`, called from +/// `sqlite3_backup_init`) lets pairs disagreeing on reserved bytes through, so a backup between them +/// corrupts the destination (`upstream/sqlite3mc-backup-guard-ignores-reserve.md`). +/// +/// Oracle: when [`BackupCase::src`] and [`BackupCase::dest`] are laid out identically, every +/// cipher setting but the key and the `PRAGMA page_size` equal, and every step reports success, +/// the destination must reopen cleanly with its own true key. A refused pair (`backup_init` +/// returning `NULL`) is never a defect. #[derive(Debug, Clone, PartialEq, Arbitrary)] pub struct BackupCase { /// The source database's own cipher and key. @@ -729,13 +765,21 @@ pub struct BackupCase { /// Connections beyond this, opened to the same file under shared-cache mode, are ignored. pub(crate) const MAX_SIBLINGS: usize = 3; +/// The cipher and key a [`SharedConnection`] stages and keys with. +#[derive(Debug, Clone, PartialEq, Eq, Arbitrary)] +pub enum SiblingKey { + /// [`SharedCacheCase::written`], the one key shared-cache mode supports per file. + Shared, + /// A configuration of its own, almost certainly not [`SharedCacheCase::written`]. + Own(NativeConfig), +} + /// One sibling connection, opened to the same file `main` already created and populated under /// [`SharedCacheCase::written`]'s key, under shared-cache mode. #[derive(Debug, Clone, PartialEq, Arbitrary)] pub struct SharedConnection { - /// The cipher and key this connection stages and keys with, almost certainly not - /// [`SharedCacheCase::written`]. - pub config: NativeConfig, + /// The cipher and key this connection stages and keys with. + pub key: SiblingKey, /// Workload attempted on this connection after opening, at most [`MAX_OPS`] steps of it, /// tolerated to fail when this connection's own key turned out wrong. pub ops: Vec, @@ -744,13 +788,15 @@ pub struct SharedConnection { /// One shared-cache execution: a main connection creates and populates a database, then up to /// [`MAX_SIBLINGS`] further connections open the *same* file under shared-cache mode. /// -/// Each sibling has its own cipher and key, almost certainly not matching `main`'s. +/// Shared-cache connections to one file share one pager and one codec, so SQLite3MC supports a +/// single key and cipher configuration per file, and a sibling keying it differently replaces +/// the codec under `main` (`utelle/SQLite3MultipleCiphers` issue #281). /// -/// Oracle: `main` never changes its own key; its own reads must keep succeeding throughout, -/// regardless of what any sibling connection does to the file it shares with it, correct key, -/// wrong key, or a different cipher entirely. Also checked after every connection closes: the -/// file itself must still read back cleanly under `written`, the same way the other harnesses' -/// own "nothing corrupts the file at rest" oracle does. +/// Oracle: when every sibling keys with [`SiblingKey::Shared`], `main`'s own reads must keep +/// succeeding throughout. Unless a sibling ran a workload after one keyed the file differently, +/// and so wrote through the replaced codec, the file itself must still read back cleanly under +/// `written` once every connection closes, the same way the other harnesses' own "nothing +/// corrupts the file at rest" oracle does. #[derive(Debug, Clone, PartialEq, Arbitrary)] pub struct SharedCacheCase { /// `main`'s own cipher and key, the file's one true key. diff --git a/fuzz/src/lib.rs b/fuzz/src/lib.rs index 1380909..7aef8e5 100644 --- a/fuzz/src/lib.rs +++ b/fuzz/src/lib.rs @@ -16,6 +16,6 @@ pub mod seedgen; pub use input::{ Algorithm, AttachCase, BackupCase, Case, Checkpoint, Cipher, Config, Key, NativeCase, - NativeKey, Op, RekeyAction, RekeyCase, SharedCacheCase, Side, Table, WalCase, + NativeKey, Op, RekeyAction, RekeyCase, SharedCacheCase, SiblingKey, Side, Table, WalCase, }; pub use run::{run, run_attach, run_backup, run_native, run_rekey, run_shared_cache, run_wal}; diff --git a/fuzz/src/run.rs b/fuzz/src/run.rs index 4d1c848..536ab70 100644 --- a/fuzz/src/run.rs +++ b/fuzz/src/run.rs @@ -19,7 +19,8 @@ use crate::{ input::{ Algorithm, AttachCase, AttachedDb, BackupCase, Case, Cell, Cipher, Column, Config, Key, NativeCase, NativeConfig, NativeKey, Op, PageSize, RekeyAction, RekeyCase, SharedCacheCase, - Side, Table, WalCase, MAX_ATTACHED, MAX_ATTACH_ROWS, MAX_FLIPS, MAX_OPS, MAX_SIBLINGS, + SiblingKey, Side, Table, WalCase, MAX_ATTACHED, MAX_ATTACH_ROWS, MAX_FLIPS, MAX_OPS, + MAX_SIBLINGS, }, }; @@ -551,15 +552,20 @@ fn exercise_attached( /// own), each with its own cipher, key, and `PRAGMA page_size`, then backs the source up into the /// destination. When `sqlite3_backup_init`'s own compatibility guard (`sqlite3mcIsBackupSupported`) /// refuses the pair, there is nothing further to check: that is the guard working as intended. -/// When it lets the pair through and the backup reports full success, the destination must still -/// reopen cleanly with its own true key: `sqlite3_backup_step`/`_finish` reporting success for a -/// destination that cannot actually be read back is exactly the defect `utelle/SQLite3MultipleCiphers` -/// issue #158 described, and exactly what this harness exists to catch a recurrence of. +/// That guard still lets pairs disagreeing on reserved bytes through, and such a backup corrupts the +/// destination (`upstream/sqlite3mc-backup-guard-ignores-reserve.md`). So only a pair laid out +/// identically, every cipher setting but the key and the `PRAGMA page_size` equal, has to reopen +/// cleanly with the destination's own true key after the backup reports full success. A failure +/// there is the defect `utelle/SQLite3MultipleCiphers` issue #158 described, and exactly what this +/// harness exists to catch a recurrence of. The one identically laid out pair exempt is a +/// destination that hides its page size and is sized below 4096-byte pages by `PRAGMA page_size` +/// alone, which SQLite3MC cannot reopen at all, backup or not +/// (`upstream/sqlite3mc-rc4-short-read-skips-decryption.md`). /// /// # Panics /// -/// When SQLite3MC refuses a setting or step the harness generates as valid, or a backup the guard -/// allowed and that reported full success leaves an unreadable destination. +/// When SQLite3MC refuses a setting or step the harness generates as valid, or a backup between +/// two identically laid out databases reported full success and left an unreadable destination. pub fn run_backup(case: &BackupCase) { static SERIAL: Mutex<()> = Mutex::new(()); let _serial = begin_run(&SERIAL, case); @@ -576,7 +582,14 @@ pub fn run_backup(case: &BackupCase) { if let Some(job) = dest.backup_init(c"main", &src, c"main") { let step_rc = job.step(-1); let finish_result = job.finish(); - if step_rc == SQLITE_DONE && finish_result.is_ok() { + if step_rc == SQLITE_DONE + && finish_result.is_ok() + && case.src_page_size == case.dest_page_size + && case.src.same_layout(&case.dest) + && !(case.dest.hides_page_size() + && case.dest.page_size.is_none() + && case.dest_page_size.is_some_and(|size| size.get() < 4096)) + { drop(src); drop(dest); let dump = dump_native(&dest_path, &case.dest); @@ -653,13 +666,16 @@ fn attach( /// Runs `case` against SQLite3MC's handling of shared-cache mode. /// /// A main connection creates and populates a database, then up to [`MAX_SIBLINGS`] further -/// connections open the *same* file under shared-cache mode, each with their own cipher and key. +/// connections open the *same* file under shared-cache mode, each keying it with `main`'s own +/// configuration or one of its own. /// /// # Panics /// /// When SQLite3MC refuses a setting or step the harness generates as valid, when `main`'s own -/// read fails after a sibling connects despite `main` never changing its own key, or when the -/// file itself fails to read back cleanly under its one true key once every connection closes. +/// read fails after siblings keyed with the same configuration connect, or when the file itself +/// fails to read back cleanly under its one true key once every connection closes. A sibling +/// workload run after another sibling keyed the file differently writes through that sibling's +/// codec (`utelle/SQLite3MultipleCiphers` issue #281), so such a run skips the file check. pub fn run_shared_cache(case: &SharedCacheCase) { static SERIAL: Mutex<()> = Mutex::new(()); let _serial = begin_run(&SERIAL, case); @@ -673,7 +689,7 @@ pub fn run_shared_cache(case: &SharedCacheCase) { let cipher = case.written.cipher; let main = open_native(&path, &case.written, "shared-cache database"); - if let Err(code) = main.execute(c"CREATE TABLE t0(i INTEGER)", &[]) { + if let Err(code) = main.execute(c"CREATE TABLE t0(i INTEGER, r REAL, t TEXT, b BLOB)", &[]) { panic!( "SQLite3MC refused to create main's own {} table: {code:?}", cipher.name() @@ -682,6 +698,8 @@ pub fn run_shared_cache(case: &SharedCacheCase) { run_workload(&main, "SQLite3MC", &case.main_ops, case); let mut siblings = Vec::with_capacity(case.siblings.len().min(MAX_SIBLINGS)); + let mut one_key = true; + let mut foreign_write = false; for sibling in case.siblings.iter().take(MAX_SIBLINGS) { let Ok(connection) = Connection::open( &SQLITE3MC, @@ -690,31 +708,43 @@ pub fn run_shared_cache(case: &SharedCacheCase) { ) else { continue; }; - let _ = configure_native(&connection, &sibling.config); + let config = match &sibling.key { + SiblingKey::Shared => &case.written, + SiblingKey::Own(config) => { + one_key &= *config == case.written; + config + } + }; + let _ = configure_native(&connection, config); + foreign_write |= !one_key && !sibling.ops.is_empty(); run_workload_tolerant(&connection, &sibling.ops); siblings.push(connection); } - if let Err(code) = main.execute(c"SELECT count(*) FROM sqlite_schema", &[]) { - panic!( - "SQLite3MC's main connection, still keyed with its own correct {} key, could not \ - read its own shared-cache database after {} sibling connection(s) opened the same \ - file for {case:#?}: {code:?}", - cipher.name(), - siblings.len(), - ); + if one_key { + if let Err(code) = main.execute(c"SELECT count(*) FROM sqlite_schema", &[]) { + panic!( + "SQLite3MC's main connection could not read its own {} shared-cache database \ + after {} sibling connection(s) keyed the same file identically for {case:#?}: \ + {code:?}", + cipher.name(), + siblings.len(), + ); + } } drop(siblings); drop(main); - let own = dump_native(&path, &case.written); - assert!( - clean_read(&own), - "SQLite3MC corrupted its own {} shared-cache database file for {case:#?}: {}", - cipher.name(), - describe(own.iter().rev().take(3)), - ); + if !foreign_write { + let own = dump_native(&path, &case.written); + assert!( + clean_read(&own), + "SQLite3MC corrupted its own {} shared-cache database file for {case:#?}: {}", + cipher.name(), + describe(own.iter().rev().take(3)), + ); + } remove(&path); }