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 diff --git a/packages/transport-webrtc/package.json b/packages/transport-webrtc/package.json index c35f99dbed..7061e49301 100644 --- a/packages/transport-webrtc/package.json +++ b/packages/transport-webrtc/package.json @@ -97,6 +97,9 @@ "./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, "node:os": false }, 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 fdb8c6ac02..a56b5afba3 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 1491e80fd9..d171fd937f 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.ts' import { UnimplementedError } from '../error.ts' import { genUfrag } from '../util.ts' 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 4aae1e5c49..17c13b1087 100644 --- a/packages/transport-webrtc/src/private-to-public/utils/connect.ts +++ b/packages/transport-webrtc/src/private-to-public/utils/connect.ts @@ -23,6 +23,9 @@ export interface ConnectOptions { remotePeer?: PeerId signal: AbortSignal privateKey: PrivateKey + remoteUfrag?: string + remotePwd?: string + version?: 'v1' | 'v2' } export interface ClientOptions extends ConnectOptions { @@ -48,37 +51,63 @@ 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) { + // 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. + 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: 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 { + // 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 dcd2c37770..a82147e25a 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 @@ -39,11 +42,13 @@ export class DirectRTCPeerConnection extends RTCPeerConnection { } async createOffer (): Promise { - // have to set ufrag before creating offer + // 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.ufrag + icePwd: this.pwd }) } @@ -55,7 +60,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 +97,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 +119,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 76b9561f13..dc31db5a6f 100644 --- a/packages/transport-webrtc/src/private-to-public/utils/sdp.ts +++ b/packages/transport-webrtc/src/private-to-public/utils/sdp.ts @@ -5,8 +5,9 @@ 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.ts' +import { MAX_MESSAGE_SIZE, UFRAG_PREFIX_V2 } from '../../constants.ts' import { InvalidFingerprintError, UnsupportedHashAlgorithmError } from '../../error.ts' +import { isIcePwd } from './stun.ts' import type { Multiaddr } from '@multiformats/multiaddr' import type { MultihashDigest } from 'multiformats/hashes/interface' @@ -17,6 +18,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 +29,34 @@ 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) + + // 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 + } + + return clientPwd +} + // Extract the certhash from a multiaddr export function certhash (ma: Multiaddr): string { const components = ma.getComponents() @@ -136,7 +167,7 @@ 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') { @@ -153,7 +184,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:${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.browser.ts b/packages/transport-webrtc/src/private-to-public/utils/stun-listener.browser.ts new file mode 100644 index 0000000000..fa2f6da62e --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/utils/stun-listener.browser.ts @@ -0,0 +1,17 @@ +import { UnimplementedError } from '../../error.ts' +import type { Logger } from '@libp2p/interface' + +export { parseStunUsernameUfrags } from './stun.ts' + +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 dfbf65c195..27a6c2af70 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,6 +1,10 @@ import { isIPv4 } from '@chainsafe/is-ip' import { IceUdpMuxListener } from 'node-datachannel' +import { UFRAG_PREFIX_V1, UFRAG_PREFIX_V2 } from '../../constants.ts' +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 { @@ -9,19 +13,48 @@ 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 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.ufrag == null) { return } - log.trace('incoming STUN packet from %s:%d %s', request.host, request.port, 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 - cb(request.ufrag, request.host, request.port) + 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, serverUfrag, clientUfrag) + return + } + + // 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 + } + + 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 + } + + 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 new file mode 100644 index 0000000000..1b879543e5 --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/utils/stun.ts @@ -0,0 +1,45 @@ +// 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 { + // 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 + } + + return { + serverUfrag, + clientUfrag + } +} diff --git a/packages/transport-webrtc/src/util.ts b/packages/transport-webrtc/src/util.ts index 80225e5d02..7a5c204a79 100644 --- a/packages/transport-webrtc/src/util.ts +++ b/packages/transport-webrtc/src/util.ts @@ -1,6 +1,6 @@ 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' @@ -102,6 +102,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/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/sdp.spec.ts b/packages/transport-webrtc/test/sdp.spec.ts index cac03c6a9e..bb163ce350 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('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.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 new file mode 100644 index 0000000000..44dc3baafe --- /dev/null +++ b/packages/transport-webrtc/test/stun-listener.spec.ts @@ -0,0 +1,75 @@ +import { expect } from 'aegir/chai' +import { isElectronMain, isNode } from 'wherearewe' +import { createDialerRTCPeerConnection } from '../src/private-to-public/utils/get-rtcpeerconnection.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 { + 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 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 + /* spell-checker:disable-next-line */ + 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() + } + + 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 90749e8d1d..24b4e84cce 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.ts' import { supportsIpV6 } from './util.ts' import type { WebRTCDirectTransportComponents } from '../src/private-to-public/transport.ts' -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) + } + }) })