From 0824db4d7765bf1134947d3a5cdf5fcb0ed2324a Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 19 Aug 2026 17:23:33 +0200 Subject: [PATCH 1/2] Add --keystore flag for signing with an encrypted keystore celocli could sign with a raw private key or a Ledger, but had no way to use an encrypted keystore file, which is how geth and cast users keep keys on disk. --keystore takes a keystore file, or a directory of them in which case --from selects the account. The password comes from --passwordFile, or a hidden prompt when that is not given. Resolution is memoized so a keystore is decrypted, and its password requested, once per command. The resolved key flows into the same paths --privateKey already uses, so both the ContractKit and viem signing paths are covered without further branching. Export the V3 primitives from @celo/keystores so the CLI can decrypt a single file without going through FileKeystore. --- .changeset/cli-keystore-flag.md | 24 ++++ packages/cli/package.json | 1 + packages/cli/src/base.test.ts | 68 +++++++++- packages/cli/src/base.ts | 59 +++++++- packages/cli/src/utils/command.ts | 5 + packages/cli/src/utils/keystore.test.ts | 173 ++++++++++++++++++++++++ packages/cli/src/utils/keystore.ts | 93 +++++++++++++ packages/sdk/keystores/src/index.ts | 1 + yarn.lock | 3 +- 9 files changed, 418 insertions(+), 9 deletions(-) create mode 100644 .changeset/cli-keystore-flag.md create mode 100644 packages/cli/src/utils/keystore.test.ts create mode 100644 packages/cli/src/utils/keystore.ts diff --git a/.changeset/cli-keystore-flag.md b/.changeset/cli-keystore-flag.md new file mode 100644 index 0000000000..2f98587421 --- /dev/null +++ b/.changeset/cli-keystore-flag.md @@ -0,0 +1,24 @@ +--- +'@celo/celocli': minor +'@celo/keystores': patch +--- + +Add a `--keystore` flag for signing with an encrypted keystore file, in the +spirit of `cast --keystore`. + +`--keystore` takes a keystore file, or a directory of them in which case +`--from` selects the account. The password is read from `--passwordFile` when +given, and otherwise requested with a hidden prompt. `--keystore` is mutually +exclusive with `--privateKey` and `--useLedger`. + +```bash +celocli transfer:celo --keystore ~/keystore/UTC--2024-...--8233d802... --to 0x... --value 1 +celocli transfer:celo --keystore ~/keystore --from 0x8233d802... --to 0x... --value 1 +``` + +`@celo/keystores` now exports its V3 keystore primitives (`decryptV3`, +`encryptV3`, `v3Filename`) so that they can be used without going through +`FileKeystore`. + +Note that `--keystore` does not yet work with the `bridge:*` commands, which +construct their signer independently of `BaseCommand`. diff --git a/packages/cli/package.json b/packages/cli/package.json index 72db533825..37360f867b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -48,6 +48,7 @@ "@celo/cryptographic-utils": "^6.0.0", "@celo/explorer": "^5.1.1", "@celo/governance": "^5.1.11", + "@celo/keystores": "^5.0.16", "@celo/metadata-claims": "^1.0.4", "@celo/utils": "^8.0.3", "@celo/viem-account-ledger": "^1.2.3", diff --git a/packages/cli/src/base.test.ts b/packages/cli/src/base.test.ts index e59f61d753..02f97ba4cb 100644 --- a/packages/cli/src/base.test.ts +++ b/packages/cli/src/base.test.ts @@ -1,16 +1,23 @@ import { Connection } from '@celo/connect' import { testWithAnvilL2 } from '@celo/dev-utils/anvil-test' +import { encryptV3, v3Filename } from '@celo/keystores' import * as ViemAccountLedgerExports from '@celo/viem-account-ledger' import * as WalletLedgerExports from '@celo/wallet-ledger' import { Config, ux } from '@oclif/core' +import { mkdtempSync, rmSync, writeFileSync } from 'fs' import http from 'http' import { tmpdir } from 'os' +import { join } from 'path' import { MethodNotFoundRpcError } from 'viem' import { privateKeyToAddress } from 'viem/accounts' import { BaseCommand } from './base' import Set from './commands/config/set' import CustomHelp from './help' -import { stripAnsiCodesFromNestedArray, testLocallyWithNode } from './test-utils/cliUtils' +import { + stripAnsiCodesFromNestedArray, + testLocallyWithNode, + testWithoutChain, +} from './test-utils/cliUtils' import { mockRpcFetch } from './test-utils/mockRpc' import { CustomFlags } from './utils/command' import * as config from './utils/config' @@ -99,6 +106,65 @@ describe('flags', () => { }) }) +describe('keystore flags', () => { + class TestKeystoreCommand extends BaseCommand { + static flags = { + ...BaseCommand.flags, + from: CustomFlags.address({ required: false }), + } + async run() { + // These cases are all rejected while parsing flags, so run() is never reached. + throw new Error('flag parsing should have rejected this invocation') + } + } + + let dir: string + let keystoreFile: string + + beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'celocli-base-keystore-')) + const keystore = await encryptV3( + '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + 'pw' + ) + keystoreFile = join(dir, v3Filename(keystore.address, Date.parse('2024-01-01T00:00:00Z'))) + writeFileSync(keystoreFile, JSON.stringify(keystore)) + }) + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('rejects --keystore together with --privateKey', async () => { + await expect( + testWithoutChain(TestKeystoreCommand, [ + '--keystore', + keystoreFile, + '--privateKey', + '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + ]) + ).rejects.toThrow(/cannot also be provided|--privateKey/) + }) + + it('rejects --keystore together with --useLedger', async () => { + await expect( + testWithoutChain(TestKeystoreCommand, ['--keystore', keystoreFile, '--useLedger']) + ).rejects.toThrow(/cannot also be provided|--useLedger/) + }) + + it('rejects --passwordFile without --keystore', async () => { + await expect( + testWithoutChain(TestKeystoreCommand, ['--passwordFile', keystoreFile]) + ).rejects.toThrow(/keystore/) + }) + + it('rejects a --keystore path that does not exist', async () => { + await expect( + testWithoutChain(TestKeystoreCommand, ['--keystore', join(dir, 'nope.json')]) + ).rejects.toThrow('does not exist') + }) +}) + // Make sure telemetry tests are deterministic, otherwise we'd have to update tests every release jest.mock('../package.json', () => ({ version: '5.2.3', diff --git a/packages/cli/src/base.ts b/packages/cli/src/base.ts index e4b33a9fd9..2e324bae1c 100644 --- a/packages/cli/src/base.ts +++ b/packages/cli/src/base.ts @@ -1,8 +1,8 @@ import { type PublicCeloClient, type WalletCeloClient } from '@celo/actions' import { CELO_DERIVATION_PATH_BASE, - ensureLeading0x, ETHEREUM_DERIVATION_PATH, + ensureLeading0x, StrongAddress, } from '@celo/base' import { type Provider, ReadOnlyWallet } from '@celo/connect' @@ -35,6 +35,7 @@ import { CustomFlags } from './utils/command' import { configExists, getDefaultDerivationPath, getNodeUrl } from './utils/config' import { getFeeCurrencyContractWrapper } from './utils/fee-currency' import { requireNodeIsSynced } from './utils/helpers' +import { privateKeyFromKeystore } from './utils/keystore' import { reportUsageStatisticsIfTelemetryEnabled } from './utils/telemetry' export abstract class BaseCommand extends Command { @@ -43,7 +44,19 @@ export abstract class BaseCommand extends Command { char: 'k', description: 'Use a private key to sign local transactions with', hidden: false, - exclusive: ['useLedger', 'useAKV'], + exclusive: ['useLedger', 'useAKV', 'keystore'], + }), + keystore: CustomFlags.path({ + description: + 'Path to an encrypted keystore file, or to a directory of them. When a directory is given, --from selects the account. You will be prompted for the password unless --passwordFile is set.', + hidden: false, + exclusive: ['privateKey', 'useLedger', 'useAKV'], + }), + passwordFile: CustomFlags.path({ + dependsOn: ['keystore'], + noCacheDefault: true, + description: 'Path to a file containing the password for --keystore', + hidden: false, }), node: Flags.string({ char: 'n', @@ -148,6 +161,7 @@ export abstract class BaseCommand extends Command { private walletClient: WalletCeloClient | null = null private _parseResult: null | ParserOutput = null private ledgerTransport: Awaited> | null = null + private _signingPrivateKey: string | null = null get _wallet(): ReadOnlyWallet | undefined { // the wallet lives on the connection; returning this._wallet would recurse @@ -158,6 +172,31 @@ export abstract class BaseCommand extends Command { this._kit!.connection.wallet = wallet } + /** + * The private key to sign with, from --privateKey or by unlocking --keystore. + * Memoized so that a keystore is only decrypted, and its password only + * requested, once per command. + * @returns The private key, or undefined when signing is delegated elsewhere + * (a Ledger, or an account the node has unlocked). + */ + protected async getSigningPrivateKey(): Promise { + const res = await this.parse() + if (res.flags.privateKey) { + return res.flags.privateKey + } + if (!res.flags.keystore) { + return undefined + } + if (!this._signingPrivateKey) { + this._signingPrivateKey = await privateKeyFromKeystore({ + keystorePath: res.flags.keystore, + passwordFile: res.flags.passwordFile, + from: res.flags.from, + }) + } + return this._signingPrivateKey + } + protected async getNodeUrl(): Promise { const res = await this.parse() @@ -178,8 +217,11 @@ export abstract class BaseCommand extends Command { } const res = await this.parse() - if (res.flags && res.flags.privateKey && !res.flags.useLedger && !res.flags.useAKV) { - this._kit.connection.addAccount(res.flags.privateKey) + if (res.flags && !res.flags.useLedger && !res.flags.useAKV) { + const privateKey = await this.getSigningPrivateKey() + if (privateKey) { + this._kit.connection.addAccount(privateKey) + } } return this._kit @@ -260,11 +302,14 @@ export abstract class BaseCommand extends Command { } } else if (res.flags.useAKV) { failWith('--useAKV flag is no longer supported') - } else if (res.flags.privateKey) { - const accountFromPrivateKey = privateKeyToAccount(ensureLeading0x(res.flags.privateKey)) + } else if (await this.getSigningPrivateKey()) { + const privateKey = (await this.getSigningPrivateKey())! + const accountFromPrivateKey = privateKeyToAccount(ensureLeading0x(privateKey)) if (accountAddress && !isAddressEqual(accountAddress, accountFromPrivateKey.address)) { failWith( - `The --from address ${accountAddress} does not match the address derived from the provided private key ${accountFromPrivateKey.address}.` + `The --from address ${accountAddress} does not match the address derived from the ${ + res.flags.keystore ? 'keystore' : 'provided private key' + } ${accountFromPrivateKey.address}.` ) } this.walletClient = createWalletClient({ diff --git a/packages/cli/src/utils/command.ts b/packages/cli/src/utils/command.ts index 5d0a90a136..bc44f863e4 100644 --- a/packages/cli/src/utils/command.ts +++ b/packages/cli/src/utils/command.ts @@ -197,6 +197,11 @@ export const CustomFlags = { description: 'Hex string', helpValue: '0x', }), + path: Flags.custom({ + parse: parsePath, + description: 'Path to an existing file or directory', + helpValue: '/path/to/file', + }), } export const CustomArgs = { diff --git a/packages/cli/src/utils/keystore.test.ts b/packages/cli/src/utils/keystore.test.ts new file mode 100644 index 0000000000..ef15486016 --- /dev/null +++ b/packages/cli/src/utils/keystore.test.ts @@ -0,0 +1,173 @@ +import { StrongAddress } from '@celo/base' +import { encryptV3, v3Filename } from '@celo/keystores' +import { ux } from '@oclif/core' +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { privateKeyFromKeystore } from './keystore' + +jest.setTimeout(60000) + +const PK1 = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' +const ADDRESS1 = '0x1Be31A94361a391bBaFB2a4CCd704F57dc04d4bb' as StrongAddress +const PK2 = '0xd72f6c0b0d7348a72eaa7d3c997bd49293bdc7d4bf79eba03e9f7ca9c5ac6b7f' +const ADDRESS2 = '0x8233d802BdC645d0d1b9B2E6face6e5825905081' as StrongAddress +const PASSWORD = 'test-keystore-password' + +describe('privateKeyFromKeystore', () => { + let dir: string + let keystore1: string + let passwordFile: string + + const writeKeystore = async (privateKey: string, password = PASSWORD) => { + const keystore = await encryptV3(privateKey, password) + const file = join(dir, v3Filename(keystore.address, Date.parse('2024-01-01T00:00:00Z'))) + writeFileSync(file, JSON.stringify(keystore)) + return file + } + + beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'celocli-keystore-test-')) + keystore1 = await writeKeystore(PK1) + passwordFile = join(dir, 'password.txt') + writeFileSync(passwordFile, `${PASSWORD}\n`) + }) + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + describe('given a keystore file', () => { + it('returns the private key', async () => { + expect(await privateKeyFromKeystore({ keystorePath: keystore1, passwordFile })).toEqual(PK1) + }) + + it('ignores a trailing newline in the password file', async () => { + const noNewline = join(dir, 'password-no-newline.txt') + writeFileSync(noNewline, PASSWORD) + expect( + await privateKeyFromKeystore({ keystorePath: keystore1, passwordFile: noNewline }) + ).toEqual(PK1) + }) + + it('fails on a wrong password', async () => { + const wrong = join(dir, 'wrong.txt') + writeFileSync(wrong, 'not-the-password') + await expect( + privateKeyFromKeystore({ keystorePath: keystore1, passwordFile: wrong }) + ).rejects.toThrow('Key derivation failed - possibly wrong passphrase') + }) + + it('reports which keystore could not be unlocked', async () => { + const wrong = join(dir, 'wrong2.txt') + writeFileSync(wrong, 'nope') + await expect( + privateKeyFromKeystore({ keystorePath: keystore1, passwordFile: wrong }) + ).rejects.toThrow(keystore1) + }) + + it('fails on a file that is not a keystore', async () => { + const notKeystore = join(dir, 'notes.txt') + writeFileSync(notKeystore, 'hello') + await expect( + privateKeyFromKeystore({ keystorePath: notKeystore, passwordFile }) + ).rejects.toThrow(/Could not unlock keystore/) + }) + }) + + describe('password prompt', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('prompts when no password file is given, hiding the input', async () => { + const promptSpy = jest.spyOn(ux, 'prompt').mockResolvedValue(PASSWORD) + + expect(await privateKeyFromKeystore({ keystorePath: keystore1 })).toEqual(PK1) + expect(promptSpy).toHaveBeenCalledWith('Keystore password', { + type: 'hide', + required: true, + }) + }) + + it('does not prompt when a password file is given', async () => { + const promptSpy = jest.spyOn(ux, 'prompt') + + await privateKeyFromKeystore({ keystorePath: keystore1, passwordFile }) + expect(promptSpy).not.toHaveBeenCalled() + }) + }) + + describe('given a directory', () => { + let multiDir: string + + beforeAll(async () => { + multiDir = mkdtempSync(join(tmpdir(), 'celocli-keystore-multi-')) + for (const pk of [PK1, PK2]) { + const keystore = await encryptV3(pk, PASSWORD) + writeFileSync( + join(multiDir, v3Filename(keystore.address, Date.parse('2024-01-01T00:00:00Z'))), + JSON.stringify(keystore) + ) + } + }) + + afterAll(() => { + rmSync(multiDir, { recursive: true, force: true }) + }) + + it('selects the keystore matching --from', async () => { + expect( + await privateKeyFromKeystore({ keystorePath: multiDir, passwordFile, from: ADDRESS1 }) + ).toEqual(PK1) + expect( + await privateKeyFromKeystore({ keystorePath: multiDir, passwordFile, from: ADDRESS2 }) + ).toEqual(PK2) + }) + + it('matches --from regardless of case', async () => { + expect( + await privateKeyFromKeystore({ + keystorePath: multiDir, + passwordFile, + from: ADDRESS1.toLowerCase() as StrongAddress, + }) + ).toEqual(PK1) + }) + + it('requires --from, listing the addresses it found', async () => { + const promise = privateKeyFromKeystore({ keystorePath: multiDir, passwordFile }) + await expect(promise).rejects.toThrow('--from is required') + await expect(promise).rejects.toThrow(ADDRESS1.toLowerCase()) + await expect(promise).rejects.toThrow(ADDRESS2.toLowerCase()) + }) + + it('fails when no keystore matches --from', async () => { + await expect( + privateKeyFromKeystore({ + keystorePath: multiDir, + passwordFile, + from: '0x0000000000000000000000000000000000000001' as StrongAddress, + }) + ).rejects.toThrow('No keystore for 0x0000000000000000000000000000000000000001') + }) + + it('skips files that are not keystores', async () => { + writeFileSync(join(multiDir, 'README'), 'not a keystore') + expect( + await privateKeyFromKeystore({ keystorePath: multiDir, passwordFile, from: ADDRESS1 }) + ).toEqual(PK1) + }) + + it('fails when the directory holds no keystores', async () => { + const empty = mkdtempSync(join(tmpdir(), 'celocli-keystore-empty-')) + try { + await expect( + privateKeyFromKeystore({ keystorePath: empty, passwordFile, from: ADDRESS1 }) + ).rejects.toThrow('No keystore files found') + } finally { + rmSync(empty, { recursive: true, force: true }) + } + }) + }) +}) diff --git a/packages/cli/src/utils/keystore.ts b/packages/cli/src/utils/keystore.ts new file mode 100644 index 0000000000..bb9e8a19b0 --- /dev/null +++ b/packages/cli/src/utils/keystore.ts @@ -0,0 +1,93 @@ +import { normalizeAddressWith0x, StrongAddress } from '@celo/base' +import { decryptV3 } from '@celo/keystores' +import { ux } from '@oclif/core' +import { readdirSync, readFileSync, statSync } from 'fs' +import path from 'path' +import { failWith } from './cli' + +interface KeystoreOptions { + /** Path to a keystore file, or to a directory containing keystore files */ + keystorePath: string + /** Path to a file whose contents are the passphrase */ + passwordFile?: string + /** Which account to unlock; required when keystorePath is a directory */ + from?: StrongAddress +} + +/** + * Reads the address a keystore entry belongs to without decrypting it. + * Returns undefined for files that aren't keystores, so that unrelated files + * sitting in a keystore directory are skipped rather than fatal. + */ +function readKeystoreAddress(file: string): string | undefined { + try { + const { address } = JSON.parse(readFileSync(file, 'utf8')) + return typeof address === 'string' ? normalizeAddressWith0x(address) : undefined + } catch { + return undefined + } +} + +/** + * Picks the keystore file holding a given address out of a directory. + * Keystore filenames are conventional, not authoritative, so entries are + * matched on the address recorded inside each file. + */ +function findKeystoreInDirectory(directory: string, from?: StrongAddress): string { + const entries = readdirSync(directory) + .map((name) => path.join(directory, name)) + .filter((file) => statSync(file).isFile()) + .map((file) => ({ file, address: readKeystoreAddress(file) })) + .filter((entry): entry is { file: string; address: string } => entry.address !== undefined) + + if (entries.length === 0) { + failWith(`No keystore files found in "${directory}"`) + } + if (!from) { + failWith( + `--from is required to choose which account to unlock in "${directory}". Available: ${entries + .map((entry) => entry.address) + .join(', ')}` + ) + } + + const match = entries.find((entry) => entry.address === normalizeAddressWith0x(from)) + if (!match) { + failWith( + `No keystore for ${from} in "${directory}". Available: ${entries + .map((entry) => entry.address) + .join(', ')}` + ) + } + return match.file +} + +async function readPassphrase(passwordFile?: string): Promise { + if (passwordFile) { + // Trailing newlines are an artifact of writing the file, not part of the passphrase. + return readFileSync(passwordFile, 'utf8').replace(/\r?\n$/, '') + } + return ux.prompt('Keystore password', { type: 'hide', required: true }) +} + +/** + * Resolves the private key held in a keystore, prompting for the passphrase + * unless a password file is given. + * @returns Private key as a 0x-prefixed hex string + */ +export async function privateKeyFromKeystore({ + keystorePath, + passwordFile, + from, +}: KeystoreOptions): Promise { + const keystoreFile = statSync(keystorePath).isDirectory() + ? findKeystoreInDirectory(keystorePath, from) + : keystorePath + + const passphrase = await readPassphrase(passwordFile) + try { + return await decryptV3(readFileSync(keystoreFile, 'utf8'), passphrase) + } catch (error) { + return failWith(`Could not unlock keystore "${keystoreFile}": ${(error as Error).message}`) + } +} diff --git a/packages/sdk/keystores/src/index.ts b/packages/sdk/keystores/src/index.ts index 9c1467886b..e26988225d 100644 --- a/packages/sdk/keystores/src/index.ts +++ b/packages/sdk/keystores/src/index.ts @@ -2,3 +2,4 @@ export * from './file-keystore' export * from './inmemory-keystore' export * from './keystore-base' export * from './keystore-wallet-wrapper' +export * from './v3-keystore' diff --git a/yarn.lock b/yarn.lock index b81d6bbe5a..362a390525 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1767,6 +1767,7 @@ __metadata: "@celo/dev-utils": "workspace:^" "@celo/explorer": "npm:^5.1.1" "@celo/governance": "npm:^5.1.11" + "@celo/keystores": "npm:^5.0.16" "@celo/metadata-claims": "npm:^1.0.4" "@celo/utils": "npm:^8.0.3" "@celo/viem-account-ledger": "npm:^1.2.3" @@ -1995,7 +1996,7 @@ __metadata: languageName: node linkType: hard -"@celo/keystores@workspace:packages/sdk/keystores": +"@celo/keystores@npm:^5.0.16, @celo/keystores@workspace:packages/sdk/keystores": version: 0.0.0-use.local resolution: "@celo/keystores@workspace:packages/sdk/keystores" dependencies: From 0874c69606cdc8ca3a7d540832cfa3ca6a552345 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 19 Aug 2026 17:32:59 +0200 Subject: [PATCH 2/2] Cover the keystore wiring and tighten flag exclusivity Both --useLedger and --useAKV now declare --keystore as exclusive, matching the reciprocal style of the other signer flags so the conflict shows up in generated help from either side. Enforcement already worked one-sided. Add tests for the BaseCommand wiring itself: that the wallet client signs with the keystore's address, that the password is requested once however many times the key is needed, and that a mismatched --from is reported against the keystore. Only the public client is stubbed; the key really is decrypted and its address really is derived. Bump @celo/keystores to minor, since exporting the V3 primitives adds public API. --- .changeset/cli-keystore-flag.md | 2 +- packages/cli/src/base.test.ts | 81 +++++++++++++++++++++++++++++++-- packages/cli/src/base.ts | 6 +-- 3 files changed, 81 insertions(+), 8 deletions(-) diff --git a/.changeset/cli-keystore-flag.md b/.changeset/cli-keystore-flag.md index 2f98587421..eeca74b559 100644 --- a/.changeset/cli-keystore-flag.md +++ b/.changeset/cli-keystore-flag.md @@ -1,6 +1,6 @@ --- '@celo/celocli': minor -'@celo/keystores': patch +'@celo/keystores': minor --- Add a `--keystore` flag for signing with an encrypted keystore file, in the diff --git a/packages/cli/src/base.test.ts b/packages/cli/src/base.test.ts index 02f97ba4cb..78c61c0809 100644 --- a/packages/cli/src/base.test.ts +++ b/packages/cli/src/base.test.ts @@ -1,3 +1,4 @@ +import { type PublicCeloClient } from '@celo/actions' import { Connection } from '@celo/connect' import { testWithAnvilL2 } from '@celo/dev-utils/anvil-test' import { encryptV3, v3Filename } from '@celo/keystores' @@ -10,6 +11,7 @@ import { tmpdir } from 'os' import { join } from 'path' import { MethodNotFoundRpcError } from 'viem' import { privateKeyToAddress } from 'viem/accounts' +import { celoSepolia } from 'viem/chains' import { BaseCommand } from './base' import Set from './commands/config/set' import CustomHelp from './help' @@ -118,17 +120,20 @@ describe('keystore flags', () => { } } + const PASSWORD = 'pw' + const keystorePrivateKey = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + const keystoreAddress = privateKeyToAddress(keystorePrivateKey) let dir: string let keystoreFile: string + let passwordFile: string beforeAll(async () => { dir = mkdtempSync(join(tmpdir(), 'celocli-base-keystore-')) - const keystore = await encryptV3( - '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', - 'pw' - ) + const keystore = await encryptV3(keystorePrivateKey, PASSWORD) keystoreFile = join(dir, v3Filename(keystore.address, Date.parse('2024-01-01T00:00:00Z'))) writeFileSync(keystoreFile, JSON.stringify(keystore)) + passwordFile = join(dir, 'password.txt') + writeFileSync(passwordFile, PASSWORD) }) afterAll(() => { @@ -163,6 +168,74 @@ describe('keystore flags', () => { testWithoutChain(TestKeystoreCommand, ['--keystore', join(dir, 'nope.json')]) ).rejects.toThrow('does not exist') }) + + // The wallet client is built without contacting a node, so only the public + // client is stubbed out here; the key really is decrypted and its address + // really is derived. + describe('wiring into the wallet client', () => { + let config: Config + + class ExposedKeystoreCommand extends TestKeystoreCommand { + async run() { + await this.getWalletClient() + } + public resolveSigningKey() { + return this.getSigningPrivateKey() + } + } + + const commandWith = (argv: string[]) => { + const command = new ExposedKeystoreCommand( + [...argv, '--node', 'http://localhost:8545'], + config + ) + jest + .spyOn(BaseCommand.prototype, 'getPublicClient') + .mockResolvedValue({ chain: celoSepolia } as unknown as PublicCeloClient) + return command + } + + beforeAll(async () => { + config = await Config.load(require.main?.filename || __dirname) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('signs with the address held in the keystore', async () => { + const command = commandWith(['--keystore', keystoreFile, '--passwordFile', passwordFile]) + + const walletClient = await command.getWalletClient() + expect(walletClient.account.address).toBe(keystoreAddress) + }) + + it('asks for the password once, however many times the key is needed', async () => { + const promptSpy = jest.spyOn(ux, 'prompt').mockResolvedValue(PASSWORD) + const command = commandWith(['--keystore', keystoreFile]) + + await command.resolveSigningKey() + await command.resolveSigningKey() + await command.getWalletClient() + + expect(promptSpy).toHaveBeenCalledTimes(1) + }) + + it('fails when --from disagrees with the keystore', async () => { + const command = commandWith([ + '--keystore', + keystoreFile, + '--passwordFile', + passwordFile, + '--from', + '0x0000000000000000000000000000000000000001', + ]) + + await expect(command.getWalletClient()).rejects.toThrow( + `does not match the address derived from the keystore ${keystoreAddress}` + ) + }) + }) }) // Make sure telemetry tests are deterministic, otherwise we'd have to update tests every release diff --git a/packages/cli/src/base.ts b/packages/cli/src/base.ts index 2e324bae1c..1eb86d801e 100644 --- a/packages/cli/src/base.ts +++ b/packages/cli/src/base.ts @@ -94,7 +94,7 @@ export abstract class BaseCommand extends Command { useLedger: Flags.boolean({ default: false, hidden: false, - exclusive: ['privateKey'], + exclusive: ['privateKey', 'keystore'], description: 'Set it to use a ledger wallet', }), ledgerAddresses: Flags.integer({ @@ -120,7 +120,7 @@ export abstract class BaseCommand extends Command { }), useAKV: Flags.boolean({ hidden: true, - exclusive: ['privateKey', 'useLedger'], + exclusive: ['privateKey', 'useLedger', 'keystore'], deprecated: true, description: 'Set it to use an Azure KeyVault HSM', }), @@ -302,7 +302,7 @@ export abstract class BaseCommand extends Command { } } else if (res.flags.useAKV) { failWith('--useAKV flag is no longer supported') - } else if (await this.getSigningPrivateKey()) { + } else if (res.flags.privateKey || res.flags.keystore) { const privateKey = (await this.getSigningPrivateKey())! const accountFromPrivateKey = privateKeyToAccount(ensureLeading0x(privateKey)) if (accountAddress && !isAddressEqual(accountAddress, accountFromPrivateKey.address)) {