Skip to content

fix: fail closed on a missing signing key, a pinned host RNG, and an unavailable CSPRNG - #40

Merged
MichaelTaylor3d merged 9 commits into
mainfrom
fix/2553-propagate-key-and-nonce-failures
Aug 11, 2026
Merged

fix: fail closed on a missing signing key, a pinned host RNG, and an unavailable CSPRNG#40
MichaelTaylor3d merged 9 commits into
mainfrom
fix/2553-propagate-key-and-nonce-failures

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Three fail-open sites in this repo, each a path where a security-relevant value silently fell back to a fixed or world-known default. All three now fail closed.

Refs DIG-Network/dig_ecosystem#2553 — deliberately NOT Closes. #2553 names four sites; the three fixed here are the ones that live in digs. The fourth, the fail-open phishing guard, is hub.dig.net/apps/web/lib/store-key.ts and stays queued in that repo.

Two additional sites of the same class turned up while working this and are NOT among #2553's four — each needs its own ticket and gate round: dig-node-core/src/lib.rs (BlindServeConfig::from_seed(store_id, &[0u8; 32])) and digstore-guest/src/content.rs:264 (host.random_bytes(c).unwrap_or_else(|_| vec![0u8; c]), which substitutes ZEROS for randomness).

What changed

1. World-known BLS signing key — crates/digstore-cli/src/ops/serve.rs, two sites.
store_ops::load_signing_key(ctx).unwrap_or_else(|_| BlsSecretKey::from_seed(&[42u8; 32])) in instantiate_host and serve_proof. That seed is reproducible by anyone reading this source, so a host missing its key attested — and signed execution proofs — under no identity at all. load_signing_key already returned Result<_, CliError>, so both are now a plain ?. Its io error also now names the file and the likely cause, because every caller is a serve path that must refuse to run: an operator seeing a bare io error would reasonably go looking for a content problem.

2. Pinned host RNG seed — same file, host_deps.
rng_seed: Some([99u8; 32]) was the ONLY non-test Some(...) in the repo; the other eight all live under tests/ and build their own HostDeps, so nothing depended on serve.rs's determinism. Now None, converging on digstore_host::serve_blind's host_deps — the only other non-test construction.

A correction to the ticket's rationale, and then a correction to THAT correction from the gate round: this RNG does not drive decoy content. digstore-guest/src/decoy.rs derives decoy size, bytes, and proof blob deterministically from the retrieval key (§14.2 by design), so decoys are byte-stable whatever the RNG does. What the RNG backs is host_random_bytes.

An earlier revision of this PR said host_random_bytes supplies the guest's §12 attestation challenge nonce, and that a constant seed therefore lets an attestation response be precomputed. That is false, and it has been removed from the code comments. digstore-guest/src/content.rs:55 hardcodes require_attestation: false — deliberately, per its doc comment, because dighub content is public and must be servable by ANY node. The nonce draw at content.rs:155 sits inside if cfg.require_attestation, and create_attestation has no other caller, so that branch cannot execute in any module this CLI compiles.

The only live consumer of the host RNG is therefore oblivious-access cover traffic (content.rs:264, proof.rs:98). At its real strength: the party the cover-traffic shuffle hides access patterns from is the HOST, and the host is what supplies this randomness — so this is convergence on the serve_blind.rs convention plus removal of a constant seed that had no business outside a test fixture, not the closing of a third-party attack. serve_blind.rs's own comment carries the original decoy inaccuracy and is getting its own ticket (out of scope here).

The same falsehood was also sitting above the load_signing_key call in instantiate_host (“otherwise it would (correctly) serve decoys”) — with attestation disabled the guest does not verify this host at all. Corrected. The key IS genuinely consumed by serve_proof (§13.7), which is now what the comment says.

3. §21.9 auth nonce fails open to all-zero — crates/digstore-remote/src/client.rs.
let _ = getrandom::getrandom(&mut nonce) discarded the CSPRNG error, so an RNG failure sent an all-zero nonce with a valid signature — every request then carries the same "unique" value and the replay protection is silently gone. Fixed by making the unsafe construction unexpressible: authed now returns Result<reqwest::RequestBuilder, ClientError> and propagates at all 10 call sites (fetch, roots, module ×2, delta ×2, push-init, push, push-complete, tombstone). New ClientError::Entropy(String); digstore-cli's map_remote_err maps it to a message that says the fault is on this machine, not the remote. Converges on identity.rs:53 and digstore-chain/src/seed.rs:107-108, which already propagate — that inconsistency was the finding. (getrandom major confirmed 0.2: getrandom::getrandom -> Result<(), Error>.)

How verified — TDD, red first, with mutation proofs

Every fix got a test written first and watched fail for the right reason, then the fallback was restored to confirm the test is load-bearing. All three mutation proofs came back RED.

Fix Test Red-first reason Mutation proof
1. signing key crates/digstore-cli/tests/serve_fails_closed.rs::serving_fails_closed_when_the_host_signing_key_is_missing failed with an Ok(decoy) — the fallback key served rather than erroring RED — restoring unwrap_or_else makes it fail
2. rng seed ops::serve::tests::the_host_instantiate_host_builds_draws_real_entropy function did not exist / seed was Some RED — restoring Some([99u8; 32]) makes it fail
3. nonce client::content_tests::auth_nonce_fails_closed_when_entropy_is_unavailable auth_nonce did not exist RED — restoring let _ = fill(...) fails with the literal all-zero nonce printed in the panic

Fix 1 ships with a control (serving_succeeds_while_the_host_signing_key_is_present) so the failure cannot be mistaken for a broken fixture, and asserts the error names the signing key rather than merely being an error.

Test suites: cargo test -p digstore-cli -p digstore-remote — all green (346 lib + 57 remote lib + every integration target). cargo fmt + cargo clippy --all-targets clean.

§3.5 installed-binary end-to-end (cargo install --path crates/digstore-cli --force --locked, then the real digs binary):

--- CONTROL: key present ---
hello fail-closed e2e
exit=0
--- remove signing key ---
error: cannot read the host signing key at C:\tmp\e2e2553b\.dig\stores\default\signing_key.bin
  (The system cannot find the file specified. (os error 2)) — the store may not have been
  initialized (`dig init`), or the key file was removed or is unreadable
exit=1

The full init -> add -> commit -> cat round trip works through the changed serve path, and removing the key produces the actionable error with a non-zero exit.

Blast radius checked

gitnexus was not used — ripgrep + direct reads instead (§2.0 fallback, stated as required). Enumerated:

  • authed — 10 call sites, all inside client.rs; the function is private, so the radius is that file plus anything matching ClientError exhaustively. The only such matcher is digstore-cli/src/ops/remote_ops.rs::map_remote_err, found by the compiler refusing to build.
  • load_signing_keypub(crate); callers are the two serve.rs sites plus existing store_ops paths, none of which had a fallback.
  • rng_seed — 11 occurrences repo-wide; one non-test (serve.rs:111, changed), eight under tests/ constructing their own HostDeps, plus the field definition and the runtime.rs consumer.

§5.1: no format or ABI change. No .dig bytes, no section id, no guest-wasm export touched.

SemVer: 0.23.1 -> 0.24.0 (minor)

ClientError is public and not #[non_exhaustive], so gaining a variant breaks any exhaustive downstream match — proven in-repo, since map_remote_err failed to compile until it handled the new arm. That is a breaking change; under SemVer for 0.x a breaking change is a minor bump. authed itself is private, so its signature change is invisible externally.

Not changed, as instructed

BlindServeDeps::mock — grepped for non-test callers as asked. Inside digs the only one is serve_blind.rs:215 (serve_blind() itself), reached from crates/digstore-host/src/bin/dighost.rs:192, a diagnostic host binary in this repo. No hub.dig.net call path exists inside digs, so that half of the ticket's claim remains unverified from here. Behaviour untouched.


Behaviour change this PR makes, stated plainly

Security flagged this and an earlier revision of this body did not acknowledge it. Keeping the fail-closed behaviour is a deliberate decision, accepted by all three gates, but it is a real user-visible change and must not be buried.

Because the read path never actually uses the host signing key (attestation is disabled, above), a store directory that has a module and generations but no signing_key.bin could be read before this PR and now refuses. Reachable ways to be in that state: restoring from a backup that skipped owner-only files, a CI artifact that carries the store but not its secret, a plain cp that dropped the 0600 file.

Affected commands: cat, checkout, dev, deploy preview, and store_status - all of which reach instantiate_host.

Why keep it anyway:

  • Both normal producers write the key - init_store and clone_from, via persist_host_identity.
  • git log -S 'signing_key.bin' confirms no released version ever produced a keyless store, so this is a recovery/copying scenario, not an upgrade path.
  • The alternative is a host serving under an identity that does not exist, which is the defect the ticket exists to remove.

serve_proof, by contrast, has zero behaviour change: it consumed this key already and merely reported the failure worse. There the fix is a pure diagnostics win.

The same now applies to trusted_keys.json (fix 4 below), for the same reason and with the same affected command set.


Gate-round follow-up pass

The triple gate returned correctness PASS, security PASS, adversarial NOT REFUTED - nothing below was merge-blocking. All of it makes the PR correct rather than merely acceptable.

4. load_host_pubkey fell back to an all-zero G1 - one line from the call this PR hardened.
store_ops::load_host_pubkey(ctx).unwrap_or(Bytes48([0u8; 48])) in serve_content_raw is the ticket's exact defect class: the same function refused one missing identity file while tolerating the other. Now ?. Security confirmed it already failed closed downstream (an all-zero G1 is in no module's embedded trusted set, digstore-guest/src/attestation.rs:105), so this is coherence and diagnostics, not a live hole - the ticket's thesis is that the fallback habit is the defect. load_trusted_keys's io error now names the file, because the section 3.5 run showed the new refusal reporting a bare os error 2 with no path.

5. A truncated signing_key.bin PANICKED instead of erroring.
SecretKey::from_seed is assert!(seed.len() >= 32) (chia-bls; this tree resolves 0.45.0, the ticket cited 0.22.0 - the assert is identical in both), so unvalidated file bytes turned a corrupt file into a process abort. A zero-length file from an interrupted init write reproduces it with no attacker, and the previous error text ("removed or is unreadable") described neither. Now validated as exactly 32 bytes, converging on the sibling read_signing_seed which has always done so. Bounded from both sides: 0/1/31 bytes and 64 bytes are rejected, exactly 32 is accepted - a >= 32 guard would silently derive a key from the first 32 bytes of an overlong file.

6. load_trusted_keys(..).unwrap_or_default() deliberately LEFT ALONE, with a comment so it is not re-litigated. An empty trusted set is the strictest set, not a permissive one: verify_node_attested rejects any proof whose signer is absent from it (NodeKeyNotAttested, digstore-prover/src/prover.rs:79). Converting it to ? would change nothing about safety.

The two original tests were call-site-blind; both are re-anchored

This is the most important item here. The adversarial gate built mutations that restore the vulnerability while every test stays green - the proofs were anchored to the edited line, not to the call site.

Mutation that previously SURVIVED Re-anchored test Observed result
Inline HostDeps { rng_seed: Some([99u8; 32]), .. } directly in instantiate_host and stop calling host_deps ops::serve::tests::the_host_instantiate_host_builds_draws_real_entropy - instantiates through the real function and asks the runtime what it was built with RED: the serve runtime must draw OS entropy; a pinned seed makes every host_random_bytes draw reproducible from this source file
auth_nonce(getrandom).unwrap_or([0u8; 32]) at the call site in authed client::content_tests::authed_propagates_an_entropy_failure_instead_of_signing_a_constant_nonce - drives authed itself under a failing CSPRNG RED: a request must not be stamped without a real nonce

Both mutations were actually applied, run, and reverted, not reasoned about.

The RNG one needed a seam: the host RNG is not observable through any export (the miss-path decoy is retrieval-key-derived, section 14.2, so it is byte-stable regardless), so HostRuntime gained an additive rng_is_deterministic() accessor recording whether HostDeps::rng_seed was Some. HostRuntime::new's signature is unchanged, so the 17 files that call it are unaffected. The nonce one gained authed_with(.., fill), with authed reduced to a one-line delegation so no second copy of the body exists for a mutation to hide in.

The two new fixes are load-bearing too - each proven by reverting ONLY its own fix

Fix Test Observed RED on revert
4. host pubkey ops::serve::tests::a_missing_trusted_key_file_refuses_to_serve a store with no host identity must refuse to serve: [0, 0, 0, 27, 201, ...] - i.e. with the fallback restored it served successfully under a nonexistent identity, which is itself the clearest confirmation that attestation is off
5. truncated key ops::store_ops::tests::a_truncated_signing_key_is_an_error_not_a_panic plus an_overlong_signing_key_is_rejected_rather_than_silently_truncated panicked at chia-bls-0.45.0/src/secret_key.rs:97: assertion failed: seed.len() >= 32 - the exact abort, reproduced

Each test carries a control on the honest path (an intact store serves; entropy-available stamps the request; exactly 32 bytes is accepted) so none of the refusals can be satisfied by a function that always fails.

Verification of this pass

  • cargo test --workspace - all green (350 digstore-cli lib, 59 digstore-remote lib, every integration target).
  • cargo fmt --all --check plus cargo clippy --workspace --all-targets -- -D warnings - clean.
  • Section 3.5 installed-binary round trip (cargo install --path crates/digstore-cli --force --locked, then the real digs binary): init -> add -> commit -> cat on live mainnet (store 8e0b6903..., mint confirmed at height 9133240, commit confirmed at 9133244) returned the exact plaintext. Both new refusals were then exercised through the installed binary:
=== trusted_keys.json missing ===
error: cannot read the store's trusted host keys at
  C:\tmp\e2e2553\.dig\stores\default\trusted_keys.json (The system cannot find the file
  specified. (os error 2)) - the store may not have been initialized (dig init), or the
  file was removed
EXIT=1

=== signing_key.bin zero-length ===
error: invalid argument: the host signing key at
  C:\tmp\e2e2553\.dig\stores\default\signing_key.bin is 0 bytes, not a 32-byte seed -
  the file is truncated or corrupt; re-create the store identity
EXIT=2

=== control (both restored) ===
hello from the 2553 integration check
EXIT=0

Case 2 is the one that previously aborted the process on an assertion.

Blast radius for this pass

gitnexus was not used; ripgrep plus direct reads (section 2.0 fallback, stated as required). HostRuntime::new - additive private field plus accessor, signature unchanged, so none of its 17 calling files are affected. authed - private, one call shape, now delegating. load_host_pubkey / load_signing_key / load_trusted_keys - pub(crate), callers enumerated (7 for the signing key), and the compiler confirms the propagation. Section 5.1 holds: no .dig byte, no section id, no guest-wasm ABI export changed.

Deliberately out of scope, each getting its own ticket

  • digstore-guest/src/content.rs:264 - host.random_bytes(c).unwrap_or_else(|_| vec![0u8; c]), a genuine fourth site of this class that the ticket never listed. It is in the no_std guest crate under the section 5.1 append-only guest-ABI rule and needs its own gate round.
  • The serve_blind.rs comment carrying the original decoy inaccuracy.
  • The missing server-side nonce replay cache in digstore-remote/src/server.rs.
  • The read path's requirement of a host key at all, as a design question.

Version stays 0.24.0: this pass adds no new public breaking surface beyond the ClientError variant already accounted for.

Comment thread crates/digstore-remote/src/client.rs
@MichaelTaylor3d MichaelTaylor3d changed the title fix(serve): propagate signing-key and nonce failures instead of falling back fix: fail closed on a missing signing key, a pinned host RNG, and an unavailable CSPRNG Aug 11, 2026

@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.

Correctness gate: PASS (head 0dd56f78)

Independent review, fresh context, read-only worktree. I re-ran and re-proved the evidence myself rather than taking the PR body on trust.

Verified independently

  • Mutation proof, fix 1 — REPRODUCED RED. Restoring unwrap_or_else(|_| BlsSecretKey::from_seed(&[42u8; 32])) at serve.rs makes serving_fails_closed_when_the_host_signing_key_is_missing fail with Ok(<decoy envelope bytes>), exactly the reported red shape, while the control serving_succeeds_while_the_host_signing_key_is_present stays green. The test discriminates for the right reason, and the msg.contains("signing key") assertion is pinned by the new load_signing_key error text. Worktree restored clean afterwards.
  • Mutation proof, fix 3 — REPRODUCED RED. Restoring let _ = fill(&mut nonce); fails auth_nonce_fails_closed_when_entropy_is_unavailable with the literal all-zero nonce.
  • No re-introduced fail-open across the authed -> Result change. All 10 call sites (client.rs 260, 274, 308, 360, 384, 476, 516, 578, 623, 651) use a plain ?; no unwrap_or(req), no .ok(), no let _ =. The anonymous/no-identity path still short-circuits with Ok(req) and draws no entropy.
  • getrandom major is 0.2 (digstore-remote/Cargo.toml:28), so getrandom::getrandom -> Result<(), Error> matches the auth_nonce seam and Error::UNSUPPORTED exists.
  • The implementer's correction to the ticket is CORRECT. digstore-guest/src/decoy.rs derives decoy size and bytes as ChaCha20(SHA-256(retrieval_key || tag)) — byte-stable regardless of the host RNG. So no behavioural test of fix 2 was available and the structural assertion is the honest instrument, not a shortcut.
  • The serve_proof reachability claim is CORRECT. serve_proof -> serve_content -> serve_content_raw -> instantiate_host, which ?s on the missing key first, so serve_proof's own ? is unreachable with the key absent. Untestable by construction; the change is still right.
  • Blast radius outside the two crates: none. ClientError is matched only in digstore-cli (remote_ops.rs, cat.rs, one remote integration test); digstore-remote has exactly one in-repo consumer; dig-client-wasm does not depend on it; serve.rs/store_ops are CLI-internal (pub(crate)). The unrun workspace suite is a low risk here.
  • cargo test -p digstore-remote --lib 57/57 green and --test serve_fails_closed 2/2 green, run by me on this head.
  • SemVer: minor is right. A public non-#[non_exhaustive] enum gaining a variant is breaking; 0.x breaking -> minor.
  • §5.1: no .dig byte, section id, or guest-wasm export touched.
  • Scope: proportionate to the binding B3 triage — three narrow fixes, no re-architecture. The auth_nonce seam is the minimum needed to make the failure testable, not over-engineering.

Not verified

  • Full cargo test --workspace (not run locally by either of us; carried by CI).

Two non-gating observations posted inline. Neither blocks merge; I am resolving both myself.


Non-gating observations

Posted here rather than as inline threads: both target lines are outside the diff, so GitHub rejects an anchored comment, and keeping them out of threads also avoids a non-gating comment silently barring merge under required_conversation_resolution.

1. crates/digstore-cli/src/ops/serve.rs:166 — Two lines above the fix, load_host_pubkey(ctx).unwrap_or(Bytes48([0u8; 48])) is the same fail-open shape this PR exists to remove: a missing host pubkey silently becomes an all-zero key. It is milder — a zero pubkey cannot match the secret, so the guest's attestation check rejects and the module serves decoys rather than forging an identity — which is why I am not gating on it. But the PR's own rationale ("surface the missing key instead of degrading into an anonymous host") applies verbatim, and the fix is one character.

Deliberately not handed to Copilot: adjacent to a security fix, cheap, and better done in a follow-up lane with its own red test than bolted onto a gate round in progress.

2. crates/digstore-host/src/serve_blind.rs:191-194 — I confirmed the implementer's finding from the code: digstore-guest/src/decoy.rs derives decoy size and bytes deterministically from the retrieval key (ChaCha20(SHA-256(retrieval_key || tag)), §14.2), so decoys are byte-stable whatever the host RNG does. This comment's claim that a predictable seed "would let an observer tell a decoy from real content" is therefore false, and it is the rationale the ticket inherited. The true reason to unpin is the §12 attestation challenge nonce and oblivious-access cover traffic — which is exactly what the new comment in ops/serve.rs says.

Leaving a wrong security rationale in-repo is how the next reader re-derives the wrong threat model, so it is worth a one-line sweep — but it is a comment, in a file this PR does not otherwise touch, and gating on it would be disproportionate to the B3 banding. Follow-up, not a blocker.

MichaelTaylor3d and others added 7 commits August 11, 2026 06:19
Co-Authored-By: Claude <noreply@anthropic.com>
The §21.9 per-request nonce was drawn with `let _ = getrandom(...)`, so a
CSPRNG failure sent an all-zero nonce alongside a valid signature — every
request would then carry the same 'unique' value and the replay protection the
nonce exists to provide would be silently gone.

`authed` now returns `Result<RequestBuilder, ClientError>` and propagates at
all ten call sites, which makes the unsafe construction unexpressible: an
authed request cannot be built without real entropy. Converges on
`identity.rs` and `digstore-chain`'s `seed.rs`, which already propagate.

Refs DIG-Network/dig_ecosystem#2553

Co-Authored-By: Claude <noreply@anthropic.com>
…ve RNG

`ops::serve` had two fail-open sites. A missing `signing_key.bin` fell back to
`BlsSecretKey::from_seed(&[42u8; 32])` at both `instantiate_host` and
`serve_proof` — a value anyone can reproduce from this source, so the host
attested and signed proofs under no identity at all. Both now propagate with
`?` (`load_signing_key` already returned `Result<_, CliError>`), and its io
error now names the file and the likely cause instead of a bare io message.

`host_deps` also pinned `rng_seed` to a constant in a non-test path. That RNG
backs `host_random_bytes`, which supplies the guest's §12 attestation challenge
nonce and its oblivious-access cover traffic; it is now `None`, converging on
`digstore_host::serve_blind`, the only other non-test construction.

Refs DIG-Network/dig_ecosystem#2553

Co-Authored-By: Claude <noreply@anthropic.com>
`ClientError` gains an `Entropy` variant. The enum is public and not
`#[non_exhaustive]`, so an exhaustive downstream `match` breaks — proven
in-repo: `digstore-cli`'s `map_remote_err` failed to compile until it handled
the new arm. Under SemVer for 0.x a breaking change is a MINOR bump, so
0.23.1 -> 0.24.0. No `.dig` format bytes and no guest-wasm ABI export change.

Refs DIG-Network/dig_ecosystem#2553

Co-Authored-By: Claude <noreply@anthropic.com>
Three defects the #2553 gate round surfaced, all the same class the ticket
was opened for: a fallback that is invisible at the call site.

* load_host_pubkey fell back to an all-zero G1 one line from the signing-key
  load that now refuses. It failed closed downstream (no zero key is in any
  module's trusted set), so the cost was a misattributed diagnosis, not a
  hole. Propagate it.
* load_signing_key handed unvalidated file bytes to SecretKey::from_seed,
  which asserts len >= 32 and therefore ABORTS the process on a truncated
  key. An init interrupted mid-write reproduces it with no attacker. Validate
  exactly 32 bytes, as the sibling read_signing_seed always has.
* Two comments were false. The serve RNG does not feed a §12 attestation
  nonce: digstore-guest's content path hardcodes require_attestation: false,
  so that branch is unreachable in any module this CLI compiles, and the
  guest does not reject an unattested host either. Say what is true.

Both new tests are re-anchored to the call site. The previous pair asserted
on helper return values and stayed green under mutations that restore the
vulnerability: inlining a pinned HostDeps in instantiate_host, and
unwrap_or([0u8; 32]) on the nonce draw. HostRuntime::rng_is_deterministic
(additive) lets a test ask the runtime what it was built with, and
authed_with injects the entropy source so the propagation, not just the
helper, is under test.

load_trusted_keys keeps unwrap_or_default deliberately: an empty trusted set
is the strictest set, and verify_node_attested rejects against it. Comment
added so it is not re-litigated.

Refs #2553

Co-Authored-By: Claude <noreply@anthropic.com>
The §3.5 installed-binary check showed the new fail-closed refusal reporting
a bare "The system cannot find the file specified. (os error 2)" with no path
and no subject. It fails closed correctly and tells an operator nothing,
which is the same diagnostics gap the pubkey fallback caused, one layer down.

The serve test now asserts the message names trusted_keys.json rather than
merely that the call failed — a refusal with an unreadable reason is only
half of what the fix claims.

Refs #2553

Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d force-pushed the fix/2553-propagate-key-and-nonce-failures branch from e387c26 to 5717168 Compare August 11, 2026 13:36
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

§3.5 installed-binary e2e — fail-closed proved end-to-end, with a before/after control

Rebased onto main (d2f5c12, includes #41). Version conflicts were version-only in
Cargo.toml + 6 Cargo.lock entries; all 6 version.workspace = true members moved together to
0.24.0 and cargo metadata --locked exits 0. The other 8 workspace members pin independent
versions and are untouched.

Built the guest wasm first, then cargo install --path crates/digstore-cli --force --locked.
Store built headlessly with digs new static-site + digs compileno chain, no wallet, no
spend
.

Before / after, on two real installed binaries

condition digstore 0.23.0 (pre-fix) digs 0.24.0 (this PR)
signing_key.bin deleted exit 0 — serves the content exit 1 — refuses, names the file

The old binary serving here is the defect in its most concrete form: with the store's identity
gone it fell back to BlsSecretKey::from_seed(&[42u8; 32]), a seed reproducible by anyone reading
the source, and served (and would sign execution proofs) under an identity that belongs to nobody.

The four break cases against digs 0.24.0

condition exit behaviour
signing_key.bin = 0 bytes 2 invalid argument: the host signing key at … is 0 bytes, not a 32-byte seed — no panic
signing_key.bin = 31 bytes 2 same, reports 31 bytes — no panic
signing_key.bin removed 1 cannot read the host signing key at … (os error 2)
trusted_keys.json removed 1 cannot read the store's trusted host keys at …

The two truncation cases matter beyond tidiness: SecretKey::from_seed is assert!(len >= 32), so
before the length check a truncated file (an init interrupted mid-write leaves exactly that)
aborted the process. A panic is the one failure an operator cannot act on. Confirmed no panic,
no RUST_BACKTRACE, no assertion text in any case.

Availability control — the thing a fail-closed change is most likely to break

An intact store served correctly before the break cases and again after restoring both
files (exit 0 both times). So the refusals are attributable to the missing identity, not to a
store that stopped working.

Local suite

cargo test -p digstore-cli --release: 409 passed, 0 failed, 1 ignored across 30 test
binaries. All five new tests executed by name (not merely compiled):
a_truncated_signing_key_is_an_error_not_a_panic,
an_overlong_signing_key_is_rejected_rather_than_silently_truncated,
an_exactly_32_byte_signing_key_is_accepted,
the_host_instantiate_host_builds_draws_real_entropy,
a_missing_trusted_key_file_refuses_to_serve.

Incidental observation, not a blocker

The first cat against a freshly compiled --dig-dir migrates the layout, moving
signing_key.bin / trusted_keys.json from <dig-dir>/ into <dig-dir>/stores/default/. Worth
knowing because a script holding a path to the pre-migration location breaks silently.

Full triple gate (correctness + security + adversarial refutation) is running; the PR stays DRAFT
until those verdicts return.

@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.

CORRECTNESS GATE: PASS — head 5717168.

Verified by RUNNING in an isolated target dir (CARGO_TARGET_DIR=/c/tmp/ct-digs2553-rev, guest wasm rebuilt first):

  • ops::serve::tests — 2 passed (the_host_instantiate_host_builds_draws_real_entropy, a_missing_trusted_key_file_refuses_to_serve)
  • tests/serve_fails_closed.rs — 2 passed (control + fail-closed)
  • ops::store_ops::tests signing-key length trio — 3 passed
  • digstore-remote --lib — 60 passed (incl. the 4 new nonce tests)
  • serve-adjacent regressions after rng_seed: None: adv_host_no_inspect, adv_self_serve, adv_delegated_host_key, ops_roundtrip — all green

(a) Availability — no regression found. Every writer of .dig/signing_key.bin writes exactly 32 bytes, and every store-creating path writes BOTH files in the same unit: init_store (store_ops.rs:139, :157), persist_host_identity used by clone (remote_ops.rs:490), and adopt_existing_store (store_ops.rs:240, :255). Both writes have existed since the initial full-CLI commit (bac22f0), so no store from an older digs lacks them. clone's deliberate re-key still persists both halves, so serve-only clones are unaffected (adv_delegated_host_key and ops_roundtrip confirm by running). The only reachable consumer of these loads is catserve_proof/serve_content_raw, which always operates on an init/clone/adopt'd dir. The one residual case is a hand-copied .dig directory with the secret omitted — .dig is gitignored wholesale and clone is the supported acquisition path, so this is not a supported flow; it is worth one line in the release notes, not a block.

(b) EXACT-32 is correct. It matches read_signing_seed (store_ops.rs:307), which has always required exactly 32, and every writer emits &[u8; 32]. No producer of >32 bytes exists.

(c) Test quality — non-vacuous. rng_is_deterministic is set from deps.rng_seed.is_some() on the line immediately preceding the match deps.rng_seed that chooses the RNG (digstore-host/src/runtime.rs:134-137), so the flag and the RNG choice read the same field two lines apart; a refactor that pins the RNG must edit the match, and the inlined-HostDeps mutation the doc-comment names would flip the flag to true and fail the test. The test is anchored at the real instantiate_host call site, not at host_deps, which is the property that matters. a_missing_trusted_key_file_refuses_to_serve cannot pass for the wrong reason: load_host_pubkey is the FIRST statement of serve_content_raw (serve.rs:189), it is the only reader of trusted_keys.json reached before it, the test carries an in-test control that the intact store serves, and it asserts the message names the file rather than merely that an error occurred. The remote-side pair is the right shape too: authed_with is driven for both the failing and the succeeding CSPRNG, so auth_nonce(..).unwrap_or([0u8;32]) at the call site fails.

(d) Yes, they execute in CI. Workflow CI (.github/workflows/ci.yml), job build & test (${{ matrix.os }}), step Test (nextest, flaky-aware): cargo nextest run --workspace --locked --retries 2 (line 117). --workspace covers both the #[cfg(test)] mod tests in digstore-cli's lib and the new tests/serve_fails_closed.rs integration target. I also ran each of them locally and observed them pass, so this is not inferred from the workflow alone.

(e) Readable-code holds — the new comments are WHY-only and unusually honest about scope (the rng_seed comment explicitly refuses to overclaim an attack it does not close). Coverage: this repo has no cargo llvm-cov gate on main (documented at ci.yml:108-116); that pre-existing gap is not this PR's to close, and the diff adds tests to every path it changes.

Non-gating notes (posted here, not as blocking threads):

  1. PR body arithmetic: "covers four sites; only these three live in digs" then names two more elsewhere — 3+2=5. Fix the count or the enumeration.
  2. PR body's verification table names ops::serve::tests::the_serve_host_draws_real_entropy_and_never_a_pinned_seed; the shipped test is the_host_instantiate_host_builds_draws_real_entropy. Stale row; also that name is grammatically broken and reads worse than the one it replaced.
  3. ClientError (digstore-remote/src/error.rs:52) is a public enum without #[non_exhaustive], so this additive variant is a breaking change for any external exhaustive matcher. The 0.23.2 → 0.24.0 bump is the correct 0.x answer; consider adding #[non_exhaustive] in this same breaking window so the next variant is not another one.
  4. dig-constants check: nothing in this diff is a shared cross-repo constant — the values REMOVED ([42u8;32], [99u8;32], the all-zero nonce/pubkey) were local fail-open defaults, and nothing here hardcodes a value dig-constants publishes. No action.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Security gate finding confirmed empirically: the "fails closed downstream" rationale is false

The security audit flagged the comment at crates/digstore-cli/src/ops/serve.rs:179-183 (repeated
at :387-392), which claims the old all-zero host pubkey "happens to fail closed downstream (no
zero key is ever in a module's embedded trusted set)".

That claim is false, and it is false for the reason stated two functions above it: the guest's
content path hardcodes require_attestation: false, so the host pubkey is never read on that path
and therefore never checked against any trusted set.

Measured on the previous released binary rather than argued:

binary condition result
digstore 0.23.0 trusted_keys.json deleted exit 0 — serves the content
digs 0.24.0 (this PR) trusted_keys.json deleted exit 1 — refuses, names the file

So the pre-fix behaviour was not a harmless misattribution that failed closed later. A store whose
host identity had gone missing served normally under an identity that does not exist. This PR's
fix is materially stronger than its own comment advertises.

Why it is worth correcting rather than leaving: as written, the comment gives a future maintainer a
documented justification for reverting serve.rs:184 to unwrap_or(Bytes48([0u8; 48])) on the
grounds that it is safe anyway. It is not. Both comments will be corrected in this PR.

Related, from the same audit and not a defect: the retained unwrap_or_default() on
trusted_node_keys at serve.rs:318 is genuinely safe. verify_node_attested
(digstore-prover/src/prover.rs:70-82) is defined once, has no is_empty() special case, is
overridden by no implementor, and its result is consumed with ? — an empty set rejects
everything. empty_attestation_trusted_set_is_rejected passes. The "do not fix this into a ?"
instruction is correct.

MichaelTaylor3d and others added 2 commits August 11, 2026 07:31
…ity requirement

The comments at serve.rs claimed the pre-fix all-zero host pubkey "happens to
fail closed downstream (no zero key is ever in a module's embedded trusted
set)". That is false. The guest's content path hardcodes
`require_attestation: false`, so the host pubkey is never read there and never
checked against any trusted set. Measured against the released 0.23.0 binary:
with `trusted_keys.json` deleted it exits 0 and serves the content under an
identity that exists nowhere.

The wrong version mattered more than a stale comment normally would: it read as
a documented licence to restore `unwrap_or(Bytes48([0u8; 48]))` on the grounds
that the fallback was safe anyway.

Also adds SPEC.md 13.6, which states the identity requirement normatively
(refuse and name the file; exactly 32 bytes; never substitute a default), so
the behaviour is specified rather than only tested.

Documentation only - no behaviour change.

Co-Authored-By: Claude <noreply@anthropic.com>
…claim

Two defects in the SPEC edit from the previous commit.

First, that edit dropped the "## 14. Client -> node resolution (the origin)"
header outright, so section 14's intro paragraph and 14.1 were left orphaned
under 13.6 — the node-resolution contract silently became part of the host
identity section. Header restored.

Second, 13.6 asserted "a host MUST NOT serve, attest, or sign without them".
The attest/sign/push half is right and stays. The serve half overreaches: the
guest content path hardcodes require_attestation false and never reads the host
public key, so an identity is not structurally required in order to READ
committed content. Writing a normative rule the implementation is expected to
reverse is worse than writing none, so 13.6 now states only what holds either
way — never substitute a placeholder, exactly 32 bytes, refuse to attest/sign/
push, and name the offending file wherever a path does load the identity.

Adds the positive form of the substitution ban: where a path genuinely needs no
identity, carry none rather than a fabricated one.

Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 11, 2026 15:08
@MichaelTaylor3d
MichaelTaylor3d merged commit 412fa27 into main Aug 11, 2026
9 of 10 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the fix/2553-propagate-key-and-nonce-failures branch August 11, 2026 15:09
MichaelTaylor3d added a commit that referenced this pull request Aug 11, 2026
…er load_host_pubkey

The identity-rename makes two banned tokens unreachable: `bls_public: Some(`
and `bls_secret: Some(` name fields that no longer exist, so the guard would
have passed by construction while appearing to protect the read path. Ban the
positions that are actually reachable under the new shape — `identity: Some(`
and `with_identity(`. `load_signing_key` stays deliberately absent:
`serve_proof` lives in this file and must keep calling it.

Correct that test's doc, which claimed the scan catches a re-added refusal. It
catches only the `load_host_pubkey` form; the `load_signing_key` form — PR
#40's exact shape — is covered by the behavioural
`a_store_with_no_identity_still_serves_committed_content` and its control
`serve_proof_still_refuses_without_a_signing_key`. Name those instead.

Make the scan's scope assumption loud: it keeps the prefix before the FIRST
`#[cfg(test)]`, so a new gated helper above the read path would silently
narrow it to text that no longer contains the code it polices. Assert the
expected occurrence count so that addition fails with an instruction.

Add a store_ops-level successor to the deleted
`a_missing_trusted_key_file_refuses_to_serve`: `load_host_pubkey` must error
both when `trusted_keys.json` is absent and when it holds an empty array. The
code is CORRECT today and the hypothetical regression fails closed downstream
(an all-zero 48 bytes is not a canonical G1 point), so this closes a hole in
the guards rather than a live defect.

Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d added a commit that referenced this pull request Aug 11, 2026
* chore(2712): open lane for anonymous read path

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(host): make the store identity representable as absent

HostDeps, HostKeys and AttestationBackend carry Option instead of a
substituted key, and UnavailableAttestationBackend refuses to attest rather
than signing under a borrowed one. HostKeys.bls_secret is removed: it had two
writes and no reads.

Refs DIG-Network/dig_ecosystem#2712

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(serve): carry no store identity on the read path

Reading committed content consumes no identity, so the read path no longer
loads one. A store whose signing_key.bin / trusted_keys.json is missing or
unreadable now serves its content instead of aborting, which also unbreaks
checkout, dev, deploy --preview and compute_status -- none of which has a
network ladder to fall through to.

serve_proof keeps its fail-closed load: signing IS attribution.

Refs DIG-Network/dig_ecosystem#2712

Co-Authored-By: Claude <noreply@anthropic.com>

* test(guest): an anonymous host cannot serve attestation-gated content

Proves the optional host identity is not a hole: where the content gate DOES
require attestation, a host holding no identity gets a Decoy. Widens the
SigningHost double with an anonymous mode mirroring digstore-host's
UnavailableAttestationBackend, and keeps the identified host as the control on
the same fixture.

Refs DIG-Network/dig_ecosystem#2712

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(spec): state that serving committed content consults no identity

SPEC 13.6 banned substituting an identity but never said what a path that does
not need one should do, which left "refuse the read" readable as the stricter
option. It is not stricter: it withholds content whose integrity does not
depend on the host, while leaving every signing path exactly as safe.

Also bumps digstore-host 0.2.0 -> 0.3.0 (breaking Rust API: HostDeps and
HostKeys identity fields became Option, AttestationBackend::public_key returns
Option) and the workspace 0.24.0 -> 0.25.0.

Refs DIG-Network/dig_ecosystem#2712

Co-Authored-By: Claude <noreply@anthropic.com>

* test(cli): pin `cat` reading without a store identity

Command-level twins of the ops::serve unit tests: `cat` serves after the
identity files are destroyed, and `cat --verify-proof` still refuses, naming
the missing file. The second is the control that keeps the relaxation scoped to
reads.

Refs DIG-Network/dig_ecosystem#2712

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(cli): classify an absent store identity as IDENTITY_UNAVAILABLE

The CliError variant existed but was constructed nowhere. Wire it into
load_signing_key so a store that cannot sign reports a stable code
(IDENTITY_UNAVAILABLE, exit 20) instead of the catch-all exit 1, and so a §6.2
machine consumer can branch on the class rather than on prose.

The corrupt-length branches stay InvalidArgument: a truncated or overlong key
is a different problem with a different remedy from an absent one.

Refs DIG-Network/dig_ecosystem#2712

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: gitignore the per-worktree GitNexus index

The index is a regenerable per-worktree artifact (52-299 MB when it builds) and
must never enter the tree. Two stub files from a failed analyze run had been
committed by a `git add -A`.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(serve): pin that serving survives a missing host key while signing fails closed

Salvaged from a lane that died at a session end with this uncommitted.
UNVERIFIED: not compiled, not run since the edits.

Covers the #2712 reversal of SPEC 13.6's 'MUST NOT serve' — reading is
anonymous and must not require a host identity, but a proof must still
refuse to be signed by the world-known fallback key.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(serve): derive the expected signer key without a crate-private loader

`store_ops::load_host_pubkey` is `pub(crate)`, so the integration test could not
name it and the crate failed to compile on both CI runners (E0603).

Re-derive the expected public key in the test from the seed bytes on disk instead
of widening the crate's public API. This is also the stronger oracle: reading the
expectation back through the crate's own loader would be circular, because a
loader that substituted a stand-in would hand the test the same stand-in the
signer used and the comparison would still pass. `from_seed` is a crypto
primitive rather than the code under test, so re-deriving through it is
independent of the loading path this suite exists to police.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(serve): use expect_err for the fail-closed signing assertion

`clippy::err_expect` is denied workspace-wide, so `.err().expect(..)` failed the
lint gate. This defect was latent behind the E0603 fixed in the previous commit:
compilation aborts at the first error, so CI reported only that one and this
never surfaced.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(host): derive has_host_public_key from installed state, not a deps mirror

The accessor recorded `deps.bls_public.is_some()` into a bool field at
construction and returned that, so it answered "what did the caller pass"
rather than "what did this runtime install".

That is the wrong question for the one job the accessor has. Every revert-proof
in the read path is built on it, and a substitution reintroduced INSIDE this
constructor — #2553's defect, one crate below the call site it is policing —
leaves the mirror `false` while the guest is handed a key-shaped value through
`host_get_public_key`. The guard would stay green through exactly the
regression it exists to catch.

Reading `store.data().host.keys.bls_public` observes the key the runtime
actually installed, so the guard now fails on that substitution.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(host)!: make a host identity whole-or-absent, and test the anonymous arm

`HostDeps.bls_secret`/`bls_public` were two independent `Option`s, so a
half-present identity was representable: `Some(secret)` + `None` public
silently discarded the secret and downgraded the host to anonymous, and
`None` + `Some(public)` advertised a key it could not sign for. Replace both
with `identity: Option<HostIdentity>`, a struct that derives its public half
from its secret — half-present and mismatched pairs are now unrepresentable.

`HostDeps` becomes `#[non_exhaustive]` with a `new` + `with_*` builder.
`new` yields an ANONYMOUS host, so an identity is acquired by asking for one
rather than by forgetting a field.

Test the `(None, None)` backend-selection arm on observable behaviour of a
real `HostRuntime`: an anonymous runtime answers `host_get_public_key` with
`NotFound` and refuses to attest. Substituting a `BlsAttestationBackend`
built from `from_seed(&[42u8; 32])` into that arm — #2553's world-known key,
one crate below the call site — left every existing test green, because
`teehook`'s test drives the backend in isolation and `has_host_public_key`
reads a field the arm does not set.

BREAKING CHANGE: `HostDeps` is `#[non_exhaustive]`; construct it with
`HostDeps::new(..)`. `bls_secret`/`bls_public` are replaced by `identity`.
`BlindServeConfig` is deliberately UNCHANGED and still requires both halves:
the network-facing blind-serve path must not become anonymizable.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(serve): re-point the source-scan ban at the live tokens, and cover load_host_pubkey

The identity-rename makes two banned tokens unreachable: `bls_public: Some(`
and `bls_secret: Some(` name fields that no longer exist, so the guard would
have passed by construction while appearing to protect the read path. Ban the
positions that are actually reachable under the new shape — `identity: Some(`
and `with_identity(`. `load_signing_key` stays deliberately absent:
`serve_proof` lives in this file and must keep calling it.

Correct that test's doc, which claimed the scan catches a re-added refusal. It
catches only the `load_host_pubkey` form; the `load_signing_key` form — PR
#40's exact shape — is covered by the behavioural
`a_store_with_no_identity_still_serves_committed_content` and its control
`serve_proof_still_refuses_without_a_signing_key`. Name those instead.

Make the scan's scope assumption loud: it keeps the prefix before the FIRST
`#[cfg(test)]`, so a new gated helper above the read path would silently
narrow it to text that no longer contains the code it polices. Assert the
expected occurrence count so that addition fails with an instruction.

Add a store_ops-level successor to the deleted
`a_missing_trusted_key_file_refuses_to_serve`: `load_host_pubkey` must error
both when `trusted_keys.json` is absent and when it holds an empty array. The
code is CORRECT today and the hypothetical regression fails closed downstream
(an all-zero 48 bytes is not a canonical G1 point), so this closes a hole in
the guards rather than a live defect.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(guest): name the field the anonymous-host double mirrors

`bls_public` no longer exists on `HostDeps`; the anonymous state is
`identity: None`.

Co-Authored-By: Claude <noreply@anthropic.com>

* style(host): rustfmt the host_deps result expression

The CI `Format check` step runs BEFORE clippy and the test suite, so this
failure skipped every later step on the ubuntu runner — the red job said
nothing about whether the code builds or passes, only that it was unformatted.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(host): cover the blind-serve identity-mismatch refusal, and ban the reachable token

The `host_deps` guard that refuses a `BlindServeConfig` whose `bls_public`
does not belong to its `bls_secret` went in without a test. It is a new error
branch on the network-facing path, so it gets one: a mismatched pair is refused
with `HostError::Validation`, and the correctly-derived pair serves real bytes
as the control. `from_seed` derives both halves and cannot express the
mismatch, so the public field is assigned directly.

Drop `identity: Some(` from the read-path token ban and add
`.identity = Some(`. The literal form is a compile error from digstore-cli —
`HostDeps` is `#[non_exhaustive]` and this is a different crate — so that
entry could never fire. Field assignment is the form that IS reachable, since
`#[non_exhaustive]` restricts construction rather than assignment, and it
matched none of the four tokens. The behavioural
`the_read_runtime_carries_no_host_identity` already catches that bypass; the
scan is the cheap second leg.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
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.

2 participants