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 d7469d0452..d6e78f6acb 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.1", "@libp2p/interface-internal": "^1.2.3", @@ -58,7 +59,10 @@ "@multiformats/mafmt": "^12.1.6", "@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", + "get-port": "^7.1.0", "it-length-prefixed": "^9.0.4", "it-protobuf-stream": "^1.1.3", "it-pushable": "^3.2.3", @@ -69,9 +73,12 @@ "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", "uint8arraylist": "^2.4.8", "uint8arrays": "^5.1.0" }, @@ -96,10 +103,14 @@ "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/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", + "node:net": false }, "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-public/constants.ts b/packages/transport-webrtc/src/private-to-public/constants.ts new file mode 100644 index 0000000000..02c2493957 --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/constants.ts @@ -0,0 +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.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..cb42259fc1 --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/listener.ts @@ -0,0 +1,235 @@ + +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 pWaitFor from 'p-wait-for' +import { connect } from './utils/connect.js' +import { generateTransportCertificate } from './utils/generate-certificates.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' + +/** + * 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 { + handler?: ConnectionHandler + upgrader: Upgrader + certificates?: TransportCertificate[] + maxInboundStreams?: number + dataChannel?: DataChannelOptions + rtcConfiguration?: RTCConfiguration | (() => RTCConfiguration | Promise) + useLibjuice?: boolean +} + +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 server?: StunServer + 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.multiaddrs = [] + this.connections = new Map() + this.log = components.logger.forComponent('libp2p:webrtc-direct:listener') + this.certificate = init.certificates?.[0] + + 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') + } + let 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') + } + + 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) { + 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.server.address() + + getNetworkAddresses(address.address, address.port, ipVersion).forEach((ma) => { + this.multiaddrs.push(multiaddr(`${ma}/webrtc-direct/certhash/${certificate.certhash}`)) + }) + + this.safeDispatchEvent('listening') + } + + 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) { + this.log('already got peer connection for', key) + return + } + + 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) + + peerConnection.addEventListener('connectionstatechange', () => { + switch (peerConnection.connectionState) { + case 'failed': + case 'disconnected': + case 'closed': + this.connections.delete(key) + break + default: + break + } + }) + + try { + 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(`/ip${isIPv4(remoteHost) ? 4 : 6}/${remoteHost}/udp/${remotePort}`), + hashCode: sha256.code, + dataChannel: this.init.dataChannel, + upgrader: this.init.upgrader, + peerId: this.components.peerId, + handler: this.init.handler + }) + + this.safeDispatchEvent('connection', { detail: conn }) + } catch (err) { + peerConnection.close() + throw err + } + } + + getAddrs (): Multiaddr[] { + return this.multiaddrs + } + + async close (): Promise { + for (const connection of this.connections.values()) { + connection.close() + } + + await this.server?.close() + + // 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: 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) + .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/${port}`) + } + + return [ + `/ip${version}/${host}/udp/${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 bf026fabc8..d7896ac5d5 100644 --- a/packages/transport-webrtc/src/private-to-public/transport.ts +++ b/packages/transport-webrtc/src/private-to-public/transport.ts @@ -1,22 +1,18 @@ -import { noise } from '@chainsafe/libp2p-noise' -import { transportSymbol, serviceCapabilities } from '@libp2p/interface' +import { serviceCapabilities, 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 { 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 type { DataChannelOptions } from '../index.js' -import type { CreateListenerOptions, Transport, Listener, ComponentLogger, Logger, Connection, CounterGroup, Metrics, PeerId } from '@libp2p/interface' +import { raceSignal } from 'race-signal' +import { inappropriateMultiaddr } from '../error.js' +import { UFRAG_PREFIX } from './constants.js' +import { WebRTCDirectListener } from './listener.js' +import { connect } from './utils/connect.js' +import { genUfrag } from './utils/generate-ufrag.js' +import { createDialerRTCPeerConnection } from './utils/get-rtcpeerconnection.js' +import * as sdp from './utils/sdp.js' +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' /** @@ -45,6 +41,7 @@ export interface WebRTCDirectTransportComponents { peerId: PeerId metrics?: Metrics logger: ComponentLogger + transportManager: TransportManager } export interface WebRTCMetrics { @@ -54,6 +51,8 @@ export interface WebRTCMetrics { export interface WebRTCTransportDirectInit { rtcConfiguration?: RTCConfiguration | (() => RTCConfiguration | Promise) dataChannel?: DataChannelOptions + certificates?: TransportCertificate[] + useLibjuice?: boolean } export class WebRTCDirectTransport implements Transport { @@ -61,10 +60,12 @@ export class WebRTCDirectTransport implements Transport { private readonly metrics?: WebRTCMetrics private readonly components: WebRTCDirectTransportComponents private readonly init: WebRTCTransportDirectInit + constructor (components: WebRTCDirectTransportComponents, init: WebRTCTransportDirectInit = {}) { this.log = components.logger.forComponent('libp2p:webrtc-direct') this.components = components this.init = init + if (components.metrics != null) { this.metrics = { dialerEvents: components.metrics.registerCounterGroup('libp2p_webrtc-direct_dialer_events_total', { @@ -86,7 +87,8 @@ export class WebRTCDirectTransport implements Transport { /** * Dial a given multiaddr */ - async dial (ma: Multiaddr, options: WebRTCDialOptions): Promise { + 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 @@ -96,7 +98,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, { + ...this.init, + ...options + }) } /** @@ -116,181 +121,36 @@ export class WebRTCDirectTransport implements Transport { /** * Connect to a peer using a multiaddr */ - async _connect (ma: Multiaddr, options: WebRTCDialOptions): Promise { - const controller = new AbortController() - const signal = controller.signal - + async _connect (ma: Multiaddr, options: DialOptions): Promise { const remotePeerString = ma.getPeerId() if (remotePeerString === null) { throw inappropriateMultiaddr("we need to have the remote's PeerId") } const theirPeerId = p.peerIdFromString(remotePeerString) - const remoteCerthash = sdp.decodeCerthash(sdp.certhash(ma)) + const ufrag = UFRAG_PREFIX + genUfrag(32) - // 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({ - ...(typeof this.init.rtcConfiguration === 'function' ? await this.init.rtcConfiguration() : this.init.rtcConfiguration ?? {}), - certificates: [certificate] - }) + // https://github.com/libp2p/specs/blob/master/webrtc/webrtc-direct.md#browser-to-public-server + const peerConnection = await createDialerRTCPeerConnection('NodeA', ufrag, typeof this.init.rtcConfiguration === 'function' ? await this.init.rtcConfiguration() : this.init.rtcConfiguration ?? {}) 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)) - } - }) - - 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 - // 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.fromMultiAddr(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. - const connectionEncrypter = noise({ prologueBytes: fingerprintsPrologue })(this.components) - - const wrappedChannel = createStream({ - channel: handshakeDataChannel, - direction: 'inbound', + return await raceSignal(connect(peerConnection, ufrag, 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(myPeerId, 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 } } - - /** - * 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/connect.ts b/packages/transport-webrtc/src/private-to-public/utils/connect.ts new file mode 100644 index 0000000000..6e56b11243 --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/utils/connect.ts @@ -0,0 +1,173 @@ +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, 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. + 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) + + 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') { + answerSdp = sdp.clientOfferFromMultiaddr(options.remoteAddr, ufrag, pwd) + options.log.trace('server derived client offer', answerSdp.sdp) + } else { + answerSdp = sdp.serverOfferFromMultiAddr(options.remoteAddr, ufrag, pwd) + options.log.trace('client derived server offer', answerSdp.sdp) + } + + 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.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..1a5740bdec --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/utils/generate-certificates.ts @@ -0,0 +1,49 @@ +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' +import type { TransportCertificate } from '../..' + +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..cb4de5cc7b --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/utils/generate-noise-prologue.ts @@ -0,0 +1,25 @@ +import * as multihashes from 'multihashes' +import { concat } from 'uint8arrays/concat' +import { fromString as uint8arrayFromString } from 'uint8arrays/from-string' +import * as sdp from './sdp.js' +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 (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], byteLength) + } + + return concat([prefix, remote, local], byteLength) +} 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-rtcpeerconnection.browser.ts b/packages/transport-webrtc/src/private-to-public/utils/get-rtcpeerconnection.browser.ts new file mode 100644 index 0000000000..043dcd7ba1 --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/utils/get-rtcpeerconnection.browser.ts @@ -0,0 +1,18 @@ +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 + // 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({ + ...(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..f5b98eaf4c --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/utils/get-rtcpeerconnection.ts @@ -0,0 +1,113 @@ +import { DescriptionType, 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(DescriptionType.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 | (() => RTCConfiguration | Promise), certificate?: TransportCertificate): Promise { + if (certificate == null) { + // 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 keyPair = await crypto.subtle.generateKey({ + name: 'ECDSA', + namedCurve: 'P-256' + }, true, ['sign', 'verify']) + + certificate = await generateTransportCertificate(keyPair, { + days: 365 + }) + } + + 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, + disableAutoNegotiation: true, + certificatePemFile: certificate.pem, + keyPemFile: certificate.privateKey, + maxMessageSize: 16384, + iceServers: toLibdatachannelIceServers(rtcConfig?.iceServers) ?? DEFAULT_STUN_SERVERS + }) + + return new DirectRTCPeerConnection({ + peerConnection, + ufrag + }) +} 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 57% 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..90c9361ae8 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,11 @@ +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 type { LoggerOptions } from '@libp2p/interface' -import type { Multiaddr } from '@multiformats/multiaddr' +import { inappropriateMultiaddr, invalidArgument, invalidFingerprint, unsupportedHashAlgorithm } from '../../error.js' +import { CERTHASH_CODE } from '../transport.js' import type { HashCode, HashName } from 'multihashes' /** @@ -12,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 } @@ -76,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 */ @@ -89,7 +80,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,14 +108,47 @@ 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, pwd: 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 ${port} 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:${pwd} +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, pwd: 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} @@ -126,20 +158,17 @@ 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 -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 +180,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/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/src/stream.ts b/packages/transport-webrtc/src/stream.ts index d875b2be40..d647e1e39a 100644 --- a/packages/transport-webrtc/src/stream.ts +++ b/packages/transport-webrtc/src/stream.ts @@ -1,13 +1,14 @@ 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 './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' @@ -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..49e6744e7f --- /dev/null +++ b/packages/transport-webrtc/test/compliance.spec.ts @@ -0,0 +1,81 @@ +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' +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 cff8a794b1..eba78b77d5 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' @@ -15,15 +15,16 @@ 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 () => { const ufrag = 'MyUserFragment' - const sdp = underTest.fromMultiAddr(sampleMultiAddr, ufrag) + const sdp = underTest.serverOfferFromMultiAddr(sampleMultiAddr, ufrag, ufrag) expect(sdp.sdp).to.contain(sampleSdp) }) @@ -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,11 +72,19 @@ 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) }) + + 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 cb40e8912e..7787e4e6b3 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) {