docs: webrtc-direct-v2#715
Conversation
dozyio
commented
Apr 23, 2026
- Adds webrtc-direct-v2 spec
|
Draft JS implementation libp2p/js-libp2p#3480 |
Passes through `rtc::IceUdpMuxRequest::localUfrag` from libdatachannel to the JS `onUnhandledStunRequest` callback. Needed by the libp2p WebRTC-Direct v2 transport design (libp2p/specs#715). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| 6. _B_ sets this inferred offer as remote description, generates an answer, and | ||
| sets it as local description. |
There was a problem hiding this comment.
B's answer ice-ufrag/ice-pwd aren't specified here, but must be libp2p+webrtc+v2/<client_pwd> (the password A set for B in the synthetic answer) — otherwise the connection fails.
There was a problem hiding this comment.
Addressed in 3587339. Step 6 now spells this out: before generating its answer, B MUST set its own ice-ufrag and ice-pwd to server_ufrag (the libp2p+webrtc+v2/<client_pwd> value A embedded). A signs its checks with that value and expects B's STUN responses under it, so ICE can't complete otherwise, exactly as you flagged. Thanks!
| 4. _A_ starts sending STUN connectivity checks. _B_ parses STUN `USERNAME` as | ||
| `server_ufrag:client_ufrag` and validates that `server_ufrag` has the prefix | ||
| `libp2p+webrtc+v2/`. |
There was a problem hiding this comment.
v1 has no equivalent prefix check, so a v1 server's handling of a v2/ ufrag is undefined. Each version should validate its prefix and drop on mismatch.
There was a problem hiding this comment.
Addressed in 3587339. Version selection is now explicit: B reads the version from the server_ufrag prefix, where libp2p+webrtc+v1/ selects v1 and libp2p+webrtc+v2/ selects v2. The libp2p+webrtc+ namespace is reserved for versions, so B MUST reject a server_ufrag whose version it doesn't recognize (or that carries no recognized prefix) rather than assume v1. A v2/ ufrag reaching a server that doesn't speak v2 is then dropped instead of undefined.
This comment was marked as off-topic.
This comment was marked as off-topic.
The initial v2 draft described the happy path but left enough unspecified that two implementations could diverge. Pin the details down so a browser and a server interoperate from the spec alone: - server credentials: B MUST set its own ICE ufrag and password to server_ufrag, or the browser rejects B's STUN responses and ICE never completes (the step that was missing). - USERNAME parsing: split on the first ':', validate both halves as ice-char within the length bounds, and reject the rest, which keeps CR/LF out of the SDP B builds. - client password: recover it by stripping the prefix from server_ufrag, read the browser's own value back from localDescription, abort if it is missing, and keep server_ufrag within the ICE length limit. - version dispatch: recognize the v1 and v2 prefixes explicitly and reject an unknown version instead of assuming v1. - ICE and DTLS roles: B is ICE Lite (controlled); the answer is a=setup:passive, so A is the DTLS client and B the DTLS server. - c= line: it carries an address family and IP, not a port (c=IN <IP4|IP6> <ip>); fixed in v1 too. - RFC references: ice-char grammar and lengths (RFC 8839 5.4), USERNAME format (RFC 8445 7.2.2), randomness (RFC 8445 5.3). - Chrome context: link the WebRTC-NoSdpMangleUfrag trial, the WebRTC tracker issue, the discuss-webrtc notice, and the getStats side channel that motivated v2. - guidance: new connections SHOULD use v2; v1 will stop working in browsers, but servers SHOULD keep accepting it during migration. - versioning note: new versions ride the ufrag prefix; a new multiaddr is only for a change the prefix cannot carry.
Chrome is removing the SDP munging that v1 browser-to-server dials rely on (the WebRTC-NoSdpMangleUfrag field trial), so the listener now also speaks the v2 flow, which carries the client's ICE password in the server ufrag instead of munging. Implements libp2p/specs#715. - listener: parse both halves of the STUN USERNAME, dispatch on the ufrag prefix (v1, v2, or reject an unknown version), recover the v2 client password, and set pion's ICE credentials to match. - udpmux: surface the server and client ufrag separately and key the mux on the server (local) ufrag, which is what pion looks up. - validation: reject STUN USERNAME fragments outside the RFC 8839 ice-char set or length bounds before they reach SDP. - dialer: add opt-in WithDialerVersion(2) that emits the v2 wire format for go-to-go and testing; v1 stays the default. - tests: parsing/validation units plus end-to-end v1 and v2 dials against a single listener.
Refine the initial v2 implementation to match the now-precise spec (libp2p/specs#715) so it can't diverge from go-libp2p or other peers. - listener: dispatch on the ufrag prefix explicitly (v1, v2, or reject an unknown version) instead of treating anything non-v2 as v1. - validation: reject STUN USERNAME fragments outside the RFC 8839 ice-char set or length bounds before they reach SDP, and validate the recovered v2 client password. - dialer: fail fast when the local ICE password can't be read back, instead of dialing with an unprefixed ufrag the server rejects. - cleanup: drop the dead createOffer guard and the dead ice-pwd zero-padding (both unreachable; the padding could also desync the inferred offer from go-libp2p). - tests: cover the credential validation and the unprefixed-ufrag rejection.
There was a problem hiding this comment.
Thanks for getting this rolling, @dozyio! The v2 design here mostly lgtm.
An air-tight transport spec is hard to write without real code and a second implementation to test against, so I wrote a GO server and used it to find the spots where the v2 text still left room for two implementations to diverge. I pushed 3587339, which fills those gaps so an implementer can build an interoperable v2 server and browser dialer from the spec alone.
Implementations:
- go-libp2p: libp2p/go-libp2p#3520 (new)
- js-libp2p: libp2p/js-libp2p#3480 (by @dozyio, i only tested + pushed small hardening commits in libp2p/js-libp2p@3c12a80)
Spec gaps filled as much as possible, so LLM-driven implementations avoid footguns:
- Server's own ICE credentials: B must set its
ice-ufrag/ice-pwdtoserver_ufrag(thelibp2p+webrtc+v2/<client_pwd>value A embedded), or A rejects B's STUN responses and ICE never completes. This is the one that bit first. - USERNAME parsing and validation: split on the first
:, validate both halves as ICE ufrags (ice-char, length bounds), and reject the rest, which keepsCR/LFout of the SDP B builds. - Client password: recover it by stripping the prefix from
server_ufrag, read the browser's own value back fromlocalDescription, abort if it's missing, and keepserver_ufragwithin the ICE length limit. - Version dispatch: recognize the v1 and v2 prefixes explicitly and reject an unknown version instead of assuming v1.
- ICE and DTLS roles: B is ICE Lite (controlled); the answer is
a=setup:passive, so A is the DTLS client and B the DTLS server. c=line: it carries an address family and IP, not a port (c=IN <IP4|IP6> <ip>); fixed in v1 too.- RFC references:
ice-chargrammar and lengths (RFC 8839 section 5.4), USERNAME format (RFC 8445 section 7.2.2), randomness (RFC 8445 section 5.3). - Chrome context: linked the WebRTC-NoSdpMangleUfrag trial, the WebRTC tracker issue, the discuss-webrtc notice, and the getStats side channel that motivated v2.
- Guidance: new connections SHOULD use v2; v1 will stop working in browsers, but servers SHOULD keep accepting it during migration.
- Versioning note: new versions ride the ufrag prefix; a new multiaddr is only needed for a change the prefix can't carry.
I also tested it end to end: the JS client in a real Google Chrome (Chrome-for-Testing, with the WebRTC-NoSdpMangleUfrag field trial on, so munging is disabled) dialing the GO server over v2. v2 connects and completes the Noise handshake, and v1 fails under the trial exactly as expected.
So.. I'm confident the spec at 3587339 is good to go, but second set of eyes + landing both GO and JS first would be good before merging.