Skip to content

fix: reject stale signed peer records using RFC 0003 sequence numbers - #280

Merged
adust09 merged 1 commit into
mainfrom
fix/248-peer-record-seq
Sep 1, 2026
Merged

fix: reject stale signed peer records using RFC 0003 sequence numbers#280
adust09 merged 1 commit into
mainfrom
fix/248-peer-record-seq

Conversation

@adust09

@adust09 adust09 commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Closes #248. First of the #248#247#259 chain.

Problem

openPeerRecordEnvelope verifies an envelope's signature, payload type and signer/subject binding, and validateSignedPeerRecord then replaces idListenAddrs with the record's addresses. Nothing read PeerRecord.seq.

A signature proves the peer authored the record at some point, not that it is current. Replaying an older, correctly signed envelope therefore rolled a peer's certified addresses back to stale state — the thing signing them was supposed to prevent. Unfinished scope from #230.

Fix

New LibP2P.Switch.CertifiedRecords:

  • CertifiedRecord { crSeq, crEnvelope, crAddresses } — the accepted record plus the envelope it arrived in, kept verbatim since a record is only self-certifying while its signature travels with it.
  • verifyPeerRecord :: PeerId -> ByteString -> Either String CertifiedRecord — signature, payload type and signer/subject binding. Now the single source of truth for that check; validateSignedPeerRecord calls it.
  • consumeCertifiedRecord :: TVar (Map PeerId CertifiedRecord) -> PeerId -> CertifiedRecord -> STM Bool — the RFC 0003 rule.
  • lookupCertifiedRecord.

Switch gains swCertifiedRecords :: TVar (Map PeerId CertifiedRecord).

The check needs the peer store, so it runs in storeIdentify rather than in the pure validation chain — that keeps requestIdentify's existing contract (it still prefers verified record addresses in its return value). validateSignedPeerRecord has already applied the record's addresses by then, so a refused record has that undone: the envelope and address list are cleared from the update, which leaves mergeIdentify holding the certified values already known. A replay therefore changes nothing.

storeIdentify moves from STM () to IO () so the envelope is opened once outside the transaction — an STM retry must not re-run Ed25519 verification.

Both intake paths (identifyPeer on connect, handleIdentifyPush) go through storeIdentify, so both are covered.

Equal sequence numbers: spec over go-libp2p

RFC 0003 says a receiver "MUST ... reject incoming records unless they contain a greater seq value than the last received".

go-libp2p's pstoremem is more permissive — it rejects only lastState.Seq > rec.Seq, accepting an equal seq as an address-TTL refresh. This implementation has no address TTL for such a refresh to renew, so accepting an equal seq would only widen the replay window for no benefit. This PR follows the RFC's wording; the divergence is documented on consumeCertifiedRecord.

Also checked while there: go's addr book keeps unsigned addresses even when a signed record exists ("Unsigned addrs ... are not touched"), so this PR deliberately leaves the unsigned idListenAddrs path alone.

Tests

13 new examples, verified to fail without the fix. With consumeCertifiedRecord stubbed to always accept, 4 of the 5 IdentifySpec cases and 4 of the CertifiedRecordsSpec sequence cases fail; the two that still pass are "first record accepted" and "greater seq accepted", which should pass either way.

  • test/LibP2P/Switch/CertifiedRecordsSpec.hsverifyPeerRecord accept/reject, and first / greater / equal / lower / per-peer sequence handling.
  • test/LibP2P/Protocol/Identify/IdentifySpec.hs — the push path end to end: greater seq accepted, equal and lower ignored, the newer envelope retained across a stale push, and a stale record not erasing what was learned in between.

cabal test: 1222 examples, 0 failures (was 1209).

Next in the chain

#247 wires accepted PX records to bounded peer dialing and will call verifyPeerRecord / consumeCertifiedRecord for the same freshness guarantee on the GossipSub path. Per the repo's no-stacked-PRs rule, that branch will be cut from main after this merges.

A valid envelope signature proves only that the peer authored the record
at some point, not that it is current. Identify accepted any record that
verified, so a correctly signed but older envelope replayed at a node
rolled its certified addresses back to stale state — exactly what signing
those addresses was meant to prevent. This was unfinished scope from #230.

Add a certified-record store to the Switch keyed by PeerId, holding the
greatest accepted seq alongside the envelope it arrived in, and gate
incoming records on it: a first record is accepted, and after that only a
strictly greater seq is. Note that go-libp2p is more permissive, rejecting
only lastState.Seq > rec.Seq so that an equal seq refreshes its address
TTL; this implementation has no address TTL for such a refresh to renew,
so it follows RFC 0003's "greater than" wording instead.

The check needs the peer store, so it runs in storeIdentify rather than in
the pure validation chain, which keeps requestIdentify's contract intact.
Because validateSignedPeerRecord has already applied the record's
addresses by then, a refused record has that undone: the envelope and
address list are cleared from the update, leaving mergeIdentify holding
the certified values already known. The envelope is opened outside the
transaction so an STM retry cannot re-run signature verification.

Both intake paths — Identify on connect and Identify Push — go through
storeIdentify, so both are covered. The reusable entry points live in
LibP2P.Switch.CertifiedRecords so GossipSub peer exchange can apply the
same rule (#247).

Closes #248
@adust09
adust09 merged commit 64c105a into main Sep 1, 2026
4 checks passed
@adust09
adust09 deleted the fix/248-peer-record-seq branch September 1, 2026 04:16
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.

identify: reject stale signed peer records using RFC 0003 sequence numbers

1 participant