Uh oh!
There was an error while loading. Please reload this page.
feat(peer): per-frame range verification metadata + serve observability (#1577/#1595) - #87
Conversation
Co-Authored-By: Claude <noreply@anthropic.com>
5ec5f11 to
613795dCompareTwo changes to the peer-facing read surface, in the same handler. #1577 — every `dig.fetchRange` frame now carries its own verification metadata (`total_length`, `chunk_lens`, `root`, the whole-resource `inclusion_proof`, and a TRUTHFUL `first_chunk_index`/`chunk_index`), where it previously rode the `offset == 0` frame alone. A downloader fetches ranges in parallel from many holders, so a peer serving only `offset > 0` frames declared no root and the client's consistency check had nothing to compare — a wrong-generation source was undetectable until the whole resource had been paid for in bandwidth. The served window is still EXACTLY the requested span (a verifying client fails a range closed on any length but the one it planned), and the metadata is additive, so a client reading only offset/length/bytes/complete is unaffected. No per-CHUNK proof (`range_proof`) is emitted, deliberately: the generation tree's leaves are per-RESOURCE (`resource_leaf` = SHA-256 of a resource's whole ciphertext, folded by `MerkleTree::from_leaves`), so a chunk has no committed digest to prove and `MerkleTree::build` has no callers in the store at all. An unfoldable proof would invite a client to trust bytes it cannot check, so the field stays absent rather than false. Recorded in SPEC.md with the store-format prerequisite real per-chunk proofs would need. #1595 — the peer-facing serves now announce their outcome. An inbound `dig.fetchRange` logs the requesting peer_id, the content id, the requested offset, and one of served / not-held / bad-range / redirect (with served bytes, frame count and whether a proof was attached); `dig.getAvailability` logs the queried id, the answer, and the reason (held / not-held / rejected-non-canonical-key / store-roots). Ids, counts and outcomes only — never a payload byte, never a proof. The holder previously emitted nothing at all, so a read could only be proven with tcpdump and "holder inbound = zero" stayed ambiguous. Refs #1577, #1595, #1425, dig-node#104 Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES-REQUIRED (recorded as a comment review - GitHub rejects request-changes from the PR author identity, 422).
Independent correctness review - CHANGES-REQUIRED (2 findings)
The central claim is CONFIRMED independently, in BOTH store crates:
resource_leaf(ciphertext) = SHA-256(whole resource ciphertext)-digs/crates/digstore-core/src/merkle.rs:140, mirrored atdig-capsule/src/imp/core/merkle.rs.- Every production tree is
MerkleTree::from_leaves(resource_leaves)-digstore-stage/src/lib.rs:233,digstore-store/src/store.rs:226,digstore-compiler/src/pipeline.rs:84+data_section.rs:328,digstore-guest/src/content.rs:410; identical set indig-capsule(stage/mod.rs:239,store/store.rs:227,compiler/pipeline.rs:84,guest/content.rs:412,reader.rs:122). MerkleTree::build(chunks)(the chunk-leaf constructor) has ZERO non-test callers in either crate.dig-downloadreads norange_proof;MerkleVerifier::verify_range(verify.rs:307-340) is structural, and - importantly - the orchestrator passes its OWN plannedrange.chunk_start, not the peer-supplied index (orchestrator.rs:848), so omittingfirst_chunk_indexcannot be exploited.ResourceCommitment::check_consistent(verify.rs:165) only compares roots when BOTH areSome, so a metadata-lessoffset > 0frame genuinely WAS unchecked - the gap this PR closes is real, and the fix is the right one.
Additivity holds: dig_nat::RangeFrame (mux.rs:130-156) has no deny_unknown_fields, all new fields are Option + #[serde(default)], chunk_index still always rides frame 0 (chunk_index_at(lens, 0) == Some(0)), and nothing is falsified. Fail-closed is preserved; the served window is still exactly the planned span. Tests are non-vacuous (Part A would panic in commitment_from_frame on a pre-change mid-resource frame; Part B captured an empty log). Logging carries ids/counts/outcomes only, with a real negative assertion; the availability batch is capped (MAX_AVAILABILITY_ITEMS), so the per-item INFO line is not a peer-driven log amplifier. Minor bump + Cargo.lock agree; all 13 checks green; zero pre-existing threads.
Two things block, both inline:
- A cross-repo contract now contradicts itself (SS4.1 coherence) -
dig-rpc-protocol0.5.0 normatively mandates the opposite of the new dig-node SPEC text. frames: 0logged on the fetch-through serve - a false count in the very observability contract this PR defines.
Non-gating notes (no thread, do not block): (a) the refusal/redirect outcome lines log off (the advanced offset) while the served line logs offset (the requested one) - peer.rs:1162/1249/1265 vs 1191; SPEC 20.2a says "the requested offset", so a grep offset= diagnosis reads two different meanings. (b) range_frame_served logs first_chunk_index = 0 via unwrap_or_default() when the frame is unaligned; chunk_aligned=false saves it, but the same omit-rather-than-falsify rule the frame itself follows would read better here. (c) chunk_lens + the base64 inclusion_proof now repeat on EVERY frame of a multi-frame stream while the FCFS limiter accounts only resource bytes (peer.rs:1176) - a small unmetered per-frame overhead worth a follow-up ticket for a many-chunk resource.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
… (#1595) The #1595 serve log put peer-supplied identifiers straight into a tracing record with Display, at INFO, on every refusal. Inbound frames are capped only at 64 KiB, so any peer could (a) push ~64 KiB of junk into the operator's log per request and (b) embed a newline to FORGE A WHOLE RECORD -- a counterfeit 'outcome=served proof_attached=true' for a request that served nothing. That destroys the one property the log exists for: being evidence. The availability path was worse by construction, logging a root it had ALREADY established could never name a capsule. Ids now reach the log only through SafeId, whose Display emits the value when it is a canonical 64-hex content id and a short fixed sentinel otherwise, so a record is bounded and control-character-free by construction rather than by remembering to escape at each site. ServeTarget holds SafeId fields, making a verbatim id unrepresentable on this surface. The canonical-hex predicate is now one crate-level definition shared with the path-traversal guard. The fetch-through arm also lied in the outcome vocabulary: it logged a fixed frames=0, and because stream_fetched_range answers a bad range with an error frame and Ok, a refused range was reported as 'served bytes=0'. It now returns a StreamOutcome carrying the real frame count, byte total, and refusal, and the call site reports that verdict. Also: the outcome line always reports the offset the caller REQUESTED (one line per request), and an unaligned frame omits first_chunk_index rather than defaulting to 0 and claiming chunk 0. Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
Round 2 re-review at d78d92e — VERDICT: PASS
(Recorded as a comment review; GitHub rejects an approve/request-changes event from the PR-author identity, 422. Branch protection requires 0 approvals, so merge is gated on checks green + zero unresolved threads, both of which now hold.)
Thread #1 (SPEC.md:2973 cross-repo contradiction) — CLEARED
Verified dig-rpc-protocol PR#6 8a55884 (0.5.0 -> 0.5.1, doc-only, Cargo.toml + lock together, 8/8 checks green). All three contradictions I flagged are gone and the replacement prose matches the implementations I read:
range_proofis now RESERVED / MUST NOT emit, with the per-RESOURCE leaf reason and the store-format prerequisite named — consistent withdigstore-core/src/merkle.rs:140+dig-capsule/src/imp/core/merkle.rs:137and the zero non-testMerkleTree::buildcallers in both crates. Your correction on #1601 (a chunk tree whose root BECOMESresource_leafis NOT additive, because wasm re-derives it byte-identically andPublicManifest.sha256_latestis pinned to it) is right and materially better than what my finding implied.- the widen clause is now never-widen, matching
dig-downloadverify.rs:317-322(exact-length fail-closed) and dig-node SPEC. - the
rootclause is now accurate:establish_commitment(dig-download/src/orchestrator.rs:917-928) skips any provider whose declared root differs fromcontent_root_hex()BEFORE adopting metadata, so a peer-declared root can only cause rejection, never move the pinned root.first_chunk_index/chunk_index/inclusion_proof/total_length/chunk_lensdoc edits all now say MAY-appear-on-any-frame + omit-when-unaligned, which is exactly what this PR serves. Second pass over the rest of §6/§6.1 andRangeFramefound nothing else contradicting the implementation.
Thread #2 (frames: 0) — CLEARED
StreamOutcome { bytes, frames, refusal } + as_serve_outcome is a better fix than the one I suggested: it also closes a defect I did NOT catch — the fetch-through bad-range path answers with an error frame and Ok(..), so the old code would have logged outcome=served served_bytes=0 for a request that served nothing. RangeOutcome::from_error de-duplicates the code->outcome mapping so one code cannot surface under two names. Both new tests assert through the real production pair (stream_fetched_range then as_serve_outcome): frames=3/served_bytes=300, and outcome=bad-range with zero served.
D1 (SafeId) — reviewed, and it holds
Not my finding, but it touches the same lines so I checked it: ServeTarget HOLDS SafeId, so no raw &str id is representable on that surface (the right shape — safety by construction, not by remembering to escape); is_canonical_hex_id is now one crate-level predicate shared with the path-traversal guard (lib.rs:944-953); the availability path wraps all three ids. Peer attribution is NOT lost: conn_key is the mTLS peer_id (peer.rs:948), 64-hex, so it renders verbatim; empty renders <absent>. The forgery test asserts exactly one outcome record AND zero outcome=served, which is the property that matters.
Nits 1 and 2 verified fixed (offset is now the requested offset at all four call sites; unaligned frames omit first_chunk_index instead of logging 0, with chunk_aligned=false retained). Nit 3 deferral to #1604 — agreed, threading the written size out of write_framed belongs to the #1436 pacing path, not this radius.
One residual, non-gating, no thread: the corrected dig-rpc-protocol prose says the client "PINS it before fetching" unconditionally, while establish_commitment only enforces that when the content id carries a root (want_root is Some); with a root-less content id the first answering peer's root becomes the commitment. dig-node always serves root-addressed ids so nothing is wrong today, and the new text is strictly safer than what it replaced — worth a one-line "when the content id names a root" qualifier next time that file is touched.
12/12 checks green on d78d92e, both of my threads replied-to and resolved, zero unresolved threads. PASS — clear to squash-merge (merge the dig-rpc-protocol 0.5.1 doc PR too; it is the coherence half of this family).
…not a redefined leaf (#1577) Co-Authored-By: Claude <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.
What this PR does
Two changes to the peer-facing read surface, in the same handler: the per-range verification contract of a
dig.fetchRangeframe (#1577) and the serve-side observability that makes a read diagnosable from logs (#1595 / dig-node#104).#1577 — what was ALREADY served vs what changed
Already served (so this is not "no proofs on the wire"):
Node::fetch_range_framealready emittedtotal_length,chunk_lens,chunk_index, the base64 whole-resourceinclusion_proof(fromContentResponse::merkle_proof) androot— but only on theoffset == 0frame, withchunk_indexhardcoded to0. The fetched-through serve (FetchedResource::range_frame) mirrored it.Blocking finding: a per-CHUNK merkle proof is not derivable from the store format, so none is emitted. The chain-anchored generation tree commits RESOURCES, not chunks:
digstore_core::merkle::resource_leaf(ciphertext)=SHA-256of a resource's WHOLE ciphertext (merkle.rs:140).MerkleTree::from_leaves(resource_leaves)—digstore-stage/src/lib.rs:233,digstore-compiler/src/pipeline.rs:84+data_section.rs:328,digstore-guest/src/content.rs:410.MerkleTree::build(chunks)— the chunk-leaf constructor the #1437 plan pointed at — has zero callers anywhere in the store. It is dead code, not the committed tree.Verifying a single chunk therefore requires every OTHER chunk's bytes, so no
range_proofentry could be folded to the on-chain root by any verifier. Emitting one would be unverifiable decoration that invites a client to trust bytes it cannot check — the inverse of fail-closed — so the field stays ABSENT rather than false, with the reason and the store-format prerequisite recorded inSPEC.md+ therange_framemodule header. The consumer side confirms the same picture:dig-download0.7.4 does not readrange_proof/first_chunk_indexat all (that is #1462), andMerkleVerifier::verify_rangeis structural only, with the chain binding done once at completion.What was genuinely missing, and is now fixed: the metadata rode the FIRST frame only, while a downloader fetches ranges in PARALLEL across peers. A peer serving only
offset > 0frames declared noroot, soResourceCommitment::check_consistenthad nothing to compare and a wrong-generation source was undetectable per-range. Now:total_length+chunk_lens+root+inclusion_proof, so any frame from any holder at any offset can both establish the commitment and be checked against it on arrival.first_chunk_index(the dig-rpc-protocol 0.4 field) and its pre-existingchunk_indexalias carry the true first chunk index of the served span, replacing the hardcoded0— and are omitted entirely when the window starts mid-chunk, rather than asserting an alignment the client's ownverify_rangewould contradict.Both serve paths (locally-held + fetched-through) now build this in ONE place,
seams::content::range_frame, so they cannot skew.Proof shape on the wire: unchanged — base64 of
digstore_core::MerkleProof::to_bytes()(leaf + bottom-up sibling path + root), verified by the node's ownDigstoreProofVerifieragainstresource_leaf(assembled_ciphertext). Field names match the publisheddig_rpc_protocol::types::RangeFrame(0.4/0.5) exactly, with no serde renames.Additive (store-format compatibility rule): no existing field changed meaning, the served window is still EXACTLY the requested span (never widened to a chunk boundary —
verify_rangefails closed on any length but the planned one), and a client reading onlyoffset/length/bytes/completeis unaffected.#1595 — the observability lines added
Every peer-facing serve now announces its outcome (new
seams::dig_peer::serve_logowns the vocabulary):dig.fetchRange, INFO, one line per request:peer_id,store_id,root,retrieval_key,offset, andoutcome=one ofserved(withserved_bytes,frames,proof_attached),not-held,bad-range, orredirect(each with the cataloguedcode+ a shortreason). DEBUG adds the inbound request line and a per-frame line (offset, bytes,first_chunk_index,chunk_aligned).dig.getAvailability, INFO, one line per answered item: the queried content id,available=, andreason=one ofheld/not-held/rejected-non-canonical-key/store-roots(withheld_roots) — so "we do not hold it" is distinguishable from "that key could never name a capsule".Ids, counts and outcomes ONLY: no payload bytes, no proofs, no secrets. Every id logged is a value the peer itself supplied on the wire.
Tests — RED to GREEN
Part A (all driving the REAL client verifier — dig-download's
MerkleVerifierover the node'sDigstoreProofVerifier— never a hand-rolled assertion):a_mid_resource_frame_carries_metadata_the_real_client_verifier_acceptsrootwasNullon a mid-resource frame;first_chunk_indexabsentfirst_chunk_index=1, realverify_rangeacceptsthe_served_proof_binds_the_assembled_resource_to_the_generation_rootfirst_chunk_indexNullon frame 0verify_resourcebinds the assembly to the roota_tampered_range_fails_closed_against_the_served_proofa_wrong_generation_mid_resource_frame_is_now_detectablecheck_consistenthad nothing to rejectan_unaligned_offset_asserts_no_chunk_index_rather_than_a_false_onethe_frame_data_fields_are_unchanged_for_a_client_that_ignores_the_new_metadatarange_frame_later_window_still_carries_metadata_and_bounds_offsetPlus 6 unit tests on
chunk_index_at/attach_verification(boundaries, mid-chunk refusal, terminal offset, empty chunk table, data fields untouched, omit-what-is-unknown).Part B (real emitted records captured through a
tracing_subscribersink — all five were RED with a completely EMPTY log, which IS the #1595 defect):an_inbound_fetch_range_logs_who_asked_for_what_and_what_was_servedan_inbound_fetch_range_for_content_we_do_not_hold_logs_the_refusalan_inbound_availability_query_logs_the_answer_and_whyan_availability_query_naming_a_non_canonical_key_logs_that_it_was_rejectedserve_logs_carry_ids_counts_and_outcomes_but_never_payload_or_proof— asserts the served payload's base64 and the proof's base64 are ABSENT from the log while the serve is still provabledig-node-corelib: 348 to 369 tests, all green. Full workspacecargo test --lockedgreen;cargo clippy --workspace --all-targets --locked -- -D warningsclean;cargo fmt --all --checkclean.Blast radius checked
Node::fetch_range_frame— callers:NodeResponder::stream_range(the peer range stream) and thedig.fetchRangeJSON-RPC dispatch. Both covered.FetchedResource::range_frame— caller:stream_fetched_range(fetch-through). Covered; its one contract test updated deliberately.stream_fetched_rangesignature now returns the streamed byte count (io::Result<u64>) so the caller can report the serve outcome — private free fn, 1 production caller + 4 test call sites, all verified.Node::availability_answer— callers:availability_batch(peerdig.getAvailability+ the JSON-RPC path). Behaviour unchanged; only a reason is derived and logged.PeerRpcResponder(5 implementors) — not modified: the availability logging sits where the reason is known rather than widening the trait.No HIGH/CRITICAL-risk edit: no wire field changed meaning, no public API removed or renamed, no crypto primitive touched.
Version
0.58.10to 0.59.0 (minor — a new served capability),dig-node-core0.18.5to0.19.0, withCargo.lockre-locked in the same commit.Coherence
SPEC.mdgains the per-range verification contract (including the normative "MUST NOT emit a per-chunk proof", with why) and a new section 20.2a making a silent peer-facing serve a specification violation.DEVELOPMENT_LOG.mdrecords the "a serve that logs nothing is indistinguishable from a request that never arrived" and "the generation root commits resources, not chunks" lessons.SYSTEM.mdneeds no change — the wire fields already shipped in #1437 and the field names match the publishedRangeFrameexactly.Refs #1577, #1595, #1425, dig-node#104
Gate follow-up —
d78d92e(D1 log injection, D2 truthful outcome, D3 cross-repo SPEC)D1 (HIGH, peer-reachable): the serve log could be FORGED by any peer
ServeTarget::from_range_requesttookstore_id/root/retrieval_keystraight off the wire andrange_outcomeprinted them with%(Display, unescaped) at INFO on every refusal. Inbound frames are capped only at 64 KiB, so any peer could (a) push ~64 KiB of junk into the operator's log per request and (b) embed\nto forge a whole record — a counterfeitoutcome=served ... proof_attached=truefor a request that served nothing. That defeats the exact property #1595 exists for: the log as evidence (the e2e harness greps these lines). The availability path was worse by construction, logging arootit had ALREADY established could never name a capsule.RED confirmed before the fix — one crafted request produced THREE forged records:
Fix shape: made it unrepresentable, not escaped per site. A new
SafeIdnewtype wraps every peer-supplied id, and itsDisplayemits the value only when it is a canonical 64-hex content id, else a short fixed sentinel (<non-canonical>, or<absent>when the request carried no such field).ServeTargetnow HOLDSSafeIdfields, so a call site cannot log a raw id on this surface, and the availability path wraps its three ids identically. The form is bounded and control-character-free by construction. The canonical-hex predicate is now ONE crate-level definition (is_canonical_hex_id) shared with the existing path-traversal guard, so "canonical" cannot come to mean two things. Nothing diagnostic is lost: a non-canonical id could never have named held content, and the outcome/reason on the same line already says why the request failed; a canonical id is still logged in full.Tests:
a_peer_supplied_id_can_never_forge_a_second_log_line(asserts EXACTLY ONE outcome record and that it is the refusal, plus zerooutcome=served),an_oversized_peer_supplied_id_cannot_amplify_the_log(16 KiB id → emitted lines stay under 2 KiB),an_availability_query_cannot_forge_a_log_line_either, and unit tests pinning the sentinel for newline/bulk/wrong-length/traversal inputs and full fidelity for canonical (incl. mixed-case) ids.D2: the fetch-through path lied in the outcome vocabulary
It logged a hardcoded
Served { frames: 0 }(contradicting theframesdefinition this PR's own SPEC §20.2a adds), and becausestream_fetched_rangeanswers a bad range with an ERROR FRAME andOk, a refused range was reported asoutcome=served bytes=0— reintroducing the very ambiguity #1595 removes.stream_fetched_rangenow returnsStreamOutcome { bytes, frames, refusal };StreamOutcome::as_serve_outcomemaps it to the truthful log outcome, and refusals go throughRangeOutcome::from_error, shared with the local-hold path so one error code can never be reported under two outcome names. The 4 test sites needed no signature change.The reviewer correctly noted the field was UNDEFENDED (no fetch-through log test existed at all). Added
a_fetch_through_serve_logs_the_real_frame_and_byte_counts(300 bytes in 100-byte windows →served_bytes=300,frames=3) anda_fetch_through_bad_range_logs_a_refusal_not_a_serve(→outcome=bad-range, zerooutcome=served).D3: cross-repo SPEC contradiction — sibling PR
dig-rpc-protocol0.5.0 still documentedrange_proofas per-chunk proofs "present on ANY frame", the server "expands a requested byte range to the covering whole-chunk span", AND "a verifier MUST NOT trustroot" — the opposite of this PR and of dig-download 0.7.4's fail-closed clip contract. Corrected in the doc-only sibling DIG-Network/dig-rpc-protocol#6 (v0.5.1, release-first, §4.1):range_proofRESERVED + MUST NOT emit with the prerequisite named (a per-resource chunk-level commitment, #1601); the window is exactly the requested span; androotclarified as a consistency check against the client's PINNED root (a peer-declared root can only cause REJECTION, never move the pin).Also in
d78d92e(reviewer nits)first_chunk_indexinstead ofunwrap_or_default()silently claiming chunk 0 — the same omit-what-cannot-be-stated-truthfully rule the frame metadata itself follows.servedmay never name a request that served nothing.NOT addressed (reported for ticketing, radius deliberately not expanded): per-frame
chunk_lens+inclusion_proofrepetition is not metered by the FCFS rate limiter (peer.rsmetersthis_len, the resource bytes only), so the metadata overhead every frame now carries is unmetered. Metering it needs the serialised frame size, which means either double-serialising or threading the size out ofwrite_framed— a real change to the pacing path, not a one-liner.Blast radius checked (gitnexus disabled per §2.0 override — grep +
impact-equivalent code read)ServeTargetfields (peer/store/root/retrieval_key):pub(crate), constructed at exactly ONE site (peer.rsstream_range) and read only by the threeserve_logemitters + this module's tests. Type change contained toserve_log.rs+peer.rs.stream_fetched_range: 1 production caller (theMissOutcome::Fetchedarm) + 4 test sites, all inpeer.rs; the return type widened fromu64toStreamOutcomeand every site compiles unchanged apart from the caller that now reads the verdict.is_canonical_capsule_key: 3 call sites (sync_eligible,availability_answer, and the new predicate). Behaviour identical — only its internals now delegate to the extractedis_canonical_hex_id; no signature change.availability_answered: 1 call site (lib.rsavailability_answer); signature unchanged (&strargs are wrapped inside the module).range_frame.rs's per-frame metadata, the crypto/verify path, #1462's client-side scope.SafeIdispub(crate),StreamOutcomeis private).Verification
cargo test -p dig-node-core --lib→ 376 passed, 0 failed (369 atfdb9bc4+ 7 new).cargo clippy --workspace --all-targets --locked -- -D warningsclean.cargo fmt --all --checkclean.