diff --git a/packages/transport-webrtc/src/index.ts b/packages/transport-webrtc/src/index.ts index 4d3fa7489a..f71219372e 100644 --- a/packages/transport-webrtc/src/index.ts +++ b/packages/transport-webrtc/src/index.ts @@ -283,6 +283,7 @@ export interface TransportCertificate { * The hash of the certificate */ certhash: string + notAfter: number } export type { WebRTCTransportDirectInit, WebRTCDirectTransportComponents } diff --git a/packages/transport-webrtc/src/private-to-public/listener.ts b/packages/transport-webrtc/src/private-to-public/listener.ts index 4127169245..58419216f1 100644 --- a/packages/transport-webrtc/src/private-to-public/listener.ts +++ b/packages/transport-webrtc/src/private-to-public/listener.ts @@ -34,6 +34,8 @@ export interface WebRTCDirectListenerInit { dataChannel?: DataChannelOptions rtcConfiguration?: RTCConfiguration | (() => RTCConfiguration | Promise) useLibjuice?: boolean + certificateDuration?: number + certificateExpiryThreshold?: number } export interface WebRTCListenerMetrics { @@ -82,6 +84,15 @@ export class WebRTCDirectListener extends TypedEventEmitter impl } } + private isCertificateExpiring (): boolean { + if (this.certificate == null) return true + const expiryDate = this.certificate.notAfter + const now = new Date() + const timeToExpiry = expiryDate - now.getTime() + const threshold = this.init.certificateExpiryThreshold ?? 7 * 86400000 + return timeToExpiry < threshold + } + async listen (ma: Multiaddr): Promise { const { host, port } = ma.toOptions() @@ -133,20 +144,9 @@ export class WebRTCDirectListener extends TypedEventEmitter impl server: Promise.resolve() .then(async (): Promise => { // ensure we have a certificate - if (this.certificate == null) { + if (this.certificate == null || this.isCertificateExpiring()) { this.log.trace('creating TLS certificate') - const keyPair = await crypto.subtle.generateKey({ - name: 'ECDSA', - namedCurve: 'P-256' - }, true, ['sign', 'verify']) - - const certificate = await generateTransportCertificate(keyPair, { - days: 365 * 10 - }) - - if (this.certificate == null) { - this.certificate = certificate - } + await this.createAndSetCertificate() } if (port === 0) { @@ -171,6 +171,37 @@ export class WebRTCDirectListener extends TypedEventEmitter impl } } + private async createAndSetCertificate (): Promise { + const keyPair = await crypto.subtle.generateKey({ + name: 'ECDSA', + namedCurve: 'P-256' + }, true, ['sign', 'verify']) + + const certificate = await generateTransportCertificate(keyPair, { + days: this.init.certificateDuration ?? 365 * 10 + }) + + this.certificate = certificate + this.setCertificateExpiryTimeout() + } + + private setCertificateExpiryTimeout (): void { + if (this.certificate == null) { + return + } + + const expiryDate = new Date(this.certificate.notAfter) + const now = new Date() + const timeToExpiry = expiryDate.getTime() - now.getTime() + const timeoutDuration = timeToExpiry - 7 * 86400000 + + setTimeout(async () => { + this.log.trace('renewing TLS certificate') + await this.createAndSetCertificate() + this.safeDispatchEvent('listening') + }, timeoutDuration) + } + private async incomingConnection (ufrag: string, remoteHost: string, remotePort: number, signal: AbortSignal): Promise { const key = `${remoteHost}:${remotePort}:${ufrag}` let peerConnection = this.connections.get(key) 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 index 1e4008b327..f14437d721 100644 --- a/packages/transport-webrtc/src/private-to-public/utils/generate-certificates.ts +++ b/packages/transport-webrtc/src/private-to-public/utils/generate-certificates.ts @@ -46,6 +46,7 @@ export async function generateTransportCertificate (keyPair: CryptoKeyPair, opti return { privateKey: privateKeyPem, pem: cert.toString('pem'), - certhash: base64url.encode((await sha256.digest(new Uint8Array(cert.rawData))).bytes) + certhash: base64url.encode((await sha256.digest(new Uint8Array(cert.rawData))).bytes), + notAfter: notAfter.getTime() } } diff --git a/packages/transport-webrtc/test/certificate.spec.ts b/packages/transport-webrtc/test/certificate.spec.ts new file mode 100644 index 0000000000..0287655526 --- /dev/null +++ b/packages/transport-webrtc/test/certificate.spec.ts @@ -0,0 +1,94 @@ +import { defaultLogger } from '@libp2p/logger' +import { Crypto } from '@peculiar/webcrypto' +import { expect } from 'aegir/utils/chai.js' +import sinon from 'sinon' +import { stubInterface } from 'sinon-ts' +import { WebRTCDirectListener, type WebRTCDirectListenerInit } from '../src/private-to-public/listener.js' +import { type WebRTCDirectTransportComponents } from '../src/private-to-public/transport.js' +import { generateTransportCertificate } from '../src/private-to-public/utils/generate-certificates.js' +import type { TransportCertificate } from '../src/index.js' +import type { TransportManager } from '@libp2p/interface-internal' + +const crypto = new Crypto() + +describe('WebRTCDirectListener', () => { + let listener: WebRTCDirectListener + let components: WebRTCDirectTransportComponents + let init: WebRTCDirectListenerInit + + beforeEach(() => { + components = { + peerId: { toB58String: () => 'QmPeerId' } as any, + privateKey: {} as any, + logger: defaultLogger(), + transportManager: stubInterface() + } + + init = { + certificateDuration: 10, + upgrader: {} as any + } + + listener = new WebRTCDirectListener(components, init) + }) + + afterEach(() => { + sinon.restore() + }) + + it('should generate a certificate with the configured duration', async () => { + const keyPair = await crypto.subtle.generateKey({ + name: 'ECDSA', + namedCurve: 'P-256' + }, true, ['sign', 'verify']) + + const certificate: TransportCertificate = await generateTransportCertificate(keyPair, { + days: init.certificateDuration! + }) + + expect(new Date(certificate.notAfter).getTime()).to.be.closeTo( + new Date().getTime() + init.certificateDuration! * 86400000, + 1000 + ) + }) + + it('should re-emit listening event when a new certificate is generated', async () => { + const emitSpy = sinon.spy(listener as any, 'safeDispatchEvent') + const generateCertificateSpy = sinon.spy(generateTransportCertificate) + + await (listener as any).startUDPMuxServer('127.0.0.1', 0) + + expect(generateCertificateSpy.called).to.be.true + expect(emitSpy.calledWith('listening')).to.be.true + }) + + it('should generate a new certificate before expiry with default threshold', async () => { + init.certificateExpiryThreshold = undefined + listener = new WebRTCDirectListener(components, init) + ;(listener as any).certificate = { + notAfter: new Date(Date.now() + 5 * 86400000).getTime() + } + + const isCertificateExpiringSpy = sinon.spy(listener as any, 'isCertificateExpiring') + const generateCertificateSpy = sinon.spy(listener as any, 'createAndSetCertificate') + + await (listener as any).startUDPMuxServer('127.0.0.1', 0) + + expect(isCertificateExpiringSpy.returned(true)).to.be.true + expect(generateCertificateSpy.called).to.be.true + }) + + it('should generate a new certificate before expiry with custom threshold', async () => { + (listener as any).certificate = { + notAfter: new Date(Date.now() + 4 * 86400000).getTime() + } + + const isCertificateExpiringSpy = sinon.spy(listener as any, 'isCertificateExpiring') + const generateCertificateSpy = sinon.spy(listener as any, 'createAndSetCertificate') + + await (listener as any).startUDPMuxServer('127.0.0.1', 0) + + expect(isCertificateExpiringSpy.returned(true)).to.be.true + expect(generateCertificateSpy.called).to.be.true + }) +}) \ No newline at end of file