From 9a4c3cc88cb0831a4a8e1410db3fc1baeaf067d5 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Wed, 13 Nov 2024 18:40:42 +0000 Subject: [PATCH] chore: add tests --- packages/auto-tls/README.md | 4 + packages/auto-tls/package.json | 20 +- packages/auto-tls/src/auto-tls.browser.ts | 10 + packages/auto-tls/src/auto-tls.ts | 305 +++++++++++++------ packages/auto-tls/src/constants.ts | 11 + packages/auto-tls/src/domain-mapper.ts | 145 +++++++++ packages/auto-tls/src/errors.ts | 4 + packages/auto-tls/src/index.ts | 54 +++- packages/auto-tls/src/utils.ts | 101 ++++++ packages/auto-tls/test/domain-mapper.spec.ts | 138 +++++++++ packages/auto-tls/test/fixtures/ca.ts | 80 +++++ packages/auto-tls/test/fixtures/cert.ts | 155 ++++++++++ packages/auto-tls/test/index.spec.ts | 272 ++++++++++++++++- packages/auto-tls/test/utils.spec.ts | 92 ++++++ packages/interface/src/index.ts | 5 +- 15 files changed, 1293 insertions(+), 103 deletions(-) create mode 100644 packages/auto-tls/src/auto-tls.browser.ts create mode 100644 packages/auto-tls/src/constants.ts create mode 100644 packages/auto-tls/src/domain-mapper.ts create mode 100644 packages/auto-tls/src/errors.ts create mode 100644 packages/auto-tls/src/utils.ts create mode 100644 packages/auto-tls/test/domain-mapper.spec.ts create mode 100644 packages/auto-tls/test/fixtures/ca.ts create mode 100644 packages/auto-tls/test/fixtures/cert.ts create mode 100644 packages/auto-tls/test/utils.spec.ts diff --git a/packages/auto-tls/README.md b/packages/auto-tls/README.md index 589e14715e..f6bd613536 100644 --- a/packages/auto-tls/README.md +++ b/packages/auto-tls/README.md @@ -43,6 +43,8 @@ It also requires the Identify protocol. import { noise } from '@chainsafe/libp2p-noise' import { yamux } from '@chainsafe/libp2p-yamux' import { autoTLS } from '@libp2p/auto-tls' +import { identify } from '@libp2p/identify' +import { keychain } from '@libp2p/keychain' import { webSockets } from '@libp2p/websockets' import { uPnPNAT } from '@libp2p/upnp-nat' import { createLibp2p } from 'libp2p' @@ -64,6 +66,8 @@ const node = await createLibp2p({ ], services: { autoTLS: autoTLS(), + identify: identify(), + keychain: keychain(), upnp: uPnPNAT() } }) diff --git a/packages/auto-tls/package.json b/packages/auto-tls/package.json index 5e6a50bf76..e746b33187 100644 --- a/packages/auto-tls/package.json +++ b/packages/auto-tls/package.json @@ -46,19 +46,33 @@ "doc-check": "aegir doc-check" }, "dependencies": { + "@chainsafe/is-ip": "^2.0.2", + "@libp2p/crypto": "^5.0.6", "@libp2p/http-fetch": "^2.0.2", "@libp2p/interface": "^2.2.0", "@libp2p/interface-internal": "^2.0.10", + "@libp2p/keychain": "^5.0.9", "@libp2p/utils": "^6.1.3", "@multiformats/multiaddr": "^12.3.1", "@multiformats/multiaddr-matcher": "^1.4.0", - "@peculiar/webcrypto": "^1.5.0", "@peculiar/x509": "^1.12.3", "acme-client": "^5.4.0", - "multiformats": "^13.3.1" + "interface-datastore": "^8.3.1", + "multiformats": "^13.3.1", + "uint8arrays": "^5.1.0" }, "devDependencies": { - "aegir": "^44.0.1" + "@libp2p/logger": "^5.1.3", + "@libp2p/peer-id": "^5.0.7", + "aegir": "^44.0.1", + "datastore-core": "^10.0.2", + "delay": "^6.0.0", + "p-event": "^6.0.1", + "sinon": "^19.0.2", + "sinon-ts": "^2.0.0" + }, + "browser": { + "./dist/src/auto-tls.js": "./dist/src/auto-tls.browser.js" }, "sideEffects": false } diff --git a/packages/auto-tls/src/auto-tls.browser.ts b/packages/auto-tls/src/auto-tls.browser.ts new file mode 100644 index 0000000000..e5e7df36f9 --- /dev/null +++ b/packages/auto-tls/src/auto-tls.browser.ts @@ -0,0 +1,10 @@ +import type { AutoTLS as AutoTLSInterface } from './index.js' +import type { TLSCertificate } from '@libp2p/interface' + +export class AutoTLS implements AutoTLSInterface { + public certificate?: TLSCertificate + + constructor () { + throw new Error('Auto-TLS does not work in browsers') + } +} diff --git a/packages/auto-tls/src/auto-tls.ts b/packages/auto-tls/src/auto-tls.ts index ef9d09f514..ee7c61dfca 100644 --- a/packages/auto-tls/src/auto-tls.ts +++ b/packages/auto-tls/src/auto-tls.ts @@ -1,27 +1,38 @@ import { ClientAuth } from '@libp2p/http-fetch/auth' -import { serviceDependencies, stop } from '@libp2p/interface' +import { serviceDependencies, start, stop } from '@libp2p/interface' import { debounce } from '@libp2p/utils/debounce' -import { isLoopback } from '@libp2p/utils/multiaddr/is-loopback' -import { isPrivate } from '@libp2p/utils/multiaddr/is-private' -import { QUICV1, TCP, WebSockets, WebSocketsSecure, WebTransport } from '@multiformats/multiaddr-matcher' -import { Crypto } from '@peculiar/webcrypto' -import * as x509 from '@peculiar/x509' -import * as acmeClient from 'acme-client' +import { X509Certificate } from '@peculiar/x509' +import * as acme from 'acme-client' +import { Key } from 'interface-datastore' import { base36 } from 'multiformats/bases/base36' +import { equals as uint8ArrayEquals } from 'uint8arrays/equals' +import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' +import { DEFAULT_ACCOUNT_PRIVATE_KEY_BITS, DEFAULT_ACCOUNT_PRIVATE_KEY_NAME, DEFAULT_ACME_DIRECTORY, DEFAULT_CERTIFICATE_DATASTORE_KEY, DEFAULT_CERTIFICATE_PRIVATE_KEY_BITS, DEFAULT_CERTIFICATE_PRIVATE_KEY_NAME, DEFAULT_FORGE_DOMAIN, DEFAULT_FORGE_ENDPOINT, DEFAULT_PROVISION_DELAY, DEFAULT_PROVISION_TIMEOUT, DEFAULT_RENEWAL_THRESHOLD } from './constants.js' +import { DomainMapper } from './domain-mapper.js' +import { importFromPem, loadOrCreateKey, supportedAddressesFilter } from './utils.js' import type { AutoTLSComponents, AutoTLSInit, AutoTLS as AutoTLSInterface } from './index.js' -import type { PeerId, PrivateKey, Logger, TypedEventTarget, Libp2pEvents, AbortOptions, TLSCertificate } from '@libp2p/interface' +import type { PeerId, PrivateKey, Logger, TypedEventTarget, Libp2pEvents, AbortOptions } from '@libp2p/interface' import type { AddressManager } from '@libp2p/interface-internal' +import type { Keychain } from '@libp2p/keychain' import type { DebouncedFunction } from '@libp2p/utils/debounce' import type { Multiaddr } from '@multiformats/multiaddr' - -const crypto = new Crypto() -x509.cryptoProvider.set(crypto) +import type { Datastore } from 'interface-datastore' +import type { Buffer } from 'node:buffer' type CertificateEvent = 'certificate:provision' | 'certificate:renew' +interface Certificate { + key: string + cert: string + notAfter: Date +} + export class AutoTLS implements AutoTLSInterface { private readonly log: Logger private readonly addressManager: AddressManager + private readonly keychain: Keychain + private readonly datastore: Datastore private readonly privateKey: PrivateKey private readonly peerId: PeerId private readonly events: TypedEventTarget @@ -29,14 +40,22 @@ export class AutoTLS implements AutoTLSInterface { private readonly forgeDomain: string private readonly acmeDirectory: string private readonly clientAuth: ClientAuth - private readonly timeout: number + private readonly provisionTimeout: number private readonly renewThreshold: number private started: boolean private shutdownController?: AbortController - public certificate?: TLSCertificate + public certificate?: Certificate private fetching: boolean private readonly fetchCertificates: DebouncedFunction private renewTimeout?: ReturnType + private readonly accountPrivateKeyName: string + private readonly accountPrivateKeyBits: number + private readonly certificatePrivateKeyName: string + private readonly certificatePrivateKeyBits: number + private readonly certificateDatastoreKey: string + private readonly email + private readonly domain + private readonly domainMapper: DomainMapper constructor (components: AutoTLSComponents, init: AutoTLSInit = {}) { this.log = components.logger.forComponent('libp2p:certificate-manager') @@ -44,20 +63,37 @@ export class AutoTLS implements AutoTLSInterface { this.privateKey = components.privateKey this.peerId = components.peerId this.events = components.events - this.forgeEndpoint = init.forgeEndpoint ?? 'registration.libp2p.direct' - this.forgeDomain = init.forgeDomain ?? 'libp2p.direct' - this.acmeDirectory = init.acmeDirectory ?? 'https://acme-v02.api.letsencrypt.org/directory' - this.timeout = init.timeout ?? 10000 - this.renewThreshold = init.renewThreshold ?? 60000 + this.keychain = components.keychain + this.datastore = components.datastore + this.forgeEndpoint = init.forgeEndpoint ?? DEFAULT_FORGE_ENDPOINT + this.forgeDomain = init.forgeDomain ?? DEFAULT_FORGE_DOMAIN + this.acmeDirectory = init.acmeDirectory ?? DEFAULT_ACME_DIRECTORY + this.provisionTimeout = init.provisionTimeout ?? DEFAULT_PROVISION_TIMEOUT + this.renewThreshold = init.renewThreshold ?? DEFAULT_RENEWAL_THRESHOLD + this.accountPrivateKeyName = init.accountPrivateKeyName ?? DEFAULT_ACCOUNT_PRIVATE_KEY_NAME + this.accountPrivateKeyBits = init.accountPrivateKeyBits ?? DEFAULT_ACCOUNT_PRIVATE_KEY_BITS + this.certificatePrivateKeyName = init.certificatePrivateKeyName ?? DEFAULT_CERTIFICATE_PRIVATE_KEY_NAME + this.certificatePrivateKeyBits = init.certificatePrivateKeyBits ?? DEFAULT_CERTIFICATE_PRIVATE_KEY_BITS + this.certificateDatastoreKey = init.certificateDatastoreKey ?? DEFAULT_CERTIFICATE_DATASTORE_KEY this.clientAuth = new ClientAuth(this.privateKey) this.started = false this.fetching = false - this.fetchCertificates = debounce(this._fetchCertificates.bind(this), init.delay ?? 5000) + this.fetchCertificates = debounce(this._fetchCertificates.bind(this), init.provisionDelay ?? DEFAULT_PROVISION_DELAY) + + const base36EncodedPeer = base36.encode(this.peerId.toCID().bytes) + this.domain = `${base36EncodedPeer}.${this.forgeDomain}` + this.email = `${base36EncodedPeer}@${this.forgeDomain}` + + this.domainMapper = new DomainMapper(components, { + ...init, + domain: this.domain + }) } get [serviceDependencies] (): string[] { return [ - '@libp2p/identify' + '@libp2p/identify', + '@libp2p/keychain' ] } @@ -66,6 +102,7 @@ export class AutoTLS implements AutoTLSInterface { return } + await start(this.domainMapper) this.events.addEventListener('self:peer:update', this.fetchCertificates) this.shutdownController = new AbortController() this.started = true @@ -75,91 +112,171 @@ export class AutoTLS implements AutoTLSInterface { this.events.removeEventListener('self:peer:update', this.fetchCertificates) this.shutdownController?.abort() clearTimeout(this.renewTimeout) - await stop(this.fetchCertificates) + await stop(this.fetchCertificates, this.domainMapper) this.started = false } private _fetchCertificates (): void { - if (this.fetching || this.certificate != null) { - this.log('already fetching or already have a certificate') + const addresses = this.addressManager.getAddresses().filter(supportedAddressesFilter) + + if (addresses.length === 0) { + this.log('not fetching certificate as we have no public addresses') return } - const addresses = this.addressManager - .getAddresses() - .filter(ma => !isPrivate(ma) && !isLoopback(ma) && ( - TCP.exactMatch(ma) || - WebSockets.exactMatch(ma) || - WebSocketsSecure.exactMatch(ma) || - QUICV1.exactMatch(ma) || - WebTransport.exactMatch(ma) - )) + if (!this.needsRenewal(this.certificate?.notAfter)) { + this.log('certificate does not need renewal') + return + } - if (addresses.length === 0) { - this.log('not fetching certificate as we have no public addresses') + if (this.fetching) { + this.log('already fetching') return } this.fetching = true this.fetchCertificate(addresses, { - signal: AbortSignal.timeout(this.timeout) + signal: AbortSignal.timeout(this.provisionTimeout) }) .catch(err => { - this.log.error('error fetching certificates %e', err) + this.log.error('error fetching certificates - %e', err) }) .finally(() => { this.fetching = false }) } - private async fetchCertificate (mulitaddrs: Multiaddr[], options?: AbortOptions): Promise { + private async fetchCertificate (multiaddrs: Multiaddr[], options?: AbortOptions): Promise { this.log('fetching certificate') // TODO: handle rate limit errors like "too many new registrations (10) from this IP address in the last 3h0m0s, retry after 2024-11-01 09:22:38 UTC: see https://letsencrypt.org/docs/rate-limits/#new-registrations-per-ip-address" - const base36EncodedPeer = base36.encode(this.peerId.toCID().bytes) - const domain = `${base36EncodedPeer}.${this.forgeDomain}` + const certificatePrivateKey = await loadOrCreateKey(this.keychain, this.certificatePrivateKeyName, this.certificatePrivateKeyBits) + const { pem, cert } = await this.loadOrCreateCertificate(certificatePrivateKey, multiaddrs, options) - // Create CSR - const [certificatePrivateKey, csr] = await acmeClient.forge.createCsr({ - commonName: domain, - altNames: [] + let event: CertificateEvent = 'certificate:provision' + + if (this.certificate != null) { + event = 'certificate:renew' + } + + this.certificate = { + key: certificatePrivateKey, + cert: pem, + notAfter: cert.notAfter + } + + const renewAt = new Date(cert.notAfter.getTime() - this.renewThreshold) + + this.log('certificate expiry %s - renewing at %s', cert.notAfter, renewAt) + + // schedule renewing the certificate + clearTimeout(this.renewTimeout) + this.renewTimeout = setTimeout(() => { + Promise.resolve() + .then(async () => { + this.certificate = undefined + this.fetchCertificates() + }) + .catch(err => { + this.log.error('error renewing certificate - %e', err) + }) + }, Math.min(renewAt.getTime() - Date.now(), Math.pow(2, 31) - 1)) + + // emit a certificate event + this.events.safeDispatchEvent(event, { + detail: this.certificate }) + } + + private async loadOrCreateCertificate (certificatePrivateKey: string, multiaddrs: Multiaddr[], options?: AbortOptions): Promise<{ pem: string, cert: X509Certificate }> { + const existingCertificate = await this.loadCertificateIfExists(certificatePrivateKey) + + if (existingCertificate != null) { + return existingCertificate + } + + this.log('creating new csr') + + // create CSR + const csr = await this.loadOrCreateCSR(certificatePrivateKey) - const accountPrivateKey = await acmeClient.forge.createPrivateKey() + this.log('fetching new certificate') - const client = new acmeClient.Client({ + // create cert + const pem = await this.fetchAcmeCertificate(csr, multiaddrs, options) + const cert = new X509Certificate(pem) + + // cache cert + await this.datastore.put(new Key(this.certificateDatastoreKey), uint8ArrayFromString(pem)) + + return { + pem, + cert + } + } + + private async loadCertificateIfExists (certificatePrivateKey: string): Promise<{ pem: string, cert: X509Certificate } | undefined> { + const key = new Key(this.certificateDatastoreKey) + + try { + this.log.trace('try to load existing certificate') + const buf = await this.datastore.get(key) + const pem = uint8ArrayToString(buf) + const cert = new X509Certificate(pem) + + this.log.trace('loaded existing certificate') + + if (this.needsRenewal(cert.notAfter)) { + this.log('existing certificate requires renewal') + return + } + + try { + const key = importFromPem(certificatePrivateKey) + const certPublicKeyThumbprint = await cert.publicKey.getThumbprint() + const keyPublicKeyThumbprint = await crypto.subtle.digest('SHA-1', key.publicKey.raw) + + if (!uint8ArrayEquals( + new Uint8Array(certPublicKeyThumbprint, 0, certPublicKeyThumbprint.byteLength), + new Uint8Array(keyPublicKeyThumbprint, 0, keyPublicKeyThumbprint.byteLength) + )) { + this.log('certificate public key did not match the expected public key') + return + } + } catch (err: any) { + this.log.trace('failed to verify existing certificate with stored private key - %e', err) + return + } + + return { pem, cert } + } catch (err: any) { + this.log.trace('no existing valid certificate found - %e', err) + } + } + + private async loadOrCreateCSR (certificatePrivateKey: string): Promise { + const [, csr] = await acme.crypto.createCsr({ + commonName: `*.${this.domain}`, + altNames: [] + }, certificatePrivateKey) + + return csr + } + + async fetchAcmeCertificate (csr: Buffer, multiaddrs: Multiaddr[], options?: AbortOptions): Promise { + const client = new acme.Client({ directoryUrl: this.acmeDirectory, - accountKey: accountPrivateKey + accountKey: await loadOrCreateKey(this.keychain, this.accountPrivateKeyName, this.accountPrivateKeyBits) }) - const certString = await client.auto({ + + return client.auto({ csr, - email: `${base36EncodedPeer}@libp2p.direct`, + email: this.email, termsOfServiceAgreed: true, challengeCreateFn: async (authz, challenge, keyAuthorization) => { - const addresses = mulitaddrs.map(ma => ma.toString()) - - this.log('asking https://%s/v1/_acme-challenge to respond to the acme DNS challenge on our behalf', this.forgeEndpoint) - this.log('dialback public addresses: %s', addresses.join(', ')) - const response = await this.clientAuth.authenticatedFetch(`https://${this.forgeEndpoint}/v1/_acme-challenge`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - value: keyAuthorization, - addresses - }), - ...options - }) - - if (!response.ok) { - this.log.error('invalid response from forge %o', response) - throw new Error('Invalid response status') - } - - this.log('https://%s/v1/_acme-challenge will respond to the acme DNS challenge on our behalf', this.forgeEndpoint) + await this.configureAcmeChallengeResponse(multiaddrs, keyAuthorization, options) }, challengeRemoveFn: async (authz, challenge, keyAuthorization) => { // no-op @@ -167,34 +284,38 @@ export class AutoTLS implements AutoTLSInterface { challengePriority: ['dns-01'], skipChallengeVerification: true }) + } - this.log('fetched certificate', certString) - - let event: CertificateEvent = 'certificate:provision' + async configureAcmeChallengeResponse (multiaddrs: Multiaddr[], keyAuthorization: string, options?: AbortOptions): Promise { + const addresses = multiaddrs.map(ma => ma.toString()) - if (this.certificate != null) { - event = 'certificate:renew' - } + this.log('asking https://%s/v1/_acme-challenge to respond to the acme DNS challenge on our behalf', this.forgeEndpoint) + this.log('dialback public addresses: %s', addresses.join(', ')) + const response = await this.clientAuth.authenticatedFetch(`https://${this.forgeEndpoint}/v1/_acme-challenge`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + value: keyAuthorization, + addresses + }), + ...options + }) - this.certificate = { - key: certificatePrivateKey.toString('base64'), - cert: certString + if (!response.ok) { + this.log.error('invalid response from forge %o', response) + throw new Error('Invalid response status') } - // emit an event - this.events.safeDispatchEvent(event, { - detail: this.certificate - }) - - const cert = new x509.X509Certificate(certString) - const renewAt = new Date(cert.notAfter.getTime() - this.renewThreshold) + this.log('https://%s/v1/_acme-challenge will respond to the acme DNS challenge on our behalf', this.forgeEndpoint) + } - this.log('certificate expiry %s - renewing at %s', cert.notAfter, renewAt) + private needsRenewal (notAfter?: Date): boolean { + if (notAfter == null) { + return true + } - // schedule renewing the certificate - this.renewTimeout = setTimeout(() => { - this.certificate = undefined - this._fetchCertificates() - }, renewAt.getTime() - Date.now()) + return notAfter.getTime() - this.renewThreshold < Date.now() } } diff --git a/packages/auto-tls/src/constants.ts b/packages/auto-tls/src/constants.ts new file mode 100644 index 0000000000..c5cc63cb44 --- /dev/null +++ b/packages/auto-tls/src/constants.ts @@ -0,0 +1,11 @@ +export const DEFAULT_FORGE_ENDPOINT = 'https://registration.libp2p.direct' +export const DEFAULT_FORGE_DOMAIN = 'libp2p.direct' +export const DEFAULT_ACME_DIRECTORY = 'https://acme-v02.api.letsencrypt.org/directory' +export const DEFAULT_PROVISION_TIMEOUT = 10000 +export const DEFAULT_PROVISION_DELAY = 5000 +export const DEFAULT_RENEWAL_THRESHOLD = 60000 +export const DEFAULT_ACCOUNT_PRIVATE_KEY_NAME = 'auto-tls-acme-account-private-key' +export const DEFAULT_ACCOUNT_PRIVATE_KEY_BITS = 2048 +export const DEFAULT_CERTIFICATE_PRIVATE_KEY_NAME = 'auto-tls-certificate-private-key' +export const DEFAULT_CERTIFICATE_PRIVATE_KEY_BITS = 2048 +export const DEFAULT_CERTIFICATE_DATASTORE_KEY = '/libp2p/auto-tls/certificate' diff --git a/packages/auto-tls/src/domain-mapper.ts b/packages/auto-tls/src/domain-mapper.ts new file mode 100644 index 0000000000..1c87747528 --- /dev/null +++ b/packages/auto-tls/src/domain-mapper.ts @@ -0,0 +1,145 @@ +import { isIPv4, isIPv6 } from '@chainsafe/is-ip' +import { getPublicIps } from './utils.js' +import type { ComponentLogger, Libp2pEvents, Logger, TypedEventTarget } from '@libp2p/interface' +import type { AddressManager } from '@libp2p/interface-internal' + +export interface DomainMapperComponents { + logger: ComponentLogger + events: TypedEventTarget + addressManager: AddressManager +} + +export interface DomainMapperInit { + domain: string +} + +export class DomainMapper { + private readonly log: Logger + private readonly addressManager: AddressManager + private readonly events: TypedEventTarget + private readonly mappedAddresses: Set + private readonly domain: string + private hasCertificate: boolean + + constructor (components: DomainMapperComponents, init: DomainMapperInit) { + this.log = components.logger.forComponent('libp2p:certificate-manager:domain-mapper') + this.addressManager = components.addressManager + this.events = components.events + this.domain = init.domain + + this.mappedAddresses = new Set() + this.hasCertificate = false + + this.onCertificate = this.onCertificate.bind(this) + this.onSelfUpdate = this.onSelfUpdate.bind(this) + } + + start (): void { + this.events.addEventListener('self:peer:update', this.onSelfUpdate) + this.events.addEventListener('certificate:provision', this.onCertificate) + this.events.addEventListener('certificate:renew', this.onCertificate) + } + + stop (): void { + this.events.removeEventListener('self:peer:update', this.onSelfUpdate) + this.events.removeEventListener('certificate:provision', this.onCertificate) + this.events.removeEventListener('certificate:renew', this.onCertificate) + } + + onSelfUpdate (): void { + if (this.hasCertificate) { + this.updateMappings() + } + } + + onCertificate (): void { + this.hasCertificate = true + this.updateMappings() + } + + updateMappings (): void { + const publicIps = getPublicIps(this.addressManager.getAddresses()) + + // did our public IPs change? + const addedIp4 = [] + const addedIp6 = [] + const removedIp4 = [] + const removedIp6 = [] + + for (const ip of publicIps) { + if (this.mappedAddresses.has(ip)) { + continue + } + + if (isIPv4(ip)) { + addedIp4.push(ip) + } + + if (isIPv6(ip)) { + addedIp6.push(ip) + } + } + + for (const ip of this.mappedAddresses) { + if (publicIps.has(ip)) { + continue + } + + if (isIPv4(ip)) { + removedIp4.push(ip) + } + + if (isIPv6(ip)) { + removedIp6.push(ip) + } + } + + removedIp4.forEach(ip => { + const domain = this.toDomain(ip, 4) + this.log.trace('removing mapping of IP %s to domain %s', ip, domain) + this.addressManager.removeDNSMapping(domain) + this.mappedAddresses.delete(ip) + }) + + removedIp6.forEach(ip => { + const domain = this.toDomain(ip, 6) + this.log.trace('removing mapping of IP %s to domain %s', ip, domain) + this.addressManager.removeDNSMapping(domain) + this.mappedAddresses.delete(ip) + }) + + addedIp4.forEach(ip => { + const domain = this.toDomain(ip, 4) + this.log.trace('mapping IP %s to domain %s', ip, domain) + this.addressManager.addDNSMapping(domain, [ip]) + this.mappedAddresses.add(ip) + }) + + addedIp6.forEach(ip => { + const domain = this.toDomain(ip, 6) + this.log.trace('mapping IP %s to domain %s', ip, domain) + this.addressManager.addDNSMapping(domain, [ip]) + this.mappedAddresses.add(ip) + }) + } + + private toDomain (ip: string, family: 4 | 6): string { + if (family === 4) { + // https://github.com/ipshipyard/p2p-forge#ipv4-subdomain-handling + return `${ip.replaceAll('.', '-')}.${this.domain}` + } + + // https://github.com/ipshipyard/p2p-forge#ipv6-subdomain-handling + let ipSubdomain = ip.replaceAll(':', '-') + + if (ipSubdomain.startsWith('-')) { + ipSubdomain = `0${ipSubdomain}` + } + + if (ipSubdomain.endsWith('-')) { + ipSubdomain = `${ipSubdomain}0` + } + + return `${ipSubdomain}.${this.domain}` + } +} diff --git a/packages/auto-tls/src/errors.ts b/packages/auto-tls/src/errors.ts new file mode 100644 index 0000000000..561d4330cc --- /dev/null +++ b/packages/auto-tls/src/errors.ts @@ -0,0 +1,4 @@ +export class IncorrectKeyType extends Error { + static name = 'IncorrectKeyType' + name = 'IncorrectKeyType' +} diff --git a/packages/auto-tls/src/index.ts b/packages/auto-tls/src/index.ts index c4c16c040e..3640ae3cc1 100644 --- a/packages/auto-tls/src/index.ts +++ b/packages/auto-tls/src/index.ts @@ -20,6 +20,8 @@ * import { noise } from '@chainsafe/libp2p-noise' * import { yamux } from '@chainsafe/libp2p-yamux' * import { autoTLS } from '@libp2p/auto-tls' + * import { identify } from '@libp2p/identify' + * import { keychain } from '@libp2p/keychain' * import { webSockets } from '@libp2p/websockets' * import { uPnPNAT } from '@libp2p/upnp-nat' * import { createLibp2p } from 'libp2p' @@ -41,6 +43,8 @@ * ], * services: { * autoTLS: autoTLS(), + * identify: identify(), + * keychain: keychain(), * upnp: uPnPNAT() * } * }) @@ -56,6 +60,8 @@ import { AutoTLS as AutoTLSClass } from './auto-tls.js' import type { PeerId, PrivateKey, ComponentLogger, Libp2pEvents, TypedEventTarget, TLSCertificate } from '@libp2p/interface' import type { AddressManager } from '@libp2p/interface-internal' +import type { Keychain } from '@libp2p/keychain' +import type { Datastore } from 'interface-datastore' export interface AutoTLSComponents { privateKey: PrivateKey @@ -63,13 +69,16 @@ export interface AutoTLSComponents { logger: ComponentLogger addressManager: AddressManager events: TypedEventTarget + keychain: Keychain + datastore: Datastore } export interface AutoTLSInit { /** - * Where to send requests to answer an ACME DNS challenge on our behalf + * Where to send requests to answer an ACME DNS challenge on our behalf - note + * that `/v1/_acme-challenge` will be added to the end of the URL * - * @default 'registration.libp2p.direct' + * @default 'https://registration.libp2p.direct' */ forgeEndpoint?: string @@ -97,17 +106,17 @@ export interface AutoTLSInit { * * @default 10000 */ - timeout?: number + provisionTimeout?: number /** - * Certificates are aquired when the `self:peer:update` event fires, which + * Certificates are acquired when the `self:peer:update` event fires, which * happens when the node's addresses change. To avoid starting to map ports * while multiple addresses are being added, the mapping function is debounced * by this number of ms * * @default 5000 */ - delay?: number + provisionDelay?: number /** * How long before the expiry of the certificate to renew it in ms @@ -115,6 +124,41 @@ export interface AutoTLSInit { * @default 60000 */ renewThreshold?: number + + /** + * The key the certificate is stored in the datastore under + * + * @default '/libp2p/auto-tls/certificate' + */ + certificateDatastoreKey?: string + + /** + * The name the ACME account RSA private key is stored in the keychain with + * + * @default 'auto-tls-acme-account-private-key' + */ + accountPrivateKeyName?: string + + /** + * How many bits the RSA private key for the account should be + * + * @default 2048 + */ + accountPrivateKeyBits?: number + + /** + * The name the certificate RSA private key is stored in the keychain with + * + * @default 'auto-tls-certificate-private-key' + */ + certificatePrivateKeyName?: string + + /** + * How many bits the RSA private key for the certificate should be + * + * @default 2048 + */ + certificatePrivateKeyBits?: number } export interface AutoTLS { diff --git a/packages/auto-tls/src/utils.ts b/packages/auto-tls/src/utils.ts new file mode 100644 index 0000000000..8f3e0fe014 --- /dev/null +++ b/packages/auto-tls/src/utils.ts @@ -0,0 +1,101 @@ +import { Buffer } from 'node:buffer' +import { createPrivateKey } from 'node:crypto' +import { isIPv4, isIPv6 } from '@chainsafe/is-ip' +import { generateKeyPair, privateKeyFromRaw } from '@libp2p/crypto/keys' +import { isLoopback } from '@libp2p/utils/multiaddr/is-loopback' +import { isPrivate } from '@libp2p/utils/multiaddr/is-private' +import { IP, QUICV1, TCP, WebSockets, WebSocketsSecure, WebTransport } from '@multiformats/multiaddr-matcher' +import { IncorrectKeyType } from './errors.js' +import type { RSAPrivateKey } from '@libp2p/interface' +import type { Keychain } from '@libp2p/keychain' +import type { Multiaddr } from '@multiformats/multiaddr' + +/** + * Loads a key and returns it in PCKS#1 DER in PEM format + */ +export async function loadOrCreateKey (keychain: Keychain, name: string, size: number): Promise { + let key: RSAPrivateKey + + try { + const storedKey = await keychain.exportKey(name) + + if (storedKey.type !== 'RSA') { + throw new IncorrectKeyType(`Key type must be RSA, got "${storedKey.type}"`) + } + + key = storedKey + } catch (err: any) { + if (err.name !== 'NotFoundError') { + throw err + } + + key = await generateKeyPair('RSA', size) + await keychain.importKey(name, key) + } + + return formatAsPem(key) +} + +export function toBuffer (uint8Array: Uint8Array): Buffer { + return Buffer.from(uint8Array.buffer, uint8Array.byteOffset, uint8Array.byteLength) +} + +export function formatAsPem (key: RSAPrivateKey): string { + const obj = createPrivateKey({ + format: 'der', + key: toBuffer(key.raw), + type: 'pkcs1' + }) + + return obj.export({ format: 'pem', type: 'pkcs8' }).toString() +} + +export function importFromPem (pem: string): RSAPrivateKey { + const obj = createPrivateKey({ + format: 'pem', + key: pem + }) + const der = obj.export({ + format: 'der', + type: 'pkcs1' + }) + + const key = privateKeyFromRaw(der) + + if (key.type !== 'RSA') { + throw new IncorrectKeyType(`Got incorrect key type - ${key.type}`) + } + + return key +} + +export function supportedAddressesFilter (ma: Multiaddr): boolean { + // only routable addresses + if (isPrivate(ma) || isLoopback(ma)) { + return false + } + + // only these transports over IPvX + return IP.matches(ma) && ( + TCP.exactMatch(ma) || + WebSockets.exactMatch(ma) || + WebSocketsSecure.exactMatch(ma) || + QUICV1.exactMatch(ma) || + WebTransport.exactMatch(ma) + ) +} + +export function getPublicIps (addrs: Multiaddr[]): Set { + const output = new Set() + + addrs.filter(supportedAddressesFilter) + .forEach(ma => { + const options = ma.toOptions() + + if (isIPv4(options.host) || isIPv6(options.host)) { + output.add(options.host) + } + }) + + return output +} diff --git a/packages/auto-tls/test/domain-mapper.spec.ts b/packages/auto-tls/test/domain-mapper.spec.ts new file mode 100644 index 0000000000..0e8c246046 --- /dev/null +++ b/packages/auto-tls/test/domain-mapper.spec.ts @@ -0,0 +1,138 @@ +import { TypedEventEmitter, start, stop } from '@libp2p/interface' +import { defaultLogger } from '@libp2p/logger' +import { multiaddr } from '@multiformats/multiaddr' +import { expect } from 'aegir/chai' +import { stubInterface } from 'sinon-ts' +import { DomainMapper } from '../src/domain-mapper.js' +import { importFromPem } from '../src/utils.js' +import { CERT, PRIVATE_KEY_PEM } from './fixtures/cert.js' +import type { ComponentLogger, Libp2pEvents, TypedEventTarget, Peer } from '@libp2p/interface' +import type { AddressManager } from '@libp2p/interface-internal' +import type { StubbedInstance } from 'sinon-ts' + +export interface StubbedDomainMapperComponents { + logger: ComponentLogger + events: TypedEventTarget + addressManager: StubbedInstance +} + +describe('domain-mapper', () => { + let components: StubbedDomainMapperComponents + let mapper: DomainMapper + + beforeEach(async () => { + components = { + logger: defaultLogger(), + events: new TypedEventEmitter(), + addressManager: stubInterface() + } + + mapper = new DomainMapper(components, { + domain: 'example.com' + }) + + await start(mapper) + }) + + afterEach(async () => { + await stop(mapper) + }) + + it('should map domains on self peer update', () => { + const ip4 = '81.12.12.9' + const ip6 = '2001:4860:4860::8889' + + components.addressManager.getAddresses.returns([ + multiaddr('/ip4/127.0.0.1/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN'), + multiaddr('/ip4/192.168.1.234/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN'), + multiaddr('/dns4/example.com/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN'), + multiaddr(`/ip4/${ip4}/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN`), + multiaddr(`/ip6/${ip6}/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN`) + ]) + + components.events.safeDispatchEvent('certificate:provision', { + detail: { + key: importFromPem(PRIVATE_KEY_PEM), + cert: CERT + } + }) + + expect(components.addressManager.addDNSMapping.calledWith('81-12-12-9.example.com', [ + ip4 + ])).to.be.true() + expect(components.addressManager.addDNSMapping.calledWith('2001-4860-4860--8889.example.com', [ + ip6 + ])).to.be.true() + }) + + it('should update domain mapping on self peer update', () => { + const ip4v1 = '81.12.12.9' + const ip6v1 = '2001:4860:4860::8889' + + components.addressManager.getAddresses.returns([ + multiaddr('/ip4/127.0.0.1/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN'), + multiaddr('/ip4/192.168.1.234/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN'), + multiaddr('/dns4/example.com/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN'), + multiaddr(`/ip4/${ip4v1}/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN`), + multiaddr(`/ip6/${ip6v1}/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN`) + ]) + + components.events.safeDispatchEvent('certificate:provision', { + detail: { + key: importFromPem(PRIVATE_KEY_PEM), + cert: CERT + } + }) + + expect(components.addressManager.addDNSMapping.calledWith('81-12-12-9.example.com', [ + ip4v1 + ])).to.be.true() + expect(components.addressManager.addDNSMapping.calledWith('2001-4860-4860--8889.example.com', [ + ip6v1 + ])).to.be.true() + + const ip4v2 = '81.12.12.10' + const ip6v2 = '2001:4860:4860::8890' + + components.addressManager.getAddresses.returns([ + multiaddr('/ip4/127.0.0.1/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN'), + multiaddr('/ip4/192.168.1.234/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN'), + multiaddr('/dns4/example.com/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN'), + multiaddr(`/ip4/${ip4v2}/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN`), + multiaddr(`/ip6/${ip6v2}/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN`) + ]) + + components.events.safeDispatchEvent('self:peer:update', { + detail: stubInterface() + }) + + expect(components.addressManager.removeDNSMapping.calledWith('81-12-12-9.example.com')).to.be.true() + expect(components.addressManager.removeDNSMapping.calledWith('2001-4860-4860--8889.example.com')).to.be.true() + + expect(components.addressManager.addDNSMapping.calledWith('81-12-12-10.example.com', [ + ip4v2 + ])).to.be.true() + expect(components.addressManager.addDNSMapping.calledWith('2001-4860-4860--8890.example.com', [ + ip6v2 + ])).to.be.true() + }) + + it('should not map domains when no certificate is available', () => { + const ip4 = '81.12.12.9' + const ip6 = '2001:4860:4860::8889' + + components.addressManager.getAddresses.returns([ + multiaddr('/ip4/127.0.0.1/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN'), + multiaddr('/ip4/192.168.1.234/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN'), + multiaddr('/dns4/example.com/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN'), + multiaddr(`/ip4/${ip4}/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN`), + multiaddr(`/ip6/${ip6}/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN`) + ]) + + components.events.safeDispatchEvent('self:peer:update', { + detail: stubInterface() + }) + + expect(components.addressManager.addDNSMapping.called).to.be.false() + }) +}) diff --git a/packages/auto-tls/test/fixtures/ca.ts b/packages/auto-tls/test/fixtures/ca.ts new file mode 100644 index 0000000000..e377a687ce --- /dev/null +++ b/packages/auto-tls/test/fixtures/ca.ts @@ -0,0 +1,80 @@ +/** + * A CA private key - generated with: + * + * ``` + * openssl req -new -newkey rsa:2048 -nodes -out ca_csr.csr -keyout ca_private.key -sha256 + * ``` + */ +export const CA_PRIVATE_KEY = `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDQJ6XkhBlL7tky +cfuOqZVdvoSXBCw88/qzklufwj4nd3mn2DAFmYoM7ZJcwLzhf+clqspznn4bFMju +eCwQKoc7zq84RoE2Ln0kOHhB6RiLQjp+YsuxSoqLjogVfbg4YKZUsGMdBU9qLQ7r +RlkHhVBH6KVAmoBDFUrPBF6OFlGaai1A9utScd4W+GYEZth1y5hbmLWvmc3XPvFa +RnY7Vj5jUQBt42ilQ22LusoGYcVZbEviiOeSwapnaRPr0lrn/CSkvxb2Ws2W6aLk +HRMk8ymBWZGKiX34eaZ+ktNqDTQkNuoZ5anc0+Z6Q6v7X+0w6NWQ7xCWAtNuXElB +js1JE/uzAgMBAAECggEAShvjl1FkxEEceTZVrrw33rFm+XFV4rmmp2pTTrTUdi62 +VIjGyCebG/E1a+p/FPX5sNJ20+U41qF2zFhir7rEfQmgHrQTatvwWAX24th/kY0Z +0EeSZ+O3Ieq0Dpq9DO10KrDGCN4MISw7pI5eQiw3ofJ1a2PWiIu7H6tZktLlaMkB +qABOHpJBrrB0OnSDoHXMJD5OEQcOpcJ79Y1ESljDCTcWhimPQzgB2Tq9c8Sj5ysB +JfOWYE5U0Ad5ig/VWF9VFbYbj2CIK89QVg4JDlwYTxYXvcpOyHHdAauv86c8q6V2 +QAHz7Rq9YlWKyUnb5qmrjST6IL3Xc+FhpKw0rzjPKQKBgQD7t6QC6WR+Z7wn3kGp +oveaT4M5is/rfPMsqoGtyr8pM+BDtXjmNP92Kn6j7P8zbNE4WsCj/m4JOz7biDf1 +WT3XirWRxiKDnpdw20OS6xPDVJprbm9DvDbo/ZfV95KyW1ggUTQYfdnb1Gom9Fhz +8w9/nQj6IfU0QP+mDa97S4cKTwKBgQDTskUuWwbEHna4453K6qSLbTXhcNIBuPUV +u7mo1byUVdQe5DEXLsi7lQOuezqG/6dIwQjwGQf9e8WHHYM1k5t46/cjz2qke1ly +MT4vl2/J7/tMB2M3FArx3Ydz0N1x9wPqzaO7wOx5sMGZqrfZySu9sOwkGuYAUpnr +Rj2NIjCzXQKBgAovmDd18lcbI4YJfGa87YAVD55Ye6lv2PdJvw2lUq78JmsXANlv +85Z4ib9ga8NM9/pr0bfRJ+q/tv9zN7B5+AKs3kQT3HmvBTnP5aAgWyBgYA9Q3LfE ++gPbnzVNW2ZUQ/Cq2IzVKue2ZMVGxf2LLGlhlWdp0F5Y8v9pNlyq5cAJAoGAUm5V +L+Kz9MPj+MRw2eWaIsxosZsLuy35CPhrJ8nqP1xYV5sFXoCSGzDAGT3UoWKFEfhQ +caVdjh+W37DnOYJ7hI9lUWVfoiKBxsxT6ZYvKlOu54Ds6jJ8vIdFShynTcwgk1p1 +ihNqQUxJZnuqUTxbMubkXH641qFTW+Ci8QTCL+UCgYEA5qMM+0Q5sFd/GFGOkc3g +SRC/AIx1GWWJXT8e54KpwUV/cUUPsSrMgJTRNYXKogdZEHgMs3o6GSFd7xOo2bGN +y/+APOMDRrX+lab+eZhTMQBmLvrPR6l9NRR3Q9z73PjcUMidSf6Us+XC7pJKU7SP +h3CFtzqbiSslFpqTZTgLbF8= +-----END PRIVATE KEY-----` + +/** + * A CA CSR for ca.example.com. See `CA_PRIVATE_KEY` for generation instructions + */ +export const CA_CSR = `-----BEGIN CERTIFICATE REQUEST----- +MIICXjCCAUYCAQAwGTEXMBUGA1UEAwwOY2EuZXhhbXBsZS5jb20wggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDQJ6XkhBlL7tkycfuOqZVdvoSXBCw88/qz +klufwj4nd3mn2DAFmYoM7ZJcwLzhf+clqspznn4bFMjueCwQKoc7zq84RoE2Ln0k +OHhB6RiLQjp+YsuxSoqLjogVfbg4YKZUsGMdBU9qLQ7rRlkHhVBH6KVAmoBDFUrP +BF6OFlGaai1A9utScd4W+GYEZth1y5hbmLWvmc3XPvFaRnY7Vj5jUQBt42ilQ22L +usoGYcVZbEviiOeSwapnaRPr0lrn/CSkvxb2Ws2W6aLkHRMk8ymBWZGKiX34eaZ+ +ktNqDTQkNuoZ5anc0+Z6Q6v7X+0w6NWQ7xCWAtNuXElBjs1JE/uzAgMBAAGgADAN +BgkqhkiG9w0BAQsFAAOCAQEAweN+f75FolYtglWCPoFxYdhcBt8r3Pp8uJ5GGp6i +1ml7q9Eri921tnUuNyElaHrC/OBtyTYlxNlQA2LSa9AEXpznpBhESizgxyiEDBfY +jeKdsCU9jmwfj13NefydaDbPou/mNIvV1Keb4C0ivrrmv0LeVsI3BmGNt/bVTm/u +wdtAMsj9lCwTWVOoNX0FkpCr7QQvDs1Q1kKZgFud20YtQuN05j/13CbqK7aT/iuJ +Zmxxyl9n5Lr4Yhr6P+RkfLpgLVxGUB3Ydw7m7pgNVnzo4a8Ob8EpWNLXILaaZ69d +o6+0NNM8QX1ptyp90AnSEkvRgx/B3Ov+yW7wARg4d2z7fQ== +-----END CERTIFICATE REQUEST-----` + +/** + * CA certificate used to sign other CSRs - expires in 2124. Generated with: + * + * ``` + * openssl x509 -signkey ca_private.key -days 36525 -req -in ca_csr.csr -out ca.cert -sha256 + * ``` + */ +export const CA_CERT = `-----BEGIN CERTIFICATE----- +MIIC4zCCAcugAwIBAgIUUeSW3UVzLNxMqBN+fjifrmLAOUEwDQYJKoZIhvcNAQEL +BQAwGTEXMBUGA1UEAwwOY2EuZXhhbXBsZS5jb20wIBcNMjQxMTEzMTIyNTQ1WhgP +MjEyNDExMTQxMjI1NDVaMBkxFzAVBgNVBAMMDmNhLmV4YW1wbGUuY29tMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Cel5IQZS+7ZMnH7jqmVXb6ElwQs +PPP6s5Jbn8I+J3d5p9gwBZmKDO2SXMC84X/nJarKc55+GxTI7ngsECqHO86vOEaB +Ni59JDh4QekYi0I6fmLLsUqKi46IFX24OGCmVLBjHQVPai0O60ZZB4VQR+ilQJqA +QxVKzwRejhZRmmotQPbrUnHeFvhmBGbYdcuYW5i1r5nN1z7xWkZ2O1Y+Y1EAbeNo +pUNti7rKBmHFWWxL4ojnksGqZ2kT69Ja5/wkpL8W9lrNlumi5B0TJPMpgVmRiol9 ++HmmfpLTag00JDbqGeWp3NPmekOr+1/tMOjVkO8QlgLTblxJQY7NSRP7swIDAQAB +oyEwHzAdBgNVHQ4EFgQUfxYUCjJS1VhR+vk07OgYvbpCwOUwDQYJKoZIhvcNAQEL +BQADggEBAI6h1eUfFH4+Bhz6nLLuAHJ2HD0MJXULPYIkfrra32op5f7ZW8kl0/qZ +G2cexnY4qjHR9qRqIzV1K292Go2N0n9uOiUlWEpyAZeST7KdHSwyYsAF94XI02Zo +lM4pkmDFF/zoRPYYZPZpT20RnNYKDuc8i6KWWbZUHvqr8zXsZCnb9QK4Am8OwubV +J9wSNLoK47HZv0ycqkdh+wcX3xWfInFywg8dOpA97MEq7aULEezciXCr5+v/Kwb+ +ocuR7yhdZTa5ak8oEPRY+2Vl5mY5yp1qopZqY7wMd6eZn0JCLa4a5Z8Fj5Mjh6Kd +Qwsr4ULX6LlyP2R/2IaU9Iq9SSoiMF0= +-----END CERTIFICATE-----` diff --git a/packages/auto-tls/test/fixtures/cert.ts b/packages/auto-tls/test/fixtures/cert.ts new file mode 100644 index 0000000000..69d8e80e4c --- /dev/null +++ b/packages/auto-tls/test/fixtures/cert.ts @@ -0,0 +1,155 @@ +/** + * A 2048 bit RSA private key - generated with: + * + * ``` + * openssl genrsa -out user_private.key 2048 + * ``` + */ +export const PRIVATE_KEY_PEM = `-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC8AafaHa4q5LcG +JAnCTKwAN5ag9nL+9dQTZSrdZHxDxYMsSa3ZCJ14bbst2A9N0JbfjuNURT1Ev4dP +be9P/N6SF8TeIyOuJCqj9HR4w3bU4O3jN5mrCSev05mkRRxiUFIrotmg8U/r1zw9 +JdwTAopdykp6Nfx+ILVywAseIXr6sibD0zrDzn8zPW+/vlDDYPyxFRTNR7yeNz40 +VFUNyMDhzt0brjSrlctk7/12cNXJ2B4Na5dam8nPoVFK/MB8F5ZI4hDQf50cARs6 +gwC9wAmJJ98dqLvXtzsPhe51fQLO/F/sPRpCa9Tgtu0+5QERo4gwR7js7lqpH9dq +RL+7EGr9AgMBAAECggEAIQn1cmo22kjZNRYONPv+WgdqzByjNtUCkOn9b2yiPdKu +65CDIZnW+nvmIfdPMTCP4vecUYnda4BxCe9a1+arQmc07IimU4r9h1SFR1eqQVzJ +0V1K/vib5dU8sdaHV2ugFRUIQ7VEg0CO0HwfwaN3p2XOUs278npTCLhg+8mMcBOj +Winb9mR7/Ksf5Uoe44gRCjVStIHkxgSCdDsADD9Ggv75sYWeZ+eN/rmMckLOT27M +wFbW2AxbWFDk3hj1+2sD02b/M0PV/CPpknNgwkrETL3/FnSmBZ/O9K7CIHfYGH5b +jlnRISzCQGXCMlAbDRQI7oUprAxJigQVnYv+4qHBjQKBgQDrV9zhmXB2k9CjB9tB +mMpVqDVj5qeVyIdn0SBzax1qm4mxCCwgW6JJwllDPYEZrrlXYQjaZ7mWtjJVwjuu +bKeA4dbvFTketWSImw9Y/ovvzopZLw7DxKaUDDO6AiiOZDFvSwYqNDDTUUSX0ijS +BSLmVpPyMfmoxbjRn6lQY0tRnwKBgQDMgiQinMPUXH51Wl+Tm8v2+5LItfo2nlrk +zArGjmPhGsgJDlgi/k7/xdpAmE+uL5b7Jo9YewxEu8Jmc3xcq9s9SuJw7JOHzcYq +8X8ZGEgdYqFZpM9wY31hAYyaI3zO7A+OhZOvPSgDjrDNrTvriczDUD38XJ7+KweK +DmlHaEsV4wKBgCPZU3UuCVqWs55R8Q0x+AhKQi/Aj+CaFj4zNe0+8NEvdi74Xrhj +HPp9V1mNwd+mpObxigay7CtP/6TenHa6aF2SiUoMApJx8Sl57UGSLMDPxnVFXMtn +ZjSBE1QPRhxCmOEqHXtKTfGynG8//SXY0HMj1w96m1whGkEcQA9VwMBjAoGAVfRI +7cdHw4TQndBLJYYw1vDrw7JApR4vg8SCrut/7UfNVYkS4DpUx8nHrqiVrNdRtOOD +EiQ9htIHpfnaBjUxI3TK8b1tUIHbTYdM7SY4gSlIOZ48lbcrJk95YfuSZIHxE+zu +opOosr4Rb5DlA11ak6ixNNVU+ezp8UuXUizyihMCgYBOU6fmRBszYDetKpEUenpg +4KySxO6rcstADlLKHAMHRuPKHPYgMozZR3KlRwCCrK/HRgAzSrOonUWfJdp0nrDM +hAH/0gxzn58p3tormv94llkBJ6Voj5QvSdOeESKzYdz5gyK29dy08503w8CEI2eO +a/xhT7PUGisjcZwNIZuLhA== +-----END PRIVATE KEY-----` + +/** + * A CSR created by `PRIVATE_KEY_PEM` with the common name "example.com" + * + * Generated with: + * + * ``` + * openssl req -new -key user_private.key -out user.csr + * ``` + */ +export const CSR = `-----BEGIN CERTIFICATE REQUEST----- +MIICWzCCAUMCAQAwFjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQC8AafaHa4q5LcGJAnCTKwAN5ag9nL+9dQTZSrd +ZHxDxYMsSa3ZCJ14bbst2A9N0JbfjuNURT1Ev4dPbe9P/N6SF8TeIyOuJCqj9HR4 +w3bU4O3jN5mrCSev05mkRRxiUFIrotmg8U/r1zw9JdwTAopdykp6Nfx+ILVywAse +IXr6sibD0zrDzn8zPW+/vlDDYPyxFRTNR7yeNz40VFUNyMDhzt0brjSrlctk7/12 +cNXJ2B4Na5dam8nPoVFK/MB8F5ZI4hDQf50cARs6gwC9wAmJJ98dqLvXtzsPhe51 +fQLO/F/sPRpCa9Tgtu0+5QERo4gwR7js7lqpH9dqRL+7EGr9AgMBAAGgADANBgkq +hkiG9w0BAQsFAAOCAQEAe3OgoSpAQ2gxC7Y9OZ0hOgTrqWSBbOWWBiY3aLfOkq3a +E/tLp6ORv7Uod5P1O6BKPkWsdbJPhSkHJT3Q/GnVxMMPXBBDQmfZ+Y1lDnQusLQy +rjYc2xAsMrhsUajwmcUJOgFu5S+2hWNBuIIf/esa+/8SpK/EGnr7xsfF4f3tM1Sv +A0jW/iVUAJPlacbQbohqtAOMDonL9UhNQmjODNGkaELPcXKIRbhXabHuB843WkKp +m0bRszzyrF14clG4zTmWEYFQA5OaA8uvuI3HfLZQr5D0k7rtLMMEOZ2M5TbkPHaj +QwevAKYJB94ZYbpOFDMoi4fbZY3BB3Zrv1uE6fe9mA== +-----END CERTIFICATE REQUEST-----` + +/** + * A certificate generated from `CSR` and signed using the CA in `ca.ts`. + * Expires in 2124 + * + * Generated with: + * + * ``` + * openssl x509 -req -days 36524 -in user.csr -CA ca.cert -CAkey ca_private.key -out user.cert -set_serial 01 -sha256 + * ``` + */ +export const CERT = `-----BEGIN CERTIFICATE----- +MIIC7jCCAdagAwIBAgIBATANBgkqhkiG9w0BAQsFADAZMRcwFQYDVQQDDA5jYS5l +eGFtcGxlLmNvbTAgFw0yNDExMTMxMjI3MzBaGA8yMTI0MTExMzEyMjczMFowFjEU +MBIGA1UEAwwLZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQC8AafaHa4q5LcGJAnCTKwAN5ag9nL+9dQTZSrdZHxDxYMsSa3ZCJ14bbst +2A9N0JbfjuNURT1Ev4dPbe9P/N6SF8TeIyOuJCqj9HR4w3bU4O3jN5mrCSev05mk +RRxiUFIrotmg8U/r1zw9JdwTAopdykp6Nfx+ILVywAseIXr6sibD0zrDzn8zPW+/ +vlDDYPyxFRTNR7yeNz40VFUNyMDhzt0brjSrlctk7/12cNXJ2B4Na5dam8nPoVFK +/MB8F5ZI4hDQf50cARs6gwC9wAmJJ98dqLvXtzsPhe51fQLO/F/sPRpCa9Tgtu0+ +5QERo4gwR7js7lqpH9dqRL+7EGr9AgMBAAGjQjBAMB0GA1UdDgQWBBSCbM4qVww9 +as2QQGB3xQywcmt5pDAfBgNVHSMEGDAWgBR/FhQKMlLVWFH6+TTs6Bi9ukLA5TAN +BgkqhkiG9w0BAQsFAAOCAQEAYgVxsBf76IiFe0/zvsOaLUpvodrJynfv6WdFnItP +3AZDb6hfDt+KXz5DYJ+FgELYTlz3hN5U2vYLrIk0BJ7o2PnMc7JEmOBstLoXkRtr +w2F2PlGwTAum8tZyDYKSe3MesbjXCIIia4xqnwCR0z0JXTb0yhq3YkLbZ/amrSdE +QnflMOUwfKIYPOc7sfZJoT48MbS3BPAsFEjzb6cbNdz6zrj6GVUT85lKL/Y2MtKA +MuZ/t6e+U15YkxryhmjStpclKop5gYo7/xK8s61CxjZnNA4m/VJBDlDByjFpH6OC +ZNCQUjc/WZv9Ncx9gJ4ZtSJZTZd9vei7Er4oeip7I1a0Vg== +-----END CERTIFICATE-----` + +/** + * Similar to CERT but this certificate has expired + */ +export const EXPIRED_CERT = `-----BEGIN CERTIFICATE----- +MIIC4zCCAcugAwIBAgIUUeSW3UVzLNxMqBN+fjifrmLAOUEwDQYJKoZIhvcNAQEL +BQAwGTEXMBUGA1UEAwwOY2EuZXhhbXBsZS5jb20wIBcNMjQxMTEzMTIyNTQ1WhgP +MjEyNDExMTQxMjI1NDVaMBkxFzAVBgNVBAMMDmNhLmV4YW1wbGUuY29tMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Cel5IQZS+7ZMnH7jqmVXb6ElwQs +PPP6s5Jbn8I+J3d5p9gwBZmKDO2SXMC84X/nJarKc55+GxTI7ngsECqHO86vOEaB +Ni59JDh4QekYi0I6fmLLsUqKi46IFX24OGCmVLBjHQVPai0O60ZZB4VQR+ilQJqA +QxVKzwRejhZRmmotQPbrUnHeFvhmBGbYdcuYW5i1r5nN1z7xWkZ2O1Y+Y1EAbeNo +pUNti7rKBmHFWWxL4ojnksGqZ2kT69Ja5/wkpL8W9lrNlumi5B0TJPMpgVmRiol9 ++HmmfpLTag00JDbqGeWp3NPmekOr+1/tMOjVkO8QlgLTblxJQY7NSRP7swIDAQAB +oyEwHzAdBgNVHQ4EFgQUfxYUCjJS1VhR+vk07OgYvbpCwOUwDQYJKoZIhvcNAQEL +BQADggEBAI6h1eUfFH4+Bhz6nLLuAHJ2HD0MJXULPYIkfrra32op5f7ZW8kl0/qZ +G2cexnY4qjHR9qRqIzV1K292Go2N0n9uOiUlWEpyAZeST7KdHSwyYsAF94XI02Zo +lM4pkmDFF/zoRPYYZPZpT20RnNYKDuc8i6KWWbZUHvqr8zXsZCnb9QK4Am8OwubV +J9wSNLoK47HZv0ycqkdh+wcX3xWfInFywg8dOpA97MEq7aULEezciXCr5+v/Kwb+ +ocuR7yhdZTa5ak8oEPRY+2Vl5mY5yp1qopZqY7wMd6eZn0JCLa4a5Z8Fj5Mjh6Kd +Qwsr4ULX6LlyP2R/2IaU9Iq9SSoiMF0= +-----END CERTIFICATE-----` + +/** + * Similar to CERT but this certificate has garbage data + */ +export const INVALID_CERT = `-----BEGIN CERTIFICATE----- +MIIC7jCCAdagAwIBAgIBATANBgkqhkiG9w0BAQsFADAZMRcwFQYDVQQDDA5jYS5A +eGFtcGxlLmNvbTAgFw0yNDExMTMxMjI3MzBaGA8yMTI0MTExMzEyMjczMFowFjEA +MBIGA1UEAwwLZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEA +AoIBAQC8AafaHa4q5LcGJAnCTKwAN5ag9nL+9dQTZSrdZHxDxYMsSa3ZCJ14bbsA +2A9N0JbfjuNURT1Ev4dPbe9P/N6SF8TeIyOuJCqj9HR4w3bU4O3jN5mrCSev05mA +RRxiUFIrotmg8U/r1zw9JdwTAopdykp6Nfx+ILVywAseIXr6sibD0zrDzn8zPW+A +vlDDYPyxFRTNR7yeNz40VFiaminvalidrjSrlctk7/12cNXJ2B4Na5dam8nPoVFA +/MB8F5ZI4hDQf50cARs6gwC9wAmJJ98dqLvXtzsPhe51fQLO/F/sPRpCa9Tgtu0A +5QERo4gwR7js7lqpH9dqRL+7EGr9AgMBAAGjQjBAMB0GA1UdDgQWBBSCbM4qVwwA +as2QQGB3xQywcmt5pDAfBgNVHSMEGDAWgBR/FhQKMlLVWFH6+TTs6Bi9ukLA5TAA +BgkqhkiG9w0BAQsFAAOCAQEAYgVxsBf76IiFe0/zvsOaLUpvodrJynfv6WdFnItA +3AZDb6hfDt+KXz5DYJ+FgELYTlz3hN5U2vYLrIk0BJ7o2PnMc7JEmOBstLoXkRtA +w2F2PlGwTAum8tZyDYKSe3MesbjXCIIia4xqnwCR0z0JXTb0yhq3YkLbZ/amrSdA +QnflMOUwfKIYPOc7sfZJoT48MbS3BPAsFEjzb6cbNdz6zrj6GVUT85lKL/Y2MtKA +MuZ/t6e+U15YkxryhmjStpclKop5gYo7/xK8s61CxjZnNA4m/VJBDlDByjFpH6OA +ZNCQUjc/WZv9Ncx9gJ4ZtSJZTZd9vei7Er4oeip7I1a0Vg== +-----END CERTIFICATE-----` + +/** + * Similar to CERT but this certificate was requested by a different private key + */ +export const CERT_FOR_OTHER_KEY = `-----BEGIN CERTIFICATE----- +MIIC7jCCAdagAwIBAgIBATANBgkqhkiG9w0BAQsFADAZMRcwFQYDVQQDDA5jYS5l +eGFtcGxlLmNvbTAgFw0yNDExMTMxMjU4NDRaGA8yMTI0MTExMzEyNTg0NFowFjEU +MBIGA1UEAwwLZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQCXERLHKjn5qGFdltfQSgM7K6+sgTSKjlzIH+EuiD0IbxWj0xMEJhNpXHsh +MgnmMUNcd8b4WyWCukqXRdRj2D7bMlr5D6YqLU71e5/OIS220Wzin4gkb+hXaKYZ +AyOsGVOdlaLeWKDhL1WH9Uy9DWjyC7+OdxBUoRTc5tX658bUGOkKrz3fO/5UBcvS +v+SGoORVC4EyT1BhrKHGEc61qZSYAEKjyROYqKcCdhy4YEl61duri8Qf4Fw4FIQr +Au6IZ+4urPDF34lYkQMOoALUaP1/WFM7GL1oEl0rZMcCdymHpnZ+InmNylZbbbI4 +RObEctQglW3+0TzyNKOYi91x5h+lAgMBAAGjQjBAMB0GA1UdDgQWBBRTAMsY4/2y +3ep23M7TU3y2/It6cjAfBgNVHSMEGDAWgBR/FhQKMlLVWFH6+TTs6Bi9ukLA5TAN +BgkqhkiG9w0BAQsFAAOCAQEAyfdqlnrQ1DXzz0Dis38DNcjulVMINSzM62+8y93y +wQA+XuLyXsDv8GLKD1JsJb1L7jKFJPtgk0drGlTSuCdHJiPZNBAGspHRVxEffMZO +k/vlCOHDIwInSaDY9gKLXuib91N3MHSF4AYnNpYPZRGu4GZZ2B2WJ6JBVA0BWLjb +h0A3zxz55VhXkB75I3KIKgqTOSXQ57A5HTIt9vsX2kLsvkEFeoGfzic9AQcGOZwm +2kxJrfw5gVKG8hS2xndadU+KDqtKIbkMrJ+ooNz7xOgZUaG5at6YzikhXAVdPf5s +U6wMXeEAJ71wTeTUvwKoI3EEiJBUAfsd5BFqzVxOpMWlig== +-----END CERTIFICATE-----` diff --git a/packages/auto-tls/test/index.spec.ts b/packages/auto-tls/test/index.spec.ts index 6873d22022..43c9880d8f 100644 --- a/packages/auto-tls/test/index.spec.ts +++ b/packages/auto-tls/test/index.spec.ts @@ -1,5 +1,275 @@ +import { generateKeyPair } from '@libp2p/crypto/keys' +import { TypedEventEmitter, start, stop } from '@libp2p/interface' +import { defaultLogger } from '@libp2p/logger' +import { peerIdFromPrivateKey } from '@libp2p/peer-id' +import { multiaddr } from '@multiformats/multiaddr' +import { expect } from 'aegir/chai' +import { MemoryDatastore } from 'datastore-core' +import delay from 'delay' +import { Key, type Datastore } from 'interface-datastore' +import { pEvent } from 'p-event' +import Sinon from 'sinon' +import { stubInterface } from 'sinon-ts' +import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' +import { AutoTLS } from '../src/auto-tls.js' +import { DEFAULT_CERTIFICATE_DATASTORE_KEY, DEFAULT_CERTIFICATE_PRIVATE_KEY_NAME } from '../src/constants.js' +import { importFromPem } from '../src/utils.js' +import { CERT, CERT_FOR_OTHER_KEY, EXPIRED_CERT, INVALID_CERT, PRIVATE_KEY_PEM } from './fixtures/cert.js' +import type { ComponentLogger, Libp2pEvents, Peer, PeerId, PrivateKey, RSAPrivateKey, TypedEventTarget } from '@libp2p/interface' +import type { AddressManager } from '@libp2p/interface-internal' +import type { Keychain } from '@libp2p/keychain' +import type { StubbedInstance } from 'sinon-ts' + +interface StubbedAutoTLSComponents { + privateKey: PrivateKey + peerId: PeerId + logger: ComponentLogger + addressManager: StubbedInstance + events: TypedEventTarget + keychain: StubbedInstance + datastore: Datastore +} + describe('auto-tls', () => { - it('should fetch a TLS certificate', async () => { + let autoTLS: AutoTLS + let components: StubbedAutoTLSComponents + let certificateKey: RSAPrivateKey + + beforeEach(async () => { + const privateKey = await generateKeyPair('Ed25519') + certificateKey = importFromPem(PRIVATE_KEY_PEM) + + components = { + privateKey, + peerId: peerIdFromPrivateKey(privateKey), + logger: defaultLogger(), + addressManager: stubInterface(), + events: new TypedEventEmitter(), + keychain: stubInterface(), + datastore: new MemoryDatastore() + } + + // mixture of LAN and public addresses + components.addressManager.getAddresses.returns([ + multiaddr(`/ip4/127.0.0.1/tcp/1235/p2p/${components.peerId}`), + multiaddr(`/ip4/192.168.0.100/tcp/1235/p2p/${components.peerId}`), + multiaddr(`/ip4/82.32.57.46/tcp/2345/p2p/${components.peerId}`) + ]) + }) + + afterEach(async () => { + await stop(autoTLS) + }) + + it('should provision a TLS certificate', async () => { + autoTLS = new AutoTLS(components, { + provisionDelay: 10 + }) + await start(autoTLS) + + const eventPromise = pEvent(components.events, 'certificate:provision') + + autoTLS.fetchAcmeCertificate = Sinon.stub().resolves(CERT) + + components.keychain.exportKey.withArgs(DEFAULT_CERTIFICATE_PRIVATE_KEY_NAME).resolves(certificateKey) + + components.events.safeDispatchEvent('self:peer:update', { + detail: { + peer: stubInterface() + } + }) + + const event = await eventPromise + expect(event).to.have.nested.property('detail.cert', CERT) + expect(autoTLS.fetchAcmeCertificate).to.have.property('called', true) + }) + + it('should reuse an existing TLS certificate', async () => { + autoTLS = new AutoTLS(components, { + provisionDelay: 10 + }) + await start(autoTLS) + + const eventPromise = pEvent(components.events, 'certificate:provision') + + autoTLS.fetchAcmeCertificate = Sinon.stub().rejects(new Error('Should not have provisioned new certificate')) + + components.keychain.exportKey.withArgs(DEFAULT_CERTIFICATE_PRIVATE_KEY_NAME).resolves(certificateKey) + + await components.datastore.put(new Key(DEFAULT_CERTIFICATE_DATASTORE_KEY), uint8ArrayFromString(CERT)) + + components.events.safeDispatchEvent('self:peer:update', { + detail: { + peer: stubInterface() + } + }) + + const event = await eventPromise + expect(event).to.have.nested.property('detail.cert', CERT) + expect(autoTLS.fetchAcmeCertificate).to.have.property('called', false) + }) + + it('should provision a new TLS certificate when the existing one is corrupted', async () => { + autoTLS = new AutoTLS(components, { + provisionDelay: 10 + }) + await start(autoTLS) + + const eventPromise = pEvent(components.events, 'certificate:provision') + + autoTLS.fetchAcmeCertificate = Sinon.stub().resolves(CERT) + + components.keychain.exportKey.withArgs(DEFAULT_CERTIFICATE_PRIVATE_KEY_NAME).resolves(certificateKey) + + await components.datastore.put(new Key(DEFAULT_CERTIFICATE_DATASTORE_KEY), uint8ArrayFromString(INVALID_CERT)) + + components.events.safeDispatchEvent('self:peer:update', { + detail: { + peer: stubInterface() + } + }) + + const event = await eventPromise + expect(event).to.have.nested.property('detail.cert', CERT) + expect(autoTLS.fetchAcmeCertificate).to.have.property('called', true) + }) + + it.skip('should provision a new TLS certificate when the existing one has expired', async () => { + autoTLS = new AutoTLS(components, { + provisionDelay: 10 + }) + await start(autoTLS) + + const eventPromise = pEvent(components.events, 'certificate:provision') + + autoTLS.fetchAcmeCertificate = Sinon.stub().resolves(CERT) + + components.keychain.exportKey.withArgs(DEFAULT_CERTIFICATE_PRIVATE_KEY_NAME).resolves(certificateKey) + + await components.datastore.put(new Key(DEFAULT_CERTIFICATE_DATASTORE_KEY), uint8ArrayFromString(EXPIRED_CERT)) + + components.events.safeDispatchEvent('self:peer:update', { + detail: { + peer: stubInterface() + } + }) + + const event = await eventPromise + expect(event).to.have.nested.property('detail.cert', CERT) + expect(autoTLS.fetchAcmeCertificate).to.have.property('called', true) + }) + + it('should provision a new TLS certificate when validation fails', async () => { + autoTLS = new AutoTLS(components, { + provisionDelay: 10 + }) + await start(autoTLS) + + const eventPromise = pEvent(components.events, 'certificate:provision') + + autoTLS.fetchAcmeCertificate = Sinon.stub().resolves(CERT) + + components.keychain.exportKey.withArgs(DEFAULT_CERTIFICATE_PRIVATE_KEY_NAME).resolves(certificateKey) + + await components.datastore.put(new Key(DEFAULT_CERTIFICATE_DATASTORE_KEY), uint8ArrayFromString(CERT_FOR_OTHER_KEY)) + + components.events.safeDispatchEvent('self:peer:update', { + detail: { + peer: stubInterface() + } + }) + + const event = await eventPromise + expect(event).to.have.nested.property('detail.cert', CERT) + expect(autoTLS.fetchAcmeCertificate).to.have.property('called', true) + }) + + it('should not provision when there are no public addresses', async () => { + autoTLS = new AutoTLS(components, { + provisionDelay: 10 + }) + await start(autoTLS) + + // mixture of LAN and public addresses + components.addressManager.getAddresses.returns([ + multiaddr(`/ip4/127.0.0.1/tcp/1235/p2p/${components.peerId}`), + multiaddr(`/ip4/192.168.0.100/tcp/1235/p2p/${components.peerId}`) + ]) + + let dispatched = 0 + + components.events.addEventListener('certificate:provision', () => { + dispatched++ + }) + components.events.addEventListener('certificate:renew', () => { + dispatched++ + }) + + await delay(1000) + + expect(dispatched).to.equal(0) + }) + + it('should not provision when there are no supported addresses', async () => { + autoTLS = new AutoTLS(components, { + provisionDelay: 10 + }) + await start(autoTLS) + + // mixture of LAN and public addresses + components.addressManager.getAddresses.returns([ + multiaddr(`/ip4/82.32.57.46/tcp/2345/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN/p2p-circuit/p2p/${components.peerId}`) + ]) + + let dispatched = 0 + + components.events.addEventListener('certificate:provision', () => { + dispatched++ + }) + components.events.addEventListener('certificate:renew', () => { + dispatched++ + }) + + await delay(1000) + + expect(dispatched).to.equal(0) + }) + + it('should remap domain names when the external IP address changes', async () => { + autoTLS = new AutoTLS(components, { + provisionDelay: 10 + }) + await start(autoTLS) + + const eventPromise = pEvent(components.events, 'certificate:provision') + + autoTLS.fetchAcmeCertificate = Sinon.stub().resolves(CERT) + + components.keychain.exportKey.withArgs(DEFAULT_CERTIFICATE_PRIVATE_KEY_NAME).resolves(certificateKey) + + await components.datastore.put(new Key(DEFAULT_CERTIFICATE_DATASTORE_KEY), uint8ArrayFromString(CERT_FOR_OTHER_KEY)) + + components.events.safeDispatchEvent('self:peer:update', { + detail: { + peer: stubInterface() + } + }) + + const event = await eventPromise + expect(event).to.have.nested.property('detail.cert', CERT) + expect(autoTLS.fetchAcmeCertificate).to.have.property('called', true) + + // a different external address is reported + components.addressManager.getAddresses.returns([ + multiaddr(`/ip4/127.0.0.1/tcp/1235/p2p/${components.peerId}`), + multiaddr(`/ip4/192.168.0.100/tcp/1235/p2p/${components.peerId}`), + multiaddr(`/ip4/64.23.65.25/tcp/2345/p2p/${components.peerId}`) + ]) + components.events.safeDispatchEvent('self:peer:update', { + detail: { + peer: stubInterface() + } + }) }) }) diff --git a/packages/auto-tls/test/utils.spec.ts b/packages/auto-tls/test/utils.spec.ts new file mode 100644 index 0000000000..17a818885f --- /dev/null +++ b/packages/auto-tls/test/utils.spec.ts @@ -0,0 +1,92 @@ +import { createPrivateKey } from 'node:crypto' +import { generateKeyPair } from '@libp2p/crypto/keys' +import { multiaddr } from '@multiformats/multiaddr' +import { expect } from 'aegir/chai' +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' +import { formatAsPem, getPublicIps, importFromPem } from '../src/utils.js' +import { PRIVATE_KEY_PEM } from './fixtures/cert.js' + +describe('utils', () => { + describe('formatAsPem', () => { + it('should transform a key to pem', async () => { + const bits = 1024 + const key = await generateKeyPair('RSA', bits) + const pem = formatAsPem(key) + + const keyObject = createPrivateKey({ + format: 'pem', + key: pem + }) + + expect(keyObject.type).to.equal('private') + expect(keyObject.asymmetricKeyType).to.equal('rsa') + expect(keyObject.asymmetricKeyDetails?.modulusLength).to.equal(bits) + + expect(key.raw).to.equalBytes(keyObject.export({ + format: 'der', + type: 'pkcs1' + })) + }) + }) + + describe('importFromPem', () => { + it('should read a key from pem', async () => { + const key = importFromPem(PRIVATE_KEY_PEM) + const digest = await crypto.subtle.digest('SHA-1', key.publicKey.raw) + const thumbprint = uint8ArrayToString(new Uint8Array(digest, 0, digest.byteLength), 'base16') + + expect(key.type).to.equal('RSA') + expect(thumbprint).to.equal('5f3a7c26f15600df20648213777783661ccdcfcf') + }) + }) + + describe('getPublicIps', () => { + it('should return supported public IPs', () => { + const addresses = [ + // tcp + '/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN', + + // insecure ws + '/tcp/1234/ws/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN', + + // secure wss + '/tcp/1234/wss/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN', + + // secure tls/ws + '/tcp/1234/tls/ws/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN', + + // secure tls/ws with sni + '/tcp/1234/tls/sni/example.com/ws/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN', + + // quic-v1 + '/udp/1234/quic-v1/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN', + + // webtransport + '/udp/1234/quic-v1/webtransport/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN' + ] + + const expected: string[] = [] + + const output = getPublicIps([ + multiaddr('/ip4/127.0.0.1/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN'), + multiaddr('/ip4/192.168.1.234/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN'), + multiaddr('/dns4/example.com/tcp/1234/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN'), + + ...addresses.map((fragment, index) => { + const ip = `81.12.12.${index}` + expected.push(ip) + + return multiaddr(`/ip4/${ip}${fragment}`) + }), + ...addresses.map((fragment, index) => { + const ip = `2001:4860:4860::888${index}` + expected.push(ip) + + return multiaddr(`/ip6/${ip}${fragment}`) + }) + ]) + + expect([...output]).to.deep.equal(expected) + }) + }) +}) diff --git a/packages/interface/src/index.ts b/packages/interface/src/index.ts index eeb753e1fe..00b397497a 100644 --- a/packages/interface/src/index.ts +++ b/packages/interface/src/index.ts @@ -17,7 +17,7 @@ import type { Connection, NewStreamOptions, Stream } from './connection/index.js' import type { ContentRouting } from './content-routing/index.js' import type { TypedEventTarget } from './event-target.js' -import type { Ed25519PublicKey, PublicKey, RSAPublicKey, Secp256k1PublicKey } from './keys/index.js' +import type { Ed25519PublicKey, PrivateKey, PublicKey, RSAPublicKey, Secp256k1PublicKey } from './keys/index.js' import type { Metrics } from './metrics/index.js' import type { Ed25519PeerId, PeerId, RSAPeerId, Secp256k1PeerId, URLPeerId } from './peer-id/index.js' import type { PeerInfo } from './peer-info/index.js' @@ -285,7 +285,8 @@ export interface Libp2pEvents { 'certificate:provision': CustomEvent /** - * This event notifies listeners that a TLS certificate is available for use + * This event notifies listeners that a new TLS certificate is available for + * use. Any previous certificate may no longer be valid. */ 'certificate:renew': CustomEvent