diff --git a/docs/docs-operate/operators/keystore/creating-keystores.md b/docs/docs-operate/operators/keystore/creating-keystores.md index 354b1add8595..163b5e0906ec 100644 --- a/docs/docs-operate/operators/keystore/creating-keystores.md +++ b/docs/docs-operate/operators/keystore/creating-keystores.md @@ -397,7 +397,8 @@ This creates keystores at: ### Encrypted Keystore Passwords Use `--password`, `--password-file`, or `AZTEC_KEYSTORE_PASSWORD` to write encrypted ETH JSON V3 and BLS -EIP-2335 keystore files: +EIP-2335 keystore files with a shared password. To use different passwords, provide role-specific sources such as +`--eth-password` and `--bls-password`. ```bash export AZTEC_KEYSTORE_PASSWORD='your-secure-password' @@ -408,9 +409,12 @@ aztec validator-keys new \ --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. +For each key type, role-specific sources take precedence over shared sources: `--eth-password`, +`--eth-password-file`, and `AZTEC_ETH_KEYSTORE_PASSWORD` for ETH keystores; `--bls-password`, +`--bls-password-file`, and `AZTEC_BLS_KEYSTORE_PASSWORD` for BLS keystores. Shared sources use `--password`, +`--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 diff --git a/docs/docs-operate/operators/keystore/troubleshooting.md b/docs/docs-operate/operators/keystore/troubleshooting.md index 6712cddc60c4..f277fbb06941 100644 --- a/docs/docs-operate/operators/keystore/troubleshooting.md +++ b/docs/docs-operate/operators/keystore/troubleshooting.md @@ -372,6 +372,10 @@ aztec validator-keys new [options] | `--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 | +| `--eth-password ` | Password for encrypted ETH keystore files | `AZTEC_ETH_KEYSTORE_PASSWORD`, then shared password | +| `--eth-password-file ` | File containing the ETH encrypted-keystore password | None | +| `--bls-password ` | Password for encrypted BLS keystore files | `AZTEC_BLS_KEYSTORE_PASSWORD`, then shared password | +| `--bls-password-file ` | File containing the BLS 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 76400ecaccc8..8588f40c55a7 100644 --- a/yarn-project/cli/src/cmds/validator_keys/index.ts +++ b/yarn-project/cli/src/cmds/validator_keys/index.ts @@ -39,11 +39,23 @@ export function injectCommands(program: Command, log: LogFn) { .option('--ikm ', 'Initial keying material for BLS (alternative to mnemonic)', value => parseHex(value, 32)) .option('--bls-path ', `EIP-2334 path (default ${defaultBlsPath})`) .addOption( - new Option('--password ', 'Password for writing keystore files (ETH JSON V3 and BLS EIP-2335)').env( + new Option('--password ', 'Shared password for writing ETH JSON V3 and BLS EIP-2335 keystore files').env( 'AZTEC_KEYSTORE_PASSWORD', ), ) - .option('--password-file ', 'File containing the password for writing keystore files') + .option('--password-file ', 'File containing the shared password for writing keystore files') + .addOption( + new Option('--eth-password ', 'Password for writing ETH JSON V3 keystore files').env( + 'AZTEC_ETH_KEYSTORE_PASSWORD', + ), + ) + .option('--eth-password-file ', 'File containing the password for writing ETH JSON V3 keystore files') + .addOption( + new Option('--bls-password ', 'Password for writing BLS EIP-2335 keystore files').env( + 'AZTEC_BLS_KEYSTORE_PASSWORD', + ), + ) + .option('--bls-password-file ', 'File containing the password for writing BLS EIP-2335 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 2b0dd0f001ba..208ebc7a8748 100644 --- a/yarn-project/cli/src/cmds/validator_keys/new.ts +++ b/yarn-project/cli/src/cmds/validator_keys/new.ts @@ -43,6 +43,10 @@ export type NewValidatorKeystoreOptions = { blsPath?: string; password?: string; passwordFile?: string; + ethPassword?: string; + ethPasswordFile?: string; + blsPassword?: string; + blsPasswordFile?: string; encryptedKeystoreDir?: string; json?: boolean; feeRecipient: AztecAddress; @@ -54,6 +58,18 @@ export type NewValidatorKeystoreOptions = { l1ChainId?: number; }; +type PasswordSourceOptions = Pick< + NewValidatorKeystoreOptions, + 'password' | 'passwordFile' | 'ethPassword' | 'ethPasswordFile' | 'blsPassword' | 'blsPasswordFile' +>; + +type PasswordSource = { + password?: string; + passwordFile?: string; + passwordOption: string; + passwordFileOption: string; +}; + function validatePassword(password: string, source: string): string { if (password.length === 0) { throw new Error(`${source} cannot be empty`); @@ -71,24 +87,57 @@ function stripOneTrailingNewline(password: string): string { 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'); +async function resolvePasswordSource(source: PasswordSource): Promise { + if (source.password !== undefined && source.passwordFile !== undefined) { + throw new Error(`${source.passwordOption} and ${source.passwordFileOption} cannot be used together`); } - if (options.password !== undefined) { - return validatePassword(options.password, '--password'); + if (source.password !== undefined) { + return validatePassword(source.password, source.passwordOption); } - if (options.passwordFile !== undefined) { - if (options.passwordFile.length === 0) { - throw new Error('--password-file cannot be empty'); + if (source.passwordFile !== undefined) { + if (source.passwordFile.length === 0) { + throw new Error(`${source.passwordFileOption} cannot be empty`); } - const password = stripOneTrailingNewline(await readFile(options.passwordFile, 'utf-8')); - return validatePassword(password, '--password-file'); + const password = stripOneTrailingNewline(await readFile(source.passwordFile, 'utf-8')); + return validatePassword(password, source.passwordFileOption); } return undefined; } +async function resolvePasswords(options: PasswordSourceOptions) { + const sharedPassword = await resolvePasswordSource({ + password: options.password, + passwordFile: options.passwordFile, + passwordOption: '--password', + passwordFileOption: '--password-file', + }); + const ethPassword = + (await resolvePasswordSource({ + password: options.ethPassword, + passwordFile: options.ethPasswordFile, + passwordOption: '--eth-password', + passwordFileOption: '--eth-password-file', + })) ?? sharedPassword; + const blsPassword = + (await resolvePasswordSource({ + password: options.blsPassword, + passwordFile: options.blsPasswordFile, + passwordOption: '--bls-password', + passwordFileOption: '--bls-password-file', + })) ?? sharedPassword; + + if (ethPassword === undefined && blsPassword === undefined) { + return {}; + } + if (ethPassword === undefined || blsPassword === undefined) { + throw new Error( + 'Both ETH and BLS passwords are required when writing encrypted keystores. Provide --password to use one password for both, or provide both --eth-password and --bls-password.', + ); + } + return { ethPassword, blsPassword }; +} + export async function newValidatorKeystore(options: NewValidatorKeystoreOptions, log: LogFn) { // validate bls-path inputs before proceeding with key generation validateBlsPathOptions(options); @@ -116,6 +165,10 @@ export async function newValidatorKeystore(options: NewValidatorKeystoreOptions, mnemonic: _mnemonic, password: passwordOption, passwordFile, + ethPassword: ethPasswordOption, + ethPasswordFile, + blsPassword: blsPasswordOption, + blsPasswordFile, encryptedKeystoreDir, stakerOutput, gseAddress, @@ -123,8 +176,15 @@ export async function newValidatorKeystore(options: NewValidatorKeystoreOptions, l1ChainId, } = options; - const password = await resolvePassword({ password: passwordOption, passwordFile }); - const shouldEncryptKeystores = password !== undefined; + const { ethPassword, blsPassword } = await resolvePasswords({ + password: passwordOption, + passwordFile, + ethPassword: ethPasswordOption, + ethPasswordFile, + blsPassword: blsPasswordOption, + blsPasswordFile, + }); + const shouldEncryptKeystores = ethPassword !== undefined && blsPassword !== undefined; const mnemonic = _mnemonic ?? generateMnemonic(wordlist); if (!_mnemonic && !json && !shouldEncryptKeystores) { @@ -157,8 +217,8 @@ export async function newValidatorKeystore(options: NewValidatorKeystoreOptions, if (shouldEncryptKeystores) { const encryptedKeystoreOutDir = encryptedKeystoreDir && encryptedKeystoreDir.length > 0 ? encryptedKeystoreDir : keystoreOutDir; - await writeEthJsonV3ToFile(validators, { outDir: encryptedKeystoreOutDir, password }); - await writeBlsBn254ToFile(validators, { outDir: encryptedKeystoreOutDir, password }); + await writeEthJsonV3ToFile(validators, { outDir: encryptedKeystoreOutDir, password: ethPassword }); + await writeBlsBn254ToFile(validators, { outDir: encryptedKeystoreOutDir, password: blsPassword }); } const keystore = { @@ -184,7 +244,7 @@ export async function newValidatorKeystore(options: NewValidatorKeystoreOptions, // Process each validator for (let i = 0; i < validators.length; i++) { const validator = validators[i]; - const outputs = await processAttesterAccounts(validator.attester, gse, password); + const outputs = await processAttesterAccounts(validator.attester, gse); // Collect all staker outputs for (let j = 0; j < outputs.length; j++) { 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 d596eaec2ea4..24abb2c06169 100644 --- a/yarn-project/cli/src/cmds/validator_keys/valkeys.test.ts +++ b/yarn-project/cli/src/cmds/validator_keys/valkeys.test.ts @@ -39,7 +39,7 @@ function getEncryptedAttester(keystore: KeyStore) { describe('validator keys utilities', () => { let tmp: string; let feeRecipient: AztecAddress; - let originalKeystorePassword: string | undefined; + let originalKeystorePasswords: Record; beforeAll(async () => { feeRecipient = await AztecAddress.random(); @@ -47,15 +47,23 @@ describe('validator keys utilities', () => { }); beforeEach(() => { - originalKeystorePassword = process.env.AZTEC_KEYSTORE_PASSWORD; + originalKeystorePasswords = { + AZTEC_KEYSTORE_PASSWORD: process.env.AZTEC_KEYSTORE_PASSWORD, + AZTEC_ETH_KEYSTORE_PASSWORD: process.env.AZTEC_ETH_KEYSTORE_PASSWORD, + AZTEC_BLS_KEYSTORE_PASSWORD: process.env.AZTEC_BLS_KEYSTORE_PASSWORD, + }; delete process.env.AZTEC_KEYSTORE_PASSWORD; + delete process.env.AZTEC_ETH_KEYSTORE_PASSWORD; + delete process.env.AZTEC_BLS_KEYSTORE_PASSWORD; }); afterEach(() => { - if (originalKeystorePassword === undefined) { - delete process.env.AZTEC_KEYSTORE_PASSWORD; - } else { - process.env.AZTEC_KEYSTORE_PASSWORD = originalKeystorePassword; + for (const [key, value] of Object.entries(originalKeystorePasswords)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } } }); @@ -593,6 +601,142 @@ describe('validator keys utilities', () => { ).rejects.toThrow(/--password and --password-file cannot be used together/); }); + it('writes ETH and BLS keystores with different passwords', async () => { + const path = join(tmp, 'separate-passwords.json'); + const ethPassword = 'eth-password-123'; + const blsPassword = 'bls-password-123'; + const logs: string[] = []; + const log = (s: string) => logs.push(s); + + await newValidatorKeystore( + { + dataDir: tmp, + file: 'separate-passwords.json', + count: 1, + mnemonic: TEST_MNEMONIC, + ethPassword, + blsPassword, + encryptedKeystoreDir: tmp, + feeRecipient: ('0x' + '07'.repeat(32)) as unknown as AztecAddress, + }, + log, + ); + + const att = getEncryptedAttester(loadKeystoreFile(path)); + expect(att.eth.password).toBe(ethPassword); + expect(att.bls.password).toBe(blsPassword); + }); + + it('uses role-specific passwords over the shared password', async () => { + const path = join(tmp, 'shared-with-bls-override.json'); + const sharedPassword = 'shared-password-123'; + const blsPassword = 'bls-password-override'; + const logs: string[] = []; + const log = (s: string) => logs.push(s); + + await newValidatorKeystore( + { + dataDir: tmp, + file: 'shared-with-bls-override.json', + count: 1, + mnemonic: TEST_MNEMONIC, + password: sharedPassword, + blsPassword, + encryptedKeystoreDir: tmp, + feeRecipient: ('0x' + '07'.repeat(32)) as unknown as AztecAddress, + }, + log, + ); + + const att = getEncryptedAttester(loadKeystoreFile(path)); + expect(att.eth.password).toBe(sharedPassword); + expect(att.bls.password).toBe(blsPassword); + }); + + it('reads ETH and BLS passwords from role-specific environment variables through command options', async () => { + const path = join(tmp, 'role-env-passwords.json'); + const ethPassword = 'eth-env-password-123'; + const blsPassword = 'bls-env-password-123'; + process.env.AZTEC_ETH_KEYSTORE_PASSWORD = ethPassword; + process.env.AZTEC_BLS_KEYSTORE_PASSWORD = blsPassword; + const logs: string[] = []; + const log = (s: string) => logs.push(s); + const program = new Command(); + program.exitOverride(); + program + .command('new') + .addOption(new Option('--eth-password ').env('AZTEC_ETH_KEYSTORE_PASSWORD')) + .addOption(new Option('--bls-password ').env('AZTEC_BLS_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', + 'role-env-passwords.json', + '--mnemonic', + TEST_MNEMONIC, + '--encrypted-keystore-dir', + tmp, + ], + { from: 'user' }, + ); + + const att = getEncryptedAttester(loadKeystoreFile(path)); + expect(att.eth.password).toBe(ethPassword); + expect(att.bls.password).toBe(blsPassword); + }); + + it('rejects role-specific password sources without a password for the other role', async () => { + const logs: string[] = []; + const log = (s: string) => logs.push(s); + + await expect( + newValidatorKeystore( + { + dataDir: tmp, + file: 'only-eth-password.json', + count: 1, + mnemonic: TEST_MNEMONIC, + ethPassword: 'eth-password-123', + feeRecipient: ('0x' + '07'.repeat(32)) as unknown as AztecAddress, + }, + log, + ), + ).rejects.toThrow(/Both ETH and BLS passwords are required/); + }); + + it('rejects conflicting role-specific password sources', async () => { + const ethPasswordFile = join(tmp, 'conflicting-eth-password.txt'); + writeFileSync(ethPasswordFile, 'eth-file-password', 'utf-8'); + const logs: string[] = []; + const log = (s: string) => logs.push(s); + + await expect( + newValidatorKeystore( + { + dataDir: tmp, + file: 'conflicting-eth-password-source.json', + count: 1, + mnemonic: TEST_MNEMONIC, + ethPassword: 'eth-password-123', + ethPasswordFile, + blsPassword: 'bls-password-123', + feeRecipient: ('0x' + '07'.repeat(32)) as unknown as AztecAddress, + }, + log, + ), + ).rejects.toThrow(/--eth-password and --eth-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);