Skip to content

fix(dig-node): resolve reads to the generation that holds the file (#2088) - #184

Merged
MichaelTaylor3d merged 3 commits into
mainfrom
fix/2088-generation-resolution
Aug 5, 2026
Merged

fix(dig-node): resolve reads to the generation that holds the file (#2088)#184
MichaelTaylor3d merged 3 commits into
mainfrom
fix/2088-generation-resolution

Conversation

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor

Closes dig_ecosystem#2088 (P0). A store with more than one generation was unreadable for every file not changed in its latest commit — every store broke on its second commit for files that commit didn't touch.

Root cause

decrypt_local (content_serve.rs) rejected any local capsule whose roothash != pinned tip. A file unchanged since an earlier commit lives in an older capsule (roothash == latest_root ≠ tip) → dropped as a miss → tiers 2/3 serve at the tip, where the ciphertext isn't present → blind-serve decoy (proof folds to tip but leaf ≠ the resource leaf → verify_and_decrypt fails, or the AES-GCM-SIV tag fails). Reading at the older root was refused by the #127 anti-rollback pin (-32005). Both directions failed.

Fix

After the #127 pin resolves the chain-anchored tip (pin logic untouched), the serve reads the tip capsule'sPublicManifest (data-section §13, already parsed today for X-Dig-Generation) via resource_manifest_entry, gets the requested path's latest_root + sha256_latest + generation_index, and computes serve_root (the tip, or the older capsule that holds the path) + expected_leaf. These thread through all three tiers (local / peer / RPC).

The security crux — verify_and_decrypt:trusted_root is now serve_root (so the proof still folds to the capsule it belongs to), plus a new binding — proof.leaf == expected_leaf where expected_leaf = entry.sha256_latest comes only from the chain-anchored tip manifest, never the request, never the older capsule. That leaf-binding is what ties older-generation bytes to the chain-anchored tip even though the proof folds to latest_root.

Anti-rollback invariant (preserved)

The node serves only against the chain-anchored tip or an older latest_root taken from that tip's own manifest, with served bytes pinned to the tip manifest's sha256_latest; a client-supplied root is still rejected with -32005 unless it equals the tip.

  • The client-root pin (-32005 ROOT_NOT_ANCHORED) is untouched — a regression test proves a client-supplied superseded root still fails.
  • serve_root is never client-derivable; older-capsule redirection happens solely via the node's own trusted tip manifest.
  • expected_leaf == None (legacy/private/absent-path manifest) ⇒ byte-identical to pre-fix behaviour (tip serve + constant-time decoy for genuine misses).
  • X-Dig-Root continues to report the chain-anchored tip (never the older root); X-Dig-Generation discloses the actual generation index.

Residual (tracked, not blocking)

PublicManifest (§13) is not committed into current_root, and an older capsule's own root isn't chain-anchored. This fix adds no new trust boundary — it binds older-gen bytes to the tip via sha256_latest read from the tip capsule the chain already vouches for (the node already serves every current file + reads this same manifest today). Full closure = dig_ecosystem#2203 (digstore: commit the manifest into the on-chain root, release-first). Recorded in DEVELOPMENT_LOG.md.

How verified (TDD, regression-first)

  • serve_content_plaintext_resolves_reads_to_the_generation_that_holds_the_file_2088 — a real two-generation store (gen0 {index.html:A, asset.js:B}, gen1 touches only index.html:A', tip=gen1): GET /asset.js → 200 + real plaintext B + X-Dig-Generation: 0; GET /index.htmlA' + gen 1. Pre-fix-fails proof: reverting the fix makes this test fail (pinned to tip, local decoy dropped, falls through to unroutable upstream).
  • serve_content_plaintext_still_refuses_a_client_supplied_superseded_root_2088 — client supplies gen0 root → -32005.
  • Unit: verify_and_decrypt_serves_older_gen_root_when_leaf_matches + verify_and_decrypt_rejects_leaf_mismatch_against_tip_manifest (fail-closed on a tampered older capsule).
  • cargo test -p dig-node-core --lib770 passed, 0 failed; cargo test -p dig-node-service --test content_serve13 passed (all prior single-gen regressions green). fmt + clippy -D warnings clean; both crates build.

Blast radius

verify_and_decrypt/decrypt_local/peer_serve_plaintext are private to content_serve.rs (all callers in-file). serve_content_plaintext (public) + the ContentServer trait keep identical signatures — no wire/trait change. resource_generation signature unchanged (delegates to resource_manifest_entry). SPEC.md §4.2 updated with the invariant + §4.2.1.

Version

root [workspace.package].version 0.99.0 → 0.99.1 (patch, fix:); Cargo.lock regenerated.


Generated by Claude Code

…2088)
A >1-generation store was unreadable for every file not in its latest
commit: the plaintext serve pinned every read to the chain-anchored tip,
but a file unchanged since an earlier commit lives in an OLDER capsule
(its root != tip), so the tip fetch folded to a decoy and the file read
as a 404 for every generation but the latest.
After the #127 pin resolves the tip, read the tip capsule's PublicManifest
(section 13) to resolve, per path, the latest_root that holds the file
(serve_root) and its sha256_latest leaf (expected_leaf). Serve from
serve_root through all three tiers, and bind older-generation bytes to
the chain-anchored tip by requiring proof.leaf == expected_leaf in
verify_and_decrypt. The older capsule's own root is not chain-anchored
and is attacker-choosable in isolation, so sha256_latest -- read from the
trusted tip manifest -- is what ties the served bytes to the tip.
Anti-rollback is untouched: a client-supplied superseded root still fails
-32005; only the node's own trusted tip manifest may redirect a read to
an older capsule. Legacy/private/absent-manifest reads are byte-identical
to before. X-Dig-Generation now reports the generation that holds the
file, which may be below the tip ordinal.
Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3dClaude

Copy link
Copy Markdown
ContributorAuthor

🔴 Dual gate: reviewer PASS, security CHANGES-REQUIRED — fix in progress, do not merge yet.

  • loop-reviewer (correctness): PASS (7/7 — resolution rule, verify-chain shape, tier threading, fix(service): anchor identity + cache under the state dir, not $HOME #127 pin untouched, headers, non-vacuous tests, hygiene).
  • loop-security: CHANGES-REQUIRED (HIGH, live). The fix reads BOTH serve_root (entry.latest_root) and expected_leaf (entry.sha256_latest) from the PublicManifest (§13), which is NOT committed into current_root and is not checked by the capsule anchor gate. For a redirected read the verify chain becomes proof.root == serve_root (attacker-named) + proof.leaf == expected_leaf (attacker-named) — the chain root drops out of byte-authenticity. A malicious holder/upstream can serve a genuine tip capsule with a forged §13 and substitute arbitrary content on any multi-generation public store, stamped X-Dig-Verified: true. The sha256_latest binding is circular because its source is attacker-controlled. This re-opens the fix(service): anchor identity + cache under the state dir, not $HOME #127 invariant.

Fix being added to this branch: before honoring a redirect, chain-authenticate serve_root against the store's on-chain lineage / root_history (the lineage the #127 pin already walks via anchored_state). Only redirect to a latest_root that is a genuine historical root of this store; otherwise fail closed (tip serve / miss). That restores the chain-root-in-the-authenticity-path property; expected_leaf becomes defense-in-depth. Regression test: a forged-§13 tip capsule pointing latest_root at a non-lineage root must be refused. (#2203 — binding §13 into current_root — remains the full closure; this cross-check makes the redirect safe without it.)

Re-running loop-security on the updated head before merge.


Generated by Claude Code

…tion redirect (#2088)
The #2088 generation-resolution serve read both serve_root (entry.latest_root)
and expected_leaf (entry.sha256_latest) from the tip capsule's §13 PublicManifest
— an additive section NOT committed into current_root and NOT checked by the
capsule anchor gate. A malicious holder could serve a genuine tip capsule with a
FORGED §13 pointing latest_root/sha256_latest at attacker content, substituting
arbitrary bytes (and rolling back) on any multi-generation public store while
stamping X-Dig-Verified: true — re-opening the #127 anti-rollback invariant.
Add the missing gate: before honoring a redirect to an older serve_root, verify
that root is a GENUINE root in the store's authenticated on-chain singleton
lineage via a new AnchoredRootResolver::verify_lineage_root (CoinsetResolver walks
sync_datastore_with_history + membership; the trait default and test mock accept
only the current tip). If serve_root is not in the lineage the serve fails closed
to the tip (decoy/miss), never the attacker-named bytes. expected_leaf stays a
fail-closed defense-in-depth routing guard; the client-root -32005 pin is
unchanged. #2203 (bind §13 into current_root) remains the full closure.
Regression test: a forged §13 whose latest_root is out-of-lineage is refused
(never serves the attacker bytes); the genuine two-generation store still serves
real plaintext (its older root is authenticated in the lineage). SPEC §14.4a.
Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d merged commit 7d86ff5 into mainAug 5, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the fix/2088-generation-resolution branch August 5, 2026 23:34
MichaelTaylor3d pushed a commit that referenced this pull request Aug 6, 2026
…ade (#2211)
The #2088 leaf binding + the #184 lineage cross-check bind a redirected serve
to a genuine lineage generation, but not the path's canonical one: §13 is
additive and NOT committed into the chain-anchored current_root, so a forged
tip §13 can redirect a tip-committed path at a genuine-but-superseded prior
generation and downgrade it.
Serve TIP-FIRST with no §13 leaf binding (bind purely by proof.root == tip),
and consult the §13 redirect + expected_leaf ONLY on a genuine tip miss. A
path the tip's current_root commits is served from the chain-anchored tip
(forged redirect never reached, Case A closed); an older-generation file
misses at the tip and falls through to the still-lineage-authenticated §13
redirect exactly as #2088 intends (Case B preserved). Case B rollback stays
open on #2211, blocked on the per-path current-state commitment (#2203).
Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d pushed a commit that referenced this pull request Aug 6, 2026
… root (#2211)
The tip-authoritative Case-A closure assumes a tip serve MISS means the path is
legitimately absent from the tip generation. That premise is unenforced: the
capsule anchor gate compares only the 32-byte CurrentRoot HEADER against the
chain, never recomputing the tree from MerkleNodes. A malicious holder can craft
a tip capsule whose header still names the genuine tip while its data is tampered
so a tip-committed path no longer folds to it -- the node admits + caches it, the
tip serve misses that path, and the forged §13 drives a redirect at a genuine-but-
superseded prior generation: the rollback Case A was meant to prevent.
Refuse a §13 redirect unless the tip capsule's own data folds to its committed
current_root (verify_module_root recomputes MerkleNodes root == committed
CurrentRoot, and that root == the chain-anchored tip). A tampered tip yields a
clean miss, never a downgrade. Placed at the §13-trust boundary so it covers a
tip capsule however it entered the cache.
Also restore the #2088 fall-through: the tip-first / §13-redirect two-pass now
DEFERS a non-Served tip outcome (a decoy miss OR an upstream error) while a
redirect candidate remains, so a tip-pass upstream error no longer pre-empts a
legitimate older-generation read.
Regression test: a tampered-tip capsule forced to miss must never serve the
rolled-back v1 (red without the gate: serves V1-OLD). The #2088 older-gen read,
Case-A honest downgrade, #184 out-of-lineage, and client-supplied-superseded
tests stay green. SPEC §14.4a corrected; DEVELOPMENT_LOG updated.
Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d added a commit that referenced this pull request Aug 6, 2026
…ade (#2211)
* chore(dig-node): stub for #2211 tip-authoritative serve
Co-Authored-By: Claude <noreply@anthropic.com>
* test(dig-node): add #2211 Case-A forged-downgrade regression (red)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(dig-node): serve tip-authoritative to close the Case-A §13 downgrade (#2211)
The #2088 leaf binding + the #184 lineage cross-check bind a redirected serve
to a genuine lineage generation, but not the path's canonical one: §13 is
additive and NOT committed into the chain-anchored current_root, so a forged
tip §13 can redirect a tip-committed path at a genuine-but-superseded prior
generation and downgrade it.
Serve TIP-FIRST with no §13 leaf binding (bind purely by proof.root == tip),
and consult the §13 redirect + expected_leaf ONLY on a genuine tip miss. A
path the tip's current_root commits is served from the chain-anchored tip
(forged redirect never reached, Case A closed); an older-generation file
misses at the tip and falls through to the still-lineage-authenticated §13
redirect exactly as #2088 intends (Case B preserved). Case B rollback stays
open on #2211, blocked on the per-path current-state commitment (#2203).
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(dig-node): gate §13 redirect on tip capsule backing its committed root (#2211)
The tip-authoritative Case-A closure assumes a tip serve MISS means the path is
legitimately absent from the tip generation. That premise is unenforced: the
capsule anchor gate compares only the 32-byte CurrentRoot HEADER against the
chain, never recomputing the tree from MerkleNodes. A malicious holder can craft
a tip capsule whose header still names the genuine tip while its data is tampered
so a tip-committed path no longer folds to it -- the node admits + caches it, the
tip serve misses that path, and the forged §13 drives a redirect at a genuine-but-
superseded prior generation: the rollback Case A was meant to prevent.
Refuse a §13 redirect unless the tip capsule's own data folds to its committed
current_root (verify_module_root recomputes MerkleNodes root == committed
CurrentRoot, and that root == the chain-anchored tip). A tampered tip yields a
clean miss, never a downgrade. Placed at the §13-trust boundary so it covers a
tip capsule however it entered the cache.
Also restore the #2088 fall-through: the tip-first / §13-redirect two-pass now
DEFERS a non-Served tip outcome (a decoy miss OR an upstream error) while a
redirect candidate remains, so a tip-pass upstream error no longer pre-empts a
legitimate older-generation read.
Regression test: a tampered-tip capsule forced to miss must never serve the
rolled-back v1 (red without the gate: serves V1-OLD). The #2088 older-gen read,
Case-A honest downgrade, #184 out-of-lineage, and client-supplied-superseded
tests stay green. SPEC §14.4a corrected; DEVELOPMENT_LOG updated.
Co-Authored-By: Claude <noreply@anthropic.com>
* style(dig-node): use if-let for the single-arm tamper assertion (#2211)
Silences clippy::single_match on the _2211 tampered-capsule test; logic
unchanged (the gate + adversarial coverage are identical).
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@MichaelTaylor3d@claude