Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 12 additions & 12 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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"
Expand Down
12 changes: 8 additions & 4 deletions crates/digstore-guest/src/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,10 +260,14 @@ fn gather_content<H: DigHost + ?Sized>(
} 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.
Expand Down
23 changes: 16 additions & 7 deletions crates/digstore-guest/src/oblivious.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<F>(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<F, E>(real: &[u32], pool_size: u32, mut rand: F) -> Result<AccessPlan, E>
where
F: FnMut(u32) -> Vec<u8>,
F: FnMut(u32) -> Result<Vec<u8>, E>,
{
let bucket = padded_count(real.len());
let mut slots: Vec<u32> = 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([
Expand All @@ -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<usize> = (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;
Expand All @@ -70,8 +79,8 @@ where
}
}
}
AccessPlan {
Ok(AccessPlan {
order: slots,
real_positions: positions,
}
})
}
13 changes: 9 additions & 4 deletions crates/digstore-guest/src/proof.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,15 @@ pub fn serve_proof<H: DigHost + ?Sized>(
} 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<u8>> = Vec::with_capacity(plan.order.len());
for idx in &plan.order {
gathered.push(read_chunk(ds, *idx).unwrap_or_default());
Expand Down
79 changes: 79 additions & 0 deletions crates/digstore-guest/tests/content_proof.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(&[]);
Expand Down
9 changes: 9 additions & 0 deletions crates/digstore-guest/tests/mock_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ pub struct MockHost {
pub jwks: HostResult,
pub time: u64,
pub rand_calls: Cell<u32>,
/// 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<ErrorCode>,
}

impl Default for MockHost {
Expand All @@ -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,
}
}
}
Expand All @@ -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)
Expand Down
Loading
Loading