diff --git a/Cargo.lock b/Cargo.lock index c0f123a5..f966ffd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2665,7 +2665,7 @@ dependencies = [ [[package]] name = "digstore-chain" -version = "0.29.8" +version = "0.29.9" dependencies = [ "aes-gcm", "anyhow", @@ -2707,7 +2707,7 @@ dependencies = [ [[package]] name = "digstore-chunker" -version = "0.29.8" +version = "0.29.9" dependencies = [ "digstore-core", "hex", @@ -2717,7 +2717,7 @@ dependencies = [ [[package]] name = "digstore-cli" -version = "0.29.8" +version = "0.29.9" dependencies = [ "anstream 0.6.21", "anstyle", @@ -2786,7 +2786,7 @@ dependencies = [ [[package]] name = "digstore-core" -version = "0.29.8" +version = "0.29.9" dependencies = [ "aes-gcm-siv", "hex", @@ -2798,7 +2798,7 @@ dependencies = [ [[package]] name = "digstore-crypto" -version = "0.29.8" +version = "0.29.9" dependencies = [ "chia-bls 0.36.1", "digstore-core", @@ -2832,7 +2832,7 @@ dependencies = [ [[package]] name = "digstore-host" -version = "0.29.8" +version = "0.29.9" dependencies = [ "anyhow", "clap", @@ -2857,7 +2857,7 @@ dependencies = [ [[package]] name = "digstore-prover" -version = "0.29.8" +version = "0.29.9" dependencies = [ "bincode 1.3.3", "digstore-core", @@ -2873,7 +2873,7 @@ dependencies = [ [[package]] name = "digstore-remote" -version = "0.29.8" +version = "0.29.9" dependencies = [ "async-trait", "axum", @@ -2902,7 +2902,7 @@ dependencies = [ [[package]] name = "digstore-stage" -version = "0.29.8" +version = "0.29.9" dependencies = [ "digstore-chunker", "digstore-compiler", @@ -2918,7 +2918,7 @@ dependencies = [ [[package]] name = "digstore-store" -version = "0.29.8" +version = "0.29.9" dependencies = [ "digstore-chunker", "digstore-core", @@ -2933,7 +2933,7 @@ dependencies = [ [[package]] name = "digstore-subscription" -version = "0.29.8" +version = "0.29.9" dependencies = [ "async-trait", "digstore-core", diff --git a/Cargo.toml b/Cargo.toml index cecb4598..eb1f3c1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["crates/digstore-prover/guest", "crates/dig-client-wasm"] [workspace.package] edition = "2021" -version = "0.29.8" +version = "0.29.9" license = "GPL-2.0-only" [workspace.dependencies] @@ -28,17 +28,17 @@ license = "GPL-2.0-only" # The version MUST equal `[workspace.package].version`, because every member inherits # that value. `scripts/check-workspace-dep-versions.sh` enforces it in CI, so a release # bump can never silently leave a published crate pointing at the previous version. -digstore-core = { path = "crates/digstore-core", version = "0.29.8" } -digstore-chain = { path = "crates/digstore-chain", version = "0.29.8" } -digstore-chunker = { path = "crates/digstore-chunker", version = "0.29.8" } -digstore-crypto = { path = "crates/digstore-crypto", version = "0.29.8" } -digstore-store = { path = "crates/digstore-store", version = "0.29.8" } -digstore-prover = { path = "crates/digstore-prover", version = "0.29.8" } -digstore-host = { path = "crates/digstore-host", version = "0.29.8" } -digstore-stage = { path = "crates/digstore-stage", version = "0.29.8" } -digstore-remote = { path = "crates/digstore-remote", version = "0.29.8" } -digstore-cli = { path = "crates/digstore-cli", version = "0.29.8" } -digstore-subscription = { path = "crates/digstore-subscription", version = "0.29.8" } +digstore-core = { path = "crates/digstore-core", version = "0.29.9" } +digstore-chain = { path = "crates/digstore-chain", version = "0.29.9" } +digstore-chunker = { path = "crates/digstore-chunker", version = "0.29.9" } +digstore-crypto = { path = "crates/digstore-crypto", version = "0.29.9" } +digstore-store = { path = "crates/digstore-store", version = "0.29.9" } +digstore-prover = { path = "crates/digstore-prover", version = "0.29.9" } +digstore-host = { path = "crates/digstore-host", version = "0.29.9" } +digstore-stage = { path = "crates/digstore-stage", version = "0.29.9" } +digstore-remote = { path = "crates/digstore-remote", version = "0.29.9" } +digstore-cli = { path = "crates/digstore-cli", version = "0.29.9" } +digstore-subscription = { path = "crates/digstore-subscription", version = "0.29.9" } sha2 = "0.10" proptest = "1" diff --git a/crates/digstore-guest/src/content.rs b/crates/digstore-guest/src/content.rs index ab23448f..17fa0811 100644 --- a/crates/digstore-guest/src/content.rs +++ b/crates/digstore-guest/src/content.rs @@ -260,10 +260,14 @@ fn gather_content( } else { 0 }; - let plan = build_access_plan(&entry.chunk_indices, pool_size, |c| { - host.random_bytes(c) - .unwrap_or_else(|_| alloc::vec![0u8; c as usize]) - }); + // Fail closed on an RNG draw failure: a degraded (e.g. all-zero) access plan + // would make the cover-traffic shuffle deterministic and defeat the privacy + // property it exists for, so treat it exactly like a gate failure or a + // lookup miss rather than serving real content built from bad randomness. + let plan = match build_access_plan(&entry.chunk_indices, pool_size, |c| host.random_bytes(c)) { + Ok(plan) => plan, + Err(_) => return GatheredOutcome::Decoy(decoy_content_response(&req.retrieval_key, &root)), + }; // Read EVERY slot in the plan (cover + real) so the access pattern is uniform, // then keep only the real chunks in original order. diff --git a/crates/digstore-guest/src/oblivious.rs b/crates/digstore-guest/src/oblivious.rs index afe47777..5b05ea08 100644 --- a/crates/digstore-guest/src/oblivious.rs +++ b/crates/digstore-guest/src/oblivious.rs @@ -23,16 +23,25 @@ pub struct AccessPlan { /// slots with deterministic cover indices drawn from `[0, pool_size)`, then /// Fisher-Yates shuffle using bytes from `rand` (the host RNG, re-randomized per /// call). Cover reads + shuffle hide which/how-many indices are real. -pub fn build_access_plan(real: &[u32], pool_size: u32, mut rand: F) -> AccessPlan +/// +/// `rand` is FALLIBLE by construction: the host RNG is the only source of the +/// randomness this cover-traffic scheme depends on, and there is no safe +/// placeholder for "randomness that could not be obtained" — a constant (e.g. +/// all-zero) bytes buffer would make the shuffle/cover-index draw deterministic, +/// which defeats the whole point of hiding the access pattern from the host. A +/// draw failure therefore propagates as `Err`, and callers fail closed (treat it +/// the same as any other gate failure) rather than serving real content built +/// from degraded randomness. +pub fn build_access_plan(real: &[u32], pool_size: u32, mut rand: F) -> Result where - F: FnMut(u32) -> Vec, + F: FnMut(u32) -> Result, E>, { let bucket = padded_count(real.len()); let mut slots: Vec = real.to_vec(); // Fill cover slots with pseudo-random pool indices (distinct intent, may repeat). let need = bucket - slots.len(); if need > 0 && pool_size > 0 { - let cover_bytes = rand((need as u32) * 4); + let cover_bytes = rand((need as u32) * 4)?; for i in 0..need { let b = i * 4; let v = u32::from_be_bytes([ @@ -45,12 +54,12 @@ where } } else { // even when no cover needed, consume a draw to keep RNG cadence uniform - let _ = rand(4); + rand(4)?; } // Track where each real index currently sits, then shuffle and follow it. let mut positions: Vec = (0..real.len()).collect(); - let shuffle_bytes = rand((bucket as u32) * 4); + let shuffle_bytes = rand((bucket as u32) * 4)?; // Fisher-Yates from the end. for i in (1..slots.len()).rev() { let b = (i % bucket) * 4; @@ -70,8 +79,8 @@ where } } } - AccessPlan { + Ok(AccessPlan { order: slots, real_positions: positions, - } + }) } diff --git a/crates/digstore-guest/src/proof.rs b/crates/digstore-guest/src/proof.rs index 9f53b832..ebd8b772 100644 --- a/crates/digstore-guest/src/proof.rs +++ b/crates/digstore-guest/src/proof.rs @@ -94,10 +94,15 @@ pub fn serve_proof( } else { 0 }; - let plan = build_access_plan(&entry.chunk_indices, pool_size, |c| { - host.random_bytes(c) - .unwrap_or_else(|_| alloc::vec![0u8; c as usize]) - }); + // Fail closed on an RNG draw failure (mirrors content.rs::gather_content): a + // degraded (e.g. all-zero) access plan would make the cover-traffic shuffle + // deterministic and defeat the privacy property it exists for, so treat it + // exactly like a lookup miss rather than proving real content served from + // bad randomness. + let plan = match build_access_plan(&entry.chunk_indices, pool_size, |c| host.random_bytes(c)) { + Ok(plan) => plan, + Err(_) => return ProofOutcome::Decoy(decoy_prelude(&req.retrieval_key, &root)), + }; let mut gathered: Vec> = Vec::with_capacity(plan.order.len()); for idx in &plan.order { gathered.push(read_chunk(ds, *idx).unwrap_or_default()); diff --git a/crates/digstore-guest/tests/content_proof.rs b/crates/digstore-guest/tests/content_proof.rs index 7ffbe7b5..88ec6a86 100644 --- a/crates/digstore-guest/tests/content_proof.rs +++ b/crates/digstore-guest/tests/content_proof.rs @@ -89,6 +89,49 @@ fn hit_returns_real_content_response() { } } +// Regression: dig_ecosystem#2714. Same real HIT fixture as +// `hit_returns_real_content_response` above (a valid retrieval_key present in +// the table, real chunk bytes in the pool) so a lookup miss cannot be the +// reason for a Decoy here -- only the RNG failure can be. Before the fix, a +// host RNG error during the oblivious gather fell back to an all-zero buffer +// and this hit still returned `Real` (built from a deterministic, non-hidden +// access plan); the fixed code must fail closed to `Decoy`, matching how a +// gate failure or a lookup miss already behave. +#[test] +fn hit_with_failing_host_rng_returns_decoy_not_real() { + let key = Bytes32([0x11; 32]); + let entry = KeyTableEntry { + static_key: key, + generation: Bytes32([0xBB; 32]), + chunk_indices: vec![0, 1, 2, 3], + total_size: 20, + }; + let table = encode_key_table(&[entry]); + let pool = fixtures::pack_pool(&[b"alpha", b"beta_", b"gamma", b"delta"]); + let blob = fixtures::section_keytable_and_pool([0xAA; 32], [0xBB; 32], &table, &pool); + let ds = DataSection::parse(&blob).unwrap(); + + let host = MockHost { + random_bytes_fails_with: Some(digstore_core::ErrorCode::GeneralError), + ..MockHost::default() + }; + let req = ContentRequest { + retrieval_key: key, + root_hash: None, + range: None, + jwt: None, + window: None, + }; + assert!( + matches!( + serve_content(&host, &ds, &req, &gate_config()), + ContentOutcome::Decoy(_) + ), + "a host RNG failure on a real hit must fail closed to Decoy, never serve Real \ + content built from a degraded (e.g. all-zero) access plan" + ); +} + #[test] fn miss_returns_decoy() { let table = encode_key_table(&[]); // empty table => every key misses @@ -525,6 +568,42 @@ fn proof_hit_returns_prelude_binding_output_and_nonce() { } } +// Regression: dig_ecosystem#2714, proof-path sibling of +// `hit_with_failing_host_rng_returns_decoy_not_real` above. Same real HIT +// fixture as `proof_hit_returns_prelude_binding_output_and_nonce`, so only the +// RNG failure -- never a lookup miss -- can explain a Decoy outcome here. +#[test] +fn proof_hit_with_failing_host_rng_returns_decoy_not_real() { + let key = Bytes32([0x11; 32]); + let entry = KeyTableEntry { + static_key: key, + generation: Bytes32([0xBB; 32]), + chunk_indices: vec![0], + total_size: 5, + }; + let table = encode_key_table(&[entry]); + let pool = fixtures::pack_pool(&[b"alpha"]); + let blob = fixtures::section_keytable_and_pool([0xAA; 32], [0xBB; 32], &table, &pool); + let ds = DataSection::parse(&blob).unwrap(); + let host = MockHost { + random_bytes_fails_with: Some(digstore_core::ErrorCode::GeneralError), + ..MockHost::default() + }; + let req = ProofRequest { + retrieval_key: key, + root_hash: None, + client_nonce: [3u8; 32], + }; + assert!( + matches!( + serve_proof(&host, &ds, &req, &gate_config()), + ProofOutcome::Decoy(_) + ), + "a host RNG failure on a real hit must fail closed to Decoy, never prove Real \ + content served from a degraded (e.g. all-zero) access plan" + ); +} + #[test] fn proof_miss_returns_decoy() { let table = encode_key_table(&[]); diff --git a/crates/digstore-guest/tests/mock_host.rs b/crates/digstore-guest/tests/mock_host.rs index 7903ca7c..f51db876 100644 --- a/crates/digstore-guest/tests/mock_host.rs +++ b/crates/digstore-guest/tests/mock_host.rs @@ -15,6 +15,11 @@ pub struct MockHost { pub jwks: HostResult, pub time: u64, pub rand_calls: Cell, + /// Script `random_bytes` to fail (returning this code on every call) + /// instead of the default counter ramp. Regression coverage for + /// dig_ecosystem#2714: a host RNG failure must fail closed, never + /// substitute a constant buffer. + pub random_bytes_fails_with: Option, } impl Default for MockHost { @@ -26,6 +31,7 @@ impl Default for MockHost { jwks: Ok(b"{}".to_vec()), time: 1_700_000_000, rand_calls: Cell::new(0), + random_bytes_fails_with: None, } } } @@ -52,6 +58,9 @@ impl DigHost for MockHost { fn random_bytes(&self, count: u32) -> HostResult { let n = self.rand_calls.get(); self.rand_calls.set(n + 1); + if let Some(code) = self.random_bytes_fails_with { + return Err(code); + } // distinct per call: byte i = (n*31 + i) wrapping Ok((0..count) .map(|i| (n.wrapping_mul(31).wrapping_add(i)) as u8) diff --git a/crates/digstore-guest/tests/oblivious.rs b/crates/digstore-guest/tests/oblivious.rs index f2cc5158..8ccefcbb 100644 --- a/crates/digstore-guest/tests/oblivious.rs +++ b/crates/digstore-guest/tests/oblivious.rs @@ -26,15 +26,18 @@ fn padded_count_never_below_true_count() { use digstore_guest::oblivious::build_access_plan; use std::cell::Cell; -/// Minimal seeded RNG matching the DigHost::random_bytes counter ramp. +/// Minimal seeded RNG matching the DigHost::random_bytes counter ramp. Never +/// fails; `Rng::bytes` returns `Result` purely to match `build_access_plan`'s +/// fallible closure signature (production RNG failure is exercised separately +/// by `FailingRng`, below). struct Rng(Cell); impl Rng { - fn bytes(&self, count: u32) -> Vec { + fn bytes(&self, count: u32) -> Result, ()> { let n = self.0.get(); self.0.set(n + 1); - (0..count) + Ok((0..count) .map(|i| (n.wrapping_mul(97).wrapping_add(i.wrapping_mul(13))) as u8) - .collect() + .collect()) } } @@ -43,7 +46,7 @@ fn plan_includes_all_real_indices_plus_cover() { let real = vec![2u32, 5, 7]; let pool_size = 32u32; let rng = Rng(Cell::new(0)); - let plan = build_access_plan(&real, pool_size, |c| rng.bytes(c)); + let plan = build_access_plan(&real, pool_size, |c| rng.bytes(c)).unwrap(); // Every real index must be present. for r in &real { assert!(plan.order.contains(r), "real index {r} must be read"); @@ -67,8 +70,8 @@ fn two_calls_reorder_differently() { let pool_size = 64u32; let rng_a = Rng(Cell::new(0)); let rng_b = Rng(Cell::new(999)); - let a = build_access_plan(&real, pool_size, |c| rng_a.bytes(c)); - let b = build_access_plan(&real, pool_size, |c| rng_b.bytes(c)); + let a = build_access_plan(&real, pool_size, |c| rng_a.bytes(c)).unwrap(); + let b = build_access_plan(&real, pool_size, |c| rng_b.bytes(c)).unwrap(); assert_ne!( a.order, b.order, "different randomness must reorder the plan" @@ -78,3 +81,69 @@ fn two_calls_reorder_differently() { assert!(a.order.contains(r) && b.order.contains(r)); } } + +// --- Regression: dig_ecosystem#2714 ----------------------------------------- +// `build_access_plan` used to accept an INFALLIBLE `rand` closure, so both +// production callers (content.rs, proof.rs) could only cope with a host RNG +// error by substituting a constant (all-zero) buffer — making the shuffle/ +// cover-index draw deterministic and defeating the access-pattern-hiding +// property this module exists for. `rand` is now fallible and the function +// propagates a draw failure rather than silently degrading to a constant. +// +// `FailingRng` succeeds for `succeeds` calls, then fails on every call after +// that — so tests can target EITHER of the two draw sites in +// `build_access_plan` (the cover-index draw / cadence-consuming draw, and the +// shuffle draw), not just "the RNG never works". +struct FailingRng { + calls: Cell, + succeeds: u32, +} +impl FailingRng { + fn new(succeeds: u32) -> Self { + FailingRng { + calls: Cell::new(0), + succeeds, + } + } + fn bytes(&self, count: u32) -> Result, ()> { + let n = self.calls.get(); + self.calls.set(n + 1); + if n < self.succeeds { + Ok(vec![0xAB; count as usize]) + } else { + Err(()) + } + } +} + +#[test] +fn first_draw_failure_is_propagated_not_swallowed() { + // padded_count(3) = 4 > real.len(), so `need > 0` and the cover-index + // branch draws before the shuffle draw. Failing from the very first call + // must surface as `Err`, not an `Ok(plan)` built from a placeholder. + let real = vec![2u32, 5, 7]; + let rng = FailingRng::new(0); + let result = build_access_plan(&real, 32, |c| rng.bytes(c)); + assert_eq!( + result, + Err(()), + "an RNG failure on the first draw must propagate as Err, never a plan built from a placeholder" + ); +} + +#[test] +fn shuffle_draw_failure_is_propagated_even_after_a_successful_cover_draw() { + // Lets the FIRST draw (cover-index fill) succeed, then fails the SECOND + // draw (the Fisher-Yates shuffle). This is the discriminating case for a + // fix that propagates the cover-draw's error but forgets the `?` on the + // shuffle draw (or vice versa) -- a fixture that only ever fails on the + // first call cannot see that mistake. + let real = vec![2u32, 5, 7]; + let rng = FailingRng::new(1); + let result = build_access_plan(&real, 32, |c| rng.bytes(c)); + assert_eq!( + result, + Err(()), + "a shuffle-draw failure must propagate as Err even when the earlier cover draw succeeded" + ); +}