Skip to content

feat(webrtc): stable /certhash across restarts#3512

Open
lidel wants to merge 3 commits into
masterfrom
feat/webrtc-direct-deterministic-cert
Open

feat(webrtc): stable /certhash across restarts#3512
lidel wants to merge 3 commits into
masterfrom
feat/webrtc-direct-deterministic-cert

Conversation

@lidel

@lidel lidel commented May 28, 2026

Copy link
Copy Markdown
Member

Problem

webrtc-direct minted a fresh random ECDSA cert on every process start. The cert's SHA-256 is the /certhash in the listen multiaddr, so the advertised address changed on every restart and invalidated anything that had pinned it: peerstore TTLs, DHT records, external address books, dialer caches.

Fix

Derive the DTLS cert deterministically from the libp2p host key, so the /certhash depends only on that key and stays put across restarts:

  • HKDF (stdlib crypto/hkdf) over the host key yields the serial and the ECDSA P-256 key; signing with a nil rand source is deterministic on Go 1.24+.
  • A fixed 2020-01-01 to 2120-01-01 window keeps the DER byte-stable.
  • The serial is read unsigned so it's always positive, as RFC 5280 requires. The old signed-then-negate left math.MinInt64 negative, which x509.CreateCertificate rejects; I caught it fuzzing the serial against the RFC. webtransport shared the bug, so it's fixed here too.
    • I think we did not notice this before because new cert on each restart hid this as an ephemeral hiccup.
  • A golden-vector test pins the /certhash for a fixed key, so any drift in the derivation fails CI instead of silently changing every node's address.

The DTLS cert isn't the identity credential (Noise XX inside DTLS handles that), so a stable cert is safe. Full rationale and spec links live in p2p/transport/webrtc/cert.go.

Derive the webrtc-direct DTLS cert deterministically from the host
private key (HKDF + Go 1.24 deterministic ECDSA + hardcoded validity
window) so the /certhash multiaddr component depends only on the host
key. The advertised address no longer churns on every restart, and
cached peerstore/DHT entries stay valid.

cert.go documents why this is safe under the libp2p webrtc-direct spec
and current browser DTLS behavior.
@lidel lidel requested review from MarcoPolo and sukunrt May 28, 2026 11:34
@lidel lidel marked this pull request as ready for review May 28, 2026 11:34
Comment thread p2p/transport/webrtc/cert.go Outdated
Comment on lines +114 to +117
priv, err := keygen.ECDSA(elliptic.P256(), ecdsaSeed)
if err != nil {
return nil, fmt.Errorf("derive ECDSA key: %w", err)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you use webrtc.GenerateCertificate(priv) for the certificate creation?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sadly no, it's non-deterministic: random serial, NotBefore/NotAfter from time.Now(), and it signs with rand.Reader. The DER bytes change every run, so /certhash would shift on each restart, which is the behavior (bug?) this PR fixes. NewCertificate has the same problem since it also signs with rand.Reader, so Go 1.24's deterministic-ECDSA path never kicks in. That's why the cert is built by hand with a nil-rand signer and wrapped via webrtc.CertificateFromX509.

@sukunrt

sukunrt commented Jun 3, 2026

Copy link
Copy Markdown
Member

@MarcoPolo This looks fine to me, but I'd like your review on this.

lidel added 2 commits June 14, 2026 13:22
Switch the deterministic cert to the stdlib crypto/hkdf (Go 1.24+),
derive the serial unsigned so it is always positive, and pin the
fingerprint for a fixed key with a golden vector.

The golden vector turns any drift in the derivation (filippo.io/keygen,
the x509 encoder, or this code) into a CI failure instead of a silent,
network-visible /certhash change for every node.

A note marks the serial and signer logic as intentionally local to this
transport rather than shared with webtransport, since the two are
independent on the wire and in browsers and may diverge.
The serial was read as int64 and negated when negative, but
math.MinInt64 negates back to itself, so that one HKDF-derived value
stayed negative. x509.CreateCertificate rejects a negative serial
(RFC 5280 4.1.2.2 requires a positive one), so cert generation would
fail for that key.

Found while fuzzing the deterministic-cert serial against the RFC. The
bug predates this branch, but it is fixed here, alongside the webrtc
cert changes, rather than in a separate PR: both transports share the
same serial-derivation pattern and splitting the fix would conflict
with the webrtc cert work in this branch.

Read the serial unsigned and clamp to at least 1.
@lidel

lidel commented Jun 14, 2026

Copy link
Copy Markdown
Member Author

Pushed two small follow-ups here, both belong in this PR (nothing that needs a separate change).

The cert serial derivation wasn't RFC 5280 compliant: it read the bytes as a signed int64 and flipped the sign when negative, but math.MinInt64 flips back to itself, so that one value stayed negative and x509.CreateCertificate refuses a negative serial. I ran into it while fuzzing the serial against the RFC. webtransport had the same pattern, so I fixed both rather than leave one half-done.

I also added a golden-vector test that pins the certhash for a fixed key, so the derivation can't drift without CI catching it.

No behavior change otherwise. The webrtc side also moves to the stdlib crypto/hkdf, which produces identical bytes.

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.

3 participants