From ead19f2ac3520d6f882093c8c1aa2789adc0dd21 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Thu, 7 Sep 2023 15:23:02 +0100 Subject: [PATCH] feat: support listening on WebRTCDirect addresses in node.js Adds support for direct WebRTC connections via SDP munging. --- packages/transport-webrtc/package.json | 4 + .../src/private-to-public/listener.browser.ts | 24 + .../src/private-to-public/listener.ts | 347 +++++++++++++ .../src/private-to-public/sdp.ts | 5 +- .../src/private-to-public/transport.ts | 15 +- .../utils/generate-certificates.ts | 459 ++++++++++++++++++ .../src/private-to-public/utils/stun.ts | 312 ++++++++++++ .../src/webrtc/rtc-peer-connection.ts | 46 +- .../test/public-to-private.node.spec.ts | 143 ++++++ .../test/transport.browser.spec.ts | 4 +- 10 files changed, 1345 insertions(+), 14 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 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/stun.ts create mode 100644 packages/transport-webrtc/test/public-to-private.node.spec.ts diff --git a/packages/transport-webrtc/package.json b/packages/transport-webrtc/package.json index d3e19cb1e4..7072cef5df 100644 --- a/packages/transport-webrtc/package.json +++ b/packages/transport-webrtc/package.json @@ -51,8 +51,10 @@ "@libp2p/peer-id": "^3.0.2", "@multiformats/mafmt": "^12.1.2", "@multiformats/multiaddr": "^12.1.5", + "@types/ip": "^1.1.0", "abortable-iterator": "^5.0.1", "detect-browser": "^5.3.0", + "ip": "^1.1.8", "it-length-prefixed": "^9.0.1", "it-pipe": "^3.0.1", "it-protobuf-stream": "^1.0.0", @@ -62,6 +64,7 @@ "multiformats": "^12.0.1", "multihashes": "^4.0.3", "node-datachannel": "^0.4.3", + "node-forge": "^1.3.1", "p-defer": "^4.0.0", "p-event": "^6.0.0", "protons-runtime": "^5.0.0", @@ -85,6 +88,7 @@ "sinon-ts": "^1.0.0" }, "browser": { + "./dist/src/private-to-public/listener.js": "./dist/src/private-to-public/listener.browser.js", "./dist/src/webrtc/index.js": "./dist/src/webrtc/index.browser.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..562f114faf --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/listener.browser.ts @@ -0,0 +1,24 @@ +import { EventEmitter } from '@libp2p/interface/events' +import type { Listener, ListenerEvents } from '@libp2p/interface/transport' +import { unimplemented } from '../error.js' +import type { Multiaddr } from '@multiformats/multiaddr' + +export class WebRTCDirectListener extends EventEmitter implements Listener { + constructor () { + super() + + throw unimplemented('WebRTCDirectListener.constructor') + } + + async listen (multiaddr: Multiaddr): Promise { + throw unimplemented('WebRTCDirectListener.listen') + } + + getAddrs (): [] { + throw unimplemented('WebRTCDirectListener.getAddrs') + } + + async close (): Promise { + throw unimplemented('WebRTCDirectListener.close') + } +} 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..0c34fd1fb9 --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/listener.ts @@ -0,0 +1,347 @@ +import dgram from 'dgram' +import { EventEmitter, CustomEvent } from '@libp2p/interface/events' +import { logger } from '@libp2p/logger' +import type { MultiaddrConnection, Connection } from '@libp2p/interface/connection' +import type { CounterGroup, MetricGroup, Metrics } from '@libp2p/interface/metrics' +import type { Listener, ListenerEvents, Upgrader } from '@libp2p/interface/transport' +import { multiaddr, type Multiaddr } from '@multiformats/multiaddr' +import { base64url } from 'multiformats/bases/base64' +import { base16upper } from 'multiformats/bases/base16' +import { RTCPeerConnection } from '../webrtc/index.js' +import * as Digest from 'multiformats/hashes/digest' +import { sha256 } from 'multiformats/hashes/sha2' +import { decode, ATTR } from './utils/stun.js' + +const log = logger('libp2p:webrtc:listener') + +/** + * Attempts to close the given maConn. If a failure occurs, it will be logged + */ +async function attemptClose (maConn: MultiaddrConnection): Promise { + try { + await maConn.close() + } catch (err: any) { + log.error('an error occurred closing the connection', err) + maConn.abort(err) + } +} + +export interface CloseServerOnMaxConnectionsOpts { + /** Server listens once connection count is less than `listenBelow` */ + listenBelow: number + /** Close server once connection count is greater than or equal to `closeAbove` */ + closeAbove: number + onListenError?: (err: Error) => void +} + +interface WebRTCDirectListenerInit { + handler?: (conn: Connection) => void + upgrader: Upgrader + + reuseAddr?: boolean + ipv6Only?: boolean + recvBufferSize?: number + sendBufferSize?: number + lookup?: () => {} + + metrics?: Metrics +} + +const SERVER_STATUS_UP = 1 +const SERVER_STATUS_DOWN = 0 + +export interface WebRTCDirectListenerMetrics { + status: MetricGroup + errors: CounterGroup + events: CounterGroup +} + +export class WebRTCDirectListener extends EventEmitter implements Listener { + private socket?: dgram.Socket + /** Keep track of open connections to destroy in case of timeout */ + private readonly connections = new Set() + private metrics?: WebRTCDirectListenerMetrics + private shutDownController?: AbortController + private addr?: string + + private init: WebRTCDirectListenerInit + private certificates: RTCCertificate[] + private peerConnections: Map + + constructor (init: WebRTCDirectListenerInit) { + super() + + this.init = init + this.certificates = [] + this.peerConnections = new Map() + } +/* + private onSocket (socket: net.Socket): void { + // Avoid uncaught errors caused by unstable connections + socket.on('error', err => { + log('socket error', err) + this.metrics?.events.increment({ [`${this.addr} error`]: true }) + }) + + let maConn: MultiaddrConnection + try { + maConn = toMultiaddrConnection(socket, { + listeningAddr: this.status.started ? this.status.listeningAddr : undefined, + socketInactivityTimeout: this.context.socketInactivityTimeout, + socketCloseTimeout: this.context.socketCloseTimeout, + metrics: this.metrics?.events, + metricPrefix: `${this.addr} ` + }) + } catch (err) { + log.error('inbound connection failed', err) + this.metrics?.errors.increment({ [`${this.addr} inbound_to_connection`]: true }) + return + } + + log('new inbound connection %s', maConn.remoteAddr) + try { + this.context.upgrader.upgradeInbound(maConn) + .then((conn) => { + log('inbound connection upgraded %s', maConn.remoteAddr) + this.connections.add(maConn) + + socket.once('close', () => { + this.connections.delete(maConn) + + if ( + this.context.closeServerOnMaxConnections != null && + this.connections.size < this.context.closeServerOnMaxConnections.listenBelow + ) { + // The most likely case of error is if the port taken by this application is binded by + // another process during the time the server if closed. In that case there's not much + // we can do. netListen() will be called again every time a connection is dropped, which + // acts as an eventual retry mechanism. onListenError allows the consumer act on this. + this.netListen().catch(e => { + log.error('error attempting to listen server once connection count under limit', e) + this.context.closeServerOnMaxConnections?.onListenError?.(e as Error) + }) + } + }) + + if (this.context.handler != null) { + this.context.handler(conn) + } + + if ( + this.context.closeServerOnMaxConnections != null && + this.connections.size >= this.context.closeServerOnMaxConnections.closeAbove + ) { + this.netClose() + } + + this.dispatchEvent(new CustomEvent('connection', { detail: conn })) + }) + .catch(async err => { + log.error('inbound connection failed', err) + this.metrics?.errors.increment({ [`${this.addr} inbound_upgrade`]: true }) + + await attemptClose(maConn) + }) + .catch(err => { + log.error('closing inbound connection failed', err) + }) + } catch (err) { + log.error('inbound connection failed', err) + + attemptClose(maConn) + .catch(err => { + log.error('closing inbound connection failed', err) + this.metrics?.errors.increment({ [`${this.addr} inbound_closing_failed`]: true }) + }) + } + } +*/ + getAddrs (): Multiaddr[] { + const addressInfo = this.socket?.address() + + if (addressInfo == null) { + return [] + } + + const certs = this.certificates.map(cert => { + const encoded = `F${cert.getFingerprints()[0].value}`.replaceAll(':', '') + const buf = base16upper.decode(encoded) + const digest = Digest.create(sha256.code, buf) + + return base64url.encode(digest.bytes) + }) + + return [ + multiaddr(`/${addressInfo.family === 'IPv4' ? 'ip4' : 'ip6'}/${addressInfo.address}/udp/${addressInfo.port}/webrtc-direct/${certs.map(cert => `/certhash/${cert}`)}`) + ] + } + + async listen (ma: Multiaddr): Promise { + const addr = ma.nodeAddress() + let type: 'udp4' | 'udp6' + + if (addr.family === 4) { + type = 'udp4' + } else if (addr.family === 6) { + type = 'udp6' + } else { + throw new Error('can only listen on ip4 or ip6 addresses') + } + + this.shutDownController = new AbortController() + + const socket = this.socket = dgram.createSocket({ + type, + reuseAddr: this.init.reuseAddr, + ipv6Only: this.init.ipv6Only, + recvBufferSize: this.init.recvBufferSize, + sendBufferSize: this.init.sendBufferSize, + lookup: this.init.lookup, + signal: this.shutDownController.signal + }, this._onMessage.bind(this)) + + this.socket.on('error', (err) => { + log('socket error', err) + this.metrics?.events.increment({ [`${this.addr} error`]: true }) + this.dispatchEvent(new CustomEvent('close')) + }) + + this.socket.on('close', () => { + this.metrics?.status.update({ + [`${this.addr}`]: SERVER_STATUS_DOWN + }) + this.dispatchEvent(new CustomEvent('close')) + }) + + this.socket.on('listening', () => { + // we are listening, register metrics for our port + const address = socket.address() + + if (address == null) { + this.addr = 'unknown' + } else { + this.addr = `${address.address}:${address.port}` + } + + if (this.init.metrics != null) { + this.init.metrics.registerMetricGroup('libp2p_tcp_inbound_connections_total', { + label: 'address', + help: 'Current active connections in TCP listener', + calculate: () => { + return { + [`${this.addr}`]: this.connections.size + } + } + }) + + this.metrics = { + status: this.init.metrics.registerMetricGroup('libp2p_tcp_listener_status_info', { + label: 'address', + help: 'Current status of the WebRTCDirect listener socket' + }), + errors: this.init.metrics.registerMetricGroup('libp2p_tcp_listener_errors_total', { + label: 'address', + help: 'Total count of WebRTCDirect listener errors by type' + }), + events: this.init.metrics.registerMetricGroup('libp2p_tcp_listener_events_total', { + label: 'address', + help: 'Total count of WebRTCDirect listener events by type' + }) + } + + this.metrics?.status.update({ + [this.addr]: SERVER_STATUS_UP + }) + } + + this.dispatchEvent(new CustomEvent('listening')) + }) + + this.socket.bind(addr.port, addr.address) + + this.certificates = [ + await RTCPeerConnection.generateCertificate({ + name: 'ECDSA', + namedCurve: 'P-256' + }) + ] + } + + private _onMessage (message: Buffer, rinfo: RemoteInfo) { + Promise.resolve().then(async () => { + const stun = decode(message) + + if (stun == null) { + console.info('wat bad stun') + log.error('could not decode incoming STUN package') + return + } + + const ufrag = stun.attrs[ATTR.USERNAME].toString().split(':')[0] + + const key = `${rinfo.address}:${rinfo.port}:${ufrag}` + let peerConnection = this.peerConnections.get(key) + + if (peerConnection != null) { + return + } + peerConnection = new RTCPeerConnection({ + certificates: this.certificates + }) + + this.peerConnections.set(key, peerConnection) + + const offer = `v=0 +o=rtc 409579682 0 IN IP4 127.0.0.1 +s=- +t=0 0 +a=group:BUNDLE 0 +a=msid-semantic:WMS * +a=setup:actpass +a=ice-ufrag:${ufrag} +a=ice-pwd:${ufrag} +a=ice-options:ice2,trickle +a=fingerprint:SHA-256 89:3E:15:3A:40:EC:55:5B:8C:5A:7A:D5:D9:3A:F7:77:A7:EC:2D:DE:2F:CF:CB:CD:07:87:78:14:7C:D0:13:DD +m=application 9 UDP/DTLS/SCTP webrtc-datachannel +c=IN ${rinfo.family === 'IPv4' ? 'IP4' : 'IP6'} ${rinfo.address} ${rinfo.port} +a=mid:0 +a=sendrecv +a=sctp-port:5000 +a=max-message-size:16384 +` + + await peerConnection.setRemoteDescription({ type: 'offer', sdp: offer }) + + const answer = await peerConnection.createAnswer() + answer.sdp = answer.sdp + ?.replace(/\na=max-message-size:\d+\r\n/, '\na=max-message-size:16384\r\n') + + await peerConnection.setLocalDescription(answer) + + peerConnection.ondatachannel = (channel) => { + // perform noise handshake over first opened channel + } + }) + } + + async close (): Promise { + await Promise.all( + Array.from(this.connections.values()).map(async maConn => { await attemptClose(maConn) }) + ) + + // close peer connections + for (const connection of this.peerConnections.values()) { + connection.close() + } + + // close UDP socket + this.shutDownController?.abort() + } +} + +interface RemoteInfo { + address: string + family: 'IPv4' | 'IPv6' + port: number + size: number +} diff --git a/packages/transport-webrtc/src/private-to-public/sdp.ts b/packages/transport-webrtc/src/private-to-public/sdp.ts index c487c8c57d..2f8e395608 100644 --- a/packages/transport-webrtc/src/private-to-public/sdp.ts +++ b/packages/transport-webrtc/src/private-to-public/sdp.ts @@ -118,7 +118,7 @@ export function toSupportedHashFunction (name: multihashes.HashName): string { function ma2sdp (ma: Multiaddr, ufrag: string): string { const { host, port } = ma.toOptions() const ipVersion = ipv(ma) - const [CERTFP] = ma2Fingerprint(ma) + const [fingerPrint] = ma2Fingerprint(ma) return `v=0 o=- 0 0 IN ${ipVersion} ${host} @@ -131,7 +131,7 @@ a=mid:0 a=setup:passive a=ice-ufrag:${ufrag} a=ice-pwd:${ufrag} -a=fingerprint:${CERTFP} +a=fingerprint:${fingerPrint} a=sctp-port:5000 a=max-message-size:16384 a=candidate:1467250027 1 UDP 1467250027 ${host} ${port} typ host\r\n` @@ -158,5 +158,6 @@ export function munge (desc: RTCSessionDescriptionInit, ufrag: string): RTCSessi 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=max-message-size:\d+\r\n/, '\na=max-message-size:16384\r\n') return desc } diff --git a/packages/transport-webrtc/src/private-to-public/transport.ts b/packages/transport-webrtc/src/private-to-public/transport.ts index f824197378..a207629735 100644 --- a/packages/transport-webrtc/src/private-to-public/transport.ts +++ b/packages/transport-webrtc/src/private-to-public/transport.ts @@ -6,7 +6,7 @@ import { protocols } from '@multiformats/multiaddr' 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, invalidArgument } from '../error.js' import { WebRTCMultiaddrConnection } from '../maconn.js' import { DataChannelMuxerFactory } from '../muxer.js' import { createStream } from '../stream.js' @@ -20,6 +20,7 @@ import type { Connection } from '@libp2p/interface/connection' import type { CounterGroup, Metrics } from '@libp2p/interface/metrics' import type { PeerId } from '@libp2p/interface/peer-id' import type { Multiaddr } from '@multiformats/multiaddr' +import { WebRTCDirectListener } from './listener.js' const log = logger('libp2p:webrtc:transport') @@ -33,7 +34,7 @@ const HANDSHAKE_TIMEOUT_MS = 10_000 * * {@link https://github.com/multiformats/multiaddr/blob/master/protocols.csv} */ -export const WEBRTC_CODE: number = protocols('webrtc-direct').code +export const WEBRTC_DIRECT_CODE: number = protocols('webrtc-direct').code /** * Created by converting the hexadecimal protocol code to an integer. @@ -88,7 +89,7 @@ export class WebRTCDirectTransport implements Transport { * Create transport listeners no supported by browsers */ createListener (options: CreateListenerOptions): Listener { - throw unimplemented('WebRTCTransport.createListener') + return new WebRTCDirectListener(options) } /** @@ -130,8 +131,10 @@ export class WebRTCDirectTransport implements Transport { const certificate = await RTCPeerConnection.generateCertificate({ name: 'ECDSA', namedCurve: 'P-256', + + // TODO: this isn't part of the spec for ECDSA keys, only RSA - probably doesn't need to be here hash: sdp.toSupportedHashFunction(remoteCerthash.name) - } as any) + }) const peerConnection = new RTCPeerConnection({ certificates: [certificate] }) @@ -277,10 +280,10 @@ export class WebRTCDirectTransport implements Transport { } /** - * Determine if a given multiaddr contains a WebRTC Code (280), + * Determine if a given multiaddr contains a WebRTCDirect Code (280), * a Certhash Code (466) and a PeerId */ function validMa (ma: Multiaddr): boolean { const codes = ma.protoCodes() - return codes.includes(WEBRTC_CODE) && codes.includes(CERTHASH_CODE) && ma.getPeerId() != null && !codes.includes(protocols('p2p-circuit').code) + return codes.includes(WEBRTC_DIRECT_CODE) } 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..cb298e6aeb --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/utils/generate-certificates.ts @@ -0,0 +1,459 @@ +// nb. this will one day be needed by the WebTransport transport too +// at that point we should consider tidying it up and moving what's +// below to @libp2p/crypto + +// @ts-expect-error has no types, @types/node-forge is broken +import forge from 'node-forge' +import { webcrypto as crypto, X509Certificate } from 'crypto' +import * as Digest from 'multiformats/hashes/digest' +import { sha256 } from 'multiformats/hashes/sha2' +import type { MultihashDigest } from 'multiformats/hashes/interface' + +/** + * PEM format server certificate and private key + */ +export interface TLSCertificate { + privateKey: string + pem: string + hash: MultihashDigest<0x12> // sha-256 + secret: string +} + +const { pki, asn1, oids } = forge +// taken from node-forge +/** + * Converts an X.509 subject or issuer to an ASN.1 RDNSequence. + * + * @param obj - the subject or issuer (distinguished name). + * + * @returns the ASN.1 RDNSequence. + */ +function _dnToAsn1 (obj: any): any { + // create an empty RDNSequence + const rval = asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, []) + + // iterate over attributes + let attr, set + const attrs = obj.attributes + for (let i = 0; i < attrs.length; ++i) { + attr = attrs[i] + let value = attr.value + + // reuse tag class for attribute value if available + let valueTagClass = asn1.Type.PRINTABLESTRING + if ('valueTagClass' in attr) { + valueTagClass = attr.valueTagClass + + if (valueTagClass === asn1.Type.UTF8) { + value = forge.util.encodeUtf8(value) + } + // FIXME: handle more encodings + } + + // create a RelativeDistinguishedName set + // each value in the set is an AttributeTypeAndValue first + // containing the type (an OID) and second the value + set = asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SET, true, [ + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, [ + // AttributeType + asn1.create( + asn1.Class.UNIVERSAL, + asn1.Type.OID, + false, + asn1.oidToDer(attr.type).getBytes() + ), + // AttributeValue + asn1.create(asn1.Class.UNIVERSAL, valueTagClass, false, value) + ]) + ]) + rval.value.push(set) + } + + return rval +} + +// taken from node-forge almost not modified +/** + * Converts a Date object to ASN.1 + * Handles the different format before and after 1st January 2050 + * + * @param date - date object. + * + * @returns the ASN.1 object representing the date. + */ + +const jan11950 = new Date('1950-01-01T00:00:00Z') +const jan12050 = new Date('2050-01-01T00:00:00Z') +function _dateToAsn1 (date: Date): any { + if (date >= jan11950 && date < jan12050) { + return asn1.create( + asn1.Class.UNIVERSAL, + asn1.Type.UTCTIME, + false, + asn1.dateToUtcTime(date) + ) + } else { + return asn1.create( + asn1.Class.UNIVERSAL, + asn1.Type.GENERALIZEDTIME, + false, + asn1.dateToGeneralizedTime(date) + ) + } +} + +// taken from node-forge almost not modified +/** + * Convert signature parameters object to ASN.1 + * + * @param {string} oid - Signature algorithm OID + * @param params - The signature parametrs object + * @returns ASN.1 object representing signature parameters + */ +function _signatureParametersToAsn1 (oid: string, params: any): any { + const parts = [] + switch (oid) { + case oids['RSASSA-PSS']: + if (params.hash.algorithmOid !== undefined) { + parts.push( + asn1.create(asn1.Class.CONTEXT_SPECIFIC, 0, true, [ + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, [ + asn1.create( + asn1.Class.UNIVERSAL, + asn1.Type.OID, + false, + asn1.oidToDer(params.hash.algorithmOid).getBytes() + ), + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.NULL, false, '') + ]) + ]) + ) + } + + if (params.mgf.algorithmOid !== undefined) { + parts.push( + asn1.create(asn1.Class.CONTEXT_SPECIFIC, 1, true, [ + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, [ + asn1.create( + asn1.Class.UNIVERSAL, + asn1.Type.OID, + false, + asn1.oidToDer(params.mgf.algorithmOid).getBytes() + ), + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, [ + asn1.create( + asn1.Class.UNIVERSAL, + asn1.Type.OID, + false, + asn1.oidToDer(params.mgf.hash.algorithmOid).getBytes() + ), + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.NULL, false, '') + ]) + ]) + ]) + ) + } + + if (params.saltLength !== undefined) { + parts.push( + asn1.create(asn1.Class.CONTEXT_SPECIFIC, 2, true, [ + asn1.create( + asn1.Class.UNIVERSAL, + asn1.Type.INTEGER, + false, + asn1.integerToDer(params.saltLength).getBytes() + ) + ]) + ) + } + + return asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, parts) + default: + return asn1.create(asn1.Class.UNIVERSAL, asn1.Type.NULL, false, '') + } +} + +// taken from node-forge and modified to work with ECDSA +/** + * Gets the ASN.1 TBSCertificate part of an X.509v3 certificate. + * + * @param cert - the certificate. + * + * @returns the asn1 TBSCertificate. + */ +function getTBSCertificate (cert: PKICertificate): any { + // TBSCertificate + const notBefore = _dateToAsn1(cert.validity.notBefore) + const notAfter = _dateToAsn1(cert.validity.notAfter) + + const tbs = asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, [ + // version + asn1.create(asn1.Class.CONTEXT_SPECIFIC, 0, true, [ + // integer + asn1.create( + asn1.Class.UNIVERSAL, + asn1.Type.INTEGER, + false, + asn1.integerToDer(cert.version).getBytes() + ) + ]), + // serialNumber + asn1.create( + asn1.Class.UNIVERSAL, + asn1.Type.INTEGER, + false, + forge.util.hexToBytes(cert.serialNumber) + ), + // signature + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, [ + // algorithm + asn1.create( + asn1.Class.UNIVERSAL, + asn1.Type.OID, + false, + asn1.oidToDer(cert.siginfo.algorithmOid).getBytes() + ), + // parameters + _signatureParametersToAsn1( + cert.siginfo.algorithmOid, + cert.siginfo.parameters + ) + ]), + // issuer + _dnToAsn1(cert.issuer), + // validity + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, [ + notBefore, + notAfter + ]), + // subject + _dnToAsn1(cert.subject), + // SubjectPublicKeyInfo + // here comes our modification, we are other objects here + asn1.fromDer( + new forge.util.ByteBuffer( + cert.publicKey + ) /* is in already SPKI format but in DER encoding */ + ) + ]) + + if (cert.issuer.uniqueId !== '' && cert.issuer.uniqueId != null) { + // issuerUniqueID (optional) + tbs.value.push( + asn1.create(asn1.Class.CONTEXT_SPECIFIC, 1, true, [ + asn1.create( + asn1.Class.UNIVERSAL, + asn1.Type.BITSTRING, + false, + // TODO: support arbitrary bit length ids + String.fromCharCode(0x00) + cert.issuer.uniqueId + ) + ]) + ) + } + if (cert.subject.uniqueId !== '' && cert.subject.uniqueId != null) { + // subjectUniqueID (optional) + tbs.value.push( + asn1.create(asn1.Class.CONTEXT_SPECIFIC, 2, true, [ + asn1.create( + asn1.Class.UNIVERSAL, + asn1.Type.BITSTRING, + false, + // TODO: support arbitrary bit length ids + String.fromCharCode(0x00) + cert.subject.uniqueId + ) + ]) + ) + } + + if (cert.extensions.length > 0) { + // extensions (optional) + tbs.value.push(pki.certificateExtensionsToAsn1(cert.extensions)) + } + + return tbs +} + +// function taken form selfsigned +// a hexString is considered negative if it's most significant bit is 1 +// because serial numbers use ones' complement notation +// this RFC in section 4.1.2.2 requires serial numbers to be positive +// http://www.ietf.org/rfc/rfc5280.txt +function toPositiveHex (hexString: string): string { + let mostSiginficativeHexAsInt = parseInt(hexString[0], 16) + if (mostSiginficativeHexAsInt < 8) { + return hexString + } + + mostSiginficativeHexAsInt -= 8 + return mostSiginficativeHexAsInt.toString() + hexString.substring(1) +} + +export interface GenerateWebTransportCertificateOptions { + days?: number + start?: Date + extensions?: any[] +} + +export interface ForgeWebTransportCertificate { + private: string + public: string + privateRaw: Uint8Array + publicRaw: Uint8Array + cert: string + hash: Uint8Array + fingerprint: string +} + +interface PKICertificate { + serialNumber: string + validity: { + notBefore: Date + notAfter: Date + } + publicKey: ArrayBuffer + siginfo: { + algorithmOid: string + parameters: any + } + signatureOid: string + signature: ArrayBuffer + tbsCertificate: ArrayBuffer + md: ArrayBuffer + version: number + extensions: any[] + subject: { + uniqueId: string + } + issuer: { + uniqueId: string + } + + setSubject: (attrs: any) => void + setIssuer: (attrs: any) => void + setExtensions: (attrs: any) => void +} + +// the next is an edit of the selfsigned function reduced to the function necessary for webtransport +export async function generateTLSCertificate (attrs: Array<{ shortName: string, value: string }>, options: GenerateWebTransportCertificateOptions = {}): Promise { + const keyPair = await crypto.subtle.generateKey( + { + name: 'ECDSA', + namedCurve: 'P-256' + }, + true, + ['sign', 'verify'] + ) + + const cert: PKICertificate = pki.createCertificate() + + cert.serialNumber = toPositiveHex( + forge.util.bytesToHex(forge.random.getBytesSync(9)) + ) // the serial number can be decimal or hex (if preceded by 0x) + cert.validity.notBefore = options.start ?? new Date() + cert.validity.notAfter = new Date() + cert.validity.notAfter.setDate( + cert.validity.notBefore.getDate() + (options.days ?? 14) + ) // per spec only 14 days allowed + + cert.setSubject(attrs) + cert.setIssuer(attrs) + + const privateKey = crypto.subtle.exportKey('pkcs8', keyPair.privateKey) + const publicKey = (cert.publicKey = await crypto.subtle.exportKey( + 'spki', + keyPair.publicKey + )) + + cert.setExtensions( + (options.extensions != null) || [ + { + name: 'basicConstraints', + cA: true + }, + { + name: 'keyUsage', + keyCertSign: true, + digitalSignature: true, + nonRepudiation: true, + keyEncipherment: true, + dataEncipherment: true + }, + { + name: 'subjectAltName', + altNames: [ + { + type: 6, // URI + value: 'http://example.org/webid#me' + } + ] + } + ] + ) + + // to signing + // patch oids object + oids['1.2.840.10045.4.3.2'] = 'ecdsa-with-sha256' + oids['ecdsa-with-sha256'] = '1.2.840.10045.4.3.2' + + cert.siginfo.algorithmOid = cert.signatureOid = '1.2.840.10045.4.3.2' // 'ecdsa-with-sha256' + + cert.tbsCertificate = getTBSCertificate(cert) + const encoded = Buffer.from( + asn1.toDer(cert.tbsCertificate).getBytes(), + 'binary' + ) + cert.md = await crypto.subtle.digest('SHA-256', encoded) + cert.signature = await crypto.subtle.sign( + { + name: 'ECDSA', + hash: { name: 'SHA-256' } + }, + keyPair.privateKey, + encoded + ) + + const pemcert = pki.certificateToPem(cert) + + const x509cert = new X509Certificate(pemcert) + + const certhash = Buffer.from( + x509cert.fingerprint256.split(':').map((el) => parseInt(el, 16)) + ) + + const privateKeyBuffer = await privateKey + + const pem = { + private: forge.pem.encode({ + type: 'PRIVATE KEY', + body: new forge.util.ByteBuffer(privateKeyBuffer).getBytes() + }), + public: forge.pem.encode({ + type: 'PUBLIC KEY', + body: new forge.util.ByteBuffer(publicKey).getBytes() + }), + privateRaw: new Uint8Array(privateKeyBuffer, 0, privateKeyBuffer.byteLength), + publicRaw: new Uint8Array(publicKey, 0, publicKey.byteLength), + cert: pemcert, + hash: certhash, + fingerprint: x509cert.fingerprint256 + } + + return pem +} + +export async function generateTLSCertificates (attrs: Array<{ shortName: string, value: string }>, options: GenerateWebTransportCertificateOptions[] = []): Promise { + return await Promise.all( + options.map(async options => { + const certificate = await generateTLSCertificate(attrs, options) + const digest = Digest.create(sha256.code, certificate.hash) + + return { + privateKey: certificate.private, + pem: certificate.cert, + hash: digest, + secret: 'super-secret-shhhhhh' + } + }) + ) +} diff --git a/packages/transport-webrtc/src/private-to-public/utils/stun.ts b/packages/transport-webrtc/src/private-to-public/utils/stun.ts new file mode 100644 index 0000000000..91fcaecf4c --- /dev/null +++ b/packages/transport-webrtc/src/private-to-public/utils/stun.ts @@ -0,0 +1,312 @@ +import ip from 'ip' + +// Packet header length +export const HEADER_LENGTH = 20; +// STUN magic cookie +export const MAGIC_COOKIE = 0x2112A442; +// Max transaction ID (32bit) +export const TID_MAX = Math.pow(2,32); + +// Binding Class +export const BINDING_CLASS = 0x0001; +// STUN Method Mask +export const METHOD_MASK = 0x0110; +// STUN Method +export const METHOD = { + REQUEST: 0x0000, + INDICATION: 0x0010, + RESPONSE_S: 0x0100, + RESPONSE_E: 0x0110 +}; + +// Attributes +export const ATTR = { + MAPPED_ADDRESS: 0x0001, + USERNAME: 0x0006, + MESSAGE_INTEGRITY: 0x0008, + ERROR_CODE: 0x0009, + UNKNOWN_ATTRIBUTES: 0x000A, + REALM: 0x0014, + NONCE: 0x0015, + XOR_MAPPED_ADDRESS: 0x0020, + SOFTWARE: 0x8022, + ALTERNATE_SERVER: 0x8023, + FINGERPRINT: 0x8028 +}; + +// Error code +export const ERROR_CODE = { + 300: 'Try Alternate', + 400: 'Bad Request', + 401: 'Unauthorized', + 420: 'Unknown Attribute', + 438: 'Stale Nonce', + 500: 'Server Error' +}; + +interface StunHeader { + method: number + length: number + magic_cookie: number, + tid: number +} + +interface AddressInfo { + address: string + family: 4 | 6 + port: number +} + +// Packet Class +export class StunPacket { + class: number + method: number + attrs: Record + tid: number + + constructor (stun_class: number, method: number, attrs: Record) { + this.class = stun_class; + this.method = method; + this.attrs = attrs || {}; + this.tid = this._getTransactionId() + } + + /** + * Generate tansaction ID + */ + private _getTransactionId() { + return (Math.random() * TID_MAX) + } + + /** + * Encode packet + */ + encode () { + var encoded_attrs = this._encodeAttributes(); + var encoded_header = this._encodeHeader(encoded_attrs.length); + + return Buffer.concat([encoded_header, encoded_attrs]); + } + + /** + * Encode packet header + */ + private _encodeHeader (length: number ) { + var type = this.method | this.class; + var encoded_header = new Buffer(HEADER_LENGTH); + + encoded_header.writeUInt16BE((type & 0x3fff), 0); + encoded_header.writeUInt16BE(length, 2); + encoded_header.writeUInt32BE(MAGIC_COOKIE, 4); + encoded_header.writeUInt32BE(0, 8); + encoded_header.writeUInt32BE(0, 12); + encoded_header.writeUInt32BE(this.tid, 16); + + return encoded_header; + } + + /** + * Encode packet attributes + */ + private _encodeAttributes () { + var encoded_attrs = new Buffer(0); + + // TODO: Not implemented yet + + return encoded_attrs; + } + + /** + * Determines whether STUN Packet + */ + +} + +export function isStunPacket(buffer: Buffer): boolean { + var block = buffer.readUInt8(0); + var bit1 = block & 0x80; + var bit2 = block & 0x40; + + return (bit1 === 0 && bit2 === 0) ? true : false; +} + +/** + * Decode packet + */ +export function decode(buffer: Buffer) { + if (isStunPacket(buffer) === false) { + return null; + } + + var buffer_header = buffer.slice(0, HEADER_LENGTH); + var header = decodeHeader(buffer_header); + + var buffer_attrs = buffer.slice(HEADER_LENGTH, buffer.length); + var attrs = decodeAttributes(buffer_attrs, buffer_header); + + var packet = new StunPacket(BINDING_CLASS, header.method, attrs); + packet.tid = header.tid; + + return packet; +} + +/** + * Decode packet header + */ +function decodeHeader(buffer: Buffer): StunHeader { + return { + method: buffer.readUInt16BE(0) & METHOD_MASK, + length: buffer.readUInt16BE(2), + magic_cookie: buffer.readUInt32BE(4), + tid: buffer.readUInt32BE(16) + } +} + +/** + * Decode packet attributes + */ +function decodeAttributes(buffer: Buffer, buffer_header: Buffer) { + var attrs: Record = {}; + var offset = 0; + + while (offset < buffer.length) { + var type = buffer.readUInt16BE(offset); + offset += 2; + + var length = buffer.readUInt16BE(offset); + var block_out = length % 4; + if (block_out > 0) { + length += 4 - block_out; + } + offset += 2; + + var value: any = buffer.slice(offset, offset + length); + offset += length; + + switch (type) { + case ATTR.MAPPED_ADDRESS: + value = decodeMappedAddress(value); + break; + case ATTR.XOR_MAPPED_ADDRESS: + value = decodeXorMappedAddress(value, buffer_header); + break; + case ATTR.ERROR_CODE: + value = decodeErrorCode(value); + break; + case ATTR.UNKNOWN_ATTRIBUTES: + value = decodeUnknownAttributes(value); + break; + } + + attrs[type] = value; + } + + return attrs; +}; + +/** + * Decode MAPPED-ADDRESS value + */ +function decodeMappedAddress(buffer: Buffer): AddressInfo { + var family: 4 | 6 = (buffer.readUInt16BE(0) === 0x02) ? 6 : 4; + + return { + family: family, + port: buffer.readUInt16BE(2), + address: ip.toString(buffer, 4, family) + }; +} + +/** + * Decode XOR-MAPPED-ADDRESS value + * See https://tools.ietf.org/html/rfc5389#section-15.2 + */ +function decodeXorMappedAddress(buffer: Buffer, buffer_header: Buffer) { + var family = (buffer.readUInt16BE(0) === 0x02) ? 6 : 4; + + var magic = buffer_header.slice(4, 8); // BE + var tid = buffer_header.slice(8, 20); // BE + + var xport = buffer.slice(2, 4); // LE + var xaddr = buffer.slice(4, family === 4 ? 8 : 20); // LE + + var port = xor(xport, magic.slice(0, 2)); + var addr = xor(xaddr, family === 4 ? magic : Buffer.concat([magic, tid])) + + return { + family: family, + port: port.readUInt16BE(0), + address: ip.toString(addr, 0, family) + }; +} + +//// Decode XOR-MAPPED-ADDRESS value +//Packet._decodeXorMappedAddress = function _decodeMappedAddress(buffer, buffer_header) { +// var family = (buffer.readUInt16BE(0) === 0x02) ? 6 : 4; +// +// var xport = buffer.slice(2, 4); +// var xaddr = buffer.slice(4); +// +// var xorAddrBuf = buffer.slice(4, family === 4 ? 8 : 20); +// +// var port = this._xor(xport, new Buffer(Packet.MAGIC_COOKIE).slice(0, 2)); +// var addr; +// if (family === 4) { +// addr = this._xor(xaddr, new Buffer(Packet.MAGIC_COOKIE)); +// } else { +// var magic_buffer = new Buffer(Packet.MAGIC_COOKIE); +// var tid_buffer = new Buffer([tid]); +// addr = this._xor(xaddr, Buffer.concat([magic_buffer, tid_buffer])); +// } +// +// return { +// family: family, +// port: port, +// address: addr +// }; +//}; + +/** + * Decode ERROR-CODE value + */ +function decodeErrorCode(buffer: Buffer) { + var block = buffer.readUInt32BE(0); + var code = (block & 0x700) * 100 + block & 0xff; + var reason = buffer.readUInt32BE(4); + + return { + code: code, + reason: reason + }; +}; + +/** + * Decode UNKNOWN-ATTRIBUTES value + */ +function decodeUnknownAttributes(buffer: Buffer) { + var unknown_attrs = []; + var offset = 0; + + while (offset < buffer.length) { + unknown_attrs.push(buffer.readUInt16BE(offset)); + offset += 2; + } + + return unknown_attrs; +}; + +function xor(a: Buffer, b: Buffer): Buffer { + var data = []; + + if (b.length > a.length) { + var tmp = a; + a = b; + b = tmp; + } + + for (var i=0, len=a.length; i { - throw new Error('Not implemented') + static async generateCertificate (keygenAlgorithm: Algorithm | string): Promise { + if (typeof keygenAlgorithm === 'string' || keygenAlgorithm.name != 'ECDSA' || keygenAlgorithm.namedCurve != 'P-256') { + throw new Error('Not implemented') + } + + const days = 364 + + // generate a TLS certificate + const cert = await generateTLSCertificate([ + { shortName: 'C', value: 'N/a' }, + { shortName: 'ST', value: 'N/a' }, + { shortName: 'L', value: 'N/a' }, + { shortName: 'O', value: 'WebRTC Direct Dialer' }, + { shortName: 'CN', value: '0.0.0.0' } + ], { + // 365 days is the max value allowed + // https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/generateCertificate_static#certificate_expiration_time + days + }) + + return { + expires: Date.now() + (86400000 * days), + getFingerprints () { + return [{ + algorithm: 'sha-256', + value: cert.fingerprint + }] + } + } } canTrickleIceCandidates: boolean | null @@ -273,8 +307,12 @@ export class PeerConnection extends EventTarget implements RTCPeerConnection { throw new Error('Remote SDP must be set') } - // @ts-expect-error types are wrong - this.#peerConnection.setRemoteDescription(description.sdp, description.type) + this.#peerConnection.setRemoteDescription( + // https://github.com/paullouisageneau/libdatachannel/issues/968 + description.sdp.replaceAll('a=fingerprint:SHA-256', 'a=fingerprint:sha-256'), + // @ts-expect-error types are wrong + description.type + ) } } diff --git a/packages/transport-webrtc/test/public-to-private.node.spec.ts b/packages/transport-webrtc/test/public-to-private.node.spec.ts new file mode 100644 index 0000000000..64b5d3334f --- /dev/null +++ b/packages/transport-webrtc/test/public-to-private.node.spec.ts @@ -0,0 +1,143 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ + +import { noise } from '@chainsafe/libp2p-noise' +import { yamux } from '@chainsafe/libp2p-yamux' +import { WebRTCDirect } from '@multiformats/mafmt' +import { expect } from 'aegir/chai' +import map from 'it-map' +import { pipe } from 'it-pipe' +import toBuffer from 'it-to-buffer' +import { createLibp2p } from 'libp2p' +import { identifyService } from 'libp2p/identify' +import { webRTCDirect } from '../src/index.js' +import type { Libp2p } from '@libp2p/interface' +import type { Connection } from '@libp2p/interface/connection' + +async function createPublicNode (): Promise { + return createLibp2p({ + addresses: { + listen: [ + '/ip4/0.0.0.0/udp/0/webrtc-direct' + ] + }, + transports: [ + webRTCDirect() + ], + connectionEncryption: [ + noise() + ], + streamMuxers: [ + yamux() + ], + services: { + identify: identifyService() + }, + connectionGater: { + denyDialMultiaddr: () => false + }, + connectionManager: { + minConnections: 0 + } + }) +} + +async function createPrivateNode (): Promise { + return createLibp2p({ + transports: [ + webRTCDirect() + ], + connectionEncryption: [ + noise() + ], + streamMuxers: [ + yamux() + ], + services: { + identify: identifyService() + }, + connectionGater: { + denyDialMultiaddr: () => false + }, + connectionManager: { + minConnections: 0 + } + }) +} + +describe.only('WebRTCDirect basics', () => { + const echo = '/echo/1.0.0' + + let localNode: Libp2p + let remoteNode: Libp2p + + async function connectNodes (): Promise { + const remoteAddr = remoteNode.getMultiaddrs() + .filter(ma => WebRTCDirect.matches(ma)).pop() + + if (remoteAddr == null) { + throw new Error('Remote peer could not listen on relay') + } + + await remoteNode.handle(echo, ({ stream }) => { + void pipe( + stream, + stream + ) + }) + + return await localNode.dial(remoteAddr) + } + + beforeEach(async () => { + localNode = await createPrivateNode() + remoteNode = await createPublicNode() + }) + + afterEach(async () => { + if (localNode != null) { + await localNode.stop() + } + + if (remoteNode != null) { + await remoteNode.stop() + } + }) + + it('can dial a private node', async () => { + const connection = await connectNodes() + + // open a stream on the echo protocol + const stream = await connection.newStream(echo) + + // send and receive some data + const input = new Array(5).fill(0).map(() => new Uint8Array(10)) + const output = await pipe( + input, + stream, + (source) => map(source, list => list.subarray()), + async (source) => toBuffer(source) + ) + + // asset that we got the right data + expect(output).to.equalBytes(toBuffer(input)) + }) + + it('can send a large file to a private node', async () => { + const connection = await connectNodes() + + // open a stream on the echo protocol + const stream = await connection.newStream(echo) + + // send and receive some data + const input = new Array(5).fill(0).map(() => new Uint8Array(1024 * 1024)) + const output = await pipe( + input, + stream, + (source) => map(source, list => list.subarray()), + async (source) => toBuffer(source) + ) + + // asset that we got the right data + expect(output).to.equalBytes(toBuffer(input)) + }) +}) diff --git a/packages/transport-webrtc/test/transport.browser.spec.ts b/packages/transport-webrtc/test/transport.browser.spec.ts index ea7b56d545..7929315a4d 100644 --- a/packages/transport-webrtc/test/transport.browser.spec.ts +++ b/packages/transport-webrtc/test/transport.browser.spec.ts @@ -38,7 +38,7 @@ describe('WebRTC Transport', () => { const options = ignoredDialOption() // don't await as this isn't an e2e test - transport.dial(ma, options) + void transport.dial(ma, options) }) it('createListner throws', () => { @@ -77,7 +77,7 @@ describe('WebRTC Transport', () => { assert.isNotNull(result) expect(result.constructor.name).to.equal('Array') - expect(result).to.have.length(1) + expect(result).to.have.length(2) expect(result[0].equals(expected)).to.be.true() })