Skip to content

API-279: Guard wallet export against accidental plaintext key disclosure - #501

Open
gulshngill wants to merge 2 commits into
mainfrom
feat/api-279-guard-wallet-export
Open

API-279: Guard wallet export against accidental plaintext key disclosure#501
gulshngill wants to merge 2 commits into
mainfrom
feat/api-279-guard-wallet-export

Conversation

@gulshngill

Copy link
Copy Markdown
Contributor

Summary

nansen wallet export <name> previously printed raw EVM and Solana private keys to stdout with only a warning line. This PR gates plaintext disclosure behind explicit acknowledgement and adds a safer file-based export path.

  • Redacted default: wallet export <name> now prints addresses + [REDACTED] placeholders and never decrypts — no password needed, secrets never materialize in memory on the default path.
  • --reveal (explicit acknowledgement): prints plaintext keys to stdout, exactly as before. When stdout is an interactive terminal, a warning goes to stderr first (scrollback/screen-share/terminal-logging risk). Works fully non-interactively — NANSEN_WALLET_PASSWORD=... nansen wallet export <name> --reveal is the automation path.
  • --file <path> (safer path): writes the export JSON to an exclusively-created file with 0600 permissions. Refuses to overwrite (O_EXCL, which also defeats pre-planted symlinks), removes the file if a mid-write/close failure leaves partial key material, and keeps stdout free of secrets. --reveal + --file together, bare --file, and non-string --file values are rejected before any decryption.
  • Parser hardening: reveal joins the boolean-flag allowlist in parseArgs so no argument ordering can make it swallow the wallet name or bypass the --file conflict check.
  • Secrecy tests (17 new): key material never appears in the redacted default, ordinary error envelopes (wrong password, missing wallet, post-decryption write failures), DEBUG=1 stderr output, or telemetry payloads (which carry flag names and error codes only). Includes an ENOSPC mid-write test asserting the partial file is removed.
  • Docs: skills/nansen-wallet-manager documents the three modes; skills/nansen-wallet-keychain-migration verification steps now use --reveal with all output discarded, branching on the exit code (the old bare-export check would have become a silent false positive).

Breaking-ish: scripts that parsed wallet export output must add --reveal or switch to --file. Shipped as a minor changeset with a migration note. Wallet generation, storage format, and signing are untouched, per the ticket.

Explicit assumptions

  • "Explicit acknowledgement" = the --reveal flag rather than an interactive confirm — this repo forbids interactive prompts in core, and a flag serves humans and agents identically.
  • "File/clipboard" = file only; clipboard would require a new platform-specific dependency.

Validation

  • npm test: 54 files, 2048 passed (17 new), all mocked
  • npm run lint: clean
  • Independent Codex code review: APPROVED after 5 rounds (fixed: partial-write cleanup, unlink-only-what-we-created via exclusive open, close-failure handling, no double-close)
  • Security review of the diff: no findings (acknowledgement-bypass orderings, symlink/umask attacks on --file, error/cause leakage, telemetry/DEBUG surfaces all traced clean)

Linear: API-279

🤖 Generated with Claude Code

wallet export is now redacted by default (addresses only, no decryption).
Printing private keys requires explicit acknowledgement via --reveal, which
warns on stderr when stdout is an interactive terminal. New --file <path>
writes keys to an exclusively-created 0600 file (refusing to overwrite,
cleaning up partial writes) while keeping stdout clean. Tests prove key
material never reaches ordinary errors, DEBUG logs, or telemetry payloads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/wallet.js
// (key-free, but noisy) reaches the envelope.
let fd;
try {
fd = fs.openSync(options.file, 'wx', 0o600);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Potential file inclusion attack via reading file - medium severity
If an attacker can control the input leading into the ReadFile function, they might be able to read sensitive files and launch further attacks with that information.

Show fix

Remediation: Ignore this issue only after you've verified or sanitized the input going into this function. This issue is only relevant in the backend, not in the frontend!

Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

@nansen-pr-reviewer

nansen-pr-reviewer Bot commented Aug 19, 2026

Copy link
Copy Markdown

pr-reviewer Summary for #82a93e2

📝 2 findings

Review completed. Please address the findings below.

Findings by Severity

Severity Count
🟡 Medium 2

Review effort: 3/5 (Moderate)

Summary

This is a well-considered security improvement. The three-mode export design (redacted default / --reveal / --file) is solid, the O_EXCL + cleanup-on-failure lifecycle is correct, and the test suite thoroughly probes the secrecy surface. Two medium findings worth addressing before merge:

Findings (2 medium)

src/wallet.jswantsFile when flags.file is truthy but options.file is also accidentally a string

Severity: medium

The check on line 760 is:

const wantsFile = options.file !== undefined || flags.file;
if (wantsFile && typeof options.file !== 'string') {
  throw new CommandError('...', 'INVALID_INPUT');
}

When flags.file is set (bare --file) and options.file happens to be a string from a previous option-value slot (e.g. a confused arg ordering like --wallet alice --file where alice has already been consumed), wantsFile is true and options.file is a string, so the guard passes silently. The code then calls fs.openSync(options.file, ...) using whatever stale value was in options.file. This is an unlikely ordering in practice, but since parseArgs fills options from any --key value pair, stale values are plausible.

Suggested fix: add an explicit check that the source of the file value is consistent: if flags.file is set (bare boolean), always reject regardless of options.file's type:

if (flags.file) {
  throw new CommandError('--file requires a path: nansen wallet export <name> --file <path>', 'INVALID_INPUT');
}
const wantsFile = typeof options.file === 'string';

This makes the validation unambiguous. A test case covering { flags: { file: true }, options: { file: 'some-path' } } would pin the expected rejection.


.changeset/guard-wallet-export.md — semver bump is major but PR description says minor

Severity: medium

The changeset file correctly records major (this is a breaking change — existing scripts that parse wallet export output stop seeing key material). However, the PR description states "Shipped as a minor changeset with a migration note" — a contradiction that could confuse reviewers who read only the description before approving. The changeset file is what the release tooling reads, so the major bump will take effect correctly, but the PR description should be updated to remove the conflicting minor claim to avoid confusion in the CHANGELOG review step.

Suggested fix: Edit the PR description to say major (or remove the semver mention entirely, since the changeset already documents it).


Token usage: 2,274 input, 4,123 output, 337,939 cache read, 29,403 cache write | Usage Guide

New pushes are reviewed automatically with a 10-minute cooldown between reviews. To request a review at any time, comment @nansen-pr-reviewer re-review.

@gulshngill

Copy link
Copy Markdown
Contributor Author

@nansen-pr-reviewer re-review

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