From 0a1e60c609699a8f8d3798a6b8675a79a55526d7 Mon Sep 17 00:00:00 2001 From: Paul Grey Date: Fri, 24 Apr 2026 11:58:52 +1200 Subject: [PATCH 1/6] Redact private keys from key:list and key:get by default key:list now shows only public keys with their associated accounts (resolved via get_accounts_by_authorizers). Private keys require --reveal-private plus a typed "I UNDERSTAND" confirmation. key:get now also requires the typed confirmation before printing a private key. Both commands refuse to print private keys when stdout or stdin is not a TTY (prevents accidental leaks via pipes, redirects, or CI logs). Pass --force to bypass the prompt in trusted interactive contexts. Previously "key:list" would dump every stored private key to the terminal with no warning or opt-in, which is dangerous during screen shares, pair programming, or recorded sessions. --- src/commands/key/get.ts | 38 +++++++++++++++---- src/commands/key/list.ts | 81 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 104 insertions(+), 15 deletions(-) diff --git a/src/commands/key/get.ts b/src/commands/key/get.ts index 956205f..f2b5ec5 100644 --- a/src/commands/key/get.ts +++ b/src/commands/key/get.ts @@ -1,22 +1,46 @@ -import { Command } from '@oclif/command' +import { Command, flags } from '@oclif/command' import { CliUx } from '@oclif/core' import { green, red } from 'colors' import passwordManager from '../../storage/passwordManager' +const CONFIRMATION_PHRASE = 'I UNDERSTAND' + export default class GetPrivateKey extends Command { - static description = 'Find private key for public key' + static description = 'Reveal the private key for a saved public key (requires typed confirmation)' static args = [ - {name: 'publicKey', required: true}, + { name: 'publicKey', required: true }, ] + static flags = { + force: flags.boolean({ + char: 'f', + description: 'Skip the typed confirmation (use only in trusted, non-interactive contexts)', + default: false, + }), + } + async run() { - const { args } = this.parse(GetPrivateKey) + const { args, flags: parsedFlags } = this.parse(GetPrivateKey) + const privateKey = await passwordManager.getPrivateKey(args.publicKey) - if (privateKey) { - CliUx.ux.log(`${green('Success:')} ${privateKey}`) - } else { + if (!privateKey) { CliUx.ux.log(`${red('Failure:')} No matching private key found in saved keys`) + return } + + if (!parsedFlags.force) { + if (!process.stdout.isTTY || !process.stdin.isTTY) { + CliUx.ux.error('Refusing to print a private key to a non-TTY stream. Re-run in an interactive terminal, or pass --force if you know what you are doing.') + } + CliUx.ux.log(red('WARNING: This will print a PRIVATE KEY to the terminal.')) + CliUx.ux.log(red('Anyone with this key can control the associated account. Make sure no one is watching and your terminal is not being recorded.')) + const confirmation = await CliUx.ux.prompt(`Type "${CONFIRMATION_PHRASE}" to continue`) + if (confirmation !== CONFIRMATION_PHRASE) { + CliUx.ux.error('Confirmation phrase did not match. Aborting.') + } + } + + CliUx.ux.log(`${green('Success:')} ${privateKey}`) } } diff --git a/src/commands/key/list.ts b/src/commands/key/list.ts index 5629c65..1fd6780 100644 --- a/src/commands/key/list.ts +++ b/src/commands/key/list.ts @@ -1,21 +1,86 @@ -import { Command } from '@oclif/command' -import {CliUx} from '@oclif/core' +import { Command, flags } from '@oclif/command' +import { CliUx } from '@oclif/core' import { Key } from '@proton/js' +import { red, yellow } from 'colors' import passwordManager from '../../storage/passwordManager' +import { network } from '../../storage/networks' + +const CONFIRMATION_PHRASE = 'I UNDERSTAND' export default class ListAllKeys extends Command { - static description = 'List All Key' + static description = 'List saved keys. Shows public keys and associated accounts by default; pass --reveal-private to include private keys.' + + static flags = { + 'reveal-private': flags.boolean({ + char: 'r', + description: 'Include private keys in the output (requires typed confirmation)', + default: false, + }), + force: flags.boolean({ + char: 'f', + description: 'Skip the typed confirmation when used with --reveal-private (use only in trusted, non-interactive contexts)', + default: false, + }), + } async run() { + const { flags: parsedFlags } = this.parse(ListAllKeys) + const revealPrivate = parsedFlags['reveal-private'] + const privateKeys = await passwordManager.getPrivateKeys() - const displayKeys = privateKeys.map(privateKey => { - const parsedPrivateKey = Key.PrivateKey.fromString(privateKey) + if (privateKeys.length === 0) { + CliUx.ux.log('No keys saved.') + return + } + + const publicKeys = privateKeys.map(pk => Key.PrivateKey.fromString(pk).getPublicKey().toString()) + + let accountsByPubkey: Record> = {} + try { + const res = await network.rpc.get_accounts_by_authorizers([], publicKeys) + for (const entry of res.accounts) { + if (!entry.authorizing_key) continue + const list = accountsByPubkey[entry.authorizing_key] || (accountsByPubkey[entry.authorizing_key] = []) + list.push({ account: entry.account_name, permission: entry.permission_name }) + } + } catch (err) { + CliUx.ux.warn(`Could not resolve accounts for keys: ${(err as Error).message}`) + } + + if (!revealPrivate) { + const display = privateKeys.map(pk => { + const publicKey = Key.PrivateKey.fromString(pk).getPublicKey().toString() + return { + publicKey, + accounts: accountsByPubkey[publicKey] || [], + } + }) + CliUx.ux.styledJSON(display) + CliUx.ux.log(yellow('\nPrivate keys hidden. Use --reveal-private to include them.')) + return + } + + if (!parsedFlags.force) { + if (!process.stdout.isTTY || !process.stdin.isTTY) { + CliUx.ux.error('Refusing to print private keys to a non-TTY stream. Re-run in an interactive terminal, or pass --force if you know what you are doing.') + } + CliUx.ux.log(red('WARNING: This will print your PRIVATE KEYS to the terminal.')) + CliUx.ux.log(red('Anyone with these keys can control your accounts. Make sure no one is watching and your terminal is not being recorded.')) + const confirmation = await CliUx.ux.prompt(`Type "${CONFIRMATION_PHRASE}" to continue`) + if (confirmation !== CONFIRMATION_PHRASE) { + CliUx.ux.error('Confirmation phrase did not match. Aborting.') + } + } + const display = privateKeys.map(pk => { + const parsed = Key.PrivateKey.fromString(pk) + const publicKey = parsed.getPublicKey().toString() return { - publicKey: parsedPrivateKey.getPublicKey().toString(), - privateKey: parsedPrivateKey.toString() + publicKey, + privateKey: parsed.toString(), + accounts: accountsByPubkey[publicKey] || [], } }) - CliUx.ux.styledJSON(displayKeys); + CliUx.ux.styledJSON(display) } } From 63d44943e0d244c3ffad683d0e1e77912288ff0e Mon Sep 17 00:00:00 2001 From: Paul Grey Date: Fri, 24 Apr 2026 13:19:47 +1200 Subject: [PATCH 2/6] docs: update README for key:list / key:get new flags --- README.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 26132c8..dcffb9f 100644 --- a/README.md +++ b/README.md @@ -675,28 +675,35 @@ _See code: [lib/commands/key/generate.js](https://github.com/ProtonProtocol/prot ## `proton key:get PUBLICKEY` -Find private key for public key +Reveal the private key for a saved public key (requires typed confirmation) ``` USAGE - $ proton key:get [PUBLICKEY] + $ proton key:get [PUBLICKEY] [-f] + +FLAGS + -f, --force Skip the typed confirmation (use only in trusted, non-interactive contexts) DESCRIPTION - Find private key for public key + Reveal the private key for a saved public key (requires typed confirmation) ``` _See code: [lib/commands/key/get.js](https://github.com/ProtonProtocol/proton-cli/blob/v0.1.97/lib/commands/key/get.js)_ ## `proton key:list` -List All Key +List saved keys. Shows public keys and associated accounts by default; pass --reveal-private to include private keys. ``` USAGE - $ proton key:list + $ proton key:list [-r] [-f] + +FLAGS + -r, --reveal-private Include private keys in the output (requires typed confirmation) + -f, --force Skip the typed confirmation when used with --reveal-private (use only in trusted, non-interactive contexts) DESCRIPTION - List All Key + List saved keys. Shows public keys and associated accounts by default; pass --reveal-private to include private keys. ``` _See code: [lib/commands/key/list.js](https://github.com/ProtonProtocol/proton-cli/blob/v0.1.97/lib/commands/key/list.js)_ From 856b2cf4ebc32abccdf9e67d42d0017055f4c3fc Mon Sep 17 00:00:00 2001 From: Paul Grey Date: Fri, 24 Apr 2026 13:27:01 +1200 Subject: [PATCH 3/6] fix(key:list): normalize legacy EOS-format keys from RPC response get_accounts_by_authorizers returns authorizing_key in legacy EOS format (EOS...), but public keys were mapped by the modern K1 form (PUB_K1_...). String-equality lookups missed every entry, leaving accounts: [] for every key. Normalize via Key.PublicKey.fromString ().toString() so both formats resolve to the canonical modern form. --- src/commands/key/list.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/key/list.ts b/src/commands/key/list.ts index 1fd6780..1dea077 100644 --- a/src/commands/key/list.ts +++ b/src/commands/key/list.ts @@ -40,7 +40,9 @@ export default class ListAllKeys extends Command { const res = await network.rpc.get_accounts_by_authorizers([], publicKeys) for (const entry of res.accounts) { if (!entry.authorizing_key) continue - const list = accountsByPubkey[entry.authorizing_key] || (accountsByPubkey[entry.authorizing_key] = []) + // RPC may return keys in legacy EOS format; normalize to modern PUB_K1_... form. + const canonical = Key.PublicKey.fromString(entry.authorizing_key).toString() + const list = accountsByPubkey[canonical] || (accountsByPubkey[canonical] = []) list.push({ account: entry.account_name, permission: entry.permission_name }) } } catch (err) { From 5c8ca562562d40a24ad9dd9ea5c008efca8c280b Mon Sep 17 00:00:00 2001 From: Paul Grey Date: Fri, 24 Apr 2026 13:46:14 +1200 Subject: [PATCH 4/6] Add reveal-password gate for key:get and key:list --reveal-private Introduces a separate reveal password, held only by the user, that gates private-key disclosure in the CLI. Daily operations (signing transactions, listing public keys) are unaffected, so AI agents and scripts can continue to run normal commands without friction. New commands: - proton key:reveal-setup Set or change the reveal password. Stored as a salted scrypt hash in config; plaintext never written to disk. - proton key:reveal-disable Remove the reveal password (requires entering the current one). Changed commands: - proton key:get If a reveal password is set, prompts for it and verifies (scrypt, constant-time compare) before printing. If unset, keeps the typed "I UNDERSTAND" confirmation and hints the user toward key:reveal-setup. - proton key:list -r Same gating as key:get. Removed: - --force flag on both commands. It was the escape hatch an AI agent used to bypass the typed confirmation on its own accord. The reveal password is the intentional opt-in; no "skip all prompts" switch. The non-TTY refusal is preserved: neither command will print a private key when stdout or stdin is not a terminal. Known limitation: the reveal password gates the CLI path only. A process running as the user with filesystem access can still read unencrypted keys from the config directly. For full at-rest protection use key:lock or a follow-up Keychain-backed store. --- README.md | 50 +++++++++++++++++++++------ src/commands/key/get.ts | 29 +++++++--------- src/commands/key/list.ts | 22 ++++++------ src/commands/key/reveal-disable.ts | 36 ++++++++++++++++++++ src/commands/key/reveal-setup.ts | 50 +++++++++++++++++++++++++++ src/storage/config.ts | 10 ++++++ src/storage/revealPassword.ts | 54 ++++++++++++++++++++++++++++++ 7 files changed, 213 insertions(+), 38 deletions(-) create mode 100644 src/commands/key/reveal-disable.ts create mode 100644 src/commands/key/reveal-setup.ts create mode 100644 src/storage/revealPassword.ts diff --git a/README.md b/README.md index dcffb9f..05d8ac5 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,8 @@ USAGE * [`proton key:lock`](#proton-keylock) * [`proton key:remove [PRIVATEKEY]`](#proton-keyremove-privatekey) * [`proton key:reset`](#proton-keyreset) +* [`proton key:reveal-disable`](#proton-keyreveal-disable) +* [`proton key:reveal-setup`](#proton-keyreveal-setup) * [`proton key:unlock [PASSWORD]`](#proton-keyunlock-password) * [`proton msig:approve PROPOSER PROPOSAL AUTH`](#proton-msigapprove-proposer-proposal-auth) * [`proton msig:cancel PROPOSALNAME AUTH`](#proton-msigcancel-proposalname-auth) @@ -675,35 +677,32 @@ _See code: [lib/commands/key/generate.js](https://github.com/ProtonProtocol/prot ## `proton key:get PUBLICKEY` -Reveal the private key for a saved public key (requires typed confirmation) +Reveal the private key for a saved public key (gated by the reveal password if one is set) ``` USAGE - $ proton key:get [PUBLICKEY] [-f] - -FLAGS - -f, --force Skip the typed confirmation (use only in trusted, non-interactive contexts) + $ proton key:get [PUBLICKEY] DESCRIPTION - Reveal the private key for a saved public key (requires typed confirmation) + Reveal the private key for a saved public key (gated by the reveal password + if one is set) ``` _See code: [lib/commands/key/get.js](https://github.com/ProtonProtocol/proton-cli/blob/v0.1.97/lib/commands/key/get.js)_ ## `proton key:list` -List saved keys. Shows public keys and associated accounts by default; pass --reveal-private to include private keys. +List saved keys. Shows public keys and associated accounts by default; pass --reveal-private to include private keys (gated by the reveal password if one is set). ``` USAGE - $ proton key:list [-r] [-f] + $ proton key:list [-r] FLAGS - -r, --reveal-private Include private keys in the output (requires typed confirmation) - -f, --force Skip the typed confirmation when used with --reveal-private (use only in trusted, non-interactive contexts) + -r, --reveal-private Include private keys in the output (requires the reveal password if set, or a typed confirmation otherwise) DESCRIPTION - List saved keys. Shows public keys and associated accounts by default; pass --reveal-private to include private keys. + List saved keys. Shows public keys and associated accounts by default; pass --reveal-private to include private keys (gated by the reveal password if one is set). ``` _See code: [lib/commands/key/list.js](https://github.com/ProtonProtocol/proton-cli/blob/v0.1.97/lib/commands/key/list.js)_ @@ -750,6 +749,35 @@ DESCRIPTION _See code: [lib/commands/key/reset.js](https://github.com/ProtonProtocol/proton-cli/blob/v0.1.97/lib/commands/key/reset.js)_ +## `proton key:reveal-disable` + +Remove the reveal password (requires entering the current one) + +``` +USAGE + $ proton key:reveal-disable + +DESCRIPTION + Remove the reveal password (requires entering the current one) +``` + +_See code: [lib/commands/key/reveal-disable.js](https://github.com/ProtonProtocol/proton-cli/blob/v0.1.97/lib/commands/key/reveal-disable.js)_ + +## `proton key:reveal-setup` + +Set or change the reveal password required to view private keys via key:get or key:list --reveal-private + +``` +USAGE + $ proton key:reveal-setup + +DESCRIPTION + Set or change the reveal password required to view private keys via key:get + or key:list --reveal-private +``` + +_See code: [lib/commands/key/reveal-setup.js](https://github.com/ProtonProtocol/proton-cli/blob/v0.1.97/lib/commands/key/reveal-setup.js)_ + ## `proton key:unlock [PASSWORD]` Unlock all keys (Caution: Your keys will be stored in plaintext on disk) diff --git a/src/commands/key/get.ts b/src/commands/key/get.ts index f2b5ec5..7c744e7 100644 --- a/src/commands/key/get.ts +++ b/src/commands/key/get.ts @@ -1,27 +1,20 @@ -import { Command, flags } from '@oclif/command' +import { Command } from '@oclif/command' import { CliUx } from '@oclif/core' -import { green, red } from 'colors' +import { green, red, yellow } from 'colors' import passwordManager from '../../storage/passwordManager' +import { isRevealPasswordSet, requireRevealPassword } from '../../storage/revealPassword' const CONFIRMATION_PHRASE = 'I UNDERSTAND' export default class GetPrivateKey extends Command { - static description = 'Reveal the private key for a saved public key (requires typed confirmation)' + static description = 'Reveal the private key for a saved public key (gated by the reveal password if one is set)' static args = [ { name: 'publicKey', required: true }, ] - static flags = { - force: flags.boolean({ - char: 'f', - description: 'Skip the typed confirmation (use only in trusted, non-interactive contexts)', - default: false, - }), - } - async run() { - const { args, flags: parsedFlags } = this.parse(GetPrivateKey) + const { args } = this.parse(GetPrivateKey) const privateKey = await passwordManager.getPrivateKey(args.publicKey) if (!privateKey) { @@ -29,10 +22,14 @@ export default class GetPrivateKey extends Command { return } - if (!parsedFlags.force) { - if (!process.stdout.isTTY || !process.stdin.isTTY) { - CliUx.ux.error('Refusing to print a private key to a non-TTY stream. Re-run in an interactive terminal, or pass --force if you know what you are doing.') - } + if (!process.stdout.isTTY || !process.stdin.isTTY) { + CliUx.ux.error('Refusing to print a private key to a non-TTY stream. Run this in an interactive terminal.') + } + + if (isRevealPasswordSet()) { + await requireRevealPassword() + } else { + CliUx.ux.log(yellow('No reveal password is set. Run `proton key:reveal-setup` to protect private-key reveals behind a password that AI agents running on this machine cannot bypass.')) CliUx.ux.log(red('WARNING: This will print a PRIVATE KEY to the terminal.')) CliUx.ux.log(red('Anyone with this key can control the associated account. Make sure no one is watching and your terminal is not being recorded.')) const confirmation = await CliUx.ux.prompt(`Type "${CONFIRMATION_PHRASE}" to continue`) diff --git a/src/commands/key/list.ts b/src/commands/key/list.ts index 1dea077..795aa88 100644 --- a/src/commands/key/list.ts +++ b/src/commands/key/list.ts @@ -4,21 +4,17 @@ import { Key } from '@proton/js' import { red, yellow } from 'colors' import passwordManager from '../../storage/passwordManager' import { network } from '../../storage/networks' +import { isRevealPasswordSet, requireRevealPassword } from '../../storage/revealPassword' const CONFIRMATION_PHRASE = 'I UNDERSTAND' export default class ListAllKeys extends Command { - static description = 'List saved keys. Shows public keys and associated accounts by default; pass --reveal-private to include private keys.' + static description = 'List saved keys. Shows public keys and associated accounts by default; pass --reveal-private to include private keys (gated by the reveal password if one is set).' static flags = { 'reveal-private': flags.boolean({ char: 'r', - description: 'Include private keys in the output (requires typed confirmation)', - default: false, - }), - force: flags.boolean({ - char: 'f', - description: 'Skip the typed confirmation when used with --reveal-private (use only in trusted, non-interactive contexts)', + description: 'Include private keys in the output (requires the reveal password if set, or a typed confirmation otherwise)', default: false, }), } @@ -62,10 +58,14 @@ export default class ListAllKeys extends Command { return } - if (!parsedFlags.force) { - if (!process.stdout.isTTY || !process.stdin.isTTY) { - CliUx.ux.error('Refusing to print private keys to a non-TTY stream. Re-run in an interactive terminal, or pass --force if you know what you are doing.') - } + if (!process.stdout.isTTY || !process.stdin.isTTY) { + CliUx.ux.error('Refusing to print private keys to a non-TTY stream. Run this in an interactive terminal.') + } + + if (isRevealPasswordSet()) { + await requireRevealPassword() + } else { + CliUx.ux.log(yellow('No reveal password is set. Run `proton key:reveal-setup` to protect private-key reveals behind a password that AI agents running on this machine cannot bypass.')) CliUx.ux.log(red('WARNING: This will print your PRIVATE KEYS to the terminal.')) CliUx.ux.log(red('Anyone with these keys can control your accounts. Make sure no one is watching and your terminal is not being recorded.')) const confirmation = await CliUx.ux.prompt(`Type "${CONFIRMATION_PHRASE}" to continue`) diff --git a/src/commands/key/reveal-disable.ts b/src/commands/key/reveal-disable.ts new file mode 100644 index 0000000..54d405d --- /dev/null +++ b/src/commands/key/reveal-disable.ts @@ -0,0 +1,36 @@ +import { Command } from '@oclif/command' +import { CliUx } from '@oclif/core' +import { green, red } from 'colors' +import { + clearRevealPasswordHash, + getStoredRevealPasswordHash, + isRevealPasswordSet, + verifyRevealPassword, +} from '../../storage/revealPassword' + +export default class KeyRevealDisable extends Command { + static description = 'Remove the reveal password (requires entering the current one)' + + async run() { + if (!isRevealPasswordSet()) { + CliUx.ux.log('No reveal password is set.') + return + } + if (!process.stdin.isTTY || !process.stdout.isTTY) { + CliUx.ux.error('This command must be run in an interactive terminal.') + } + + const current = await CliUx.ux.prompt('Current reveal password', { type: 'hide' }) + const stored = getStoredRevealPasswordHash()! + if (!verifyRevealPassword(current, stored)) { + CliUx.ux.error('Current reveal password is incorrect.') + } + + clearRevealPasswordHash() + CliUx.ux.log(green('Success: reveal password removed. key:get and key:list --reveal-private will no longer prompt for it.')) + } + + async catch(e: Error) { + CliUx.ux.error(red(e.message)) + } +} diff --git a/src/commands/key/reveal-setup.ts b/src/commands/key/reveal-setup.ts new file mode 100644 index 0000000..27afdef --- /dev/null +++ b/src/commands/key/reveal-setup.ts @@ -0,0 +1,50 @@ +import { Command } from '@oclif/command' +import { CliUx } from '@oclif/core' +import { green, red, yellow } from 'colors' +import { + hashRevealPassword, + isRevealPasswordSet, + setRevealPasswordHash, + verifyRevealPassword, + getStoredRevealPasswordHash, +} from '../../storage/revealPassword' + +const MIN_LENGTH = 12 + +export default class KeyRevealSetup extends Command { + static description = 'Set or change the reveal password required to view private keys via key:get or key:list --reveal-private' + + async run() { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + CliUx.ux.error('This command must be run in an interactive terminal.') + } + + if (isRevealPasswordSet()) { + CliUx.ux.log(yellow('A reveal password is already set. Enter the current one to change it.')) + const current = await CliUx.ux.prompt('Current reveal password', { type: 'hide' }) + const stored = getStoredRevealPasswordHash()! + if (!verifyRevealPassword(current, stored)) { + CliUx.ux.error('Current reveal password is incorrect.') + } + } else { + CliUx.ux.log('Setting a reveal password. This will be required whenever a private key is revealed via key:get or key:list --reveal-private.') + CliUx.ux.log(yellow('Use a password you keep only in your head. Do not share it with AI agents, pair programmers, or scripts.')) + } + + const pw1 = await CliUx.ux.prompt(`New reveal password (min ${MIN_LENGTH} chars)`, { type: 'hide' }) + if (pw1.length < MIN_LENGTH) { + CliUx.ux.error(`Password must be at least ${MIN_LENGTH} characters.`) + } + const pw2 = await CliUx.ux.prompt('Confirm reveal password', { type: 'hide' }) + if (pw1 !== pw2) { + CliUx.ux.error('Passwords do not match.') + } + + setRevealPasswordHash(hashRevealPassword(pw1)) + CliUx.ux.log(green('Success: reveal password set. key:get and key:list --reveal-private will now prompt for it.')) + } + + async catch(e: Error) { + CliUx.ux.error(red(e.message)) + } +} diff --git a/src/storage/config.ts b/src/storage/config.ts index 3742630..47e09de 100644 --- a/src/storage/config.ts +++ b/src/storage/config.ts @@ -37,6 +37,15 @@ const schema = { }, }; +export interface RevealPasswordHash { + salt: string; + hash: string; + N: number; + r: number; + p: number; + keyLen: number; +} + export const config = new Conf<{ privateKeys: string[]; isLocked: boolean; @@ -44,6 +53,7 @@ export const config = new Conf<{ networks: { chain: string; endpoints: string[] }[]; currentChain: string; endpoints?: { chain: string; endpoints: string[] }[]; + revealPasswordHash?: RevealPasswordHash; }>({ schema, configName: "proton-cli", diff --git a/src/storage/revealPassword.ts b/src/storage/revealPassword.ts new file mode 100644 index 0000000..a653ae7 --- /dev/null +++ b/src/storage/revealPassword.ts @@ -0,0 +1,54 @@ +import { scryptSync, randomBytes, timingSafeEqual } from 'crypto' +import { CliUx } from '@oclif/core' +import { config, RevealPasswordHash } from './config' + +const SCRYPT_PARAMS = { N: 2 ** 15, r: 8, p: 1, keyLen: 64 } + +export function hashRevealPassword(password: string): RevealPasswordHash { + const salt = randomBytes(32) + const { N, r, p, keyLen } = SCRYPT_PARAMS + const hash = scryptSync(password, salt, keyLen, { N, r, p }) + return { + salt: salt.toString('hex'), + hash: hash.toString('hex'), + N, r, p, keyLen, + } +} + +export function verifyRevealPassword(password: string, stored: RevealPasswordHash): boolean { + const salt = Buffer.from(stored.salt, 'hex') + const expected = Buffer.from(stored.hash, 'hex') + const actual = scryptSync(password, salt, stored.keyLen, { N: stored.N, r: stored.r, p: stored.p }) + if (actual.length !== expected.length) return false + return timingSafeEqual(actual, expected) +} + +export function isRevealPasswordSet(): boolean { + return !!config.get('revealPasswordHash') +} + +export function getStoredRevealPasswordHash(): RevealPasswordHash | undefined { + return config.get('revealPasswordHash') +} + +export function setRevealPasswordHash(hash: RevealPasswordHash): void { + config.set('revealPasswordHash', hash) +} + +export function clearRevealPasswordHash(): void { + config.delete('revealPasswordHash' as any) +} + +export async function requireRevealPassword(): Promise { + const stored = getStoredRevealPasswordHash() + if (!stored) return + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + CliUx.ux.error('Refusing to prompt for the reveal password in a non-TTY stream. Run this in an interactive terminal.') + } + + const input = await CliUx.ux.prompt('Enter reveal password', { type: 'hide' }) + if (!verifyRevealPassword(input, stored)) { + CliUx.ux.error('Reveal password is incorrect.') + } +} From 360561bf15b7dd047525ffc1f998bd523fa5df4b Mon Sep 17 00:00:00 2001 From: Paul Grey Date: Fri, 24 Apr 2026 13:51:33 +1200 Subject: [PATCH 5/6] fix(revealPassword): raise scrypt maxmem so the hash actually runs scrypt with N=2^15, r=8 needs ~32 MiB of RAM per call, which lands right at Node's default maxmem. Some builds round up and throw "memory limit exceeded". Explicitly pass maxmem=128 MiB on both hash and verify so the call always succeeds; the cost/security params are unchanged. --- src/storage/revealPassword.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/storage/revealPassword.ts b/src/storage/revealPassword.ts index a653ae7..8d7fb43 100644 --- a/src/storage/revealPassword.ts +++ b/src/storage/revealPassword.ts @@ -3,11 +3,14 @@ import { CliUx } from '@oclif/core' import { config, RevealPasswordHash } from './config' const SCRYPT_PARAMS = { N: 2 ** 15, r: 8, p: 1, keyLen: 64 } +// Scrypt memory cost is ~128 * N * r bytes. For N=2^15, r=8 that's 32 MiB, which +// is exactly at Node's default maxmem. Raise the ceiling so the hash actually runs. +const SCRYPT_MAXMEM = 128 * 1024 * 1024 export function hashRevealPassword(password: string): RevealPasswordHash { const salt = randomBytes(32) const { N, r, p, keyLen } = SCRYPT_PARAMS - const hash = scryptSync(password, salt, keyLen, { N, r, p }) + const hash = scryptSync(password, salt, keyLen, { N, r, p, maxmem: SCRYPT_MAXMEM }) return { salt: salt.toString('hex'), hash: hash.toString('hex'), @@ -18,7 +21,7 @@ export function hashRevealPassword(password: string): RevealPasswordHash { export function verifyRevealPassword(password: string, stored: RevealPasswordHash): boolean { const salt = Buffer.from(stored.salt, 'hex') const expected = Buffer.from(stored.hash, 'hex') - const actual = scryptSync(password, salt, stored.keyLen, { N: stored.N, r: stored.r, p: stored.p }) + const actual = scryptSync(password, salt, stored.keyLen, { N: stored.N, r: stored.r, p: stored.p, maxmem: SCRYPT_MAXMEM }) if (actual.length !== expected.length) return false return timingSafeEqual(actual, expected) } From dff3f38f7e33f9bbf60f9f2c51af9ff6c9619e36 Mon Sep 17 00:00:00 2001 From: Paul Grey Date: Thu, 30 Apr 2026 04:59:37 +1200 Subject: [PATCH 6/6] Address PR #39 review: restore --force, update endpoints, harden config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore --force flag on key:get and key:list (per @roman): Scripts need a way to bypass interactive prompts. The flag now skips the typed "I UNDERSTAND" confirmation and the TTY refusal, but it does NOT skip the reveal password if one is set — the reveal password remains the agent-resistant gate. Update default RPC endpoints in src/constants.ts (per @roman): The previous endpoints did not implement get_accounts_by_authorizers, causing key:list account resolution to error out. Replaced with endpoints that support it: proton: rpc.api.mainnet.metalx.com, proton.cryptolions.io, proton.eosusa.io proton-test: rpc.api.testnet.metalx.com, proton-testnet.eoscafeblock.com, test.proton.eosusa.io Address Copilot review comments: - Validate stored scrypt parameters (N power-of-two and bounded, r/p/keyLen bounded, salt/hash hex-shaped and bounded). Rejects tampered config values that could otherwise trigger local DoS via extreme memory/cost factors. - Wrap verifyRevealPassword in requireRevealPassword with a try/catch that emits a clear remediation message ("run key:reveal-disable then key:reveal-setup") instead of an unhandled stack trace. - Drop the `as any` from clearRevealPasswordHash now that revealPasswordHash is in the Conf type generic. - Add a JSON Schema entry for revealPasswordHash so malformed values are rejected by Conf at load time. - Extract the shared CONFIRMATION_PHRASE into src/storage/confirmation.ts; both key:get and key:list import it. README: document the restored --force flag and its non-bypass of the reveal password. --- README.md | 8 ++++-- src/commands/key/get.ts | 24 ++++++++++++----- src/commands/key/list.ts | 17 ++++++++---- src/constants.ts | 11 +++++--- src/storage/config.ts | 12 +++++++++ src/storage/confirmation.ts | 3 +++ src/storage/revealPassword.ts | 49 +++++++++++++++++++++++++++++++++-- 7 files changed, 105 insertions(+), 19 deletions(-) create mode 100644 src/storage/confirmation.ts diff --git a/README.md b/README.md index 05d8ac5..9564c10 100644 --- a/README.md +++ b/README.md @@ -681,7 +681,10 @@ Reveal the private key for a saved public key (gated by the reveal password if o ``` USAGE - $ proton key:get [PUBLICKEY] + $ proton key:get [PUBLICKEY] [-f] + +FLAGS + -f, --force Skip the typed confirmation and TTY check. Intended for non-interactive scripts. Does NOT skip the reveal password if one is set. DESCRIPTION Reveal the private key for a saved public key (gated by the reveal password @@ -696,10 +699,11 @@ List saved keys. Shows public keys and associated accounts by default; pass --re ``` USAGE - $ proton key:list [-r] + $ proton key:list [-r] [-f] FLAGS -r, --reveal-private Include private keys in the output (requires the reveal password if set, or a typed confirmation otherwise) + -f, --force Skip the typed confirmation and TTY check when used with --reveal-private. Intended for non-interactive scripts. Does NOT skip the reveal password if one is set. DESCRIPTION List saved keys. Shows public keys and associated accounts by default; pass --reveal-private to include private keys (gated by the reveal password if one is set). diff --git a/src/commands/key/get.ts b/src/commands/key/get.ts index 7c744e7..5508264 100644 --- a/src/commands/key/get.ts +++ b/src/commands/key/get.ts @@ -1,10 +1,9 @@ -import { Command } from '@oclif/command' +import { Command, flags } from '@oclif/command' import { CliUx } from '@oclif/core' import { green, red, yellow } from 'colors' import passwordManager from '../../storage/passwordManager' import { isRevealPasswordSet, requireRevealPassword } from '../../storage/revealPassword' - -const CONFIRMATION_PHRASE = 'I UNDERSTAND' +import { CONFIRMATION_PHRASE } from '../../storage/confirmation' export default class GetPrivateKey extends Command { static description = 'Reveal the private key for a saved public key (gated by the reveal password if one is set)' @@ -13,8 +12,16 @@ export default class GetPrivateKey extends Command { { name: 'publicKey', required: true }, ] + static flags = { + force: flags.boolean({ + char: 'f', + description: 'Skip the typed confirmation and TTY check. Intended for non-interactive scripts. Does NOT skip the reveal password if one is set.', + default: false, + }), + } + async run() { - const { args } = this.parse(GetPrivateKey) + const { args, flags: parsedFlags } = this.parse(GetPrivateKey) const privateKey = await passwordManager.getPrivateKey(args.publicKey) if (!privateKey) { @@ -22,13 +29,16 @@ export default class GetPrivateKey extends Command { return } - if (!process.stdout.isTTY || !process.stdin.isTTY) { - CliUx.ux.error('Refusing to print a private key to a non-TTY stream. Run this in an interactive terminal.') + if (!parsedFlags.force) { + if (!process.stdout.isTTY || !process.stdin.isTTY) { + CliUx.ux.error('Refusing to print a private key to a non-TTY stream. Run this in an interactive terminal, or pass --force in a trusted script.') + } } if (isRevealPasswordSet()) { + // Reveal password is required regardless of --force. await requireRevealPassword() - } else { + } else if (!parsedFlags.force) { CliUx.ux.log(yellow('No reveal password is set. Run `proton key:reveal-setup` to protect private-key reveals behind a password that AI agents running on this machine cannot bypass.')) CliUx.ux.log(red('WARNING: This will print a PRIVATE KEY to the terminal.')) CliUx.ux.log(red('Anyone with this key can control the associated account. Make sure no one is watching and your terminal is not being recorded.')) diff --git a/src/commands/key/list.ts b/src/commands/key/list.ts index 795aa88..884f692 100644 --- a/src/commands/key/list.ts +++ b/src/commands/key/list.ts @@ -5,8 +5,7 @@ import { red, yellow } from 'colors' import passwordManager from '../../storage/passwordManager' import { network } from '../../storage/networks' import { isRevealPasswordSet, requireRevealPassword } from '../../storage/revealPassword' - -const CONFIRMATION_PHRASE = 'I UNDERSTAND' +import { CONFIRMATION_PHRASE } from '../../storage/confirmation' export default class ListAllKeys extends Command { static description = 'List saved keys. Shows public keys and associated accounts by default; pass --reveal-private to include private keys (gated by the reveal password if one is set).' @@ -17,6 +16,11 @@ export default class ListAllKeys extends Command { description: 'Include private keys in the output (requires the reveal password if set, or a typed confirmation otherwise)', default: false, }), + force: flags.boolean({ + char: 'f', + description: 'Skip the typed confirmation and TTY check when used with --reveal-private. Intended for non-interactive scripts. Does NOT skip the reveal password if one is set.', + default: false, + }), } async run() { @@ -58,13 +62,16 @@ export default class ListAllKeys extends Command { return } - if (!process.stdout.isTTY || !process.stdin.isTTY) { - CliUx.ux.error('Refusing to print private keys to a non-TTY stream. Run this in an interactive terminal.') + if (!parsedFlags.force) { + if (!process.stdout.isTTY || !process.stdin.isTTY) { + CliUx.ux.error('Refusing to print private keys to a non-TTY stream. Run this in an interactive terminal, or pass --force in a trusted script.') + } } if (isRevealPasswordSet()) { + // Reveal password is required regardless of --force. await requireRevealPassword() - } else { + } else if (!parsedFlags.force) { CliUx.ux.log(yellow('No reveal password is set. Run `proton key:reveal-setup` to protect private-key reveals behind a password that AI agents running on this machine cannot bypass.')) CliUx.ux.log(red('WARNING: This will print your PRIVATE KEYS to the terminal.')) CliUx.ux.log(red('Anyone with these keys can control your accounts. Make sure no one is watching and your terminal is not being recorded.')) diff --git a/src/constants.ts b/src/constants.ts index 8d5b131..147f7d2 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,13 +1,18 @@ export const networks = [ { chain: "proton", - endpoints: ["https://proton.greymass.com"], + endpoints: [ + "https://rpc.api.mainnet.metalx.com", + "https://proton.cryptolions.io", + "https://proton.eosusa.io", + ], }, { chain: "proton-test", endpoints: [ - "https://protontestnet.ledgerwise.io", - "https://proton-testnet.eosphere.io", + "https://rpc.api.testnet.metalx.com", + "https://proton-testnet.eoscafeblock.com", + "https://test.proton.eosusa.io", ], }, ]; diff --git a/src/storage/config.ts b/src/storage/config.ts index 47e09de..34db054 100644 --- a/src/storage/config.ts +++ b/src/storage/config.ts @@ -35,6 +35,18 @@ const schema = { currentChain: { type: JST.String, }, + revealPasswordHash: { + type: JST.Object, + properties: { + salt: { type: JST.String, pattern: "^[0-9a-fA-F]+$" }, + hash: { type: JST.String, pattern: "^[0-9a-fA-F]+$" }, + N: { type: JST.Number, minimum: 1024, maximum: 1048576 }, + r: { type: JST.Number, minimum: 1, maximum: 32 }, + p: { type: JST.Number, minimum: 1, maximum: 16 }, + keyLen: { type: JST.Number, minimum: 16, maximum: 128 }, + }, + required: ["salt", "hash", "N", "r", "p", "keyLen"], + }, }; export interface RevealPasswordHash { diff --git a/src/storage/confirmation.ts b/src/storage/confirmation.ts new file mode 100644 index 0000000..f88b0fd --- /dev/null +++ b/src/storage/confirmation.ts @@ -0,0 +1,3 @@ +// Shared confirmation phrase used as a typed-acknowledgement gate in commands +// that print private keys when no reveal password is configured. +export const CONFIRMATION_PHRASE = 'I UNDERSTAND' diff --git a/src/storage/revealPassword.ts b/src/storage/revealPassword.ts index 8d7fb43..134e4b6 100644 --- a/src/storage/revealPassword.ts +++ b/src/storage/revealPassword.ts @@ -7,6 +7,43 @@ const SCRYPT_PARAMS = { N: 2 ** 15, r: 8, p: 1, keyLen: 64 } // is exactly at Node's default maxmem. Raise the ceiling so the hash actually runs. const SCRYPT_MAXMEM = 128 * 1024 * 1024 +// Bounds for stored scrypt parameters. A tampered config could otherwise pin +// the verifier to extreme values that cause local DoS or excessive memory use. +const PARAM_BOUNDS = { + N: { min: 1 << 10, max: 1 << 20 }, // 1 KiB cost factor up to 1 Mi + r: { min: 1, max: 32 }, + p: { min: 1, max: 16 }, + keyLen: { min: 16, max: 128 }, + saltHexLen: { min: 16, max: 256 }, + hashHexLen: { min: 32, max: 256 }, +} + +function isPowerOfTwo(n: number): boolean { + return Number.isInteger(n) && n > 0 && (n & (n - 1)) === 0 +} + +function validateStoredHash(stored: RevealPasswordHash): void { + const { N, r, p, keyLen, salt, hash } = stored + if (!isPowerOfTwo(N) || N < PARAM_BOUNDS.N.min || N > PARAM_BOUNDS.N.max) { + throw new Error(`Invalid scrypt N parameter (${N}) in stored reveal password.`) + } + if (!Number.isInteger(r) || r < PARAM_BOUNDS.r.min || r > PARAM_BOUNDS.r.max) { + throw new Error(`Invalid scrypt r parameter (${r}) in stored reveal password.`) + } + if (!Number.isInteger(p) || p < PARAM_BOUNDS.p.min || p > PARAM_BOUNDS.p.max) { + throw new Error(`Invalid scrypt p parameter (${p}) in stored reveal password.`) + } + if (!Number.isInteger(keyLen) || keyLen < PARAM_BOUNDS.keyLen.min || keyLen > PARAM_BOUNDS.keyLen.max) { + throw new Error(`Invalid scrypt keyLen (${keyLen}) in stored reveal password.`) + } + if (typeof salt !== 'string' || !/^[0-9a-fA-F]+$/.test(salt) || salt.length < PARAM_BOUNDS.saltHexLen.min || salt.length > PARAM_BOUNDS.saltHexLen.max) { + throw new Error('Invalid salt encoding in stored reveal password.') + } + if (typeof hash !== 'string' || !/^[0-9a-fA-F]+$/.test(hash) || hash.length < PARAM_BOUNDS.hashHexLen.min || hash.length > PARAM_BOUNDS.hashHexLen.max) { + throw new Error('Invalid hash encoding in stored reveal password.') + } +} + export function hashRevealPassword(password: string): RevealPasswordHash { const salt = randomBytes(32) const { N, r, p, keyLen } = SCRYPT_PARAMS @@ -19,6 +56,7 @@ export function hashRevealPassword(password: string): RevealPasswordHash { } export function verifyRevealPassword(password: string, stored: RevealPasswordHash): boolean { + validateStoredHash(stored) const salt = Buffer.from(stored.salt, 'hex') const expected = Buffer.from(stored.hash, 'hex') const actual = scryptSync(password, salt, stored.keyLen, { N: stored.N, r: stored.r, p: stored.p, maxmem: SCRYPT_MAXMEM }) @@ -39,7 +77,7 @@ export function setRevealPasswordHash(hash: RevealPasswordHash): void { } export function clearRevealPasswordHash(): void { - config.delete('revealPasswordHash' as any) + config.delete('revealPasswordHash') } export async function requireRevealPassword(): Promise { @@ -51,7 +89,14 @@ export async function requireRevealPassword(): Promise { } const input = await CliUx.ux.prompt('Enter reveal password', { type: 'hide' }) - if (!verifyRevealPassword(input, stored)) { + let ok: boolean + try { + ok = verifyRevealPassword(input, stored) + } catch (err) { + CliUx.ux.error(`Stored reveal password configuration is invalid: ${(err as Error).message}\nRun \`proton key:reveal-disable\` and then \`proton key:reveal-setup\` to reset it.`) + return + } + if (!ok) { CliUx.ux.error('Reveal password is incorrect.') } }