From 19de4720488037c020358a0444cf00adee6ab092 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Sat, 2 Nov 2024 14:24:11 +0000 Subject: [PATCH 1/8] feat: add auto-tls service Adds an optional service that requests a Let's Encrypt-style TLS certificate when publicly dialable addresses are detected. This will allow transports such as WebSockets to upgrade themselves to be the secure version. --- .release-please-manifest.json | 2 +- .release-please.json | 1 + packages/auto-tls/LICENSE | 4 + packages/auto-tls/LICENSE-APACHE | 5 + packages/auto-tls/LICENSE-MIT | 19 +++ packages/auto-tls/README.md | 105 ++++++++++++++++ packages/auto-tls/package.json | 64 ++++++++++ packages/auto-tls/src/auto-tls.ts | 181 +++++++++++++++++++++++++++ packages/auto-tls/src/index.ts | 119 ++++++++++++++++++ packages/auto-tls/test/index.spec.ts | 5 + packages/auto-tls/tsconfig.json | 21 ++++ packages/auto-tls/typedoc.json | 5 + packages/interface/src/index.ts | 27 ++++ 13 files changed, 557 insertions(+), 1 deletion(-) create mode 100644 packages/auto-tls/LICENSE create mode 100644 packages/auto-tls/LICENSE-APACHE create mode 100644 packages/auto-tls/LICENSE-MIT create mode 100644 packages/auto-tls/README.md create mode 100644 packages/auto-tls/package.json create mode 100644 packages/auto-tls/src/auto-tls.ts create mode 100644 packages/auto-tls/src/index.ts create mode 100644 packages/auto-tls/test/index.spec.ts create mode 100644 packages/auto-tls/tsconfig.json create mode 100644 packages/auto-tls/typedoc.json diff --git a/.release-please-manifest.json b/.release-please-manifest.json index c16fe6e36f..8dc9469ef7 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1 +1 @@ -{"packages/connection-encrypter-plaintext":"2.0.10","packages/connection-encrypter-tls":"2.0.10","packages/crypto":"5.0.6","packages/interface":"2.2.0","packages/interface-compliance-tests":"6.1.8","packages/interface-internal":"2.0.10","packages/kad-dht":"14.1.0","packages/keychain":"5.0.9","packages/libp2p":"2.2.1","packages/logger":"5.1.3","packages/metrics-devtools":"1.1.8","packages/metrics-prometheus":"4.2.4","packages/metrics-simple":"1.2.6","packages/multistream-select":"6.0.8","packages/peer-collections":"6.0.10","packages/peer-discovery-bootstrap":"11.0.10","packages/peer-discovery-mdns":"11.0.10","packages/peer-id":"5.0.7","packages/peer-record":"8.0.10","packages/peer-store":"11.0.10","packages/pnet":"2.0.10","packages/protocol-autonat":"2.0.10","packages/protocol-dcutr":"2.0.10","packages/protocol-echo":"2.1.1","packages/protocol-fetch":"2.0.10","packages/protocol-identify":"3.0.10","packages/protocol-perf":"4.0.10","packages/protocol-ping":"2.0.10","packages/pubsub":"10.0.10","packages/pubsub-floodsub":"10.1.8","packages/record":"4.0.4","packages/stream-multiplexer-mplex":"11.0.10","packages/transport-circuit-relay-v2":"3.1.0","packages/transport-tcp":"10.0.11","packages/transport-webrtc":"5.0.16","packages/transport-websockets":"9.0.11","packages/transport-webtransport":"5.0.16","packages/upnp-nat":"2.0.10","packages/utils":"6.1.3"} +{"packages/auto-tls":"0.0.0","packages/connection-encrypter-plaintext":"2.0.10","packages/connection-encrypter-tls":"2.0.10","packages/crypto":"5.0.6","packages/interface":"2.2.0","packages/interface-compliance-tests":"6.1.8","packages/interface-internal":"2.0.10","packages/kad-dht":"14.1.0","packages/keychain":"5.0.9","packages/libp2p":"2.2.1","packages/logger":"5.1.3","packages/metrics-devtools":"1.1.8","packages/metrics-prometheus":"4.2.4","packages/metrics-simple":"1.2.6","packages/multistream-select":"6.0.8","packages/peer-collections":"6.0.10","packages/peer-discovery-bootstrap":"11.0.10","packages/peer-discovery-mdns":"11.0.10","packages/peer-id":"5.0.7","packages/peer-record":"8.0.10","packages/peer-store":"11.0.10","packages/pnet":"2.0.10","packages/protocol-autonat":"2.0.10","packages/protocol-dcutr":"2.0.10","packages/protocol-echo":"2.1.1","packages/protocol-fetch":"2.0.10","packages/protocol-identify":"3.0.10","packages/protocol-perf":"4.0.10","packages/protocol-ping":"2.0.10","packages/pubsub":"10.0.10","packages/pubsub-floodsub":"10.1.8","packages/record":"4.0.4","packages/stream-multiplexer-mplex":"11.0.10","packages/transport-circuit-relay-v2":"3.1.0","packages/transport-tcp":"10.0.11","packages/transport-webrtc":"5.0.16","packages/transport-websockets":"9.0.11","packages/transport-webtransport":"5.0.16","packages/upnp-nat":"2.0.10","packages/utils":"6.1.3"} diff --git a/.release-please.json b/.release-please.json index 07baf97322..5256574045 100644 --- a/.release-please.json +++ b/.release-please.json @@ -9,6 +9,7 @@ { "type": "refactor", "section": "Refactors", "hidden": false } ], "packages": { + "packages/auto-tls": {}, "packages/connection-encrypter-plaintext": {}, "packages/connection-encrypter-tls": {}, "packages/crypto": {}, diff --git a/packages/auto-tls/LICENSE b/packages/auto-tls/LICENSE new file mode 100644 index 0000000000..20ce483c86 --- /dev/null +++ b/packages/auto-tls/LICENSE @@ -0,0 +1,4 @@ +This project is dual licensed under MIT and Apache-2.0. + +MIT: https://www.opensource.org/licenses/mit +Apache-2.0: https://www.apache.org/licenses/license-2.0 diff --git a/packages/auto-tls/LICENSE-APACHE b/packages/auto-tls/LICENSE-APACHE new file mode 100644 index 0000000000..14478a3b60 --- /dev/null +++ b/packages/auto-tls/LICENSE-APACHE @@ -0,0 +1,5 @@ +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/packages/auto-tls/LICENSE-MIT b/packages/auto-tls/LICENSE-MIT new file mode 100644 index 0000000000..72dc60d84b --- /dev/null +++ b/packages/auto-tls/LICENSE-MIT @@ -0,0 +1,19 @@ +The MIT License (MIT) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/auto-tls/README.md b/packages/auto-tls/README.md new file mode 100644 index 0000000000..589e14715e --- /dev/null +++ b/packages/auto-tls/README.md @@ -0,0 +1,105 @@ +# @libp2p/auto-tls + +[![libp2p.io](https://img.shields.io/badge/project-libp2p-yellow.svg?style=flat-square)](http://libp2p.io/) +[![Discuss](https://img.shields.io/discourse/https/discuss.libp2p.io/posts.svg?style=flat-square)](https://discuss.libp2p.io) +[![codecov](https://img.shields.io/codecov/c/github/libp2p/js-libp2p.svg?style=flat-square)](https://codecov.io/gh/libp2p/js-libp2p) +[![CI](https://img.shields.io/github/actions/workflow/status/libp2p/js-libp2p/main.yml?branch=main\&style=flat-square)](https://github.com/libp2p/js-libp2p/actions/workflows/main.yml?query=branch%3Amain) + +> Automatically acquire a .libp2p.direct TLS certificate + +# About + + + +When a publicly dialable address is detected, use the p2p-forge service at + to acquire a valid Let's Encrypted-backed +TLS certificate, which the node can then use with the relevant transports. + +The node must be configured with a listener for at least one of the following +transports: + +- TCP or WS or WSS, (along with the Yamux multiplexer and TLS or Noise encryption) +- QUIC-v1 +- WebTransport + +It also requires the Identify protocol. + +## Example - Use UPnP to hole punch and auto-upgrade to Secure WebSockets + +```TypeScript +import { noise } from '@chainsafe/libp2p-noise' +import { yamux } from '@chainsafe/libp2p-yamux' +import { autoTLS } from '@libp2p/auto-tls' +import { webSockets } from '@libp2p/websockets' +import { uPnPNAT } from '@libp2p/upnp-nat' +import { createLibp2p } from 'libp2p' + +const node = await createLibp2p({ + addresses: { + listen: [ + '/ip4/0.0.0.0/tcp/0/ws' + ] + }, + transports: [ + webSockets() + ], + connectionEncrypters: [ + noise() + ], + streamMuxers: [ + yamux() + ], + services: { + autoTLS: autoTLS(), + upnp: uPnPNAT() + } +}) + +// ...time passes + +console.info(node.getMultiaddrs()) +// includes public WSS address: +// [ '/ip4/123.123.123.123/tcp/12345/wss ] +``` + +# Install + +```console +$ npm i @libp2p/plaintext +``` + +## Browser ` +``` + +# API Docs + +- + +# License + +Licensed under either of + +- Apache 2.0, ([LICENSE-APACHE](https://github.com/libp2p/js-libp2p/blob/main/packages/connection-encrypter-plaintext/LICENSE-APACHE) / ) +- MIT ([LICENSE-MIT](https://github.com/libp2p/js-libp2p/blob/main/packages/connection-encrypter-plaintext/LICENSE-MIT) / ) + +# Contribution + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. diff --git a/packages/auto-tls/package.json b/packages/auto-tls/package.json new file mode 100644 index 0000000000..5e6a50bf76 --- /dev/null +++ b/packages/auto-tls/package.json @@ -0,0 +1,64 @@ +{ + "name": "@libp2p/auto-tls", + "version": "0.0.0", + "description": "Automatically acquire a .libp2p.direct TLS certificate", + "license": "Apache-2.0 OR MIT", + "homepage": "https://github.com/libp2p/js-libp2p/tree/main/packages/auto-tls#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/libp2p/js-libp2p.git" + }, + "bugs": { + "url": "https://github.com/libp2p/js-libp2p/issues" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "type": "module", + "types": "./dist/src/index.d.ts", + "files": [ + "src", + "dist", + "!dist/test", + "!**/*.tsbuildinfo" + ], + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js" + } + }, + "eslintConfig": { + "extends": "ipfs", + "parserOptions": { + "project": true, + "sourceType": "module" + } + }, + "scripts": { + "build": "aegir build --bundle false", + "test": "aegir test -t node", + "clean": "aegir clean", + "lint": "aegir lint", + "test:node": "aegir test -t node --cov", + "dep-check": "aegir dep-check", + "doc-check": "aegir doc-check" + }, + "dependencies": { + "@libp2p/http-fetch": "^2.0.2", + "@libp2p/interface": "^2.2.0", + "@libp2p/interface-internal": "^2.0.10", + "@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" + }, + "devDependencies": { + "aegir": "^44.0.1" + }, + "sideEffects": false +} diff --git a/packages/auto-tls/src/auto-tls.ts b/packages/auto-tls/src/auto-tls.ts new file mode 100644 index 0000000000..5cd224c581 --- /dev/null +++ b/packages/auto-tls/src/auto-tls.ts @@ -0,0 +1,181 @@ +import { ClientAuth } from '@libp2p/http-fetch/auth' +import { serviceDependencies, 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 { base36 } from 'multiformats/bases/base36' +import type { AutoTLSComponents, AutoTLSInit, AutoTLS as AutoTLSInterface } from './index.js' +import type { PeerId, PrivateKey, Logger, TypedEventTarget, Libp2pEvents, AbortOptions, TLSCertificate } from '@libp2p/interface' +import type { AddressManager } from '@libp2p/interface-internal' +import type { DebouncedFunction } from '@libp2p/utils/debounce' +import type { Multiaddr } from '@multiformats/multiaddr' + +const crypto = new Crypto() +x509.cryptoProvider.set(crypto) + +export class AutoTLS implements AutoTLSInterface { + private readonly log: Logger + private readonly addressManager: AddressManager + private readonly privateKey: PrivateKey + private readonly peerId: PeerId + private readonly events: TypedEventTarget + private readonly forgeEndpoint: string + private readonly forgeDomain: string + private readonly acmeDirectory: string + private readonly clientAuth: ClientAuth + private readonly timeout: number + private started: boolean + private shutdownController?: AbortController + public certificate?: TLSCertificate + private fetching: boolean + private readonly fetchCertificates: DebouncedFunction + + constructor (components: AutoTLSComponents, init: AutoTLSInit = {}) { + this.log = components.logger.forComponent('libp2p:certificate-manager') + this.addressManager = components.addressManager + 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.clientAuth = new ClientAuth(this.privateKey) + this.started = false + this.fetching = false + this.fetchCertificates = debounce(this._fetchCertificates.bind(this), init.delay ?? 5000) + } + + get [serviceDependencies] (): string[] { + return [ + '@libp2p/identify' + ] + } + + async start (): Promise { + if (this.started) { + return + } + + this.events.addEventListener('self:peer:update', this.fetchCertificates) + this.shutdownController = new AbortController() + this.started = true + } + + async stop (): Promise { + this.events.removeEventListener('self:peer:update', this.fetchCertificates) + this.shutdownController?.abort() + await stop(this.fetchCertificates) + this.started = false + } + + private _fetchCertificates (): void { + if (this.fetching || this.certificate != null) { + this.log('already fetching or already have a certificate') + 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 (addresses.length === 0) { + this.log('not fetching certificate as we have no public addresses') + return + } + + this.fetching = true + + this.fetchCertificate(addresses, { + signal: AbortSignal.timeout(this.timeout) + }) + .catch(err => { + this.log.error('error fetching certificates %e', err) + }) + .finally(() => { + this.fetching = false + }) + } + + private async fetchCertificate (mulitaddrs: 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}` + + // Create CSR + const [certificatePrivateKey, csr] = await acmeClient.forge.createCsr({ + commonName: domain, + altNames: [] + }) + + const accountPrivateKey = await acmeClient.forge.createPrivateKey() + + const client = new acmeClient.Client({ + directoryUrl: this.acmeDirectory, + accountKey: accountPrivateKey + }) + const certString = await client.auto({ + csr, + email: `${base36EncodedPeer}@libp2p.direct`, + 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) + }, + challengeRemoveFn: async (authz, challenge, keyAuthorization) => { + // no-op + }, + challengePriority: ['dns-01'], + skipChallengeVerification: true + }) + + this.log('fetched certificate', certString) + + const cert = new x509.X509Certificate(certString) + + this.certificate = { + privateKey: certificatePrivateKey, + certificate: certString, + commonName: domain, + expires: cert.notAfter + } + + // emit an event + this.events.safeDispatchEvent('certificate', { + detail: this.certificate + }) + } +} diff --git a/packages/auto-tls/src/index.ts b/packages/auto-tls/src/index.ts new file mode 100644 index 0000000000..0eaa1f5614 --- /dev/null +++ b/packages/auto-tls/src/index.ts @@ -0,0 +1,119 @@ +/** + * @packageDocumentation + * + * When a publicly dialable address is detected, use the p2p-forge service at + * https://registration.libp2p.direct to acquire a valid Let's Encrypted-backed + * TLS certificate, which the node can then use with the relevant transports. + * + * The node must be configured with a listener for at least one of the following + * transports: + * + * * TCP or WS or WSS, (along with the Yamux multiplexer and TLS or Noise encryption) + * * QUIC-v1 + * * WebTransport + * + * It also requires the Identify protocol. + * + * @example Use UPnP to hole punch and auto-upgrade to Secure WebSockets + * + * ```TypeScript + * import { noise } from '@chainsafe/libp2p-noise' + * import { yamux } from '@chainsafe/libp2p-yamux' + * import { autoTLS } from '@libp2p/auto-tls' + * import { webSockets } from '@libp2p/websockets' + * import { uPnPNAT } from '@libp2p/upnp-nat' + * import { createLibp2p } from 'libp2p' + * + * const node = await createLibp2p({ + * addresses: { + * listen: [ + * '/ip4/0.0.0.0/tcp/0/ws' + * ] + * }, + * transports: [ + * webSockets() + * ], + * connectionEncrypters: [ + * noise() + * ], + * streamMuxers: [ + * yamux() + * ], + * services: { + * autoTLS: autoTLS(), + * upnp: uPnPNAT() + * } + * }) + * + * // ...time passes + * + * console.info(node.getMultiaddrs()) + * // includes public WSS address: + * // [ '/ip4/123.123.123.123/tcp/12345/wss ] + * ``` + */ + +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' + +export interface AutoTLSComponents { + privateKey: PrivateKey + peerId: PeerId + logger: ComponentLogger + addressManager: AddressManager + events: TypedEventTarget +} + +export interface AutoTLSInit { + /** + * Where to send requests to answer an ACME DNS challenge on our behalf + * + * @default 'registration.libp2p.direct' + */ + forgeEndpoint?: string + + /** + * The top level domain under which we will request certificate for + * + * @default 'libp2p.direct' + */ + forgeDomain?: string + + /** + * Which ACME service to use - examples are: + * + * - https://api.buypass.com/acme/directory + * - https://dv.acme-v02.api.pki.goog/directory + * - https://acme-v02.api.letsencrypt.org/directory + * - https://acme.zerossl.com/v2/DV90 + * + * @default 'https://acme-v02.api.letsencrypt.org/directory' + */ + acmeDirectory?: string + + /** + * How long to attempt to acquire a certificate before timing out in ms + * + * @default 10000 + */ + timeout?: number + + /** + * Certificates are aquired 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 +} + +export interface AutoTLS { + certificate?: TLSCertificate +} + +export function autoTLS (init: AutoTLSInit = {}): (components: AutoTLSComponents) => AutoTLS { + return (components: AutoTLSComponents) => new AutoTLSClass(components, init) +} diff --git a/packages/auto-tls/test/index.spec.ts b/packages/auto-tls/test/index.spec.ts new file mode 100644 index 0000000000..6873d22022 --- /dev/null +++ b/packages/auto-tls/test/index.spec.ts @@ -0,0 +1,5 @@ +describe('auto-tls', () => { + it('should fetch a TLS certificate', async () => { + + }) +}) diff --git a/packages/auto-tls/tsconfig.json b/packages/auto-tls/tsconfig.json new file mode 100644 index 0000000000..8adc863e38 --- /dev/null +++ b/packages/auto-tls/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "aegir/src/config/tsconfig.aegir.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": [ + "src", + "test" + ], + "references": [ + { + "path": "../interface" + }, + { + "path": "../interface-internal" + }, + { + "path": "../utils" + } + ] +} diff --git a/packages/auto-tls/typedoc.json b/packages/auto-tls/typedoc.json new file mode 100644 index 0000000000..f599dc728d --- /dev/null +++ b/packages/auto-tls/typedoc.json @@ -0,0 +1,5 @@ +{ + "entryPoints": [ + "./src/index.ts" + ] +} diff --git a/packages/interface/src/index.ts b/packages/interface/src/index.ts index eb29f1d7f1..b3cfdb6cb3 100644 --- a/packages/interface/src/index.ts +++ b/packages/interface/src/index.ts @@ -53,6 +53,28 @@ export interface SignedPeerRecord { seq: bigint } +export interface TLSCertificate { + /** + * The private key that corresponds to the certificate + */ + privateKey: Uint8Array + + /** + * The common name the certificate is for + */ + commonName: string + + /** + * The date the certificate expires + */ + expires: Date + + /** + * The certificate chain in PEM format + */ + certificate: string +} + /** * Data returned from a successful identify response */ @@ -267,6 +289,11 @@ export interface Libp2pEvents { */ 'connection:close': CustomEvent + /** + * This event notifies listeners that a TLS certificate is available for use + */ + 'certificate': CustomEvent + /** * This event notifies listeners that the node has started * From 9df10d4536892e2f7421a59f5579d4c4e7e63c6b Mon Sep 17 00:00:00 2001 From: achingbrain Date: Sat, 2 Nov 2024 16:24:29 +0000 Subject: [PATCH 2/8] chore: make cert compatible with https --- packages/auto-tls/src/auto-tls.ts | 2 +- packages/interface/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/auto-tls/src/auto-tls.ts b/packages/auto-tls/src/auto-tls.ts index 5cd224c581..6a6e5ddd99 100644 --- a/packages/auto-tls/src/auto-tls.ts +++ b/packages/auto-tls/src/auto-tls.ts @@ -167,7 +167,7 @@ export class AutoTLS implements AutoTLSInterface { const cert = new x509.X509Certificate(certString) this.certificate = { - privateKey: certificatePrivateKey, + privateKey: certificatePrivateKey.toString('base64'), certificate: certString, commonName: domain, expires: cert.notAfter diff --git a/packages/interface/src/index.ts b/packages/interface/src/index.ts index b3cfdb6cb3..8d06f724d3 100644 --- a/packages/interface/src/index.ts +++ b/packages/interface/src/index.ts @@ -57,7 +57,7 @@ export interface TLSCertificate { /** * The private key that corresponds to the certificate */ - privateKey: Uint8Array + privateKey: string /** * The common name the certificate is for From 6c45d377767017def79fa95b58a4155b9be631d0 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Sun, 3 Nov 2024 17:38:11 +0000 Subject: [PATCH 3/8] chore: simplify interface --- packages/auto-tls/src/auto-tls.ts | 28 ++++++++++++++++++++++------ packages/auto-tls/src/index.ts | 7 +++++++ packages/interface/src/index.ts | 23 +++++++++-------------- 3 files changed, 38 insertions(+), 20 deletions(-) diff --git a/packages/auto-tls/src/auto-tls.ts b/packages/auto-tls/src/auto-tls.ts index 6a6e5ddd99..dfdc66a19e 100644 --- a/packages/auto-tls/src/auto-tls.ts +++ b/packages/auto-tls/src/auto-tls.ts @@ -17,6 +17,8 @@ import type { Multiaddr } from '@multiformats/multiaddr' const crypto = new Crypto() x509.cryptoProvider.set(crypto) +type CertificateEvent = 'certificate:provision' | 'certificate:renew' + export class AutoTLS implements AutoTLSInterface { private readonly log: Logger private readonly addressManager: AddressManager @@ -28,11 +30,13 @@ export class AutoTLS implements AutoTLSInterface { private readonly acmeDirectory: string private readonly clientAuth: ClientAuth private readonly timeout: number + private readonly renewThreshold: number private started: boolean private shutdownController?: AbortController public certificate?: TLSCertificate private fetching: boolean private readonly fetchCertificates: DebouncedFunction + private renewTimeout?: ReturnType constructor (components: AutoTLSComponents, init: AutoTLSInit = {}) { this.log = components.logger.forComponent('libp2p:certificate-manager') @@ -44,6 +48,7 @@ export class AutoTLS implements AutoTLSInterface { 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.clientAuth = new ClientAuth(this.privateKey) this.started = false this.fetching = false @@ -69,6 +74,7 @@ export class AutoTLS implements AutoTLSInterface { async stop (): Promise { this.events.removeEventListener('self:peer:update', this.fetchCertificates) this.shutdownController?.abort() + clearTimeout(this.renewTimeout) await stop(this.fetchCertificates) this.started = false } @@ -164,18 +170,28 @@ export class AutoTLS implements AutoTLSInterface { this.log('fetched certificate', certString) - const cert = new x509.X509Certificate(certString) + let event: CertificateEvent = 'certificate:provision' + + if (this.certificate != null) { + event = 'certificate:renew' + } this.certificate = { - privateKey: certificatePrivateKey.toString('base64'), - certificate: certString, - commonName: domain, - expires: cert.notAfter + key: certificatePrivateKey.toString('base64'), + cert: certString } // emit an event - this.events.safeDispatchEvent('certificate', { + this.events.safeDispatchEvent(event, { detail: this.certificate }) + + const cert = new x509.X509Certificate(certString) + + // schedule renewing the certificate + this.renewTimeout = setTimeout(() => { + this.certificate = undefined + this._fetchCertificates() + }, cert.notAfter.getTime() - this.renewThreshold) } } diff --git a/packages/auto-tls/src/index.ts b/packages/auto-tls/src/index.ts index 0eaa1f5614..c4c16c040e 100644 --- a/packages/auto-tls/src/index.ts +++ b/packages/auto-tls/src/index.ts @@ -108,6 +108,13 @@ export interface AutoTLSInit { * @default 5000 */ delay?: number + + /** + * How long before the expiry of the certificate to renew it in ms + * + * @default 60000 + */ + renewThreshold?: number } export interface AutoTLS { diff --git a/packages/interface/src/index.ts b/packages/interface/src/index.ts index 8d06f724d3..eeb753e1fe 100644 --- a/packages/interface/src/index.ts +++ b/packages/interface/src/index.ts @@ -55,24 +55,14 @@ export interface SignedPeerRecord { export interface TLSCertificate { /** - * The private key that corresponds to the certificate + * The private key that corresponds to the certificate in PEM format */ - privateKey: string - - /** - * The common name the certificate is for - */ - commonName: string - - /** - * The date the certificate expires - */ - expires: Date + key: string /** * The certificate chain in PEM format */ - certificate: string + cert: string } /** @@ -292,7 +282,12 @@ export interface Libp2pEvents { /** * This event notifies listeners that a TLS certificate is available for use */ - 'certificate': CustomEvent + 'certificate:provision': CustomEvent + + /** + * This event notifies listeners that a TLS certificate is available for use + */ + 'certificate:renew': CustomEvent /** * This event notifies listeners that the node has started From c114661918e48db7b81a0cac82f2786a118e4c41 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Sun, 3 Nov 2024 17:46:31 +0000 Subject: [PATCH 4/8] chore: update renew timestamp --- packages/auto-tls/src/auto-tls.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/auto-tls/src/auto-tls.ts b/packages/auto-tls/src/auto-tls.ts index dfdc66a19e..ef9d09f514 100644 --- a/packages/auto-tls/src/auto-tls.ts +++ b/packages/auto-tls/src/auto-tls.ts @@ -187,11 +187,14 @@ export class AutoTLS implements AutoTLSInterface { }) const cert = new x509.X509Certificate(certString) + const renewAt = new Date(cert.notAfter.getTime() - this.renewThreshold) + + this.log('certificate expiry %s - renewing at %s', cert.notAfter, renewAt) // schedule renewing the certificate this.renewTimeout = setTimeout(() => { this.certificate = undefined this._fetchCertificates() - }, cert.notAfter.getTime() - this.renewThreshold) + }, renewAt.getTime() - Date.now()) } } From 9a4c3cc88cb0831a4a8e1410db3fc1baeaf067d5 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Wed, 13 Nov 2024 18:40:42 +0000 Subject: [PATCH 5/8] 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 From f175dbec56c1039a14cf06687b81e2ec390f41ed Mon Sep 17 00:00:00 2001 From: achingbrain Date: Wed, 13 Nov 2024 18:49:31 +0000 Subject: [PATCH 6/8] chore: remove unused import --- packages/interface/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interface/src/index.ts b/packages/interface/src/index.ts index 00b397497a..4961c77ada 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, PrivateKey, PublicKey, RSAPublicKey, Secp256k1PublicKey } from './keys/index.js' +import type { Ed25519PublicKey, 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' From a7666c5a26791da4e5995c3c35ad73c14ab81aa2 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Fri, 15 Nov 2024 12:22:08 +0000 Subject: [PATCH 7/8] chore: pr comments --- packages/auto-tls/src/auto-tls.ts | 24 ++++++++++++------------ packages/auto-tls/src/constants.ts | 6 +++--- packages/auto-tls/src/index.ts | 5 +++-- packages/auto-tls/test/index.spec.ts | 18 +++++++++++++++++- 4 files changed, 35 insertions(+), 18 deletions(-) diff --git a/packages/auto-tls/src/auto-tls.ts b/packages/auto-tls/src/auto-tls.ts index ee7c61dfca..59502c7064 100644 --- a/packages/auto-tls/src/auto-tls.ts +++ b/packages/auto-tls/src/auto-tls.ts @@ -36,9 +36,9 @@ export class AutoTLS implements AutoTLSInterface { private readonly privateKey: PrivateKey private readonly peerId: PeerId private readonly events: TypedEventTarget - private readonly forgeEndpoint: string + private readonly forgeEndpoint: URL private readonly forgeDomain: string - private readonly acmeDirectory: string + private readonly acmeDirectory: URL private readonly clientAuth: ClientAuth private readonly provisionTimeout: number private readonly renewThreshold: number @@ -46,7 +46,7 @@ export class AutoTLS implements AutoTLSInterface { private shutdownController?: AbortController public certificate?: Certificate private fetching: boolean - private readonly fetchCertificates: DebouncedFunction + private readonly onSelfPeerUpdate: DebouncedFunction private renewTimeout?: ReturnType private readonly accountPrivateKeyName: string private readonly accountPrivateKeyBits: number @@ -65,9 +65,9 @@ export class AutoTLS implements AutoTLSInterface { this.events = components.events this.keychain = components.keychain this.datastore = components.datastore - this.forgeEndpoint = init.forgeEndpoint ?? DEFAULT_FORGE_ENDPOINT + this.forgeEndpoint = new URL(init.forgeEndpoint ?? DEFAULT_FORGE_ENDPOINT) this.forgeDomain = init.forgeDomain ?? DEFAULT_FORGE_DOMAIN - this.acmeDirectory = init.acmeDirectory ?? DEFAULT_ACME_DIRECTORY + this.acmeDirectory = new URL(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 @@ -78,7 +78,7 @@ export class AutoTLS implements AutoTLSInterface { this.clientAuth = new ClientAuth(this.privateKey) this.started = false this.fetching = false - this.fetchCertificates = debounce(this._fetchCertificates.bind(this), init.provisionDelay ?? DEFAULT_PROVISION_DELAY) + this.onSelfPeerUpdate = debounce(this._onSelfPeerUpdate.bind(this), init.provisionDelay ?? DEFAULT_PROVISION_DELAY) const base36EncodedPeer = base36.encode(this.peerId.toCID().bytes) this.domain = `${base36EncodedPeer}.${this.forgeDomain}` @@ -103,20 +103,20 @@ export class AutoTLS implements AutoTLSInterface { } await start(this.domainMapper) - this.events.addEventListener('self:peer:update', this.fetchCertificates) + this.events.addEventListener('self:peer:update', this.onSelfPeerUpdate) this.shutdownController = new AbortController() this.started = true } async stop (): Promise { - this.events.removeEventListener('self:peer:update', this.fetchCertificates) + this.events.removeEventListener('self:peer:update', this.onSelfPeerUpdate) this.shutdownController?.abort() clearTimeout(this.renewTimeout) - await stop(this.fetchCertificates, this.domainMapper) + await stop(this.onSelfPeerUpdate, this.domainMapper) this.started = false } - private _fetchCertificates (): void { + private _onSelfPeerUpdate (): void { const addresses = this.addressManager.getAddresses().filter(supportedAddressesFilter) if (addresses.length === 0) { @@ -177,7 +177,7 @@ export class AutoTLS implements AutoTLSInterface { Promise.resolve() .then(async () => { this.certificate = undefined - this.fetchCertificates() + this.onSelfPeerUpdate() }) .catch(err => { this.log.error('error renewing certificate - %e', err) @@ -267,7 +267,7 @@ export class AutoTLS implements AutoTLSInterface { async fetchAcmeCertificate (csr: Buffer, multiaddrs: Multiaddr[], options?: AbortOptions): Promise { const client = new acme.Client({ - directoryUrl: this.acmeDirectory, + directoryUrl: this.acmeDirectory.toString(), accountKey: await loadOrCreateKey(this.keychain, this.accountPrivateKeyName, this.accountPrivateKeyBits) }) diff --git a/packages/auto-tls/src/constants.ts b/packages/auto-tls/src/constants.ts index c5cc63cb44..b8f26e82fb 100644 --- a/packages/auto-tls/src/constants.ts +++ b/packages/auto-tls/src/constants.ts @@ -1,9 +1,9 @@ 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_PROVISION_TIMEOUT = 10_000 +export const DEFAULT_PROVISION_DELAY = 5_000 +export const DEFAULT_RENEWAL_THRESHOLD = 86_400_000 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' diff --git a/packages/auto-tls/src/index.ts b/packages/auto-tls/src/index.ts index 3640ae3cc1..5e06311eef 100644 --- a/packages/auto-tls/src/index.ts +++ b/packages/auto-tls/src/index.ts @@ -119,9 +119,10 @@ export interface AutoTLSInit { provisionDelay?: number /** - * How long before the expiry of the certificate to renew it in ms + * How long before the expiry of the certificate to renew it in ms, defaults + * to one day * - * @default 60000 + * @default 86_400_000 */ renewThreshold?: number diff --git a/packages/auto-tls/test/index.spec.ts b/packages/auto-tls/test/index.spec.ts index 43c9880d8f..4c3eb57b22 100644 --- a/packages/auto-tls/test/index.spec.ts +++ b/packages/auto-tls/test/index.spec.ts @@ -61,6 +61,22 @@ describe('auto-tls', () => { await stop(autoTLS) }) + it('should error with an invalid forge endpoint', () => { + expect(() => { + return new AutoTLS(components, { + forgeEndpoint: 'not a valid url' + }) + }).to.throw('Invalid URL') + }) + + it('should error with an invalid acme directory', () => { + expect(() => { + return new AutoTLS(components, { + acmeDirectory: 'not a valid url' + }) + }).to.throw('Invalid URL') + }) + it('should provision a TLS certificate', async () => { autoTLS = new AutoTLS(components, { provisionDelay: 10 @@ -134,7 +150,7 @@ describe('auto-tls', () => { expect(autoTLS.fetchAcmeCertificate).to.have.property('called', true) }) - it.skip('should provision a new TLS certificate when the existing one has expired', async () => { + it('should provision a new TLS certificate when the existing one has expired', async () => { autoTLS = new AutoTLS(components, { provisionDelay: 10 }) From 3f6b47cd4cea2d7e39b3d522184edaea75b0b5b5 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Fri, 15 Nov 2024 14:18:54 +0000 Subject: [PATCH 8/8] chore: use same crypto provider as x509 --- packages/auto-tls/src/auto-tls.ts | 30 ++++++++---------- packages/auto-tls/src/utils.ts | 51 ++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 18 deletions(-) diff --git a/packages/auto-tls/src/auto-tls.ts b/packages/auto-tls/src/auto-tls.ts index 59502c7064..f2e9201364 100644 --- a/packages/auto-tls/src/auto-tls.ts +++ b/packages/auto-tls/src/auto-tls.ts @@ -1,5 +1,5 @@ import { ClientAuth } from '@libp2p/http-fetch/auth' -import { serviceDependencies, start, stop } from '@libp2p/interface' +import { serviceCapabilities, serviceDependencies, start, stop } from '@libp2p/interface' import { debounce } from '@libp2p/utils/debounce' import { X509Certificate } from '@peculiar/x509' import * as acme from 'acme-client' @@ -10,7 +10,7 @@ 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 { createCsr, importFromPem, loadOrCreateKey, supportedAddressesFilter } from './utils.js' import type { AutoTLSComponents, AutoTLSInit, AutoTLS as AutoTLSInterface } from './index.js' import type { PeerId, PrivateKey, Logger, TypedEventTarget, Libp2pEvents, AbortOptions } from '@libp2p/interface' import type { AddressManager } from '@libp2p/interface-internal' @@ -18,7 +18,6 @@ import type { Keychain } from '@libp2p/keychain' import type { DebouncedFunction } from '@libp2p/utils/debounce' import type { Multiaddr } from '@multiformats/multiaddr' import type { Datastore } from 'interface-datastore' -import type { Buffer } from 'node:buffer' type CertificateEvent = 'certificate:provision' | 'certificate:renew' @@ -90,6 +89,10 @@ export class AutoTLS implements AutoTLSInterface { }) } + readonly [serviceCapabilities]: string[] = [ + '@libp2p/auto-tls' + ] + get [serviceDependencies] (): string[] { return [ '@libp2p/identify', @@ -185,6 +188,7 @@ export class AutoTLS implements AutoTLSInterface { }, Math.min(renewAt.getTime() - Date.now(), Math.pow(2, 31) - 1)) // emit a certificate event + this.log('dispatching %s', event) this.events.safeDispatchEvent(event, { detail: this.certificate }) @@ -200,7 +204,7 @@ export class AutoTLS implements AutoTLSInterface { this.log('creating new csr') // create CSR - const csr = await this.loadOrCreateCSR(certificatePrivateKey) + const csr = await createCsr(`*.${this.domain}`, certificatePrivateKey) this.log('fetching new certificate') @@ -256,16 +260,7 @@ export class AutoTLS implements AutoTLSInterface { } } - 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 { + async fetchAcmeCertificate (csr: string, multiaddrs: Multiaddr[], options?: AbortOptions): Promise { const client = new acme.Client({ directoryUrl: this.acmeDirectory.toString(), accountKey: await loadOrCreateKey(this.keychain, this.accountPrivateKeyName, this.accountPrivateKeyBits) @@ -289,9 +284,10 @@ export class AutoTLS implements AutoTLSInterface { async configureAcmeChallengeResponse (multiaddrs: Multiaddr[], keyAuthorization: string, options?: AbortOptions): Promise { const addresses = multiaddrs.map(ma => ma.toString()) - this.log('asking https://%s/v1/_acme-challenge to respond to the acme DNS challenge on our behalf', this.forgeEndpoint) + const endpoint = `${this.forgeEndpoint}v1/_acme-challenge` + this.log('asking %sv1/_acme-challenge to respond to the acme DNS challenge on our behalf', endpoint) this.log('dialback public addresses: %s', addresses.join(', ')) - const response = await this.clientAuth.authenticatedFetch(`https://${this.forgeEndpoint}/v1/_acme-challenge`, { + const response = await this.clientAuth.authenticatedFetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -308,7 +304,7 @@ export class AutoTLS implements AutoTLSInterface { 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) + this.log('%s will respond to the acme DNS challenge on our behalf', endpoint) } private needsRenewal (notAfter?: Date): boolean { diff --git a/packages/auto-tls/src/utils.ts b/packages/auto-tls/src/utils.ts index 8f3e0fe014..0bb984941c 100644 --- a/packages/auto-tls/src/utils.ts +++ b/packages/auto-tls/src/utils.ts @@ -1,10 +1,11 @@ import { Buffer } from 'node:buffer' -import { createPrivateKey } from 'node:crypto' +import { createPrivateKey, createPublicKey } 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 { KeyUsageFlags, KeyUsagesExtension, PemConverter, Pkcs10CertificateRequestGenerator, SubjectAlternativeNameExtension, cryptoProvider } from '@peculiar/x509' import { IncorrectKeyType } from './errors.js' import type { RSAPrivateKey } from '@libp2p/interface' import type { Keychain } from '@libp2p/keychain' @@ -99,3 +100,51 @@ export function getPublicIps (addrs: Multiaddr[]): Set { return output } + +export async function createCsr (domain: string, keyPem: string): Promise { + const signingAlgorithm = { + name: 'RSASSA-PKCS1-v1_5', + hash: { name: 'SHA-256' } + } + + // have to use the same crypto provider as Pkcs10CertificateRequestGenerator + const crypto = cryptoProvider.get() + + const jwk = createPublicKey({ + format: 'pem', + key: keyPem + }).export({ + format: 'jwk' + }) + + /* Decode PEM and import into CryptoKeyPair */ + const privateKeyDec = PemConverter.decodeFirst(keyPem.toString()) + const privateKey = await crypto.subtle.importKey('pkcs8', privateKeyDec, signingAlgorithm, true, ['sign']) + const publicKey = await crypto.subtle.importKey('jwk', jwk, signingAlgorithm, true, ['verify']) + + const extensions = [ + /* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.3 */ + new KeyUsagesExtension(KeyUsageFlags.digitalSignature | KeyUsageFlags.keyEncipherment), // eslint-disable-line no-bitwise + + /* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.6 */ + new SubjectAlternativeNameExtension([{ type: 'dns', value: domain }]) + ] + + /* Create CSR */ + const csr = await Pkcs10CertificateRequestGenerator.create({ + keys: { + privateKey, + publicKey + }, + extensions, + signingAlgorithm, + name: [{ + // @ts-expect-error herp + CN: [{ + utf8String: domain + }] + }] + }, crypto) + + return csr.toString('pem') +}