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 () => {