Skip to content

security: redact private keys and add reveal-password gate for key:get / key:list#39

Open
paulgnz wants to merge 6 commits intoXPRNetwork:masterfrom
paulgnz:security/key-list-redact
Open

security: redact private keys and add reveal-password gate for key:get / key:list#39
paulgnz wants to merge 6 commits intoXPRNetwork:masterfrom
paulgnz:security/key-list-redact

Conversation

@paulgnz
Copy link
Copy Markdown
Contributor

@paulgnz paulgnz commented Apr 23, 2026

Summary

Three layers of protection for the CLI's private-key commands, aimed at AI coding agents that can run shell commands on a developer's machine:

  1. key:list default no longer prints private keys. Shows public keys + the accounts/permissions each key authorizes (resolved via get_accounts_by_authorizers).
  2. key:get and key:list --reveal-private are TTY-only. Piped, redirected, or CI contexts are refused — no --force escape hatch.
  3. Opt-in reveal password (proton key:reveal-setup). When set, a second password held only in the user's head is required to reveal any private key via the CLI. The daily workflow (signing transactions, listing public keys) is unaffected, so agents and scripts keep working.

Motivation

proton key:list previously dumped every stored private key to stdout with no warning. In an AI-agent world this is a routine exposure risk:

  • AI coding agents (the primary concern) — agents like Claude Code, Cursor, Copilot, Devin, and Aider routinely run proton commands on a developer's behalf to inspect state. An agent that runs proton key:list to "see what accounts are available" ingests every private key into its context window, which is then transmitted to a third-party LLM provider (and retained per their data policy), written to local transcript files in plaintext (often cloud-synced), exposed to any subsequent prompt injection in the session, and potentially echoed back in summaries, commit messages, or PR descriptions. Many users run agents with bash auto-approval, so this happens with no human-in-the-loop check. key:list looks like a safe read-only status command, so no model hesitates to run it.
  • Recorded / shared terminals — screen shares, pair programming, Zoom, Loom, asciinema, and corporate session capture all leak the output to other viewers and to recordings that persist indefinitely.
  • Pipes, redirects, and shell historyproton key:list > keys.txt, | tee debug.log, scrollback buffers, and shell history files all quietly write private keys to disk.
  • CI and scripted environments — a misplaced key:list in a script dumps private keys into CI logs, which are typically world-readable inside an organization.
  • Curious exploration — a new user running proton key:list to "see what's in my wallet" is immediately exposed to their own keys without having asked for them.

Industry standard for wallet tooling (hardware wallets, MetaMask, Ledger Live, solana-keygen, cast wallet) is that private keys are never displayed without an explicit, friction-added opt-in. The current Proton CLI behavior is an outlier — and the rise of agentic tooling has turned what was already a foot-gun into a routine exposure risk.

New commands

Command Purpose
proton key:reveal-setup Set or change the reveal password. Stored as a salted scrypt hash. Plaintext is never written to disk.
proton key:reveal-disable Remove the reveal password (requires entering the current one).

Changed commands

Command Default With reveal password set
proton key:list { publicKey, accounts: [{ account, permission }] } unchanged
proton key:list --reveal-private typed "I UNDERSTAND" confirmation prompts for reveal password
proton key:get <pubkey> typed "I UNDERSTAND" confirmation prompts for reveal password

--force has been removed from both commands — it was the exact bypass an AI agent used in practice. The reveal password is the intentional, human-only opt-in.

TTY enforcement: all private-key-printing paths refuse to run when stdout or stdin is not a terminal.

Agent behavior matrix

Agent action Before this PR After this PR (no reveal password) After this PR (reveal password set)
proton key:list dumps all private keys shows public keys + accounts only shows public keys + accounts only
proton key:list --reveal-private N/A blocked (TTY check or prompt it can't answer) blocked (password prompt it can't answer)
proton key:get <pk> prints private key blocked (TTY check) blocked (password prompt)
proton action transfer ... works works works

Scope note

This PR hardens the CLI display paths. Users who want stronger at-rest protection should run proton key:lock. A Keychain-backed store can be a follow-up.

Test plan

  • proton key:list — prints public keys + account arrays, no private keys
  • proton key:list --reveal-private when piped/redirected — refused
  • proton key:get <pk> when piped — refused
  • proton key:reveal-setup — sets password, hash stored, plaintext not persisted
  • proton key:reveal-setup a second time — requires current password
  • proton key:reveal-disable — removes hash, requires current password
  • proton key:list --reveal-private with reveal password set — prompts for password, correct password reveals, wrong password aborts
  • proton key:get <pk> with reveal password set — same
  • npx tsc -b — clean build
  • Verified no private key material (PVT_K1_, PVT_R1_, legacy WIF) appears in any commit

Scope notes

  • get_accounts_by_authorizers is already used elsewhere in the CLI — no new dependencies.
  • Scrypt is Node's built-in crypto.scryptSync — no new dependencies.
  • The conf library schema is extended with an optional revealPasswordHash field; existing configs without it remain valid.

paulgnz added 4 commits April 24, 2026 11:58
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.
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.
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.
@paulgnz paulgnz changed the title security: redact private keys from key:list and key:get by default security: redact private keys and add reveal-password gate for key:get / key:list Apr 24, 2026
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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens Proton CLI key-reveal workflows to reduce accidental/private-key exfiltration, especially when commands are run by AI agents or in non-interactive contexts.

Changes:

  • Redacts private keys from the default proton key:list output and adds --reveal-private gating.
  • Enforces TTY-only behavior for any private-key-printing path (key:get, key:list --reveal-private) and adds explicit user confirmation / optional reveal-password prompting.
  • Introduces reveal-password setup/disable commands and persists the (salted, scrypt) verifier in CLI config; updates README command docs.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/storage/revealPassword.ts Adds scrypt-based reveal-password hashing/verification + prompt gate.
src/storage/config.ts Extends config typing to include stored reveal-password verifier.
src/commands/key/reveal-setup.ts New command to set/change reveal password.
src/commands/key/reveal-disable.ts New command to remove reveal password (requires current).
src/commands/key/list.ts Changes default output to public keys + account resolution; adds --reveal-private gating + TTY enforcement.
src/commands/key/get.ts Adds TTY enforcement + confirmation/reveal-password gate before printing a private key.
README.md Documents new commands and updated key:get / key:list behavior/flags.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +22 to +26
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)
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

verifyRevealPassword feeds stored.N/r/p/keyLen from the on-disk config directly into crypto.scryptSync. If the config file is tampered/corrupted, those values can trigger extremely slow hashing or excessive memory usage (local DoS) or throw. Consider either (a) not storing parameters at all and always verifying with the fixed SCRYPT_PARAMS, or (b) strictly validating/clamping the stored params to safe bounds (and treating out-of-policy values as an invalid/disabled reveal password).

Suggested change
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)
try {
const salt = Buffer.from(stored.salt, 'hex')
const expected = Buffer.from(stored.hash, 'hex')
const { N, r, p, keyLen } = SCRYPT_PARAMS
const actual = scryptSync(password, salt, keyLen, { N, r, p, maxmem: SCRYPT_MAXMEM })
if (actual.length !== expected.length) return false
return timingSafeEqual(actual, expected)
} catch {
return false
}

Copilot uses AI. Check for mistakes.
Comment thread src/storage/revealPassword.ts Outdated
Comment on lines +54 to +55
if (!verifyRevealPassword(input, stored)) {
CliUx.ux.error('Reveal password is incorrect.')
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requireRevealPassword/verifyRevealPassword can throw (e.g., invalid hex in stored.salt/hash, invalid keyLen, or scrypt parameter errors). Since key:get/key:list don’t override catch(), this may surface as an unhandled exception/stack trace. Wrapping the verification path in a try/catch and emitting a clear remediation message (e.g. config corrupted; re-run proton key:reveal-setup or proton key:reveal-disable) would make the failure mode safer and more user-friendly.

Suggested change
if (!verifyRevealPassword(input, stored)) {
CliUx.ux.error('Reveal password is incorrect.')
try {
if (!verifyRevealPassword(input, stored)) {
CliUx.ux.error('Reveal password is incorrect.')
}
} catch {
CliUx.ux.error(
'Stored reveal password configuration is invalid or corrupted. Re-run `proton key:reveal-setup` to set it up again, or `proton key:reveal-disable` to remove it.'
)

Copilot uses AI. Check for mistakes.
Comment thread src/storage/revealPassword.ts Outdated
}

export function clearRevealPasswordHash(): void {
config.delete('revealPasswordHash' as any)
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clearRevealPasswordHash uses config.delete('revealPasswordHash' as any), which weakens type safety and can hide real key-name mistakes. Prefer keeping this typed (e.g. config.delete('revealPasswordHash')) and, if needed, adjust the Conf typing/wrapper so delete accepts the config keys without an any cast.

Suggested change
config.delete('revealPasswordHash' as any)
config.delete('revealPasswordHash')

Copilot uses AI. Check for mistakes.
Comment thread src/storage/config.ts
Comment on lines +40 to 58
export interface RevealPasswordHash {
salt: string;
hash: string;
N: number;
r: number;
p: number;
keyLen: number;
}

export const config = new Conf<{
privateKeys: string[];
isLocked: boolean;
tryKeychain: boolean;
networks: { chain: string; endpoints: string[] }[];
currentChain: string;
endpoints?: { chain: string; endpoints: string[] }[];
revealPasswordHash?: RevealPasswordHash;
}>({
schema,
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

revealPasswordHash is added to the Conf type, but there’s no corresponding entry in the schema object. Given this is security-sensitive state, consider defining a schema for it (object with required salt/hash/N/r/p/keyLen fields) so malformed config values are rejected early instead of causing runtime errors during password verification.

Copilot uses AI. Check for mistakes.
Comment thread src/commands/key/list.ts Outdated
import { network } from '../../storage/networks'
import { isRevealPasswordSet, requireRevealPassword } from '../../storage/revealPassword'

const CONFIRMATION_PHRASE = 'I UNDERSTAND'
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This phrase duplicates in get.ts. Can we share it with both commands? Like import it from another file.

…arden config

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.
@paulgnz
Copy link
Copy Markdown
Contributor Author

paulgnz commented Apr 29, 2026

Thanks for the review @andreyjamer (and Copilot) — all feedback addressed in dff3f38:

Dev review:

  1. --force restored on key:get and key:list. It now skips the typed confirmation and the TTY refusal, so scripts work again. Important: --force does not skip the reveal password if one is set — that remains the intentional human-only gate against AI agents. README updated to document this.

  2. Endpoints in src/constants.ts updated to use ones that implement get_accounts_by_authorizers:

    • 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

Copilot review comments:

  1. scrypt parameter validation added in revealPassword.ts. verifyRevealPassword now goes through validateStoredHash which rejects:

    • N outside [2^10, 2^20] or not a power of two
    • r outside [1, 32]
    • p outside [1, 16]
    • keyLen outside [16, 128]
    • salt/hash not matching ^[0-9a-fA-F]+$ or outside reasonable length bounds

    This closes the local-DoS vector from a tampered config setting extreme work factors.

  2. Error handling: requireRevealPassword now wraps verification in try/catch and emits a clear remediation message (`run `proton key:reveal-disable` then `proton key:reveal-setup` to reset it`) instead of surfacing a raw stack trace if the stored hash is malformed.

  3. Removed as any from clearRevealPasswordHash. Now uses typed config.delete('revealPasswordHash').

  4. JSON Schema entry for revealPasswordHash added to src/storage/config.ts, with required nested fields and pattern/range constraints, so Conf rejects malformed values at load time.

  5. Shared CONFIRMATION_PHRASE extracted into src/storage/confirmation.ts and imported by both key:get and key:list.

Re: future work (test suite, oclif upgrade) — happy to take that on as separate PRs once this lands. The hardening here is a foundation; tests around key handling specifically would be a great next step.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants