UNPKG

@hashgraph/solo

Version:

An opinionated CLI tool to deploy and manage private Hedera Networks.

523 lines 24 kB
// SPDX-License-Identifier: Apache-2.0 var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __param = (this && this.__param) || function (paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }; var KeyManager_1; import * as x509 from '@peculiar/x509'; import fs from 'node:fs'; import path from 'node:path'; import { SoloError } from './errors/solo-error.js'; import { IllegalArgumentError } from './errors/illegal-argument-error.js'; import { MissingArgumentError } from './errors/missing-argument-error.js'; import * as constants from './constants.js'; import { Templates } from './templates.js'; import * as helpers from './helpers.js'; import chalk from 'chalk'; import { inject, injectable } from 'tsyringe-neo'; import { patchInject } from './dependency-injection/container-helper.js'; import { InjectTokens } from './dependency-injection/inject-tokens.js'; import { PathEx } from '../business/utils/path-ex.js'; import { SecretType } from '../integration/kube/resources/secret/secret-type.js'; import * as selfsigned from 'selfsigned'; import { webcrypto } from 'node:crypto'; // eslint-disable-next-line n/no-unsupported-features/node-builtins x509.cryptoProvider.set(crypto); let KeyManager = class KeyManager { static { KeyManager_1 = this; } logger; static SigningKeyAlgo = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-384', publicExponent: new Uint8Array([1, 0, 1]), modulusLength: 3072, }; static SigningKeyUsage = ['sign', 'verify']; static TLSKeyAlgo = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-384', publicExponent: new Uint8Array([1, 0, 1]), modulusLength: 4096, }; static TLSKeyUsage = ['sign', 'verify']; static TLSCertKeyUsages = x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment | x509.KeyUsageFlags.dataEncipherment; static TLSCertKeyExtendedUsages = [x509.ExtendedKeyUsage.serverAuth, x509.ExtendedKeyUsage.clientAuth]; static ECKeyAlgo = { name: 'ECDSA', namedCurve: 'P-384', hash: 'SHA-384', }; constructor(logger) { this.logger = logger; this.logger = patchInject(logger, InjectTokens.SoloLogger, this.constructor.name); } /** Convert NodeCryptoKey into PEM string */ async convertPrivateKeyToPem(privateKey) { const ab = await webcrypto.subtle.exportKey('pkcs8', privateKey); return x509.PemConverter.encode(ab, 'PRIVATE KEY'); } /** * Convert PEM private key into NodeCryptoKey * @param pemStr - PEM string * @param algo - key algorithm * @param [keyUsages] */ async convertPemToPrivateKey(pemString, algo, keyUsages = ['sign']) { if (!algo) { throw new MissingArgumentError('algo is required'); } const items = x509.PemConverter.decode(pemString); // Since pem file may include multiple PEM data, the decoder returns an array // However for private key there should be a single item. // So, we just being careful here to pick the last item (similar to how last PEM data represents the actual cert in // a certificate bundle) const lastItem = items.at(-1); // eslint-disable-next-line n/no-unsupported-features/node-builtins return await crypto.subtle.importKey('pkcs8', lastItem, algo, false, keyUsages); } /** * Return file names for node key * @param nodeAlias * @param keysDirectory - directory where keys and certs are stored */ prepareNodeKeyFilePaths(nodeAlias, keysDirectory) { if (!nodeAlias) { throw new MissingArgumentError('nodeAlias is required'); } if (!keysDirectory) { throw new MissingArgumentError('keysDirectory is required'); } const keyFile = PathEx.join(keysDirectory, Templates.renderGossipPemPrivateKeyFile(nodeAlias)); const certFile = PathEx.join(keysDirectory, Templates.renderGossipPemPublicKeyFile(nodeAlias)); return { privateKeyFile: keyFile, certificateFile: certFile, }; } /** * Return file names for TLS key * @param nodeAlias * @param keysDirectory - directory where keys and certs are stored */ prepareTlsKeyFilePaths(nodeAlias, keysDirectory) { if (!nodeAlias) { throw new MissingArgumentError('nodeAlias is required'); } if (!keysDirectory) { throw new MissingArgumentError('keysDirectory is required'); } const keyFile = PathEx.join(keysDirectory, `hedera-${nodeAlias}.key`); const certFile = PathEx.join(keysDirectory, `hedera-${nodeAlias}.crt`); return { privateKeyFile: keyFile, certificateFile: certFile, }; } /** * Store node keys and certs as PEM files * @param nodeAlias * @param nodeKey * @param keysDirectory - directory where keys and certs are stored * @param nodeKeyFiles * @param [keyName] - optional key type name for logging * @returns a Promise that saves the keys and certs as PEM files */ async storeNodeKey(nodeAlias, nodeKey, keysDirectory, nodeKeyFiles, keyName = '') { if (!nodeAlias) { throw new MissingArgumentError('nodeAlias is required'); } if (!nodeKey || !nodeKey.privateKey) { throw new MissingArgumentError('nodeKey.ed25519PrivateKey is required'); } if (!nodeKey || !nodeKey.certificateChain) { throw new MissingArgumentError('nodeKey.certificateChain is required'); } if (!keysDirectory) { throw new MissingArgumentError('keysDirectory is required'); } if (!nodeKeyFiles || !nodeKeyFiles.privateKeyFile) { throw new MissingArgumentError('nodeKeyFiles.privateKeyFile is required'); } if (!nodeKeyFiles || !nodeKeyFiles.certificateFile) { throw new MissingArgumentError('nodeKeyFiles.certificateFile is required'); } const keyPem = await this.convertPrivateKeyToPem(nodeKey.privateKey); const certPems = []; for (const cert of nodeKey.certificateChain) { certPems.push(cert.toString('pem')); } return new Promise((resolve, reject) => { try { this.logger.debug(`Storing ${keyName} key for node: ${nodeAlias}`, { nodeKeyFiles }); fs.writeFileSync(nodeKeyFiles.privateKeyFile, keyPem); // remove if the certificate file exists already as otherwise we'll keep appending to the last if (fs.existsSync(nodeKeyFiles.certificateFile)) { fs.rmSync(nodeKeyFiles.certificateFile); } for (const certPem of certPems) { fs.writeFileSync(nodeKeyFiles.certificateFile, certPem + '\n', { flag: 'a' }); } this.logger.debug(`Stored ${keyName} key for node: ${nodeAlias}`, { nodeKeyFiles, }); resolve(nodeKeyFiles); } catch (error) { reject(error); } }); } /** * Load node keys and certs from PEM files * @param nodeAlias * @param keysDirectory - directory where keys and certs are stored * @param algo - algorithm used for key * @param nodeKeyFiles an object stores privateKeyFile and certificateFile * @param [keyName] - optional key type name for logging * @returns */ async loadNodeKey(nodeAlias, keysDirectory, algo, nodeKeyFiles, keyName = '') { if (!nodeAlias) { throw new MissingArgumentError('nodeAlias is required'); } if (!keysDirectory) { throw new MissingArgumentError('keysDirectory is required'); } if (!algo) { throw new MissingArgumentError('algo is required'); } if (!nodeKeyFiles || !nodeKeyFiles.privateKeyFile) { throw new MissingArgumentError('nodeKeyFiles.privateKeyFile is required'); } if (!nodeKeyFiles || !nodeKeyFiles.certificateFile) { throw new MissingArgumentError('nodeKeyFiles.certificateFile is required'); } this.logger.debug(`Loading ${keyName}-keys for node: ${nodeAlias}`, { nodeKeyFiles }); const keyBytes = fs.readFileSync(nodeKeyFiles.privateKeyFile); const keyPem = keyBytes.toString(); const key = await this.convertPemToPrivateKey(keyPem, algo); const certBytes = fs.readFileSync(nodeKeyFiles.certificateFile); const certPems = x509.PemConverter.decode(certBytes.toString()); const certs = []; for (const certPem of certPems) { const cert = new x509.X509Certificate(certPem); certs.push(cert); } const certChain = await new x509.X509ChainBuilder({ certificates: certs.slice(1) }).build(certs[0]); this.logger.debug(`Loaded ${keyName}-key for node: ${nodeAlias}`, { nodeKeyFiles, cert: certs[0].toString('pem'), }); return { privateKey: key, certificate: certs[0], certificateChain: certChain, }; } /** Generate signing key and certificate */ async generateSigningKey(nodeAlias) { try { const keyPrefix = constants.SIGNING_KEY_PREFIX; const currentDate = new Date(); const friendlyName = Templates.renderNodeFriendlyName(keyPrefix, nodeAlias); this.logger.debug(`generating ${keyPrefix}-key for node: ${nodeAlias}`, { friendlyName }); // eslint-disable-next-line n/no-unsupported-features/node-builtins const keypair = await crypto.subtle.generateKey(KeyManager_1.SigningKeyAlgo, true, KeyManager_1.SigningKeyUsage); const cert = await x509.X509CertificateGenerator.createSelfSigned({ serialNumber: '01', name: `CN=${friendlyName}`, notBefore: currentDate, // @ts-ignore notAfter: new Date().setFullYear(currentDate.getFullYear() + constants.CERTIFICATE_VALIDITY_YEARS), keys: keypair, extensions: [ new x509.BasicConstraintsExtension(true, 1, true), new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.serverAuth, x509.ExtendedKeyUsage.clientAuth], true), new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), await x509.SubjectKeyIdentifierExtension.create(keypair.publicKey), ], }); const certChain = await new x509.X509ChainBuilder().build(cert); this.logger.debug(`generated ${keyPrefix}-key for node: ${nodeAlias}`, { cert: cert.toString('pem') }); return { privateKey: keypair.privateKey, certificate: cert, certificateChain: certChain, }; } catch (error) { throw new SoloError(`failed to generate signing key: ${error.message}`, error); } } /** * Store signing key and certificate * @param nodeAlias * @param nodeKey - an object containing privateKeyPem, certificatePem data * @param keysDirectory - directory where keys and certs are stored * @returns returns a Promise that saves the keys and certs as PEM files */ storeSigningKey(nodeAlias, nodeKey, keysDirectory) { const nodeKeyFiles = this.prepareNodeKeyFilePaths(nodeAlias, keysDirectory); return this.storeNodeKey(nodeAlias, nodeKey, keysDirectory, nodeKeyFiles, 'signing'); } /** * Load signing key and certificate * @param nodeAlias * @param keysDirectory - directory path where pem files are stored */ loadSigningKey(nodeAlias, keysDirectory) { const nodeKeyFiles = this.prepareNodeKeyFilePaths(nodeAlias, keysDirectory); return this.loadNodeKey(nodeAlias, keysDirectory, KeyManager_1.SigningKeyAlgo, nodeKeyFiles, 'signing'); } /** * Generate gRPC TLS key * * It generates TLS keys in PEM format such as below: * hedera-<nodeAlias>.key * hedera-<nodeAlias>.crt * * @param nodeAlias * @param distinguishedName distinguished name as: new x509.Name(`CN=${nodeAlias},ST=${state},L=${locality},O=${org},OU=${orgUnit},C=${country}`) */ async generateGrpcTlsKey(nodeAlias, distinguishedName = new x509.Name(`CN=${nodeAlias}`)) { if (!nodeAlias) { throw new MissingArgumentError('nodeAlias is required'); } if (!distinguishedName) { throw new MissingArgumentError('distinguishedName is required'); } try { const currentDate = new Date(); this.logger.debug(`generating gRPC TLS for node: ${nodeAlias}`, { distinguishedName }); // eslint-disable-next-line n/no-unsupported-features/node-builtins const keypair = await crypto.subtle.generateKey(KeyManager_1.TLSKeyAlgo, true, KeyManager_1.TLSKeyUsage); const cert = await x509.X509CertificateGenerator.createSelfSigned({ serialNumber: '01', name: distinguishedName, notBefore: currentDate, // @ts-ignore notAfter: new Date().setFullYear(currentDate.getFullYear() + constants.CERTIFICATE_VALIDITY_YEARS), keys: keypair, extensions: [ new x509.BasicConstraintsExtension(false, 0, true), new x509.KeyUsagesExtension(KeyManager_1.TLSCertKeyUsages, true), new x509.ExtendedKeyUsageExtension(KeyManager_1.TLSCertKeyExtendedUsages, true), await x509.SubjectKeyIdentifierExtension.create(keypair.publicKey, false), await x509.AuthorityKeyIdentifierExtension.create(keypair.publicKey, false), ], }); const certChain = await new x509.X509ChainBuilder().build(cert); this.logger.debug(`generated gRPC TLS for node: ${nodeAlias}`, { cert: cert.toString('pem') }); return { privateKey: keypair.privateKey, certificate: cert, certificateChain: certChain, }; } catch (error) { throw new SoloError(`failed to generate gRPC TLS key: ${error.message}`, error); } } /** * Store TLS key and certificate * @param nodeAlias * @param nodeKey * @param keysDirectory - directory where keys and certs are stored * @returns a Promise that saves the keys and certs as PEM files */ storeTLSKey(nodeAlias, nodeKey, keysDirectory) { const nodeKeyFiles = this.prepareTlsKeyFilePaths(nodeAlias, keysDirectory); return this.storeNodeKey(nodeAlias, nodeKey, keysDirectory, nodeKeyFiles, 'gRPC TLS'); } /** * Load TLS key and certificate * @param nodeAlias * @param keysDirectory - directory path where pem files are stored */ loadTLSKey(nodeAlias, keysDirectory) { const nodeKeyFiles = this.prepareTlsKeyFilePaths(nodeAlias, keysDirectory); return this.loadNodeKey(nodeAlias, keysDirectory, KeyManager_1.TLSKeyAlgo, nodeKeyFiles, 'gRPC TLS'); } copyNodeKeysToStaging(nodeKey, destinationDirectory) { for (const keyFile of [nodeKey.privateKeyFile, nodeKey.certificateFile]) { if (!fs.existsSync(keyFile)) { throw new SoloError(`file (${keyFile}) is missing`); } const fileName = path.basename(keyFile); fs.cpSync(keyFile, PathEx.join(destinationDirectory, fileName)); } } copyGossipKeysToStaging(keysDirectory, stagingKeysDirectory, nodeAliases) { // copy gossip keys to the staging for (const nodeAlias of nodeAliases) { const signingKeyFiles = this.prepareNodeKeyFilePaths(nodeAlias, keysDirectory); this.copyNodeKeysToStaging(signingKeyFiles, stagingKeysDirectory); } } /** * Return a list of subtasks to generate gossip keys * * WARNING: These tasks MUST run in sequence. * * @param nodeAliases * @param keysDirectory - keys directory * @param curDate - current date * @param [allNodeAliases] - includes the nodeAliases to get new keys as well as existing nodeAliases that will be included in the public.pfx file * @returns a list of subtasks */ taskGenerateGossipKeys(nodeAliases, keysDirectory, currentDate = new Date(), _allNodeAliases = null) { if (!Array.isArray(nodeAliases) || !nodeAliases.every(nodeAlias => typeof nodeAlias === 'string')) { throw new IllegalArgumentError('nodeAliases must be an array of strings, nodeAliases = ' + JSON.stringify(nodeAliases)); } const subTasks = [ { title: 'Backup old files', task: () => helpers.backupOldPemKeys(nodeAliases, keysDirectory, currentDate), }, ]; for (const nodeAlias of nodeAliases) { subTasks.push({ title: `Gossip key for node: ${chalk.yellow(nodeAlias)}`, task: async () => { const signingKey = await this.generateSigningKey(nodeAlias); const signingKeyFiles = await this.storeSigningKey(nodeAlias, signingKey, keysDirectory); this.logger.debug(`generated Gossip signing keys for node ${nodeAlias}`, { keyFiles: signingKeyFiles }); }, }); } return subTasks; } /** * Return a list of subtasks to generate gRPC TLS keys * * WARNING: These tasks should run in sequence * * @param nodeAliases * @param keysDirectory keys directory * @param curDate current date * @returns return a list of subtasks */ taskGenerateTLSKeys(nodeAliases, keysDirectory, currentDate = new Date()) { // check if nodeAliases is an array of strings if (!Array.isArray(nodeAliases) || !nodeAliases.every((nodeAlias) => typeof nodeAlias === 'string')) { throw new SoloError('nodeAliases must be an array of strings'); } const nodeKeyFiles = new Map(); const subTasks = [ { title: 'Backup old files', task: () => helpers.backupOldTlsKeys(nodeAliases, keysDirectory, currentDate), }, ]; for (const nodeAlias of nodeAliases) { subTasks.push({ title: `TLS key for node: ${chalk.yellow(nodeAlias)}`, task: async () => { const tlsKey = await this.generateGrpcTlsKey(nodeAlias); const tlsKeyFiles = await this.storeTLSKey(nodeAlias, tlsKey, keysDirectory); nodeKeyFiles.set(nodeAlias, { tlsKeyFiles, }); }, }); } return subTasks; } /** * Given the path to the PEM certificate (Base64 ASCII), will return the DER (raw binary) bytes * @param pemCertFullPath */ getDerFromPemCertificate(pemCertFullPath) { const certPem = fs.readFileSync(pemCertFullPath).toString(); const decodedDers = x509.PemConverter.decode(certPem); if (!decodedDers || decodedDers.length === 0) { throw new SoloError('unable to load perm key: ' + pemCertFullPath); } return new Uint8Array(decodedDers[0]); } /** * Creates a TLS secret in Kubernetes for the Explorer * @param k8Factory Kubernetes factory instance * @param namespace Namespace to create the secret in * @param domainName Domain name for the TLS certificate * @param cacheDirectory Directory to store temporary files * @param secretName Name of the secret to create * @returns Promise<void> */ static async createTlsSecret(k8Factory, namespace, domainName, cacheDirectory, secretName) { const caSecretName = secretName; const generateDirectory = PathEx.join(cacheDirectory); // Generate TLS certificate and key const { certificatePath, keyPath } = await KeyManager_1.generateTls(generateDirectory, domainName); try { const certData = fs.readFileSync(certificatePath).toString(); const keyData = fs.readFileSync(keyPath).toString(); const data = { 'tls.crt': Buffer.from(certData).toString('base64'), 'tls.key': Buffer.from(keyData).toString('base64'), }; // Create k8s secret with the generated certificate and key const isSecretCreated = await k8Factory .default() .secrets() .createOrReplace(namespace, caSecretName, SecretType.OPAQUE, data); if (!isSecretCreated) { throw new SoloError('failed to create secret for explorer TLS certificates'); } } catch (error) { const errorMessage = 'failed to create secret for explorer TLS certificates, please check if the secret already exists'; throw new SoloError(errorMessage, error); } } /** * Generates a self-signed TLS certificate and key * @param directory Directory to store the certificate and key * @param name Common name for the certificate * @param expireDays Number of days until the certificate expires * @returns Promise with paths to the certificate and key files */ static async generateTls(directory, name = 'localhost', expireDays = 365) { // Define attributes for the certificate const attributes = [{ name: 'commonName', value: name }]; const certificatePath = PathEx.join(directory, `${name}.crt`); const keyPath = PathEx.join(directory, `${name}.key`); // Generate the certificate and key try { const notBeforeDate = new Date(); const notAfterDate = new Date(notBeforeDate); notAfterDate.setDate(notAfterDate.getDate() + expireDays); const pems = await selfsigned.generate(attributes, { keySize: 2048, algorithm: 'sha256', notBeforeDate, notAfterDate, }); fs.writeFileSync(certificatePath, pems.cert); fs.writeFileSync(keyPath, pems.private); return { certificatePath, keyPath, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new SoloError(`Error generating TLS keys: ${errorMessage}`); } } }; KeyManager = KeyManager_1 = __decorate([ injectable(), __param(0, inject(InjectTokens.SoloLogger)), __metadata("design:paramtypes", [Object]) ], KeyManager); export { KeyManager }; //# sourceMappingURL=key-manager.js.map