From 4ddfd74482948ef141b6f401d76c3f5a35b4d85e Mon Sep 17 00:00:00 2001 From: Alex Gherghisan Date: Thu, 2 Jul 2026 08:58:05 +0000 Subject: [PATCH] fix: improve valkeys password handling (A-1359) --- .../operators/keystore/creating-keystores.md | 18 ++ .../operators/keystore/troubleshooting.md | 3 + .../cli/src/cmds/validator_keys/index.ts | 10 +- .../cli/src/cmds/validator_keys/new.ts | 49 +++- .../src/cmds/validator_keys/valkeys.test.ts | 226 +++++++++++++++++- 5 files changed, 289 insertions(+), 17 deletions(-) diff --git a/docs/docs-operate/operators/keystore/creating-keystores.md b/docs/docs-operate/operators/keystore/creating-keystores.md index 14dc1713c36b..354b1add8595 100644 --- a/docs/docs-operate/operators/keystore/creating-keystores.md +++ b/docs/docs-operate/operators/keystore/creating-keystores.md @@ -394,6 +394,24 @@ This creates keystores at: - **Directory**: `~/.aztec/keystore/` - **Filename**: `key1.json`, `key2.json`, etc. (auto-increments) +### Encrypted Keystore Passwords + +Use `--password`, `--password-file`, or `AZTEC_KEYSTORE_PASSWORD` to write encrypted ETH JSON V3 and BLS +EIP-2335 keystore files: + +```bash +export AZTEC_KEYSTORE_PASSWORD='your-secure-password' +aztec validator-keys new \ + --fee-recipient 0x0000000000000000000000000000000000000000000000000000000000000000 \ + --count 5 \ + --publishers 0x7988a4a779f058a0 \ + --encrypted-keystore-dir ~/.aztec/keystore/encrypted +``` + +Password precedence is `--password`, then `--password-file`, then `AZTEC_KEYSTORE_PASSWORD`. Passwords cannot be +empty. When the command generates a mnemonic while writing encrypted keystores, it does not print the mnemonic to stdout; +provide `--mnemonic` if you need deterministic recovery. + ## Verifying Your Keystore Verify the keystore is valid JSON: diff --git a/docs/docs-operate/operators/keystore/troubleshooting.md b/docs/docs-operate/operators/keystore/troubleshooting.md index f0420d04f92d..6712cddc60c4 100644 --- a/docs/docs-operate/operators/keystore/troubleshooting.md +++ b/docs/docs-operate/operators/keystore/troubleshooting.md @@ -370,6 +370,9 @@ aztec validator-keys new [options] | `--gse-address
` | GSE contract address (required with --staker-output) | None | | `--l1-rpc-urls ` | L1 RPC endpoints (required with --staker-output) | None | | `--legacy` | Use 2.1.4 BLS derivation path (only for regenerating old keys) | `false` | +| `--password ` | Shared password for encrypted ETH and BLS keystore files | `AZTEC_KEYSTORE_PASSWORD` if set | +| `--password-file ` | File containing the shared encrypted-keystore password | None | +| `--encrypted-keystore-dir ` | Directory for encrypted ETH and BLS keystore files | Keystore output directory | | `--data-dir ` | Output directory | `~/.aztec/keystore` | | `--file ` | Keystore filename | `key1.json` | diff --git a/yarn-project/cli/src/cmds/validator_keys/index.ts b/yarn-project/cli/src/cmds/validator_keys/index.ts index 2cb8c1b53bcf..76400ecaccc8 100644 --- a/yarn-project/cli/src/cmds/validator_keys/index.ts +++ b/yarn-project/cli/src/cmds/validator_keys/index.ts @@ -1,6 +1,6 @@ import type { LogFn } from '@aztec/foundation/log'; -import { Command } from 'commander'; +import { Command, Option } from 'commander'; import { parseAztecAddress, parseEthereumAddress, parseHex, parseOptionalInteger } from '../../utils/commands.js'; import { defaultBlsPath } from './utils.js'; @@ -38,10 +38,12 @@ export function injectCommands(program: Command, log: LogFn) { .option('--remote-signer ', 'Default remote signer URL for accounts in this file') .option('--ikm ', 'Initial keying material for BLS (alternative to mnemonic)', value => parseHex(value, 32)) .option('--bls-path ', `EIP-2334 path (default ${defaultBlsPath})`) - .option( - '--password ', - 'Password for writing keystore files (ETH JSON V3 and BLS EIP-2335). Empty string allowed', + .addOption( + new Option('--password ', 'Password for writing keystore files (ETH JSON V3 and BLS EIP-2335)').env( + 'AZTEC_KEYSTORE_PASSWORD', + ), ) + .option('--password-file ', 'File containing the password for writing keystore files') .option('--encrypted-keystore-dir ', 'Output directory for encrypted keystore file(s)') .option('--json', 'Echo resulting JSON to stdout') .option('--staker-output', 'Generate a single staker output JSON file with an array of validator entries') diff --git a/yarn-project/cli/src/cmds/validator_keys/new.ts b/yarn-project/cli/src/cmds/validator_keys/new.ts index e1603f211eb0..2b0dd0f001ba 100644 --- a/yarn-project/cli/src/cmds/validator_keys/new.ts +++ b/yarn-project/cli/src/cmds/validator_keys/new.ts @@ -6,7 +6,7 @@ import type { LogFn } from '@aztec/foundation/log'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import { wordlist } from '@scure/bip39/wordlists/english.js'; -import { writeFile } from 'fs/promises'; +import { readFile, writeFile } from 'fs/promises'; import { basename, dirname, join } from 'path'; import { createPublicClient, fallback, http } from 'viem'; import { generateMnemonic, mnemonicToAccount } from 'viem/accounts'; @@ -42,6 +42,7 @@ export type NewValidatorKeystoreOptions = { ikm?: string; blsPath?: string; password?: string; + passwordFile?: string; encryptedKeystoreDir?: string; json?: boolean; feeRecipient: AztecAddress; @@ -53,6 +54,41 @@ export type NewValidatorKeystoreOptions = { l1ChainId?: number; }; +function validatePassword(password: string, source: string): string { + if (password.length === 0) { + throw new Error(`${source} cannot be empty`); + } + return password; +} + +function stripOneTrailingNewline(password: string): string { + if (password.endsWith('\n')) { + password = password.slice(0, -1); + } + if (password.endsWith('\r')) { + password = password.slice(0, -1); + } + return password; +} + +async function resolvePassword(options: Pick) { + if (options.password !== undefined && options.passwordFile !== undefined) { + throw new Error('--password and --password-file cannot be used together'); + } + if (options.password !== undefined) { + return validatePassword(options.password, '--password'); + } + if (options.passwordFile !== undefined) { + if (options.passwordFile.length === 0) { + throw new Error('--password-file cannot be empty'); + } + const password = stripOneTrailingNewline(await readFile(options.passwordFile, 'utf-8')); + return validatePassword(password, '--password-file'); + } + + return undefined; +} + export async function newValidatorKeystore(options: NewValidatorKeystoreOptions, log: LogFn) { // validate bls-path inputs before proceeding with key generation validateBlsPathOptions(options); @@ -78,7 +114,8 @@ export async function newValidatorKeystore(options: NewValidatorKeystoreOptions, blsPath, ikm, mnemonic: _mnemonic, - password, + password: passwordOption, + passwordFile, encryptedKeystoreDir, stakerOutput, gseAddress, @@ -86,9 +123,11 @@ export async function newValidatorKeystore(options: NewValidatorKeystoreOptions, l1ChainId, } = options; + const password = await resolvePassword({ password: passwordOption, passwordFile }); + const shouldEncryptKeystores = password !== undefined; const mnemonic = _mnemonic ?? generateMnemonic(wordlist); - if (!_mnemonic && !json) { + if (!_mnemonic && !json && !shouldEncryptKeystores) { log('No mnemonic provided, generating new one...'); log(`Using new mnemonic:`); log(''); @@ -115,7 +154,7 @@ export async function newValidatorKeystore(options: NewValidatorKeystoreOptions, }); // If password provided, write ETH JSON V3 and BLS BN254 keystores and replace plaintext - if (password !== undefined) { + if (shouldEncryptKeystores) { const encryptedKeystoreOutDir = encryptedKeystoreDir && encryptedKeystoreDir.length > 0 ? encryptedKeystoreDir : keystoreOutDir; await writeEthJsonV3ToFile(validators, { outDir: encryptedKeystoreOutDir, password }); @@ -160,7 +199,7 @@ export async function newValidatorKeystore(options: NewValidatorKeystoreOptions, } } - const outputData = !_mnemonic ? { ...keystore, generatedMnemonic: mnemonic } : keystore; + const outputData = !_mnemonic && !shouldEncryptKeystores ? { ...keystore, generatedMnemonic: mnemonic } : keystore; // Handle JSON output if (json) { diff --git a/yarn-project/cli/src/cmds/validator_keys/valkeys.test.ts b/yarn-project/cli/src/cmds/validator_keys/valkeys.test.ts index 24cb31861a0f..d596eaec2ea4 100644 --- a/yarn-project/cli/src/cmds/validator_keys/valkeys.test.ts +++ b/yarn-project/cli/src/cmds/validator_keys/valkeys.test.ts @@ -4,6 +4,7 @@ import { loadKeystoreFile } from '@aztec/node-keystore/loader'; import type { KeyStore } from '@aztec/node-keystore/types'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; +import { Command, Option } from 'commander'; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; @@ -27,15 +28,37 @@ import { validatePublisherOptions } from './utils.js'; const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; +function getEncryptedAttester(keystore: KeyStore) { + const validator = keystore.validators?.[0]; + if (!validator || typeof validator.attester !== 'object' || !('eth' in validator.attester)) { + throw new Error('Expected encrypted attester with ETH and BLS key references'); + } + return validator.attester as any; +} + describe('validator keys utilities', () => { let tmp: string; let feeRecipient: AztecAddress; + let originalKeystorePassword: string | undefined; beforeAll(async () => { feeRecipient = await AztecAddress.random(); tmp = mkdtempSync(join(tmpdir(), 'aztec-valkeys-')); }); + beforeEach(() => { + originalKeystorePassword = process.env.AZTEC_KEYSTORE_PASSWORD; + delete process.env.AZTEC_KEYSTORE_PASSWORD; + }); + + afterEach(() => { + if (originalKeystorePassword === undefined) { + delete process.env.AZTEC_KEYSTORE_PASSWORD; + } else { + process.env.AZTEC_KEYSTORE_PASSWORD = originalKeystorePassword; + } + }); + afterAll(() => { rmSync(tmp, { recursive: true, force: true }); }); @@ -418,6 +441,7 @@ describe('validator keys utilities', () => { it('writes keys to files when password is provided', async () => { const path = join(tmp, 'created-files.json'); + const password = 'test-password-123'; const logs: string[] = []; const log = (s: string) => logs.push(s); await newValidatorKeystore( @@ -427,7 +451,7 @@ describe('validator keys utilities', () => { count: 1, publisherCount: 1, mnemonic: TEST_MNEMONIC, - password: '', + password, encryptedKeystoreDir: tmp, feeRecipient: ('0x' + '07'.repeat(32)) as unknown as AztecAddress, }, @@ -435,13 +459,199 @@ describe('validator keys utilities', () => { ); const keystore: KeyStore = loadKeystoreFile(path); expect(keystore.validators).toBeDefined(); - const v = keystore.validators![0]; - // attester may be plain object or contain eth+bls - const att = typeof v.attester === 'object' && 'eth' in v.attester ? v.attester : { eth: v.attester }; - expect(typeof (att.eth as any).path).toBe('string'); - if ('bls' in att && att.bls) { - expect(typeof (att.bls as any).path).toBe('string'); - } + const att = getEncryptedAttester(keystore); + expect(typeof att.eth.path).toBe('string'); + expect(att.eth.password).toBe(password); + expect(typeof att.bls.path).toBe('string'); + expect(att.bls.password).toBe(password); + }); + + it('rejects empty passwords', async () => { + const logs: string[] = []; + const log = (s: string) => logs.push(s); + + await expect( + newValidatorKeystore( + { + dataDir: tmp, + file: 'empty-password.json', + count: 1, + mnemonic: TEST_MNEMONIC, + password: '', + feeRecipient: ('0x' + '07'.repeat(32)) as unknown as AztecAddress, + }, + log, + ), + ).rejects.toThrow(/--password cannot be empty/); + }); + + it('reads password from a file and strips one trailing newline', async () => { + const path = join(tmp, 'password-file.json'); + const passwordFile = join(tmp, 'keystore-password.txt'); + const password = 'file-password-123'; + writeFileSync(passwordFile, `${password}\n`, 'utf-8'); + const logs: string[] = []; + const log = (s: string) => logs.push(s); + + await newValidatorKeystore( + { + dataDir: tmp, + file: 'password-file.json', + count: 1, + mnemonic: TEST_MNEMONIC, + passwordFile, + encryptedKeystoreDir: tmp, + feeRecipient: ('0x' + '07'.repeat(32)) as unknown as AztecAddress, + }, + log, + ); + + const att = getEncryptedAttester(loadKeystoreFile(path)); + expect(att.eth.password).toBe(password); + expect(att.bls.password).toBe(password); + }); + + it('reads password from AZTEC_KEYSTORE_PASSWORD through the command option', async () => { + const path = join(tmp, 'env-password.json'); + const password = 'env-password-123'; + process.env.AZTEC_KEYSTORE_PASSWORD = password; + const logs: string[] = []; + const log = (s: string) => logs.push(s); + const program = new Command(); + program.exitOverride(); + program + .command('new') + .addOption(new Option('--password ').env('AZTEC_KEYSTORE_PASSWORD')) + .option('--data-dir ') + .option('--file ') + .option('--mnemonic ') + .option('--encrypted-keystore-dir ') + .action(async options => { + await newValidatorKeystore({ ...options, feeRecipient }, log); + }); + + await program.parseAsync( + [ + 'new', + '--data-dir', + tmp, + '--file', + 'env-password.json', + '--mnemonic', + TEST_MNEMONIC, + '--encrypted-keystore-dir', + tmp, + ], + { from: 'user' }, + ); + + const att = getEncryptedAttester(loadKeystoreFile(path)); + expect(att.eth.password).toBe(password); + expect(att.bls.password).toBe(password); + }); + + it('rejects empty password sources', async () => { + const emptyPasswordFile = join(tmp, 'empty-keystore-password.txt'); + writeFileSync(emptyPasswordFile, '\n', 'utf-8'); + const logs: string[] = []; + const log = (s: string) => logs.push(s); + + await expect( + newValidatorKeystore( + { + dataDir: tmp, + file: 'empty-password-file.json', + count: 1, + mnemonic: TEST_MNEMONIC, + passwordFile: emptyPasswordFile, + feeRecipient: ('0x' + '07'.repeat(32)) as unknown as AztecAddress, + }, + log, + ), + ).rejects.toThrow(/--password-file cannot be empty/); + }); + + it('rejects using password and passwordFile together', async () => { + const passwordFile = join(tmp, 'conflicting-keystore-password.txt'); + writeFileSync(passwordFile, 'file-password', 'utf-8'); + const logs: string[] = []; + const log = (s: string) => logs.push(s); + + await expect( + newValidatorKeystore( + { + dataDir: tmp, + file: 'conflicting-password-source.json', + count: 1, + mnemonic: TEST_MNEMONIC, + password: 'flag-password', + passwordFile, + feeRecipient: ('0x' + '07'.repeat(32)) as unknown as AztecAddress, + }, + log, + ), + ).rejects.toThrow(/--password and --password-file cannot be used together/); + }); + + it('does not output a generated mnemonic when writing encrypted keystores', async () => { + const logs: string[] = []; + const log = (s: string) => logs.push(s); + + await newValidatorKeystore( + { + dataDir: tmp, + file: 'encrypted-generated-mnemonic.json', + count: 1, + password: 'test-password-123', + encryptedKeystoreDir: tmp, + feeRecipient: ('0x' + '07'.repeat(32)) as unknown as AztecAddress, + }, + log, + ); + + expect(logs.join('\n')).not.toMatch(/No mnemonic provided|Using new mnemonic/); + }); + + it('omits generatedMnemonic from JSON output when writing encrypted keystores', async () => { + const logs: string[] = []; + const log = (s: string) => logs.push(s); + + await newValidatorKeystore( + { + dataDir: tmp, + file: 'encrypted-json-output.json', + count: 1, + password: 'test-password-123', + encryptedKeystoreDir: tmp, + json: true, + feeRecipient: ('0x' + '07'.repeat(32)) as unknown as AztecAddress, + }, + log, + ); + + const output = JSON.parse(logs[0]); + expect(output.generatedMnemonic).toBeUndefined(); + expect(output.validators).toBeDefined(); + }); + + it('keeps generatedMnemonic in JSON output when not writing encrypted keystores', async () => { + const logs: string[] = []; + const log = (s: string) => logs.push(s); + + await newValidatorKeystore( + { + dataDir: tmp, + file: 'plaintext-json-output.json', + count: 1, + json: true, + feeRecipient: ('0x' + '07'.repeat(32)) as unknown as AztecAddress, + }, + log, + ); + + const output = JSON.parse(logs[0]); + expect(typeof output.generatedMnemonic).toBe('string'); + expect(output.validators).toBeDefined(); }); it('creates BN254 encrypted keystores that can be loaded and decrypted', async () => {