feat!: sign every gossiped peer-advert and verify it on receipt - #226
Merged
Merged
Conversation
…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
marked this pull request as ready for review
September 20, 2026 08:59
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-advertgains two mandatory fields,identity-keyandsignature. An advert is valid when sha256 of the embedded public key equalsdeviceand 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
signatureremoved, 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 => anymap, so you can craft an advert whose encoding also satisfiestoken-claims, and a Sig_structure over that would be replayable as a capability token's signed payload. Thewire-mesh/peer-advert/v1prefix 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-secondswithout 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 itssnapshot-secondsis 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.verifyPeerAdvertis 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.jsonis 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:
createRelayHubnow requires anidentityoption carryingverify/deriveDeviceId, and an advert without the two new fields does not decode at all.Two things worth flagging.
conformance/generate.tswas 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, butcreateRelayHub()in its test helper needs the new option and one hand-builtPeerAdvertliteral 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 testandjust conformanceare all green, including 70/70 vectors in both languages.Fixes #225