Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions cli/commands/wallet/import.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,20 @@ export default async function walletImport(args, flags) {
let wallet;
let origin;

// Key material is masked for the same reason the passphrase is: it is read
// in front of whoever can see the screen (and shared screens / recordings).
if (hasEvmKey) {
const key = await readSecret("Enter EVM private key (hex): ");
const key = await readSecret("Enter EVM private key (hex): ", { mask: true });
wallet = ows.importFromKey(name, key, passphrase, "evm");
origin = WALLET_ORIGIN.EVM_KEY;
} else if (hasSolKey) {
const key = await readSecret("Enter Solana private key (base58, hex, or byte array): ");
const key = await readSecret("Enter Solana private key (base58, hex, or byte array): ", {
mask: true,
});
wallet = ows.importFromKey(name, key, passphrase, "solana");
origin = WALLET_ORIGIN.SOL_KEY;
} else {
const mnemonic = await readSecret("Enter mnemonic phrase: ");
const mnemonic = await readSecret("Enter mnemonic phrase: ", { mask: true });
wallet = ows.importFromMnemonic(name, mnemonic, passphrase);
origin = WALLET_ORIGIN.MNEMONIC;
}
Expand Down
95 changes: 94 additions & 1 deletion cli/tests/unit/cli/utils/common/prompt.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { describe, it, before, after } from "node:test";
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { readPassphraseFromFile } from "#zerion/utils/common/prompt.js";
import { applyMaskedChunk, readPassphraseFromFile } from "#zerion/utils/common/prompt.js";

const isWindows = process.platform === "win32";

Expand Down Expand Up @@ -72,3 +72,96 @@ describe("readPassphraseFromFile", () => {
assert.throws(() => readPassphraseFromFile(path), /empty/i);
});
});

describe("applyMaskedChunk", () => {
const KEY = "0x0ee04d8466aa0f5a05d3ab5a3f9f1e1c2b3a49586d7c8b9a0f1e2d3c4b5a6978";

it("masks a typed character one-for-one", () => {
assert.deepEqual(applyMaskedChunk("", "a"), {
value: "a",
echo: "*",
done: false,
abort: false,
});
});

it("masks every character of a pasted key, not just the chunk", () => {
const step = applyMaskedChunk("", KEY);
assert.equal(step.value, KEY);
assert.equal(step.echo, "*".repeat(KEY.length));
assert.equal(step.done, false);
});

it("submits when a pasted key arrives with a trailing newline", () => {
const step = applyMaskedChunk("", `${KEY}\n`);
assert.equal(step.value, KEY);
assert.equal(step.done, true);
assert.equal(step.echo, "*".repeat(KEY.length));
});

it("submits on CRLF without keeping the CR", () => {
const step = applyMaskedChunk("0xab", "cd\r\n");
assert.equal(step.value, "0xabcd");
assert.equal(step.done, true);
});

it("drops anything after the terminator so it cannot leak into the next prompt", () => {
const step = applyMaskedChunk("", "secret\nleftover");
assert.equal(step.value, "secret");
assert.equal(step.echo, "*".repeat(6));
assert.equal(step.done, true);
});

it("submits on Ctrl-D", () => {
const step = applyMaskedChunk("abc", "\u0004");
assert.equal(step.value, "abc");
assert.equal(step.done, true);
});

it("aborts on Ctrl-C", () => {
const step = applyMaskedChunk("abc", "\u0003");
assert.equal(step.abort, true);
assert.equal(step.done, false);
});

it("erases the last character on backspace", () => {
const step = applyMaskedChunk("abc", "\u007F");
assert.equal(step.value, "ab");
assert.equal(step.echo, "\b \b");
});

it("ignores backspace on an empty buffer", () => {
assert.deepEqual(applyMaskedChunk("", "\u007F"), {
value: "",
echo: "",
done: false,
abort: false,
});
});

it("strips bracketed-paste markers", () => {
const step = applyMaskedChunk("", `\u001B[200~${KEY}\u001B[201~`);
assert.equal(step.value, KEY);
assert.equal(step.echo, "*".repeat(KEY.length));
});

it("ignores arrow keys instead of masking them as input", () => {
const step = applyMaskedChunk("ab", "\u001B[Ac");
assert.equal(step.value, "abc");
assert.equal(step.echo, "*");
});

it("ignores other control characters", () => {
const step = applyMaskedChunk("", "\u0001a\u0002");
assert.equal(step.value, "a");
assert.equal(step.echo, "*");
});

it("keeps spaces so a mnemonic survives", () => {
const words = "test test test test test test test test test test test junk";
const step = applyMaskedChunk("", `${words}\r`);
assert.equal(step.value, words);
assert.equal(step.done, true);
assert.equal(step.echo, "*".repeat(words.length));
});
});
105 changes: 84 additions & 21 deletions cli/utils/common/prompt.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,71 @@
import { createInterface } from "node:readline";
import { readFileSync, statSync } from "node:fs";

const ENTER = ["\n", "\r"];
const CTRL_C = "\u0003";
const CTRL_D = "\u0004";
const BACKSPACE = ["\u007F", "\b"];
const ESC = "\u001B";

/**
* Fold one raw-mode stdin chunk into the masked-input state machine.
*
* A chunk is not one keystroke: a paste arrives as a single multi-character
* chunk, and pasting is how private keys and mnemonics are actually entered.
* So every chunk is walked character by character — otherwise a pasted key
* echoes one `*` for the whole paste and a trailing newline lands *inside*
* the secret instead of submitting it.
*
* Returns the new buffer plus what to echo, and whether the caller should
* finish (Enter/Ctrl-D) or abort (Ctrl-C). Anything after the terminator in
* the same chunk is dropped rather than left to leak into the next prompt.
*/
export function applyMaskedChunk(current, chunk) {
let value = current;
let echo = "";

for (let i = 0; i < chunk.length; i++) {
const ch = chunk[i];

if (ENTER.includes(ch) || ch === CTRL_D) {
return { value, echo, done: true, abort: false };
}

if (ch === CTRL_C) {
return { value, echo, done: false, abort: true };
}

if (BACKSPACE.includes(ch)) {
if (value.length > 0) {
value = value.slice(0, -1);
echo += "\b \b";
}
continue;
}

if (ch === ESC) {
// Terminal escape sequence — arrow keys, bracketed-paste markers
// (ESC [ 200 ~ … ESC [ 201 ~). Swallow it so it never becomes part of
// the secret. CSI runs until a final byte in @–~; other sequences are
// rare enough that dropping the ESC alone is fine.
if (chunk[i + 1] === "[") {
let j = i + 2;
while (j < chunk.length && !/[@-~]/.test(chunk[j])) j++;
i = j;
}
continue;
}

// Remaining control characters carry no text — never echo a `*` for them.
if (ch < " ") continue;

value += ch;
echo += "*";
}

return { value, echo, done: false, abort: false };
}

export function readSecret(prompt, { mask = false } = {}) {
return new Promise((resolve) => {
process.stderr.write(prompt);
Expand All @@ -16,29 +81,27 @@ export function readSecret(prompt, { mask = false } = {}) {
process.stdin.resume();
process.stdin.setEncoding("utf8");

const onData = (ch) => {
if (ch === "\n" || ch === "\r" || ch === "\u0004") {
// Enter or Ctrl-D — done
process.stdin.setRawMode(false);
process.stdin.pause();
process.stdin.removeListener("data", onData);
process.stderr.write("\n");
resolve(input.trim());
} else if (ch === "\u0003") {
// Ctrl-C — abort
process.stdin.setRawMode(false);
process.stdin.pause();
const restore = () => {
process.stdin.setRawMode(false);
process.stdin.pause();
process.stdin.removeListener("data", onData);
};

const onData = (chunk) => {
const step = applyMaskedChunk(input, chunk);
input = step.value;
if (step.echo) process.stderr.write(step.echo);

if (step.abort) {
restore();
process.stderr.write("\n");
process.exit(130);
} else if (ch === "\u007F" || ch === "\b") {
// Backspace
if (input.length > 0) {
input = input.slice(0, -1);
process.stderr.write("\b \b");
}
} else {
input += ch;
process.stderr.write("*");
}

if (step.done) {
restore();
process.stderr.write("\n");
resolve(input.trim());
}
};

Expand Down
Loading