From 898dd08aa70ceb057467f8a39ca29788b6637ed6 Mon Sep 17 00:00:00 2001 From: Brian Botha Date: Fri, 15 Jul 2022 19:22:39 +1000 Subject: [PATCH] feat: added `--private-key-file` option to `CommandStart.ts` and `CommandBootstrap.ts` This should allow us to override the keypair generation with the provided private key. this will speed up agent starting. Note that the key is provided as a Pem. The `PrivateKey` type contains functions that get destroyed somewhere between `commandStart.ts` and `keyManager.createKeyManager`. So I'm using the Pem string to keep the type primitive Related #404 --- src/bin/agent/CommandStart.ts | 6 ++++ src/bin/bootstrap/CommandBootstrap.ts | 8 ++++- src/bin/errors.ts | 6 ++++ src/bin/utils/options.ts | 8 +++++ src/bin/utils/processors.ts | 26 ++++++++++++++- src/bootstrap/utils.ts | 5 +-- src/keys/KeyManager.ts | 29 ++++++++-------- src/validation/utils.ts | 18 ++++++++++ tests/bin/agent/start.test.ts | 48 +++++++++++++++++++++++++++ tests/bin/bootstrap.test.ts | 31 +++++++++++++++++ tests/keys/KeyManager.test.ts | 16 ++++++--- 11 files changed, 178 insertions(+), 23 deletions(-) diff --git a/src/bin/agent/CommandStart.ts b/src/bin/agent/CommandStart.ts index 6ccc4e9c04..7b4b2cca8f 100644 --- a/src/bin/agent/CommandStart.ts +++ b/src/bin/agent/CommandStart.ts @@ -37,6 +37,7 @@ class CommandStart extends CommandPolykey { this.addOption(binOptions.backgroundOutFile); this.addOption(binOptions.backgroundErrFile); this.addOption(binOptions.fresh); + this.addOption(binOptions.privateKeyFile); this.action(async (options) => { options.clientHost = options.clientHost ?? config.defaults.networkConfig.clientHost; @@ -88,12 +89,17 @@ class CommandStart extends CommandPolykey { const [seedNodes, defaults] = options.seedNodes; let seedNodes_ = seedNodes; if (defaults) seedNodes_ = { ...options.network, ...seedNodes }; + const privateKeyPem = + options.privateKeyFile != null + ? await binProcessors.processPrivateKeyFile(options.privateKeyFile) + : undefined; const agentConfig = { password, nodePath: options.nodePath, keysConfig: { rootKeyPairBits: options.rootKeyPairBits, recoveryCode: recoveryCodeIn, + privateKeyPemOverride: privateKeyPem, }, proxyConfig: { connConnectTime: options.connectionTimeout, diff --git a/src/bin/bootstrap/CommandBootstrap.ts b/src/bin/bootstrap/CommandBootstrap.ts index 9842653c0a..5531cd5e48 100644 --- a/src/bin/bootstrap/CommandBootstrap.ts +++ b/src/bin/bootstrap/CommandBootstrap.ts @@ -11,6 +11,7 @@ class CommandBootstrap extends CommandPolykey { this.addOption(binOptions.recoveryCodeFile); this.addOption(binOptions.rootKeyPairBits); this.addOption(binOptions.fresh); + this.addOption(binOptions.privateKeyFile); this.action(async (options) => { const bootstrapUtils = await import('../../bootstrap/utils'); const password = await binProcessors.processNewPassword( @@ -21,19 +22,24 @@ class CommandBootstrap extends CommandPolykey { options.recoveryCodeFile, this.fs, ); + const privateKeyPem = + options.privateKeyFile != null + ? await binProcessors.processPrivateKeyFile(options.privateKeyFile) + : undefined; const recoveryCodeOut = await bootstrapUtils.bootstrapState({ password, nodePath: options.nodePath, keysConfig: { rootKeyPairBits: options.rootKeyPairBits, recoveryCode: recoveryCodeIn, + privateKeyPemOverride: privateKeyPem, }, fresh: options.fresh, fs: this.fs, logger: this.logger, }); this.logger.info(`Bootstrapped ${options.nodePath}`); - process.stdout.write(recoveryCodeOut + '\n'); + if (recoveryCodeOut != null) process.stdout.write(recoveryCodeOut + '\n'); }); } } diff --git a/src/bin/errors.ts b/src/bin/errors.ts index 95951d2602..be6876a651 100644 --- a/src/bin/errors.ts +++ b/src/bin/errors.ts @@ -29,6 +29,11 @@ class ErrorCLIRecoveryCodeFileRead extends ErrorCLI { exitCode = sysexits.NOINPUT; } +class ErrorCLIPrivateKeyFileRead extends ErrorCLI { + static description = 'Failed to read private key Pem file'; + exitCode = sysexits.NOINPUT; +} + class ErrorCLIFileRead extends ErrorCLI { static description = 'Failed to read file'; exitCode = sysexits.NOINPUT; @@ -61,6 +66,7 @@ export { ErrorCLIPasswordMissing, ErrorCLIPasswordFileRead, ErrorCLIRecoveryCodeFileRead, + ErrorCLIPrivateKeyFileRead, ErrorCLIFileRead, ErrorCLIPolykeyAgentStatus, ErrorCLIPolykeyAgentProcess, diff --git a/src/bin/utils/options.ts b/src/bin/utils/options.ts index f2da17b8ca..cdb0a894c1 100644 --- a/src/bin/utils/options.ts +++ b/src/bin/utils/options.ts @@ -163,6 +163,13 @@ const noPing = new commander.Option('--no-ping', 'Skip ping step').default( true, ); +const privateKeyFile = new commander.Option( + '--private-key-file ', + 'Override key generation with a private key Pem from a file.', +) + .env('PK_PRIVATE_KEY_FILE') + .default(undefined); + export { nodePath, format, @@ -187,4 +194,5 @@ export { pullVault, forceNodeAdd, noPing, + privateKeyFile, }; diff --git a/src/bin/utils/processors.ts b/src/bin/utils/processors.ts index df43437d04..c6561eb931 100644 --- a/src/bin/utils/processors.ts +++ b/src/bin/utils/processors.ts @@ -1,5 +1,5 @@ import type { FileSystem } from '../../types'; -import type { RecoveryCode } from '../../keys/types'; +import type { RecoveryCode, PrivateKeyPem } from '../../keys/types'; import type { NodeId } from '../../nodes/types'; import type { Host, Port } from '../../network/types'; import type { @@ -403,6 +403,29 @@ async function processAuthentication( return meta; } +async function processPrivateKeyFile( + privateKeyFile: string, + fs: FileSystem = require('fs'), +): Promise { + let privateKeyPem: string; + try { + privateKeyPem = ( + await fs.promises.readFile(privateKeyFile, 'utf-8') + ).trim(); + } catch (e) { + throw new binErrors.ErrorCLIPrivateKeyFileRead(e.message, { + data: { + errno: e.errno, + syscall: e.syscall, + code: e.code, + path: e.path, + }, + cause: e, + }); + } + return privateKeyPem; +} + export { promptPassword, promptNewPassword, @@ -412,4 +435,5 @@ export { processClientOptions, processClientStatus, processAuthentication, + processPrivateKeyFile, }; diff --git a/src/bootstrap/utils.ts b/src/bootstrap/utils.ts index 60844fc197..9eece1244d 100644 --- a/src/bootstrap/utils.ts +++ b/src/bootstrap/utils.ts @@ -1,5 +1,5 @@ import type { FileSystem } from '../types'; -import type { RecoveryCode } from '../keys/types'; +import type { RecoveryCode, PrivateKeyPem } from '../keys/types'; import path from 'path'; import Logger from '@matrixai/logger'; import { DB } from '@matrixai/db'; @@ -40,11 +40,12 @@ async function bootstrapState({ rootCertDuration?: number; dbKeyBits?: number; recoveryCode?: RecoveryCode; + privateKeyPemOverride?: PrivateKeyPem; }; fresh?: boolean; fs?: FileSystem; logger?: Logger; -}): Promise { +}): Promise { const umask = 0o077; logger.info(`Setting umask to ${umask.toString(8).padStart(3, '0')}`); process.umask(umask); diff --git a/src/keys/KeyManager.ts b/src/keys/KeyManager.ts index e5a2724738..4ba29031bd 100644 --- a/src/keys/KeyManager.ts +++ b/src/keys/KeyManager.ts @@ -6,7 +6,7 @@ import type { CertificatePemChain, RecoveryCode, KeyManagerChangeData, - PrivateKey, + PrivateKeyPem, } from './types'; import type { FileSystem } from '../types'; import type { NodeId } from '../nodes/types'; @@ -41,7 +41,7 @@ class KeyManager { fs = require('fs'), logger = new Logger(this.name), recoveryCode, - privateKeyOverride, + privateKeyPemOverride, fresh = false, }: { keysPath: string; @@ -53,7 +53,7 @@ class KeyManager { fs?: FileSystem; logger?: Logger; recoveryCode?: RecoveryCode; - privateKeyOverride?: PrivateKey; + privateKeyPemOverride?: PrivateKeyPem; fresh?: boolean; }): Promise { logger.info(`Creating ${this.name}`); @@ -70,7 +70,7 @@ class KeyManager { await keyManager.start({ password, recoveryCode, - privateKeyOverride, + privateKeyPemOverride, fresh, }); logger.info(`Created ${this.name}`); @@ -138,12 +138,12 @@ class KeyManager { public async start({ password, recoveryCode, - privateKeyOverride, + privateKeyPemOverride, fresh = false, }: { password: string; recoveryCode?: RecoveryCode; - privateKeyOverride?: PrivateKey; + privateKeyPemOverride?: PrivateKeyPem; fresh?: boolean; }): Promise { this.logger.info(`Starting ${this.constructor.name}`); @@ -166,7 +166,7 @@ class KeyManager { password, this.rootKeyPairBits, recoveryCode, - privateKeyOverride, + privateKeyPemOverride, ); const rootCert = await this.setupRootCert( rootKeyPair, @@ -597,18 +597,18 @@ class KeyManager { /** * Generates and writes the encrypted keypair to a the root key file. - * If privateKeyOverride is provided then key generation is skipped in favor of the provided key. - * If state already exists the privateKeyOverride is ignored. + * If privateKeyPemOverride is provided then key generation is skipped in favor of the provided key. + * If state already exists the privateKeyPemOverride is ignored. * @param password * @param bits - Bit-width of the generated key. * @param recoveryCode - Code to generate the key from. - * @param privateKeyOverride - Override generation with a provided private key. + * @param privateKeyPemOverride - Override generation with a provided private key. */ protected async setupRootKeyPair( password: string, bits: number = 4096, recoveryCode: RecoveryCode | undefined, - privateKeyOverride: PrivateKey | undefined, + privateKeyPemOverride: PrivateKeyPem | undefined, ): Promise<[KeyPair, RecoveryCode | undefined]> { let rootKeyPair: KeyPair; let recoveryCodeNew: RecoveryCode | undefined; @@ -627,10 +627,11 @@ class KeyManager { } return [rootKeyPair, undefined]; } else { - if (privateKeyOverride != null) { + if (privateKeyPemOverride != null) { this.logger.info('Using provided root key pair'); - const publicKey = keysUtils.publicKeyFromPrivateKey(privateKeyOverride); - rootKeyPair = { privateKey: privateKeyOverride, publicKey }; + const privateKey = keysUtils.privateKeyFromPem(privateKeyPemOverride); + const publicKey = keysUtils.publicKeyFromPrivateKey(privateKey); + rootKeyPair = { privateKey, publicKey }; await this.writeRootKeyPair(rootKeyPair, password); return [rootKeyPair, undefined]; } diff --git a/src/validation/utils.ts b/src/validation/utils.ts index 8197348a98..753cf5eb6b 100644 --- a/src/validation/utils.ts +++ b/src/validation/utils.ts @@ -12,12 +12,14 @@ import type { GestaltAction, GestaltId } from '../gestalts/types'; import type { VaultAction, VaultId } from '../vaults/types'; import type { Host, Hostname, Port } from '../network/types'; import type { ClaimId } from '../claims/types'; +import type { PrivateKey } from '../keys/types'; import * as validationErrors from './errors'; import * as nodesUtils from '../nodes/utils'; import * as gestaltsUtils from '../gestalts/utils'; import * as vaultsUtils from '../vaults/utils'; import * as networkUtils from '../network/utils'; import * as claimsUtils from '../claims/utils'; +import * as keysUtils from '../keys/utils'; import config from '../config'; function parseInteger(data: any): number { @@ -259,6 +261,21 @@ function parseSeedNodes(data: any): [SeedNodes, boolean] { return [seedNodes, defaults]; } +function parsePrivateKeyPem(data: any): PrivateKey { + if (typeof data !== 'string') { + throw new validationErrors.ErrorParse('Private key Pem must be a string'); + } + let privateKey: PrivateKey; + try { + privateKey = keysUtils.privateKeyFromPem(data); + } catch (e) { + throw new validationErrors.ErrorParse( + 'Must provide a valid private key Pem', + ); + } + return privateKey; +} + export { parseInteger, parseNumber, @@ -276,4 +293,5 @@ export { parsePort, parseNetwork, parseSeedNodes, + parsePrivateKeyPem, }; diff --git a/tests/bin/agent/start.test.ts b/tests/bin/agent/start.test.ts index d6cf9554aa..e8c06a13a7 100644 --- a/tests/bin/agent/start.test.ts +++ b/tests/bin/agent/start.test.ts @@ -10,6 +10,7 @@ import PolykeyAgent from '@/PolykeyAgent'; import Status from '@/status/Status'; import * as statusErrors from '@/status/errors'; import config from '@/config'; +import * as keysUtils from '@/keys/utils'; import * as testBinUtils from '../utils'; import * as testUtils from '../../utils'; import { runDescribeIfPlatforms, runTestIfPlatforms } from '../../utils'; @@ -740,6 +741,53 @@ describe('start', () => { }, global.defaultTimeout * 2, ); + runTestIfPlatforms('linux', 'docker')( + 'start with --private-key override', + async () => { + const status = new Status({ + statusPath: path.join(dataDir, 'polykey', config.defaults.statusBase), + statusLockPath: path.join( + dataDir, + 'polykey', + config.defaults.statusLockBase, + ), + fs, + logger, + }); + const password = 'abc123'; + // Make sure these ports are not occupied + const rootKeys = await keysUtils.generateKeyPair(4096); + const privateKeyPem = keysUtils.privateKeyToPem(rootKeys.privateKey); + const nodeId = keysUtils.publicKeyToNodeId(rootKeys.publicKey); + const privateKeyPath = path.join(dataDir, 'private.pem'); + await fs.promises.writeFile(privateKeyPath, privateKeyPem, { + encoding: 'utf-8', + }); + const agentProcess = await testBinUtils.pkSpawnSwitch(global.testCmd)( + [ + 'agent', + 'start', + '--workers', + '0', + '--verbose', + '--private-key-file', + privateKeyPath, + ], + { + PK_NODE_PATH: path.join(dataDir, 'polykey'), + PK_PASSWORD: password, + }, + dataDir, + logger, + ); + const statusInfo = await status.waitFor('LIVE'); + expect(nodeId.equals(statusInfo.data.nodeId)).toBe(true); + agentProcess.kill('SIGINT'); + // Check for graceful exit + await status.waitFor('DEAD'); + }, + global.defaultTimeout * 2, + ); runDescribeIfPlatforms('linux')('start with global agent', () => { let globalAgentStatus: StatusLive; let globalAgentClose; diff --git a/tests/bin/bootstrap.test.ts b/tests/bin/bootstrap.test.ts index 3c0b68a3a9..71fc7314cb 100644 --- a/tests/bin/bootstrap.test.ts +++ b/tests/bin/bootstrap.test.ts @@ -6,6 +6,7 @@ import { errors as statusErrors } from '@/status'; import { errors as bootstrapErrors } from '@/bootstrap'; import * as testBinUtils from './utils'; import { runTestIfPlatforms } from '../utils'; +import * as keysUtils from '../../src/keys/utils'; describe('bootstrap', () => { const logger = new Logger('bootstrap test', LogLevel.WARN, [ @@ -54,6 +55,36 @@ describe('bootstrap', () => { }, global.defaultTimeout * 2, ); + runTestIfPlatforms('linux', 'docker')( + 'bootstraps node state from provided private key', + async () => { + const password = 'password'; + const passwordPath = path.join(dataDir, 'password'); + await fs.promises.writeFile(passwordPath, password); + const keyPair = await keysUtils.generateKeyPair(4096); + const privateKeyPem = keysUtils.privateKeyToPem(keyPair.privateKey); + const privateKeyPath = path.join(dataDir, 'private.pem'); + await fs.promises.writeFile(privateKeyPath, privateKeyPem, { + encoding: 'utf-8', + }); + const { exitCode } = await testBinUtils.pkStdioSwitch(global.testCmd)( + [ + 'bootstrap', + '--password-file', + passwordPath, + '--verbose', + '--private-key-file', + privateKeyPath, + ], + { + PK_NODE_PATH: path.join(dataDir, 'polykey'), + }, + dataDir, + ); + expect(exitCode).toBe(0); + }, + global.defaultTimeout * 2, + ); runTestIfPlatforms('linux', 'docker')( 'bootstrapping occupied node state', async () => { diff --git a/tests/keys/KeyManager.test.ts b/tests/keys/KeyManager.test.ts index c2cbab1886..cd95162127 100644 --- a/tests/keys/KeyManager.test.ts +++ b/tests/keys/KeyManager.test.ts @@ -164,20 +164,26 @@ describe('KeyManager', () => { test('override key generation with privateKeyOverride', async () => { const keysPath = `${dataDir}/keys`; const keyPair = await keysUtils.generateKeyPair(4096); - const mockedGenerateKeyPair = jest.spyOn(keysUtils, 'generateDeterministicKeyPair'); + const privateKeyPem = keysUtils.privateKeyToPem(keyPair.privateKey); + const mockedGenerateKeyPair = jest.spyOn( + keysUtils, + 'generateDeterministicKeyPair', + ); const keyManager = await KeyManager.createKeyManager({ keysPath, password, - privateKeyOverride: keyPair.privateKey, + privateKeyPemOverride: privateKeyPem, logger, }); - expect(mockedGenerateKeyPair).not.toHaveBeenCalled() + expect(mockedGenerateKeyPair).not.toHaveBeenCalled(); const keysPathContents = await fs.promises.readdir(keysPath); expect(keysPathContents).toContain('root.pub'); expect(keysPathContents).toContain('root.key'); - expect(keysUtils.publicKeyToPem(keyManager.getRootKeyPair().publicKey)).toEqual(keysUtils.publicKeyToPem(keyPair.publicKey)); + expect( + keysUtils.publicKeyToPem(keyManager.getRootKeyPair().publicKey), + ).toEqual(keysUtils.publicKeyToPem(keyPair.publicKey)); await keyManager.stop(); - }) + }); test('uses WorkerManager for generating root key pair', async () => { const keysPath = `${dataDir}/keys`; const keyManager = await KeyManager.createKeyManager({