Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ SECG
setbit
stopstr
supercop
ufrags
unuse
userland
xxhandshake
Expand Down
3 changes: 3 additions & 0 deletions packages/transport-webrtc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down
3 changes: 2 additions & 1 deletion packages/transport-webrtc/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions packages/transport-webrtc/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -351,4 +361,4 @@ function webRTC (init?: WebRTCTransportInit): (components: WebRTCTransportCompon
return (components: WebRTCTransportComponents) => new WebRTCTransport(components, init)
}

export { webRTC, webRTCDirect }
export { webRTC, webRTCDirect, webRTCDirectV2 }
14 changes: 8 additions & 6 deletions packages/transport-webrtc/src/private-to-public/listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,10 @@ export class WebRTCDirectListener extends TypedEventEmitter<ListenerEvents> 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)
})
Expand All @@ -170,8 +170,8 @@ export class WebRTCDirectListener extends TypedEventEmitter<ListenerEvents> impl
}
}

private async incomingConnection (ufrag: string, remoteHost: string, remotePort: number, signal: AbortSignal): Promise<void> {
const key = `${remoteHost}:${remotePort}:${ufrag}`
private async incomingConnection (serverUfrag: string, clientUfrag: string, clientPwd: string | undefined, remoteHost: string, remotePort: number, signal: AbortSignal): Promise<void> {
const key = `${remoteHost}:${remotePort}:${serverUfrag}:${clientUfrag}`
let peerConnection = this.connections.get(key)

if (peerConnection != null) {
Expand All @@ -185,7 +185,7 @@ export class WebRTCDirectListener extends TypedEventEmitter<ListenerEvents> 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,
Expand All @@ -208,12 +208,14 @@ export class WebRTCDirectListener extends TypedEventEmitter<ListenerEvents> 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -84,15 +92,19 @@ 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 {
peerConnection,
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 {
Expand All @@ -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,
Expand Down
63 changes: 46 additions & 17 deletions packages/transport-webrtc/src/private-to-public/utils/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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/<client_pwd>) 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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -39,11 +42,13 @@ export class DirectRTCPeerConnection extends RTCPeerConnection {
}

async createOffer (): Promise<globalThis.RTCSessionDescriptionInit | any> {
// 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
})
}

Expand All @@ -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
})
}

Expand Down Expand Up @@ -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 }>
Expand All @@ -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,
Expand Down
37 changes: 34 additions & 3 deletions packages/transport-webrtc/src/private-to-public/utils/sdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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(?<fingerprint>(:?[0-9a-fA-F]{2})+)$/m
const icePwdRegex = /^a=ice-pwd:(?<pwd>[^\r\n]+)$/m

export function getFingerprintFromSdp (sdp: string | undefined): string | undefined {
if (sdp == null) {
return undefined
Expand All @@ -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()
Expand Down Expand Up @@ -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') {
Expand All @@ -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}
Expand Down
Loading
Loading