Uh oh!
There was an error while loading. Please reload this page.
fix(dig-node): resolve reads to the generation that holds the file (#2088) - #184
Conversation
Co-Authored-By: Claude <noreply@anthropic.com>
…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>
MichaelTaylor3d
commented
Aug 5, 2026
🔴 Dual gate: reviewer PASS, security CHANGES-REQUIRED — fix in progress, do not merge yet.
Fix being added to this branch: before honoring a redirect, chain-authenticate 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>
Uh oh!
There was an error while loading. Please reload this page.
…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>
… 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>
…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>
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 whoseroothash != 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_decryptfails, 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's
PublicManifest(data-section §13, already parsed today forX-Dig-Generation) viaresource_manifest_entry, gets the requested path'slatest_root+sha256_latest+generation_index, and computesserve_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_rootis nowserve_root(so the proof still folds to the capsule it belongs to), plus a new binding —proof.leaf == expected_leafwhereexpected_leaf = entry.sha256_latestcomes 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 tolatest_root.Anti-rollback invariant (preserved)
-32005 ROOT_NOT_ANCHORED) is untouched — a regression test proves a client-supplied superseded root still fails.serve_rootis 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-Rootcontinues to report the chain-anchored tip (never the older root);X-Dig-Generationdiscloses the actual generation index.Residual (tracked, not blocking)
PublicManifest(§13) is not committed intocurrent_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 viasha256_latestread 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 inDEVELOPMENT_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 onlyindex.html:A', tip=gen1):GET /asset.js→ 200 + real plaintextB+X-Dig-Generation: 0;GET /index.html→A'+ 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.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 --lib→ 770 passed, 0 failed;cargo test -p dig-node-service --test content_serve→ 13 passed (all prior single-gen regressions green). fmt +clippy -D warningsclean; both crates build.Blast radius
verify_and_decrypt/decrypt_local/peer_serve_plaintextare private tocontent_serve.rs(all callers in-file).serve_content_plaintext(public) + theContentServertrait keep identical signatures — no wire/trait change.resource_generationsignature unchanged (delegates toresource_manifest_entry). SPEC.md §4.2 updated with the invariant + §4.2.1.Version
root
[workspace.package].version0.99.0 → 0.99.1 (patch,fix:);Cargo.lockregenerated.Generated by Claude Code