Skip to content

fix(digstore-guest): fail closed when host RNG errors during oblivious gather - #69

Merged
MichaelTaylor3d merged 2 commits into
mainfrom
fix/2714-guest-rng-fail-closed
Sep 6, 2026
Merged

MichaelTaylor3d merged 2 commits into
mainfrom
fix/2714-guest-rng-fail-closed

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Closes DIG-Network/dig_ecosystem#2714

What

digstore-guest's oblivious-gather cover-traffic planner (build_access_plan) took an
INFALLIBLE rand closure. Both production callers could therefore only cope with a
host_random_bytes failure by substituting an all-zero buffer:

host.random_bytes(c).unwrap_or_else(|_| vec![0u8; c as usize])

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 sweep
    comment 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-index
draw 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.2
attestation challenge) already propagated correctly via ? before this PR — it just isn't
reachable in this CLI today, since GateConfig::from_embedded hardcodes
require_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 or output_commitment the
guest 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_plan is now generic over an error type and takes a fallible
    rand: FnMut(u32) -> Result<Vec<u8>, E>, returning Result<AccessPlan, E>. Every internal
    draw (?) now propagates instead of swallowing.
  • 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
    (GatheredOutcome::Decoy / ProofOutcome::Decoy), never real content built from degraded
    randomness. This was the natural fit once the signature changed — no awkward propagation, no
    new error-response shape invented; serve_content/serve_proof already have exactly one
    place each that means "don't serve real content," and RNG failure now uses it.
  • Production call sites become one line: build_access_plan(&entry.chunk_indices, pool_size, |c| host.random_bytes(c)), since host.random_bytes already returns the exact
    Result<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:

Site Disposition
digstore-guest/src/content.rs:264 Fixed (this PR)
digstore-guest/src/proof.rs:98 Fixed (this PR — the ticket's sweep-comment site)
digstore-guest/src/content.rs:155 (attestation nonce) Already correct (?), pre-existing
digstore-chain/src/coinset.rs:150 (rand_u64, retry-backoff jitter) Reviewed, out of class — documented, reasoned fallback to zero jitter on "effectively impossible" failure; non-security value (retry timing only), not a key/nonce/access-pattern
digstore-cli/src/ops/store_ops.rs:1385 (random_seed, key/salt material) Already correct — .expect(...), deliberately NO weak fallback (own doc comment)
digstore-remote/src/identity.rs:54 (random_seed, identity key) Already correct — same .expect(...) convention
digstore-remote/src/client.rs (§21.9 auth nonce) Already fixed by #40, with its own dedicated mutation-defeating regression test (authed_propagates_an_entropy_failure_instead_of_signing_a_constant_nonce)
dig-node-core::BlindServeConfig::from_seed(store_id, &[0u8; 32]) Different repo (not present anywhere in digs) — already tracked and closed as dig_ecosystem#2735

No other site of this class found. #2553 (the parent fail-open sweep ticket) stays open for
its remaining hub.dig.net site, unaffected by this PR.

Tests (TDD, all four proven load-bearing by revert)

  1. oblivious.rs::first_draw_failure_is_propagated_not_swallowed — RNG fails on the very
    first draw (the cover-index fill) → Err, not a plan built from a placeholder.
  2. 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 the
    discriminating case for a fix that wires ? on one draw but not the other; a fixture that
    only ever fails on the first call cannot see that mistake.
  3. 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, never Real. Using a hit
    rather 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.
  4. proof.rs's sibling — same shape, asserting ProofOutcome::Decoy.

Revert-proof: committed the fix first, then temporarily reintroduced the exact original
defect inside build_access_plan (swallow-to-zeros, kept the new Result signature so the
surrounding callers/tests still compiled) and re-ran the four tests — all four failed for the
right reason (Ok(AccessPlan{...}) where Err(()) was expected; Real where Decoy was
expected). Restored via git restore (safe post-commit) and confirmed green again.

Blast radius (§2.0)

build_access_plan has exactly two production callers in the entire workspace
(gather_content, serve_proof) plus its own three test call sites — verified by exhaustive
grep, since gitnexus's impact/context/detect_changes all returned a defective result on
this repo's freshly-built (0-commits-behind) index: a Binder exception: Cannot find property id for n on impact/context, and a false changed_symbols: [] / partial: true on
detect_changes despite correctly counting 8 changed files. This is a tool defect, not
staleness (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-guest as a normal Rust dependency (digstore-guest is publish = false,
NC-7-exempt, excluded from the [workspace.dependencies] version-pin table; the only two
digstore_guest:: references outside its own crate are doc-comments in
digstore-compiler's tests, not compiled code). Risk: LOW — an internal, non-published
crate's private implementation function; the wasm ABI (host imports/exports) is unchanged.

Version + CI (all green, real counts below)

  • main was at 0.29.8; this PR bumps to 0.29.9 (all 12 occurrences: [workspace.package]
    • the 11 [workspace.dependencies] path-dep pins). scripts/check-workspace-dep-versions.sh
      passes. Cargo.lock regenerated 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 2111 passed, 0 skipped,
    including all four new/modified regression tests by name.
  • cargo nextest run --workspace --locked --retries 21586 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 --locked0 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.)
  • §3.5: guest wasm rebuilt against the real fix (cargo build -p digstore-guest --target wasm32-unknown-unknown --release --locked, exit 0) — note the FIRST guest-wasm build in this
    lane's history predated a self-caught mistake (below) and reflected pre-fix code; this rebuild
    supersedes it. digstore-cli reinstalled (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 own
    crates/digstore-cli/tests/*.rs resolve the CLI via assert_cmd::Command::cargo_bin, which
    cargo/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-confusion
mistake, 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 --porcelain empty there). Every build/test
result 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.

MichaelTaylor3d and others added 2 commits September 6, 2026 05:50
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 MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Every draw is fallible -- confirmed by reading oblivious.rs: both the cover-index draw and the shuffle draw use rand(...)?. 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) and shuffle_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.
  2. Decoy path is indistinguishable -- both callers fold Err(_) into the exact same Decoy branch 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.
  3. No time/counter-seeded replacement -- confirmed by grep: zero occurrences of unwrap_or_else(|_| vec![0...]) or any placeholder-buffer pattern anywhere in content.rs/oblivious.rs/proof.rs at this SHA. The only fallback behavior left is Err propagation.
  4. No missed third site -- grepped the full digstore-guest crate (production code only, tests excluded) for every call to host.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/panicked lines in the full log.
  • Confirmed both new regression tests exist and pass: hit_with_failing_host_rng_returns_decoy_not_real and proof_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 only MockHost.random_bytes_fails_with injected -- 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 returned Real here).
  • Confirmed MockHost.random_bytes_fails_with: Option<ErrorCode> is a real, correctly-wired scripting mechanism in tests/mock_host.rs, not a no-op.

Disclosures -- both checked, no action needed

  • Shared modules/apps/digs checkout: independently confirmed clean (git status --porcelain empty at review time; I made no changes to it myself -- I worked entirely from a fetched refs/pull/69/head ref and a disposable worktree under C:\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.

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 6, 2026 13:59
@MichaelTaylor3d
MichaelTaylor3d merged commit 7ee48b4 into main Sep 6, 2026
12 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the fix/2714-guest-rng-fail-closed branch September 6, 2026 14:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant