From eb61d74fe7c3205d0060d245421efd9a0fadf20b Mon Sep 17 00:00:00 2001 From: achingbrain Date: Fri, 7 Jun 2024 17:50:29 +0100 Subject: [PATCH] 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/listener.browser.ts | 28 ++ .../src/private-to-public/listener.ts | 380 ++++++++++++++++++ .../{ => private-to-public}/pb/message.proto | 0 .../src/{ => private-to-public}/pb/message.ts | 0 .../src/private-to-public/transport.ts | 108 ++--- .../src/private-to-public/util.ts | 41 +- .../utils/generate-certificates.browser.ts | 3 + .../utils/generate-certificates.ts | 57 +++ .../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 +- 18 files changed, 698 insertions(+), 94 deletions(-) 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 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/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/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..812d5a2903 --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/listener.ts @@ -0,0 +1,380 @@ +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 { generateNoisePrologue } from './util.js' +import { generateTransportCertificate, type TransportCertificate } from './utils/generate-certificates.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 ufrag = usernameAttribute?.value?.toString().split(':')[0] + + if (ufrag == null) { + this.log.trace('ufrag missing from incoming STUN message from %s:%s', rinfo.address, rinfo.port) + return + } + + 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/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..e45503b2d4 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 { WebRTCDirectListener } from './listener.js' +import { genUfrag, generateNoisePrologue } from './util.js' +// import { generateTransportCertificate } from './utils/generate-certificates.js' +import { createDialerRTCPeerConnection } from './utils/get-dialer-rtcpeerconnection.js' +import * as sdp from './utils/sdp.js' import type { WebRTCDialOptions } from './options.js' import type { DataChannelOptions } from '../index.js' +import type { CreateListenerOptions, Transport, Listener, ComponentLogger, Logger, Connection, CounterGroup, Metrics, PeerId, Startable } 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,6 +79,14 @@ export class WebRTCDirectTransport implements Transport { } } + async start (): Promise { + this.shutdownController = new AbortController() + } + + async stop (): Promise { + this.shutdownController.abort() + } + /** * Dial a given multiaddr */ @@ -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 + }) } /** @@ -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 = 'libp2p+webrtc+v1/' + 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/util.ts b/packages/transport-webrtc/src/private-to-public/util.ts index 31858d5888..9c09d4bcb3 100644 --- a/packages/transport-webrtc/src/private-to-public/util.ts +++ b/packages/transport-webrtc/src/private-to-public/util.ts @@ -1,2 +1,41 @@ -const charset = Array.from('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/') +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 './utils/sdp.js' +import type { Logger } from '@libp2p/interface' +import type { Multiaddr } from '@multiformats/multiaddr' +import type { HashCode } from 'multihashes' + +const charset = Array.from('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890') export const genUfrag = (len: number): string => [...Array(len)].map(() => charset.at(Math.floor(Math.random() * charset.length))).join('') + +/** + * 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/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/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) {