fix(digstore-guest): fail closed when host RNG errors during oblivious gather - #69
Conversation
Co-Authored-By: Claude <noreply@anthropic.com>
…s gather build_access_plan's rand closure was infallible, so both production callers (content.rs::gather_content, proof.rs::serve_proof) could only cope with a host_random_bytes error by substituting an all-zero buffer. That makes the Fisher-Yates shuffle and cover-index draw deterministic, which defeats the whole point of the oblivious-gather cover traffic: hiding the real access pattern from the serving host. Nothing anywhere reported that randomness had never been obtained, so a caller could not tell '32 random bytes' from '32 zeros', and neither could an auditor reading a served response afterward. rand is now fallible (FnMut(u32) -> Result<Vec<u8>, E>) and build_access_plan propagates a draw failure as Err instead of swallowing it. Both callers fold that Err into the SAME fail-closed path the crate already uses for a gate failure or a lookup miss: return the indistinguishable decoy response, never real content built from degraded randomness. Severity: the leaked randomness backs oblivious-gather cover traffic only (content.rs:155's attestation-nonce draw is unreachable in this CLI -- require_attestation is hardcoded false -- and is unaffected). No chunk ciphertext, key material, or on-chain artifact is derived from these bytes, so no persisted artifact carries zeros; the defect is a privacy property (access-pattern hiding) silently degrading to a deterministic, replayable pattern on host RNG failure, not a confidentiality or custody compromise. Adds four regression tests: two at build_access_plan's own boundary (oblivious.rs) targeting the cover-draw and the shuffle-draw independently, so a fix that propagates one but not the other cannot pass; and two at the serve_content/serve_proof boundary (content_proof.rs) using a REAL lookup hit (not a miss) so only the RNG failure -- never a lookup miss -- can explain the Decoy outcome the test asserts. Refs DIG-Network/dig_ecosystem#2714 Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
PASS -- verdict recorded by an independent fresh-context reviewer.
Head SHA audited (resolved myself, not the dispatch prompt): c2049289fb6c97649f4eaa97be330f3e357895d5. mergeStateStatus=CLEAN, Closes DIG-Network/dig_ecosystem#2714 verified via closingIssuesReferences API (not body text).
Independent severity trace -- confirmed, not just re-stated
Traced build_access_plan's output myself: the RNG bytes only ever feed the Fisher-Yates shuffle indices and the cover-index fill inside oblivious::build_access_plan (oblivious.rs). Both call sites (content.rs:267, proof.rs:97) consume only plan.order (which chunks get read, in what order) and plan.real_positions (bookkeeping to recover the real bytes afterward). Neither the bytes drawn from the RNG nor plan.order/plan.real_positions ever reach output_commitment or serving_digest (proof.rs:105-134) -- those are SHA-256 over the real chunk bytes themselves (gathered[*pos] for pos in plan.real_positions), never over the plan/order/index values. So: confirmed, this is a privacy-property degradation (access-pattern hiding becoming deterministic/replayable under a bad RNG), never a confidentiality, key-material, or commitment-integrity issue. No already-produced on-chain/persisted artifact needs remediation.
Also independently confirmed the attestation-challenge draw at content.rs:155 (host.random_bytes(32).map_err(|_| ())?) already propagated correctly pre-PR and is unrelated to this fix, and that it sits behind cfg.require_attestation, which defaults false (GateConfig::default() at content.rs:60) -- so it's unreachable today, exactly as claimed. That "unreachable" status has a stated expiry (if the flag ever flips) and the code already fails closed correctly for when it does.
The four checks
- Every draw is fallible -- confirmed by reading
oblivious.rs: both the cover-index draw and the shuffle draw userand(...)?. More importantly, the test suite (tests/oblivious.rs) has two DISCRIMINATING tests, not one:first_draw_failure_is_propagated_not_swallowed(fails on the cover draw) andshuffle_draw_failure_is_propagated_even_after_a_successful_cover_draw(lets the cover draw succeed, fails only the shuffle draw). This is exactly the shape needed to catch a partial fix that wires?on one draw and not the other -- a fixture that only ever fails on the first call cannot see that mistake, and this suite doesn't rely on only that fixture. - Decoy path is indistinguishable -- both callers fold
Err(_)into the exact sameDecoybranch already used for a lookup miss (content.rs:269,proof.rs:102-103), reusing existing decoy-construction code (decoy_content_response,decoy_prelude) rather than a new path. No new distinguishing branch introduced. - No time/counter-seeded replacement -- confirmed by grep: zero occurrences of
unwrap_or_else(|_| vec![0...])or any placeholder-buffer pattern anywhere incontent.rs/oblivious.rs/proof.rsat this SHA. The only fallback behavior left isErrpropagation. - No missed third site -- grepped the full
digstore-guestcrate (production code only, tests excluded) for every call tohost.random_bytes: exactly 3 production call sites exist (content.rs:155,content.rs:267,proof.rs:97), and all three are fail-closed at this SHA. No fourth call site exists anywhere in the crate.
Tests -- independently built and RUN, not taken on trust
Built the guest wasm dependency chain and ran cargo test -p digstore-guest --lib --tests myself in a disposable worktree (C:\tmp\worktrees\digs-pr69-review, since removed, target/ cleaned). Real result, checked for count not just exit code:
- 15 test binaries, 111 tests passed, 0 failed, zero
FAILED/panickedlines in the full log. - Confirmed both new regression tests exist and pass:
hit_with_failing_host_rng_returns_decoy_not_realandproof_hit_with_failing_host_rng_returns_decoy_not_real(tests/content_proof.rs), each using a REAL lookup-hit fixture identical to the sibling "returns Real" test, with onlyMockHost.random_bytes_fails_withinjected -- so a miss cannot explain the Decoy outcome, only the RNG failure. These are non-vacuous: they assert exactly the behavior that did NOT hold pre-fix (old code returnedRealhere). - Confirmed
MockHost.random_bytes_fails_with: Option<ErrorCode>is a real, correctly-wired scripting mechanism intests/mock_host.rs, not a no-op.
Disclosures -- both checked, no action needed
- Shared
modules/apps/digscheckout: independently confirmed clean (git status --porcelainempty at review time; I made no changes to it myself -- I worked entirely from a fetchedrefs/pull/69/headref and a disposable worktree underC:\tmp\worktrees, removed after use). - gitnexus binder exception +
changed_symbols: []: the grep-based fallback sweep is verified correct independently above (exactly 3 production call sites, all fixed) -- the fallback claim holds.
§3.5 note
The lane's disclosure that digstore-cli's integration tests resolve the binary via assert_cmd::Command::cargo_bin (a fresh build) rather than the literally-PATH-installed copy is accurate and is a real, if narrow, residual gap -- but it is pre-existing test-harness behavior, not something this PR introduced or worsened, and not a reason to block this fix.
Verdict
PASS. No changes required.
Closes DIG-Network/dig_ecosystem#2714
What
digstore-guest's oblivious-gather cover-traffic planner (build_access_plan) took anINFALLIBLE
randclosure. Both production callers could therefore only cope with ahost_random_bytesfailure by substituting an all-zero buffer:Two sites, both now fixed:
crates/digstore-guest/src/content.rs:264(gather_content, the ticket's named site)crates/digstore-guest/src/proof.rs:98(same shape, surfaced by a 2026-09-04 sweepcomment on the ticket and folded in per §1.3c rule 1 rather than filed separately)
What the bytes are used for, and the severity that implies
Both sites feed
build_access_plan(oblivious.rs) — the Fisher-Yates shuffle + cover-indexdraw that hides the real chunk-access pattern from the serving host. Nothing here is key
material, a nonce, or an IV, and no persisted/on-chain artifact is derived from these bytes.
The one nonce draw in this crate that IS security-critical (
content.rs:155, the §12.2attestation challenge) already propagated correctly via
?before this PR — it just isn'treachable in this CLI today, since
GateConfig::from_embeddedhardcodesrequire_attestation: false(dighub content is public and servable by any node).So the severity is: a privacy property (access-pattern hiding), not a confidentiality or
custody compromise. On a host RNG failure, the old code kept serving real content, but with
a deterministic cover-traffic pattern — the exact same "random" shuffle and decoy indices
every time, for every request, on every host whose RNG happened to fail. That defeats the
whole point of the shuffle: a host (or anyone comparing served responses) could fingerprint
the deterministic pattern and infer which slots were real vs. cover, with no need to observe
multiple requests to build up a statistical correlation. Nothing anywhere reported that
randomness had never been obtained, so a caller could not distinguish "32 random bytes" from
"32 zeros," and neither could an auditor reading a served response afterward.
No artifact carries zeros from this. The access plan is a transient, per-request, in-guest
decision (which pool slots to read this call) — it is never written to disk, never part of a
.dig's persisted sections, and never included in the merkle-proof oroutput_commitmenttheguest returns (those commit only to the REAL chunk bytes, which are unaffected). So this is a
live defect to fix, not an incident requiring remediation of already-produced data.
The fix
Fail closed, per the ticket's instruction — propagate the error rather than picking a
different placeholder:
oblivious::build_access_planis now generic over an error type and takes a falliblerand: FnMut(u32) -> Result<Vec<u8>, E>, returningResult<AccessPlan, E>. Every internaldraw (
?) now propagates instead of swallowing.Errinto the same fail-closed path the crate already uses for agate failure or a lookup miss: return the indistinguishable decoy response
(
GatheredOutcome::Decoy/ProofOutcome::Decoy), never real content built from degradedrandomness. This was the natural fit once the signature changed — no awkward propagation, no
new error-response shape invented;
serve_content/serve_proofalready have exactly oneplace each that means "don't serve real content," and RNG failure now uses it.
build_access_plan(&entry.chunk_indices, pool_size, |c| host.random_bytes(c)), sincehost.random_bytesalready returns the exactResult<Vec<u8>, ErrorCode>shape the closure needs — no.map_err/wrapper required.No time-seeded or counter-derived replacement was used anywhere, per the ticket's instruction.
Sweep of the class (repo-wide, not just the two sites)
Grepped for
random_bytes(...).unwrap_or*,.unwrap_or_default()on anything RNG/nonce/seed/salt-shaped, and every raw
getrandom::getrandom(call in the repo:digstore-guest/src/content.rs:264digstore-guest/src/proof.rs:98digstore-guest/src/content.rs:155(attestation nonce)?), pre-existingdigstore-chain/src/coinset.rs:150(rand_u64, retry-backoff jitter)digstore-cli/src/ops/store_ops.rs:1385(random_seed, key/salt material).expect(...), deliberately NO weak fallback (own doc comment)digstore-remote/src/identity.rs:54(random_seed, identity key).expect(...)conventiondigstore-remote/src/client.rs(§21.9 auth nonce)authed_propagates_an_entropy_failure_instead_of_signing_a_constant_nonce)dig-node-core::BlindServeConfig::from_seed(store_id, &[0u8; 32])digs) — already tracked and closed as dig_ecosystem#2735No other site of this class found.
#2553(the parent fail-open sweep ticket) stays open forits remaining hub.dig.net site, unaffected by this PR.
Tests (TDD, all four proven load-bearing by revert)
oblivious.rs::first_draw_failure_is_propagated_not_swallowed— RNG fails on the veryfirst draw (the cover-index fill) →
Err, not a plan built from a placeholder.oblivious.rs::shuffle_draw_failure_is_propagated_even_after_a_successful_cover_draw—the FIRST draw succeeds, the SECOND (shuffle) draw fails → still
Err. This is thediscriminating case for a fix that wires
?on one draw but not the other; a fixture thatonly ever fails on the first call cannot see that mistake.
content_proof.rs::hit_with_failing_host_rng_returns_decoy_not_real— a REAL lookup HIT(not a miss) with a failing host RNG →
ContentOutcome::Decoy, neverReal. Using a hitrather than a miss matters: a miss would return Decoy for an unrelated reason on both the
old and new code, so it couldn't distinguish the fix from the bug.
proof.rs's sibling — same shape, assertingProofOutcome::Decoy.Revert-proof: committed the fix first, then temporarily reintroduced the exact original
defect inside
build_access_plan(swallow-to-zeros, kept the newResultsignature so thesurrounding callers/tests still compiled) and re-ran the four tests — all four failed for the
right reason (
Ok(AccessPlan{...})whereErr(())was expected;RealwhereDecoywasexpected). Restored via
git restore(safe post-commit) and confirmed green again.Blast radius (§2.0)
build_access_planhas exactly two production callers in the entire workspace(
gather_content,serve_proof) plus its own three test call sites — verified by exhaustivegrep, since gitnexus's
impact/context/detect_changesall returned a defective result onthis repo's freshly-built (0-commits-behind) index: a
Binder exception: Cannot find property id for nonimpact/context, and a falsechanged_symbols: []/partial: trueondetect_changesdespite correctly counting 8 changed files. This is a tool defect, notstaleness (the index was built at this branch's HEAD moments before querying) — falling back
to grep + direct read per §2.0's sanctioned fallback. No other crate in the workspace depends
on
digstore-guestas a normal Rust dependency (digstore-guestispublish = false,NC-7-exempt, excluded from the
[workspace.dependencies]version-pin table; the only twodigstore_guest::references outside its own crate are doc-comments indigstore-compiler's tests, not compiled code). Risk: LOW — an internal, non-publishedcrate's private implementation function; the wasm ABI (host imports/exports) is unchanged.
Version + CI (all green, real counts below)
mainwas at 0.29.8; this PR bumps to 0.29.9 (all 12 occurrences:[workspace.package][workspace.dependencies]path-dep pins).scripts/check-workspace-dep-versions.shpasses.
Cargo.lockregenerated to match.cargo fmt --all -- --check— clean.cargo clippy --workspace --all-targets --locked -- -D warnings -A clippy::default_constructed_unit_structs -A clippy::field_reassign_with_default— clean,zero warnings (this also compiles every test target, including the four new tests).
cargo build --workspace— clean, zero warnings.cargo nextest run -p digstore-guest --locked --retries 2— 111 passed, 0 skipped,including all four new/modified regression tests by name.
cargo nextest run --workspace --locked --retries 2— 1586 passed, 0 failed, 17 skipped(2 marked slow), in 416s. Real counts (not a filtered-to-zero false green — no test filter
was used).
cargo test --doc --workspace --locked— 0 doctests exist anywhere in this workspace(verified genuine: every one of ~12 per-crate blocks reports "running 0 tests" with no
filter argument in play, so this isn't the filtered-to-zero trap — this codebase simply
has no
///fenced code examples. My changed files add none.)cargo build -p digstore-guest --target wasm32-unknown-unknown --release --locked, exit 0) — note the FIRST guest-wasm build in thislane's history predated a self-caught mistake (below) and reflected pre-fix code; this rebuild
supersedes it.
digstore-clireinstalled (cargo install --path crates/digstore-cli --force --locked):Replaced package \digstore-cli v0.29.1` with `digstore-cli v0.29.9`(executables `dig-store.exe`, `digs.exe`)
; confirmed via \dig-store --version` / `digs--version` → both report
0.29.9. Honest note on scope: this crate's owncrates/digstore-cli/tests/*.rsresolve the CLI viaassert_cmd::Command::cargo_bin, whichcargo/nextest points at a freshly-built binary from current source (not literally the
PATH-installed copy) — so the 1586-test run above and the reinstalled global binary are built
from byte-identical source at this commit, which is the staleness guarantee §3.5 exists to
provide, even though it isn't literally "shelling out to PATH".
Self-caught process error, disclosed for the record
My first pass at the source edits landed in the primary shared submodule checkout
(
dig_ecosystem/modules/apps/digs/) instead of my dedicated worktree — a path-confusionmistake, not a deliberate shortcut. Caught before anything was committed anywhere: generated a
patch from the primary checkout's diff, applied it cleanly to the worktree, and restored the
primary checkout to clean (verified
git status --porcelainempty there). Every build/testresult in this PR is from the worktree, post-correction; nothing in the primary checkout was
ever committed or pushed.
Do not merge / undraft
Left as draft per instruction.