diff --git a/.release-please-manifest.json b/.release-please-manifest.json index eec4d658e2..44b92c7181 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1 +1 @@ -{"packages/connection-encrypter-plaintext":"2.0.11","packages/connection-encrypter-tls":"2.0.11","packages/crypto":"5.0.7","packages/interface":"2.2.1","packages/interface-compliance-tests":"6.1.11","packages/interface-internal":"2.1.1","packages/kad-dht":"14.1.3","packages/keychain":"5.0.10","packages/libp2p":"2.3.1","packages/logger":"5.1.4","packages/metrics-devtools":"1.1.10","packages/metrics-prometheus":"4.2.7","packages/metrics-simple":"1.2.7","packages/multistream-select":"6.0.9","packages/peer-collections":"6.0.12","packages/peer-discovery-bootstrap":"11.0.13","packages/peer-discovery-mdns":"11.0.13","packages/peer-id":"5.0.8","packages/peer-record":"8.0.12","packages/peer-store":"11.0.12","packages/pnet":"2.0.13","packages/protocol-autonat":"2.0.12","packages/protocol-dcutr":"2.0.12","packages/protocol-echo":"2.1.3","packages/protocol-fetch":"2.0.12","packages/protocol-identify":"3.0.12","packages/protocol-perf":"4.0.13","packages/protocol-ping":"2.0.12","packages/pubsub":"10.0.12","packages/pubsub-floodsub":"10.1.11","packages/record":"4.0.4","packages/stream-multiplexer-mplex":"11.0.13","packages/transport-circuit-relay-v2":"3.1.3","packages/transport-memory":"1.0.1","packages/transport-tcp":"10.0.13","packages/transport-webrtc":"5.0.19","packages/transport-websockets":"9.0.13","packages/transport-webtransport":"5.0.18","packages/upnp-nat":"2.0.12","packages/utils":"6.2.1"} +{"packages/auto-tls":"0.0.0","packages/connection-encrypter-plaintext":"2.0.11","packages/connection-encrypter-tls":"2.0.11","packages/crypto":"5.0.7","packages/interface":"2.2.1","packages/interface-compliance-tests":"6.1.11","packages/interface-internal":"2.1.1","packages/kad-dht":"14.1.3","packages/keychain":"5.0.10","packages/libp2p":"2.3.1","packages/logger":"5.1.4","packages/metrics-devtools":"1.1.10","packages/metrics-prometheus":"4.2.7","packages/metrics-simple":"1.2.7","packages/multistream-select":"6.0.9","packages/peer-collections":"6.0.12","packages/peer-discovery-bootstrap":"11.0.13","packages/peer-discovery-mdns":"11.0.13","packages/peer-id":"5.0.8","packages/peer-record":"8.0.12","packages/peer-store":"11.0.12","packages/pnet":"2.0.13","packages/protocol-autonat":"2.0.12","packages/protocol-dcutr":"2.0.12","packages/protocol-echo":"2.1.3","packages/protocol-fetch":"2.0.12","packages/protocol-identify":"3.0.12","packages/protocol-perf":"4.0.13","packages/protocol-ping":"2.0.12","packages/pubsub":"10.0.12","packages/pubsub-floodsub":"10.1.11","packages/record":"4.0.4","packages/stream-multiplexer-mplex":"11.0.13","packages/transport-circuit-relay-v2":"3.1.3","packages/transport-memory":"1.0.1","packages/transport-tcp":"10.0.13","packages/transport-webrtc":"5.0.19","packages/transport-websockets":"9.0.13","packages/transport-webtransport":"5.0.18","packages/upnp-nat":"2.0.12","packages/utils":"6.2.1"} diff --git a/.release-please.json b/.release-please.json index 6fb8596cd7..636942d46b 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..f6bd613536 --- /dev/null +++ b/packages/auto-tls/README.md @@ -0,0 +1,109 @@ +# @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 { identify } from '@libp2p/identify' +import { keychain } from '@libp2p/keychain' +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(), + identify: identify(), + keychain: keychain(), + 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..e746b33187 --- /dev/null +++ b/packages/auto-tls/package.json @@ -0,0 +1,78 @@ +{ + "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": { + "@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/x509": "^1.12.3", + "acme-client": "^5.4.0", + "interface-datastore": "^8.3.1", + "multiformats": "^13.3.1", + "uint8arrays": "^5.1.0" + }, + "devDependencies": { + "@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 new file mode 100644 index 0000000000..f2e9201364 --- /dev/null +++ b/packages/auto-tls/src/auto-tls.ts @@ -0,0 +1,317 @@ +import { ClientAuth } from '@libp2p/http-fetch/auth' +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' +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 { 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' +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' + +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 + private readonly forgeEndpoint: URL + private readonly forgeDomain: string + private readonly acmeDirectory: URL + private readonly clientAuth: ClientAuth + private readonly provisionTimeout: number + private readonly renewThreshold: number + private started: boolean + private shutdownController?: AbortController + public certificate?: Certificate + private fetching: boolean + private readonly onSelfPeerUpdate: 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') + this.addressManager = components.addressManager + this.privateKey = components.privateKey + this.peerId = components.peerId + this.events = components.events + this.keychain = components.keychain + this.datastore = components.datastore + this.forgeEndpoint = new URL(init.forgeEndpoint ?? DEFAULT_FORGE_ENDPOINT) + this.forgeDomain = init.forgeDomain ?? DEFAULT_FORGE_DOMAIN + 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 + 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.onSelfPeerUpdate = debounce(this._onSelfPeerUpdate.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 + }) + } + + readonly [serviceCapabilities]: string[] = [ + '@libp2p/auto-tls' + ] + + get [serviceDependencies] (): string[] { + return [ + '@libp2p/identify', + '@libp2p/keychain' + ] + } + + async start (): Promise { + if (this.started) { + return + } + + await start(this.domainMapper) + 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.onSelfPeerUpdate) + this.shutdownController?.abort() + clearTimeout(this.renewTimeout) + await stop(this.onSelfPeerUpdate, this.domainMapper) + this.started = false + } + + private _onSelfPeerUpdate (): void { + const addresses = this.addressManager.getAddresses().filter(supportedAddressesFilter) + + if (addresses.length === 0) { + this.log('not fetching certificate as we have no public addresses') + return + } + + if (!this.needsRenewal(this.certificate?.notAfter)) { + this.log('certificate does not need renewal') + return + } + + if (this.fetching) { + this.log('already fetching') + return + } + + this.fetching = true + + this.fetchCertificate(addresses, { + signal: AbortSignal.timeout(this.provisionTimeout) + }) + .catch(err => { + this.log.error('error fetching certificates - %e', err) + }) + .finally(() => { + this.fetching = false + }) + } + + 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 certificatePrivateKey = await loadOrCreateKey(this.keychain, this.certificatePrivateKeyName, this.certificatePrivateKeyBits) + const { pem, cert } = await this.loadOrCreateCertificate(certificatePrivateKey, multiaddrs, options) + + 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.onSelfPeerUpdate() + }) + .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.log('dispatching %s', 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 createCsr(`*.${this.domain}`, certificatePrivateKey) + + this.log('fetching new certificate') + + // 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) + } + } + + 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) + }) + + return client.auto({ + csr, + email: this.email, + termsOfServiceAgreed: true, + challengeCreateFn: async (authz, challenge, keyAuthorization) => { + await this.configureAcmeChallengeResponse(multiaddrs, keyAuthorization, options) + }, + challengeRemoveFn: async (authz, challenge, keyAuthorization) => { + // no-op + }, + challengePriority: ['dns-01'], + skipChallengeVerification: true + }) + } + + async configureAcmeChallengeResponse (multiaddrs: Multiaddr[], keyAuthorization: string, options?: AbortOptions): Promise { + const addresses = multiaddrs.map(ma => ma.toString()) + + 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(endpoint, { + 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('%s will respond to the acme DNS challenge on our behalf', endpoint) + } + + private needsRenewal (notAfter?: Date): boolean { + if (notAfter == null) { + return true + } + + 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..b8f26e82fb --- /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 = 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' +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 new file mode 100644 index 0000000000..5e06311eef --- /dev/null +++ b/packages/auto-tls/src/index.ts @@ -0,0 +1,171 @@ +/** + * @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 { identify } from '@libp2p/identify' + * import { keychain } from '@libp2p/keychain' + * 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(), + * identify: identify(), + * keychain: keychain(), + * 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' +import type { Keychain } from '@libp2p/keychain' +import type { Datastore } from 'interface-datastore' + +export interface AutoTLSComponents { + privateKey: PrivateKey + peerId: PeerId + 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 - note + * that `/v1/_acme-challenge` will be added to the end of the URL + * + * @default 'https://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 + */ + provisionTimeout?: number + + /** + * 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 + */ + provisionDelay?: number + + /** + * How long before the expiry of the certificate to renew it in ms, defaults + * to one day + * + * @default 86_400_000 + */ + 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 { + certificate?: TLSCertificate +} + +export function autoTLS (init: AutoTLSInit = {}): (components: AutoTLSComponents) => AutoTLS { + return (components: AutoTLSComponents) => new AutoTLSClass(components, init) +} diff --git a/packages/auto-tls/src/utils.ts b/packages/auto-tls/src/utils.ts new file mode 100644 index 0000000000..0bb984941c --- /dev/null +++ b/packages/auto-tls/src/utils.ts @@ -0,0 +1,150 @@ +import { Buffer } from 'node:buffer' +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' +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 +} + +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') +} 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 new file mode 100644 index 0000000000..4c3eb57b22 --- /dev/null +++ b/packages/auto-tls/test/index.spec.ts @@ -0,0 +1,291 @@ +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', () => { + 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 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 + }) + 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('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/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..4961c77ada 100644 --- a/packages/interface/src/index.ts +++ b/packages/interface/src/index.ts @@ -53,6 +53,18 @@ export interface SignedPeerRecord { seq: bigint } +export interface TLSCertificate { + /** + * The private key that corresponds to the certificate in PEM format + */ + key: string + + /** + * The certificate chain in PEM format + */ + cert: string +} + /** * Data returned from a successful identify response */ @@ -267,6 +279,17 @@ export interface Libp2pEvents { */ 'connection:close': CustomEvent + /** + * This event notifies listeners that a TLS certificate is available for use + */ + 'certificate:provision': CustomEvent + + /** + * This event notifies listeners that a new TLS certificate is available for + * use. Any previous certificate may no longer be valid. + */ + 'certificate:renew': CustomEvent + /** * This event notifies listeners that the node has started *