Skip to content

feat!: sign every gossiped peer-advert and verify it on receipt - #226

Merged
Mearman merged 8 commits into
mainfrom
feat/authenticated-gossip
Sep 20, 2026
Merged

Mearman merged 8 commits into
mainfrom
feat/authenticated-gossip

Conversation

@Mearman

@Mearman Mearman commented Sep 20, 2026

Copy link
Copy Markdown
Member

The hub registered whatever device id a connection gossiped, last one wins, and the adverts themselves were unsigned, so any client could publish an entry under someone else's id and take their place in everyone's directory. Receivers applied gossiped adverts without checking them either. This signs each advert with the key of the device it names, and makes every receiver check one for itself.

peer-advert gains two mandatory fields, identity-key and signature. An advert is valid when sha256 of the embedded public key equals device and the signature verifies under that key, which makes it self-certifying exactly as a capability token already is: you can check one with no prior contact with the device and no directory to consult. That matters because an advert legitimately travels beyond its author, so binding it to the connection it arrived on could never have worked. A hub re-broadcasts what it receives, and a gateway forwards the adverts of the local peers it fronts.

The signature covers the domain-separated canonical encoding of the advert with signature removed, so the open extension tail is covered too and nothing gossiped in it can be altered, added or dropped. I did not reuse tokens.cddl's COSE Sig_structure, which every other signature here does reuse. The tail is an open * tstr => any map, so you can craft an advert whose encoding also satisfies token-claims, and a Sig_structure over that would be replayable as a capability token's signed payload. The wire-mesh/peer-advert/v1 prefix plus a zero byte makes the two byte strings disjoint by construction.

Signatures alone do not stop someone replaying a victim's genuine advert to steal its route, since the replayer cannot change snapshot-seconds without breaking the signature but does not need to. So the hub only lets an advert take over a device registered to a different live connection when its snapshot-seconds is strictly greater, and lets the connection already holding it update on greater-or-equal, so an unchanged heartbeat from the rightful owner still refreshes what the hub replays. The known gap, documented in the hub's module header and the spec: a device reconnecting within the same second while its old connection is still open does not displace it until its next gossip.

verifyPeerAdvert is one shared function in core. The hub runs it before registering, forwarding or replaying an advert, and the session runs it before anything reaches its directory, because the hub is a facilitator for the directory rather than an authority over it. A frame with a mix of good and bad entries keeps the good ones. A frame where everything fails is dropped whole, with no fan-out and no catch-up frame back to the sender.

adverts.v1.json is a new conformance file, the only one here with real key material rather than filler. Each vector records the signing input and the verdict a verifier must reach, because two implementations that rebuild those bytes even slightly differently reject each other's every advert while passing all their own tests. The generator builds the input from the spec text rather than importing either implementation, and TS and Rust are both checked against it. Ed25519 from a fixed seed, since ECDSA's fresh nonce would break CI's regenerate-and-diff.

No compatibility path, as discussed: createRelayHub now requires an identity option carrying verify/deriveDeviceId, and an advert without the two new fields does not decode at all.

Two things worth flagging. conformance/generate.ts was already at the max-lines cap, so the advert vectors and the file writer moved into their own modules; regenerating produces byte-identical output. And agent-comms will break on release, though not badly: it forwards adverts verbatim and never builds one, so the gateway path is fine, but createRelayHub() in its test helper needs the new option and one hand-built PeerAdvert literal needs the two fields. Its own echo-back of hub-learned adverts becomes dead work under the strict freshness rule, which is a fix rather than a regression.

just lint, just typecheck, just test and just conformance are all green, including 70/70 vectors in both languages.

Fixes #225

…signature

peer-advert gains two mandatory fields, identity-key and signature. An advert
is valid only when sha256(identity-key.public-key) equals `device` and the
signature verifies under that key over a domain-separated canonical encoding
of the advert with `signature` removed, so the whole extension tail is covered
too.

The construction is deliberately not tokens.cddl's COSE Sig_structure: an
advert's open `* tstr => any` tail lets an attacker craft one whose encoding
also satisfies token-claims, and a Sig_structure over it would be replayable
as a capability token's signed payload. The "wire-mesh/peer-advert/v1" prefix
plus a zero byte makes the two byte strings disjoint by construction.

Records the hub's snapshot-seconds rule against replay of a genuine advert,
and its known limitation for a same-second reconnect.

BREAKING CHANGE: peer-advert now has two mandatory fields, so an advert built
without them no longer decodes and every advert must be signed by the device
it names.
peer-advert.ts is the one place that decides what a valid advert is, so the
hub and every session agree byte-for-byte. It builds the signing input (the
"wire-mesh/peer-advert/v1" context plus a zero byte, then the canonical CDE
encoding of the advert without its signature), signs an advert, and returns a
verdict for one, never throwing: an attacker picks alg and public-key, so a
key import that fails is a refusal like any other.

A session signs its self-advert and checks every advert before it reaches the
directory. The hub checks every advert before registering, forwarding, or
replaying it, forwards only the accepted ones and only verbatim, and drops a
frame whose adverts all fail rather than answering it with a catch-up dump.

A verified advert still has to be fresh to take a device's registration over:
strictly newer from a different connection, so replaying a victim's genuine
advert cannot steal its route, and newer-or-equal from the connection that
already holds it, so an unchanged heartbeat still refreshes what is held.

BREAKING CHANGE: createRelayHub now requires an `identity` option carrying
verify/deriveDeviceId, and a peer-advert built without `identity-key` and
`signature` is refused by every receiver.
… the wire crate

The wire crate gains both fields, the signing-input construction, and a
builder shared between it and the encoder so the bytes a verifier rebuilds
cannot drift from the bytes that went out. Rust core gains verify_peer_advert,
the mirror of the TypeScript one, reaching a verdict for every input.

adverts.v1.json is a new vector file built on real Ed25519 material rather
than filler, since what an advert signs over has to be pinned across
implementations, not merely shaped right: each vector records the signing
input and the verdict a verifier must reach. It covers a valid advert, one
with an extension, a tampered extension value, an extension added after
signing, a genuine signature by a key that does not hash to the named device,
and a corrupted signature. The generator derives its key from a fixed seed and
uses Ed25519 because those signatures are deterministic, so CI can regenerate
the file and diff it. It builds the signing input from the spec text rather
than importing either implementation, which is what makes agreement between
them evidence of anything.

verify.test.ts still checks only the round trip every vector file shares; the
signing input and the verdict are checked by each implementation's own
conformance run.

BREAKING CHANGE: PeerAdvert has two new mandatory fields in Rust as well, so
an advert decoded or constructed without them is rejected.
…ecked

The repo README gained a bullet for it alongside the other guarantees, core's
README lists the new domain module with the rest of the verification logic,
and the Cloudflare hub's README says what its own verifier is for and why a
client still checks every advert independently of it.
…real keys

New coverage for the rules themselves: the hub drops a forged advert without
registering or forwarding it, keeps the genuine entries of a frame that also
carries a forged one, accepts a gateway's advert for a local peer it does not
own, refuses a replayed advert the route it names, moves that route for the
device's own newer advert, and still refreshes from the connection already
holding it. A session drops an advert whose signature is broken or whose key
does not hash to the device it names, and its own self-advert is one another
session records. validateGossipExtensions is covered directly, including the
two newly reserved names.

Existing hub and session fixtures now mint real Ed25519 identities, because an
advert naming a device-id no keypair produced can only ever exercise the
rejection path. Tests that never verify an advert keep synthetic filler.
FakeConnection gained an idle getter and a settle helper: a signature check
finishes a macrotask later than the frame that triggered it, so counting ticks
was a race.
…ut of the generator

generate.ts sits at this repo's max-lines cap, so the advert vectors go to
adverts.ts and the writer, with each output file's description beside it, to
vector-files.ts. Regenerating produces byte-identical output.

generate.ts imports them by their real .ts extension, which is what Node's own
TypeScript support resolves; nothing here is emitted, so allowImportingTsExtensions
costs nothing.
Also narrows the session test's directory accessor with a list predicate
rather than an Array.isArray check, which widens an unknown to any[] and made
the return unsafe. Prettier reflowed a few lines through eslint's own fix.
@Mearman
Mearman marked this pull request as ready for review September 20, 2026 08:59
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review ✅ Completed 2026-09-20T09:06:48.762472Z 8797e7a Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

The browser harness and the Playwright spec each held a hand-written copy of
the gossip frame's JSON-serialisable shape. peer-advert gained identity-key
and signature, the harness's copy was updated and the spec's was not, and
because the two sit in separate TypeScript programs nothing caught it until
the end-to-end run read alg off undefined.

Both now import the shape from live-check/wire-gossip.ts, which carries no
runtime code and no global declaration, so neither program pulls in the
other's globals. The spec's advert gains filler key and signature bytes: the
frame rides the established data channel directly, so nothing between the two
pages verifies it, and the only claim is that the exact bytes come out the
other end.
@Mearman
Mearman merged commit 0cca623 into main Sep 20, 2026
7 checks passed
@Mearman
Mearman deleted the feat/authenticated-gossip branch September 20, 2026 09:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hub does not authenticate gossiped device ids or sign directory adverts

1 participant