Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ edition = "2021"
# the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a
# release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet)
# keep their own independent versions — only the released binary tracks the workspace version.
version = "0.100.1"
version = "0.100.3"

# Release hardening, matching digstore: keep integer-overflow checks ON in release.
# The node parses untrusted serialized input and does offset/length arithmetic over
Expand Down
26 changes: 26 additions & 0 deletions DEVELOPMENT_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@ High-signal realizations from debugging/development: non-obvious cross-system co
sharp edges, and gotchas. Concise durable facts with context — NOT a change diary. See
`CLAUDE.md` §4.5 for the maintenance contract (a curator periodically re-verifies + prunes).

## Admit gate must recompute from CONTENT, not from the attacker's MerkleNodes digests (#2246/#2240)

`ChainAnchoredModuleVerifier` (the capsule-admit gate shared by the reshare-admit pull AND the
`cache.pushCapsule` land via `verify_capsule_integrity`) once only byte-compared the capsule's committed
`CurrentRoot` header against the chain-anchored root. A first fix RECOMPUTED
`MerkleTree::from_leaves(decode_merkle_leaves(MerkleNodes)).root()` — but that was HOLLOW and adversarial
verification refuted it. Rule 4 already forces `committed_root == chain_root` (a public value); a
one-leaf tree's root IS that leaf (`from_leaves` does NOT re-tag leaves — `LEAF_TAG` is applied only in
`build`, and there is no fold for a single leaf); and `decode_merkle_leaves` accepts arbitrary bytes. So
`MerkleNodes = [chain_root]` plus an empty or garbage `ChunkPool` recomputed to the committed root FOR
FREE — admitting, caching, serving, and DHT-announcing a contentless phantom-holder capsule. Trusting
attacker-supplied digests for an admit decision proves nothing.

The gate now recomputes the root from the SERVED CONTENT: for each `KeyTable` (id 8) entry, gather its
chunk ciphertexts from the `ChunkPool` (id 9) via `datasection::read_chunk`, `leaf =
merkle::resource_leaf(serving::concat_output(cts))`, SORT the `(static_key, leaf)` pairs ASCENDING by
`static_key`, then fold `MerkleTree::from_leaves`. The sort is load-bearing: the producer
(`digstore-store` `store.rs`) sorts `resource_leaves.sort_by_key(|r| r.0)` before folding, but KeyTable
storage order is NOT guaranteed sorted, so recomputing in storage order yields the wrong root for ≥2
resources. `MerkleNodes` is retained ONLY as a defense-in-depth cross-check (its leaves must equal the
sorted content leaves, since the served inclusion proofs are generated from it) — never as the trust
anchor. Fail-closed on an absent `KeyTable`/`ChunkPool`, an out-of-range chunk index, an undecodable
section, or a `MerkleNodes`↔content mismatch. A legitimately EMPTY store (no entries) folds to
`from_leaves(vec![]).root() == sha256(&[])` and MUST be admitted, not errored (§5.1). Lesson: an
integrity gate must bind the BYTES it will serve, never a sibling digest field the same attacker chose.

## Local-RPC authz — holder-REVEALING reads gate too, not just mutators (#2108)

Holder-revealing `cache.*` READS must be control-token-gated over the HTTP (loopback) surface, not
Expand Down
24 changes: 23 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,17 @@ OPENED push MUST additionally prove the caller is the store's **§21.6/§21.9 au
the target store: the pushed module commits a publisher public key whose `SHA-256` DERIVES `store_id`
(`store_id = sha256(publisher_pubkey)`, the DIG store-identity derivation), AND the request carries a
BLS signature over `SHA-256(root || store_id)` that verifies under that key. The merkle-integrity
check RECOMPUTES the merkle root from the capsule's own SERVED CONTENT — for each `KeyTable` entry,
`leaf = resource_leaf(concat_output(its ChunkPool ciphertexts))`, the leaves sorted ASCENDING by
`static_key`, folded with `MerkleTree::from_leaves` — and refuses the push unless it reproduces the
committed `CurrentRoot`. The attacker-supplied `MerkleNodes` digests are NEVER trusted for this
decision (retained only as a defense-in-depth cross-check that the served inclusion proofs match the
content): a single-leaf `from_leaves(vec![x]).root() == x` meant a `MerkleNodes = [chain_root]` plus an
empty/garbage `ChunkPool` recomputed to the committed root for free, admitting a contentless
phantom-holder capsule (#2246/#2240). An absent `KeyTable`/`ChunkPool`, a chunk index the pool cannot
satisfy, an undecodable section, or a `MerkleNodes`↔content mismatch fails closed; a legitimately EMPTY
store folds to `from_leaves(vec![]).root() == sha256(&[])` and passes. A header naming the chain root is
not proof the bytes hash to it. This
check gives INTEGRITY, never AUTHORITY — without the writer check an opened node would be an
unauthenticated cache-poison + DHT-announce-amplification surface (the #179/#1576 class). A push that
arrives on the peer surface with no signature, a signature under a key that does not derive
Expand Down Expand Up @@ -1912,7 +1923,18 @@ NOT make naming a near key cost an on-chain mint: a peer may name any `(store, r
near our `peer_id` and, on an opted-in node, drive a CHEAP DHT provider-lookup for it (a key that names
no real store simply finds no providers and the pull fails there — the low cost is "no providers", not
a per-key mint). The on-chain-mint + merkle cost binds a LATER step — actually becoming a cached
HOLDER: a pulled module is bound to its `root` by merkle verification and is never SERVED as current
HOLDER: a pulled module is bound to its `root` by merkle verification — the admit gate
(`ChainAnchoredModuleVerifier`, shared by the reshare-admit pull AND the `cache.pushCapsule` land via
`verify_capsule_integrity`) RECOMPUTES the merkle root from the capsule's own SERVED CONTENT (per
`KeyTable` entry, `leaf = resource_leaf(concat_output(its ChunkPool ciphertexts))`, leaves sorted
ASCENDING by `static_key`, folded via `MerkleTree::from_leaves`) and refuses (`NotAnchored`) unless it
equals the committed `CurrentRoot`. The attacker-supplied `MerkleNodes` digests are NEVER trusted for
the admit decision (only cross-checked for served-proof consistency): trusting them let a single-leaf
`MerkleNodes = [chain_root]` plus an empty/garbage `ChunkPool` recompute to the committed root for free
and admit a contentless phantom-holder capsule (#2246/#2240). So a header-matching but
tampered/incomplete `.dig` (or one with an absent `KeyTable`/`ChunkPool`, an out-of-range chunk index,
or an undecodable section) is never admitted; a legitimately EMPTY store folds to `sha256(&[])` and is.
It is never SERVED as current
unless `root` equals the chain-anchored tip (the serve-time read-path pin, §7.10d(a) / §14.4). So the
worst a near-key attacker extracts from an opted-in node is a bounded, single-flighted, byte-capped
pull of REAL near-neighbourhood content of a possibly-old generation — never caching of fabricated,
Expand Down
Loading