fix: carry no identity on the read path - #42
Conversation
Co-Authored-By: Claude <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
855a52d to
92c9ee6
Compare
…ng 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>
…oader `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>
`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>
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
CHANGES-REQUIRED — correctness gate (head ef7138c)
The core of this change is right, and I want to say that plainly before the findings: the read/sign asymmetry is complete and coherent. I enumerated every path that reaches instantiate_host and every remaining consumer of the identity, and each lands where it should.
Read paths, now anonymous (correct): cat.rs:119, cat.rs:255, checkout.rs:71, dev.rs:390, dev.rs:564 (via read_resource_plaintext), deploy.rs:613 (same), store_ops.rs:703 (read_discovery_manifest), store_ops.rs:812 (committed_content then compute_status).
Identity-consuming paths, still fail-closed (correct): serve.rs:301 (serve_proof), push.rs:48, remote_ops.rs:537, remote_ops.rs:617, and store_ops.rs:996 (finalize_commit embedding the trusted key — a write path, and load_host_pubkey's only surviving caller).
deploy --preview and dev both init_store their OWN ephemeral store before committing, so the finalize_commit identity read there is always satisfied and AC1 is not undermined by it.
No None-becomes-authorized path exists. imports.rs:126 returns NotFound, imports.rs:169 returns AttestationFailed, and the guest gate at content.rs:168 maps a create_attestation error to Err(()) and therefore a Decoy. The three-arm match at runtime.rs:158 sends every partial-identity combination to UnavailableAttestationBackend, which is the fail-closed direction. AC4 holds: I grepped every src/ tree and no host-identity placeholder literal survives outside tests.
SPEC §13.6 agrees with the code; ## 14 is present at line 742 and §14.1 at 748 is not orphaned.
dig-constants check (both questions asked). (1) Nothing here belongs in dig-constants — no cross-repo asset id, address, puzzle hash, port, genesis value or version pin is introduced; IDENTITY_UNAVAILABLE / exit 20 extends a registry local to digstore-cli that codes 1-19 already live in. (2) Nothing here hardcodes a value dig-constants publishes. Non-gating.
Two BLOCKING findings below. Both are in digstore-host and both are cheap in this window. Neither goes to Copilot — they touch key handling and revert-proof integrity, so they come back to the orchestrator.
Test-vacuity assessment — what each test would NOT catch
| Test | Catches | Blind to |
|---|---|---|
a_store_with_no_identity_still_serves_committed_content |
a re-added identity load in instantiate_host, either file |
a substituted key — the plaintext is identical either way |
the_read_runtime_carries_no_host_identity |
bls_public: Some(..) inlined into host_deps |
a re-added refusal (an aborted read builds no runtime) — hence the sibling leg |
the_read_path_never_reaches_for_the_store_identity |
a restored load_host_pubkey / [0u8; 48] in serve.rs |
see finding 3 — NOT the load_signing_key refusal, which is the exact PR #40 shape |
serve_proof_still_refuses_without_a_signing_key |
"identity optional everywhere" | nothing material; the control leg is real |
cat_serves_committed_content_after_the_store_identity_is_destroyed |
the CLI-level regression including exit status | a network fallback masking a local failure (no node in the harness, so acceptable) |
cat_verify_proof_still_refuses_without_a_signing_key |
a signing path silently going anonymous | — |
an_absent_signing_key_is_classified_as_identity_unavailable |
the classification collapsing back to Other |
— |
an_anonymous_host_cannot_serve_content_that_requires_attestation |
the gate tolerating a failed create_attestation |
the REAL host — it drives a double; see finding 1 |
None of the eight is vacuous. Asserting decrypted plaintext rather than a bare Ok is exactly right given the decoy-through-Ok shape, and the controls are genuine controls. The gaps are the two blocking findings.
| (None, Some(secret), Some(public)) => { | ||
| Arc::new(BlsAttestationBackend::new(secret, public)) | ||
| } | ||
| (None, _, _) => Arc::new(UnavailableAttestationBackend), |
There was a problem hiding this comment.
BLOCKING — the anonymous-backend arm is untested, and it is the one place #2553 could reappear unseen.
Nothing anywhere in the repo asserts that a HostRuntime built with bls_secret: None / bls_public: None actually installs UnavailableAttestationBackend. Look at the coverage that appears to cover it, and does not:
teehook.rs:113testsUnavailableAttestationBackendin isolation — it never observes that the runtime selects it.serve.rs:448(the_read_runtime_carries_no_host_identity) assertshas_host_public_key(), which isdeps.bls_public.is_some()recorded atruntime.rs:148— a field flag set before this match and wholly independent of which backend is chosen.- The guest proof in
content_proof.rsdrives aSigningHostdouble, not this crate.
So apply the mutation this PR exists to prevent, one crate lower than last time:
(None, _, _) => Arc::new(BlsAttestationBackend::new(
BlsSecretKey::from_seed(&[42u8; 32]),
/* its public half */,
)),Every test in the repo stays green. has_host_public_key() still reports false, the source-scan test at serve.rs:511 only reads serve.rs, and the guest double is unaffected. That is #2553's world-known-key defect reconstituted, invisible to the whole suite — while the PR body claims all eight tests are revert-proven load-bearing.
What is needed is a test in digstore-host that builds a runtime from anonymous deps and asserts the OBSERVABLE fail-closed behaviour rather than the flag: host_get_public_key answers NotFound, and an attestation attempt fails instead of returning a signature. Assert on the attest RESULT, not on a concrete backend type — a type-name assertion is defeated by any other stand-in backend.
Returning to the orchestrator, not Copilot: revert-proof + attestation.
| /// half cannot be attributed, and a public half with no secret cannot sign. | ||
| /// An anonymous host still serves committed content — the guest's content | ||
| /// path does not consult the host identity — it simply cannot attest. | ||
| pub bls_secret: Option<BlsSecretKey>, |
There was a problem hiding this comment.
BLOCKING — the documented pairing invariant is unenforceable by the type, in the only breaking window you get.
The doc directly above states the MUST: "Both halves are supplied together or not at all: a secret with no public half cannot be attributed, and a public half with no secret cannot sign." Nothing enforces it. HostDeps is a plain pub struct built by literal, so both illegal states are constructible and both fail silently:
bls_secret: Some(..), bls_public: None— the match at:158falls through to(None, _, _)and the secret is discarded. A host that genuinely holds an identity silently becomes anonymous and fails any attestation gate. That is exactly the silent identity downgrade §13.6 exists to forbid, arriving through the front door.bls_secret: None, bls_public: Some(..)—host_get_public_keyhands out a key this host cannot sign for.
Neither is a security hole today (both directions fail closed at the gate), which is why this is an API-shape finding and not a security one. It is BLOCKING because of timing: digstore-host is already taking 0.2.0 to 0.3.0 and this PR already rewrites all 8 HostDeps literals. Deferring costs a second breaking window across the same 8 sites for no new capability.
Make the absence one representable value:
pub struct HostIdentity { pub secret: BlsSecretKey, pub public: Bytes48 }
// ...
pub identity: Option<HostIdentity>,runtime.rs:158 then collapses to a two-arm match, serve_blind.rs:190 becomes Some(HostIdentity { .. }), and the read path's None is unmistakable. An API that merely PERMITS the correct shape is not enough when the correct shape is a MUST in the doc comment.
NON-BLOCKING rider on the same type, best decided in this window rather than the next: HostDeps changed fields in a breaking release and still has no #[non_exhaustive]. It cannot be added without a constructor (digstore-cli is a foreign crate to it), so if you take the HostIdentity refactor, consider adding HostDeps::new(..) plus #[non_exhaustive] at the same time — otherwise the next additive field is breaking window number three.
Returning to the orchestrator, not Copilot: key handling.
| /// `has_host_public_key` (the behavioural leg) is green both when the read | ||
| /// path carries NOTHING and — crucially — it is NOT green when a stand-in key | ||
| /// is substituted, so it catches a restored `unwrap_or(Bytes48([0u8; 48]))`. | ||
| /// What it CANNOT catch is a re-added *refusal*: a runtime that never gets |
There was a problem hiding this comment.
NON-BLOCKING — this doc claims a discrimination the test does not have, on the exact regression shape it was written for.
The comment says the source-scan leg "catches [a re-added refusal] by asserting the read path never reaches for the identity in the first place." It catches the load_host_pubkey form. It does not catch the load_signing_key form — and that is the one PR #40 actually shipped and this PR removes from instantiate_host. load_signing_key is deliberately excluded from the ban list at :520 (correctly, because serve_proof lives in the same file and must keep calling it), so re-adding let secret = store_ops::load_signing_key(ctx)?; to instantiate_host leaves this leg green.
The regression is still caught — by a_store_with_no_identity_still_serves_committed_content, serving_succeeds_when_the_host_signing_key_is_missing, and the CLI-level cat test — so nothing is actually unguarded. The problem is the doc: a future reader trusting this sentence will believe the source scan is a complete refusal-detector, and may weaken the behavioural tests it silently depends on. Narrow the claim to what it does — it catches a re-added pubkey load and a substituted key — and name the behavioural tests that own refusal-detection.
Returning to the orchestrator, not Copilot: it sits on a vacuity claim.
…s 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>
DO NOT MERGE — gate round in progress. Keep this DRAFT until the orchestrator's gates return.
digs' cron cuts a stable tag at midnight UTC (its stable job is guarded on
github.event_name == 'schedule', the §3.6-A contradiction tracked as #698), somainreaches usersunattended. The merge gate is the last line of defence here, not the first of two.
Closes DIG-Network/dig_ecosystem#2712
What changed
A missing store identity aborted the read path instead of being irrelevant to it.
dig cat's localleg returned
Err, which pre-empted the §5.3 client→node ladder entirely — the ladder is reached viaOk(None)("not here, try the network"), and anErris deliberately treated as a real failure thatmust never be masked by a network retry.
The read path now carries no identity at all. Not a tolerated placeholder — absence made
representable:
HostDeps.bls_secret/.bls_publicandHostKeys.bls_publicbecomeOption;AttestationBackend::public_keyreturnsOption<Bytes48>; a newUnavailableAttestationBackendrefuses to attest rather than signing under a borrowed key.
HostKeys.bls_secretis removed —it had two writes and no reads, which kept un-clonable key material one field access away from
every import handler for no purpose.
serve.rs'sinstantiate_hostreads neithersigning_key.binnortrusted_keys.json.serve_proofkeeps its fail-closed load. The asymmetry is the fix: serving consumes noidentity, signing is an act of attribution.
Why this is not a rollback of #2553
#2553's defect was substituting a stand-in identity (an all-zero G1, a
from_seed(&[42u8; 32])seed). The cure for a substituted key is to stop substituting — not to refuse the read. Refusing cost
availability on every read while buying nothing, because the guest never consults the value
(
require_attestationisfalseon the content path,digstore-guest/src/content.rs:60).Measured on installed binaries, this is visible: 0.23.0 with the identity destroyed serves the
content and signs a proof under the world-known fallback key, surfacing five layers later as
NodeKeyNotAttested(b145dfcb…)with a hint blaming the content ("the store data was tamperedwith"). That hex is the public key of
from_seed(&[42u8; 32]). This PR serves the read and refusesthe proof, naming
signing_key.bin.Scope was wider than
dig catFive further call sites reach
instantiate_hostand have no ladder to fall through to:checkout.rs:71,dev.rs:390/:564,deploy.rs:613,store_ops.rs:703/:812— socheckout,dev,deploy --previewandcompute_statuswere all broken by a missing identity. Verified workingafter the fix on the installed binary.
Blast radius checked
gitnexus could not index this worktree —
npx gitnexus analyzesegfaults (npm 11, #1939) and theglobal 1.6.3 binary exits without producing an index (
gitnexus status→ "Repository not indexed").Fell back to ripgrep + direct read per §2.0 bound (2), and
detect_changes()was likewise unavailable;the diff was instead verified by name against the expected set (23 files).
HostDepsliteralsHostKeys,AttestationBackendload_host_pubkeystore_ops:996, push path)load_signing_keyAttestationBackend::public_keyimports.rs:166)Cross-repo:
dig-node/crates/dig-node-corepinsdigstore-hostat rev4c34f0beand consumesonly
serve_blind/BlindServeConfig— both still require an identity (Some(...)atserve_blind.rs:186), so it is unaffected now and after a future rev bump.dig-appdoes not dependon digstore-host. No other consumer exists; all in-repo deps are
path =.RISK:
digstore-hosttakes a breaking Rust API change (0.2.0→0.3.0). It is apath =dependency with no out-of-workspace consumers, so no release-first cascade is required. This is not
a §5.1 guest-ABI or
.dig-format change — thedig_hostimports and guest exports are untouched, andno golden fixture changes.
Evidence
Installed-binary e2e (§3.5)
Guest wasm built first, then
cargo install --path crates/digstore-cli --force --locked→digs 0.25.0. Real store, realinit/add/commit, then both identity files deleted:cat— identity intacthello serve, exit 0hello serve, exit 0cat— identity destroyedhello serve, exit 0 — via a substituted keyhello serve, exit 0 — carrying NO identitycat --verify-proof— intactcat --verify-proof— destroyedNodeKeyNotAttested(b145dfcb…), blames the contentsigning_key.bincheckout <root> --out— destroyedhello servestatus— destroyedTests — every one revert-proven load-bearing
Reverting only the fix (re-adding the two loads to
serve_content_raw; done from a file copy aftercommitting, never
git checkout) turns these red:cli_cat_no_identity— both tests FAILED (the command-level pin, the ticket's actual subject)ops::serve::tests::a_store_with_no_identity_still_serves_committed_content— FAILEDops::serve::tests::the_read_path_never_reaches_for_the_store_identity— FAILEDops::serve::tests::serve_proof_still_refuses_without_a_signing_key— FAILED, and revealingly:under the pre-fix behaviour
serve_proof's refusal is attributed totrusted_keys.json(raisedby the serve step it calls first), not to its own signing key. The control detects that
misattribution.
ops::serve::tests::the_read_runtime_carries_no_host_identity— stayed green, which is exactlythe blind spot its doc comment claims (an accessor cannot see a re-added refusal, because a
runtime that is never built trivially carries no identity). The two-leg split is now verified rather
than asserted.
Fixture design notes. Every read assertion is on the decrypted, merkle-verified plaintext, not
on
Ok/exit 0 — a retrieval miss returns a decoy through the same success path (§14.2), so anexit-status-only check would be satisfied by a runtime that had quietly stopped finding the resource.
Each test keeps the intact store as a truthful control, and
destroy_identityasserts it removedboth files so a layout change cannot silently make the suite vacuous.
The safety proof that the
Optionis not a hole:an_anonymous_host_cannot_serve_content_that_requires_attestation(digstore-guest) — where the gateDOES require attestation, an identity-less host gets a Decoy. The trusted set still contains a
real valid key and the identified host is the control on the same fixture, so exactly one actor
varies. Note the plan's "compile a fixture with
require_attestation: true" is not expressible atthe CLI level —
GateConfig::from_embeddedhardcodes itfalseand the compiler exposes no switch;the guest suite is where that proof lives.
Suite results
digstore-cli --lib354 passed (+1),digstore-guest/content_proof30 passed,digstore-host+digstore-guestall targets green,digstore-compiler/-remotegreen, and theintegration targets most exposed to this change all green:
cli_cat_roundtrip(5),cli_cat_no_identity(2),cli_checkout,cli_tamper(2),cli_status,cli_deploy(17),cli_dev,cli_errors,cli_help_json(3),cli_doctor(4),cli_remote_clone_push_pull(5),adv_self_serve,adv_delegated_host_key,adv_host_no_inspect. Enumerated == executed in everyrun.
cargo fmt --allclean;cargo clippy --workspace --all-targets -- -D warningsclean.SPEC
§13.6's "MUST NOT serve" sentence the brief asked me to correct is already absent from
main—002814cremoved it during PR #40, so there was nothing to reverse and I did not invent an edit. The## 14header is present (§14.1 is not orphaned). What §13.6 did lack was the positive rule, whichleft "refuse the read" readable as the stricter option. Added: serving consumes no identity and MUST
NOT be refused for a missing one; absence MUST be representable as absence rather than a placeholder;
and making the identity optional MUST NOT make any gate optional. Section title extended to
"…and not consulted to read".
Version
0.25.0— minor. It adds capability (reads,checkout,dev,deploy --previewandstatusnow work where they aborted) with no CLI contract removed or repurposed; the new
IDENTITY_UNAVAILABLE/exit-20 class is additive.mainwas already at an unreleased0.24.0againsttag
v0.23.1, and the version-increment gate requires an increase beyondmain.digstore-hostgoes0.2.0→0.3.0separately (breaking Rust API, pre-1.0 minor). Every workspaceCargo.lockentrywas relocked — verified only third-party
tokio-tungstenite/tungsteniteremain at0.24.0.Also in this PR
CliError::IdentityUnavailableexisted but was constructed nowhere (dead code from thepre-restart lane). Wired into
load_signing_key, so a store that cannot sign reports a stableIDENTITY_UNAVAILABLE/ exit 20 instead of the catch-all exit 1. Corrupt-length branches stayInvalidArgument— different problem, different remedy. Test added; the existing 32-byte boundtests (both sides) still pass unchanged.
format!string in the error hint that emitted 18 literal spaces mid-sentence..gitignorethe per-worktree.gitnexusindex.Not done, deliberately
Per the brief this PR touches digs only. No write/publish path was made identity-optional — those
genuinely consume an identity and weakening them would be a security regression. No docs.dig.net
change is needed: the aborting behaviour only ever existed on unreleased
main, so no published docdescribes it.