Skip to content

Commit da7353a

Browse files
authored
fix: rotate webrtc direct certificates (#3073)
Lowers the validity of webrtc direct certs to two weeks similar to webtransport and updates listening multiaddrs after the cert has been renewed. The validity is lowered because the longest `setTimeout` we can do is just under a month, otherwise we overflow the max value we can pass to `setTimeout`.
1 parent 4c64bd0 commit da7353a

File tree

5 files changed

+257
-71
lines changed

5 files changed

+257
-71
lines changed

packages/transport-webrtc/src/constants.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,9 @@ export const DEFAULT_CERTIFICATE_PRIVATE_KEY_TYPE = 'ECDSA'
129129
/**
130130
* How long the certificate is valid for
131131
*/
132-
export const DEFAULT_CERTIFICATE_LIFESPAN = 365
132+
export const DEFAULT_CERTIFICATE_LIFESPAN = 1_209_600_000
133+
134+
/**
135+
* Renew the certificate this long before it expires
136+
*/
137+
export const DEFAULT_CERTIFICATE_RENEWAL_THRESHOLD = 86_400_000

packages/transport-webrtc/src/private-to-public/listener.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export class WebRTCDirectListener extends TypedEventEmitter<ListenerEvents> impl
8484

8585
// inform the transport manager our addresses have changed
8686
init.emitter.addEventListener('certificate:renew', evt => {
87+
this.log('received new TLS certificate', evt.detail.certhash)
8788
this.certificate = evt.detail
8889
this.safeDispatchEvent('listening')
8990
})

packages/transport-webrtc/src/private-to-public/transport.ts

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { generateKeyPair, privateKeyToCryptoKeyPair } from '@libp2p/crypto/keys'
2-
import { NotFoundError, NotStartedError, TypedEventEmitter, serviceCapabilities, transportSymbol } from '@libp2p/interface'
2+
import { InvalidParametersError, NotFoundError, NotStartedError, TypedEventEmitter, serviceCapabilities, transportSymbol } from '@libp2p/interface'
33
import { peerIdFromString } from '@libp2p/peer-id'
44
import { WebRTCDirect } from '@multiformats/multiaddr-matcher'
55
import { BasicConstraintsExtension, X509Certificate, X509CertificateGenerator } from '@peculiar/x509'
@@ -9,7 +9,7 @@ import { sha256 } from 'multiformats/hashes/sha2'
99
import { raceSignal } from 'race-signal'
1010
import { equals as uint8ArrayEquals } from 'uint8arrays/equals'
1111
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
12-
import { DEFAULT_CERTIFICATE_DATASTORE_KEY, DEFAULT_CERTIFICATE_LIFESPAN, DEFAULT_CERTIFICATE_PRIVATE_KEY_NAME } from '../constants.js'
12+
import { DEFAULT_CERTIFICATE_DATASTORE_KEY, DEFAULT_CERTIFICATE_LIFESPAN, DEFAULT_CERTIFICATE_PRIVATE_KEY_NAME, DEFAULT_CERTIFICATE_RENEWAL_THRESHOLD } from '../constants.js'
1313
import { genUfrag } from '../util.js'
1414
import { WebRTCDirectListener } from './listener.js'
1515
import { connect } from './utils/connect.js'
@@ -23,8 +23,6 @@ import type { Keychain } from '@libp2p/keychain'
2323
import type { Multiaddr } from '@multiformats/multiaddr'
2424
import type { Datastore } from 'interface-datastore'
2525

26-
const ONE_DAY_MS = 86_400_000
27-
2826
export interface WebRTCDirectTransportComponents {
2927
peerId: PeerId
3028
privateKey: PrivateKey
@@ -88,16 +86,16 @@ export interface WebRTCTransportDirectInit {
8886
certificateKeychainName?: string
8987

9088
/**
91-
* Number of days a certificate should be valid for
89+
* Number of ms a certificate should be valid for (defaults to 14 days)
9290
*
93-
* @default 365
91+
* @default 2_592_000_000
9492
*/
9593
certificateLifespan?: number
9694

9795
/**
98-
* Certificates will be renewed this many days before their expiry
96+
* Certificates will be renewed this many ms before expiry (defaults to 1 day)
9997
*
100-
* @default 5
98+
* @default 86_400_000
10199
*/
102100
certificateRenewalThreshold?: number
103101
}
@@ -114,13 +112,18 @@ export class WebRTCDirectTransport implements Transport, Startable {
114112
private certificate?: TransportCertificate
115113
private privateKey?: PrivateKey
116114
private readonly emitter: TypedEventTarget<WebRTCDirectTransportCertificateEvents>
115+
private renewCertificateTask?: ReturnType<typeof setTimeout>
117116

118117
constructor (components: WebRTCDirectTransportComponents, init: WebRTCTransportDirectInit = {}) {
119118
this.log = components.logger.forComponent('libp2p:webrtc-direct')
120119
this.components = components
121120
this.init = init
122121
this.emitter = new TypedEventEmitter()
123122

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+
}
126+
124127
if (components.metrics != null) {
125128
this.metrics = {
126129
dialerEvents: components.metrics.registerCounterGroup('libp2p_webrtc-direct_dialer_events_total', {
@@ -144,7 +147,11 @@ export class WebRTCDirectTransport implements Transport, Startable {
144147
}
145148

146149
async stop (): Promise<void> {
150+
if (this.renewCertificateTask != null) {
151+
clearTimeout(this.renewCertificateTask)
152+
}
147153

154+
this.certificate = undefined
148155
}
149156

150157
/**
@@ -225,14 +232,14 @@ export class WebRTCDirectTransport implements Transport, Startable {
225232
}
226233
}
227234

228-
private async getCertificate (): Promise<TransportCertificate> {
235+
private async getCertificate (forceRenew?: boolean): Promise<TransportCertificate> {
229236
if (isTransportCertificate(this.init.certificate)) {
230-
this.log.trace('using provided TLS certificate')
237+
this.log('using provided TLS certificate')
231238
return this.init.certificate
232239
}
233240

234241
const privateKey = await this.loadOrCreatePrivateKey()
235-
const { pem, certhash } = await this.loadOrCreateCertificate(privateKey)
242+
const { pem, certhash } = await this.loadOrCreateCertificate(privateKey, forceRenew)
236243

237244
return {
238245
privateKey: await formatAsPem(privateKey),
@@ -276,8 +283,8 @@ export class WebRTCDirectTransport implements Transport, Startable {
276283
return this.privateKey
277284
}
278285

279-
private async loadOrCreateCertificate (privateKey: PrivateKey): Promise<{ pem: string, certhash: string }> {
280-
if (this.certificate != null) {
286+
private async loadOrCreateCertificate (privateKey: PrivateKey, forceRenew?: boolean): Promise<{ pem: string, certhash: string }> {
287+
if (this.certificate != null && forceRenew !== true) {
281288
return this.certificate
282289
}
283290

@@ -286,17 +293,45 @@ export class WebRTCDirectTransport implements Transport, Startable {
286293
const keyPair = await privateKeyToCryptoKeyPair(privateKey)
287294

288295
try {
296+
if (forceRenew === true) {
297+
this.log.trace('forcing renewal of TLS certificate')
298+
throw new NotFoundError()
299+
}
300+
289301
this.log.trace('checking for stored TLS certificate')
290302
cert = await this.loadCertificate(dsKey, keyPair)
291303
} catch (err: any) {
292304
if (err.name !== 'NotFoundError') {
293305
throw err
294306
}
295307

296-
this.log('generating TLS certificate using private key')
308+
this.log.trace('generating new TLS certificate')
297309
cert = await this.createCertificate(dsKey, keyPair)
298310
}
299311

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+
300335
return {
301336
pem: cert.toString('pem'),
302337
certhash: base64url.encode((await sha256.digest(new Uint8Array(cert.rawData))).bytes)
@@ -308,14 +343,16 @@ export class WebRTCDirectTransport implements Transport, Startable {
308343
const cert = new X509Certificate(buf)
309344

310345
// check expiry date
311-
const threshold = Date.now() - ((this.init.certificateLifespan ?? DEFAULT_CERTIFICATE_LIFESPAN) * ONE_DAY_MS)
346+
const expiryTime = cert.notAfter.getTime() - (this.init.certificateRenewalThreshold ?? DEFAULT_CERTIFICATE_RENEWAL_THRESHOLD)
312347

313-
if (cert.notAfter.getTime() < threshold) {
348+
if (Date.now() > expiryTime) {
314349
this.log('stored TLS certificate has expired')
315350
// act as if no certificate was present
316351
throw new NotFoundError()
317352
}
318353

354+
this.log('loaded certificate, expires in %d ms', expiryTime)
355+
319356
// check public keys match
320357
const exportedCertKey = await cert.publicKey.export(crypto)
321358
const rawCertKey = await crypto.subtle.exportKey('raw', exportedCertKey)
@@ -329,12 +366,14 @@ export class WebRTCDirectTransport implements Transport, Startable {
329366
throw new NotFoundError()
330367
}
331368

369+
this.log('loaded certificate, expiry time is %o', expiryTime)
370+
332371
return cert
333372
}
334373

335374
async createCertificate (dsKey: Key, keyPair: CryptoKeyPair): Promise<X509Certificate> {
336375
const notBefore = new Date()
337-
const notAfter = new Date(notBefore.getTime() + ((this.init.certificateLifespan ?? DEFAULT_CERTIFICATE_LIFESPAN) * ONE_DAY_MS))
376+
const notAfter = new Date(Date.now() + (this.init.certificateLifespan ?? DEFAULT_CERTIFICATE_LIFESPAN))
338377

339378
// have to set ms to 0 to work around https://github.com/PeculiarVentures/x509/issues/73
340379
notBefore.setMilliseconds(0)
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/* eslint-disable @typescript-eslint/no-floating-promises */
2+
3+
import { generateKeyPair } from '@libp2p/crypto/keys'
4+
import { start, stop } from '@libp2p/interface'
5+
import { keychain } from '@libp2p/keychain'
6+
import { defaultLogger } from '@libp2p/logger'
7+
import { peerIdFromPrivateKey } from '@libp2p/peer-id'
8+
import { multiaddr, type Multiaddr } from '@multiformats/multiaddr'
9+
import { expect } from 'aegir/chai'
10+
import { anySignal } from 'any-signal'
11+
import { MemoryDatastore } from 'datastore-core'
12+
import delay from 'delay'
13+
import { stubInterface } from 'sinon-ts'
14+
import { isNode, isElectronMain } from 'wherearewe'
15+
import { CODEC_CERTHASH } from '../src/constants.js'
16+
import { WebRTCDirectTransport } from '../src/private-to-public/transport.js'
17+
import type { WebRTCDirectTransportComponents } from '../src/private-to-public/transport.js'
18+
import type { Upgrader, Listener, Transport } from '@libp2p/interface'
19+
import type { TransportManager } from '@libp2p/interface-internal'
20+
21+
describe('WebRTCDirect Transport - certificates', () => {
22+
let components: WebRTCDirectTransportComponents
23+
let listener: Listener
24+
let upgrader: Upgrader
25+
let transport: Transport
26+
27+
beforeEach(async () => {
28+
const privateKey = await generateKeyPair('Ed25519')
29+
const datastore = new MemoryDatastore()
30+
const logger = defaultLogger()
31+
32+
components = {
33+
peerId: peerIdFromPrivateKey(privateKey),
34+
logger,
35+
transportManager: stubInterface<TransportManager>(),
36+
privateKey,
37+
upgrader: stubInterface<Upgrader>({
38+
createInboundAbortSignal: (signal) => {
39+
return anySignal([
40+
AbortSignal.timeout(5_000),
41+
signal
42+
])
43+
}
44+
}),
45+
datastore,
46+
keychain: keychain()({
47+
datastore,
48+
logger
49+
})
50+
}
51+
52+
upgrader = stubInterface<Upgrader>()
53+
})
54+
55+
afterEach(async () => {
56+
await listener?.close()
57+
await stop(transport)
58+
})
59+
60+
it('should reuse the same certificate after a restart', async function () {
61+
if (!isNode && !isElectronMain) {
62+
return this.skip()
63+
}
64+
65+
const ipv4 = multiaddr('/ip4/127.0.0.1/udp/0')
66+
67+
transport = new WebRTCDirectTransport(components)
68+
await start(transport)
69+
listener = transport.createListener({
70+
upgrader
71+
})
72+
await listener.listen(ipv4)
73+
74+
const certHashes1 = getCerthashes(listener.getAddrs())
75+
await listener.close()
76+
await stop(transport)
77+
78+
transport = new WebRTCDirectTransport(components)
79+
await start(transport)
80+
listener = transport.createListener({
81+
upgrader
82+
})
83+
await listener.listen(ipv4)
84+
85+
const certHashes2 = getCerthashes(listener.getAddrs())
86+
await listener.close()
87+
await stop(transport)
88+
89+
expect(certHashes1).to.have.lengthOf(1)
90+
expect(certHashes1).to.have.nested.property('[0]').that.is.a('string')
91+
expect(certHashes1).to.deep.equal(certHashes2)
92+
})
93+
94+
it('should renew certificate that expires while stopped', async function () {
95+
if (!isNode && !isElectronMain) {
96+
return this.skip()
97+
}
98+
99+
const ipv4 = multiaddr('/ip4/127.0.0.1/udp/0')
100+
101+
transport = new WebRTCDirectTransport(components, {
102+
certificateLifespan: 500,
103+
certificateRenewalThreshold: 100
104+
})
105+
await start(transport)
106+
listener = transport.createListener({
107+
upgrader
108+
})
109+
await listener.listen(ipv4)
110+
111+
const certHashes1 = getCerthashes(listener.getAddrs())
112+
113+
await listener.close()
114+
await stop(transport)
115+
116+
// wait fo the cert to expire
117+
await delay(1000)
118+
119+
await start(transport)
120+
listener = transport.createListener({
121+
upgrader
122+
})
123+
await listener.listen(ipv4)
124+
125+
const certHashes2 = getCerthashes(listener.getAddrs())
126+
127+
await listener.close()
128+
await stop(transport)
129+
130+
expect(certHashes1).to.have.lengthOf(1)
131+
expect(certHashes1).to.have.nested.property('[0]').that.is.a('string')
132+
expect(certHashes2).to.have.lengthOf(1)
133+
expect(certHashes2).to.have.nested.property('[0]').that.is.a('string')
134+
expect(certHashes1).to.not.deep.equal(certHashes2)
135+
})
136+
137+
it('should renew certificate before expiry', async function () {
138+
if (!isNode && !isElectronMain) {
139+
return this.skip()
140+
}
141+
142+
await stop(transport)
143+
144+
const ipv4 = multiaddr('/ip4/127.0.0.1/udp/0')
145+
146+
transport = new WebRTCDirectTransport({
147+
...components,
148+
datastore: new MemoryDatastore()
149+
}, {
150+
certificateLifespan: 2000,
151+
certificateRenewalThreshold: 1900
152+
})
153+
await start(transport)
154+
listener = transport.createListener({
155+
upgrader
156+
})
157+
await listener.listen(ipv4)
158+
159+
const certHashes1 = getCerthashes(listener.getAddrs())
160+
161+
// wait until the certificate is still valid but we are within the renewal
162+
// threshold
163+
await delay(1000)
164+
165+
const certHashes2 = getCerthashes(listener.getAddrs())
166+
167+
await listener.close()
168+
await stop(transport)
169+
170+
expect(certHashes1).to.have.lengthOf(1)
171+
expect(certHashes1).to.have.nested.property('[0]').that.is.a('string')
172+
expect(certHashes2).to.have.lengthOf(1)
173+
expect(certHashes2).to.have.nested.property('[0]').that.is.a('string')
174+
expect(certHashes1).to.not.deep.equal(certHashes2)
175+
})
176+
})
177+
178+
function getCerthashes (addrs: Multiaddr[]): string[] {
179+
const output: string[] = []
180+
181+
addrs
182+
.forEach(ma => {
183+
ma.stringTuples()
184+
.forEach(([key, value]) => {
185+
if (key === CODEC_CERTHASH && value != null) {
186+
output.push(value)
187+
}
188+
})
189+
})
190+
191+
return output
192+
}

0 commit comments

Comments
 (0)