Skip to content

Index manifest signature is never verified — clients don't pin the feed signer #1

Description

@petfold

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

  1. 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".
  2. 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.jsresolveFeed(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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions