@libp2p/webrtc 5.2.9 → 5.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,20 +1,28 @@
1
- import { serviceCapabilities, transportSymbol } from '@libp2p/interface'
1
+ import { generateKeyPair, privateKeyToCryptoKeyPair } from '@libp2p/crypto/keys'
2
+ import { InvalidParametersError, NotFoundError, NotStartedError, TypedEventEmitter, serviceCapabilities, transportSymbol } from '@libp2p/interface'
2
3
  import { peerIdFromString } from '@libp2p/peer-id'
3
4
  import { WebRTCDirect } from '@multiformats/multiaddr-matcher'
5
+ import { BasicConstraintsExtension, X509Certificate, X509CertificateGenerator } from '@peculiar/x509'
6
+ import { Key } from 'interface-datastore'
7
+ import { base64url } from 'multiformats/bases/base64'
8
+ import { sha256 } from 'multiformats/hashes/sha2'
4
9
  import { raceSignal } from 'race-signal'
10
+ import { equals as uint8ArrayEquals } from 'uint8arrays/equals'
11
+ import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
12
+ import { DEFAULT_CERTIFICATE_DATASTORE_KEY, DEFAULT_CERTIFICATE_LIFESPAN, DEFAULT_CERTIFICATE_PRIVATE_KEY_NAME, DEFAULT_CERTIFICATE_RENEWAL_THRESHOLD } from '../constants.js'
5
13
  import { genUfrag } from '../util.js'
6
14
  import { WebRTCDirectListener } from './listener.js'
7
15
  import { connect } from './utils/connect.js'
8
16
  import { createDialerRTCPeerConnection } from './utils/get-rtcpeerconnection.js'
17
+ import { formatAsPem } from './utils/pem.js'
9
18
  import type { DataChannelOptions, TransportCertificate } from '../index.js'
10
19
  import type { WebRTCDialEvents } from '../private-to-private/transport.js'
11
- import type { CreateListenerOptions, Transport, Listener, ComponentLogger, Logger, Connection, CounterGroup, Metrics, PeerId, DialTransportOptions, PrivateKey, Upgrader } from '@libp2p/interface'
20
+ import type { CreateListenerOptions, Transport, Listener, ComponentLogger, Logger, Connection, CounterGroup, Metrics, PeerId, DialTransportOptions, PrivateKey, Upgrader, Startable, TypedEventTarget } from '@libp2p/interface'
12
21
  import type { TransportManager } from '@libp2p/interface-internal'
22
+ import type { Keychain } from '@libp2p/keychain'
13
23
  import type { Multiaddr } from '@multiformats/multiaddr'
24
+ import type { Datastore } from 'interface-datastore'
14
25
 
15
- /**
16
- * The peer for this transport
17
- */
18
26
  export interface WebRTCDirectTransportComponents {
19
27
  peerId: PeerId
20
28
  privateKey: PrivateKey
@@ -22,6 +30,8 @@ export interface WebRTCDirectTransportComponents {
22
30
  logger: ComponentLogger
23
31
  transportManager: TransportManager
24
32
  upgrader: Upgrader
33
+ keychain?: Keychain
34
+ datastore: Datastore
25
35
  }
26
36
 
27
37
  export interface WebRTCMetrics {
@@ -29,26 +39,90 @@ export interface WebRTCMetrics {
29
39
  }
30
40
 
31
41
  export interface WebRTCTransportDirectInit {
42
+ /**
43
+ * The default configuration used by all created RTCPeerConnections
44
+ */
32
45
  rtcConfiguration?: RTCConfiguration | (() => RTCConfiguration | Promise<RTCConfiguration>)
46
+
47
+ /**
48
+ * The default configuration used by all created RTCDataChannels
49
+ */
33
50
  dataChannel?: DataChannelOptions
51
+
52
+ /**
53
+ * @deprecated use `certificate` instead - this option will be removed in a future release
54
+ */
34
55
  certificates?: TransportCertificate[]
35
56
 
57
+ /**
58
+ * Use an existing TLS certificate to secure incoming connections or supply
59
+ * settings to generate one.
60
+ *
61
+ * This must be an ECDSA certificate using the P-256 curve.
62
+ *
63
+ * From our testing we find that P-256 elliptic curve is supported by Pion,
64
+ * webrtc-rs, as well as Chromium (P-228 and P-384 was not supported in
65
+ * Chromium).
66
+ */
67
+ certificate?: TransportCertificate
68
+
36
69
  /**
37
70
  * @deprecated this setting is ignored and will be removed in a future release
38
71
  */
39
72
  useLibjuice?: boolean
73
+
74
+ /**
75
+ * The key the certificate is stored in the datastore under
76
+ *
77
+ * @default '/libp2p/webrtc-direct/certificate'
78
+ */
79
+ certificateDatastoreKey?: string
80
+
81
+ /**
82
+ * The name the certificate private key is stored in the keychain with
83
+ *
84
+ * @default 'webrtc-direct-certificate-private-key'
85
+ */
86
+ certificateKeychainName?: string
87
+
88
+ /**
89
+ * Number of ms a certificate should be valid for (defaults to 14 days)
90
+ *
91
+ * @default 2_592_000_000
92
+ */
93
+ certificateLifespan?: number
94
+
95
+ /**
96
+ * Certificates will be renewed this many ms before expiry (defaults to 1 day)
97
+ *
98
+ * @default 86_400_000
99
+ */
100
+ certificateRenewalThreshold?: number
101
+ }
102
+
103
+ export interface WebRTCDirectTransportCertificateEvents {
104
+ 'certificate:renew': CustomEvent<TransportCertificate>
40
105
  }
41
106
 
42
- export class WebRTCDirectTransport implements Transport {
107
+ export class WebRTCDirectTransport implements Transport, Startable {
43
108
  private readonly log: Logger
44
109
  private readonly metrics?: WebRTCMetrics
45
110
  private readonly components: WebRTCDirectTransportComponents
46
111
  private readonly init: WebRTCTransportDirectInit
112
+ private certificate?: TransportCertificate
113
+ private privateKey?: PrivateKey
114
+ private readonly emitter: TypedEventTarget<WebRTCDirectTransportCertificateEvents>
115
+ private renewCertificateTask?: ReturnType<typeof setTimeout>
47
116
 
48
117
  constructor (components: WebRTCDirectTransportComponents, init: WebRTCTransportDirectInit = {}) {
49
118
  this.log = components.logger.forComponent('libp2p:webrtc-direct')
50
119
  this.components = components
51
120
  this.init = init
121
+ this.emitter = new TypedEventEmitter()
122
+
123
+ if (init.certificateLifespan != null && init.certificateRenewalThreshold != null && init.certificateRenewalThreshold >= init.certificateLifespan) {
124
+ throw new InvalidParametersError('Certificate renewal threshold must be less than certificate lifespan')
125
+ }
52
126
 
53
127
  if (components.metrics != null) {
54
128
  this.metrics = {
@@ -68,6 +142,18 @@ export class WebRTCDirectTransport implements Transport {
68
142
  '@libp2p/transport'
69
143
  ]
70
144
 
145
+ async start (): Promise<void> {
146
+ this.certificate = await this.getCertificate()
147
+ }
148
+
149
+ async stop (): Promise<void> {
150
+ if (this.renewCertificateTask != null) {
151
+ clearTimeout(this.renewCertificateTask)
152
+ }
153
+
154
+ this.certificate = undefined
155
+ }
156
+
71
157
  /**
72
158
  * Dial a given multiaddr
73
159
  */
@@ -81,9 +167,15 @@ export class WebRTCDirectTransport implements Transport {
81
167
  * Create transport listeners no supported by browsers
82
168
  */
83
169
  createListener (options: CreateListenerOptions): Listener {
170
+ if (this.certificate == null) {
171
+ throw new NotStartedError()
172
+ }
173
+
84
174
  return new WebRTCDirectListener(this.components, {
85
175
  ...this.init,
86
- ...options
176
+ ...options,
177
+ certificate: this.certificate,
178
+ emitter: this.emitter
87
179
  })
88
180
  }
89
181
 
@@ -139,4 +231,186 @@ export class WebRTCDirectTransport implements Transport {
139
231
  throw err
140
232
  }
141
233
  }
234
+
235
+ private async getCertificate (forceRenew?: boolean): Promise<TransportCertificate> {
236
+ if (isTransportCertificate(this.init.certificate)) {
237
+ this.log('using provided TLS certificate')
238
+ return this.init.certificate
239
+ }
240
+
241
+ const privateKey = await this.loadOrCreatePrivateKey()
242
+ const { pem, certhash } = await this.loadOrCreateCertificate(privateKey, forceRenew)
243
+
244
+ return {
245
+ privateKey: await formatAsPem(privateKey),
246
+ pem,
247
+ certhash
248
+ }
249
+ }
250
+
251
+ private async loadOrCreatePrivateKey (): Promise<PrivateKey> {
252
+ if (this.privateKey != null) {
253
+ return this.privateKey
254
+ }
255
+
256
+ const keychainName = this.init.certificateKeychainName ?? DEFAULT_CERTIFICATE_PRIVATE_KEY_NAME
257
+ const keychain = this.getKeychain()
258
+
259
+ try {
260
+ if (keychain == null) {
261
+ this.log('no keychain configured - not checking for stored private key')
262
+ throw new NotFoundError()
263
+ }
264
+
265
+ this.log.trace('checking for stored private key')
266
+ this.privateKey = await keychain.exportKey(keychainName)
267
+ } catch (err: any) {
268
+ if (err.name !== 'NotFoundError') {
269
+ throw err
270
+ }
271
+
272
+ this.log.trace('generating private key')
273
+ this.privateKey = await generateKeyPair('ECDSA', 'P-256')
274
+
275
+ if (keychain != null) {
276
+ this.log.trace('storing private key')
277
+ await keychain.importKey(keychainName, this.privateKey)
278
+ } else {
279
+ this.log('no keychain configured - not storing private key')
280
+ }
281
+ }
282
+
283
+ return this.privateKey
284
+ }
285
+
286
+ private async loadOrCreateCertificate (privateKey: PrivateKey, forceRenew?: boolean): Promise<{ pem: string, certhash: string }> {
287
+ if (this.certificate != null && forceRenew !== true) {
288
+ return this.certificate
289
+ }
290
+
291
+ let cert: X509Certificate
292
+ const dsKey = new Key(this.init.certificateDatastoreKey ?? DEFAULT_CERTIFICATE_DATASTORE_KEY)
293
+ const keyPair = await privateKeyToCryptoKeyPair(privateKey)
294
+
295
+ try {
296
+ if (forceRenew === true) {
297
+ this.log.trace('forcing renewal of TLS certificate')
298
+ throw new NotFoundError()
299
+ }
300
+
301
+ this.log.trace('checking for stored TLS certificate')
302
+ cert = await this.loadCertificate(dsKey, keyPair)
303
+ } catch (err: any) {
304
+ if (err.name !== 'NotFoundError') {
305
+ throw err
306
+ }
307
+
308
+ this.log.trace('generating new TLS certificate')
309
+ cert = await this.createCertificate(dsKey, keyPair)
310
+ }
311
+
312
+ // set timeout to renew certificate
313
+ let renewTime = (cert.notAfter.getTime() - (this.init.certificateRenewalThreshold ?? DEFAULT_CERTIFICATE_RENEWAL_THRESHOLD)) - Date.now()
314
+
315
+ if (renewTime < 0) {
316
+ renewTime = 100
317
+ }
318
+
319
+ this.log('will renew TLS certificate after %d ms', renewTime)
320
+
321
+ this.renewCertificateTask = setTimeout(() => {
322
+ this.log('renewing TLS certificate')
323
+ this.getCertificate(true)
324
+ .then(cert => {
325
+ this.certificate = cert
326
+ this.emitter.safeDispatchEvent('certificate:renew', {
327
+ detail: cert
328
+ })
329
+ })
330
+ .catch(err => {
331
+ this.log.error('could not renew certificate - %e', err)
332
+ })
333
+ }, renewTime)
334
+
335
+ return {
336
+ pem: cert.toString('pem'),
337
+ certhash: base64url.encode((await sha256.digest(new Uint8Array(cert.rawData))).bytes)
338
+ }
339
+ }
340
+
341
+ async loadCertificate (dsKey: Key, keyPair: CryptoKeyPair): Promise<X509Certificate> {
342
+ const buf = await this.components.datastore.get(dsKey)
343
+ const cert = new X509Certificate(buf)
344
+
345
+ // check expiry date
346
+ const expiryTime = cert.notAfter.getTime() - (this.init.certificateRenewalThreshold ?? DEFAULT_CERTIFICATE_RENEWAL_THRESHOLD)
347
+
348
+ if (Date.now() > expiryTime) {
349
+ this.log('stored TLS certificate has expired')
350
+ // act as if no certificate was present
351
+ throw new NotFoundError()
352
+ }
353
+
354
+ this.log('loaded certificate, expires in %d ms', expiryTime)
355
+
356
+ // check public keys match
357
+ const exportedCertKey = await cert.publicKey.export(crypto)
358
+ const rawCertKey = await crypto.subtle.exportKey('raw', exportedCertKey)
359
+ const rawKeyPairKey = await crypto.subtle.exportKey('raw', keyPair.publicKey)
360
+
361
+ if (!uint8ArrayEquals(
362
+ new Uint8Array(rawCertKey, 0, rawCertKey.byteLength),
363
+ new Uint8Array(rawKeyPairKey, 0, rawKeyPairKey.byteLength)
364
+ )) {
365
+ this.log('stored TLS certificate public key did not match public key from private key')
366
+ throw new NotFoundError()
367
+ }
368
+
369
+ this.log('loaded certificate, expiry time is %o', expiryTime)
370
+
371
+ return cert
372
+ }
373
+
374
+ async createCertificate (dsKey: Key, keyPair: CryptoKeyPair): Promise<X509Certificate> {
375
+ const notBefore = new Date()
376
+ const notAfter = new Date(Date.now() + (this.init.certificateLifespan ?? DEFAULT_CERTIFICATE_LIFESPAN))
377
+
378
+ // have to set ms to 0 to work around https://github.com/PeculiarVentures/x509/issues/73
379
+ notBefore.setMilliseconds(0)
380
+ notAfter.setMilliseconds(0)
381
+
382
+ const cert = await X509CertificateGenerator.createSelfSigned({
383
+ serialNumber: (BigInt(Math.random().toString().replace('.', '')) * 100000n).toString(16),
384
+ name: 'CN=example.com, C=US, L=CA, O=example, ST=CA',
385
+ notBefore,
386
+ notAfter,
387
+ keys: keyPair,
388
+ extensions: [
389
+ new BasicConstraintsExtension(false, undefined, true)
390
+ ]
391
+ }, crypto)
392
+
393
+ if (this.getKeychain() != null) {
394
+ this.log.trace('storing TLS certificate')
395
+ await this.components.datastore.put(dsKey, uint8ArrayFromString(cert.toString('pem')))
396
+ } else {
397
+ this.log('no keychain is configured so not storing TLS certificate since the private key will not be reused')
398
+ }
399
+
400
+ return cert
401
+ }
402
+
403
+ private getKeychain (): Keychain | undefined {
404
+ try {
405
+ return this.components.keychain
406
+ } catch {}
407
+ }
408
+ }
409
+
410
+ function isTransportCertificate (obj?: any): obj is TransportCertificate {
411
+ if (obj == null) {
412
+ return false
413
+ }
414
+
415
+ return typeof obj.privateKey === 'string' && typeof obj.pem === 'string' && typeof obj.certhash === 'string'
142
416
  }
@@ -0,0 +1,18 @@
1
+ import { privateKeyToCryptoKeyPair } from '@libp2p/crypto/keys'
2
+ import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
3
+ import type { PrivateKey } from '@libp2p/interface'
4
+
5
+ export function toBuffer (uint8Array: Uint8Array): Buffer {
6
+ return Buffer.from(uint8Array.buffer, uint8Array.byteOffset, uint8Array.byteLength)
7
+ }
8
+
9
+ export async function formatAsPem (privateKey: PrivateKey): Promise<string> {
10
+ const keyPair = await privateKeyToCryptoKeyPair(privateKey)
11
+ const exported = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey)
12
+
13
+ return [
14
+ '-----BEGIN PRIVATE KEY-----',
15
+ ...uint8ArrayToString(new Uint8Array(exported), 'base64pad').split(/(.{64})/).filter(Boolean),
16
+ '-----END PRIVATE KEY-----'
17
+ ].join('\n')
18
+ }