From 8135cebf80fa168f2ef91db8d26dca46aad42ca5 Mon Sep 17 00:00:00 2001 From: dozyio Date: Sat, 25 Apr 2026 09:05:10 +0100 Subject: [PATCH 1/9] feat: webrtc-direct-v2 --- packages/transport-webrtc/src/constants.ts | 3 +- packages/transport-webrtc/src/index.ts | 14 ++- .../src/private-to-public/listener.ts | 14 ++- .../private-to-public/transport.browser.ts | 17 ++- .../src/private-to-public/utils/connect.ts | 56 ++++++--- .../utils/get-rtcpeerconnection.ts | 12 +- .../src/private-to-public/utils/sdp.ts | 36 +++++- .../private-to-public/utils/stun-listener.ts | 40 +++++- packages/transport-webrtc/src/util.ts | 6 +- packages/transport-webrtc/test/sdp.spec.ts | 7 ++ .../test/stun-listener.spec.ts | 43 +++++++ .../transport-webrtc/test/transport.spec.ts | 118 +++++++++++++++++- 12 files changed, 324 insertions(+), 42 deletions(-) create mode 100644 packages/transport-webrtc/test/stun-listener.spec.ts diff --git a/packages/transport-webrtc/src/constants.ts b/packages/transport-webrtc/src/constants.ts index 2184d08c82..8e7762f3f0 100644 --- a/packages/transport-webrtc/src/constants.ts +++ b/packages/transport-webrtc/src/constants.ts @@ -24,7 +24,8 @@ export const UFRAG_ALPHABET = Array.from('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKL /** * Used to detect the version of the WebRTC Direct connection protocol in use */ -export const UFRAG_PREFIX = 'libp2p+webrtc+v1/' +export const UFRAG_PREFIX_V1 = 'libp2p+webrtc+v1/' +export const UFRAG_PREFIX_V2 = 'libp2p+webrtc+v2/' /** * How much can be buffered to the DataChannel at once diff --git a/packages/transport-webrtc/src/index.ts b/packages/transport-webrtc/src/index.ts index f70fc4e35b..3a878f1855 100644 --- a/packages/transport-webrtc/src/index.ts +++ b/packages/transport-webrtc/src/index.ts @@ -342,7 +342,17 @@ export interface TransportCertificate { export type { WebRTCTransportDirectInit, WebRTCDirectTransportComponents } function webRTCDirect (init?: WebRTCTransportDirectInit): (components: WebRTCDirectTransportComponents) => Transport { - return (components: WebRTCDirectTransportComponents) => new WebRTCDirectTransport(components, init) + return (components: WebRTCDirectTransportComponents) => new WebRTCDirectTransport(components, { + version: 'v1', + ...(init ?? {}) + }) +} + +function webRTCDirectV2 (init?: WebRTCTransportDirectInit): (components: WebRTCDirectTransportComponents) => Transport { + return (components: WebRTCDirectTransportComponents) => new WebRTCDirectTransport(components, { + version: 'v2', + ...(init ?? {}) + }) } export type { WebRTCTransportInit, WebRTCTransportComponents } @@ -351,4 +361,4 @@ function webRTC (init?: WebRTCTransportInit): (components: WebRTCTransportCompon return (components: WebRTCTransportComponents) => new WebRTCTransport(components, init) } -export { webRTC, webRTCDirect } +export { webRTC, webRTCDirect, webRTCDirectV2 } diff --git a/packages/transport-webrtc/src/private-to-public/listener.ts b/packages/transport-webrtc/src/private-to-public/listener.ts index 8a8f10fad5..5aae0d1807 100644 --- a/packages/transport-webrtc/src/private-to-public/listener.ts +++ b/packages/transport-webrtc/src/private-to-public/listener.ts @@ -155,10 +155,10 @@ export class WebRTCDirectListener extends TypedEventEmitter impl this.log.trace('listening on free port %d', port) } - return stunListener(host, port, this.log, (ufrag, remoteHost, remotePort) => { + return stunListener(host, port, this.log, (serverUfrag, clientUfrag, clientPwd, remoteHost, remotePort) => { const signal = this.components.upgrader.createInboundAbortSignal(this.shutdownController.signal) - this.incomingConnection(ufrag, remoteHost, remotePort, signal) + this.incomingConnection(serverUfrag, clientUfrag, clientPwd, remoteHost, remotePort, signal) .catch(err => { this.log.error('error processing incoming STUN request - %e', err) }) @@ -170,8 +170,8 @@ export class WebRTCDirectListener extends TypedEventEmitter impl } } - private async incomingConnection (ufrag: string, remoteHost: string, remotePort: number, signal: AbortSignal): Promise { - const key = `${remoteHost}:${remotePort}:${ufrag}` + private async incomingConnection (serverUfrag: string, clientUfrag: string, clientPwd: string | undefined, remoteHost: string, remotePort: number, signal: AbortSignal): Promise { + const key = `${remoteHost}:${remotePort}:${serverUfrag}:${clientUfrag}` let peerConnection = this.connections.get(key) if (peerConnection != null) { @@ -185,7 +185,7 @@ export class WebRTCDirectListener extends TypedEventEmitter impl signal.throwIfAborted() // https://github.com/libp2p/specs/blob/master/webrtc/webrtc-direct.md#browser-to-public-server - const results = await createDialerRTCPeerConnection('server', ufrag, { + const results = await createDialerRTCPeerConnection('server', serverUfrag, { rtcConfiguration: this.init.rtcConfiguration, certificate: this.certificate, events: this.metrics?.listenerEvents, @@ -208,12 +208,14 @@ export class WebRTCDirectListener extends TypedEventEmitter impl }) try { - await connect(peerConnection, results.muxerFactory, ufrag, { + await connect(peerConnection, results.muxerFactory, serverUfrag, { role: 'server', log: this.log, logger: this.components.logger, events: this.metrics?.listenerEvents, signal, + remoteUfrag: clientUfrag, + remotePwd: clientPwd, remoteAddr: multiaddr(`/ip${isIPv4(remoteHost) ? 4 : 6}/${remoteHost}/udp/${remotePort}/webrtc-direct`), dataChannel: this.init.dataChannel, upgrader: this.init.upgrader, diff --git a/packages/transport-webrtc/src/private-to-public/transport.browser.ts b/packages/transport-webrtc/src/private-to-public/transport.browser.ts index 8a72666d02..ec2f36eb16 100644 --- a/packages/transport-webrtc/src/private-to-public/transport.browser.ts +++ b/packages/transport-webrtc/src/private-to-public/transport.browser.ts @@ -2,6 +2,7 @@ import { serviceCapabilities, transportSymbol } from '@libp2p/interface' import { peerIdFromString } from '@libp2p/peer-id' import { CODE_P2P } from '@multiformats/multiaddr' import { WebRTCDirect } from '@multiformats/multiaddr-matcher' +import { UFRAG_PREFIX_V1 } from '../constants.js' import { UnimplementedError } from '../error.ts' import { genUfrag } from '../util.js' import { connect } from './utils/connect.ts' @@ -30,6 +31,13 @@ export interface WebRTCMetrics { } export interface WebRTCTransportDirectInit { + /** + * Select which WebRTC Direct flow to dial with. + * + * @default 'v1' + */ + version?: 'v1' | 'v2' + /** * The default configuration used by all created RTCPeerConnections */ @@ -84,7 +92,10 @@ export class WebRTCDirectTransport implements Transport { theirPeerId = peerIdFromString(remotePeerString) } - const ufrag = genUfrag() + const ufrag = this.init.version === 'v2' + ? genUfrag(32, '') + : genUfrag(32, UFRAG_PREFIX_V1) + const pwd = this.init.version === 'v2' ? genUfrag(22, '') : ufrag // https://github.com/libp2p/specs/blob/master/webrtc/webrtc-direct.md#browser-to-public-server const { @@ -92,7 +103,8 @@ export class WebRTCDirectTransport implements Transport { muxerFactory } = await createDialerRTCPeerConnection('client', ufrag, { rtcConfiguration: typeof this.init.rtcConfiguration === 'function' ? await this.init.rtcConfiguration() : this.init.rtcConfiguration ?? {}, - dataChannel: this.init.dataChannel + dataChannel: this.init.dataChannel, + pwd }) try { @@ -102,6 +114,7 @@ export class WebRTCDirectTransport implements Transport { logger: this.components.logger, events: this.metrics?.dialerEvents, signal: options.signal, + version: this.init.version, remoteAddr: ma, dataChannel: this.init.dataChannel, upgrader: options.upgrader, diff --git a/packages/transport-webrtc/src/private-to-public/utils/connect.ts b/packages/transport-webrtc/src/private-to-public/utils/connect.ts index 06eb0462f7..e6ba217f88 100644 --- a/packages/transport-webrtc/src/private-to-public/utils/connect.ts +++ b/packages/transport-webrtc/src/private-to-public/utils/connect.ts @@ -24,6 +24,9 @@ export interface ConnectOptions { remotePeer?: PeerId signal: AbortSignal privateKey: PrivateKey + remoteUfrag?: string + remotePwd?: string + version?: 'v1' | 'v2' } export interface ClientOptions extends ConnectOptions { @@ -51,37 +54,56 @@ export async function connect (peerConnection: RTCPeerConnection | DirectRTCPeer try { if (options.role === 'client') { // the client has to set the local offer before the remote answer - - // Create offer and munge sdp with ufrag == pwd. This allows the remote to - // respond to STUN messages without performing an actual SDP exchange. - // This is because it can infer the passwd field by reading the USERNAME - // attribute of the STUN message. options.log.trace('client creating local offer') const offerSdp = await peerConnection.createOffer() options.log.trace('client created local offer %s', offerSdp.sdp) - const mungedOfferSdp = sdp.munge(offerSdp, ufrag) - options.log.trace('client setting local offer %s', mungedOfferSdp.sdp) - await peerConnection.setLocalDescription(mungedOfferSdp) - const answerSdp = sdp.serverAnswerFromMultiaddr(options.remoteAddr, ufrag) + let remoteAnswerUfrag = ufrag + + if (options.version === 'v2') { + // v2 path: do not munge local SDP. Keep client credentials as generated + // and encode client_pwd in the synthetic server ufrag. + options.log.trace('client setting local offer %s', offerSdp.sdp) + await peerConnection.setLocalDescription(offerSdp) + + const localPwd = sdp.getIcePwdFromSdp(peerConnection.localDescription?.sdp) + + if (localPwd != null) { + remoteAnswerUfrag = sdp.serverUfragV2(localPwd) + } + } else { + // v1 compatibility path: force ice-ufrag === ice-pwd so the server can + // infer credentials from STUN USERNAME without explicit SDP exchange. + const mungedOfferSdp = sdp.munge(offerSdp, ufrag) + options.log.trace('client setting local offer %s', mungedOfferSdp.sdp) + await peerConnection.setLocalDescription(mungedOfferSdp) + } + + const answerSdp = sdp.serverAnswerFromMultiaddr(options.remoteAddr, remoteAnswerUfrag) options.log.trace('client setting server description %s', answerSdp.sdp) await peerConnection.setRemoteDescription(answerSdp) } else { // the server has to set the remote offer before the local answer - const offerSdp = sdp.clientOfferFromMultiAddr(options.remoteAddr, ufrag) + const remoteUfrag = options.remoteUfrag ?? ufrag + const remotePwd = options.remotePwd ?? remoteUfrag + const offerSdp = sdp.clientOfferFromMultiAddr(options.remoteAddr, remoteUfrag, remotePwd) options.log.trace('server setting client %s %s', offerSdp.type, offerSdp.sdp) await peerConnection.setRemoteDescription(offerSdp) - // Create offer and munge sdp with ufrag == pwd. This allows the remote to - // respond to STUN messages without performing an actual SDP exchange. - // This is because it can infer the passwd field by reading the USERNAME - // attribute of the STUN message. options.log.trace('server creating local answer') const answerSdp = await peerConnection.createAnswer() options.log.trace('server created local answer') - const mungedAnswerSdp = sdp.munge(answerSdp, ufrag) - options.log.trace('server setting local description %s', answerSdp.sdp) - await peerConnection.setLocalDescription(mungedAnswerSdp) + + if (options.remotePwd != null) { + // v2 path: keep server answer credentials as generated. + options.log.trace('server setting local description %s', answerSdp.sdp) + await peerConnection.setLocalDescription(answerSdp) + } else { + // v1 compatibility path: keep local answer credentials aligned to ufrag. + const mungedAnswerSdp = sdp.munge(answerSdp, ufrag) + options.log.trace('server setting local description %s', answerSdp.sdp) + await peerConnection.setLocalDescription(mungedAnswerSdp) + } } if (handshakeDataChannel.readyState !== 'open') { diff --git a/packages/transport-webrtc/src/private-to-public/utils/get-rtcpeerconnection.ts b/packages/transport-webrtc/src/private-to-public/utils/get-rtcpeerconnection.ts index 7931bc14b0..414233eff2 100644 --- a/packages/transport-webrtc/src/private-to-public/utils/get-rtcpeerconnection.ts +++ b/packages/transport-webrtc/src/private-to-public/utils/get-rtcpeerconnection.ts @@ -12,18 +12,21 @@ const crypto = new Crypto() interface DirectRTCPeerConnectionInit extends RTCConfiguration { ufrag: string + pwd: string peerConnection: PeerConnection } export class DirectRTCPeerConnection extends RTCPeerConnection { private peerConnection: PeerConnection private readonly ufrag: string + private readonly pwd: string constructor (init: DirectRTCPeerConnectionInit) { super(init) this.peerConnection = init.peerConnection this.ufrag = init.ufrag + this.pwd = init.pwd // make sure C++ peer connection is garbage collected // https://github.com/murat-dogan/node-datachannel/issues/366#issuecomment-3228453155 @@ -40,10 +43,10 @@ export class DirectRTCPeerConnection extends RTCPeerConnection { async createOffer (): Promise { // have to set ufrag before creating offer - if (this.connectionState === 'new') { + if (this.connectionState === 'new' && !this.ufrag.startsWith('libp2p+webrtc+v2/')) { this.peerConnection?.setLocalDescription('offer', { iceUfrag: this.ufrag, - icePwd: this.ufrag + icePwd: this.pwd }) } @@ -55,7 +58,7 @@ export class DirectRTCPeerConnection extends RTCPeerConnection { if (this.connectionState === 'new') { this.peerConnection?.setLocalDescription('answer', { iceUfrag: this.ufrag, - icePwd: this.ufrag + icePwd: this.pwd }) } @@ -92,6 +95,7 @@ export interface CreateDialerRTCPeerConnectionOptions { certificate?: TransportCertificate events?: CounterGroup dataChannel?: DataChannelOptions + pwd?: string } export async function createDialerRTCPeerConnection (role: 'client', ufrag: string, options?: CreateDialerRTCPeerConnectionOptions): Promise<{ peerConnection: globalThis.RTCPeerConnection, muxerFactory: DataChannelMuxerFactory }> @@ -113,10 +117,12 @@ export async function createDialerRTCPeerConnection (role: 'client' | 'server', } const rtcConfig = typeof options.rtcConfiguration === 'function' ? await options.rtcConfiguration() : options.rtcConfiguration + const pwd = options.pwd ?? ufrag const peerConnection = new DirectRTCPeerConnection({ ...rtcConfig, ufrag, + pwd, peerConnection: new PeerConnection(`${role}-${Date.now()}`, { disableFingerprintVerification: true, disableAutoNegotiation: true, diff --git a/packages/transport-webrtc/src/private-to-public/utils/sdp.ts b/packages/transport-webrtc/src/private-to-public/utils/sdp.ts index 3f93a1c13e..865f1e9683 100644 --- a/packages/transport-webrtc/src/private-to-public/utils/sdp.ts +++ b/packages/transport-webrtc/src/private-to-public/utils/sdp.ts @@ -5,7 +5,7 @@ import { base64url } from 'multiformats/bases/base64' import { bases, digest } from 'multiformats/basics' import * as Digest from 'multiformats/hashes/digest' import { sha256 } from 'multiformats/hashes/sha2' -import { MAX_MESSAGE_SIZE } from '../../constants.js' +import { MAX_MESSAGE_SIZE, UFRAG_PREFIX_V2 } from '../../constants.js' import { InvalidFingerprintError, UnsupportedHashAlgorithmError } from '../../error.js' import type { Multiaddr } from '@multiformats/multiaddr' import type { MultihashDigest } from 'multiformats/hashes/interface' @@ -17,6 +17,8 @@ import type { MultihashDigest } from 'multiformats/hashes/interface' export const multibaseDecoder: any = Object.values(bases).map(b => b.decoder).reduce((d, b) => d.or(b)) const fingerprintRegex = /^a=fingerprint:(?:\w+-[0-9]+)\s(?(:?[0-9a-fA-F]{2})+)$/m +const icePwdRegex = /^a=ice-pwd:(?[^\r\n]+)$/m + export function getFingerprintFromSdp (sdp: string | undefined): string | undefined { if (sdp == null) { return undefined @@ -26,6 +28,32 @@ export function getFingerprintFromSdp (sdp: string | undefined): string | undefi return searchResult?.groups?.fingerprint } +export function getIcePwdFromSdp (sdp: string | undefined): string | undefined { + if (sdp == null) { + return undefined + } + + return sdp.match(icePwdRegex)?.groups?.pwd +} + +export function serverUfragV2 (clientIcePwd: string): string { + return `${UFRAG_PREFIX_V2}${clientIcePwd}` +} + +export function decodeV2ClientPwd (serverUfrag: string): string | undefined { + if (!serverUfrag.startsWith(UFRAG_PREFIX_V2)) { + return undefined + } + + const clientPwd = serverUfrag.substring(UFRAG_PREFIX_V2.length) + + if (clientPwd.length === 0) { + return undefined + } + + return clientPwd +} + // Extract the certhash from a multiaddr export function certhash (ma: Multiaddr): string { const components = ma.getComponents() @@ -136,13 +164,15 @@ a=end-of-candidates /** * Create an offer SDP message from a multiaddr */ -export function clientOfferFromMultiAddr (ma: Multiaddr, ufrag: string): RTCSessionDescriptionInit { +export function clientOfferFromMultiAddr (ma: Multiaddr, ufrag: string, pwd: string = ufrag): RTCSessionDescriptionInit { const { host, port, type } = getNetConfig(ma) if (type !== 'ip4' && type !== 'ip6') { throw new InvalidParametersError(`Multiaddr ${ma} was not an IPv4 or IPv6 address`) } + const normalizedPwd = pwd.length >= 22 ? pwd : `${pwd}${'0'.repeat(22 - pwd.length)}` + const sdp = `v=0 o=- 0 0 IN IP${type === 'ip4' ? 4 : 6} ${host} s=- @@ -153,7 +183,7 @@ m=application ${port} UDP/DTLS/SCTP webrtc-datachannel a=mid:0 a=setup:active a=ice-ufrag:${ufrag} -a=ice-pwd:${ufrag} +a=ice-pwd:${normalizedPwd} a=fingerprint:sha-256 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00 a=sctp-port:5000 a=max-message-size:${MAX_MESSAGE_SIZE} diff --git a/packages/transport-webrtc/src/private-to-public/utils/stun-listener.ts b/packages/transport-webrtc/src/private-to-public/utils/stun-listener.ts index dfbf65c195..a7dafe61a9 100644 --- a/packages/transport-webrtc/src/private-to-public/utils/stun-listener.ts +++ b/packages/transport-webrtc/src/private-to-public/utils/stun-listener.ts @@ -1,5 +1,6 @@ import { isIPv4 } from '@chainsafe/is-ip' import { IceUdpMuxListener } from 'node-datachannel' +import { decodeV2ClientPwd } from './sdp.ts' import type { Logger } from '@libp2p/interface' import type { AddressInfo } from 'node:net' @@ -9,19 +10,50 @@ export interface StunServer { } export interface Callback { - (ufrag: string, remoteHost: string, remotePort: number): void + (serverUfrag: string, clientUfrag: string, clientPwd: string | undefined, remoteHost: string, remotePort: number): void +} + +export function parseStunUsernameUfrags (serverUfrag: string, clientUfrag: string): { serverUfrag: string, clientUfrag: string } | undefined { + if (serverUfrag.length === 0 || clientUfrag.length === 0) { + return undefined + } + + return { + serverUfrag, + clientUfrag + } } export async function stunListener (host: string, port: number, log: Logger, cb: Callback): Promise { const listener = new IceUdpMuxListener(port, host) listener.onUnhandledStunRequest(request => { - if (request.ufrag == null) { + if (request.localUfrag == null || request.ufrag == null) { + if (request.ufrag != null) { + log.trace('incoming legacy STUN packet from %s:%d %s', request.host, request.port, request.ufrag) + cb(request.ufrag, request.ufrag, undefined, request.host, request.port) + } + return } - log.trace('incoming STUN packet from %s:%d %s', request.host, request.port, request.ufrag) + if (!request.localUfrag.startsWith('libp2p+webrtc+v2/')) { + log.trace('incoming legacy STUN packet from %s:%d %s', request.host, request.port, request.ufrag) + cb(request.ufrag, request.ufrag, undefined, request.host, request.port) + return + } + + const parsed = parseStunUsernameUfrags(request.localUfrag, request.ufrag) + + if (parsed == null) { + log.trace('incoming STUN packet from %s:%d had invalid ufrags %s %s', request.host, request.port, request.localUfrag, request.ufrag) + return + } + + log.trace('incoming STUN packet from %s:%d %s:%s', request.host, request.port, request.localUfrag, request.ufrag) + + const clientPwd = decodeV2ClientPwd(parsed.serverUfrag) - cb(request.ufrag, request.host, request.port) + cb(parsed.serverUfrag, parsed.clientUfrag, clientPwd, request.host, request.port) }) return { diff --git a/packages/transport-webrtc/src/util.ts b/packages/transport-webrtc/src/util.ts index d4999996c7..039ae7cec6 100644 --- a/packages/transport-webrtc/src/util.ts +++ b/packages/transport-webrtc/src/util.ts @@ -1,7 +1,7 @@ import { detect } from 'detect-browser' import pDefer from 'p-defer' import pTimeout from 'p-timeout' -import { DATA_CHANNEL_DRAIN_TIMEOUT, DEFAULT_ICE_SERVERS, UFRAG_ALPHABET, UFRAG_PREFIX } from './constants.ts' +import { DATA_CHANNEL_DRAIN_TIMEOUT, DEFAULT_ICE_SERVERS, UFRAG_ALPHABET, UFRAG_PREFIX_V1 } from './constants.ts' import type { LoggerOptions } from '@libp2p/interface' import type { Duplex, Source } from 'it-stream-types' import type { PeerConnection } from 'node-datachannel' @@ -106,6 +106,6 @@ export async function getRtcConfiguration (config?: RTCConfiguration | (() => RT return config } -export const genUfrag = (len: number = 32): string => { - return UFRAG_PREFIX + [...Array(len)].map(() => UFRAG_ALPHABET.at(Math.floor(Math.random() * UFRAG_ALPHABET.length))).join('') +export const genUfrag = (len: number = 32, prefix: string = UFRAG_PREFIX_V1): string => { + return prefix + [...Array(len)].map(() => UFRAG_ALPHABET.at(Math.floor(Math.random() * UFRAG_ALPHABET.length))).join('') } diff --git a/packages/transport-webrtc/test/sdp.spec.ts b/packages/transport-webrtc/test/sdp.spec.ts index 3d8f1bcd32..2a11c89f22 100644 --- a/packages/transport-webrtc/test/sdp.spec.ts +++ b/packages/transport-webrtc/test/sdp.spec.ts @@ -85,4 +85,11 @@ a=end-of-candidates` expect(output.toString()).to.equal('/certhash/uEiC5P6FL6EZzCG9zUT4nnVa3KWdMSriNIe-_5roWN7psKg') }) + + it('pads short client offer ice-pwd values', () => { + const result = underTest.clientOfferFromMultiAddr(sampleMultiAddr, 'short-ufrag') + + expect(result.sdp).to.contain('a=ice-ufrag:short-ufrag') + expect(result.sdp).to.match(/a=ice-pwd:short-ufrag0{11}\n/) + }) }) diff --git a/packages/transport-webrtc/test/stun-listener.spec.ts b/packages/transport-webrtc/test/stun-listener.spec.ts new file mode 100644 index 0000000000..86ad7245a7 --- /dev/null +++ b/packages/transport-webrtc/test/stun-listener.spec.ts @@ -0,0 +1,43 @@ +import { expect } from 'aegir/chai' +import { createDialerRTCPeerConnection } from '../src/private-to-public/utils/get-rtcpeerconnection.ts' +import { getIcePwdFromSdp } from '../src/private-to-public/utils/sdp.ts' +import { parseStunUsernameUfrags } from '../src/private-to-public/utils/stun-listener.ts' + +function getIceUfragFromSdp (sdp: string | undefined): string | undefined { + return sdp?.match(/^a=ice-ufrag:(?[^\r\n]+)$/m)?.groups?.ufrag +} + +describe('stun listener username parsing', () => { + it('should parse server and client ufrags from the username', () => { + const result = parseStunUsernameUfrags('libp2p+webrtc+v2/server', 'browserClient') + + expect(result).to.deep.equal({ + serverUfrag: 'libp2p+webrtc+v2/server', + clientUfrag: 'browserClient' + }) + }) + + it('should reject malformed usernames', () => { + expect(parseStunUsernameUfrags('', 'browserClient')).to.be.undefined() + expect(parseStunUsernameUfrags('libp2p+webrtc+v2/server', '')).to.be.undefined() + }) + + it('should support pre-seeded distinct v2 ICE credentials on node', async () => { + const clientUfrag = 'clientUfrag123456' + const clientPwd = 'clientPassword1234567890' + const { peerConnection } = await createDialerRTCPeerConnection('client', clientUfrag, { + pwd: clientPwd + }) + peerConnection.createDataChannel('', { negotiated: true, id: 0 }) + + try { + const offer = await peerConnection.createOffer() + await peerConnection.setLocalDescription(offer) + + expect(getIceUfragFromSdp(peerConnection.localDescription?.sdp)).to.equal(clientUfrag) + expect(getIcePwdFromSdp(peerConnection.localDescription?.sdp)).to.equal(clientPwd) + } finally { + peerConnection.close() + } + }) +}) diff --git a/packages/transport-webrtc/test/transport.spec.ts b/packages/transport-webrtc/test/transport.spec.ts index c358c32b0d..72db91bc32 100644 --- a/packages/transport-webrtc/test/transport.spec.ts +++ b/packages/transport-webrtc/test/transport.spec.ts @@ -14,7 +14,7 @@ import { isNode, isElectronMain } from 'wherearewe' import { WebRTCDirectTransport } from '../src/private-to-public/transport.js' import { supportsIpV6 } from './util.ts' import type { WebRTCDirectTransportComponents } from '../src/private-to-public/transport.js' -import type { Upgrader, Listener, Transport } from '@libp2p/interface' +import type { Connection, Upgrader, Listener, Transport } from '@libp2p/interface' import type { TransportManager } from '@libp2p/interface-internal' import type { Multiaddr } from '@multiformats/multiaddr' @@ -34,6 +34,32 @@ function assertAllMultiaddrsHaveSamePort (addrs: Multiaddr[]): void { const LISTEN_SUPPORTED = isNode || isElectronMain +async function createTransportComponents (): Promise { + const privateKey = await generateKeyPair('Ed25519') + const datastore = new MemoryDatastore() + const logger = defaultLogger() + + return { + peerId: peerIdFromPrivateKey(privateKey), + logger, + transportManager: stubInterface(), + privateKey, + upgrader: stubInterface({ + createInboundAbortSignal: (signal) => { + return anySignal([ + AbortSignal.timeout(5_000), + signal + ]) + } + }), + datastore, + keychain: keychain()({ + datastore, + logger + }) + } +} + describe('WebRTCDirect Transport', () => { let components: WebRTCDirectTransportComponents let listener: Listener @@ -302,4 +328,94 @@ describe('WebRTCDirect Transport', () => { await listener.close() await otherListener.close() }) + + it('v1 client can dial dual server', async function () { + if (!LISTEN_SUPPORTED) { + return this.skip() + } + + let inboundCalled = false + let outboundCalled = false + const outboundConnection = stubInterface() + + const serverListener = transport.createListener({ + upgrader: stubInterface({ + upgradeInbound: async () => { + inboundCalled = true + } + }) + }) + + const clientTransport = new WebRTCDirectTransport(await createTransportComponents()) + await start(clientTransport) + + try { + await serverListener.listen(multiaddr('/ip4/127.0.0.1/udp/0')) + const addrs = serverListener.getAddrs() + const serverAddr = addrs.find(addr => getNetConfig(addr).host === '127.0.0.1') ?? addrs[0] + + const conn = await clientTransport.dial(serverAddr, { + upgrader: stubInterface({ + upgradeOutbound: async () => { + outboundCalled = true + return outboundConnection + } + }), + signal: AbortSignal.timeout(15_000) + }) + + expect(conn).to.equal(outboundConnection) + expect(inboundCalled).to.be.true() + expect(outboundCalled).to.be.true() + } finally { + await serverListener.close() + await stop(clientTransport) + } + }) + + it('v2 client can dial dual server', async function () { + if (!LISTEN_SUPPORTED) { + return this.skip() + } + + let inboundCalled = false + let outboundCalled = false + const outboundConnection = stubInterface() + + const serverListener = transport.createListener({ + upgrader: stubInterface({ + upgradeInbound: async () => { + inboundCalled = true + } + }) + }) + + const clientTransport = new WebRTCDirectTransport(await createTransportComponents(), { + version: 'v2' + }) + await start(clientTransport) + + try { + await serverListener.listen(multiaddr('/ip4/127.0.0.1/udp/0')) + const addrs = serverListener.getAddrs() + const serverAddr = addrs.find(addr => getNetConfig(addr).host === '127.0.0.1') ?? addrs[0] + + const conn = await clientTransport.dial(serverAddr, { + upgrader: stubInterface({ + upgradeOutbound: async () => { + outboundCalled = true + return outboundConnection + } + }), + signal: AbortSignal.timeout(15_000) + }) + + expect(conn).to.equal(outboundConnection) + expect(inboundCalled).to.be.true() + expect(outboundCalled).to.be.true() + } finally { + await serverListener.close() + await stop(clientTransport) + } + }) }) From 03fc5fa1863f4ac80f0702aa4117a41bbf7a3468 Mon Sep 17 00:00:00 2001 From: dozyio Date: Sun, 26 Apr 2026 10:50:00 +0100 Subject: [PATCH 2/9] chore: bump node-datachannel --- packages/transport-webrtc/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/transport-webrtc/package.json b/packages/transport-webrtc/package.json index 961e8204ee..c5de2ffc85 100644 --- a/packages/transport-webrtc/package.json +++ b/packages/transport-webrtc/package.json @@ -66,7 +66,7 @@ "it-stream-types": "^2.0.2", "main-event": "^1.0.1", "multiformats": "^13.4.0", - "node-datachannel": "^0.29.0", + "node-datachannel": "^0.32.3", "p-defer": "^4.0.1", "p-event": "^7.0.0", "p-timeout": "^7.0.0", From 72988f595cdf87b1be8b347d7b82da55de5078e4 Mon Sep 17 00:00:00 2001 From: dozyio Date: Sun, 26 Apr 2026 12:03:42 +0100 Subject: [PATCH 3/9] chore: add ufrags to dict --- .github/dictionary.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/dictionary.txt b/.github/dictionary.txt index 02aaa2027f..fc79a952ec 100644 --- a/.github/dictionary.txt +++ b/.github/dictionary.txt @@ -26,6 +26,7 @@ SECG setbit stopstr supercop +ufrags unuse userland xxhandshake From 997a1542307bd66957243f7842ab526463c51720 Mon Sep 17 00:00:00 2001 From: dozyio Date: Sun, 26 Apr 2026 12:26:32 +0100 Subject: [PATCH 4/9] chore: skip node tests in browser --- packages/transport-webrtc/package.json | 2 ++ packages/transport-webrtc/test/stun-listener.spec.ts | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/transport-webrtc/package.json b/packages/transport-webrtc/package.json index 1bb3b68f35..cb764ae340 100644 --- a/packages/transport-webrtc/package.json +++ b/packages/transport-webrtc/package.json @@ -97,6 +97,8 @@ "./dist/src/private-to-public/listener.js": "./dist/src/private-to-public/listener.browser.js", "./dist/src/private-to-public/transport.js": "./dist/src/private-to-public/transport.browser.js", "./dist/src/private-to-public/utils/get-rtcpeerconnection.js": "./dist/src/private-to-public/utils/get-rtcpeerconnection.browser.js", + "node-datachannel": "./dist/src/webrtc/node-datachannel.browser.js", + "node-datachannel/polyfill": "./dist/src/webrtc/index.browser.js", "node:net": false, "node:os": false }, diff --git a/packages/transport-webrtc/test/stun-listener.spec.ts b/packages/transport-webrtc/test/stun-listener.spec.ts index 86ad7245a7..5bbf57caeb 100644 --- a/packages/transport-webrtc/test/stun-listener.spec.ts +++ b/packages/transport-webrtc/test/stun-listener.spec.ts @@ -1,4 +1,5 @@ import { expect } from 'aegir/chai' +import { isElectronMain, isNode } from 'wherearewe' import { createDialerRTCPeerConnection } from '../src/private-to-public/utils/get-rtcpeerconnection.ts' import { getIcePwdFromSdp } from '../src/private-to-public/utils/sdp.ts' import { parseStunUsernameUfrags } from '../src/private-to-public/utils/stun-listener.ts' @@ -22,7 +23,11 @@ describe('stun listener username parsing', () => { expect(parseStunUsernameUfrags('libp2p+webrtc+v2/server', '')).to.be.undefined() }) - it('should support pre-seeded distinct v2 ICE credentials on node', async () => { + it('should support pre-seeded distinct v2 ICE credentials on node', async function () { + if (!isNode && !isElectronMain) { + return this.skip() + } + const clientUfrag = 'clientUfrag123456' const clientPwd = 'clientPassword1234567890' const { peerConnection } = await createDialerRTCPeerConnection('client', clientUfrag, { From 5a96f96841633e8e161b33cfa89e948da2774d0f Mon Sep 17 00:00:00 2001 From: dozyio Date: Sun, 26 Apr 2026 12:40:31 +0100 Subject: [PATCH 5/9] chore: Could not resolve "node-datachannel" --- packages/transport-webrtc/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/transport-webrtc/package.json b/packages/transport-webrtc/package.json index cb764ae340..cb31c0e964 100644 --- a/packages/transport-webrtc/package.json +++ b/packages/transport-webrtc/package.json @@ -97,6 +97,7 @@ "./dist/src/private-to-public/listener.js": "./dist/src/private-to-public/listener.browser.js", "./dist/src/private-to-public/transport.js": "./dist/src/private-to-public/transport.browser.js", "./dist/src/private-to-public/utils/get-rtcpeerconnection.js": "./dist/src/private-to-public/utils/get-rtcpeerconnection.browser.js", + "./dist/src/private-to-public/utils/stun-listener.js": "./dist/src/private-to-public/utils/stun-listener.browser.js", "node-datachannel": "./dist/src/webrtc/node-datachannel.browser.js", "node-datachannel/polyfill": "./dist/src/webrtc/index.browser.js", "node:net": false, From cd38ea435ae0d9651f2fbe9adc7574494fa8e550 Mon Sep 17 00:00:00 2001 From: dozyio Date: Sun, 26 Apr 2026 13:14:43 +0100 Subject: [PATCH 6/9] chore: split datachannel funcs --- .../utils/stun-listener.browser.ts | 16 ++++++++++++++++ .../private-to-public/utils/stun-listener.ts | 15 +++------------ .../src/private-to-public/utils/stun.ts | 10 ++++++++++ .../src/webrtc/node-datachannel.browser.ts | 17 +++++++++++++++++ .../transport-webrtc/test/stun-listener.spec.ts | 2 +- 5 files changed, 47 insertions(+), 13 deletions(-) create mode 100644 packages/transport-webrtc/src/private-to-public/utils/stun-listener.browser.ts create mode 100644 packages/transport-webrtc/src/private-to-public/utils/stun.ts create mode 100644 packages/transport-webrtc/src/webrtc/node-datachannel.browser.ts diff --git a/packages/transport-webrtc/src/private-to-public/utils/stun-listener.browser.ts b/packages/transport-webrtc/src/private-to-public/utils/stun-listener.browser.ts new file mode 100644 index 0000000000..c3f9912776 --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/utils/stun-listener.browser.ts @@ -0,0 +1,16 @@ +import { UnimplementedError } from '../../error.ts' +export { parseStunUsernameUfrags } from './stun.ts' +import type { Logger } from '@libp2p/interface' + +export interface StunServer { + close(): Promise + address(): never +} + +export interface Callback { + (serverUfrag: string, clientUfrag: string, clientPwd: string | undefined, remoteHost: string, remotePort: number): void +} + +export async function stunListener (host: string, port: number, log: Logger, cb: Callback): Promise { + throw new UnimplementedError('stunListener') +} diff --git a/packages/transport-webrtc/src/private-to-public/utils/stun-listener.ts b/packages/transport-webrtc/src/private-to-public/utils/stun-listener.ts index a7dafe61a9..d5e4bf6b71 100644 --- a/packages/transport-webrtc/src/private-to-public/utils/stun-listener.ts +++ b/packages/transport-webrtc/src/private-to-public/utils/stun-listener.ts @@ -1,7 +1,9 @@ import { isIPv4 } from '@chainsafe/is-ip' import { IceUdpMuxListener } from 'node-datachannel' import { decodeV2ClientPwd } from './sdp.ts' +import { parseStunUsernameUfrags } from './stun.ts' import type { Logger } from '@libp2p/interface' +import type { IceUdpMuxRequest } from 'node-datachannel' import type { AddressInfo } from 'node:net' export interface StunServer { @@ -13,20 +15,9 @@ export interface Callback { (serverUfrag: string, clientUfrag: string, clientPwd: string | undefined, remoteHost: string, remotePort: number): void } -export function parseStunUsernameUfrags (serverUfrag: string, clientUfrag: string): { serverUfrag: string, clientUfrag: string } | undefined { - if (serverUfrag.length === 0 || clientUfrag.length === 0) { - return undefined - } - - return { - serverUfrag, - clientUfrag - } -} - export async function stunListener (host: string, port: number, log: Logger, cb: Callback): Promise { const listener = new IceUdpMuxListener(port, host) - listener.onUnhandledStunRequest(request => { + listener.onUnhandledStunRequest((request: IceUdpMuxRequest) => { if (request.localUfrag == null || request.ufrag == null) { if (request.ufrag != null) { log.trace('incoming legacy STUN packet from %s:%d %s', request.host, request.port, request.ufrag) diff --git a/packages/transport-webrtc/src/private-to-public/utils/stun.ts b/packages/transport-webrtc/src/private-to-public/utils/stun.ts new file mode 100644 index 0000000000..de75f5d982 --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/utils/stun.ts @@ -0,0 +1,10 @@ +export function parseStunUsernameUfrags (serverUfrag: string, clientUfrag: string): { serverUfrag: string, clientUfrag: string } | undefined { + if (serverUfrag.length === 0 || clientUfrag.length === 0) { + return undefined + } + + return { + serverUfrag, + clientUfrag + } +} diff --git a/packages/transport-webrtc/src/webrtc/node-datachannel.browser.ts b/packages/transport-webrtc/src/webrtc/node-datachannel.browser.ts new file mode 100644 index 0000000000..21ae49a484 --- /dev/null +++ b/packages/transport-webrtc/src/webrtc/node-datachannel.browser.ts @@ -0,0 +1,17 @@ +class NodeDataChannelUnavailableError extends Error { + constructor () { + super('node-datachannel is not available in browsers') + } +} + +export class PeerConnection { + constructor () { + throw new NodeDataChannelUnavailableError() + } +} + +export class IceUdpMuxListener { + constructor () { + throw new NodeDataChannelUnavailableError() + } +} diff --git a/packages/transport-webrtc/test/stun-listener.spec.ts b/packages/transport-webrtc/test/stun-listener.spec.ts index 5bbf57caeb..a66a302111 100644 --- a/packages/transport-webrtc/test/stun-listener.spec.ts +++ b/packages/transport-webrtc/test/stun-listener.spec.ts @@ -2,7 +2,7 @@ import { expect } from 'aegir/chai' import { isElectronMain, isNode } from 'wherearewe' import { createDialerRTCPeerConnection } from '../src/private-to-public/utils/get-rtcpeerconnection.ts' import { getIcePwdFromSdp } from '../src/private-to-public/utils/sdp.ts' -import { parseStunUsernameUfrags } from '../src/private-to-public/utils/stun-listener.ts' +import { parseStunUsernameUfrags } from '../src/private-to-public/utils/stun.ts' function getIceUfragFromSdp (sdp: string | undefined): string | undefined { return sdp?.match(/^a=ice-ufrag:(?[^\r\n]+)$/m)?.groups?.ufrag From ab07a2b9a3fcf1d244b105b25ab8ea164804d3df Mon Sep 17 00:00:00 2001 From: dozyio Date: Sun, 26 Apr 2026 13:26:13 +0100 Subject: [PATCH 7/9] chore: lint --- .../src/private-to-public/utils/stun-listener.browser.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/transport-webrtc/src/private-to-public/utils/stun-listener.browser.ts b/packages/transport-webrtc/src/private-to-public/utils/stun-listener.browser.ts index c3f9912776..fa2f6da62e 100644 --- a/packages/transport-webrtc/src/private-to-public/utils/stun-listener.browser.ts +++ b/packages/transport-webrtc/src/private-to-public/utils/stun-listener.browser.ts @@ -1,7 +1,8 @@ import { UnimplementedError } from '../../error.ts' -export { parseStunUsernameUfrags } from './stun.ts' import type { Logger } from '@libp2p/interface' +export { parseStunUsernameUfrags } from './stun.ts' + export interface StunServer { close(): Promise address(): never From 3c12a80b4c8646ebeaa2492a5432f7772e7ae0af Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Sat, 20 Jun 2026 01:59:46 +0200 Subject: [PATCH 8/9] fix(webrtc): align webrtc-direct v2 with the spec 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. --- .../src/private-to-public/utils/connect.ts | 13 ++++-- .../utils/get-rtcpeerconnection.ts | 6 ++- .../src/private-to-public/utils/sdp.ts | 9 ++-- .../private-to-public/utils/stun-listener.ts | 44 ++++++++++++------- .../src/private-to-public/utils/stun.ts | 37 +++++++++++++++- packages/transport-webrtc/test/sdp.spec.ts | 4 +- .../test/stun-listener.spec.ts | 28 +++++++++++- 7 files changed, 111 insertions(+), 30 deletions(-) diff --git a/packages/transport-webrtc/src/private-to-public/utils/connect.ts b/packages/transport-webrtc/src/private-to-public/utils/connect.ts index 3f7086d42c..0b0f07b869 100644 --- a/packages/transport-webrtc/src/private-to-public/utils/connect.ts +++ b/packages/transport-webrtc/src/private-to-public/utils/connect.ts @@ -65,9 +65,14 @@ export async function connect (peerConnection: RTCPeerConnection | DirectRTCPeer const localPwd = sdp.getIcePwdFromSdp(peerConnection.localDescription?.sdp) - if (localPwd != null) { - remoteAnswerUfrag = sdp.serverUfragV2(localPwd) + if (localPwd == null) { + // without the local ICE password we cannot build a valid v2 server + // ufrag; fail loudly instead of dialing with an unprefixed ufrag the + // server would reject + throw new WebRTCTransportError('Could not read local ICE password from local description for v2 dial') } + + remoteAnswerUfrag = sdp.serverUfragV2(localPwd) } else { // v1 compatibility path: force ice-ufrag === ice-pwd so the server can // infer credentials from STUN USERNAME without explicit SDP exchange. @@ -92,7 +97,9 @@ export async function connect (peerConnection: RTCPeerConnection | DirectRTCPeer options.log.trace('server created local answer') if (options.remotePwd != null) { - // v2 path: keep server answer credentials as generated. + // v2 path: the answer credentials were already pinned to server_ufrag + // (libp2p+webrtc+v2/) when the peer connection was created, + // so the generated answer already carries them and needs no munging. options.log.trace('server setting local description %s', answerSdp.sdp) await peerConnection.setLocalDescription(answerSdp) } else { diff --git a/packages/transport-webrtc/src/private-to-public/utils/get-rtcpeerconnection.ts b/packages/transport-webrtc/src/private-to-public/utils/get-rtcpeerconnection.ts index 414233eff2..a4a8c0bce5 100644 --- a/packages/transport-webrtc/src/private-to-public/utils/get-rtcpeerconnection.ts +++ b/packages/transport-webrtc/src/private-to-public/utils/get-rtcpeerconnection.ts @@ -42,8 +42,10 @@ export class DirectRTCPeerConnection extends RTCPeerConnection { } async createOffer (): Promise { - // have to set ufrag before creating offer - if (this.connectionState === 'new' && !this.ufrag.startsWith('libp2p+webrtc+v2/')) { + // node-datachannel needs the ICE credentials set before creating the offer. + // This runs for both v1 and v2 dials; the browser path uses a native + // RTCPeerConnection and never reaches this class. + if (this.connectionState === 'new') { this.peerConnection?.setLocalDescription('offer', { iceUfrag: this.ufrag, icePwd: this.pwd diff --git a/packages/transport-webrtc/src/private-to-public/utils/sdp.ts b/packages/transport-webrtc/src/private-to-public/utils/sdp.ts index 865f1e9683..9985d89fdd 100644 --- a/packages/transport-webrtc/src/private-to-public/utils/sdp.ts +++ b/packages/transport-webrtc/src/private-to-public/utils/sdp.ts @@ -7,6 +7,7 @@ import * as Digest from 'multiformats/hashes/digest' import { sha256 } from 'multiformats/hashes/sha2' import { MAX_MESSAGE_SIZE, UFRAG_PREFIX_V2 } from '../../constants.js' import { InvalidFingerprintError, UnsupportedHashAlgorithmError } from '../../error.js' +import { isIcePwd } from './stun.ts' import type { Multiaddr } from '@multiformats/multiaddr' import type { MultihashDigest } from 'multiformats/hashes/interface' @@ -47,7 +48,9 @@ export function decodeV2ClientPwd (serverUfrag: string): string | undefined { const clientPwd = serverUfrag.substring(UFRAG_PREFIX_V2.length) - if (clientPwd.length === 0) { + // The recovered value becomes the inferred offer's ice-pwd, so it must be a + // valid ICE password (ice-char, length 22..256) per RFC 8839 section 5.4. + if (!isIcePwd(clientPwd)) { return undefined } @@ -171,8 +174,6 @@ export function clientOfferFromMultiAddr (ma: Multiaddr, ufrag: string, pwd: str throw new InvalidParametersError(`Multiaddr ${ma} was not an IPv4 or IPv6 address`) } - const normalizedPwd = pwd.length >= 22 ? pwd : `${pwd}${'0'.repeat(22 - pwd.length)}` - const sdp = `v=0 o=- 0 0 IN IP${type === 'ip4' ? 4 : 6} ${host} s=- @@ -183,7 +184,7 @@ m=application ${port} UDP/DTLS/SCTP webrtc-datachannel a=mid:0 a=setup:active a=ice-ufrag:${ufrag} -a=ice-pwd:${normalizedPwd} +a=ice-pwd:${pwd} a=fingerprint:sha-256 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00 a=sctp-port:5000 a=max-message-size:${MAX_MESSAGE_SIZE} diff --git a/packages/transport-webrtc/src/private-to-public/utils/stun-listener.ts b/packages/transport-webrtc/src/private-to-public/utils/stun-listener.ts index d5e4bf6b71..fbdc84a9f8 100644 --- a/packages/transport-webrtc/src/private-to-public/utils/stun-listener.ts +++ b/packages/transport-webrtc/src/private-to-public/utils/stun-listener.ts @@ -1,5 +1,6 @@ import { isIPv4 } from '@chainsafe/is-ip' import { IceUdpMuxListener } from 'node-datachannel' +import { UFRAG_PREFIX_V1, UFRAG_PREFIX_V2 } from '../../constants.js' import { decodeV2ClientPwd } from './sdp.ts' import { parseStunUsernameUfrags } from './stun.ts' import type { Logger } from '@libp2p/interface' @@ -18,33 +19,42 @@ export interface Callback { export async function stunListener (host: string, port: number, log: Logger, cb: Callback): Promise { const listener = new IceUdpMuxListener(port, host) listener.onUnhandledStunRequest((request: IceUdpMuxRequest) => { - if (request.localUfrag == null || request.ufrag == null) { - if (request.ufrag != null) { - log.trace('incoming legacy STUN packet from %s:%d %s', request.host, request.port, request.ufrag) - cb(request.ufrag, request.ufrag, undefined, request.host, request.port) - } - - return - } - - if (!request.localUfrag.startsWith('libp2p+webrtc+v2/')) { - log.trace('incoming legacy STUN packet from %s:%d %s', request.host, request.port, request.ufrag) - cb(request.ufrag, request.ufrag, undefined, request.host, request.port) + if (request.ufrag == null) { return } - const parsed = parseStunUsernameUfrags(request.localUfrag, request.ufrag) + // The STUN USERNAME is "server_ufrag:client_ufrag" (RFC 8445 section 7.2.2). + // When the mux cannot split it (no colon) localUfrag is absent and the single + // ufrag is the shared v1 value used as both the server and client ufrag. + const serverUfrag = request.localUfrag ?? request.ufrag + const clientUfrag = request.ufrag + const parsed = parseStunUsernameUfrags(serverUfrag, clientUfrag) if (parsed == null) { - log.trace('incoming STUN packet from %s:%d had invalid ufrags %s %s', request.host, request.port, request.localUfrag, request.ufrag) + log.trace('incoming STUN packet from %s:%d had invalid ufrags %s %s', request.host, request.port, serverUfrag, clientUfrag) return } - log.trace('incoming STUN packet from %s:%d %s:%s', request.host, request.port, request.localUfrag, request.ufrag) + // Select the version explicitly from the server ufrag prefix. An unrecognized + // version is rejected, never assumed to be v1. See https://github.com/libp2p/specs/blob/master/webrtc/webrtc-direct.md. + if (parsed.serverUfrag.startsWith(UFRAG_PREFIX_V2)) { + const clientPwd = decodeV2ClientPwd(parsed.serverUfrag) + if (clientPwd == null) { + log.trace('incoming v2 STUN packet from %s:%d had an invalid client password %s', request.host, request.port, parsed.serverUfrag) + return + } + log.trace('incoming v2 STUN packet from %s:%d %s:%s', request.host, request.port, parsed.serverUfrag, parsed.clientUfrag) + cb(parsed.serverUfrag, parsed.clientUfrag, clientPwd, request.host, request.port) + return + } - const clientPwd = decodeV2ClientPwd(parsed.serverUfrag) + if (parsed.serverUfrag.startsWith(UFRAG_PREFIX_V1)) { + log.trace('incoming v1 STUN packet from %s:%d %s', request.host, request.port, parsed.serverUfrag) + cb(parsed.serverUfrag, parsed.clientUfrag, undefined, request.host, request.port) + return + } - cb(parsed.serverUfrag, parsed.clientUfrag, clientPwd, request.host, request.port) + log.trace('incoming STUN packet from %s:%d has an unsupported version prefix %s', request.host, request.port, parsed.serverUfrag) }) return { diff --git a/packages/transport-webrtc/src/private-to-public/utils/stun.ts b/packages/transport-webrtc/src/private-to-public/utils/stun.ts index de75f5d982..1b879543e5 100644 --- a/packages/transport-webrtc/src/private-to-public/utils/stun.ts +++ b/packages/transport-webrtc/src/private-to-public/utils/stun.ts @@ -1,5 +1,40 @@ +// ICE credential charset and length, per RFC 8839 section 5.4 +// (ice-char = ALPHA / DIGIT / "+" / "/"; ufrag = 4*256ice-char; +// password = 22*256ice-char). +// +// ice-char is ASCII-only, so any string that passes ICE_CHAR_REGEX has exactly +// one UTF-16 code unit per character. The String.length checks below therefore +// equal both the ice-char count and the byte length that go-libp2p measures with +// len(), so the two implementations accept the same credentials; non-ice-char +// input (including any multi-byte character) is rejected by both regardless of +// how each counts length. +const ICE_CHAR_REGEX = /^[A-Za-z0-9+/]+$/ +const ICE_UFRAG_MIN_LENGTH = 4 +const ICE_PWD_MIN_LENGTH = 22 +const ICE_CREDENTIAL_MAX_LENGTH = 256 + +/** + * True if `value` is a valid ICE username fragment + * (RFC 8839 section 5.4: ufrag = 4*256ice-char). + */ +export function isIceUfrag (value: string): boolean { + return value.length >= ICE_UFRAG_MIN_LENGTH && value.length <= ICE_CREDENTIAL_MAX_LENGTH && ICE_CHAR_REGEX.test(value) +} + +/** + * True if `value` is a valid ICE password + * (RFC 8839 section 5.4: password = 22*256ice-char). + */ +export function isIcePwd (value: string): boolean { + return value.length >= ICE_PWD_MIN_LENGTH && value.length <= ICE_CREDENTIAL_MAX_LENGTH && ICE_CHAR_REGEX.test(value) +} + export function parseStunUsernameUfrags (serverUfrag: string, clientUfrag: string): { serverUfrag: string, clientUfrag: string } | undefined { - if (serverUfrag.length === 0 || clientUfrag.length === 0) { + // Both fragments come from the attacker-controlled STUN USERNAME + // (":", RFC 8445 section 7.2.2) and are ICE username + // fragments. Reject anything outside ice-char or the length bounds in RFC 8839 + // section 5.4 before it goes into an SDP offer. + if (!isIceUfrag(serverUfrag) || !isIceUfrag(clientUfrag)) { return undefined } diff --git a/packages/transport-webrtc/test/sdp.spec.ts b/packages/transport-webrtc/test/sdp.spec.ts index 2a11c89f22..b990d45a70 100644 --- a/packages/transport-webrtc/test/sdp.spec.ts +++ b/packages/transport-webrtc/test/sdp.spec.ts @@ -86,10 +86,10 @@ a=end-of-candidates` expect(output.toString()).to.equal('/certhash/uEiC5P6FL6EZzCG9zUT4nnVa3KWdMSriNIe-_5roWN7psKg') }) - it('pads short client offer ice-pwd values', () => { + it('renders the client offer ice-pwd verbatim', () => { const result = underTest.clientOfferFromMultiAddr(sampleMultiAddr, 'short-ufrag') expect(result.sdp).to.contain('a=ice-ufrag:short-ufrag') - expect(result.sdp).to.match(/a=ice-pwd:short-ufrag0{11}\n/) + expect(result.sdp).to.contain('a=ice-pwd:short-ufrag\n') }) }) diff --git a/packages/transport-webrtc/test/stun-listener.spec.ts b/packages/transport-webrtc/test/stun-listener.spec.ts index a66a302111..461bef0704 100644 --- a/packages/transport-webrtc/test/stun-listener.spec.ts +++ b/packages/transport-webrtc/test/stun-listener.spec.ts @@ -1,7 +1,7 @@ import { expect } from 'aegir/chai' import { isElectronMain, isNode } from 'wherearewe' import { createDialerRTCPeerConnection } from '../src/private-to-public/utils/get-rtcpeerconnection.ts' -import { getIcePwdFromSdp } from '../src/private-to-public/utils/sdp.ts' +import { decodeV2ClientPwd, getIcePwdFromSdp } from '../src/private-to-public/utils/sdp.ts' import { parseStunUsernameUfrags } from '../src/private-to-public/utils/stun.ts' function getIceUfragFromSdp (sdp: string | undefined): string | undefined { @@ -23,6 +23,32 @@ describe('stun listener username parsing', () => { expect(parseStunUsernameUfrags('libp2p+webrtc+v2/server', '')).to.be.undefined() }) + it('should reject ufrags outside the RFC 8839 ice-char set or length bounds', () => { + // CRLF / SDP injection attempt + expect(parseStunUsernameUfrags('libp2p+webrtc+v2/aaaa\r\na=candidate:x', 'browserClient')).to.be.undefined() + // ':' is not an ice-char + expect(parseStunUsernameUfrags('libp2p+webrtc+v2/aaaa', 'ab:cd')).to.be.undefined() + // too short (< 4 chars) + expect(parseStunUsernameUfrags('abc', 'browserClient')).to.be.undefined() + // too long (> 256 chars) + expect(parseStunUsernameUfrags('a'.repeat(257), 'browserClient')).to.be.undefined() + // multi-byte input: String.length (UTF-16) differs from the byte length + // go-libp2p measures, but both reject it on the charset check + expect(parseStunUsernameUfrags('abécd', 'browserClient')).to.be.undefined() + expect(parseStunUsernameUfrags('ab😀cd', 'browserClient')).to.be.undefined() + }) + + it('decodeV2ClientPwd validates the recovered ICE password', () => { + const pwd = 'a'.repeat(24) + expect(decodeV2ClientPwd(`libp2p+webrtc+v2/${pwd}`)).to.equal(pwd) + // wrong prefix + expect(decodeV2ClientPwd(`libp2p+webrtc+v1/${pwd}`)).to.be.undefined() + // too short (< 22 chars) + expect(decodeV2ClientPwd('libp2p+webrtc+v2/short')).to.be.undefined() + // non-ice-char (CRLF) + expect(decodeV2ClientPwd(`libp2p+webrtc+v2/${'a'.repeat(22)}\r\n`)).to.be.undefined() + }) + it('should support pre-seeded distinct v2 ICE credentials on node', async function () { if (!isNode && !isElectronMain) { return this.skip() From 398affa14e59fcb71e7703f3e1b9180df2c9fa30 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Sat, 20 Jun 2026 02:39:32 +0200 Subject: [PATCH 9/9] chore(webrtc): ignore non-ascii spell-check word --- packages/transport-webrtc/test/stun-listener.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/transport-webrtc/test/stun-listener.spec.ts b/packages/transport-webrtc/test/stun-listener.spec.ts index 461bef0704..44dc3baafe 100644 --- a/packages/transport-webrtc/test/stun-listener.spec.ts +++ b/packages/transport-webrtc/test/stun-listener.spec.ts @@ -34,6 +34,7 @@ describe('stun listener username parsing', () => { expect(parseStunUsernameUfrags('a'.repeat(257), 'browserClient')).to.be.undefined() // multi-byte input: String.length (UTF-16) differs from the byte length // go-libp2p measures, but both reject it on the charset check + /* spell-checker:disable-next-line */ expect(parseStunUsernameUfrags('abécd', 'browserClient')).to.be.undefined() expect(parseStunUsernameUfrags('ab😀cd', 'browserClient')).to.be.undefined() })