fix: fail closed on a missing signing key, a pinned host RNG, and an unavailable CSPRNG - #40
Conversation
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
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]))atserve.rsmakesserving_fails_closed_when_the_host_signing_key_is_missingfail withOk(<decoy envelope bytes>), exactly the reported red shape, while the controlserving_succeeds_while_the_host_signing_key_is_presentstays green. The test discriminates for the right reason, and themsg.contains("signing key")assertion is pinned by the newload_signing_keyerror text. Worktree restored clean afterwards. - Mutation proof, fix 3 — REPRODUCED RED. Restoring
let _ = fill(&mut nonce);failsauth_nonce_fails_closed_when_entropy_is_unavailablewith the literal all-zero nonce. - No re-introduced fail-open across the
authed->Resultchange. All 10 call sites (client.rs 260, 274, 308, 360, 384, 476, 516, 578, 623, 651) use a plain?; nounwrap_or(req), no.ok(), nolet _ =. The anonymous/no-identity path still short-circuits withOk(req)and draws no entropy. getrandommajor is 0.2 (digstore-remote/Cargo.toml:28), sogetrandom::getrandom -> Result<(), Error>matches theauth_nonceseam andError::UNSUPPORTEDexists.- The implementer's correction to the ticket is CORRECT.
digstore-guest/src/decoy.rsderives decoy size and bytes asChaCha20(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_proofreachability claim is CORRECT.serve_proof -> serve_content -> serve_content_raw -> instantiate_host, which?s on the missing key first, soserve_proof's own?is unreachable with the key absent. Untestable by construction; the change is still right. - Blast radius outside the two crates: none.
ClientErroris matched only indigstore-cli(remote_ops.rs,cat.rs, one remote integration test);digstore-remotehas exactly one in-repo consumer;dig-client-wasmdoes not depend on it;serve.rs/store_opsare CLI-internal (pub(crate)). The unrun workspace suite is a low risk here. cargo test -p digstore-remote --lib57/57 green and--test serve_fails_closed2/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
.digbyte, section id, or guest-wasm export touched. - Scope: proportionate to the binding B3 triage — three narrow fixes, no re-architecture. The
auth_nonceseam 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.
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>
e387c26 to
5717168
Compare
§3.5 installed-binary e2e — fail-closed proved end-to-end, with a before/after controlRebased onto Built the guest wasm first, then Before / after, on two real installed binaries
The old binary serving here is the defect in its most concrete form: with the store's identity The four break cases against
|
| 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
left a comment
There was a problem hiding this comment.
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::testssigning-key length trio — 3 passeddigstore-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 cat → serve_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):
- 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.
- PR body's verification table names
ops::serve::tests::the_serve_host_draws_real_entropy_and_never_a_pinned_seed; the shipped test isthe_host_instantiate_host_builds_draws_real_entropy. Stale row; also that name is grammatically broken and reads worse than the one it replaced. 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. The0.23.2 → 0.24.0bump is the correct 0.x answer; consider adding#[non_exhaustive]in this same breaking window so the next variant is not another one.- 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 valuedig-constantspublishes. No action.
Security gate finding confirmed empirically: the "fails closed downstream" rationale is falseThe security audit flagged the comment at That claim is false, and it is false for the reason stated two functions above it: the guest's Measured on the previous released binary rather than argued:
So the pre-fix behaviour was not a harmless misattribution that failed closed later. A store whose Why it is worth correcting rather than leaving: as written, the comment gives a future maintainer a Related, from the same audit and not a defect: the retained |
…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>
…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>
* 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>
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 NOTCloses. #2553 names four sites; the three fixed here are the ones that live in digs. The fourth, the fail-open phishing guard, ishub.dig.net/apps/web/lib/store-key.tsand 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])) anddigstore-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]))ininstantiate_hostandserve_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_keyalready returnedResult<_, 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-testSome(...)in the repo; the other eight all live undertests/and build their ownHostDeps, so nothing depended onserve.rs's determinism. NowNone, converging ondigstore_host::serve_blind'shost_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.rsderives 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 ishost_random_bytes.An earlier revision of this PR said
host_random_bytessupplies 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:55hardcodesrequire_attestation: false— deliberately, per its doc comment, because dighub content is public and must be servable by ANY node. The nonce draw atcontent.rs:155sits insideif cfg.require_attestation, andcreate_attestationhas 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 theserve_blind.rsconvention 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_keycall ininstantiate_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 byserve_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:authednow returnsResult<reqwest::RequestBuilder, ClientError>and propagates at all 10 call sites (fetch, roots, module ×2, delta ×2, push-init, push, push-complete, tombstone). NewClientError::Entropy(String);digstore-cli'smap_remote_errmaps it to a message that says the fault is on this machine, not the remote. Converges onidentity.rs:53anddigstore-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.
crates/digstore-cli/tests/serve_fails_closed.rs::serving_fails_closed_when_the_host_signing_key_is_missingOk(decoy)— the fallback key served rather than erroringunwrap_or_elsemakes it failops::serve::tests::the_host_instantiate_host_builds_draws_real_entropySomeSome([99u8; 32])makes it failclient::content_tests::auth_nonce_fails_closed_when_entropy_is_unavailableauth_noncedid not existlet _ = fill(...)fails with the literal all-zero nonce printed in the panicFix 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-targetsclean.§3.5 installed-binary end-to-end (
cargo install --path crates/digstore-cli --force --locked, then the realdigsbinary):The full
init->add->commit->catround 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 insideclient.rs; the function is private, so the radius is that file plus anything matchingClientErrorexhaustively. The only such matcher isdigstore-cli/src/ops/remote_ops.rs::map_remote_err, found by the compiler refusing to build.load_signing_key—pub(crate); callers are the twoserve.rssites 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 undertests/constructing their ownHostDeps, plus the field definition and theruntime.rsconsumer.§5.1: no format or ABI change. No
.digbytes, no section id, no guest-wasm export touched.SemVer: 0.23.1 -> 0.24.0 (minor)
ClientErroris public and not#[non_exhaustive], so gaining a variant breaks any exhaustive downstreammatch— proven in-repo, sincemap_remote_errfailed to compile until it handled the new arm. That is a breaking change; under SemVer for0.xa breaking change is a minor bump.autheditself 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 isserve_blind.rs:215(serve_blind()itself), reached fromcrates/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.bincould 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 plaincpthat dropped the 0600 file.Affected commands:
cat,checkout,dev,deploypreview, andstore_status- all of which reachinstantiate_host.Why keep it anyway:
init_storeandclone_from, viapersist_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.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_pubkeyfell 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]))inserve_content_rawis 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 bareos error 2with no path.5. A truncated
signing_key.binPANICKED instead of erroring.SecretKey::from_seedisassert!(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 interruptedinitwrite 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 siblingread_signing_seedwhich has always done so. Bounded from both sides: 0/1/31 bytes and 64 bytes are rejected, exactly 32 is accepted - a>= 32guard 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_attestedrejects 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.
HostDeps { rng_seed: Some([99u8; 32]), .. }directly ininstantiate_hostand stop callinghost_depsops::serve::tests::the_host_instantiate_host_builds_draws_real_entropy- instantiates through the real function and asks the runtime what it was built withthe serve runtime must draw OS entropy; a pinned seed makes every host_random_bytes draw reproducible from this source fileauth_nonce(getrandom).unwrap_or([0u8; 32])at the call site inauthedclient::content_tests::authed_propagates_an_entropy_failure_instead_of_signing_a_constant_nonce- drivesautheditself under a failing CSPRNGa request must not be stamped without a real nonceBoth 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
HostRuntimegained an additiverng_is_deterministic()accessor recording whetherHostDeps::rng_seedwasSome.HostRuntime::new's signature is unchanged, so the 17 files that call it are unaffected. The nonce one gainedauthed_with(.., fill), withauthedreduced 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
ops::serve::tests::a_missing_trusted_key_file_refuses_to_servea 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 offops::store_ops::tests::a_truncated_signing_key_is_an_error_not_a_panicplusan_overlong_signing_key_is_rejected_rather_than_silently_truncatedpanicked at chia-bls-0.45.0/src/secret_key.rs:97: assertion failed: seed.len() >= 32- the exact abort, reproducedEach 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 --checkpluscargo clippy --workspace --all-targets -- -D warnings- clean.cargo install --path crates/digstore-cli --force --locked, then the realdigsbinary):init->add->commit->caton live mainnet (store8e0b6903..., mint confirmed at height 9133240, commit confirmed at 9133244) returned the exact plaintext. Both new refusals were then exercised through the installed binary: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.digbyte, 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 theno_stdguest crate under the section 5.1 append-only guest-ABI rule and needs its own gate round.serve_blind.rscomment carrying the original decoy inaccuracy.digstore-remote/src/server.rs.Version stays 0.24.0: this pass adds no new public breaking surface beyond the
ClientErrorvariant already accounted for.