From 23e2a2f112b525bd4a90fc79cad16e8ff7113ba7 Mon Sep 17 00:00:00 2001 From: JackCC Date: Sun, 19 Apr 2026 10:11:24 +0800 Subject: [PATCH 1/2] feat(cli): support per-keystore passwords for validator import --- .../validator-management/vc-configuration.md | 22 +++- packages/cli/src/cmds/validator/import.ts | 29 +++-- packages/cli/src/cmds/validator/options.ts | 10 ++ .../signers/importExternalKeystores.ts | 104 ++++++++++++++-- .../cli/src/cmds/validator/signers/index.ts | 9 +- .../cli/test/e2e/importFromFsDirect.test.ts | 44 +++++++ .../cli/test/e2e/importFromFsPreStep.test.ts | 100 ++++++++++++++++ packages/cli/test/unit/validator/keys.test.ts | 111 ++++++++++++++++++ 8 files changed, 398 insertions(+), 31 deletions(-) diff --git a/docs/pages/run/validator-management/vc-configuration.md b/docs/pages/run/validator-management/vc-configuration.md index ea2e03ef4222..550350148a75 100644 --- a/docs/pages/run/validator-management/vc-configuration.md +++ b/docs/pages/run/validator-management/vc-configuration.md @@ -26,11 +26,11 @@ To import a validator JSON keystore that was created via one of the methods desc Inside the keystore JSON file, you should have an [EIP-2335 keystore file](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-2335.md#json-schema). -You will also need the passphrase used the encrypt the keystore. This can be specified interactively, or provided in a plaintext file. +You will also need the passphrase used to encrypt the keystore. This can be entered interactively, provided in a shared plaintext file, or provided as per-keystore plaintext files. #### Option 1: Import Keys To Lodestar's Keystores Folder -You can load the keys into the keystore folder using the [`validator import`](../validator-management/validator-cli.md#validator-import) command. There are two methods for importing keystores: +You can load the keys into the keystore folder using the [`validator import`](../validator-management/validator-cli.md#validator-import) command. There are three methods for importing keystores: _Interactive passphrase import_ @@ -44,24 +44,36 @@ _Plaintext passphrase file import_ ./lodestar validator import --importKeystores ./validator_keys --importKeystoresPassword ./password.txt ``` +_Per-keystore plaintext passphrase file import_ + +```bash +./lodestar validator import --importKeystores ./validator_keys --importKeystoresPasswords ./passwords +``` + :::info -The interactive passphrase import method will prompt every keystore in the `validator_keys` folder for import and will ask for the individual password for each keystore. **This method will allow you to import multiple keystores with different passwords.** +The interactive passphrase import method will prompt once for a shared password and use it for every keystore found in the `validator_keys` folder. The plaintext passphrase file import method will allow you to import all keystores in the `validator_keys` folder encrypted with the same password contained in `password.txt` for efficiency. + +The per-keystore plaintext passphrase file import method expects one file per validator in `./passwords`, named `0x.txt`. ::: -Once imported with either method, these keystores will be automatically loaded when you start the validator. To list the imported keystores, use the [`validator list`](./validator-cli.md#validator-list) command. +Once imported with any of these methods, these keystores will be automatically loaded when you start the validator. To list the imported keystores, use the [`validator list`](./validator-cli.md#validator-list) command. --- #### Option 2: Import Keys When Starting the Validator -To import keys when you start the validator specify the [`--importKeystores`](./validator-cli.md#--importkeystores) and [`--importKeystoresPassword`](./validator-cli.md#--importkeystorespassword) flags with the [`validator`](./validator-cli.md#base-validator-command) command: +To import keys when you start the validator, specify the [`--importKeystores`](./validator-cli.md#--importkeystores) flag on the [`validator`](./validator-cli.md#base-validator-command) command. To avoid the interactive prompt, also pass either [`--importKeystoresPassword`](./validator-cli.md#--importkeystorespassword) for a shared password file or [`--importKeystoresPasswords`](./validator-cli.md#--importkeystorespasswords) for per-keystore password files: ```bash ./lodestar validator --importKeystores ./validator_keys --importKeystoresPassword ./password.txt ``` +```bash +./lodestar validator --importKeystores ./validator_keys --importKeystoresPasswords ./passwords +``` + :::warning If you import keys using `--importKeystores` at runtime (Option 2) any keys loaded to the keystores folder from Option 1 will be ignored. ::: diff --git a/packages/cli/src/cmds/validator/import.ts b/packages/cli/src/cmds/validator/import.ts index c3c566c089e4..0220d33f7b4c 100644 --- a/packages/cli/src/cmds/validator/import.ts +++ b/packages/cli/src/cmds/validator/import.ts @@ -7,18 +7,21 @@ import {YargsError, getPubkeyHexFromKeystore} from "../../util/index.js"; import {PersistedKeysBackend} from "./keymanager/persistedKeys.js"; import {IValidatorCliArgs, validatorOptions} from "./options.js"; import {getAccountPaths} from "./paths.js"; -import {importKeystoreDefinitionsFromExternalDir, readPassphraseOrPrompt} from "./signers/importExternalKeystores.js"; +import {readKeystoreDefinitionsFromArgs} from "./signers/importExternalKeystores.js"; -type ValidatorImportArgs = Pick; +type ValidatorImportArgs = Pick< + IValidatorCliArgs, + "importKeystores" | "importKeystoresPassword" | "importKeystoresPasswords" +>; -const {importKeystores, importKeystoresPassword} = validatorOptions; +const {importKeystores, importKeystoresPassword, importKeystoresPasswords} = validatorOptions; export const importCmd: CliCommand = { command: "import", describe: "Imports one or more EIP-2335 keystores into a Lodestar validator client directory, \ -requesting passwords interactively. The directory flag provides a convenient \ +using either a shared password or per-keystore password files. The directory flag provides a convenient \ method for importing a directory of keys generated by the eth2-deposit-cli \ Ethereum Foundation utility.", @@ -27,9 +30,14 @@ Ethereum Foundation utility.", command: "validator import --network hoodi --importKeystores $HOME/staking-deposit-cli/validator_keys", description: "Import validator keystores generated with the Ethereum Foundation Staking Launchpad", }, + { + command: + "validator import --network hoodi --importKeystores $HOME/staking-deposit-cli/validator_keys --importKeystoresPasswords $HOME/staking-deposit-cli/passwords", + description: "Import validator keystores with one password file per validator public key", + }, ], - // Note: re-uses `--importKeystores` and `--importKeystoresPassword` from root validator command options + // Note: re-uses `--importKeystores`, `--importKeystoresPassword`, and `--importKeystoresPasswords` from root validator command options options: { importKeystores: { @@ -37,12 +45,13 @@ Ethereum Foundation utility.", requiresArg: true, }, importKeystoresPassword, + importKeystoresPasswords, }, handler: async (args) => { const {network} = getBeaconConfigFromArgs(args); - // This command takes: importKeystores, importKeystoresPassword + // This command takes: importKeystores, importKeystoresPassword, importKeystoresPasswords // // - recursively finds keystores in importKeystores // - validates keystores can decrypt @@ -52,12 +61,10 @@ Ethereum Foundation utility.", throw new YargsError("Must set importKeystores"); } - // Collect same password for all keystores - // If importKeystoresPassword is not provided, interactive prompt for it - - const keystoreDefinitions = importKeystoreDefinitionsFromExternalDir({ + const keystoreDefinitions = await readKeystoreDefinitionsFromArgs({ keystoresPath: args.importKeystores, - password: await readPassphraseOrPrompt(args), + importKeystoresPassword: args.importKeystoresPassword, + importKeystoresPasswords: args.importKeystoresPasswords, }); if (keystoreDefinitions.length === 0) { diff --git a/packages/cli/src/cmds/validator/options.ts b/packages/cli/src/cmds/validator/options.ts index a73b3821b118..12fea15fcd6a 100644 --- a/packages/cli/src/cmds/validator/options.ts +++ b/packages/cli/src/cmds/validator/options.ts @@ -56,6 +56,7 @@ export type IValidatorCliArgs = AccountValidatorArgs & importKeystores?: string[]; importKeystoresPassword?: string; + importKeystoresPasswords?: string; disableKeystoresThreadPool?: boolean; "http.requestWireFormat"?: string; @@ -303,6 +304,15 @@ export const validatorOptions: CliCommandOptions = { alias: ["passphraseFile"], // Backwards compatibility with old `validator import` cmd description: "Path to a file with password to decrypt all keystores from `importKeystores` option", defaultDescription: "./password.txt", + conflicts: ["importKeystoresPasswords"], + type: "string", + }, + + importKeystoresPasswords: { + description: + "Path to a directory containing per-keystore password files named `0x.txt` for `importKeystores`", + defaultDescription: "./passwords/0x.txt", + conflicts: ["importKeystoresPassword"], type: "string", }, diff --git a/packages/cli/src/cmds/validator/signers/importExternalKeystores.ts b/packages/cli/src/cmds/validator/signers/importExternalKeystores.ts index f86ea90ab1a8..78cbaa381939 100644 --- a/packages/cli/src/cmds/validator/signers/importExternalKeystores.ts +++ b/packages/cli/src/cmds/validator/signers/importExternalKeystores.ts @@ -1,28 +1,110 @@ +import fs from "node:fs"; +import path from "node:path"; import inquirer from "inquirer"; -import {readPassphraseFile, recursiveLookup} from "../../../util/index.js"; +import {Keystore} from "@chainsafe/bls-keystore"; +import {YargsError, getPubkeyHexFromKeystore, readPassphraseFile, recursiveLookup} from "../../../util/index.js"; import {LocalKeystoreDefinition} from "../keymanager/interface.js"; +type ImportKeystoreDefinitionsFromExternalDirArgs = + | { + keystoresPath: string[]; + password: string; + } + | { + keystoresPath: string[]; + passwordsDir: string; + }; + /** * Imports keystores from un-controlled directories provided by the user. * This directories are expected to contain other files, so some filtering in done to improve UX. * @param args */ -export function importKeystoreDefinitionsFromExternalDir(args: { +export function importKeystoreDefinitionsFromExternalDir( + args: ImportKeystoreDefinitionsFromExternalDirArgs +): LocalKeystoreDefinition[] { + if (!("password" in args)) { + assertValidPasswordsDir(args.passwordsDir); + } + + const keystorePaths = getVotingKeystorePaths(args.keystoresPath); + + if ("password" in args) { + return keystorePaths.map((keystorePath) => ({ + keystorePath, + password: args.password, + })); + } + + return keystorePaths.map((keystorePath) => ({ + keystorePath, + password: readPassphraseFile(getPasswordFilepathForKeystore(keystorePath, args.passwordsDir)), + })); +} + +export async function readKeystoreDefinitionsFromArgs(args: { keystoresPath: string[]; - password: string; -}): LocalKeystoreDefinition[] { + importKeystoresPassword?: string; + importKeystoresPasswords?: string; +}): Promise { + if (args.importKeystoresPasswords) { + return importKeystoreDefinitionsFromExternalDir({ + keystoresPath: args.keystoresPath, + passwordsDir: args.importKeystoresPasswords, + }); + } + + return importKeystoreDefinitionsFromExternalDir({ + keystoresPath: args.keystoresPath, + password: await readPassphraseOrPrompt(args), + }); +} + +function getVotingKeystorePaths(keystoresPath: string[]): string[] { const allFiles: string[] = []; - for (const keystorePath of args.keystoresPath) { + for (const keystorePath of keystoresPath) { + assertValidKeystoresPath(keystorePath); recursiveLookup(keystorePath, allFiles); } - return allFiles - .filter((filepath) => isVotingKeystore(filepath)) - .map((keystorePath) => ({ - keystorePath, - password: args.password, - })); + return allFiles.filter((filepath) => isVotingKeystore(filepath)); +} + +function assertValidKeystoresPath(keystorePath: string): void { + if (!fs.existsSync(keystorePath)) { + throw new YargsError(`importKeystores must point to an existing file or directory: ${keystorePath}`); + } +} + +function assertValidPasswordsDir(passwordsDir: string): void { + if (!fs.existsSync(passwordsDir)) { + throw new YargsError(`importKeystoresPasswords must point to an existing directory: ${passwordsDir}`); + } + + if (!fs.statSync(passwordsDir).isDirectory()) { + throw new YargsError(`importKeystoresPasswords must point to a directory: ${passwordsDir}`); + } +} + +function getPasswordFilepathForKeystore(keystorePath: string, passwordsDir: string): string { + let pubkeyHex: string; + try { + const keystore = Keystore.parse(fs.readFileSync(keystorePath, "utf8")); + pubkeyHex = getPubkeyHexFromKeystore(keystore); + } catch (e) { + throw new YargsError(`Failed to read keystore ${keystorePath}: ${e instanceof Error ? e.message : String(e)}`); + } + + const passwordFilepath = path.join(passwordsDir, `${pubkeyHex}.txt`); + + if (!fs.existsSync(passwordFilepath)) { + throw new YargsError( + `No password file found for keystore ${keystorePath}. Expected password file ${passwordFilepath}` + ); + } + + return passwordFilepath; } export async function readPassphraseOrPrompt(args: {importKeystoresPassword?: string}): Promise { diff --git a/packages/cli/src/cmds/validator/signers/index.ts b/packages/cli/src/cmds/validator/signers/index.ts index 87d6260028f1..eb640584ff05 100644 --- a/packages/cli/src/cmds/validator/signers/index.ts +++ b/packages/cli/src/cmds/validator/signers/index.ts @@ -11,7 +11,7 @@ import {decryptKeystoreDefinitions} from "../keymanager/decryptKeystoreDefinitio import {PersistedKeysBackend} from "../keymanager/persistedKeys.js"; import {IValidatorCliArgs} from "../options.js"; import {getAccountPaths} from "../paths.js"; -import {importKeystoreDefinitionsFromExternalDir, readPassphraseOrPrompt} from "./importExternalKeystores.js"; +import {readKeystoreDefinitionsFromArgs} from "./importExternalKeystores.js"; const KEYSTORE_IMPORT_PROGRESS_MS = 10000; @@ -19,7 +19,7 @@ const KEYSTORE_IMPORT_PROGRESS_MS = 10000; * Options processing hierarchy * --interopIndexes * --fromMnemonic, then requires --mnemonicIndexes - * --importKeystores, then requires --importKeystoresPassword + * --importKeystores, then supports --importKeystoresPassword or --importKeystoresPasswords * --externalSigner.fetch, then requires --externalSigner.urls * --externalSigner.pubkeys, then requires --externalSigner.urls * else load from persisted @@ -76,9 +76,10 @@ export async function getSignersFromArgs( // Import JSON keystores and run if (args.importKeystores) { - const keystoreDefinitions = importKeystoreDefinitionsFromExternalDir({ + const keystoreDefinitions = await readKeystoreDefinitionsFromArgs({ keystoresPath: args.importKeystores, - password: await readPassphraseOrPrompt(args), + importKeystoresPassword: args.importKeystoresPassword, + importKeystoresPasswords: args.importKeystoresPasswords, }); const needle = showProgress({ diff --git a/packages/cli/test/e2e/importFromFsDirect.test.ts b/packages/cli/test/e2e/importFromFsDirect.test.ts index 18432268c3d5..95c4a18bd526 100644 --- a/packages/cli/test/e2e/importFromFsDirect.test.ts +++ b/packages/cli/test/e2e/importFromFsDirect.test.ts @@ -55,3 +55,47 @@ describe("import from fs same cmd as validate", () => { await stopValidator(); }); }); + +describe("import from fs same cmd as validate with per-keystore passwords", () => { + vi.setConfig({testTimeout: 30_000}); + + const dataDir = path.join(testFilesDir, "import-and-validate-multi-password-test"); + const importFromDir = path.join(dataDir, "eth2.0_deposit_out"); + const passwordsDir = path.join(dataDir, "passwords"); + + const passphrases = ["AAAAAAAA0000000000", "BBBBBBBB1111111111"]; + const keyCount = passphrases.length; + const pubkeys = cachedPubkeysHex.slice(0, keyCount); + const secretKeys = cachedSeckeysHex.slice(0, keyCount); + + beforeAll(async () => { + rimraf.sync(dataDir); + rimraf.sync(importFromDir); + rimraf.sync(passwordsDir); + + const keystoresStr = await Promise.all( + secretKeys.map(async (secretKey, index) => { + const [keystoreStr] = await getKeystoresStr(passphrases[index], [secretKey]); + return keystoreStr; + }) + ); + + fs.mkdirSync(importFromDir, {recursive: true}); + fs.mkdirSync(passwordsDir, {recursive: true}); + + for (let i = 0; i < keyCount; i++) { + fs.writeFileSync(path.join(importFromDir, `keystore_${i}.json`), keystoresStr[i]); + fs.writeFileSync(path.join(passwordsDir, `${pubkeys[i]}.txt`), passphrases[i]); + } + }); + + it("run 'validator' with per-keystore password files and check keys are loaded", async () => { + const {keymanagerClient, stopValidator} = await startValidatorWithKeyManager( + [`--importKeystores=${importFromDir}`, `--importKeystoresPasswords=${passwordsDir}`], + {dataDir, logPrefix: "case-3"} + ); + + await expectKeys(keymanagerClient, pubkeys, "Wrong listKeys response data"); + await stopValidator(); + }); +}); diff --git a/packages/cli/test/e2e/importFromFsPreStep.test.ts b/packages/cli/test/e2e/importFromFsPreStep.test.ts index fbfdb80599dc..d7043b577151 100644 --- a/packages/cli/test/e2e/importFromFsPreStep.test.ts +++ b/packages/cli/test/e2e/importFromFsPreStep.test.ts @@ -66,3 +66,103 @@ describe("import from fs then validate", () => { await expectKeys(keymanagerClient, pubkeys, "Wrong listKeys response data"); }); }); + +describe("import from fs then validate with per-keystore passwords", () => { + vi.setConfig({testTimeout: 30_000}); + + const dataDir = path.join(testFilesDir, "import-then-validate-multi-password-test"); + const importFromDir = path.join(dataDir, "eth2.0_deposit_out"); + const passwordsDir = path.join(dataDir, "passwords"); + + const passphrases = ["AAAAAAAA0000000000", "BBBBBBBB1111111111"]; + const keyCount = passphrases.length; + const pubkeys = cachedPubkeysHex.slice(0, keyCount); + const secretKeys = cachedSeckeysHex.slice(0, keyCount); + + beforeAll(() => { + rimraf.sync(dataDir); + rimraf.sync(importFromDir); + rimraf.sync(passwordsDir); + }); + + it("run 'validator import' with per-keystore password files", async () => { + const keystoresStr = await Promise.all( + secretKeys.map(async (secretKey, index) => { + const [keystoreStr] = await getKeystoresStr(passphrases[index], [secretKey]); + return keystoreStr; + }) + ); + + fs.mkdirSync(importFromDir, {recursive: true}); + fs.mkdirSync(passwordsDir, {recursive: true}); + + for (let i = 0; i < keyCount; i++) { + fs.writeFileSync(path.join(importFromDir, `keystore_${i}.json`), keystoresStr[i]); + fs.writeFileSync(path.join(passwordsDir, `${pubkeys[i]}.txt`), passphrases[i]); + } + + const stdout = await execCliCommand("packages/cli/bin/lodestar.js", [ + "validator import", + `--dataDir ${dataDir}`, + `--importKeystores ${importFromDir}`, + `--importKeystoresPasswords ${passwordsDir}`, + ]); + + for (let i = 0; i < keyCount; i++) { + expect(stdout).toContain(pubkeys[i]); + } + }); + + it("run 'validator list' and check pubkeys are imported", async () => { + fs.mkdirSync(path.join(dataDir, "keystores"), {recursive: true}); + fs.mkdirSync(path.join(dataDir, "secrets"), {recursive: true}); + + const stdout = await execCliCommand("packages/cli/bin/lodestar.js", ["validator list", `--dataDir ${dataDir}`]); + + for (let i = 0; i < keyCount; i++) { + expect(stdout).toContain(pubkeys[i]); + } + }); + + it("run 'validator' check keys are loaded", async () => { + const {keymanagerClient, stopValidator} = await startValidatorWithKeyManager([], {dataDir}); + onTestFinished(async () => { + await stopValidator(); + }); + + await expectKeys(keymanagerClient, pubkeys, "Wrong listKeys response data"); + }); +}); + +describe("import from fs with per-keystore passwords validation", () => { + vi.setConfig({testTimeout: 30_000}); + + const dataDir = path.join(testFilesDir, "import-multi-password-missing-password-file-test"); + const importFromDir = path.join(dataDir, "eth2.0_deposit_out"); + const passwordsDir = path.join(dataDir, "passwords"); + + beforeAll(() => { + rimraf.sync(dataDir); + rimraf.sync(importFromDir); + rimraf.sync(passwordsDir); + }); + + it("run 'validator import' should fail if a per-keystore password file is missing", async () => { + const passphrase = "AAAAAAAA0000000000"; + const [secretKey] = cachedSeckeysHex; + const [keystoreStr] = await getKeystoresStr(passphrase, [secretKey]); + + fs.mkdirSync(importFromDir, {recursive: true}); + fs.mkdirSync(passwordsDir, {recursive: true}); + fs.writeFileSync(path.join(importFromDir, "keystore_0.json"), keystoreStr); + + await expect( + execCliCommand("packages/cli/bin/lodestar.js", [ + "validator import", + `--dataDir ${dataDir}`, + `--importKeystores ${importFromDir}`, + `--importKeystoresPasswords ${passwordsDir}`, + ]) + ).rejects.toThrow("No password file found for keystore"); + }); +}); diff --git a/packages/cli/test/unit/validator/keys.test.ts b/packages/cli/test/unit/validator/keys.test.ts index 589e68db7f0d..8fbd374e9faa 100644 --- a/packages/cli/test/unit/validator/keys.test.ts +++ b/packages/cli/test/unit/validator/keys.test.ts @@ -1,7 +1,9 @@ import fs from "node:fs"; import path from "node:path"; import {afterEach, describe, expect, it} from "vitest"; +import {getKeystoresStr} from "@lodestar/test-utils"; import {importKeystoreDefinitionsFromExternalDir} from "../../../src/cmds/validator/signers/importExternalKeystores.js"; +import {cachedPubkeysHex, cachedSeckeysHex} from "../../utils/cachedKeys.js"; describe("validator / signers / importKeystoreDefinitionsFromExternalDir", () => { let tmpDir: string; @@ -30,6 +32,115 @@ describe("validator / signers / importKeystoreDefinitionsFromExternalDir", () => expect(definitions.map((def) => def.keystorePath).sort()).toEqual(toReadFilepaths.sort()); }); + it("should throw if a keystores path does not exist", () => { + tmpDir = fs.mkdtempSync("cli-keystores-import-test"); + + const missingKeystorePath = inTmp("missing-validator-keys"); + + expect(() => + importKeystoreDefinitionsFromExternalDir({keystoresPath: [missingKeystorePath], password: "12345678"}) + ).toThrow("importKeystores must point to an existing file or directory"); + }); + + it("should read per-keystore password files from passwords dir", async () => { + tmpDir = fs.mkdtempSync("cli-keystores-import-test"); + + const passphrases = ["AAAAAAAA0000000000", "BBBBBBBB1111111111"]; + const secretKeys = cachedSeckeysHex.slice(0, passphrases.length); + const pubkeys = cachedPubkeysHex.slice(0, passphrases.length); + const passwordsDir = inTmp("passwords"); + const keystorePaths = passphrases.map((_, index) => inTmp(`keystore_${index}.json`)); + const keystoresStr = await Promise.all( + secretKeys.map(async (secretKey, index) => { + const [keystoreStr] = await getKeystoresStr(passphrases[index], [secretKey]); + return keystoreStr; + }) + ); + + fs.mkdirSync(passwordsDir, {recursive: true}); + + for (const [index, keystorePath] of keystorePaths.entries()) { + fs.mkdirSync(path.dirname(keystorePath), {recursive: true}); + fs.writeFileSync(keystorePath, keystoresStr[index]); + fs.writeFileSync(path.join(passwordsDir, `${pubkeys[index]}.txt`), passphrases[index]); + } + + const definitions = importKeystoreDefinitionsFromExternalDir({keystoresPath: [tmpDir], passwordsDir}); + + expect(definitions.sort((a, b) => a.keystorePath.localeCompare(b.keystorePath))).toEqual( + keystorePaths + .map((keystorePath, index) => ({ + keystorePath, + password: passphrases[index], + })) + .sort((a, b) => a.keystorePath.localeCompare(b.keystorePath)) + ); + }); + + it("should throw if per-keystore passwords dir does not exist", async () => { + tmpDir = fs.mkdtempSync("cli-keystores-import-test"); + + const passphrase = "AAAAAAAA0000000000"; + const [secretKey] = cachedSeckeysHex; + const [keystoreStr] = await getKeystoresStr(passphrase, [secretKey]); + const keystorePath = inTmp("keystore_0.json"); + const passwordsDir = inTmp("missing-passwords"); + + fs.writeFileSync(keystorePath, keystoreStr); + + expect(() => importKeystoreDefinitionsFromExternalDir({keystoresPath: [tmpDir], passwordsDir})).toThrow( + "importKeystoresPasswords must point to an existing directory" + ); + }); + + it("should throw if per-keystore passwords path is not a directory", async () => { + tmpDir = fs.mkdtempSync("cli-keystores-import-test"); + + const passphrase = "AAAAAAAA0000000000"; + const [secretKey] = cachedSeckeysHex; + const [keystoreStr] = await getKeystoresStr(passphrase, [secretKey]); + const keystorePath = inTmp("keystore_0.json"); + const passwordsDir = inTmp("passwords.txt"); + + fs.writeFileSync(keystorePath, keystoreStr); + fs.writeFileSync(passwordsDir, passphrase); + + expect(() => importKeystoreDefinitionsFromExternalDir({keystoresPath: [tmpDir], passwordsDir})).toThrow( + "importKeystoresPasswords must point to a directory" + ); + }); + + it("should throw if per-keystore password file is missing", async () => { + tmpDir = fs.mkdtempSync("cli-keystores-import-test"); + + const passwordsDir = inTmp("passwords"); + const passphrase = "AAAAAAAA0000000000"; + const [secretKey] = cachedSeckeysHex; + const [keystoreStr] = await getKeystoresStr(passphrase, [secretKey]); + const keystorePath = inTmp("keystore_0.json"); + + fs.mkdirSync(passwordsDir, {recursive: true}); + fs.writeFileSync(keystorePath, keystoreStr); + + expect(() => importKeystoreDefinitionsFromExternalDir({keystoresPath: [tmpDir], passwordsDir})).toThrow( + "No password file found for keystore" + ); + }); + + it("should include keystore path if a per-keystore keystore file is invalid", () => { + tmpDir = fs.mkdtempSync("cli-keystores-import-test"); + + const passwordsDir = inTmp("passwords"); + const keystorePath = inTmp("keystore_0.json"); + + fs.mkdirSync(passwordsDir, {recursive: true}); + fs.writeFileSync(keystorePath, "not-json"); + + expect(() => importKeystoreDefinitionsFromExternalDir({keystoresPath: [tmpDir], passwordsDir})).toThrow( + `Failed to read keystore ${keystorePath}` + ); + }); + function inTmp(filepath: string): string { return path.join(tmpDir, filepath); } From 674be369d21f3f4b949088f958bb7babe17ab250 Mon Sep 17 00:00:00 2001 From: JackCC Date: Sun, 19 Apr 2026 11:32:17 +0800 Subject: [PATCH 2/2] fix(cli): normalize per-keystore password filenames to lowercase --- .../signers/importExternalKeystores.ts | 2 +- packages/cli/test/unit/validator/keys.test.ts | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cmds/validator/signers/importExternalKeystores.ts b/packages/cli/src/cmds/validator/signers/importExternalKeystores.ts index 78cbaa381939..5fd265414447 100644 --- a/packages/cli/src/cmds/validator/signers/importExternalKeystores.ts +++ b/packages/cli/src/cmds/validator/signers/importExternalKeystores.ts @@ -96,7 +96,7 @@ function getPasswordFilepathForKeystore(keystorePath: string, passwordsDir: stri throw new YargsError(`Failed to read keystore ${keystorePath}: ${e instanceof Error ? e.message : String(e)}`); } - const passwordFilepath = path.join(passwordsDir, `${pubkeyHex}.txt`); + const passwordFilepath = path.join(passwordsDir, `${pubkeyHex.toLowerCase()}.txt`); if (!fs.existsSync(passwordFilepath)) { throw new YargsError( diff --git a/packages/cli/test/unit/validator/keys.test.ts b/packages/cli/test/unit/validator/keys.test.ts index 8fbd374e9faa..12a52ac79293 100644 --- a/packages/cli/test/unit/validator/keys.test.ts +++ b/packages/cli/test/unit/validator/keys.test.ts @@ -77,6 +77,28 @@ describe("validator / signers / importKeystoreDefinitionsFromExternalDir", () => ); }); + it("should use lowercase per-keystore password filename for mixed-case pubkey keystore", async () => { + tmpDir = fs.mkdtempSync("cli-keystores-import-test"); + + const passphrase = "AAAAAAAA0000000000"; + const [secretKey] = cachedSeckeysHex; + const [pubkey] = cachedPubkeysHex; + const [keystoreStr] = await getKeystoresStr(passphrase, [secretKey]); + const keystorePath = inTmp("keystore_0.json"); + const passwordsDir = inTmp("passwords"); + + const keystore = JSON.parse(keystoreStr) as {pubkey: string}; + keystore.pubkey = keystore.pubkey.toUpperCase(); + + fs.mkdirSync(passwordsDir, {recursive: true}); + fs.writeFileSync(keystorePath, JSON.stringify(keystore)); + fs.writeFileSync(path.join(passwordsDir, `${pubkey}.txt`), passphrase); + + const definitions = importKeystoreDefinitionsFromExternalDir({keystoresPath: [tmpDir], passwordsDir}); + + expect(definitions).toEqual([{keystorePath, password: passphrase}]); + }); + it("should throw if per-keystore passwords dir does not exist", async () => { tmpDir = fs.mkdtempSync("cli-keystores-import-test");