diff --git a/README.md b/README.md index 26132c8..9564c10 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,28 +677,36 @@ _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 (gated by the reveal password if one is set) ``` 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 - Find private key for public key + 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 All Key +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 + $ 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 All Key + 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)_ @@ -743,6 +753,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 956205f..5508264 100644 --- a/src/commands/key/get.ts +++ b/src/commands/key/get.ts @@ -1,22 +1,53 @@ -import { Command } from '@oclif/command' +import { Command, flags } 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' +import { CONFIRMATION_PHRASE } from '../../storage/confirmation' 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 (gated by the reveal password if one is set)' static args = [ - {name: 'publicKey', required: true}, + { 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) { - 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. 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 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.')) + 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..884f692 100644 --- a/src/commands/key/list.ts +++ b/src/commands/key/list.ts @@ -1,21 +1,95 @@ -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' +import { isRevealPasswordSet, requireRevealPassword } from '../../storage/revealPassword' +import { CONFIRMATION_PHRASE } from '../../storage/confirmation' 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 (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 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() { + 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 + // 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) { + 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. 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 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.')) + 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) } } 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/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 3742630..34db054 100644 --- a/src/storage/config.ts +++ b/src/storage/config.ts @@ -35,8 +35,29 @@ 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 { + salt: string; + hash: string; + N: number; + r: number; + p: number; + keyLen: number; +} + export const config = new Conf<{ privateKeys: string[]; isLocked: boolean; @@ -44,6 +65,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/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 new file mode 100644 index 0000000..134e4b6 --- /dev/null +++ b/src/storage/revealPassword.ts @@ -0,0 +1,102 @@ +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 } +// 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 + +// 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 + const hash = scryptSync(password, salt, keyLen, { N, r, p, maxmem: SCRYPT_MAXMEM }) + return { + salt: salt.toString('hex'), + hash: hash.toString('hex'), + N, r, p, keyLen, + } +} + +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 }) + 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') +} + +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' }) + 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.') + } +}