Skip to content

fix(cli): mask private key and mnemonic input on wallet import (WLT-2075) - #112

Open
zerts wants to merge 1 commit into
mainfrom
cli-use-password-style-input-for-private-keys-wlt-2075
Open

fix(cli): mask private key and mnemonic input on wallet import (WLT-2075)#112
zerts wants to merge 1 commit into
mainfrom
cli-use-password-style-input-for-private-keys-wlt-2075

Conversation

@zerts

@zerts zerts commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

zerion wallet import masked the passphrase but echoed the secret it actually imports — the EVM/Solana private key or mnemonic was printed in cleartext on screen (shoulder-surfing, shared screens, recordings, scrollback).

Masking the three prompts alone wasn't enough. readSecret's raw-mode branch treated each stdin data event as one keystroke — true for a hand-typed passphrase, false for key material, which is pasted. A paste arrives as a single multi-character chunk, so the old reader echoed one * for the whole key and folded the paste's trailing newline into the secret instead of submitting it (the import would then fail with an invalid key).

  • applyMaskedChunk(current, chunk) — new exported state machine that walks a raw-mode chunk character by character: one * per character, backspace / Ctrl-C (exit 130) / Ctrl-D handled mid-chunk, bracketed-paste (ESC[200~/ESC[201~) and arrow-key CSI sequences swallowed rather than masked as input, other control characters ignored, and anything after the terminator dropped so it can't leak into the next prompt.
  • wallet import now passes { mask: true } for --evm-key, --sol-key and --mnemonic. A mnemonic is included deliberately — it's strictly more sensitive than a single key.
  • Confirmation prompts (Type DELETE to confirm, Type YES to confirm) stay unmasked; mask remains opt-in per call site.
  • Non-TTY / piped input is unchanged (no echo to hide).

Decisions and rejected alternatives are captured in the PRD comment on the Linear issue.

Closes WLT-2075

Test plan

  • npm test — 404 pass / 0 fail, including 13 new applyMaskedChunk cases (typed char, pasted key, paste with trailing \n, CRLF, post-terminator drop, Ctrl-D, Ctrl-C, backspace, backspace on empty buffer, bracketed paste, arrow keys, other control chars, spaces in a mnemonic).
  • Verified end-to-end under a real pty (script -q /dev/null): a pasted 66-char key echoes exactly 66 asterisks and resolves to the full key; a pasted 12-word mnemonic resolves intact; typed input with backspace erases correctly (0xab + BS + c0xac).
  • Manual check to run before merge: zerion wallet import --name tmp --evm-key in a real terminal — the key line should show only asterisks.

🤖 Generated with Claude Code

…075)

`wallet import` masked the passphrase but echoed the secret it actually
imports, so an EVM/Solana private key or mnemonic was printed in cleartext
for anyone looking at the screen (or a recording, or the scrollback).

Masking the three key prompts alone was not enough: readSecret's raw-mode
branch treated every stdin `data` event as one keystroke, which holds for a
typed passphrase but not for pasted key material — a paste arrives as one
multi-character chunk, so it echoed a single `*` and folded the paste's
trailing newline into the secret instead of submitting it. The masked branch
now walks each chunk character by character via applyMaskedChunk: one `*`
per character, backspace/Ctrl-C/Ctrl-D handled mid-chunk, bracketed-paste
and arrow-key escape sequences swallowed, and anything after the terminator
dropped so it cannot leak into the next prompt.

Confirmation prompts (`Type DELETE`, `Type YES`) stay unmasked — `mask` is
still opt-in per call site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Aug 12, 2026

Copy link
Copy Markdown

WLT-2075

@zerts

zerts commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

✅ Loop review — iteration 1/5

Verdict: approved

Severity Area Finding
minor code SS3 arrows and chunk-split CSI sequences leak into the secret buffer (cli/utils/common/prompt.js:50)

Summary

Reviewed PR #112 at head 7aa8dbe (fix(cli): mask private key and mnemonic input on wallet import (WLT-2075)), a 3-file diff: the masked-input state machine in cli/utils/common/prompt.js, the three readSecret call sites in cli/commands/wallet/import.js, and 13 new unit tests. The ticket's single requirement — Enter EVM private key (hex): must not print the key in cleartext — is met, and so is the PRD's broader scope (all three key-material prompts masked, confirmation prompts left unmasked). The interesting part of the change is not the { mask: true } flags but the reader rewrite: the old raw-mode branch treated every stdin data event as one keystroke, which is wrong for pasted key material. I verified under a real pty that the new per-character walk fixes both symptoms — a pasted 66-char key now echoes 66 asterisks (was 1) and the paste's trailing newline submits instead of being folded into the secret. Correctness was checked by round-trip, not just by looking at the mask: every import resolved to the expected derived address. npm test is 404/404 green. One minor finding, explicitly accepted as a tradeoff in the code comment and non-blocking.

Findings

[minor/code] SS3 arrows and chunk-split CSI sequences leak into the secret buffer

cli/utils/common/prompt.js:50 only swallows escape sequences of the form ESC [ … final-byte. Two gaps remain, both acknowledged in the comment ("other sequences are rare enough that dropping the ESC alone is fine"):

  1. SS3 (application cursor keys): ESC O A drops the ESC but masks O and A as input. Verified live (t8): typing 34 chars of a key, pressing Up in SS3 mode, then the remaining 32 chars produced 68 asterisks and Failed to import wallet: invalid input: invalid hex private key: Invalid character 'O' at position 33.
  2. CSI split across stdin chunks: if a chunk ends mid-sequence, i = j runs off the end and the sequence's tail is masked as input in the next chunk.

Why this doesn't block: the CLI never enables application-cursor-keys mode, so terminals send the CSI form (ESC [ A), which is handled correctly — verified live (t9, two arrow presses mid-key still produced exactly 66 asterisks and a successful import). The failure mode is loud, not silent: the user sees extra asterisks and the import is rejected with a message naming the offending character, so no corrupted key can be silently stored. And it is strictly better than the pre-diff code, which appended the raw ESC itself into the secret. Fixing it would mean also consuming ESC O <char> / ESC <char> and carrying a partial-sequence flag across chunks — reasonable follow-up, not worth a loop iteration.

Verification log

  • Tests: npm ci clean; npm test → 404 pass / 0 fail / 0 skipped (matches the PR body). The new applyMaskedChunk suite runs and passes in isolation (21/21 in prompt.test.mjs). No lint/format script and no Playwright suite in this repo. No console.log/TODO/debugger added by the diff; worktree clean at head.
  • Live: CLI, not a web app — driven under a real pty via a Python pty.openpty() driver (drive.py, exitcode.py) with HOME pointed at throwaway dirs so nothing touched the real ~/.zerion. Keys used are the public Hardhat/test…junk test vectors. Flows driven, each transcript captured:
    • wallet import --evm-key, key pasted with trailing \n in one chunk → 66 asterisks for a 66-char key, no cleartext, resolves to 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (correct address ⇒ newline not folded in, nothing truncated).
    • wallet import --mnemonic, 12 words pasted → 59 asterisks, spaces preserved, correct EVM + Solana addresses.
    • wallet import --sol-key, 88-char base58 key (exported from the mnemonic wallet, then re-imported) → 88 asterisks, solAddress matches the exported address exactly.
    • Mid-chunk backspace: …ffZZ + 2×DEL + 80 in a single chunk → \b \b echoed twice, import resolves to the correct address.
    • Ctrl-C at the key prompt → exit code 130, nothing persisted.
    • Bracketed-paste-wrapped key (ESC[200~…ESC[201~) → 66 asterisks, correct address.
    • CSI arrow keys mid-entry → swallowed, 66 asterisks, successful import.
    • SS3 arrow mid-entry → the minor finding above.
    • Cross-chunk accumulation: key split into 3 writes (22 + 43 chars + \n) → 66 chars, correct head/tail.
    • Sequential prompts: a value entered at prompt N+1 is read intact after prompt N completed (no leftover bleed).
    • Regressions in adjacent callers of the shared reader: passphrase mismatch → retry loop still works, both prompts masked; wallet deleteType DELETE to confirm: DELETE still echoes in cleartext as the PRD requires, proving canonical mode is restored after the masked read; readSecret(mask:true) with bare Enter returns "", so interactive-auth's "press Enter to skip" API-key path is unaffected.
    • Non-TTY: wallet import piped still fails at Passphrase must be entered in an interactive terminal. — pre-existing, untouched by the diff (the non-mask fallback branch is unchanged).
  • Console/network: No stack traces, no unhandled rejections. One pre-existing warning surfaces during EVM import — warning: '--chain evm' is deprecated; use '--chain ethereum' … — emitted from the unchanged ows.importFromKey(name, key, passphrase, "evm") call; not introduced or worsened by this PR.
  • Animation: no animation code in diff.
  • PR feedback: GraphQL query returned zero review threads (resolved or otherwise); the only PR comment is the linear-code linkback. No human-requested changes to confirm.
  • Scratch dir: /tmp/loop-review-4Csvgb (transcripts t1t10, sol2.txt, drivers drive.py / exitcode.py / probe*.mjs).

Posted by ai-runner.

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.

1 participant