Labels: bug, enhancement
The index protocol tells readers to verify the manifest signature.
radicle-index-service/docs/index-protocol.md:49-51:
Readers verify sig with verifyMessage(JSON.stringify({...manifest, sig: ''}), sig) === indexer.signer.
The producer holds up its end — signManifest() signs EIP-191 over
canonicalManifestForSigning() (radicle-index-service/src/snapshot.ts:41-51),
and every published manifest carries sig. Canopy, the reference reader, never
checks it.
grep -rniE 'signature|verify|eip.?191|secp|ethers|recover|signer' src/ returns
nothing. loadIndex() (src/lib/index-api.js:89-109) validates exactly one
thing — manifest.schema !== "radicle-index/1" (:92) — and then trusts the
manifest and every shard it names.
So this isn't a hardening proposal; it's the reader half of the protocol being
unimplemented while the wire format already carries everything needed.
Why it matters
The stated threat model — "the index decides what you SEE; your node decides
what's TRUE" (src/lib/index-api.js:9-10) — is a sound defense for
click-through, and I'd keep it. It doesn't cover the surface the index actually
controls: the homepage directory, search results, profile pages, and the
per-RID/per-DID metadata every listing renders. Nothing in the current path
distinguishes the manifest the pinned signer produced from one anybody else
produced.
DEFAULT_INDEX_FEED (:16) pins a feed manifest reference, so feed
resolution is delegated entirely to whatever answers bzz:// (:72-80).
Canopy never learns who signed the update it got.
Proposed fix
- Verify
sig against a signer address pinned in canopy's own config, not
against manifest.indexer.signer. The spec's recipe compares to
indexer.signer, which is a field inside the document being verified — an
attacker supplies both halves and a self-consistent forgery passes. Filing
that separately against the protocol; canopy should pin regardless, which is
what docs/STATE.md means by "clients pin this".
- Additionally pin the feed owner address rather than a feed manifest
reference, resolve (owner, topic) explicitly, and verify the resolved
update's single-owner chunk client-side (address derivation + owner-signature
recovery). This is defense at the transport layer, independent of (1) — the
sibling freedom-adblock-service notes the same separation of concerns and
keeps the application signature independent of the SOC owner signature for
key-rotation headroom (src/manifest.ts:22-25).
(1) alone is small, self-contained, and unblocked — worth landing first.
What I can contribute
swarmlite's js/ package already
implements this and is dependency-free in the sense that matters here — noble-secp256k1
and js-sha3 are vendored, so nothing is added to package.json and the bundle
stays publishable to a content-addressed origin:
js/src/verify.js — BMT chunk addressing, verifying chunk store over
/chunks, verified tree reader, single-owner-chunk verification.
js/src/feeds.js — resolveFeed(api, owner, topic, { verify: true }).
Those are line-for-line ports of swarmfs's
bmt.py / join.py / feeds.py, and the JS test fixtures are generated by
swarmfs (js/test/make_fixtures.py), so the two implementations are pinned to
each other by shared vectors. That cross-check is the reason I'd be comfortable
putting this code in a security-relevant position.
Happy to open a PR wiring it into index-api.js behind the existing transport
detection.
Caveats worth deciding up front
- Step (1) needs no Swarm-specific transport at all — it's secp256k1 over a JSON
document canopy already fetches. It works on every platform today.
- Step (2) needs the
/chunks endpoint (js/src/verify.js,
js/src/index.js:33-40), and that's platform-split today: iOS routes
reserved Bee API paths straight through
(freedom-browser-ios/…/BzzSchemeHandler.swift:259-294), while desktop can only
ever produce /bzz/<ref><path> (freedom-browser/src/main/swarm/bzz-protocol.js:142),
so /chunks is unreachable there. Filed against freedom-browser separately.
Another reason to land (1) first.
- Feed latestness cannot be proven client-side (
js/src/feeds.js:38-44).
Verification proves who signed, not that this is newest; rollback is
tracked separately.
Labels: bug, enhancement
The index protocol tells readers to verify the manifest signature.
radicle-index-service/docs/index-protocol.md:49-51:The producer holds up its end —
signManifest()signs EIP-191 overcanonicalManifestForSigning()(radicle-index-service/src/snapshot.ts:41-51),and every published manifest carries
sig. Canopy, the reference reader, neverchecks it.
grep -rniE 'signature|verify|eip.?191|secp|ethers|recover|signer' src/returnsnothing.
loadIndex()(src/lib/index-api.js:89-109) validates exactly onething —
manifest.schema !== "radicle-index/1"(:92) — and then trusts themanifest and every shard it names.
So this isn't a hardening proposal; it's the reader half of the protocol being
unimplemented while the wire format already carries everything needed.
Why it matters
The stated threat model — "the index decides what you SEE; your node decides
what's TRUE" (
src/lib/index-api.js:9-10) — is a sound defense forclick-through, and I'd keep it. It doesn't cover the surface the index actually
controls: the homepage directory, search results, profile pages, and the
per-RID/per-DID metadata every listing renders. Nothing in the current path
distinguishes the manifest the pinned signer produced from one anybody else
produced.
DEFAULT_INDEX_FEED(:16) pins a feed manifest reference, so feedresolution is delegated entirely to whatever answers
bzz://(:72-80).Canopy never learns who signed the update it got.
Proposed fix
sigagainst a signer address pinned in canopy's own config, notagainst
manifest.indexer.signer. The spec's recipe compares toindexer.signer, which is a field inside the document being verified — anattacker supplies both halves and a self-consistent forgery passes. Filing
that separately against the protocol; canopy should pin regardless, which is
what
docs/STATE.mdmeans by "clients pin this".reference, resolve
(owner, topic)explicitly, and verify the resolvedupdate's single-owner chunk client-side (address derivation + owner-signature
recovery). This is defense at the transport layer, independent of (1) — the
sibling
freedom-adblock-servicenotes the same separation of concerns andkeeps the application signature independent of the SOC owner signature for
key-rotation headroom (
src/manifest.ts:22-25).(1) alone is small, self-contained, and unblocked — worth landing first.
What I can contribute
swarmlite's
js/package alreadyimplements this and is dependency-free in the sense that matters here — noble-secp256k1
and js-sha3 are vendored, so nothing is added to
package.jsonand the bundlestays publishable to a content-addressed origin:
js/src/verify.js— BMT chunk addressing, verifying chunk store over/chunks, verified tree reader, single-owner-chunk verification.js/src/feeds.js—resolveFeed(api, owner, topic, { verify: true }).Those are line-for-line ports of swarmfs's
bmt.py/join.py/feeds.py, and the JS test fixtures are generated byswarmfs (
js/test/make_fixtures.py), so the two implementations are pinned toeach other by shared vectors. That cross-check is the reason I'd be comfortable
putting this code in a security-relevant position.
Happy to open a PR wiring it into
index-api.jsbehind the existing transportdetection.
Caveats worth deciding up front
document canopy already fetches. It works on every platform today.
/chunksendpoint (js/src/verify.js,js/src/index.js:33-40), and that's platform-split today: iOS routesreserved Bee API paths straight through
(
freedom-browser-ios/…/BzzSchemeHandler.swift:259-294), while desktop can onlyever produce
/bzz/<ref><path>(freedom-browser/src/main/swarm/bzz-protocol.js:142),so
/chunksis unreachable there. Filed against freedom-browser separately.Another reason to land (1) first.
js/src/feeds.js:38-44).Verification proves who signed, not that this is newest; rollback is
tracked separately.