From e2b65379bfe97f0585eefcf420baea7c347c33cc Mon Sep 17 00:00:00 2001 From: achingbrain Date: Fri, 7 Jun 2024 17:50:29 +0100 Subject: [PATCH 1/4] feat: WebRTC-Direct support for Node.js Supports listening and dialing WebRTC Direct multiaddrs in Node.js. Depends on: - [ ] https://github.com/libp2p/go-libp2p/issues/2827 - [ ] https://github.com/paullouisageneau/libdatachannel/pull/1201 - [ ] https://github.com/paullouisageneau/libdatachannel/pull/1204 - [ ] https://github.com/murat-dogan/node-datachannel/pull/257 - [ ] https://github.com/murat-dogan/node-datachannel/pull/256 Closes: - https://github.com/libp2p/js-libp2p/issues/2581 --- packages/integration-tests/test/interop.ts | 17 +- packages/transport-webrtc/package.json | 7 +- .../src/private-to-public/constants.ts | 2 + .../src/private-to-public/listener.browser.ts | 28 ++ .../src/private-to-public/listener.ts | 382 ++++++++++++++++++ .../src/private-to-public/options.ts | 4 - .../{ => private-to-public}/pb/message.proto | 0 .../src/{ => private-to-public}/pb/message.ts | 0 .../src/private-to-public/transport.ts | 114 +++--- .../utils/generate-certificates.browser.ts | 3 + .../utils/generate-certificates.ts | 57 +++ .../utils/generate-noise-prologue.ts | 38 ++ .../{util.ts => utils/generate-ufrag.ts} | 3 +- .../get-dialer-rtcpeerconnection.browser.ts | 20 + .../utils/get-dialer-rtcpeerconnection.ts | 25 ++ .../src/private-to-public/{ => utils}/sdp.ts | 79 +++- packages/transport-webrtc/src/stream.ts | 2 +- packages/transport-webrtc/test/sdp.spec.ts | 11 +- packages/transport-webrtc/test/stream.spec.ts | 2 +- .../transport-webrtc/test/transport.spec.ts | 10 +- packages/transport-webrtc/test/util.ts | 2 +- 21 files changed, 705 insertions(+), 101 deletions(-) create mode 100644 packages/transport-webrtc/src/private-to-public/constants.ts create mode 100644 packages/transport-webrtc/src/private-to-public/listener.browser.ts create mode 100644 packages/transport-webrtc/src/private-to-public/listener.ts delete mode 100644 packages/transport-webrtc/src/private-to-public/options.ts rename packages/transport-webrtc/src/{ => private-to-public}/pb/message.proto (100%) rename packages/transport-webrtc/src/{ => private-to-public}/pb/message.ts (100%) create mode 100644 packages/transport-webrtc/src/private-to-public/utils/generate-certificates.browser.ts create mode 100644 packages/transport-webrtc/src/private-to-public/utils/generate-certificates.ts create mode 100644 packages/transport-webrtc/src/private-to-public/utils/generate-noise-prologue.ts rename packages/transport-webrtc/src/private-to-public/{util.ts => utils/generate-ufrag.ts} (59%) create mode 100644 packages/transport-webrtc/src/private-to-public/utils/get-dialer-rtcpeerconnection.browser.ts create mode 100644 packages/transport-webrtc/src/private-to-public/utils/get-dialer-rtcpeerconnection.ts rename packages/transport-webrtc/src/private-to-public/{ => utils}/sdp.ts (68%) diff --git a/packages/integration-tests/test/interop.ts b/packages/integration-tests/test/interop.ts index 01f0a4a4c9..1e7a34f96e 100644 --- a/packages/integration-tests/test/interop.ts +++ b/packages/integration-tests/test/interop.ts @@ -15,6 +15,7 @@ import { mplex } from '@libp2p/mplex' import { peerIdFromKeys } from '@libp2p/peer-id' import { tcp } from '@libp2p/tcp' import { tls } from '@libp2p/tls' +import { webRTCDirect } from '@libp2p/webrtc' import { multiaddr } from '@multiformats/multiaddr' import { execa } from 'execa' import { path as p2pd } from 'go-libp2p' @@ -45,6 +46,12 @@ async function createGoPeer (options: SpawnOptions): Promise { if (options.noListen === true) { opts.push('-noListenAddrs') + + if (options.transport === 'webrtc-direct') { + // dialing webrtc-direct is broken in go-libp2p at the moment + // https://github.com/libp2p/go-libp2p/issues/2827 + throw new UnsupportedError() + } } else { if (options.transport == null || options.transport === 'tcp') { opts.push('-hostAddrs=/ip4/127.0.0.1/tcp/0') @@ -132,7 +139,11 @@ async function createJsPeer (options: SpawnOptions): Promise { addresses: { listen: [] }, - transports: [tcp(), circuitRelayTransport()], + transports: [ + tcp(), + circuitRelayTransport(), + webRTCDirect() + ], streamMuxers: [], connectionEncryption: [noise()], connectionManager: { @@ -143,12 +154,14 @@ async function createJsPeer (options: SpawnOptions): Promise { if (options.noListen !== true) { if (options.transport == null || options.transport === 'tcp') { opts.addresses?.listen?.push('/ip4/127.0.0.1/tcp/0') + } else if (options.transport === 'webrtc-direct') { + opts.addresses?.listen?.push('/ip4/127.0.0.1/udp/0/webrtc-direct') } else { throw new UnsupportedError() } } - if (options.transport === 'webtransport' || options.transport === 'webrtc-direct') { + if (options.transport === 'webtransport') { throw new UnsupportedError() } diff --git a/packages/transport-webrtc/package.json b/packages/transport-webrtc/package.json index 74e0b2bafd..c3ba976bee 100644 --- a/packages/transport-webrtc/package.json +++ b/packages/transport-webrtc/package.json @@ -50,6 +50,7 @@ "doc-check": "aegir doc-check" }, "dependencies": { + "@chainsafe/is-ip": "^2.0.2", "@chainsafe/libp2p-noise": "^15.0.0", "@libp2p/interface": "^1.4.0", "@libp2p/interface-internal": "^1.2.2", @@ -58,6 +59,7 @@ "@multiformats/mafmt": "^12.1.6", "@multiformats/multiaddr": "^12.2.3", "@multiformats/multiaddr-matcher": "^1.2.1", + "@peculiar/x509": "^1.11.0", "detect-browser": "^5.3.0", "it-length-prefixed": "^9.0.4", "it-protobuf-stream": "^1.1.3", @@ -72,6 +74,7 @@ "protons-runtime": "^5.4.0", "race-signal": "^1.0.2", "react-native-webrtc": "^118.0.7", + "stun": "^2.1.0", "uint8arraylist": "^2.4.8", "uint8arrays": "^5.1.0" }, @@ -98,7 +101,9 @@ "sinon-ts": "^2.0.0" }, "browser": { - "./dist/src/webrtc/index.js": "./dist/src/webrtc/index.browser.js" + "./dist/src/webrtc/index.js": "./dist/src/webrtc/index.browser.js", + "./dist/src/private-to-public/listener.js": "./dist/src/private-to-public/listener.browser.js", + "./dist/src/private-to-public/utils/get-dialer-rtcpeerconnection.js": "./dist/src/private-to-public/utils/get-dialer-rtcpeerconnection.browser.js" }, "react-native": { "./dist/src/webrtc/index.js": "./dist/src/webrtc/index.react-native.js" diff --git a/packages/transport-webrtc/src/private-to-public/constants.ts b/packages/transport-webrtc/src/private-to-public/constants.ts new file mode 100644 index 0000000000..215b082cd3 --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/constants.ts @@ -0,0 +1,2 @@ + +export const UFRAG_PREFIX = 'libp2p+webrtc+v1/' diff --git a/packages/transport-webrtc/src/private-to-public/listener.browser.ts b/packages/transport-webrtc/src/private-to-public/listener.browser.ts new file mode 100644 index 0000000000..d260b50cb6 --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/listener.browser.ts @@ -0,0 +1,28 @@ +import { TypedEventEmitter } from '@libp2p/interface' +import { unimplemented } from '../error.js' +import type { PeerId, ListenerEvents, Listener } from '@libp2p/interface' +import type { TransportManager } from '@libp2p/interface-internal' +import type { Multiaddr } from '@multiformats/multiaddr' + +export interface WebRTCDirectListenerComponents { + peerId: PeerId + transportManager: TransportManager +} + +export interface WebRTCDirectListenerInit { + shutdownController: AbortController +} + +export class WebRTCDirectListener extends TypedEventEmitter implements Listener { + async listen (): Promise { + throw unimplemented('WebRTCTransport.createListener') + } + + getAddrs (): Multiaddr[] { + return [] + } + + async close (): Promise { + + } +} diff --git a/packages/transport-webrtc/src/private-to-public/listener.ts b/packages/transport-webrtc/src/private-to-public/listener.ts new file mode 100644 index 0000000000..f12928e0c8 --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/listener.ts @@ -0,0 +1,382 @@ +import { createSocket } from 'node:dgram' +import { networkInterfaces } from 'node:os' +import { isIPv4, isIPv6 } from '@chainsafe/is-ip' +import { noise } from '@chainsafe/libp2p-noise' +import { TypedEventEmitter } from '@libp2p/interface' +import { multiaddr, protocols } from '@multiformats/multiaddr' +import { IP4 } from '@multiformats/multiaddr-matcher' +import { sha256 } from 'multiformats/hashes/sha2' +import { pEvent } from 'p-event' +// @ts-expect-error no types +import stun from 'stun' +import { dataChannelError } from '../error.js' +import { WebRTCMultiaddrConnection } from '../maconn.js' +import { DataChannelMuxerFactory } from '../muxer.js' +import { createStream } from '../stream.js' +import { isFirefox } from '../util.js' +import { RTCPeerConnection } from '../webrtc/index.js' +import { UFRAG_PREFIX } from './constants.js' +import { generateTransportCertificate, type TransportCertificate } from './utils/generate-certificates.js' +import { generateNoisePrologue } from './utils/generate-noise-prologue.js' +import * as sdp from './utils/sdp.js' +import type { DataChannelOptions } from '../index.js' +import type { PeerId, ListenerEvents, Listener, Connection, Upgrader, ComponentLogger, Logger, CounterGroup, Metrics } from '@libp2p/interface' +import type { Multiaddr } from '@multiformats/multiaddr' +import type { Socket, RemoteInfo } from 'node:dgram' +import type { AddressInfo } from 'node:net' + +/** + * The time to wait, in milliseconds, for the data channel handshake to complete + */ +const HANDSHAKE_TIMEOUT_MS = 10_000 + +export interface WebRTCDirectListenerComponents { + peerId: PeerId + logger: ComponentLogger + metrics?: Metrics +} + +export interface WebRTCDirectListenerInit { + shutdownController: AbortController + handler?(conn: Connection): void + upgrader: Upgrader + certificates?: TransportCertificate[] + maxInboundStreams?: number + dataChannel?: DataChannelOptions +} + +export interface WebRTCListenerMetrics { + listenerEvents: CounterGroup +} + +const UDP_PROTOCOL = protocols('udp') +const IP4_PROTOCOL = protocols('ip4') +const IP6_PROTOCOL = protocols('ip6') + +export class WebRTCDirectListener extends TypedEventEmitter implements Listener { + private socket?: Socket + private readonly shutdownController: AbortController + private readonly multiaddrs: Multiaddr[] + private certificate?: TransportCertificate + private readonly connections: Map + private readonly log: Logger + private readonly init: WebRTCDirectListenerInit + private readonly components: WebRTCDirectListenerComponents + private readonly metrics?: WebRTCListenerMetrics + + constructor (components: WebRTCDirectListenerComponents, init: WebRTCDirectListenerInit) { + super() + + this.init = init + this.components = components + this.shutdownController = init.shutdownController + this.multiaddrs = [] + this.connections = new Map() + this.log = components.logger.forComponent('libp2p:webrtc-direct') + + if (components.metrics != null) { + this.metrics = { + listenerEvents: components.metrics.registerCounterGroup('libp2p_webrtc-direct_listener_events_total', { + label: 'event', + help: 'Total count of WebRTC-direct listen events by type' + }) + } + } + } + + async listen (ma: Multiaddr): Promise { + const parts = ma.stringTuples() + + const ipVersion = IP4.matches(ma) ? 4 : 6 + + const host = parts + .filter(([code]) => code === IP4_PROTOCOL.code) + .pop()?.[1] ?? parts + .filter(([code]) => code === IP6_PROTOCOL.code) + .pop()?.[1] + + if (host == null) { + throw new Error('IP4/6 host must be specified in webrtc-direct mulitaddr') + } + + const port = parseInt(parts + .filter(([code, value]) => code === UDP_PROTOCOL.code) + .pop()?.[1] ?? '') + + if (isNaN(port)) { + throw new Error('UDP port must be specified in webrtc-direct mulitaddr') + } + + this.socket = createSocket({ + type: `udp${ipVersion}`, + reuseAddr: true + }) + + try { + this.socket.bind(port, host) + await pEvent(this.socket, 'listening') + } catch (err) { + this.socket.close() + throw err + } + + let certificate = this.certificate + + if (certificate == null) { + const keyPair = await crypto.subtle.generateKey({ + name: 'ECDSA', + namedCurve: 'P-256' + }, true, ['sign', 'verify']) + + certificate = this.certificate = await generateTransportCertificate(keyPair, { + days: 365 + }) + } + + const address = this.socket.address() + + getNetworkAddresses(address, ipVersion).forEach((ma) => { + this.multiaddrs.push(multiaddr(`${ma}/webrtc-direct/certhash/${certificate.certhash}`)) + }) + + this.socket.on('message', (msg, rinfo) => { + try { + const response = stun.decode(msg) + + // TODO: this needs to be rate limited keyed by the remote host to + // prevent a DOS attack + this.incomingConnection(response, rinfo, certificate).catch(err => { + this.log.error('could not process incoming STUN data', err) + }) + } catch (err) { + this.log.error('could not process incoming STUN data', err) + } + }) + } + + private async incomingConnection (stunMessage: any, rinfo: RemoteInfo, certificate: TransportCertificate): Promise { + const usernameAttribute = stunMessage.getAttribute(stun.constants.STUN_ATTR_USERNAME) + const username: string | undefined = usernameAttribute?.value?.toString() + + if (username == null || !username.startsWith(UFRAG_PREFIX)) { + this.log.trace('ufrag missing from incoming STUN message from %s:%s', rinfo.address, rinfo.port) + return + } + + const ufrag = username.split(':')[0] + const key = `${rinfo.address}:${rinfo.port}:${ufrag}` + let peerConnection = this.connections.get(key) + + if (peerConnection != null) { + return + } + + peerConnection = new RTCPeerConnection({ + // @ts-expect-error missing argument + iceUfrag: ufrag, + icePwd: ufrag, + disableFingerprintVerification: true, + certificatePemFile: certificate.pem, + keyPemFile: certificate.privateKey, + maxMessageSize: 16384 + }) + + this.connections.set(key, peerConnection) + + const eventListeningName = isFirefox ? 'iceconnectionstatechange' : 'connectionstatechange' + peerConnection.addEventListener(eventListeningName, () => { + switch (peerConnection?.connectionState) { + case 'failed': + case 'disconnected': + case 'closed': + this.connections.delete(key) + break + default: + break + } + }) + + const controller = new AbortController() + const signal = controller.signal + + try { + // create data channel for running the noise handshake. Once the data + // channel is opened, we will initiate the noise handshake. This is used + // to confirm the identity of the peer. + const dataChannelOpenPromise = new Promise((resolve, reject) => { + const handshakeDataChannel = peerConnection.createDataChannel('', { negotiated: true, id: 0 }) + const handshakeTimeout = setTimeout(() => { + const error = `Data channel was never opened: state: ${handshakeDataChannel.readyState}` + this.log.error(error) + this.metrics?.listenerEvents.increment({ open_error: true }) + reject(dataChannelError('data', error)) + }, HANDSHAKE_TIMEOUT_MS) + + handshakeDataChannel.onopen = (_) => { + clearTimeout(handshakeTimeout) + resolve(handshakeDataChannel) + } + + // ref: https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel/error_event + handshakeDataChannel.onerror = (event: Event) => { + clearTimeout(handshakeTimeout) + const errorTarget = event.target?.toString() ?? 'not specified' + const error = `Error opening a data channel for handshaking: ${errorTarget}` + this.log.error(error) + // NOTE: We use unknown error here but this could potentially be + // considered a reset by some standards. + this.metrics?.listenerEvents.increment({ unknown_error: true }) + reject(dataChannelError('data', error)) + } + }) + + // 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. + // uses dummy certhash + let remoteAddr = multiaddr(`/${rinfo.family === 'IPv4' ? 'ip4' : 'ip6'}/${rinfo.address}/udp/${rinfo.port}`) + const offerSdp = sdp.clientOfferFromMultiaddr(remoteAddr, ufrag) + await peerConnection.setRemoteDescription(offerSdp) + + const answerSdp = await peerConnection.createAnswer() + const mungedAnswerSdp = sdp.munge(answerSdp, ufrag) + await peerConnection.setLocalDescription(mungedAnswerSdp) + + // wait for peerconnection.onopen to fire, or for the datachannel to open + const handshakeDataChannel = await dataChannelOpenPromise + + // now that the connection has been opened, add the remote's certhash to + // it's multiaddr so we can complete the noise handshake + const remoteFingerprint = sdp.getFingerprintFromSdp(peerConnection.currentRemoteDescription?.sdp ?? '') ?? '' + remoteAddr = remoteAddr.encapsulate(sdp.fingerprint2Ma(remoteFingerprint)) + + // Do noise handshake. + // Set the Noise Prologue to libp2p-webrtc-noise: before + // starting the actual Noise handshake. + // is the concatenation of the of the two TLS fingerprints + // of A (responder) and B (initiator) in their byte representation. + const fingerprintsPrologue = generateNoisePrologue(peerConnection, sha256.code, remoteAddr, this.log, 'initiator') + + // Since we use the default crypto interface and do not use a static key + // or early data, we pass in undefined for these parameters. + const connectionEncrypter = noise({ prologueBytes: fingerprintsPrologue })(this.components) + + const wrappedChannel = createStream({ + channel: handshakeDataChannel, + direction: 'inbound', + logger: this.components.logger, + ...(this.init.dataChannel ?? {}) + }) + const wrappedDuplex = { + ...wrappedChannel, + sink: wrappedChannel.sink.bind(wrappedChannel), + source: (async function * () { + for await (const list of wrappedChannel.source) { + for (const buf of list) { + yield buf + } + } + }()) + } + + // Creating the connection before completion of the noise + // handshake ensures that the stream opening callback is set up + const maConn = new WebRTCMultiaddrConnection(this.components, { + peerConnection, + remoteAddr, + timeline: { + open: Date.now() + }, + metrics: this.metrics?.listenerEvents + }) + + const eventListeningName = isFirefox ? 'iceconnectionstatechange' : 'connectionstatechange' + + peerConnection.addEventListener(eventListeningName, () => { + switch (peerConnection.connectionState) { + case 'failed': + case 'disconnected': + case 'closed': + maConn.close().catch((err) => { + this.log.error('error closing connection', err) + }).finally(() => { + // Remove the event listener once the connection is closed + controller.abort() + }) + break + default: + break + } + }, { signal }) + + // Track opened peer connection + this.metrics?.listenerEvents.increment({ peer_connection: true }) + + const muxerFactory = new DataChannelMuxerFactory(this.components, { + peerConnection, + metrics: this.metrics?.listenerEvents, + dataChannelOptions: this.init.dataChannel + }) + + // For inbound connections, we are expected to start the noise handshake. + // Therefore, we need to secure an outbound noise connection from the remote. + const result = await connectionEncrypter.secureOutbound(this.components.peerId, wrappedDuplex) + maConn.remoteAddr = maConn.remoteAddr.encapsulate(`/p2p/${result.remotePeer}`) + + await this.init.upgrader.upgradeInbound(maConn, { skipProtection: true, skipEncryption: true, muxerFactory }) + } catch (err) { + peerConnection.close() + throw err + } + } + + getAddrs (): Multiaddr[] { + return this.multiaddrs + } + + async close (): Promise { + this.shutdownController.abort() + this.safeDispatchEvent('close', {}) + + await new Promise((resolve) => { + if (this.socket == null) { + resolve() + return + } + + this.socket.close(() => { + resolve() + }) + }) + } +} + +function getNetworkAddresses (host: AddressInfo, version: 4 | 6): string[] { + if (host.address === '0.0.0.0' || host.address === '::1') { + // return all ip4 interfaces + return Object.entries(networkInterfaces()) + .flatMap(([_, addresses]) => addresses) + .map(address => address?.address) + .filter(address => { + if (address == null) { + return false + } + + if (version === 4) { + return isIPv4(address) + } + + if (version === 6) { + return isIPv6(address) + } + + return false + }) + .map(address => `/ip${version}/${address}/udp/${host.port}`) + } + + return [ + `/ip${version}/${host.address}/udp/${host.port}` + ] +} diff --git a/packages/transport-webrtc/src/private-to-public/options.ts b/packages/transport-webrtc/src/private-to-public/options.ts deleted file mode 100644 index c9b3b1361d..0000000000 --- a/packages/transport-webrtc/src/private-to-public/options.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { CreateListenerOptions, DialOptions } from '@libp2p/interface' - -export interface WebRTCListenerOptions extends CreateListenerOptions {} -export interface WebRTCDialOptions extends DialOptions {} diff --git a/packages/transport-webrtc/src/pb/message.proto b/packages/transport-webrtc/src/private-to-public/pb/message.proto similarity index 100% rename from packages/transport-webrtc/src/pb/message.proto rename to packages/transport-webrtc/src/private-to-public/pb/message.proto diff --git a/packages/transport-webrtc/src/pb/message.ts b/packages/transport-webrtc/src/private-to-public/pb/message.ts similarity index 100% rename from packages/transport-webrtc/src/pb/message.ts rename to packages/transport-webrtc/src/private-to-public/pb/message.ts diff --git a/packages/transport-webrtc/src/private-to-public/transport.ts b/packages/transport-webrtc/src/private-to-public/transport.ts index 3050bea758..4a064a8cba 100644 --- a/packages/transport-webrtc/src/private-to-public/transport.ts +++ b/packages/transport-webrtc/src/private-to-public/transport.ts @@ -1,21 +1,22 @@ import { noise } from '@chainsafe/libp2p-noise' -import { type CreateListenerOptions, transportSymbol, type Transport, type Listener, type ComponentLogger, type Logger, type Connection, type CounterGroup, type Metrics, type PeerId } from '@libp2p/interface' +import { transportSymbol } from '@libp2p/interface' import * as p from '@libp2p/peer-id' import { protocols } from '@multiformats/multiaddr' import { WebRTCDirect } from '@multiformats/multiaddr-matcher' -import * as multihashes from 'multihashes' -import { concat } from 'uint8arrays/concat' -import { fromString as uint8arrayFromString } from 'uint8arrays/from-string' -import { dataChannelError, inappropriateMultiaddr, unimplemented, invalidArgument } from '../error.js' +import { dataChannelError, inappropriateMultiaddr } from '../error.js' import { WebRTCMultiaddrConnection } from '../maconn.js' import { DataChannelMuxerFactory } from '../muxer.js' import { createStream } from '../stream.js' import { isFirefox } from '../util.js' -import { RTCPeerConnection } from '../webrtc/index.js' -import * as sdp from './sdp.js' -import { genUfrag } from './util.js' -import type { WebRTCDialOptions } from './options.js' +import { UFRAG_PREFIX } from './constants.js' +import { WebRTCDirectListener } from './listener.js' +import { generateNoisePrologue } from './utils/generate-noise-prologue.js' +import { genUfrag } from './utils/generate-ufrag.js' +import { createDialerRTCPeerConnection } from './utils/get-dialer-rtcpeerconnection.js' +import * as sdp from './utils/sdp.js' import type { DataChannelOptions } from '../index.js' +import type { CreateListenerOptions, Transport, Listener, ComponentLogger, Logger, Connection, CounterGroup, Metrics, PeerId, Startable, DialOptions } from '@libp2p/interface' +import type { TransportManager } from '@libp2p/interface-internal' import type { Multiaddr } from '@multiformats/multiaddr' /** @@ -44,6 +45,7 @@ export interface WebRTCDirectTransportComponents { peerId: PeerId metrics?: Metrics logger: ComponentLogger + transportManager: TransportManager } export interface WebRTCMetrics { @@ -54,15 +56,19 @@ export interface WebRTCTransportDirectInit { dataChannel?: DataChannelOptions } -export class WebRTCDirectTransport implements Transport { +export class WebRTCDirectTransport implements Transport, Startable { private readonly log: Logger private readonly metrics?: WebRTCMetrics private readonly components: WebRTCDirectTransportComponents private readonly init: WebRTCTransportDirectInit + private shutdownController: AbortController + constructor (components: WebRTCDirectTransportComponents, init: WebRTCTransportDirectInit = {}) { this.log = components.logger.forComponent('libp2p:webrtc-direct') this.components = components this.init = init + this.shutdownController = new AbortController() + if (components.metrics != null) { this.metrics = { dialerEvents: components.metrics.registerCounterGroup('libp2p_webrtc-direct_dialer_events_total', { @@ -73,10 +79,18 @@ export class WebRTCDirectTransport implements Transport { } } + async start (): Promise { + this.shutdownController = new AbortController() + } + + async stop (): Promise { + this.shutdownController.abort() + } + /** * Dial a given multiaddr */ - async dial (ma: Multiaddr, options: WebRTCDialOptions): Promise { + async dial (ma: Multiaddr, options: DialOptions): Promise { const rawConn = await this._connect(ma, options) this.log('dialing address: %a', ma) return rawConn @@ -86,7 +100,10 @@ export class WebRTCDirectTransport implements Transport { * Create transport listeners no supported by browsers */ createListener (options: CreateListenerOptions): Listener { - throw unimplemented('WebRTCTransport.createListener') + return new WebRTCDirectListener(this.components, { + ...options, + shutdownController: this.shutdownController + }) } /** @@ -116,7 +133,7 @@ export class WebRTCDirectTransport implements Transport { /** * Connect to a peer using a multiaddr */ - async _connect (ma: Multiaddr, options: WebRTCDialOptions): Promise { + async _connect (ma: Multiaddr, options: DialOptions): Promise { const controller = new AbortController() const signal = controller.signal @@ -125,25 +142,14 @@ export class WebRTCDirectTransport implements Transport { throw inappropriateMultiaddr("we need to have the remote's PeerId") } const theirPeerId = p.peerIdFromString(remotePeerString) - const remoteCerthash = sdp.decodeCerthash(sdp.certhash(ma)) - - // ECDSA is preferred over RSA here. From our testing we find that P-256 elliptic - // curve is supported by Pion, webrtc-rs, as well as Chromium (P-228 and P-384 - // was not supported in Chromium). We use the same hash function as found in the - // multiaddr if it is supported. - const certificate = await RTCPeerConnection.generateCertificate({ - name: 'ECDSA', - namedCurve: 'P-256', - hash: sdp.toSupportedHashFunction(remoteCerthash.name) - } as any) - - const peerConnection = new RTCPeerConnection({ certificates: [certificate] }) + const ufrag = UFRAG_PREFIX + genUfrag(32) + const peerConnection = await createDialerRTCPeerConnection(ufrag, remoteCerthash.name) try { - // create data channel for running the noise handshake. Once the data channel is opened, - // the remote will initiate the noise handshake. This is used to confirm the identity of - // the peer. + // create data channel for running the noise handshake. Once the data + // channel is opened, the remote will initiate the noise handshake. This + // is used to confirm the identity of the peer. const dataChannelOpenPromise = new Promise((resolve, reject) => { const handshakeDataChannel = peerConnection.createDataChannel('', { negotiated: true, id: 0 }) const handshakeTimeout = setTimeout(() => { @@ -164,14 +170,13 @@ export class WebRTCDirectTransport implements Transport { const errorTarget = event.target?.toString() ?? 'not specified' const error = `Error opening a data channel for handshaking: ${errorTarget}` this.log.error(error) - // NOTE: We use unknown error here but this could potentially be considered a reset by some standards. + // NOTE: We use unknown error here but this could potentially be + // considered a reset by some standards. this.metrics?.dialerEvents.increment({ unknown_error: true }) reject(dataChannelError('data', error)) } }) - const ufrag = 'libp2p+webrtc+v1/' + genUfrag(32) - // 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 @@ -181,21 +186,21 @@ export class WebRTCDirectTransport implements Transport { await peerConnection.setLocalDescription(mungedOfferSdp) // construct answer sdp from multiaddr and ufrag - const answerSdp = sdp.fromMultiAddr(ma, ufrag) + const answerSdp = sdp.serverOfferFromMultiAddr(ma, ufrag) await peerConnection.setRemoteDescription(answerSdp) // wait for peerconnection.onopen to fire, or for the datachannel to open const handshakeDataChannel = await dataChannelOpenPromise - const myPeerId = this.components.peerId - // Do noise handshake. - // Set the Noise Prologue to libp2p-webrtc-noise: before starting the actual Noise handshake. - // is the concatenation of the of the two TLS fingerprints of A and B in their multihash byte representation, sorted in ascending order. - const fingerprintsPrologue = this.generateNoisePrologue(peerConnection, remoteCerthash.code, ma) - - // Since we use the default crypto interface and do not use a static key or early data, - // we pass in undefined for these parameters. + // Set the Noise Prologue to libp2p-webrtc-noise: before + // starting the actual Noise handshake. + // is the concatenation of the of the two TLS fingerprints + // of A (responder) and B (initiator) in their byte representation. + const fingerprintsPrologue = generateNoisePrologue(peerConnection, remoteCerthash.code, ma, this.log, 'responder') + + // Since we use the default crypto interface and do not use a static key + // or early data, we pass in undefined for these parameters. const connectionEncrypter = noise({ prologueBytes: fingerprintsPrologue })(this.components) const wrappedChannel = createStream({ @@ -257,7 +262,7 @@ export class WebRTCDirectTransport implements Transport { // For outbound connections, the remote is expected to start the noise handshake. // Therefore, we need to secure an inbound noise connection from the remote. - await connectionEncrypter.secureInbound(myPeerId, wrappedDuplex, theirPeerId) + await connectionEncrypter.secureInbound(this.components.peerId, wrappedDuplex, theirPeerId) return await options.upgrader.upgradeOutbound(maConn, { skipProtection: true, skipEncryption: true, muxerFactory }) } catch (err) { @@ -265,29 +270,4 @@ export class WebRTCDirectTransport implements Transport { throw err } } - - /** - * Generate a noise prologue from the peer connection's certificate. - * noise prologue = bytes('libp2p-webrtc-noise:') + noise-responder fingerprint + noise-initiator fingerprint - */ - private generateNoisePrologue (pc: RTCPeerConnection, hashCode: multihashes.HashCode, ma: Multiaddr): Uint8Array { - if (pc.getConfiguration().certificates?.length === 0) { - throw invalidArgument('no local certificate') - } - - const localFingerprint = sdp.getLocalFingerprint(pc, { - log: this.log - }) - if (localFingerprint == null) { - throw invalidArgument('no local fingerprint found') - } - - const localFpString = localFingerprint.trim().toLowerCase().replaceAll(':', '') - const localFpArray = uint8arrayFromString(localFpString, 'hex') - const local = multihashes.encode(localFpArray, hashCode) - const remote: Uint8Array = sdp.mbdecoder.decode(sdp.certhash(ma)) - const prefix = uint8arrayFromString('libp2p-webrtc-noise:') - - return concat([prefix, local, remote]) - } } diff --git a/packages/transport-webrtc/src/private-to-public/utils/generate-certificates.browser.ts b/packages/transport-webrtc/src/private-to-public/utils/generate-certificates.browser.ts new file mode 100644 index 0000000000..b0c8b6ad6c --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/utils/generate-certificates.browser.ts @@ -0,0 +1,3 @@ +export async function generateWebTransportCertificate (): Promise { + throw new Error('Not implemented') +} diff --git a/packages/transport-webrtc/src/private-to-public/utils/generate-certificates.ts b/packages/transport-webrtc/src/private-to-public/utils/generate-certificates.ts new file mode 100644 index 0000000000..90820a0b86 --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/utils/generate-certificates.ts @@ -0,0 +1,57 @@ +import * as x509 from '@peculiar/x509' +import { base64url } from 'multiformats/bases/base64' +import { sha256 } from 'multiformats/hashes/sha2' +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' + +/** + * PEM format server certificate and private key + */ +export interface TransportCertificate { + privateKey: string + pem: string + certhash: string +} + +const ONE_DAY_MS = 86400000 + +export interface GenerateTransportCertificateOptions { + days: number + start?: Date + extensions?: any[] +} + +x509.cryptoProvider.set(globalThis.crypto) + +export async function generateTransportCertificate (keyPair: CryptoKeyPair, options: GenerateTransportCertificateOptions): Promise { + const notBefore = options.start ?? new Date() + notBefore.setMilliseconds(0) + const notAfter = new Date(notBefore.getTime() + (options.days * ONE_DAY_MS)) + notAfter.setMilliseconds(0) + + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + serialNumber: (BigInt(Math.random().toString().replace('.', '')) * 100000n).toString(16), + name: 'CN=ca.com, C=US, L=CA, O=example, ST=CA', + notBefore, + notAfter, + signingAlgorithm: { + name: 'ECDSA' + }, + keys: keyPair, + extensions: [ + new x509.BasicConstraintsExtension(false, undefined, true) + ] + }) + + const exported = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey) + const privateKeyPem = [ + '-----BEGIN PRIVATE KEY-----', + ...uint8ArrayToString(new Uint8Array(exported), 'base64pad').split(/(.{64})/).filter(Boolean), + '-----END PRIVATE KEY-----' + ].join('\n') + + return { + privateKey: privateKeyPem, + pem: cert.toString('pem'), + certhash: base64url.encode((await sha256.digest(new Uint8Array(cert.rawData))).bytes) + } +} diff --git a/packages/transport-webrtc/src/private-to-public/utils/generate-noise-prologue.ts b/packages/transport-webrtc/src/private-to-public/utils/generate-noise-prologue.ts new file mode 100644 index 0000000000..cd492d2328 --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/utils/generate-noise-prologue.ts @@ -0,0 +1,38 @@ +import * as multihashes from 'multihashes' +import { concat } from 'uint8arrays/concat' +import { fromString as uint8arrayFromString } from 'uint8arrays/from-string' +import { invalidArgument } from '../../error.js' +import * as sdp from './sdp.js' +import type { Logger } from '@libp2p/interface' +import type { Multiaddr } from '@multiformats/multiaddr' +import type { HashCode } from 'multihashes' + +/** + * Generate a noise prologue from the peer connection's certificate. + * noise prologue = bytes('libp2p-webrtc-noise:') + noise-responder fingerprint + noise-initiator fingerprint + */ +export function generateNoisePrologue (pc: RTCPeerConnection, hashCode: HashCode, remoteAddr: Multiaddr, log: Logger, role: 'initiator' | 'responder'): Uint8Array { + if (pc.getConfiguration().certificates?.length === 0) { + throw invalidArgument('no local certificate') + } + + const localFingerprint = sdp.getLocalFingerprint(pc, { + log + }) + + if (localFingerprint == null) { + throw invalidArgument('no local fingerprint found') + } + + const localFpString = localFingerprint.trim().toLowerCase().replaceAll(':', '') + const localFpArray = uint8arrayFromString(localFpString, 'hex') + const local = multihashes.encode(localFpArray, hashCode) + const remote: Uint8Array = sdp.mbdecoder.decode(sdp.certhash(remoteAddr)) + const prefix = uint8arrayFromString('libp2p-webrtc-noise:') + + if (role === 'responder') { + return concat([prefix, local, remote], 88) + } + + return concat([prefix, remote, local], 88) +} diff --git a/packages/transport-webrtc/src/private-to-public/util.ts b/packages/transport-webrtc/src/private-to-public/utils/generate-ufrag.ts similarity index 59% rename from packages/transport-webrtc/src/private-to-public/util.ts rename to packages/transport-webrtc/src/private-to-public/utils/generate-ufrag.ts index 31858d5888..9ae2458cfc 100644 --- a/packages/transport-webrtc/src/private-to-public/util.ts +++ b/packages/transport-webrtc/src/private-to-public/utils/generate-ufrag.ts @@ -1,2 +1,3 @@ -const charset = Array.from('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/') +const charset = Array.from('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890') + export const genUfrag = (len: number): string => [...Array(len)].map(() => charset.at(Math.floor(Math.random() * charset.length))).join('') diff --git a/packages/transport-webrtc/src/private-to-public/utils/get-dialer-rtcpeerconnection.browser.ts b/packages/transport-webrtc/src/private-to-public/utils/get-dialer-rtcpeerconnection.browser.ts new file mode 100644 index 0000000000..ed5aec1d21 --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/utils/get-dialer-rtcpeerconnection.browser.ts @@ -0,0 +1,20 @@ +// import * as sdp from '../sdp.js' +import type { HashName } from 'multihashes' + +export async function createDialerRTCPeerConnection (ufrag: string, hashName: HashName): Promise { + // ECDSA is preferred over RSA here. From our testing we find that P-256 elliptic + // curve is supported by Pion, webrtc-rs, as well as Chromium (P-228 and P-384 + // was not supported in Chromium). We use the same hash function as found in the + // multiaddr if it is supported. + const certificate = await RTCPeerConnection.generateCertificate({ + name: 'ECDSA', + + // @ts-expect-error missing from lib.dom.d.ts but required by chrome + namedCurve: 'P-256' + // hash: sdp.toSupportedHashFunction(hashName) + }) + + return new RTCPeerConnection({ + certificates: [certificate] + }) +} diff --git a/packages/transport-webrtc/src/private-to-public/utils/get-dialer-rtcpeerconnection.ts b/packages/transport-webrtc/src/private-to-public/utils/get-dialer-rtcpeerconnection.ts new file mode 100644 index 0000000000..0519afa7b5 --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/utils/get-dialer-rtcpeerconnection.ts @@ -0,0 +1,25 @@ +import { RTCPeerConnection } from '../../webrtc/index.js' +import { generateTransportCertificate } from './generate-certificates.js' +import type { HashName } from 'multihashes' + +export async function createDialerRTCPeerConnection (ufrag: string, hashName: HashName): Promise { + const keyPair = await crypto.subtle.generateKey({ + name: 'ECDSA', + namedCurve: 'P-256' + }, true, ['sign', 'verify']) + + const certificate = await generateTransportCertificate(keyPair, { + days: 365 + }) + + return new RTCPeerConnection({ + // @ts-expect-error non-standard arguments accepted by node-datachannel and + // passed on to libdatachannel/libjuice + iceUfrag: ufrag, + icePwd: ufrag, + disableFingerprintVerification: true, + certificatePemFile: certificate.pem, + keyPemFile: certificate.privateKey, + maxMessageSize: 16384 + }) +} diff --git a/packages/transport-webrtc/src/private-to-public/sdp.ts b/packages/transport-webrtc/src/private-to-public/utils/sdp.ts similarity index 68% rename from packages/transport-webrtc/src/private-to-public/sdp.ts rename to packages/transport-webrtc/src/private-to-public/utils/sdp.ts index 0520a820cf..b7460c460b 100644 --- a/packages/transport-webrtc/src/private-to-public/sdp.ts +++ b/packages/transport-webrtc/src/private-to-public/utils/sdp.ts @@ -1,9 +1,12 @@ +import { multiaddr, type Multiaddr } from '@multiformats/multiaddr' +import { base64url } from 'multiformats/bases/base64' import { bases } from 'multiformats/basics' +import * as Digest from 'multiformats/hashes/digest' +import { sha256 } from 'multiformats/hashes/sha2' import * as multihashes from 'multihashes' -import { inappropriateMultiaddr, invalidArgument, invalidFingerprint, unsupportedHashAlgorithm } from '../error.js' -import { CERTHASH_CODE } from './transport.js' +import { inappropriateMultiaddr, invalidArgument, invalidFingerprint, unsupportedHashAlgorithm } from '../../error.js' +import { CERTHASH_CODE } from '../transport.js' import type { LoggerOptions } from '@libp2p/interface' -import type { Multiaddr } from '@multiformats/multiaddr' import type { HashCode, HashName } from 'multihashes' /** @@ -89,7 +92,15 @@ export function ma2Fingerprint (ma: Multiaddr): string[] { throw invalidFingerprint(fingerprint, ma.toString()) } - return [`${prefix.toUpperCase()} ${sdp.join(':').toUpperCase()}`, fingerprint] + return [`${prefix} ${sdp.join(':').toUpperCase()}`, fingerprint] +} + +export function fingerprint2Ma (fingerprint: string): Multiaddr { + const output = fingerprint.split(':').map(str => parseInt(str, 16)) + const encoded = Uint8Array.from(output) + const digest = Digest.create(sha256.code, encoded) + + return multiaddr(`/certhash/${base64url.encode(digest.bytes)}`) } /** @@ -109,20 +120,53 @@ export function toSupportedHashFunction (name: multihashes.HashName): string { } /** - * Convert a multiaddr into a SDP + * Create an offer SDP message from a multiaddr */ -function ma2sdp (ma: Multiaddr, ufrag: string): string { +export function clientOfferFromMultiaddr (ma: Multiaddr, ufrag: string): RTCSessionDescriptionInit { const { host, port } = ma.toOptions() const ipVersion = ipv(ma) - const [CERTFP] = ma2Fingerprint(ma) - return `v=0 + const sdp = `v=0 +o=rtc 779560196 0 IN ${ipVersion} ${host} +s=- +t=0 0 +a=group:BUNDLE 0 +a=msid-semantic:WMS * +a=ice-options:ice2,trickle +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 +m=application 9 UDP/DTLS/SCTP webrtc-datachannel +c=IN ${ipVersion} ${host} +a=mid:0 +a=sendrecv +a=sctp-port:5000 +a=max-message-size:16384 +a=setup:active +a=ice-ufrag:${ufrag} +a=ice-pwd:${ufrag} +a=candidate:1467250027 1 UDP 1467250027 ${host} ${port} typ host +a=end-of-candidates +` + + return { + type: 'offer', + sdp + } +} + +/** + * Create an answer SDP message from a multiaddr + */ +export function serverOfferFromMultiAddr (ma: Multiaddr, ufrag: string): RTCSessionDescriptionInit { + const { host, port } = ma.toOptions() + const ipVersion = ipv(ma) + const [CERTFP] = ma2Fingerprint(ma) + const sdp = `v=0 o=- 0 0 IN ${ipVersion} ${host} s=- c=IN ${ipVersion} ${host} t=0 0 a=ice-lite -m=application ${port} UDP/DTLS/SCTP webrtc-datachannel +m=application 9 UDP/DTLS/SCTP webrtc-datachannel a=mid:0 a=setup:passive a=ice-ufrag:${ufrag} @@ -130,16 +174,13 @@ a=ice-pwd:${ufrag} a=fingerprint:${CERTFP} a=sctp-port:5000 a=max-message-size:16384 -a=candidate:1467250027 1 UDP 1467250027 ${host} ${port} typ host\r\n` -} +a=candidate:1467250027 1 UDP 1467250027 ${host} ${port} typ host +a=end-of-candidates +` -/** - * Create an answer SDP from a multiaddr - */ -export function fromMultiAddr (ma: Multiaddr, ufrag: string): RTCSessionDescriptionInit { return { type: 'answer', - sdp: ma2sdp(ma, ufrag) + sdp } } @@ -151,8 +192,10 @@ export function munge (desc: RTCSessionDescriptionInit, ufrag: string): RTCSessi throw invalidArgument("Can't munge a missing SDP") } + const lineBreak = desc.sdp.includes('\r\n') ? '\r\n' : '\n' + desc.sdp = desc.sdp - .replace(/\na=ice-ufrag:[^\n]*\n/, '\na=ice-ufrag:' + ufrag + '\n') - .replace(/\na=ice-pwd:[^\n]*\n/, '\na=ice-pwd:' + ufrag + '\n') + .replace(/\na=ice-ufrag:[^\n]*\n/, '\na=ice-ufrag:' + ufrag + lineBreak) + .replace(/\na=ice-pwd:[^\n]*\n/, '\na=ice-pwd:' + ufrag + lineBreak) return desc } diff --git a/packages/transport-webrtc/src/stream.ts b/packages/transport-webrtc/src/stream.ts index d875b2be40..9a46e11963 100644 --- a/packages/transport-webrtc/src/stream.ts +++ b/packages/transport-webrtc/src/stream.ts @@ -7,7 +7,7 @@ import { pEvent, TimeoutError } from 'p-event' import pTimeout from 'p-timeout' import { raceSignal } from 'race-signal' import { Uint8ArrayList } from 'uint8arraylist' -import { Message } from './pb/message.js' +import { Message } from './private-to-public/pb/message.js' import type { DataChannelOptions } from './index.js' import type { AbortOptions, ComponentLogger, Direction } from '@libp2p/interface' import type { DeferredPromise } from 'p-defer' diff --git a/packages/transport-webrtc/test/sdp.spec.ts b/packages/transport-webrtc/test/sdp.spec.ts index cff8a794b1..fe45681139 100644 --- a/packages/transport-webrtc/test/sdp.spec.ts +++ b/packages/transport-webrtc/test/sdp.spec.ts @@ -1,6 +1,6 @@ import { multiaddr } from '@multiformats/multiaddr' import { expect } from 'aegir/chai' -import * as underTest from '../src/private-to-public/sdp.js' +import * as underTest from '../src/private-to-public/utils/sdp.js' const sampleMultiAddr = multiaddr('/ip4/0.0.0.0/udp/56093/webrtc/certhash/uEiByaEfNSLBexWBNFZy_QB1vAKEj7JAXDizRs4_SnTflsQ') const sampleCerthash = 'uEiByaEfNSLBexWBNFZy_QB1vAKEj7JAXDizRs4_SnTflsQ' @@ -23,7 +23,7 @@ a=candidate:1467250027 1 UDP 1467250027 0.0.0.0 56093 typ host` describe('SDP', () => { it('converts multiaddr with certhash to an answer SDP', async () => { const ufrag = 'MyUserFragment' - const sdp = underTest.fromMultiAddr(sampleMultiAddr, ufrag) + const sdp = underTest.serverOfferFromMultiAddr(sampleMultiAddr, ufrag) expect(sdp.sdp).to.contain(sampleSdp) }) @@ -78,4 +78,11 @@ a=candidate:1467250027 1 UDP 1467250027 0.0.0.0 56093 typ host` expect(result.sdp).to.equal(expected) }) + + it('should turn a fingerprint into a multiaddr fragment', () => { + const input = 'B9:3F:A1:4B:E8:46:73:08:6F:73:51:3E:27:9D:56:B7:29:67:4C:4A:B8:8D:21:EF:BF:E6:BA:16:37:BA:6C:2A' + const output = underTest.fingerprint2Ma(input) + + expect(output.toString()).to.equal('/certhash/uEiC5P6FL6EZzCG9zUT4nnVa3KWdMSriNIe-_5roWN7psKg') + }) }) diff --git a/packages/transport-webrtc/test/stream.spec.ts b/packages/transport-webrtc/test/stream.spec.ts index b9c8735ea9..6f160d7c3e 100644 --- a/packages/transport-webrtc/test/stream.spec.ts +++ b/packages/transport-webrtc/test/stream.spec.ts @@ -9,7 +9,7 @@ import { pushable } from 'it-pushable' import { bytes } from 'multiformats' import pDefer from 'p-defer' import { Uint8ArrayList } from 'uint8arraylist' -import { Message } from '../src/pb/message.js' +import { Message } from '../src/private-to-public/pb/message.js' import { MAX_BUFFERED_AMOUNT, MAX_MESSAGE_SIZE, PROTOBUF_OVERHEAD, type WebRTCStream, createStream } from '../src/stream.js' import { RTCPeerConnection } from '../src/webrtc/index.js' import { mockDataChannel, receiveFinAck } from './util.js' diff --git a/packages/transport-webrtc/test/transport.spec.ts b/packages/transport-webrtc/test/transport.spec.ts index f28ac2cc58..2caf7fcd9a 100644 --- a/packages/transport-webrtc/test/transport.spec.ts +++ b/packages/transport-webrtc/test/transport.spec.ts @@ -6,9 +6,11 @@ import { defaultLogger } from '@libp2p/logger' import { createEd25519PeerId } from '@libp2p/peer-id-factory' import { multiaddr } from '@multiformats/multiaddr' import { expect } from 'aegir/chai' +import { stubInterface } from 'sinon-ts' import { UnimplementedError } from '../src/error.js' import { WebRTCDirectTransport, type WebRTCDirectTransportComponents } from '../src/private-to-public/transport.js' import { expectError } from './util.js' +import type { TransportManager } from '@libp2p/interface-internal' function ignoredDialOption (): CreateListenerOptions { const upgrader = mockUpgrader({}) @@ -24,7 +26,8 @@ describe('WebRTCDirect Transport', () => { components = { peerId: await createEd25519PeerId(), metrics, - logger: defaultLogger() + logger: defaultLogger(), + transportManager: stubInterface() } }) @@ -33,7 +36,8 @@ describe('WebRTCDirect Transport', () => { expect(t.constructor.name).to.equal('WebRTCDirectTransport') }) - it('can dial', async () => { + // TODO: this test should complete a dial + it.skip('can dial', async () => { const ma = multiaddr('/ip4/1.2.3.4/udp/1234/webrtc-direct/certhash/uEiAUqV7kzvM1wI5DYDc1RbcekYVmXli_Qprlw3IkiEg6tQ/p2p/12D3KooWGDMwwqrpcYKpKCgxuKT2NfqPqa94QnkoBBpqvCaiCzWd') const transport = new WebRTCDirectTransport(components) const options = ignoredDialOption() @@ -42,7 +46,7 @@ describe('WebRTCDirect Transport', () => { transport.dial(ma, options) }) - it('createListner throws', () => { + it.skip('createListner throws', () => { const t = new WebRTCDirectTransport(components) try { t.createListener(ignoredDialOption()) diff --git a/packages/transport-webrtc/test/util.ts b/packages/transport-webrtc/test/util.ts index fe3b3b42d2..5254b9551a 100644 --- a/packages/transport-webrtc/test/util.ts +++ b/packages/transport-webrtc/test/util.ts @@ -1,6 +1,6 @@ import { expect } from 'aegir/chai' import * as lengthPrefixed from 'it-length-prefixed' -import { Message } from '../src/pb/message.js' +import { Message } from '../src/private-to-public/pb/message.js' export const expectError = (error: unknown, message: string): void => { if (error instanceof Error) { From 40c198aa2b58352d48a3ff8b22b642bb535111ed Mon Sep 17 00:00:00 2001 From: achingbrain Date: Tue, 11 Jun 2024 18:35:52 +0100 Subject: [PATCH 2/4] chore: run interface compliance tests --- packages/transport-webrtc/package.json | 8 +- packages/transport-webrtc/src/index.ts | 19 ++ packages/transport-webrtc/src/maconn.ts | 7 +- packages/transport-webrtc/src/muxer.ts | 14 +- .../private-to-private/initiate-connection.ts | 2 +- .../src/private-to-public/constants.ts | 8 +- .../src/private-to-public/listener.ts | 226 +++++------------- .../src/private-to-public/transport.ts | 170 +++---------- .../src/private-to-public/utils/connect.ts | 167 +++++++++++++ .../utils/generate-certificates.ts | 10 +- .../utils/generate-noise-prologue.ts | 21 +- .../utils/get-dialer-rtcpeerconnection.ts | 25 -- ...er.ts => get-rtcpeerconnection.browser.ts} | 6 +- .../utils/get-rtcpeerconnection.ts | 107 +++++++++ .../src/private-to-public/utils/sdp.ts | 44 ++-- packages/transport-webrtc/src/stream.ts | 79 ++++-- packages/transport-webrtc/src/util.ts | 5 + .../transport-webrtc/test/compliance.spec.ts | 81 +++++++ packages/transport-webrtc/test/sdp.spec.ts | 12 +- 19 files changed, 587 insertions(+), 424 deletions(-) create mode 100644 packages/transport-webrtc/src/private-to-public/utils/connect.ts delete mode 100644 packages/transport-webrtc/src/private-to-public/utils/get-dialer-rtcpeerconnection.ts rename packages/transport-webrtc/src/private-to-public/utils/{get-dialer-rtcpeerconnection.browser.ts => get-rtcpeerconnection.browser.ts} (83%) create mode 100644 packages/transport-webrtc/src/private-to-public/utils/get-rtcpeerconnection.ts create mode 100644 packages/transport-webrtc/test/compliance.spec.ts diff --git a/packages/transport-webrtc/package.json b/packages/transport-webrtc/package.json index c3ba976bee..ca00739065 100644 --- a/packages/transport-webrtc/package.json +++ b/packages/transport-webrtc/package.json @@ -60,6 +60,7 @@ "@multiformats/multiaddr": "^12.2.3", "@multiformats/multiaddr-matcher": "^1.2.1", "@peculiar/x509": "^1.11.0", + "any-signal": "^4.1.1", "detect-browser": "^5.3.0", "it-length-prefixed": "^9.0.4", "it-protobuf-stream": "^1.1.3", @@ -71,7 +72,9 @@ "p-defer": "^4.0.1", "p-event": "^6.0.1", "p-timeout": "^6.1.2", + "p-wait-for": "^5.0.2", "protons-runtime": "^5.4.0", + "race-event": "^1.3.0", "race-signal": "^1.0.2", "react-native-webrtc": "^118.0.7", "stun": "^2.1.0", @@ -98,12 +101,13 @@ "p-retry": "^6.2.0", "protons": "^7.5.0", "sinon": "^18.0.0", - "sinon-ts": "^2.0.0" + "sinon-ts": "^2.0.0", + "wherearewe": "^2.0.1" }, "browser": { "./dist/src/webrtc/index.js": "./dist/src/webrtc/index.browser.js", "./dist/src/private-to-public/listener.js": "./dist/src/private-to-public/listener.browser.js", - "./dist/src/private-to-public/utils/get-dialer-rtcpeerconnection.js": "./dist/src/private-to-public/utils/get-dialer-rtcpeerconnection.browser.js" + "./dist/src/private-to-public/utils/get-rtcpeerconnection.js": "./dist/src/private-to-public/utils/get-rtcpeerconnection.browser.js" }, "react-native": { "./dist/src/webrtc/index.js": "./dist/src/webrtc/index.react-native.js" diff --git a/packages/transport-webrtc/src/index.ts b/packages/transport-webrtc/src/index.ts index ffda128049..a77bbe2e26 100644 --- a/packages/transport-webrtc/src/index.ts +++ b/packages/transport-webrtc/src/index.ts @@ -236,6 +236,25 @@ export interface DataChannelOptions { openTimeout?: number } +/** + * PEM format server certificate and private key + */ +export interface TransportCertificate { + /** + * The private key for the certificate in PEM format + */ + privateKey: string + /** + * PEM format certificate + */ + pem: string + + /** + * The hash of the certificate + */ + certhash: string +} + /** * @param {WebRTCTransportDirectInit} init - WebRTC direct transport configuration * @param init.dataChannel - DataChannel configurations diff --git a/packages/transport-webrtc/src/maconn.ts b/packages/transport-webrtc/src/maconn.ts index 0e2c20c011..75dfda6193 100644 --- a/packages/transport-webrtc/src/maconn.ts +++ b/packages/transport-webrtc/src/maconn.ts @@ -69,12 +69,13 @@ export class WebRTCMultiaddrConnection implements MultiaddrConnection { this.timeline = init.timeline this.peerConnection = init.peerConnection - const initialState = this.peerConnection.connectionState + const peerConnection = this.peerConnection + const initialState = peerConnection.connectionState this.peerConnection.onconnectionstatechange = () => { - this.log.trace('peer connection state change', this.peerConnection.connectionState, 'initial state', initialState) + this.log.trace('peer connection state change', peerConnection.connectionState, 'initial state', initialState) - if (this.peerConnection.connectionState === 'disconnected' || this.peerConnection.connectionState === 'failed' || this.peerConnection.connectionState === 'closed') { + if (peerConnection.connectionState === 'disconnected' || peerConnection.connectionState === 'failed' || peerConnection.connectionState === 'closed') { // nothing else to do but close the connection this.timeline.close = Date.now() } diff --git a/packages/transport-webrtc/src/muxer.ts b/packages/transport-webrtc/src/muxer.ts index 63dccd16ff..8d1f6eb939 100644 --- a/packages/transport-webrtc/src/muxer.ts +++ b/packages/transport-webrtc/src/muxer.ts @@ -56,7 +56,7 @@ export class DataChannelMuxerFactory implements StreamMuxerFactory { this.metrics = init.metrics this.protocol = init.protocol ?? PROTOCOL this.dataChannelOptions = init.dataChannelOptions ?? {} - this.log = components.logger.forComponent('libp2p:webrtc:datachannelmuxerfactory') + this.log = components.logger.forComponent('libp2p:webrtc:muxerfactory') // store any datachannels opened before upgrade has been completed this.peerConnection.ondatachannel = ({ channel }) => { @@ -155,11 +155,14 @@ export class DataChannelMuxer implements StreamMuxer { return } + // cannot access `.id` property after close as node-datachannel throws + const id = channel.id + const stream = createStream({ channel, direction: 'inbound', onEnd: () => { - this.log('incoming channel %s ended with state %s', channel.id, channel.readyState) + this.log('incoming channel %s ended with state %s', id, channel.readyState) this.#onStreamEnd(stream, channel) }, logger: this.logger, @@ -239,16 +242,19 @@ export class DataChannelMuxer implements StreamMuxer { sink: Sink, Promise> = nopSink newStream (): Stream { - // The spec says the label SHOULD be an empty string: https://github.com/libp2p/specs/blob/master/webrtc/README.md#rtcdatachannel-label + // The spec says the label MUST be an empty string: https://github.com/libp2p/specs/blob/master/webrtc/README.md#rtcdatachannel-label const channel = this.peerConnection.createDataChannel('') this.log.trace('opened outgoing datachannel with channel id %s', channel.id) + // cannot access `.id` property after close as node-datachannel throws + const id = channel.id + const stream = createStream({ channel, direction: 'outbound', onEnd: () => { - this.log('outgoing channel %s ended with state %s', channel.id, channel.readyState) + this.log('outgoing channel %s ended with state %s', id, channel.readyState) this.#onStreamEnd(stream, channel) }, logger: this.logger, diff --git a/packages/transport-webrtc/src/private-to-private/initiate-connection.ts b/packages/transport-webrtc/src/private-to-private/initiate-connection.ts index 3350f0ac8c..ae8b71c2ec 100644 --- a/packages/transport-webrtc/src/private-to-private/initiate-connection.ts +++ b/packages/transport-webrtc/src/private-to-private/initiate-connection.ts @@ -1,7 +1,7 @@ import { CodeError } from '@libp2p/interface' import { peerIdFromString } from '@libp2p/peer-id' import { pbStream } from 'it-protobuf-stream' -import { type RTCPeerConnection, RTCSessionDescription } from '../webrtc/index.js' +import { RTCSessionDescription } from '../webrtc/index.js' import { Message } from './pb/message.js' import { SIGNALING_PROTO_ID, splitAddr, type WebRTCTransportMetrics } from './transport.js' import { readCandidatesUntilConnected } from './util.js' diff --git a/packages/transport-webrtc/src/private-to-public/constants.ts b/packages/transport-webrtc/src/private-to-public/constants.ts index 215b082cd3..02c2493957 100644 --- a/packages/transport-webrtc/src/private-to-public/constants.ts +++ b/packages/transport-webrtc/src/private-to-public/constants.ts @@ -1,2 +1,8 @@ - export const UFRAG_PREFIX = 'libp2p+webrtc+v1/' + +// https://gist.github.com/mondain/b0ec1cf5f60ae726202e +export const DEFAULT_STUN_SERVERS = [ + 'stun.l.google.com:19302', + 'global.stun.twilio.com:3478', + 'stun.cloudflare.com:3478' +] diff --git a/packages/transport-webrtc/src/private-to-public/listener.ts b/packages/transport-webrtc/src/private-to-public/listener.ts index f12928e0c8..1107683bc0 100644 --- a/packages/transport-webrtc/src/private-to-public/listener.ts +++ b/packages/transport-webrtc/src/private-to-public/listener.ts @@ -1,26 +1,20 @@ import { createSocket } from 'node:dgram' import { networkInterfaces } from 'node:os' import { isIPv4, isIPv6 } from '@chainsafe/is-ip' -import { noise } from '@chainsafe/libp2p-noise' import { TypedEventEmitter } from '@libp2p/interface' import { multiaddr, protocols } from '@multiformats/multiaddr' import { IP4 } from '@multiformats/multiaddr-matcher' import { sha256 } from 'multiformats/hashes/sha2' import { pEvent } from 'p-event' +import pWaitFor from 'p-wait-for' // @ts-expect-error no types import stun from 'stun' -import { dataChannelError } from '../error.js' -import { WebRTCMultiaddrConnection } from '../maconn.js' -import { DataChannelMuxerFactory } from '../muxer.js' -import { createStream } from '../stream.js' -import { isFirefox } from '../util.js' -import { RTCPeerConnection } from '../webrtc/index.js' import { UFRAG_PREFIX } from './constants.js' -import { generateTransportCertificate, type TransportCertificate } from './utils/generate-certificates.js' -import { generateNoisePrologue } from './utils/generate-noise-prologue.js' -import * as sdp from './utils/sdp.js' -import type { DataChannelOptions } from '../index.js' -import type { PeerId, ListenerEvents, Listener, Connection, Upgrader, ComponentLogger, Logger, CounterGroup, Metrics } from '@libp2p/interface' +import { connect } from './utils/connect.js' +import { generateTransportCertificate } from './utils/generate-certificates.js' +import { type DirectRTCPeerConnection, createDialerRTCPeerConnection } from './utils/get-rtcpeerconnection.js' +import type { DataChannelOptions, TransportCertificate } from '../index.js' +import type { PeerId, ListenerEvents, Listener, Upgrader, ComponentLogger, Logger, CounterGroup, Metrics, ConnectionHandler } from '@libp2p/interface' import type { Multiaddr } from '@multiformats/multiaddr' import type { Socket, RemoteInfo } from 'node:dgram' import type { AddressInfo } from 'node:net' @@ -37,12 +31,12 @@ export interface WebRTCDirectListenerComponents { } export interface WebRTCDirectListenerInit { - shutdownController: AbortController - handler?(conn: Connection): void + handler?: ConnectionHandler upgrader: Upgrader certificates?: TransportCertificate[] maxInboundStreams?: number dataChannel?: DataChannelOptions + rtcConfiguration?: RTCConfiguration } export interface WebRTCListenerMetrics { @@ -55,10 +49,9 @@ const IP6_PROTOCOL = protocols('ip6') export class WebRTCDirectListener extends TypedEventEmitter implements Listener { private socket?: Socket - private readonly shutdownController: AbortController private readonly multiaddrs: Multiaddr[] private certificate?: TransportCertificate - private readonly connections: Map + private readonly connections: Map private readonly log: Logger private readonly init: WebRTCDirectListenerInit private readonly components: WebRTCDirectListenerComponents @@ -69,10 +62,10 @@ export class WebRTCDirectListener extends TypedEventEmitter impl this.init = init this.components = components - this.shutdownController = init.shutdownController this.multiaddrs = [] this.connections = new Map() - this.log = components.logger.forComponent('libp2p:webrtc-direct') + this.log = components.logger.forComponent('libp2p:webrtc-direct:listener') + this.certificate = init.certificates?.[0] if (components.metrics != null) { this.metrics = { @@ -141,17 +134,23 @@ export class WebRTCDirectListener extends TypedEventEmitter impl this.socket.on('message', (msg, rinfo) => { try { + this.log('incoming STUN packet from %o', rinfo) const response = stun.decode(msg) - // TODO: this needs to be rate limited keyed by the remote host to // prevent a DOS attack this.incomingConnection(response, rinfo, certificate).catch(err => { - this.log.error('could not process incoming STUN data', err) + this.log.error('could not process incoming STUN data from %o', rinfo, err) }) } catch (err) { - this.log.error('could not process incoming STUN data', err) + this.log.error('could not process incoming STUN data from %o', rinfo, err) } }) + + this.socket.on('close', () => { + this.safeDispatchEvent('close') + }) + + this.safeDispatchEvent('listening') } private async incomingConnection (stunMessage: any, rinfo: RemoteInfo, certificate: TransportCertificate): Promise { @@ -168,24 +167,19 @@ export class WebRTCDirectListener extends TypedEventEmitter impl let peerConnection = this.connections.get(key) if (peerConnection != null) { + this.log('already got peer connection for', key) return } - peerConnection = new RTCPeerConnection({ - // @ts-expect-error missing argument - iceUfrag: ufrag, - icePwd: ufrag, - disableFingerprintVerification: true, - certificatePemFile: certificate.pem, - keyPemFile: certificate.privateKey, - maxMessageSize: 16384 - }) + this.log('create peer connection for', key) + + // https://github.com/libp2p/specs/blob/master/webrtc/webrtc-direct.md#browser-to-public-server + peerConnection = await createDialerRTCPeerConnection('NodeB', ufrag, this.init.rtcConfiguration, this.certificate) this.connections.set(key, peerConnection) - const eventListeningName = isFirefox ? 'iceconnectionstatechange' : 'connectionstatechange' - peerConnection.addEventListener(eventListeningName, () => { - switch (peerConnection?.connectionState) { + peerConnection.addEventListener('connectionstatechange', () => { + switch (peerConnection.connectionState) { case 'failed': case 'disconnected': case 'closed': @@ -196,135 +190,23 @@ export class WebRTCDirectListener extends TypedEventEmitter impl } }) - const controller = new AbortController() - const signal = controller.signal - try { - // create data channel for running the noise handshake. Once the data - // channel is opened, we will initiate the noise handshake. This is used - // to confirm the identity of the peer. - const dataChannelOpenPromise = new Promise((resolve, reject) => { - const handshakeDataChannel = peerConnection.createDataChannel('', { negotiated: true, id: 0 }) - const handshakeTimeout = setTimeout(() => { - const error = `Data channel was never opened: state: ${handshakeDataChannel.readyState}` - this.log.error(error) - this.metrics?.listenerEvents.increment({ open_error: true }) - reject(dataChannelError('data', error)) - }, HANDSHAKE_TIMEOUT_MS) - - handshakeDataChannel.onopen = (_) => { - clearTimeout(handshakeTimeout) - resolve(handshakeDataChannel) - } - - // ref: https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel/error_event - handshakeDataChannel.onerror = (event: Event) => { - clearTimeout(handshakeTimeout) - const errorTarget = event.target?.toString() ?? 'not specified' - const error = `Error opening a data channel for handshaking: ${errorTarget}` - this.log.error(error) - // NOTE: We use unknown error here but this could potentially be - // considered a reset by some standards. - this.metrics?.listenerEvents.increment({ unknown_error: true }) - reject(dataChannelError('data', error)) - } - }) - - // 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. - // uses dummy certhash - let remoteAddr = multiaddr(`/${rinfo.family === 'IPv4' ? 'ip4' : 'ip6'}/${rinfo.address}/udp/${rinfo.port}`) - const offerSdp = sdp.clientOfferFromMultiaddr(remoteAddr, ufrag) - await peerConnection.setRemoteDescription(offerSdp) - - const answerSdp = await peerConnection.createAnswer() - const mungedAnswerSdp = sdp.munge(answerSdp, ufrag) - await peerConnection.setLocalDescription(mungedAnswerSdp) - - // wait for peerconnection.onopen to fire, or for the datachannel to open - const handshakeDataChannel = await dataChannelOpenPromise - - // now that the connection has been opened, add the remote's certhash to - // it's multiaddr so we can complete the noise handshake - const remoteFingerprint = sdp.getFingerprintFromSdp(peerConnection.currentRemoteDescription?.sdp ?? '') ?? '' - remoteAddr = remoteAddr.encapsulate(sdp.fingerprint2Ma(remoteFingerprint)) - - // Do noise handshake. - // Set the Noise Prologue to libp2p-webrtc-noise: before - // starting the actual Noise handshake. - // is the concatenation of the of the two TLS fingerprints - // of A (responder) and B (initiator) in their byte representation. - const fingerprintsPrologue = generateNoisePrologue(peerConnection, sha256.code, remoteAddr, this.log, 'initiator') - - // Since we use the default crypto interface and do not use a static key - // or early data, we pass in undefined for these parameters. - const connectionEncrypter = noise({ prologueBytes: fingerprintsPrologue })(this.components) - - const wrappedChannel = createStream({ - channel: handshakeDataChannel, - direction: 'inbound', + const conn = await connect(peerConnection, ufrag, { + role: 'initiator', + log: this.log, logger: this.components.logger, - ...(this.init.dataChannel ?? {}) - }) - const wrappedDuplex = { - ...wrappedChannel, - sink: wrappedChannel.sink.bind(wrappedChannel), - source: (async function * () { - for await (const list of wrappedChannel.source) { - for (const buf of list) { - yield buf - } - } - }()) - } - - // Creating the connection before completion of the noise - // handshake ensures that the stream opening callback is set up - const maConn = new WebRTCMultiaddrConnection(this.components, { - peerConnection, - remoteAddr, - timeline: { - open: Date.now() - }, - metrics: this.metrics?.listenerEvents - }) - - const eventListeningName = isFirefox ? 'iceconnectionstatechange' : 'connectionstatechange' - - peerConnection.addEventListener(eventListeningName, () => { - switch (peerConnection.connectionState) { - case 'failed': - case 'disconnected': - case 'closed': - maConn.close().catch((err) => { - this.log.error('error closing connection', err) - }).finally(() => { - // Remove the event listener once the connection is closed - controller.abort() - }) - break - default: - break - } - }, { signal }) - - // Track opened peer connection - this.metrics?.listenerEvents.increment({ peer_connection: true }) - - const muxerFactory = new DataChannelMuxerFactory(this.components, { - peerConnection, - metrics: this.metrics?.listenerEvents, - dataChannelOptions: this.init.dataChannel + metrics: this.components.metrics, + events: this.metrics?.listenerEvents, + signal: AbortSignal.timeout(HANDSHAKE_TIMEOUT_MS), + remoteAddr: multiaddr(`/${rinfo.family === 'IPv4' ? 'ip4' : 'ip6'}/${rinfo.address}/udp/${rinfo.port}`), + hashCode: sha256.code, + dataChannel: this.init.dataChannel, + upgrader: this.init.upgrader, + peerId: this.components.peerId, + handler: this.init.handler }) - // For inbound connections, we are expected to start the noise handshake. - // Therefore, we need to secure an outbound noise connection from the remote. - const result = await connectionEncrypter.secureOutbound(this.components.peerId, wrappedDuplex) - maConn.remoteAddr = maConn.remoteAddr.encapsulate(`/p2p/${result.remotePeer}`) - - await this.init.upgrader.upgradeInbound(maConn, { skipProtection: true, skipEncryption: true, muxerFactory }) + this.safeDispatchEvent('connection', { detail: conn }) } catch (err) { peerConnection.close() throw err @@ -336,19 +218,27 @@ export class WebRTCDirectListener extends TypedEventEmitter impl } async close (): Promise { - this.shutdownController.abort() - this.safeDispatchEvent('close', {}) + for (const connection of this.connections.values()) { + connection.close() + } - await new Promise((resolve) => { - if (this.socket == null) { - resolve() - return - } + await Promise.all([ + new Promise((resolve) => { + if (this.socket == null) { + resolve() + return + } - this.socket.close(() => { - resolve() + this.socket.close(() => { + resolve() + }) + }), + // RTCPeerConnections will be removed from the connections map when their + // connection state changes to 'closed'/'disconnected'/'failed + pWaitFor(() => { + return this.connections.size === 0 }) - }) + ]) } } diff --git a/packages/transport-webrtc/src/private-to-public/transport.ts b/packages/transport-webrtc/src/private-to-public/transport.ts index 4a064a8cba..a8c67f0a55 100644 --- a/packages/transport-webrtc/src/private-to-public/transport.ts +++ b/packages/transport-webrtc/src/private-to-public/transport.ts @@ -1,21 +1,17 @@ -import { noise } from '@chainsafe/libp2p-noise' import { transportSymbol } from '@libp2p/interface' import * as p from '@libp2p/peer-id' import { protocols } from '@multiformats/multiaddr' import { WebRTCDirect } from '@multiformats/multiaddr-matcher' -import { dataChannelError, inappropriateMultiaddr } from '../error.js' -import { WebRTCMultiaddrConnection } from '../maconn.js' -import { DataChannelMuxerFactory } from '../muxer.js' -import { createStream } from '../stream.js' -import { isFirefox } from '../util.js' +import { raceSignal } from 'race-signal' +import { inappropriateMultiaddr } from '../error.js' import { UFRAG_PREFIX } from './constants.js' import { WebRTCDirectListener } from './listener.js' -import { generateNoisePrologue } from './utils/generate-noise-prologue.js' +import { connect } from './utils/connect.js' import { genUfrag } from './utils/generate-ufrag.js' -import { createDialerRTCPeerConnection } from './utils/get-dialer-rtcpeerconnection.js' +import { createDialerRTCPeerConnection } from './utils/get-rtcpeerconnection.js' import * as sdp from './utils/sdp.js' -import type { DataChannelOptions } from '../index.js' -import type { CreateListenerOptions, Transport, Listener, ComponentLogger, Logger, Connection, CounterGroup, Metrics, PeerId, Startable, DialOptions } from '@libp2p/interface' +import type { DataChannelOptions, TransportCertificate } from '../index.js' +import type { CreateListenerOptions, Transport, Listener, ComponentLogger, Logger, Connection, CounterGroup, Metrics, PeerId, DialOptions } from '@libp2p/interface' import type { TransportManager } from '@libp2p/interface-internal' import type { Multiaddr } from '@multiformats/multiaddr' @@ -53,21 +49,21 @@ export interface WebRTCMetrics { } export interface WebRTCTransportDirectInit { + rtcConfiguration?: RTCConfiguration dataChannel?: DataChannelOptions + certificates?: TransportCertificate[] } -export class WebRTCDirectTransport implements Transport, Startable { +export class WebRTCDirectTransport implements Transport { private readonly log: Logger private readonly metrics?: WebRTCMetrics private readonly components: WebRTCDirectTransportComponents private readonly init: WebRTCTransportDirectInit - private shutdownController: AbortController constructor (components: WebRTCDirectTransportComponents, init: WebRTCTransportDirectInit = {}) { this.log = components.logger.forComponent('libp2p:webrtc-direct') this.components = components this.init = init - this.shutdownController = new AbortController() if (components.metrics != null) { this.metrics = { @@ -79,18 +75,11 @@ export class WebRTCDirectTransport implements Transport, Startable { } } - async start (): Promise { - this.shutdownController = new AbortController() - } - - async stop (): Promise { - this.shutdownController.abort() - } - /** * Dial a given multiaddr */ async dial (ma: Multiaddr, options: DialOptions): Promise { + options?.signal?.throwIfAborted() const rawConn = await this._connect(ma, options) this.log('dialing address: %a', ma) return rawConn @@ -101,8 +90,8 @@ export class WebRTCDirectTransport implements Transport, Startable { */ createListener (options: CreateListenerOptions): Listener { return new WebRTCDirectListener(this.components, { - ...options, - shutdownController: this.shutdownController + ...this.init, + ...options }) } @@ -134,9 +123,6 @@ export class WebRTCDirectTransport implements Transport, Startable { * Connect to a peer using a multiaddr */ async _connect (ma: Multiaddr, options: DialOptions): Promise { - const controller = new AbortController() - const signal = controller.signal - const remotePeerString = ma.getPeerId() if (remotePeerString === null) { throw inappropriateMultiaddr("we need to have the remote's PeerId") @@ -144,127 +130,25 @@ export class WebRTCDirectTransport implements Transport, Startable { const theirPeerId = p.peerIdFromString(remotePeerString) const remoteCerthash = sdp.decodeCerthash(sdp.certhash(ma)) const ufrag = UFRAG_PREFIX + genUfrag(32) - const peerConnection = await createDialerRTCPeerConnection(ufrag, remoteCerthash.name) - - try { - // create data channel for running the noise handshake. Once the data - // channel is opened, the remote will initiate the noise handshake. This - // is used to confirm the identity of the peer. - const dataChannelOpenPromise = new Promise((resolve, reject) => { - const handshakeDataChannel = peerConnection.createDataChannel('', { negotiated: true, id: 0 }) - const handshakeTimeout = setTimeout(() => { - const error = `Data channel was never opened: state: ${handshakeDataChannel.readyState}` - this.log.error(error) - this.metrics?.dialerEvents.increment({ open_error: true }) - reject(dataChannelError('data', error)) - }, HANDSHAKE_TIMEOUT_MS) - - handshakeDataChannel.onopen = (_) => { - clearTimeout(handshakeTimeout) - resolve(handshakeDataChannel) - } - - // ref: https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel/error_event - handshakeDataChannel.onerror = (event: Event) => { - clearTimeout(handshakeTimeout) - const errorTarget = event.target?.toString() ?? 'not specified' - const error = `Error opening a data channel for handshaking: ${errorTarget}` - this.log.error(error) - // NOTE: We use unknown error here but this could potentially be - // considered a reset by some standards. - this.metrics?.dialerEvents.increment({ unknown_error: true }) - reject(dataChannelError('data', error)) - } - }) - - // 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. - const offerSdp = await peerConnection.createOffer() - const mungedOfferSdp = sdp.munge(offerSdp, ufrag) - await peerConnection.setLocalDescription(mungedOfferSdp) - - // construct answer sdp from multiaddr and ufrag - const answerSdp = sdp.serverOfferFromMultiAddr(ma, ufrag) - await peerConnection.setRemoteDescription(answerSdp) - // wait for peerconnection.onopen to fire, or for the datachannel to open - const handshakeDataChannel = await dataChannelOpenPromise + // https://github.com/libp2p/specs/blob/master/webrtc/webrtc-direct.md#browser-to-public-server + const peerConnection = await createDialerRTCPeerConnection('NodeA', ufrag, this.init.rtcConfiguration) - // Do noise handshake. - // Set the Noise Prologue to libp2p-webrtc-noise: before - // starting the actual Noise handshake. - // is the concatenation of the of the two TLS fingerprints - // of A (responder) and B (initiator) in their byte representation. - const fingerprintsPrologue = generateNoisePrologue(peerConnection, remoteCerthash.code, ma, this.log, 'responder') - - // Since we use the default crypto interface and do not use a static key - // or early data, we pass in undefined for these parameters. - const connectionEncrypter = noise({ prologueBytes: fingerprintsPrologue })(this.components) - - const wrappedChannel = createStream({ - channel: handshakeDataChannel, - direction: 'inbound', + try { + return await raceSignal(connect(peerConnection, ufrag, { + role: 'responder', + log: this.log, logger: this.components.logger, - ...(this.init.dataChannel ?? {}) - }) - const wrappedDuplex = { - ...wrappedChannel, - sink: wrappedChannel.sink.bind(wrappedChannel), - source: (async function * () { - for await (const list of wrappedChannel.source) { - for (const buf of list) { - yield buf - } - } - }()) - } - - // Creating the connection before completion of the noise - // handshake ensures that the stream opening callback is set up - const maConn = new WebRTCMultiaddrConnection(this.components, { - peerConnection, + metrics: this.components.metrics, + events: this.metrics?.dialerEvents, + signal: options.signal ?? AbortSignal.timeout(HANDSHAKE_TIMEOUT_MS), remoteAddr: ma, - timeline: { - open: Date.now() - }, - metrics: this.metrics?.dialerEvents - }) - - const eventListeningName = isFirefox ? 'iceconnectionstatechange' : 'connectionstatechange' - - peerConnection.addEventListener(eventListeningName, () => { - switch (peerConnection.connectionState) { - case 'failed': - case 'disconnected': - case 'closed': - maConn.close().catch((err) => { - this.log.error('error closing connection', err) - }).finally(() => { - // Remove the event listener once the connection is closed - controller.abort() - }) - break - default: - break - } - }, { signal }) - - // Track opened peer connection - this.metrics?.dialerEvents.increment({ peer_connection: true }) - - const muxerFactory = new DataChannelMuxerFactory(this.components, { - peerConnection, - metrics: this.metrics?.dialerEvents, - dataChannelOptions: this.init.dataChannel - }) - - // For outbound connections, the remote is expected to start the noise handshake. - // Therefore, we need to secure an inbound noise connection from the remote. - await connectionEncrypter.secureInbound(this.components.peerId, wrappedDuplex, theirPeerId) - - return await options.upgrader.upgradeOutbound(maConn, { skipProtection: true, skipEncryption: true, muxerFactory }) + hashCode: remoteCerthash.code, + dataChannel: this.init.dataChannel, + upgrader: options.upgrader, + peerId: this.components.peerId, + remotePeerId: theirPeerId + }), options.signal) } catch (err) { peerConnection.close() throw err diff --git a/packages/transport-webrtc/src/private-to-public/utils/connect.ts b/packages/transport-webrtc/src/private-to-public/utils/connect.ts new file mode 100644 index 0000000000..f5707dfc5b --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/utils/connect.ts @@ -0,0 +1,167 @@ +import { noise } from '@chainsafe/libp2p-noise' +import { raceEvent } from 'race-event' +import { WebRTCTransportError } from '../../error.js' +import { WebRTCMultiaddrConnection } from '../../maconn.js' +import { DataChannelMuxerFactory } from '../../muxer.js' +import { createStream } from '../../stream.js' +import { isFirefox } from '../../util.js' +import { generateNoisePrologue } from './generate-noise-prologue.js' +import * as sdp from './sdp.js' +import type { DirectRTCPeerConnection } from './get-rtcpeerconnection.js' +import type { DataChannelOptions } from '../../index.js' +import type { ComponentLogger, Connection, ConnectionHandler, CounterGroup, Logger, Metrics, PeerId, Upgrader } from '@libp2p/interface' +import type { Multiaddr } from '@multiformats/multiaddr' +import type { HashCode } from 'multihashes' + +export interface ConnectOptions { + log: Logger + logger: ComponentLogger + metrics?: Metrics + events?: CounterGroup + remoteAddr: Multiaddr + role: 'initiator' | 'responder' + hashCode: HashCode + dataChannel?: DataChannelOptions + upgrader: Upgrader + peerId: PeerId + remotePeerId?: PeerId + handler?: ConnectionHandler + signal: AbortSignal +} + +const CONNECTION_STATE_CHANGE_EVENT = isFirefox ? 'iceconnectionstatechange' : 'connectionstatechange' + +export async function connect (peerConnection: DirectRTCPeerConnection, ufrag: string, options: ConnectOptions): Promise { + // create data channel for running the noise handshake. Once the data + // channel is opened, the remote will initiate the noise handshake. This + // is used to confirm the identity of the peer. + const handshakeDataChannel = peerConnection.createDataChannel('', { negotiated: true, id: 0 }) + + // 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('creating local offer') + const offerSdp = await peerConnection.createOffer() + const mungedOfferSdp = sdp.munge(offerSdp, ufrag) + options.log.trace('setting local description') + await peerConnection.setLocalDescription(mungedOfferSdp) + + // construct answer sdp from multiaddr and ufrag + let answerSdp: RTCSessionDescriptionInit + + if (options.role === 'initiator') { + options.log.trace('deriving client offer') + answerSdp = sdp.clientOfferFromMultiaddr(options.remoteAddr, ufrag) + } else { + options.log.trace('deriving server offer') + answerSdp = sdp.serverOfferFromMultiAddr(options.remoteAddr, ufrag) + } + + options.log.trace('setting remote description') + await peerConnection.setRemoteDescription(answerSdp) + + options.log.trace('wait for handshake channel to open') + await raceEvent(handshakeDataChannel, 'open', options.signal) + + if (options.role === 'initiator') { + // now that the connection has been opened, add the remote's certhash to + // it's multiaddr so we can complete the noise handshake + const remoteFingerprint = peerConnection.remoteFingerprint()?.value ?? '' + options.remoteAddr = options.remoteAddr.encapsulate(sdp.fingerprint2Ma(remoteFingerprint)) + } + + // Do noise handshake. + // Set the Noise Prologue to libp2p-webrtc-noise: before + // starting the actual Noise handshake. + // is the concatenation of the of the two TLS fingerprints + // of A (responder) and B (initiator) in their byte representation. + const localFingerprint = sdp.getFingerprintFromSdp(peerConnection.localDescription?.sdp) + + if (localFingerprint == null) { + throw new WebRTCTransportError('Could not get fingerprint from local description sdp', 'ERR_MISSING_FINGERPRINT') + } + + options.log.trace('performing noise handshake') + const noisePrologue = generateNoisePrologue(localFingerprint, options.hashCode, options.remoteAddr, options.role) + + // Since we use the default crypto interface and do not use a static key + // or early data, we pass in undefined for these parameters. + const connectionEncrypter = noise({ prologueBytes: noisePrologue })(options) + + const wrappedChannel = createStream({ + channel: handshakeDataChannel, + direction: 'inbound', + logger: options.logger, + ...(options.dataChannel ?? {}) + }) + const wrappedDuplex = { + ...wrappedChannel, + sink: wrappedChannel.sink.bind(wrappedChannel), + source: (async function * () { + for await (const list of wrappedChannel.source) { + for (const buf of list) { + yield buf + } + } + }()) + } + + // Creating the connection before completion of the noise + // handshake ensures that the stream opening callback is set up + const maConn = new WebRTCMultiaddrConnection(options, { + peerConnection, + remoteAddr: options.remoteAddr, + timeline: { + open: Date.now() + }, + metrics: options.events + }) + + peerConnection.addEventListener(CONNECTION_STATE_CHANGE_EVENT, () => { + switch (peerConnection.connectionState) { + case 'failed': + case 'disconnected': + case 'closed': + maConn.close().catch((err) => { + options.log.error('error closing connection', err) + }) + break + default: + break + } + }) + + // Track opened peer connection + options.events?.increment({ peer_connection: true }) + + const muxerFactory = new DataChannelMuxerFactory(options, { + peerConnection, + metrics: options.events, + dataChannelOptions: options.dataChannel + }) + + if (options.role === 'responder') { + // For outbound connections, the remote is expected to start the noise handshake. + // Therefore, we need to secure an inbound noise connection from the remote. + options.log.trace('secure inbound') + await connectionEncrypter.secureInbound(options.peerId, wrappedDuplex, options.remotePeerId) + + options.log.trace('upgrade outbound') + return options.upgrader.upgradeOutbound(maConn, { skipProtection: true, skipEncryption: true, muxerFactory }) + } + + // For inbound connections, we are expected to start the noise handshake. + // Therefore, we need to secure an outbound noise connection from the remote. + options.log.trace('secure outbound') + const result = await connectionEncrypter.secureOutbound(options.peerId, wrappedDuplex) + maConn.remoteAddr = maConn.remoteAddr.encapsulate(`/p2p/${result.remotePeer}`) + + options.log.trace('upgrade inbound') + const connection = await options.upgrader.upgradeInbound(maConn, { skipProtection: true, skipEncryption: true, muxerFactory }) + + // pass to handler + options.handler?.(connection) + + return connection +} diff --git a/packages/transport-webrtc/src/private-to-public/utils/generate-certificates.ts b/packages/transport-webrtc/src/private-to-public/utils/generate-certificates.ts index 90820a0b86..1a5740bdec 100644 --- a/packages/transport-webrtc/src/private-to-public/utils/generate-certificates.ts +++ b/packages/transport-webrtc/src/private-to-public/utils/generate-certificates.ts @@ -2,15 +2,7 @@ import * as x509 from '@peculiar/x509' import { base64url } from 'multiformats/bases/base64' import { sha256 } from 'multiformats/hashes/sha2' import { toString as uint8ArrayToString } from 'uint8arrays/to-string' - -/** - * PEM format server certificate and private key - */ -export interface TransportCertificate { - privateKey: string - pem: string - certhash: string -} +import type { TransportCertificate } from '../..' const ONE_DAY_MS = 86400000 diff --git a/packages/transport-webrtc/src/private-to-public/utils/generate-noise-prologue.ts b/packages/transport-webrtc/src/private-to-public/utils/generate-noise-prologue.ts index cd492d2328..cb4de5cc7b 100644 --- a/packages/transport-webrtc/src/private-to-public/utils/generate-noise-prologue.ts +++ b/packages/transport-webrtc/src/private-to-public/utils/generate-noise-prologue.ts @@ -1,9 +1,7 @@ import * as multihashes from 'multihashes' import { concat } from 'uint8arrays/concat' import { fromString as uint8arrayFromString } from 'uint8arrays/from-string' -import { invalidArgument } from '../../error.js' import * as sdp from './sdp.js' -import type { Logger } from '@libp2p/interface' import type { Multiaddr } from '@multiformats/multiaddr' import type { HashCode } from 'multihashes' @@ -11,28 +9,17 @@ import type { HashCode } from 'multihashes' * Generate a noise prologue from the peer connection's certificate. * noise prologue = bytes('libp2p-webrtc-noise:') + noise-responder fingerprint + noise-initiator fingerprint */ -export function generateNoisePrologue (pc: RTCPeerConnection, hashCode: HashCode, remoteAddr: Multiaddr, log: Logger, role: 'initiator' | 'responder'): Uint8Array { - if (pc.getConfiguration().certificates?.length === 0) { - throw invalidArgument('no local certificate') - } - - const localFingerprint = sdp.getLocalFingerprint(pc, { - log - }) - - if (localFingerprint == null) { - throw invalidArgument('no local fingerprint found') - } - +export function generateNoisePrologue (localFingerprint: string, hashCode: HashCode, remoteAddr: Multiaddr, role: 'initiator' | 'responder'): Uint8Array { const localFpString = localFingerprint.trim().toLowerCase().replaceAll(':', '') const localFpArray = uint8arrayFromString(localFpString, 'hex') const local = multihashes.encode(localFpArray, hashCode) const remote: Uint8Array = sdp.mbdecoder.decode(sdp.certhash(remoteAddr)) const prefix = uint8arrayFromString('libp2p-webrtc-noise:') + const byteLength = prefix.byteLength + local.byteLength + remote.byteLength if (role === 'responder') { - return concat([prefix, local, remote], 88) + return concat([prefix, local, remote], byteLength) } - return concat([prefix, remote, local], 88) + return concat([prefix, remote, local], byteLength) } diff --git a/packages/transport-webrtc/src/private-to-public/utils/get-dialer-rtcpeerconnection.ts b/packages/transport-webrtc/src/private-to-public/utils/get-dialer-rtcpeerconnection.ts deleted file mode 100644 index 0519afa7b5..0000000000 --- a/packages/transport-webrtc/src/private-to-public/utils/get-dialer-rtcpeerconnection.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { RTCPeerConnection } from '../../webrtc/index.js' -import { generateTransportCertificate } from './generate-certificates.js' -import type { HashName } from 'multihashes' - -export async function createDialerRTCPeerConnection (ufrag: string, hashName: HashName): Promise { - const keyPair = await crypto.subtle.generateKey({ - name: 'ECDSA', - namedCurve: 'P-256' - }, true, ['sign', 'verify']) - - const certificate = await generateTransportCertificate(keyPair, { - days: 365 - }) - - return new RTCPeerConnection({ - // @ts-expect-error non-standard arguments accepted by node-datachannel and - // passed on to libdatachannel/libjuice - iceUfrag: ufrag, - icePwd: ufrag, - disableFingerprintVerification: true, - certificatePemFile: certificate.pem, - keyPemFile: certificate.privateKey, - maxMessageSize: 16384 - }) -} diff --git a/packages/transport-webrtc/src/private-to-public/utils/get-dialer-rtcpeerconnection.browser.ts b/packages/transport-webrtc/src/private-to-public/utils/get-rtcpeerconnection.browser.ts similarity index 83% rename from packages/transport-webrtc/src/private-to-public/utils/get-dialer-rtcpeerconnection.browser.ts rename to packages/transport-webrtc/src/private-to-public/utils/get-rtcpeerconnection.browser.ts index ed5aec1d21..043dcd7ba1 100644 --- a/packages/transport-webrtc/src/private-to-public/utils/get-dialer-rtcpeerconnection.browser.ts +++ b/packages/transport-webrtc/src/private-to-public/utils/get-rtcpeerconnection.browser.ts @@ -1,7 +1,4 @@ -// import * as sdp from '../sdp.js' -import type { HashName } from 'multihashes' - -export async function createDialerRTCPeerConnection (ufrag: string, hashName: HashName): Promise { +export async function createDialerRTCPeerConnection (ufrag: string, rtcConfiguration?: RTCConfiguration): Promise { // ECDSA is preferred over RSA here. From our testing we find that P-256 elliptic // curve is supported by Pion, webrtc-rs, as well as Chromium (P-228 and P-384 // was not supported in Chromium). We use the same hash function as found in the @@ -15,6 +12,7 @@ export async function createDialerRTCPeerConnection (ufrag: string, hashName: Ha }) return new RTCPeerConnection({ + ...(rtcConfiguration ?? {}), certificates: [certificate] }) } 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 new file mode 100644 index 0000000000..78f4b94455 --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/utils/get-rtcpeerconnection.ts @@ -0,0 +1,107 @@ +import { PeerConnection } from 'node-datachannel' +import { RTCPeerConnection } from '../../webrtc/index.js' +import { DEFAULT_STUN_SERVERS } from '../constants.js' +import { generateTransportCertificate } from './generate-certificates.js' +import type { TransportCertificate } from '../../index.js' +import type { CertificateFingerprint, IceServer } from 'node-datachannel' + +/** + * Convert the lib.dom.d.ts RTCIceServer type into a libdatachannel IceServer + */ +export function toLibdatachannelIceServers (arg?: RTCIceServer[]): IceServer[] | undefined { + if (arg == null) { + return + } + + if (arg.length === 0) { + return [] + } + + function toLibdatachannelIceServer (arg: string, init: T): T & { hostname: string, port: number } { + const url = new URL(arg) + + return { + ...init, + hostname: url.hostname, + port: parseInt(url.port) + } + } + + const output: IceServer[] = [] + + for (const server of arg) { + if (typeof server.urls === 'string') { + output.push(toLibdatachannelIceServer(server.urls, server)) + continue + } + + for (const url of server.urls) { + output.push(toLibdatachannelIceServer(url, server)) + } + } + + return output +} + +interface DirectRTCPeerConnectionInit extends RTCConfiguration { + peerConnection: PeerConnection + ufrag: string +} + +export class DirectRTCPeerConnection extends RTCPeerConnection { + private readonly peerConnection: PeerConnection + private readonly ufrag: string + + constructor (init: DirectRTCPeerConnectionInit) { + super(init) + + this.peerConnection = init.peerConnection + this.ufrag = init.ufrag + } + + createDataChannel (label: string, dataChannelDict?: RTCDataChannelInit): RTCDataChannel { + const channel = super.createDataChannel(label, dataChannelDict) + + // have to set ufrag after first datachannel is created + if (this.connectionState === 'new') { + this.peerConnection.setLocalDescription('offer', { + iceUfrag: this.ufrag, + icePwd: this.ufrag + }) + } + + return channel + } + + remoteFingerprint (): CertificateFingerprint { + return this.peerConnection.remoteFingerprint() + } +} + +export async function createDialerRTCPeerConnection (name: string, ufrag: string, rtcConfiguration?: RTCConfiguration, certificate?: TransportCertificate): Promise { + if (certificate == null) { + const keyPair = await crypto.subtle.generateKey({ + name: 'ECDSA', + namedCurve: 'P-256' + }, true, ['sign', 'verify']) + + certificate = await generateTransportCertificate(keyPair, { + days: 365 + }) + } + + // https://github.com/libp2p/specs/blob/master/webrtc/webrtc-direct.md#browser-to-public-server + const peerConnection = new PeerConnection(name, { + disableFingerprintVerification: true, + disableAutoNegotiation: true, + certificatePemFile: certificate.pem, + keyPemFile: certificate.privateKey, + maxMessageSize: 16384, + iceServers: toLibdatachannelIceServers(rtcConfiguration?.iceServers) ?? DEFAULT_STUN_SERVERS + }) + + return new DirectRTCPeerConnection({ + peerConnection, + ufrag + }) +} 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 b7460c460b..05b43b822f 100644 --- a/packages/transport-webrtc/src/private-to-public/utils/sdp.ts +++ b/packages/transport-webrtc/src/private-to-public/utils/sdp.ts @@ -6,7 +6,6 @@ import { sha256 } from 'multiformats/hashes/sha2' import * as multihashes from 'multihashes' import { inappropriateMultiaddr, invalidArgument, invalidFingerprint, unsupportedHashAlgorithm } from '../../error.js' import { CERTHASH_CODE } from '../transport.js' -import type { LoggerOptions } from '@libp2p/interface' import type { HashCode, HashName } from 'multihashes' /** @@ -15,34 +14,12 @@ import type { HashCode, HashName } from 'multihashes' // @ts-expect-error - Not easy to combine these types. export const mbdecoder: any = Object.values(bases).map(b => b.decoder).reduce((d, b) => d.or(b)) -export function getLocalFingerprint (pc: RTCPeerConnection, options: LoggerOptions): string | undefined { - // try to fetch fingerprint from local certificate - const localCert = pc.getConfiguration().certificates?.at(0) - if (localCert == null || localCert.getFingerprints == null) { - options.log.trace('fetching fingerprint from local SDP') - const localDescription = pc.localDescription - if (localDescription == null) { - return undefined - } - return getFingerprintFromSdp(localDescription.sdp) - } - - options.log.trace('fetching fingerprint from local certificate') - - if (localCert.getFingerprints().length === 0) { +const fingerprintRegex = /^a=fingerprint:(?:\w+-[0-9]+)\s(?(:?[0-9a-fA-F]{2})+)$/m +export function getFingerprintFromSdp (sdp: string | undefined): string | undefined { + if (sdp == null) { return undefined } - const fingerprint = localCert.getFingerprints()[0].value - if (fingerprint == null) { - throw invalidFingerprint('', 'no fingerprint on local certificate') - } - - return fingerprint -} - -const fingerprintRegex = /^a=fingerprint:(?:\w+-[0-9]+)\s(?(:?[0-9a-fA-F]{2})+)$/m -export function getFingerprintFromSdp (sdp: string): string | undefined { const searchResult = sdp.match(fingerprintRegex) return searchResult?.groups?.fingerprint } @@ -79,6 +56,17 @@ export function decodeCerthash (certhash: string): { code: HashCode, name: HashN return multihashes.decode(mbdecoded) } +export function certhashToFingerprint (certhash: string): string { + const mbdecoded = decodeCerthash(certhash) + + return new Array(mbdecoded.length) + .fill(0) + .map((val, index) => { + return mbdecoded.digest[index].toString(16).padStart(2, '0').toUpperCase() + }) + .join(':') +} + /** * Extract the fingerprint from a multiaddr */ @@ -134,7 +122,7 @@ a=group:BUNDLE 0 a=msid-semantic:WMS * a=ice-options:ice2,trickle 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 -m=application 9 UDP/DTLS/SCTP webrtc-datachannel +m=application ${port} UDP/DTLS/SCTP webrtc-datachannel c=IN ${ipVersion} ${host} a=mid:0 a=sendrecv @@ -166,7 +154,7 @@ s=- c=IN ${ipVersion} ${host} t=0 0 a=ice-lite -m=application 9 UDP/DTLS/SCTP webrtc-datachannel +m=application ${port} UDP/DTLS/SCTP webrtc-datachannel a=mid:0 a=setup:passive a=ice-ufrag:${ufrag} diff --git a/packages/transport-webrtc/src/stream.ts b/packages/transport-webrtc/src/stream.ts index 9a46e11963..d647e1e39a 100644 --- a/packages/transport-webrtc/src/stream.ts +++ b/packages/transport-webrtc/src/stream.ts @@ -1,10 +1,11 @@ import { CodeError } from '@libp2p/interface' import { AbstractStream, type AbstractStreamInit } from '@libp2p/utils/abstract-stream' +import { anySignal } from 'any-signal' import * as lengthPrefixed from 'it-length-prefixed' import { type Pushable, pushable } from 'it-pushable' import pDefer from 'p-defer' -import { pEvent, TimeoutError } from 'p-event' import pTimeout from 'p-timeout' +import { raceEvent } from 'race-event' import { raceSignal } from 'race-signal' import { Uint8ArrayList } from 'uint8arraylist' import { Message } from './private-to-public/pb/message.js' @@ -89,6 +90,7 @@ export class WebRTCStream extends AbstractStream { private readonly receiveFinAck: DeferredPromise private readonly finAckTimeout: number private readonly openTimeout: number + private readonly closeController: AbortController constructor (init: WebRTCStreamInit) { // override onEnd to send/receive FIN_ACK before closing the stream @@ -133,6 +135,7 @@ export class WebRTCStream extends AbstractStream { this.receiveFinAck = pDefer() this.finAckTimeout = init.closeTimeout ?? FIN_ACK_TIMEOUT this.openTimeout = init.openTimeout ?? OPEN_TIMEOUT + this.closeController = new AbortController() // set up initial state switch (this.channel.readyState) { @@ -161,6 +164,11 @@ export class WebRTCStream extends AbstractStream { } this.channel.onclose = (_evt) => { + this.log.trace('received onclose event') + + // stop any in-progress writes + this.closeController.abort() + // if the channel has closed we'll never receive a FIN_ACK so resolve the // promise so we don't try to wait later this.receiveFinAck.resolve() @@ -171,6 +179,11 @@ export class WebRTCStream extends AbstractStream { } this.channel.onerror = (evt) => { + this.log.trace('received onerror event') + + // stop any in-progress writes + this.closeController.abort() + const err = (evt as RTCErrorEvent).error this.abort(err) } @@ -208,34 +221,59 @@ export class WebRTCStream extends AbstractStream { } async _sendMessage (data: Uint8ArrayList, checkBuffer: boolean = true): Promise { + if (this.channel.readyState === 'closed' || this.channel.readyState === 'closing') { + throw new CodeError(`Invalid datachannel state - ${this.channel.readyState}`, 'ERR_INVALID_STATE') + } + + if (this.channel.readyState !== 'open') { + const timeout = AbortSignal.timeout(this.openTimeout) + const signal = anySignal([ + this.closeController.signal, + timeout + ]) + + try { + this.log('channel state is "%s" and not "open", waiting for "open" event before sending data', this.channel.readyState) + await raceEvent(this.channel, 'open', signal) + } finally { + signal.clear() + } + + this.log('channel state is now "%s", sending data', this.channel.readyState) + } + if (checkBuffer && this.channel.bufferedAmount > this.maxBufferedAmount) { + const timeout = AbortSignal.timeout(this.bufferedAmountLowEventTimeout) + const signal = anySignal([ + this.closeController.signal, + timeout + ]) + try { this.log('channel buffer is %d, wait for "bufferedamountlow" event', this.channel.bufferedAmount) - await pEvent(this.channel, 'bufferedamountlow', { timeout: this.bufferedAmountLowEventTimeout }) + await raceEvent(this.channel, 'bufferedamountlow', signal) } catch (err: any) { - if (err instanceof TimeoutError) { + if (timeout.aborted) { throw new CodeError(`Timed out waiting for DataChannel buffer to clear after ${this.bufferedAmountLowEventTimeout}ms`, 'ERR_BUFFER_CLEAR_TIMEOUT') } throw err + } finally { + signal.clear() } } - if (this.channel.readyState === 'closed' || this.channel.readyState === 'closing') { - throw new CodeError(`Invalid datachannel state - ${this.channel.readyState}`, 'ERR_INVALID_STATE') - } - - if (this.channel.readyState !== 'open') { - this.log('channel state is "%s" and not "open", waiting for "open" event before sending data', this.channel.readyState) - await pEvent(this.channel, 'open', { timeout: this.openTimeout }) - this.log('channel state is now "%s", sending data', this.channel.readyState) + try { + // send message without copying data + this.channel.send(data.subarray()) + } catch (err: any) { + this.log.error('error while sending message', err) } - - // send message without copying data - this.channel.send(data.subarray()) } async sendData (data: Uint8ArrayList): Promise { + this.log.trace('-> will send', data.byteLength) + // sending messages is an async operation so use a copy of the list as it // may be changed beneath us data = data.sublist() @@ -245,10 +283,14 @@ export class WebRTCStream extends AbstractStream { const buf = data.subarray(0, toSend) const msgbuf = Message.encode({ message: buf }) const sendbuf = lengthPrefixed.encode.single(msgbuf) + this.log.trace('-> sending message', this.channel.readyState) await this._sendMessage(sendbuf) + this.log.trace('-> sent message', this.channel.readyState) data.consume(toSend) } + + this.log.trace('-> sent data', this.channel.readyState) } async sendReset (): Promise { @@ -256,6 +298,11 @@ export class WebRTCStream extends AbstractStream { } async sendCloseWrite (options: AbortOptions): Promise { + if (this.channel.readyState !== 'open') { + this.receiveFinAck.resolve() + return + } + const sent = await this._sendFlag(Message.Flag.FIN) if (sent) { @@ -277,6 +324,10 @@ export class WebRTCStream extends AbstractStream { } async sendCloseRead (): Promise { + if (this.channel.readyState !== 'open') { + return + } + await this._sendFlag(Message.Flag.STOP_SENDING) } diff --git a/packages/transport-webrtc/src/util.ts b/packages/transport-webrtc/src/util.ts index 6dbff5e24c..08fc50977d 100644 --- a/packages/transport-webrtc/src/util.ts +++ b/packages/transport-webrtc/src/util.ts @@ -2,6 +2,7 @@ import { detect } from 'detect-browser' import pDefer from 'p-defer' import pTimeout from 'p-timeout' import type { LoggerOptions } from '@libp2p/interface' +import type { PeerConnection } from 'node-datachannel' const browser = detect() export const isFirefox = ((browser != null) && browser.name === 'firefox') @@ -64,3 +65,7 @@ export interface AbortPromiseOptions { signal?: AbortSignal message?: string } + +export function isPeerConnection (obj: any): obj is PeerConnection { + return typeof obj.state === 'function' +} diff --git a/packages/transport-webrtc/test/compliance.spec.ts b/packages/transport-webrtc/test/compliance.spec.ts new file mode 100644 index 0000000000..4ec007e310 --- /dev/null +++ b/packages/transport-webrtc/test/compliance.spec.ts @@ -0,0 +1,81 @@ +import net from 'net' +import tests from '@libp2p/interface-compliance-tests/transport' +import { defaultLogger } from '@libp2p/logger' +import { createEd25519PeerId } from '@libp2p/peer-id-factory' +import { multiaddr } from '@multiformats/multiaddr' +import sinon from 'sinon' +import { stubInterface } from 'sinon-ts' +import { isNode, isElectron } from 'wherearewe' +import { webRTCDirect } from '../src/index.js' +import { generateTransportCertificate } from '../src/private-to-public/utils/generate-certificates.js' +import type { TransportManager } from '@libp2p/interface-internal' + +describe('webrtc-direct interface-transport compliance', () => { + if (!isNode && !isElectron) { + return + } + + tests({ + async setup () { + const keyPair = await crypto.subtle.generateKey({ + name: 'ECDSA', + namedCurve: 'P-256' + }, true, ['sign', 'verify']) + const listenCertificate = await generateTransportCertificate(keyPair, { + days: 365 + }) + const listenerPeerId = await createEd25519PeerId() + + const listener = webRTCDirect({ + certificates: [ + listenCertificate + ] + })({ + logger: defaultLogger(), + transportManager: stubInterface(), + peerId: listenerPeerId + }) + const listenAddrs = [ + multiaddr('/ip4/127.0.0.1/udp/9091/webrtc-direct'), + multiaddr('/ip4/127.0.0.1/udp/9092/webrtc-direct'), + multiaddr('/ip4/127.0.0.1/udp/9093/webrtc-direct'), + multiaddr('/ip6/::/udp/9094/webrtc-direct') + ] + + const dialer = webRTCDirect()({ + logger: defaultLogger(), + transportManager: stubInterface(), + peerId: await createEd25519PeerId() + }) + const dialAddrs = [ + multiaddr(`/ip4/127.0.0.1/udp/9091/webrtc-direct/certhash/${listenCertificate.certhash}/p2p/${listenerPeerId}`), + multiaddr(`/ip4/127.0.0.1/udp/9092/webrtc-direct/certhash/${listenCertificate.certhash}/p2p/${listenerPeerId}`), + multiaddr(`/ip4/127.0.0.1/udp/9093/webrtc-direct/certhash/${listenCertificate.certhash}/p2p/${listenerPeerId}`), + multiaddr(`/ip6/::/udp/9094/webrtc-direct/certhash/${listenCertificate.certhash}/p2p/${listenerPeerId}`) + ] + + // Used by the dial tests to simulate a delayed connect + const connector = { + delay (delayMs: number) { + const netConnect = net.connect + sinon.replace(net, 'connect', (opts: any) => { + const socket = netConnect(opts) + const socketEmit = socket.emit.bind(socket) + sinon.replace(socket, 'emit', (...args: [string]) => { + const time = args[0] === 'connect' ? delayMs : 0 + setTimeout(() => socketEmit(...args), time) + return true + }) + return socket + }) + }, + restore () { + sinon.restore() + } + } + + return { dialer, listener, listenAddrs, dialAddrs, connector } + }, + async teardown () {} + }) +}) diff --git a/packages/transport-webrtc/test/sdp.spec.ts b/packages/transport-webrtc/test/sdp.spec.ts index fe45681139..4c457f4c35 100644 --- a/packages/transport-webrtc/test/sdp.spec.ts +++ b/packages/transport-webrtc/test/sdp.spec.ts @@ -15,10 +15,11 @@ a=mid:0 a=setup:passive a=ice-ufrag:MyUserFragment a=ice-pwd:MyUserFragment -a=fingerprint:SHA-256 72:68:47:CD:48:B0:5E:C5:60:4D:15:9C:BF:40:1D:6F:00:A1:23:EC:90:17:0E:2C:D1:B3:8F:D2:9D:37:E5:B1 +a=fingerprint:sha-256 72:68:47:CD:48:B0:5E:C5:60:4D:15:9C:BF:40:1D:6F:00:A1:23:EC:90:17:0E:2C:D1:B3:8F:D2:9D:37:E5:B1 a=sctp-port:5000 a=max-message-size:16384 -a=candidate:1467250027 1 UDP 1467250027 0.0.0.0 56093 typ host` +a=candidate:1467250027 1 UDP 1467250027 0.0.0.0 56093 typ host +a=end-of-candidates` describe('SDP', () => { it('converts multiaddr with certhash to an answer SDP', async () => { @@ -48,7 +49,7 @@ describe('SDP', () => { it('converts a multiaddr into a fingerprint', () => { const fingerpint = underTest.ma2Fingerprint(sampleMultiAddr) expect(fingerpint).to.deep.equal([ - 'SHA-256 72:68:47:CD:48:B0:5E:C5:60:4D:15:9C:BF:40:1D:6F:00:A1:23:EC:90:17:0E:2C:D1:B3:8F:D2:9D:37:E5:B1', + 'sha-256 72:68:47:CD:48:B0:5E:C5:60:4D:15:9C:BF:40:1D:6F:00:A1:23:EC:90:17:0E:2C:D1:B3:8F:D2:9D:37:E5:B1', '726847cd48b05ec5604d159cbf401d6f00a123ec90170e2cd1b38fd29d37e5b1' ]) }) @@ -71,10 +72,11 @@ a=mid:0 a=setup:passive a=ice-ufrag:someotheruserfragmentstring a=ice-pwd:someotheruserfragmentstring -a=fingerprint:SHA-256 72:68:47:CD:48:B0:5E:C5:60:4D:15:9C:BF:40:1D:6F:00:A1:23:EC:90:17:0E:2C:D1:B3:8F:D2:9D:37:E5:B1 +a=fingerprint:sha-256 72:68:47:CD:48:B0:5E:C5:60:4D:15:9C:BF:40:1D:6F:00:A1:23:EC:90:17:0E:2C:D1:B3:8F:D2:9D:37:E5:B1 a=sctp-port:5000 a=max-message-size:16384 -a=candidate:1467250027 1 UDP 1467250027 0.0.0.0 56093 typ host` +a=candidate:1467250027 1 UDP 1467250027 0.0.0.0 56093 typ host +a=end-of-candidates` expect(result.sdp).to.equal(expected) }) From 03044e06e1ff66e4401959f690c74713262b68d8 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Fri, 14 Jun 2024 14:20:57 +0100 Subject: [PATCH 3/4] chore: use libjuice for udp muxing --- packages/transport-webrtc/package.json | 4 +- .../src/private-to-public/listener.ts | 117 ++++++------------ .../src/private-to-public/transport.ts | 3 +- .../src/private-to-public/utils/connect.ts | 16 ++- .../src/private-to-public/utils/sdp.ts | 8 +- .../private-to-public/utils/stun-listener.ts | 108 ++++++++++++++++ .../transport-webrtc/test/compliance.spec.ts | 2 +- packages/transport-webrtc/test/sdp.spec.ts | 2 +- 8 files changed, 170 insertions(+), 90 deletions(-) create mode 100644 packages/transport-webrtc/src/private-to-public/utils/stun-listener.ts diff --git a/packages/transport-webrtc/package.json b/packages/transport-webrtc/package.json index 4213e66c62..d6e78f6acb 100644 --- a/packages/transport-webrtc/package.json +++ b/packages/transport-webrtc/package.json @@ -62,6 +62,7 @@ "@peculiar/x509": "^1.11.0", "any-signal": "^4.1.1", "detect-browser": "^5.3.0", + "get-port": "^7.1.0", "it-length-prefixed": "^9.0.4", "it-protobuf-stream": "^1.1.3", "it-pushable": "^3.2.3", @@ -108,7 +109,8 @@ "browser": { "./dist/src/webrtc/index.js": "./dist/src/webrtc/index.browser.js", "./dist/src/private-to-public/listener.js": "./dist/src/private-to-public/listener.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/get-rtcpeerconnection.js": "./dist/src/private-to-public/utils/get-rtcpeerconnection.browser.js", + "node:net": false }, "react-native": { "./dist/src/webrtc/index.js": "./dist/src/webrtc/index.react-native.js" diff --git a/packages/transport-webrtc/src/private-to-public/listener.ts b/packages/transport-webrtc/src/private-to-public/listener.ts index 1107683bc0..668ac49439 100644 --- a/packages/transport-webrtc/src/private-to-public/listener.ts +++ b/packages/transport-webrtc/src/private-to-public/listener.ts @@ -1,23 +1,21 @@ -import { createSocket } from 'node:dgram' + import { networkInterfaces } from 'node:os' import { isIPv4, isIPv6 } from '@chainsafe/is-ip' import { TypedEventEmitter } from '@libp2p/interface' import { multiaddr, protocols } from '@multiformats/multiaddr' import { IP4 } from '@multiformats/multiaddr-matcher' +import getPort from 'get-port' import { sha256 } from 'multiformats/hashes/sha2' -import { pEvent } from 'p-event' import pWaitFor from 'p-wait-for' -// @ts-expect-error no types -import stun from 'stun' -import { UFRAG_PREFIX } from './constants.js' import { connect } from './utils/connect.js' import { generateTransportCertificate } from './utils/generate-certificates.js' -import { type DirectRTCPeerConnection, createDialerRTCPeerConnection } from './utils/get-rtcpeerconnection.js' +import { createDialerRTCPeerConnection } from './utils/get-rtcpeerconnection.js' +import { stunListener } from './utils/stun-listener.js' +import type { DirectRTCPeerConnection } from './utils/get-rtcpeerconnection.js' +import type { StunServer } from './utils/stun-listener.js' import type { DataChannelOptions, TransportCertificate } from '../index.js' import type { PeerId, ListenerEvents, Listener, Upgrader, ComponentLogger, Logger, CounterGroup, Metrics, ConnectionHandler } from '@libp2p/interface' import type { Multiaddr } from '@multiformats/multiaddr' -import type { Socket, RemoteInfo } from 'node:dgram' -import type { AddressInfo } from 'node:net' /** * The time to wait, in milliseconds, for the data channel handshake to complete @@ -37,6 +35,7 @@ export interface WebRTCDirectListenerInit { maxInboundStreams?: number dataChannel?: DataChannelOptions rtcConfiguration?: RTCConfiguration + useLibjuice?: boolean } export interface WebRTCListenerMetrics { @@ -48,7 +47,7 @@ const IP4_PROTOCOL = protocols('ip4') const IP6_PROTOCOL = protocols('ip6') export class WebRTCDirectListener extends TypedEventEmitter implements Listener { - private socket?: Socket + private server?: StunServer private readonly multiaddrs: Multiaddr[] private certificate?: TransportCertificate private readonly connections: Map @@ -79,9 +78,7 @@ export class WebRTCDirectListener extends TypedEventEmitter impl async listen (ma: Multiaddr): Promise { const parts = ma.stringTuples() - const ipVersion = IP4.matches(ma) ? 4 : 6 - const host = parts .filter(([code]) => code === IP4_PROTOCOL.code) .pop()?.[1] ?? parts @@ -91,8 +88,7 @@ export class WebRTCDirectListener extends TypedEventEmitter impl if (host == null) { throw new Error('IP4/6 host must be specified in webrtc-direct mulitaddr') } - - const port = parseInt(parts + let port = parseInt(parts .filter(([code, value]) => code === UDP_PROTOCOL.code) .pop()?.[1] ?? '') @@ -100,19 +96,21 @@ export class WebRTCDirectListener extends TypedEventEmitter impl throw new Error('UDP port must be specified in webrtc-direct mulitaddr') } - this.socket = createSocket({ - type: `udp${ipVersion}`, - reuseAddr: true - }) - - try { - this.socket.bind(port, host) - await pEvent(this.socket, 'listening') - } catch (err) { - this.socket.close() - throw err + if (port === 0 && this.init.useLibjuice !== false) { + // libjuice doesn't map 0 to a random free port so we have to do it + // ourselves + port = await getPort() } + this.server = await stunListener(host, port, ipVersion, this.log, (ufrag, pwd, remoteHost, remotePort) => { + this.incomingConnection(ufrag, pwd, remoteHost, remotePort) + .catch(err => { + this.log.error('error processing incoming STUN request', err) + }) + }, { + useLibjuice: this.init.useLibjuice + }) + let certificate = this.certificate if (certificate == null) { @@ -126,44 +124,17 @@ export class WebRTCDirectListener extends TypedEventEmitter impl }) } - const address = this.socket.address() + const address = this.server.address() - getNetworkAddresses(address, ipVersion).forEach((ma) => { + getNetworkAddresses(address.address, address.port, ipVersion).forEach((ma) => { this.multiaddrs.push(multiaddr(`${ma}/webrtc-direct/certhash/${certificate.certhash}`)) }) - this.socket.on('message', (msg, rinfo) => { - try { - this.log('incoming STUN packet from %o', rinfo) - const response = stun.decode(msg) - // TODO: this needs to be rate limited keyed by the remote host to - // prevent a DOS attack - this.incomingConnection(response, rinfo, certificate).catch(err => { - this.log.error('could not process incoming STUN data from %o', rinfo, err) - }) - } catch (err) { - this.log.error('could not process incoming STUN data from %o', rinfo, err) - } - }) - - this.socket.on('close', () => { - this.safeDispatchEvent('close') - }) - this.safeDispatchEvent('listening') } - private async incomingConnection (stunMessage: any, rinfo: RemoteInfo, certificate: TransportCertificate): Promise { - const usernameAttribute = stunMessage.getAttribute(stun.constants.STUN_ATTR_USERNAME) - const username: string | undefined = usernameAttribute?.value?.toString() - - if (username == null || !username.startsWith(UFRAG_PREFIX)) { - this.log.trace('ufrag missing from incoming STUN message from %s:%s', rinfo.address, rinfo.port) - return - } - - const ufrag = username.split(':')[0] - const key = `${rinfo.address}:${rinfo.port}:${ufrag}` + private async incomingConnection (ufrag: string, pwd: string, remoteHost: string, remotePort: number): Promise { + const key = `${remoteHost}:${remotePort}:${ufrag}` let peerConnection = this.connections.get(key) if (peerConnection != null) { @@ -191,14 +162,14 @@ export class WebRTCDirectListener extends TypedEventEmitter impl }) try { - const conn = await connect(peerConnection, ufrag, { + const conn = await connect(peerConnection, ufrag, pwd, { role: 'initiator', log: this.log, logger: this.components.logger, metrics: this.components.metrics, events: this.metrics?.listenerEvents, signal: AbortSignal.timeout(HANDSHAKE_TIMEOUT_MS), - remoteAddr: multiaddr(`/${rinfo.family === 'IPv4' ? 'ip4' : 'ip6'}/${rinfo.address}/udp/${rinfo.port}`), + remoteAddr: multiaddr(`/ip${isIPv4(remoteHost) ? 4 : 6}/${remoteHost}/udp/${remotePort}`), hashCode: sha256.code, dataChannel: this.init.dataChannel, upgrader: this.init.upgrader, @@ -222,28 +193,20 @@ export class WebRTCDirectListener extends TypedEventEmitter impl connection.close() } - await Promise.all([ - new Promise((resolve) => { - if (this.socket == null) { - resolve() - return - } + await this.server?.close() - this.socket.close(() => { - resolve() - }) - }), - // RTCPeerConnections will be removed from the connections map when their - // connection state changes to 'closed'/'disconnected'/'failed - pWaitFor(() => { - return this.connections.size === 0 - }) - ]) + // RTCPeerConnections will be removed from the connections map when their + // connection state changes to 'closed'/'disconnected'/'failed + await pWaitFor(() => { + return this.connections.size === 0 + }) + + this.safeDispatchEvent('close') } } -function getNetworkAddresses (host: AddressInfo, version: 4 | 6): string[] { - if (host.address === '0.0.0.0' || host.address === '::1') { +function getNetworkAddresses (host: string, port: number, version: 4 | 6): string[] { + if (host === '0.0.0.0' || host === '::1') { // return all ip4 interfaces return Object.entries(networkInterfaces()) .flatMap(([_, addresses]) => addresses) @@ -263,10 +226,10 @@ function getNetworkAddresses (host: AddressInfo, version: 4 | 6): string[] { return false }) - .map(address => `/ip${version}/${address}/udp/${host.port}`) + .map(address => `/ip${version}/${address}/udp/${port}`) } return [ - `/ip${version}/${host.address}/udp/${host.port}` + `/ip${version}/${host}/udp/${port}` ] } diff --git a/packages/transport-webrtc/src/private-to-public/transport.ts b/packages/transport-webrtc/src/private-to-public/transport.ts index 53e1d13dbd..1c3ac3a15e 100644 --- a/packages/transport-webrtc/src/private-to-public/transport.ts +++ b/packages/transport-webrtc/src/private-to-public/transport.ts @@ -52,6 +52,7 @@ export interface WebRTCTransportDirectInit { rtcConfiguration?: RTCConfiguration dataChannel?: DataChannelOptions certificates?: TransportCertificate[] + useLibjuice?: boolean } export class WebRTCDirectTransport implements Transport { @@ -133,7 +134,7 @@ export class WebRTCDirectTransport implements Transport { const peerConnection = await createDialerRTCPeerConnection('NodeA', ufrag, this.init.rtcConfiguration) try { - return await raceSignal(connect(peerConnection, ufrag, { + return await raceSignal(connect(peerConnection, ufrag, ufrag, { role: 'responder', log: this.log, logger: this.components.logger, 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 f5707dfc5b..6e56b11243 100644 --- a/packages/transport-webrtc/src/private-to-public/utils/connect.ts +++ b/packages/transport-webrtc/src/private-to-public/utils/connect.ts @@ -31,7 +31,7 @@ export interface ConnectOptions { const CONNECTION_STATE_CHANGE_EVENT = isFirefox ? 'iceconnectionstatechange' : 'connectionstatechange' -export async function connect (peerConnection: DirectRTCPeerConnection, ufrag: string, options: ConnectOptions): Promise { +export async function connect (peerConnection: DirectRTCPeerConnection, ufrag: string, pwd: string, options: ConnectOptions): Promise { // create data channel for running the noise handshake. Once the data // channel is opened, the remote will initiate the noise handshake. This // is used to confirm the identity of the peer. @@ -47,15 +47,21 @@ export async function connect (peerConnection: DirectRTCPeerConnection, ufrag: s options.log.trace('setting local description') await peerConnection.setLocalDescription(mungedOfferSdp) + if (options.role === 'initiator') { + options.log.trace('server offer', mungedOfferSdp.sdp) + } else { + options.log.trace('client offer', mungedOfferSdp.sdp) + } + // construct answer sdp from multiaddr and ufrag let answerSdp: RTCSessionDescriptionInit if (options.role === 'initiator') { - options.log.trace('deriving client offer') - answerSdp = sdp.clientOfferFromMultiaddr(options.remoteAddr, ufrag) + answerSdp = sdp.clientOfferFromMultiaddr(options.remoteAddr, ufrag, pwd) + options.log.trace('server derived client offer', answerSdp.sdp) } else { - options.log.trace('deriving server offer') - answerSdp = sdp.serverOfferFromMultiAddr(options.remoteAddr, ufrag) + answerSdp = sdp.serverOfferFromMultiAddr(options.remoteAddr, ufrag, pwd) + options.log.trace('client derived server offer', answerSdp.sdp) } options.log.trace('setting remote description') 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 05b43b822f..90c9361ae8 100644 --- a/packages/transport-webrtc/src/private-to-public/utils/sdp.ts +++ b/packages/transport-webrtc/src/private-to-public/utils/sdp.ts @@ -110,7 +110,7 @@ export function toSupportedHashFunction (name: multihashes.HashName): string { /** * 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): RTCSessionDescriptionInit { const { host, port } = ma.toOptions() const ipVersion = ipv(ma) @@ -130,7 +130,7 @@ a=sctp-port:5000 a=max-message-size:16384 a=setup:active a=ice-ufrag:${ufrag} -a=ice-pwd:${ufrag} +a=ice-pwd:${pwd} a=candidate:1467250027 1 UDP 1467250027 ${host} ${port} typ host a=end-of-candidates ` @@ -144,7 +144,7 @@ a=end-of-candidates /** * Create an answer SDP message from a multiaddr */ -export function serverOfferFromMultiAddr (ma: Multiaddr, ufrag: string): RTCSessionDescriptionInit { +export function serverOfferFromMultiAddr (ma: Multiaddr, ufrag: string, pwd: string): RTCSessionDescriptionInit { const { host, port } = ma.toOptions() const ipVersion = ipv(ma) const [CERTFP] = ma2Fingerprint(ma) @@ -158,7 +158,7 @@ m=application ${port} UDP/DTLS/SCTP webrtc-datachannel a=mid:0 a=setup:passive a=ice-ufrag:${ufrag} -a=ice-pwd:${ufrag} +a=ice-pwd:${pwd} a=fingerprint:${CERTFP} a=sctp-port:5000 a=max-message-size:16384 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 new file mode 100644 index 0000000000..f63f165c36 --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/utils/stun-listener.ts @@ -0,0 +1,108 @@ +import { createSocket } from 'node:dgram' +import { isIPv4 } from '@chainsafe/is-ip' +import { onUnhandledStunRequest } from 'node-datachannel' +import { pEvent } from 'p-event' +// @ts-expect-error no types +import stun from 'stun' +import { UFRAG_PREFIX } from '../constants.js' +import type { Logger } from '@libp2p/interface' +import type { AddressInfo } from 'node:net' + +export interface StunServer { + close(): Promise + address(): AddressInfo +} + +export interface Callback { + (ufrag: string, pwd: string, remoteHost: string, remotePort: number, remoteFamily: number): void +} + +async function dgramListener (host: string, port: number, ipVersion: 4 | 6, log: Logger, cb: Callback): Promise { + const socket = createSocket({ + type: `udp${ipVersion}`, + reuseAddr: true + }) + + try { + socket.bind(port, host) + await pEvent(socket, 'listening') + } catch (err) { + socket.close() + throw err + } + + socket.on('message', (msg, rinfo) => { + // TODO: this needs to be rate limited keyed by the remote host to + // prevent a DOS attack + try { + log('incoming STUN packet from %o', rinfo) + const stunMessage = stun.decode(msg) + const usernameAttribute = stunMessage.getAttribute(stun.constants.STUN_ATTR_USERNAME) + const username: string | undefined = usernameAttribute?.value?.toString() + + if (username == null || !username.startsWith(UFRAG_PREFIX)) { + log.trace('ufrag missing from incoming STUN message from %s:%s', rinfo.address, rinfo.port) + return + } + + const [ufrag, pwd] = username.split(':') + + cb(ufrag, pwd, rinfo.address, rinfo.port, rinfo.family === 'IPv4' ? 4 : 6) + } catch (err) { + log.error('could not process incoming STUN data from %o', rinfo, err) + } + }) + + return { + close: async () => { + const p = pEvent(socket, 'close') + socket.close() + await p + }, + address: () => { + return socket.address() + } + } +} + +let listening = false + +async function libjuiceListener (host: string, port: number, ipVersion: 4 | 6, log: Logger, cb: Callback): Promise { + if (listening) { + throw new Error('There can only be one WebRTC-Direct listener per-process due to the limitations of libjuice. Please pass `useLibjuice=false` to override this, but this may break NAT traversal.') + } + + onUnhandledStunRequest(host, port, (request) => { + if (request.ufrag == null || request.pwd == null) { + return + } + + cb(request.ufrag, request.pwd, request.address, request.port, request.family) + }) + + return { + close: async () => { + onUnhandledStunRequest(host, port) + listening = false + }, + address: () => { + return { + address: host, + family: isIPv4(host) ? 'IPv4' : 'IPv6', + port + } + } + } +} + +export interface STUNListenerOptions { + useLibjuice?: boolean +} + +export async function stunListener (host: string, port: number, ipVersion: 4 | 6, log: Logger, cb: Callback, opts: STUNListenerOptions = {}): Promise { + if (opts.useLibjuice === false) { + return dgramListener(host, port, ipVersion, log, cb) + } + + return libjuiceListener(host, port, ipVersion, log, cb) +} diff --git a/packages/transport-webrtc/test/compliance.spec.ts b/packages/transport-webrtc/test/compliance.spec.ts index 4ec007e310..49e6744e7f 100644 --- a/packages/transport-webrtc/test/compliance.spec.ts +++ b/packages/transport-webrtc/test/compliance.spec.ts @@ -1,4 +1,4 @@ -import net from 'net' +import net from 'node:net' import tests from '@libp2p/interface-compliance-tests/transport' import { defaultLogger } from '@libp2p/logger' import { createEd25519PeerId } from '@libp2p/peer-id-factory' diff --git a/packages/transport-webrtc/test/sdp.spec.ts b/packages/transport-webrtc/test/sdp.spec.ts index 4c457f4c35..eba78b77d5 100644 --- a/packages/transport-webrtc/test/sdp.spec.ts +++ b/packages/transport-webrtc/test/sdp.spec.ts @@ -24,7 +24,7 @@ a=end-of-candidates` describe('SDP', () => { it('converts multiaddr with certhash to an answer SDP', async () => { const ufrag = 'MyUserFragment' - const sdp = underTest.serverOfferFromMultiAddr(sampleMultiAddr, ufrag) + const sdp = underTest.serverOfferFromMultiAddr(sampleMultiAddr, ufrag, ufrag) expect(sdp.sdp).to.contain(sampleSdp) }) From 1e052def6c0ccc2ba41b183f5b33ba7cc594e571 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Fri, 14 Jun 2024 16:08:16 +0100 Subject: [PATCH 4/4] chore: allow function based config --- packages/transport-webrtc/src/private-to-public/listener.ts | 2 +- .../transport-webrtc/src/private-to-public/transport.ts | 2 +- .../src/private-to-public/utils/get-rtcpeerconnection.ts | 6 ++++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/transport-webrtc/src/private-to-public/listener.ts b/packages/transport-webrtc/src/private-to-public/listener.ts index 668ac49439..cb42259fc1 100644 --- a/packages/transport-webrtc/src/private-to-public/listener.ts +++ b/packages/transport-webrtc/src/private-to-public/listener.ts @@ -34,7 +34,7 @@ export interface WebRTCDirectListenerInit { certificates?: TransportCertificate[] maxInboundStreams?: number dataChannel?: DataChannelOptions - rtcConfiguration?: RTCConfiguration + rtcConfiguration?: RTCConfiguration | (() => RTCConfiguration | Promise) useLibjuice?: boolean } diff --git a/packages/transport-webrtc/src/private-to-public/transport.ts b/packages/transport-webrtc/src/private-to-public/transport.ts index 1c3ac3a15e..3266126072 100644 --- a/packages/transport-webrtc/src/private-to-public/transport.ts +++ b/packages/transport-webrtc/src/private-to-public/transport.ts @@ -49,7 +49,7 @@ export interface WebRTCMetrics { } export interface WebRTCTransportDirectInit { - rtcConfiguration?: RTCConfiguration + rtcConfiguration?: RTCConfiguration | (() => RTCConfiguration | Promise) dataChannel?: DataChannelOptions certificates?: TransportCertificate[] useLibjuice?: boolean 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 d4b82fe4d1..69854dd6b1 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 @@ -78,7 +78,7 @@ export class DirectRTCPeerConnection extends RTCPeerConnection { } } -export async function createDialerRTCPeerConnection (name: string, ufrag: string, rtcConfiguration?: RTCConfiguration, certificate?: TransportCertificate): Promise { +export async function createDialerRTCPeerConnection (name: string, ufrag: string, rtcConfiguration?: RTCConfiguration | (() => RTCConfiguration | Promise), certificate?: TransportCertificate): Promise { if (certificate == null) { const keyPair = await crypto.subtle.generateKey({ name: 'ECDSA', @@ -90,6 +90,8 @@ export async function createDialerRTCPeerConnection (name: string, ufrag: string }) } + const rtcConfig = typeof rtcConfiguration === 'function' ? await rtcConfiguration() : rtcConfiguration + // https://github.com/libp2p/specs/blob/master/webrtc/webrtc-direct.md#browser-to-public-server const peerConnection = new PeerConnection(name, { disableFingerprintVerification: true, @@ -97,7 +99,7 @@ export async function createDialerRTCPeerConnection (name: string, ufrag: string certificatePemFile: certificate.pem, keyPemFile: certificate.privateKey, maxMessageSize: 16384, - iceServers: toLibdatachannelIceServers(rtcConfiguration?.iceServers) ?? DEFAULT_STUN_SERVERS + iceServers: toLibdatachannelIceServers(rtcConfig?.iceServers) ?? DEFAULT_STUN_SERVERS }) return new DirectRTCPeerConnection({