Skip to content

Commit 2dfe408

Browse files
test+docs: chain-verified clone/pull closes SECURITY residual #6
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 9272a74 commit 2dfe408

5 files changed

Lines changed: 124 additions & 26 deletions

File tree

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -256,9 +256,12 @@ section. `digstore anchor status` surfaces this alongside the local `anchor.toml
256256
the pointer from any module file without a local workspace. The embedded coinset URL is
257257
a hint only — local config and flags always take precedence.
258258

259-
> Note: `clone`/`pull` verify the publisher's signature over the served head, but
260-
> do **not** yet perform third-party verification of the on-chain root against the
261-
> singleton — that is a tracked follow-up (see [`SECURITY.md`](SECURITY.md)).
259+
> Note: `clone`/`pull` verify the publisher's signature over the served head **and**
260+
> verify that the served root equals the store's current on-chain singleton root —
261+
> read from the chain via the launcher id embedded in the module. They **fail closed**
262+
> on a mismatch or an unreachable chain, making the chain the authority for the current
263+
> root. (A module with no embedded on-chain pointer falls back to the head-signature
264+
> gate.) See [`SECURITY.md`](SECURITY.md).
262265
263266
---
264267

SECURITY.md

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -185,21 +185,28 @@ not mistaken for closed.
185185
rationale and must be re-evaluated if `rsa` is ever used to decrypt. The former
186186
`bincode 1.x` advisory (RUSTSEC-2025-0141) ignore has been **removed**: `bincode`
187187
is no longer in the dependency tree. Re-evaluate each audit.
188-
6. **Clone/pull head authentication rests on the module's embedded publisher key,
189-
not on the chain (§20.1 / on-chain anchoring).** Now that `store_id` is the
190-
on-chain Chia launcher id (no longer `SHA-256(publisher key)`), the integrity
191-
gate (`verify_module_root`) checks `StoreId == requested` + merkle self-
192-
consistency, and the head signature is verified against the publisher key that
193-
the *module itself carries*. A client therefore trusts the first-seen module's
194-
embedded key (self-consistency + first-use trust) rather than a key-hash binding
195-
to the requested id. The **stronger** guarantee — verifying that the served root
196-
equals the launcher singleton's *current on-chain root*, with the chain (not the
197-
serving origin) as the authority for both the publisher key and the latest
198-
authorized root — is a tracked follow-up of the on-chain anchoring feature and is
199-
not yet wired into clone/pull. Until then, a malicious origin that serves a
200-
self-consistent module with an attacker-chosen embedded key (for the correct
201-
launcher id) and a matching self-signed head is not detected by clone/pull alone;
202-
out-of-band key/launcher confirmation closes this gap in the interim.
188+
6. **Clone/pull chain-verified head authentication — Closed (2026-06-11, Phase B).**
189+
Now that `store_id` is the on-chain Chia launcher id (no longer
190+
`SHA-256(publisher key)`), the integrity gate (`verify_module_root`) checks
191+
`StoreId == requested` + merkle self-consistency, and the head signature is
192+
verified against the publisher key the *module itself carries*. On top of that,
193+
`clone`/`pull` now verify that the served root **equals the store singleton's
194+
current on-chain root**, read from the chain via the launcher id embedded in the
195+
module's `ChainState` section (Phase A). The check **fails closed** on a mismatch
196+
*or* an unreachable chain (`CliError::VerificationFailed`, exit code 5) — it never
197+
silently falls back to trusting the served head. The chain (not the serving origin)
198+
is therefore the authority for the latest authorized root, closing the earlier
199+
first-use-trust gap: a malicious origin can no longer serve a self-consistent module
200+
with an attacker-chosen root for the correct launcher id, because the on-chain root
201+
will not match.
202+
203+
*Backward-compat caveat:* a module that carries **no embedded `ChainState`** (older
204+
modules predating Phase A) has no on-chain pointer to verify against, so the
205+
chain-root gate is a no-op for it and the head-signature gate remains the authority.
206+
*Offline test seam:* verification is exercised entirely offline via the
207+
`DIGSTORE_ANCHOR_MOCK` seam (`DIGSTORE_ANCHOR_MOCK_CHAIN_ROOT` /
208+
`DIGSTORE_ANCHOR_MOCK_CHAIN_UNREACHABLE`; see
209+
`crates/digstore-cli/tests/cli_chain_verify.rs`).
203210

204211
## Running the checks
205212

crates/digstore-chain/src/singleton.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -253,9 +253,7 @@ mod sync_tests {
253253
#[tokio::test]
254254
async fn current_root_returns_synced_metadata_root() {
255255
let chain = MockChain::default();
256-
let err = current_root(&chain, Bytes32::default())
257-
.await
258-
.unwrap_err();
256+
let err = current_root(&chain, Bytes32::default()).await.unwrap_err();
259257
match err {
260258
ChainError::Chain(msg) => assert!(msg.contains("not found"), "got: {msg}"),
261259
other => panic!("expected Chain error, got {other:?}"),
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
//! Chain-verified clone (Phase B, SECURITY.md residual #6).
2+
//!
3+
//! `clone`/`pull` now verify the served root equals the store singleton's
4+
//! CURRENT on-chain root (read via the launcher id embedded in the module's
5+
//! `ChainState`), failing closed on mismatch or an unreachable chain. These
6+
//! tests exercise that gate entirely offline through the `DIGSTORE_ANCHOR_MOCK`
7+
//! seam (always set by `common::dig`) plus the per-command chain-root knobs:
8+
//! - `DIGSTORE_ANCHOR_MOCK_CHAIN_ROOT=<hex>` — the mocked on-chain root.
9+
//! - `DIGSTORE_ANCHOR_MOCK_CHAIN_UNREACHABLE=1` — fail closed (chain unreachable).
10+
//! - neither set — skip the comparison (so legacy remote tests stay green).
11+
12+
mod common;
13+
use common::*;
14+
use predicates::prelude::*;
15+
16+
/// Read the source store's host public key (48 bytes) from trusted_keys.json —
17+
/// the exact mechanism used by `cli_remote_clone_push_pull.rs`.
18+
fn host_pubkey(dir: &tempfile::TempDir) -> [u8; 48] {
19+
let text = std::fs::read_to_string(store_dir(dir).join("trusted_keys.json")).unwrap();
20+
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
21+
let hex = v[0]["public_key"].as_str().unwrap();
22+
let bytes = hex::decode(hex).unwrap();
23+
bytes.try_into().unwrap()
24+
}
25+
26+
/// Publish a committed store to a `TestServer`; return (server, store_id, root).
27+
/// The served module embeds a `ChainState` whose launcher id == the store id
28+
/// (Phase A `commit` behavior), so the chain-root gate is active for a clone.
29+
fn published_store() -> (TestServer, String, String) {
30+
let dir = tmp_dig();
31+
dig(&dir).arg("init").assert().success();
32+
std::fs::write(dir.path().join("a.txt"), b"hello").unwrap();
33+
dig(&dir).args(["add", "a.txt"]).assert().success();
34+
dig(&dir).args(["commit", "-m", "x"]).assert().success();
35+
36+
let (store_id, root) = store_id_and_root(&dir);
37+
let module_path = store_dir(&dir)
38+
.join("modules")
39+
.join(format!("{store_id}-{root}.dig"));
40+
let module = std::fs::read(&module_path).unwrap();
41+
let pubkey = host_pubkey(&dir);
42+
let sig = genesis_push_sig(&dir, &store_id, &root);
43+
let server = TestServer::start_with_module(&store_id, &root, pubkey, &module, sig);
44+
(server, store_id, root)
45+
}
46+
47+
#[test]
48+
fn clone_passes_when_onchain_root_matches() {
49+
let (server, store_id, root) = published_store();
50+
let dest = tmp_dig();
51+
let url = format!("{}/stores/{}", server.base_url(), store_id);
52+
dig(&dest)
53+
.args(["clone", &url])
54+
.env("DIGSTORE_ANCHOR_MOCK_CHAIN_ROOT", &root)
55+
.assert()
56+
.success();
57+
}
58+
59+
#[test]
60+
fn clone_fails_closed_when_onchain_root_differs() {
61+
let (server, store_id, _root) = published_store();
62+
let dest = tmp_dig();
63+
let url = format!("{}/stores/{}", server.base_url(), store_id);
64+
dig(&dest)
65+
.args(["clone", &url])
66+
.env("DIGSTORE_ANCHOR_MOCK_CHAIN_ROOT", "11".repeat(32))
67+
.assert()
68+
.failure()
69+
.code(5)
70+
.stderr(predicate::str::contains("on-chain root"));
71+
}
72+
73+
#[test]
74+
fn clone_fails_closed_when_chain_unreachable() {
75+
let (server, store_id, _root) = published_store();
76+
let dest = tmp_dig();
77+
let url = format!("{}/stores/{}", server.base_url(), store_id);
78+
dig(&dest)
79+
.args(["clone", &url])
80+
.env("DIGSTORE_ANCHOR_MOCK_CHAIN_UNREACHABLE", "1")
81+
.assert()
82+
.failure()
83+
.code(5)
84+
.stderr(predicate::str::contains("unreachable"));
85+
}

docs/superpowers/specs/2026-06-11-chainstate-in-wasm-design.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Chainstate-in-WASM — Design
22

33
**Date:** 2026-06-11
4-
**Status:** Phase A implemented; Phase B pending
4+
**Status:** Phase A implemented; Phase B implemented
55
**Scope:** Couple a store's on-chain anchor state into its compiled WASM module so the
66
module is self-describing for chain lookup, and (fast-follow) make `clone`/`pull`
77
verify served content against the on-chain singleton.
@@ -13,10 +13,15 @@ verify served content against the on-chain singleton.
1313
anchor status` reads and surfaces it; `digstore anchor inspect <module.dig>` decodes it
1414
from any module file. All Phase A tests pass; workspace builds and clippy are clean.
1515

16-
**Phase B (chain-verified clone/pull) remains the sequenced follow-up.** It is in scope
17-
and tracked — `clone`/`pull` do not yet verify the served root against the on-chain
18-
singleton. That gap is documented in `SECURITY.md` (residual #6) and will be closed when
19-
Phase B lands.
16+
**Phase B (chain-verified clone/pull) is implemented** as of 2026-06-11. `digstore-chain`
17+
exposes `current_root(launcher)` (sync the singleton, return its metadata root), and
18+
`clone`/`pull` now verify — after the existing module/head/revocation gates — that the
19+
served root equals the store singleton's current on-chain root, read via the launcher id
20+
embedded in the module's `ChainState`. The check **fails closed** on a mismatch or an
21+
unreachable chain (`CliError::VerificationFailed`, exit code 5) and is offline-testable
22+
via the `DIGSTORE_ANCHOR_MOCK` seam (`crates/digstore-cli/tests/cli_chain_verify.rs`).
23+
A module with no embedded `ChainState` (older modules) falls back to the head-signature
24+
gate. This closes `SECURITY.md` residual #6.
2025

2126
## Summary
2227

0 commit comments

Comments
 (0)