Skip to content

fix: carry no identity on the read path - #42

Draft
MichaelTaylor3d wants to merge 12 commits into
mainfrom
loop/2712-anonymous-read-path
Draft

fix: carry no identity on the read path#42
MichaelTaylor3d wants to merge 12 commits into
mainfrom
loop/2712-anonymous-read-path

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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), so main reaches users
unattended. 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 local
leg returned Err, which pre-empted the §5.3 client→node ladder entirely — the ladder is reached via
Ok(None) ("not here, try the network"), and an Err is deliberately treated as a real failure that
must 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_public and HostKeys.bls_public become Option;
    AttestationBackend::public_key returns Option<Bytes48>; a new UnavailableAttestationBackend
    refuses to attest rather than signing under a borrowed key. HostKeys.bls_secret is 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's instantiate_host reads neither signing_key.bin nor trusted_keys.json.
  • serve_proof keeps its fail-closed load. The asymmetry is the fix: serving consumes no
    identity, 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_attestation is false on 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 tampered
with"). That hex is the public key of from_seed(&[42u8; 32]). This PR serves the read and refuses
the proof, naming signing_key.bin.

Scope was wider than dig cat

Five further call sites reach instantiate_host and have no ladder to fall through to:
checkout.rs:71, dev.rs:390/:564, deploy.rs:613, store_ops.rs:703/:812 — so checkout,
dev, deploy --preview and compute_status were all broken by a missing identity. Verified working
after the fix on the installed binary.

Blast radius checked

gitnexus could not index this worktreenpx gitnexus analyze segfaults (npm 11, #1939) and the
global 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).

Symbol Callers Handled
HostDeps literals 8 (1 production + 7 test helpers) all migrated
HostKeys, AttestationBackend re-exported only; no external constructors
load_host_pubkey was 3; now 2 (store_ops:996, push path) removed from the read path
load_signing_key 5 read-path call removed; signing calls kept
AttestationBackend::public_key 2 impls + 1 caller (imports.rs:166) all updated

Cross-repo: dig-node/crates/dig-node-core pins digstore-host at rev 4c34f0be and consumes
only serve_blind / BlindServeConfig — both still require an identity (Some(...) at
serve_blind.rs:186), so it is unaffected now and after a future rev bump. dig-app does not depend
on digstore-host. No other consumer exists; all in-repo deps are path =.

RISK: digstore-host takes a breaking Rust API change (0.2.00.3.0). It is a path =
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 — the dig_host imports and guest exports are untouched, and
no golden fixture changes.

Evidence

Installed-binary e2e (§3.5)

Guest wasm built first, then cargo install --path crates/digstore-cli --force --lockeddigs 0.25.0. Real store, real init/add/commit, then both identity files deleted:

Scenario 0.23.0 (released control) 0.25.0 (this PR)
cat — identity intact hello serve, exit 0 hello serve, exit 0
cat — identity destroyed hello serve, exit 0 — via a substituted key hello serve, exit 0 — carrying NO identity
cat --verify-proof — intact exit 0 exit 0
cat --verify-proof — destroyed exit 5, NodeKeyNotAttested(b145dfcb…), blames the content exit 1, names signing_key.bin
checkout <root> --out — destroyed (n/a) exit 0, restores hello serve
status — destroyed (n/a) exit 0

Tests — every one revert-proven load-bearing

Reverting only the fix (re-adding the two loads to serve_content_raw; done from a file copy after
committing, 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 — FAILED
  • ops::serve::tests::the_read_path_never_reaches_for_the_store_identity — FAILED
  • ops::serve::tests::serve_proof_still_refuses_without_a_signing_key — FAILED, and revealingly:
    under the pre-fix behaviour serve_proof's refusal is attributed to trusted_keys.json (raised
    by 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_identitystayed green, which is exactly
    the 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 an
exit-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_identity asserts it removed
both files so a layout change cannot silently make the suite vacuous.

The safety proof that the Option is not a hole:
an_anonymous_host_cannot_serve_content_that_requires_attestation (digstore-guest) — where the gate
DOES 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 at
the CLI level
GateConfig::from_embedded hardcodes it false and the compiler exposes no switch;
the guest suite is where that proof lives.

Suite results

digstore-cli --lib 354 passed (+1), digstore-guest/content_proof 30 passed,
digstore-host + digstore-guest all targets green, digstore-compiler/-remote green, and the
integration 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 every
run. cargo fmt --all clean; cargo clippy --workspace --all-targets -- -D warnings clean.

SPEC

§13.6's "MUST NOT serve" sentence the brief asked me to correct is already absent from main
002814c removed it during PR #40, so there was nothing to reverse and I did not invent an edit. The
## 14 header is present (§14.1 is not orphaned). What §13.6 did lack was the positive rule, which
left "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 --preview and status
now work where they aborted) with no CLI contract removed or repurposed; the new
IDENTITY_UNAVAILABLE/exit-20 class is additive. main was already at an unreleased 0.24.0 against
tag v0.23.1, and the version-increment gate requires an increase beyond main. digstore-host goes
0.2.00.3.0 separately (breaking Rust API, pre-1.0 minor). Every workspace Cargo.lock entry
was relocked — verified only third-party tokio-tungstenite/tungstenite remain at 0.24.0.

Also in this PR

  • CliError::IdentityUnavailable existed but was constructed nowhere (dead code from the
    pre-restart lane). Wired into load_signing_key, so a store that cannot sign reports a stable
    IDENTITY_UNAVAILABLE / exit 20 instead of the catch-all exit 1. Corrupt-length branches stay
    InvalidArgument — different problem, different remedy. Test added; the existing 32-byte bound
    tests (both sides) still pass unchanged.
  • Fixed a format! string in the error hint that emitted 18 literal spaces mid-sentence.
  • .gitignore the per-worktree .gitnexus index.

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 doc
describes it.

MichaelTaylor3d and others added 8 commits August 11, 2026 08:12
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>
@MichaelTaylor3d
MichaelTaylor3d force-pushed the loop/2712-anonymous-read-path branch from 855a52d to 92c9ee6 Compare August 11, 2026 18:42
MichaelTaylor3d and others added 3 commits August 11, 2026 14:43
…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 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.

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),

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.

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:113 tests UnavailableAttestationBackend in isolation — it never observes that the runtime selects it.
  • serve.rs:448 (the_read_runtime_carries_no_host_identity) asserts has_host_public_key(), which is deps.bls_public.is_some() recorded at runtime.rs:148 — a field flag set before this match and wholly independent of which backend is chosen.
  • The guest proof in content_proof.rs drives a SigningHost double, 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>,

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.

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 :158 falls 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_key hands 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

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.

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.

Comment thread crates/digstore-guest/tests/content_proof.rs
Comment thread crates/digstore-cli/src/ops/serve.rs
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant