UNPKG

@hashgraph/solo

Version:

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

652 lines (570 loc) 23.6 kB
// SPDX-License-Identifier: Apache-2.0 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 {type SoloLogger} from './logging/solo-logger.js'; import {Templates} from './templates.js'; import * as helpers from './helpers.js'; import chalk from 'chalk'; import {type NodeAlias, type NodeAliases} from '../types/aliases.js'; import {type NodeKeyObject, type PrivateKeyAndCertificateObject, type SoloListrTask} from '../types/index.js'; 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 {NamespaceName} from '../types/namespace/namespace-name.js'; import {type K8Factory} from '../integration/kube/k8-factory.js'; import {SecretType} from '../integration/kube/resources/secret/secret-type.js'; import * as selfsigned from 'selfsigned'; import {webcrypto} from 'node:crypto'; type NodeCryptoKey = webcrypto.CryptoKey; // eslint-disable-next-line n/no-unsupported-features/node-builtins x509.cryptoProvider.set(crypto); @injectable() export class KeyManager { private static SigningKeyAlgo: { name: string; hash: string; publicExponent: Uint8Array<ArrayBuffer>; modulusLength: number; } = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-384', publicExponent: new Uint8Array([1, 0, 1]), modulusLength: 3072, }; static SigningKeyUsage: KeyUsage[] = ['sign', 'verify']; static TLSKeyAlgo: {name: string; hash: string; publicExponent: Uint8Array<ArrayBuffer>; modulusLength: number} = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-384', publicExponent: new Uint8Array([1, 0, 1]), modulusLength: 4096, }; static TLSKeyUsage: KeyUsage[] = ['sign', 'verify']; static TLSCertKeyUsages: number = x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment | x509.KeyUsageFlags.dataEncipherment; static TLSCertKeyExtendedUsages: any[] = [x509.ExtendedKeyUsage.serverAuth, x509.ExtendedKeyUsage.clientAuth]; static ECKeyAlgo: {name: string; namedCurve: string; hash: string} = { name: 'ECDSA', namedCurve: 'P-384', hash: 'SHA-384', }; constructor(@inject(InjectTokens.SoloLogger) private readonly logger?: SoloLogger) { this.logger = patchInject(logger, InjectTokens.SoloLogger, this.constructor.name); } /** Convert NodeCryptoKey into PEM string */ async convertPrivateKeyToPem(privateKey: NodeCryptoKey): Promise<any> { const ab: ArrayBuffer = 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: string, algo: any, keyUsages: KeyUsage[] = ['sign']): Promise<CryptoKey> { if (!algo) { throw new MissingArgumentError('algo is required'); } const items: any = 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: any = 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: NodeAlias, keysDirectory: string): PrivateKeyAndCertificateObject { if (!nodeAlias) { throw new MissingArgumentError('nodeAlias is required'); } if (!keysDirectory) { throw new MissingArgumentError('keysDirectory is required'); } const keyFile: string = PathEx.join(keysDirectory, Templates.renderGossipPemPrivateKeyFile(nodeAlias)); const certFile: string = 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: NodeAlias, keysDirectory: string): PrivateKeyAndCertificateObject { if (!nodeAlias) { throw new MissingArgumentError('nodeAlias is required'); } if (!keysDirectory) { throw new MissingArgumentError('keysDirectory is required'); } const keyFile: string = PathEx.join(keysDirectory, `hedera-${nodeAlias}.key`); const certFile: string = 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: NodeAlias, nodeKey: NodeKeyObject, keysDirectory: string, nodeKeyFiles: PrivateKeyAndCertificateObject, keyName: string = '', ): Promise<PrivateKeyAndCertificateObject> { 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: any = await this.convertPrivateKeyToPem(nodeKey.privateKey); const certPems: string[] = []; 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: Error | any) { 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: NodeAlias, keysDirectory: string, algo: any, nodeKeyFiles: PrivateKeyAndCertificateObject, keyName: string = '', ): Promise<NodeKeyObject> { 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: Buffer = fs.readFileSync(nodeKeyFiles.privateKeyFile); const keyPem: string = keyBytes.toString(); const key: CryptoKey = await this.convertPemToPrivateKey(keyPem, algo); const certBytes: Buffer = fs.readFileSync(nodeKeyFiles.certificateFile); const certPems: any = x509.PemConverter.decode(certBytes.toString()); const certs: x509.X509Certificate[] = []; for (const certPem of certPems) { const cert: x509.X509Certificate = new x509.X509Certificate(certPem); certs.push(cert); } const certChain: any = 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: NodeAlias): Promise<NodeKeyObject> { try { const keyPrefix: string = constants.SIGNING_KEY_PREFIX; const currentDate: Date = new Date(); const friendlyName: string = 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: CryptoKeyPair = await crypto.subtle.generateKey( KeyManager.SigningKeyAlgo, true, KeyManager.SigningKeyUsage, ); const cert: any = 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: any = 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: Error | any) { 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: NodeAlias, nodeKey: NodeKeyObject, keysDirectory: string, ): Promise<PrivateKeyAndCertificateObject> { const nodeKeyFiles: PrivateKeyAndCertificateObject = 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: NodeAlias, keysDirectory: string): Promise<NodeKeyObject> { const nodeKeyFiles: PrivateKeyAndCertificateObject = this.prepareNodeKeyFilePaths(nodeAlias, keysDirectory); return this.loadNodeKey(nodeAlias, keysDirectory, KeyManager.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: NodeAlias, distinguishedName: x509.Name = new x509.Name(`CN=${nodeAlias}`), ): Promise<NodeKeyObject> { if (!nodeAlias) { throw new MissingArgumentError('nodeAlias is required'); } if (!distinguishedName) { throw new MissingArgumentError('distinguishedName is required'); } try { const currentDate: Date = new Date(); this.logger.debug(`generating gRPC TLS for node: ${nodeAlias}`, {distinguishedName}); // eslint-disable-next-line n/no-unsupported-features/node-builtins const keypair: CryptoKeyPair = await crypto.subtle.generateKey( KeyManager.TLSKeyAlgo, true, KeyManager.TLSKeyUsage, ); const cert: any = 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.TLSCertKeyUsages, true), new x509.ExtendedKeyUsageExtension(KeyManager.TLSCertKeyExtendedUsages, true), await x509.SubjectKeyIdentifierExtension.create(keypair.publicKey, false), await x509.AuthorityKeyIdentifierExtension.create(keypair.publicKey, false), ], }); const certChain: any = 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: Error | any) { 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: NodeAlias, nodeKey: NodeKeyObject, keysDirectory: string, ): Promise<PrivateKeyAndCertificateObject> { const nodeKeyFiles: PrivateKeyAndCertificateObject = 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: NodeAlias, keysDirectory: string): Promise<NodeKeyObject> { const nodeKeyFiles: PrivateKeyAndCertificateObject = this.prepareTlsKeyFilePaths(nodeAlias, keysDirectory); return this.loadNodeKey(nodeAlias, keysDirectory, KeyManager.TLSKeyAlgo, nodeKeyFiles, 'gRPC TLS'); } copyNodeKeysToStaging(nodeKey: PrivateKeyAndCertificateObject, destinationDirectory: string): void { for (const keyFile of [nodeKey.privateKeyFile, nodeKey.certificateFile]) { if (!fs.existsSync(keyFile)) { throw new SoloError(`file (${keyFile}) is missing`); } const fileName: string = path.basename(keyFile); fs.cpSync(keyFile, PathEx.join(destinationDirectory, fileName)); } } copyGossipKeysToStaging(keysDirectory: string, stagingKeysDirectory: string, nodeAliases: NodeAliases): void { // copy gossip keys to the staging for (const nodeAlias of nodeAliases) { const signingKeyFiles: PrivateKeyAndCertificateObject = 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: NodeAliases, keysDirectory: string, currentDate = new Date(), _allNodeAliases: NodeAliases | null = 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: SoloListrTask<any>[] = [ { title: 'Backup old files', task: (): string => 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: NodeAliases, keysDirectory: string, currentDate: Date = new Date(), ): SoloListrTask<any>[] { // check if nodeAliases is an array of strings if (!Array.isArray(nodeAliases) || !nodeAliases.every((nodeAlias): boolean => typeof nodeAlias === 'string')) { throw new SoloError('nodeAliases must be an array of strings'); } const nodeKeyFiles = new Map(); const subTasks: SoloListrTask<any>[] = [ { title: 'Backup old files', task: (): string => 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: string): Uint8Array<ArrayBuffer> { const certPem: string = fs.readFileSync(pemCertFullPath).toString(); const decodedDers: any = 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> */ public static async createTlsSecret( k8Factory: K8Factory, namespace: NamespaceName, domainName: string, cacheDirectory: string, secretName: string, ): Promise<void> { const caSecretName: string = secretName; const generateDirectory: string = PathEx.join(cacheDirectory); // Generate TLS certificate and key const {certificatePath, keyPath}: {certificatePath: string; keyPath: string} = await KeyManager.generateTls( generateDirectory, domainName, ); try { const certData: string = fs.readFileSync(certificatePath).toString(); const keyData: string = fs.readFileSync(keyPath).toString(); const data: Record<string, string> = { '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: boolean = 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: Error | any) { const errorMessage: string = '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 */ public static async generateTls( directory: string, name: string = 'localhost', expireDays: number = 365, ): Promise<{certificatePath: string; keyPath: string}> { // Define attributes for the certificate const attributes: {name: string; value: string}[] = [{name: 'commonName', value: name}]; const certificatePath: string = PathEx.join(directory, `${name}.crt`); const keyPath: string = PathEx.join(directory, `${name}.key`); // Generate the certificate and key try { const notBeforeDate: Date = new Date(); const notAfterDate: Date = new Date(notBeforeDate); notAfterDate.setDate(notAfterDate.getDate() + expireDays); const pems: {private: string; public: string; cert: string; fingerprint: string} = 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: Error | unknown) { const errorMessage: string = error instanceof Error ? error.message : String(error); throw new SoloError(`Error generating TLS keys: ${errorMessage}`); } } }