From 46667935cff401c778bcbb3ab1a96b4b2027622a Mon Sep 17 00:00:00 2001 From: Aryakoste Date: Mon, 24 Feb 2025 00:18:37 +0530 Subject: [PATCH 1/3] feat: Rotate WebRTC Direct certificates --- packages/transport-webrtc/src/index.ts | 1 + .../src/private-to-public/listener.ts | 19 +++-- .../utils/generate-certificates.ts | 3 +- .../transport-webrtc/test/certificate.spec.ts | 80 +++++++++++++++++++ 4 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 packages/transport-webrtc/test/certificate.spec.ts diff --git a/packages/transport-webrtc/src/index.ts b/packages/transport-webrtc/src/index.ts index 445df34172..0c3ca15d92 100644 --- a/packages/transport-webrtc/src/index.ts +++ b/packages/transport-webrtc/src/index.ts @@ -280,6 +280,7 @@ export interface TransportCertificate { * The hash of the certificate */ certhash: string + notAfter: string } 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 3c3b1a4849..eff77c7d1f 100644 --- a/packages/transport-webrtc/src/private-to-public/listener.ts +++ b/packages/transport-webrtc/src/private-to-public/listener.ts @@ -33,6 +33,7 @@ export interface WebRTCDirectListenerInit { dataChannel?: DataChannelOptions rtcConfiguration?: RTCConfiguration | (() => RTCConfiguration | Promise) useLibjuice?: boolean + certificateDuration?: number } export interface WebRTCListenerMetrics { @@ -83,6 +84,15 @@ export class WebRTCDirectListener extends TypedEventEmitter impl } } + private isCertificateExpiring (): boolean { + if (this.certificate == null) return true + const expiryDate = new Date(this.certificate.notAfter) + const now = new Date() + const timeToExpiry = expiryDate.getTime() - now.getTime() + const threshold = 30 * 24 * 60 * 60 * 1000 + return timeToExpiry < threshold + } + async listen (ma: Multiaddr): Promise { const parts = ma.stringTuples() const ipVersion = IP4.matches(ma) ? 4 : 6 @@ -154,7 +164,7 @@ 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', @@ -162,12 +172,11 @@ export class WebRTCDirectListener extends TypedEventEmitter impl }, true, ['sign', 'verify']) const certificate = await generateTransportCertificate(keyPair, { - days: 365 * 10 + days: this.init.certificateDuration ?? 365 * 10 }) + this.safeDispatchEvent('listening') - if (this.certificate == null) { - this.certificate = certificate - } + this.certificate = certificate } if (port === 0) { 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..99dc0038f2 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.toISOString() } } diff --git a/packages/transport-webrtc/test/certificate.spec.ts b/packages/transport-webrtc/test/certificate.spec.ts new file mode 100644 index 0000000000..7fb1509642 --- /dev/null +++ b/packages/transport-webrtc/test/certificate.spec.ts @@ -0,0 +1,80 @@ +import { Crypto } from '@peculiar/webcrypto' +import { expect } from 'aegir/utils/chai.js' +import sinon from 'sinon' +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' + +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: { + forComponent: () => ({ + trace: () => {}, + error: () => {} + }) + } as any, + transportManager: {} as any + } + + 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, 'emit') + const createCertificateSpy = sinon.spy(listener as any, 'createCertificate') + + await (listener as any).startUDPMuxServer('127.0.0.1', 0) + + expect(createCertificateSpy.calledOnce).to.be.true + expect(emitSpy.calledWith('listening')).to.be.true + }) + + it('should generate a new certificate before expiry', async () => { + (listener as any).certificate = { + notAfter: new Date(Date.now() + 5 * 86400000).toISOString() + } + + const isCertificateExpiringSpy = sinon.spy(listener as any, 'isCertificateExpiring') + const createCertificateSpy = sinon.spy(listener as any, 'createCertificate') + + await (listener as any).startUDPMuxServer('127.0.0.1', 0) + + expect(isCertificateExpiringSpy.returned(true)).to.be.true + expect(createCertificateSpy.calledOnce).to.be.true + }) +}) \ No newline at end of file From 2c545d8b187716e670d211f7cb993181fd81698b Mon Sep 17 00:00:00 2001 From: Aryakoste Date: Mon, 24 Feb 2025 00:35:18 +0530 Subject: [PATCH 2/3] changes to the test file --- packages/transport-webrtc/test/certificate.spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/transport-webrtc/test/certificate.spec.ts b/packages/transport-webrtc/test/certificate.spec.ts index 7fb1509642..f6115a4eb0 100644 --- a/packages/transport-webrtc/test/certificate.spec.ts +++ b/packages/transport-webrtc/test/certificate.spec.ts @@ -55,12 +55,12 @@ describe('WebRTCDirectListener', () => { }) it('should re-emit listening event when a new certificate is generated', async () => { - const emitSpy = sinon.spy(listener as any, 'emit') - const createCertificateSpy = sinon.spy(listener as any, 'createCertificate') + const emitSpy = sinon.spy(listener as any, 'safeDispatchEvent') + const generateCertificateSpy = sinon.spy(generateTransportCertificate) await (listener as any).startUDPMuxServer('127.0.0.1', 0) - expect(createCertificateSpy.calledOnce).to.be.true + expect(generateCertificateSpy.called).to.be.true expect(emitSpy.calledWith('listening')).to.be.true }) @@ -70,11 +70,11 @@ describe('WebRTCDirectListener', () => { } const isCertificateExpiringSpy = sinon.spy(listener as any, 'isCertificateExpiring') - const createCertificateSpy = sinon.spy(listener as any, 'createCertificate') + const generateCertificateSpy = sinon.spy(generateTransportCertificate) await (listener as any).startUDPMuxServer('127.0.0.1', 0) expect(isCertificateExpiringSpy.returned(true)).to.be.true - expect(createCertificateSpy.calledOnce).to.be.true + expect(generateCertificateSpy.called).to.be.true }) }) \ No newline at end of file From e219b6b3119186e182d31bc72561bdc0b2826639 Mon Sep 17 00:00:00 2001 From: Aryakoste Date: Sun, 16 Mar 2025 15:44:47 +0530 Subject: [PATCH 3/3] feat: configuratle threshold and timeout before certificate expiry --- packages/transport-webrtc/src/index.ts | 2 +- .../src/private-to-public/listener.ts | 50 +++++++++++++------ .../utils/generate-certificates.ts | 2 +- .../transport-webrtc/test/certificate.spec.ts | 34 +++++++++---- 4 files changed, 62 insertions(+), 26 deletions(-) diff --git a/packages/transport-webrtc/src/index.ts b/packages/transport-webrtc/src/index.ts index 0c3ca15d92..601385b021 100644 --- a/packages/transport-webrtc/src/index.ts +++ b/packages/transport-webrtc/src/index.ts @@ -280,7 +280,7 @@ export interface TransportCertificate { * The hash of the certificate */ certhash: string - notAfter: 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 eff77c7d1f..e2a0d577cd 100644 --- a/packages/transport-webrtc/src/private-to-public/listener.ts +++ b/packages/transport-webrtc/src/private-to-public/listener.ts @@ -34,6 +34,7 @@ export interface WebRTCDirectListenerInit { rtcConfiguration?: RTCConfiguration | (() => RTCConfiguration | Promise) useLibjuice?: boolean certificateDuration?: number + certificateExpiryThreshold?: number } export interface WebRTCListenerMetrics { @@ -86,10 +87,10 @@ export class WebRTCDirectListener extends TypedEventEmitter impl private isCertificateExpiring (): boolean { if (this.certificate == null) return true - const expiryDate = new Date(this.certificate.notAfter) + const expiryDate = this.certificate.notAfter const now = new Date() - const timeToExpiry = expiryDate.getTime() - now.getTime() - const threshold = 30 * 24 * 60 * 60 * 1000 + const timeToExpiry = expiryDate - now.getTime() + const threshold = this.init.certificateExpiryThreshold ?? 7 * 86400000 return timeToExpiry < threshold } @@ -166,17 +167,7 @@ export class WebRTCDirectListener extends TypedEventEmitter impl // ensure we have a certificate 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: this.init.certificateDuration ?? 365 * 10 - }) - this.safeDispatchEvent('listening') - - this.certificate = certificate + await this.createAndSetCertificate() } if (port === 0) { @@ -196,6 +187,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): 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 99dc0038f2..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 @@ -47,6 +47,6 @@ export async function generateTransportCertificate (keyPair: CryptoKeyPair, opti privateKey: privateKeyPem, pem: cert.toString('pem'), certhash: base64url.encode((await sha256.digest(new Uint8Array(cert.rawData))).bytes), - notAfter: notAfter.toISOString() + notAfter: notAfter.getTime() } } diff --git a/packages/transport-webrtc/test/certificate.spec.ts b/packages/transport-webrtc/test/certificate.spec.ts index f6115a4eb0..0287655526 100644 --- a/packages/transport-webrtc/test/certificate.spec.ts +++ b/packages/transport-webrtc/test/certificate.spec.ts @@ -1,10 +1,13 @@ +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() @@ -17,13 +20,8 @@ describe('WebRTCDirectListener', () => { components = { peerId: { toB58String: () => 'QmPeerId' } as any, privateKey: {} as any, - logger: { - forComponent: () => ({ - trace: () => {}, - error: () => {} - }) - } as any, - transportManager: {} as any + logger: defaultLogger(), + transportManager: stubInterface() } init = { @@ -64,13 +62,29 @@ describe('WebRTCDirectListener', () => { expect(emitSpy.calledWith('listening')).to.be.true }) - it('should generate a new certificate before expiry', async () => { + 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() + 5 * 86400000).toISOString() + notAfter: new Date(Date.now() + 4 * 86400000).getTime() } const isCertificateExpiringSpy = sinon.spy(listener as any, 'isCertificateExpiring') - const generateCertificateSpy = sinon.spy(generateTransportCertificate) + const generateCertificateSpy = sinon.spy(listener as any, 'createAndSetCertificate') await (listener as any).startUDPMuxServer('127.0.0.1', 0)