diff --git a/bin/selat.mjs b/bin/selat.mjs index e45ca7c..6682b17 100755 --- a/bin/selat.mjs +++ b/bin/selat.mjs @@ -25,6 +25,7 @@ import { skill } from "../lib/commands/skill.mjs"; import { refund } from "../lib/commands/refund.mjs"; import { fmt } from "../lib/ui.mjs"; import { ensureHarnessPath } from "../lib/host.mjs"; +import { serializeError } from "../lib/redact.mjs"; const USAGE = `${fmt.bold("selat")} — agent payment setup helper @@ -130,7 +131,8 @@ main(process.argv) // flush both streams and exit when the event loop drains. .then((code) => { process.exitCode = code ?? 0; }) .catch((err) => { - console.error(fmt.error(`fatal: ${err?.message ?? err}`)); - if (process.env.SELAT_DEBUG === "1" && err?.stack) console.error(err.stack); + const safe = serializeError(err); + console.error(fmt.error(`fatal: ${safe.message}`)); + if (process.env.SELAT_DEBUG === "1" && safe.stack) console.error(safe.stack); process.exitCode = 1; }); diff --git a/lib/arc-fund-signer.mjs b/lib/arc-fund-signer.mjs new file mode 100644 index 0000000..3226629 --- /dev/null +++ b/lib/arc-fund-signer.mjs @@ -0,0 +1,68 @@ +/** + * Arc fund signer — in-process secret load, key-blind wire type. + * + * See-boundary (CTO+Security, SELAT-AI/selat-cli#189): this module MAY see + * the raw key in memory for the sign call. The return value is the ship + * contract: `{ signature, address }` or `{ signature, fingerprint }`. No + * `key` / `privateKey` / `mnemonic` field. The key must not be threaded + * into the router, quotes, claims, receipts, or CLI dumps. + */ + +import { createHash } from "node:crypto"; +import { serializeError, withoutKeyFields } from "./redact.mjs"; + +/** 0x-prefixed 32-byte secp256k1 private key. */ +export const ARC_PRIVATE_KEY_HEX = /^0x[0-9a-fA-F]{64}$/; + +/** + * Identity surface when an address is not yet derived: SHA-256 of the + * secret, first 6 + last 4 hex chars. Not reversible to the key, and not + * the key itself. + */ +export function maskedFingerprint(secret) { + const hex = createHash("sha256").update(String(secret ?? ""), "utf8").digest("hex"); + return `${hex.slice(0, 6)}…${hex.slice(-4)}`; +} + +/** + * The only wire shape this signer is allowed to return. Extra fields — + * especially `key` / `privateKey` / `mnemonic` — are dropped, not copied. + */ +export function toArcFundSignerWire(result = {}) { + const cleaned = withoutKeyFields(result ?? {}); + const out = {}; + if (cleaned.signature != null) out.signature = cleaned.signature; + if (cleaned.address != null) out.address = cleaned.address; + else if (cleaned.fingerprint != null) out.fingerprint = cleaned.fingerprint; + return out; +} + +/** + * Sign `digest` in-process. `privateKey` is an argument (secret-load path) + * and is never copied onto the returned object. `sign` is injectable so + * tests pin the wire shape without a live chain or viem. + * + * Returns `{ signature, address }` when `address` is provided, otherwise + * `{ signature, fingerprint }`. + */ +export async function signArcFund({ digest, privateKey, address, sign } = {}) { + const secrets = typeof privateKey === "string" && privateKey.length >= 8 ? [privateKey] : []; + if (typeof sign !== "function") { + throw new Error("Arc fund signer requires an in-process sign function"); + } + let signature; + try { + signature = await sign(digest, privateKey); + } catch (err) { + const safe = serializeError(err, { secrets }); + const wrapped = new Error(safe.message); + wrapped.name = safe.name; + if (safe.code != null) wrapped.code = safe.code; + throw wrapped; + } + return toArcFundSignerWire({ + signature, + address: address || undefined, + fingerprint: address ? undefined : maskedFingerprint(privateKey) + }); +} diff --git a/lib/commands/fund.mjs b/lib/commands/fund.mjs index fd4cb33..bfba013 100644 --- a/lib/commands/fund.mjs +++ b/lib/commands/fund.mjs @@ -25,6 +25,8 @@ import { sh } from "../sh.mjs"; import { fmt, prompt, promptYesNo, stdinIsInteractive } from "../ui.mjs"; import { safeHttpUrl } from "../url-safety.mjs"; import { findSkill, skillInstallLines } from "../skill.mjs"; +import { maskedFingerprint, ARC_PRIVATE_KEY_HEX } from "../arc-fund-signer.mjs"; +import { redactText } from "../redact.mjs"; import { getAgentAddress, authStatus, @@ -91,7 +93,7 @@ asks you to confirm before depositing. (--onramp is the exception: it only mints a browser link; the purchase happens in Circle's widget.) \`selat freeze\` is a local kill switch: this command refuses while frozen.`; -export async function fund(args, { interactive = stdinIsInteractive() } = {}) { +export async function fund(args, { interactive = stdinIsInteractive(), run = sh, stdout = process.stdout, stderr = process.stderr } = {}) { // Help is inert: no skill lookup, no prompts, no deposit flow. Previously // `fund --help` fell through and started prompting toward a real deposit — // the defect class #101 fixed for setup-policy, one command over. @@ -179,17 +181,22 @@ export async function fund(args, { interactive = stdinIsInteractive() } = {}) { // RPC. Resolve those here (shell env wins, then the selat config .env) and // pass them through to setup.mjs. let depositEnv; + let arcSecrets = []; if (isArc) { const res = resolveArcDepositEnv({ method: method.value, config: await readConfig() }); if (!res.ok) { - console.error(fmt.error(res.error)); + console.error(fmt.error(redactText(res.error, arcSecrets))); if (res.missing) { console.error(fmt.dim("Arc mainnet can't use the Circle agent wallet — deposits use a raw EOA key + your private Arc RPC.")); console.error(fmt.dim(`Set them in your shell or ${configPath()}.`)); } return 1; } - depositEnv = res.env; + // Child overlay carries the key for the in-process sign path in + // setup.mjs. Never dump this object — identity on the resolve result is + // the fingerprint. Arc fund key does not enter the router. + depositEnv = arcDepositSpawnEnv(res); + if (depositEnv?.SELAT_PRIVATE_KEY) arcSecrets = [depositEnv.SELAT_PRIVATE_KEY]; } // Resolve the payer wallet once: the spending-policy line, the empty-wallet @@ -346,17 +353,28 @@ export async function fund(args, { interactive = stdinIsInteractive() } = {}) { // deposit raises the balance, so unlike a payment it cannot be accounted for // conservatively — only a fresh read is safe. invalidateCircleCache(GATEWAY_BALANCE_KEY_PREFIX); - const depositCode = (await sh( + // Arc: capture + redact child streams so a skill that echoes the key + // cannot leak it onto CLI stdout/stderr. Other chains inherit so Circle + // signing prompts still reach the terminal. + const depositArgs = [ + join(skill.path, "scripts", "setup.mjs"), + skillCommand, + "--chain", chainArg, + "--amount", String(amount), + "--confirm", phrase + ]; + const depositResult = await run( "node", - [ - join(skill.path, "scripts", "setup.mjs"), - skillCommand, - "--chain", chainArg, - "--amount", String(amount), - "--confirm", phrase - ], - { inherit: true, ...(depositEnv ? { env: depositEnv } : {}) } - )).code; + depositArgs, + isArc + ? { inherit: false, env: depositEnv } + : { inherit: true, ...(depositEnv ? { env: depositEnv } : {}) } + ); + if (isArc) { + reprintRedacted(depositResult.stdout, stdout, arcSecrets); + reprintRedacted(depositResult.stderr, stderr, arcSecrets); + } + const depositCode = depositResult.code; if (depositCode !== 0) return depositCode; if (!walletAddr) { @@ -796,22 +814,36 @@ export function fundingDetailLines({ address, chainKey, shortfall, uri, chains = * raw EOA key + a private RPC: SELAT_PRIVATE_KEY and ARC_RPC_URL. Shell env * wins over the selat config .env. Eco (fast deposits) isn't supported on Arc. * - * Returns `{ ok: true, env }` or `{ ok: false, error, missing? }`. `missing` - * is only set when the failure is unset credentials (so the caller can print - * the how-to-fix hint); a rejected method has no `missing`. + * The returned object is a key-blind wire type: `{ ok, env: { ARC_RPC_URL }, + * fingerprint }`. The raw key is held in an in-process WeakMap and applied + * to the child env only via `arcDepositSpawnEnv` — JSON.stringify / env dumps + * of this result never include it. Identity is the masked fingerprint. + * + * Returns `{ ok: true, env, fingerprint }` or `{ ok: false, error, missing? }`. + * `missing` is only set when the failure is unset credentials (so the caller + * can print the how-to-fix hint); a rejected method has no `missing`. */ +const arcFundKeys = new WeakMap(); + export function resolveArcDepositEnv({ method, config = {}, env = process.env } = {}) { if (method === "eco") { return { ok: false, error: "--method eco is not supported on Arc; use the default (direct)." }; } - const privateKey = env.SELAT_PRIVATE_KEY || config.SELAT_PRIVATE_KEY; - const rpcUrl = env.ARC_RPC_URL || config.ARC_RPC_URL; + const privateKey = String(env.SELAT_PRIVATE_KEY || config.SELAT_PRIVATE_KEY || "").trim(); + const rpcUrl = String(env.ARC_RPC_URL || config.ARC_RPC_URL || "").trim(); const missing = []; if (!privateKey) missing.push("SELAT_PRIVATE_KEY"); if (!rpcUrl) missing.push("ARC_RPC_URL"); if (missing.length) { return { ok: false, error: `Arc deposits need ${missing.join(" and ")}.`, missing }; } + if (!ARC_PRIVATE_KEY_HEX.test(privateKey)) { + // Do not echo the value — a malformed key still must stay off logs. + return { + ok: false, + error: "SELAT_PRIVATE_KEY must be a 0x-prefixed 32-byte hex key." + }; + } // The raw key signs a real transfer against whatever this RPC says the chain // state is, so a plaintext or non-http(s) endpoint is refused: an attacker on // the path could feed the deposit a forged nonce/receipt or observe it. @@ -821,7 +853,33 @@ export function resolveArcDepositEnv({ method, config = {}, env = process.env } error: `ARC_RPC_URL "${rpcUrl}" must be an https:// URL (http:// is allowed only for localhost).` }; } - return { ok: true, env: { SELAT_PRIVATE_KEY: privateKey, ARC_RPC_URL: rpcUrl } }; + const result = { + ok: true, + env: { ARC_RPC_URL: rpcUrl }, + fingerprint: maskedFingerprint(privateKey) + }; + arcFundKeys.set(result, privateKey); + return result; +} + +/** + * Child-process env overlay for an Arc deposit. In-process only — the key + * is here so setup.mjs can sign, and must not be JSON.stringified, logged, + * or forwarded to the router. Returns null when `resolved` is not an ok + * `resolveArcDepositEnv` result. + */ +export function arcDepositSpawnEnv(resolved) { + if (!resolved?.ok) return null; + const privateKey = arcFundKeys.get(resolved); + const rpcUrl = resolved.env?.ARC_RPC_URL; + if (!privateKey || !rpcUrl) return null; + return { SELAT_PRIVATE_KEY: privateKey, ARC_RPC_URL: rpcUrl }; +} + +/** Reprint a captured child stream with known secrets stripped. */ +export function reprintRedacted(text, stream = process.stdout, secrets = []) { + if (!text) return; + stream.write(redactText(text, secrets)); } /** diff --git a/lib/redact.mjs b/lib/redact.mjs new file mode 100644 index 0000000..a85b988 --- /dev/null +++ b/lib/redact.mjs @@ -0,0 +1,171 @@ +/** + * Key-blind serialization for product surfaces. + * + * The Arc fund raw key (and any mnemonic) may exist in-process for a sign + * call. It must never appear in CLI stdout/stderr, dumped env/argv, serialized + * errors, copy-debug bundles, or quote/claim/receipt wire types. + * + * Identity on those surfaces is an address or a masked fingerprint — never + * hex/base64 key material. Tx hashes are also 0x + 64 hex, so this module + * redacts *known* secret values and *named* secret fields rather than every + * 32-byte hex string. + */ + +export const REDACTED = "[redacted]"; + +/** Env keys whose values are never dumped. */ +export const SECRET_ENV_KEY_RE = + /^(SELAT_PRIVATE_KEY|.*_PRIVATE_KEY|PRIVATE_KEY|MNEMONIC|.*MNEMONIC.*|SEED_PHRASE|SECRET_KEY)$/i; + +/** Object fields that must not appear on signer / quote / claim / dump wire types. */ +export const SECRET_FIELD_NAMES = new Set([ + "key", + "privateKey", + "private_key", + "rawKey", + "raw_key", + "mnemonic", + "seed", + "seedPhrase", + "seed_phrase", + "SELAT_PRIVATE_KEY" +]); + +const SECRET_ARGV_FLAGS = new Set([ + "--raw-key", + "--private-key", + "--key", + "--mnemonic", + "--seed" +]); + +export function isSecretEnvKey(name) { + return SECRET_ENV_KEY_RE.test(String(name ?? "")); +} + +export function isSecretFieldName(name) { + return SECRET_FIELD_NAMES.has(String(name ?? "")); +} + +/** + * Replace known secret values (the in-process key/mnemonic) in free text. + * Also strips a 0x-less copy so a hex dump of the same bytes is covered. + */ +export function redactKnownSecrets(text, secrets = []) { + let s = String(text ?? ""); + for (const secret of secrets) { + if (typeof secret !== "string" || secret.length < 8) continue; + s = s.split(secret).join(REDACTED); + if (/^0x/i.test(secret) && secret.length > 10) { + s = s.split(secret.slice(2)).join(REDACTED); + } + } + return s; +} + +/** + * Redact named secret assignments in free text / JSON without needing the + * live value: `SELAT_PRIVATE_KEY=…`, `"privateKey":"…"`. A generic `"key"` + * JSON field is only redacted when its value looks like a 32-byte hex key + * or a 12/24-word mnemonic — ordinary `"key":"q"` query fields stay. + */ +export function redactNamedSecrets(text) { + let s = String(text ?? ""); + s = s.replace( + /\b(SELAT_PRIVATE_KEY|PRIVATE_KEY|[A-Z0-9_]*PRIVATE_KEY|[A-Z0-9_]*MNEMONIC)\s*[:=]\s*\S+/gi, + `$1=${REDACTED}` + ); + s = s.replace( + /"(privateKey|private_key|rawKey|raw_key|mnemonic|seed|seedPhrase|seed_phrase|SELAT_PRIVATE_KEY)"\s*:\s*"[^"]*"/gi, + `"$1":"${REDACTED}"` + ); + s = s.replace( + /"(key)"\s*:\s*"(0x[0-9a-fA-F]{64}|[a-z]+(?: [a-z]+){11,23})"/g, + `"$1":"${REDACTED}"` + ); + return s; +} + +export function redactText(text, secrets = []) { + return redactKnownSecrets(redactNamedSecrets(text), secrets); +} + +/** Drop secret-named fields from a JSON-like value before it hits a wire. */ +export function withoutKeyFields(value, { depth = 0 } = {}) { + if (value == null || typeof value !== "object" || depth > 12) return value; + if (Array.isArray(value)) return value.map((v) => withoutKeyFields(v, { depth: depth + 1 })); + const out = {}; + for (const [k, v] of Object.entries(value)) { + if (isSecretFieldName(k)) continue; + out[k] = withoutKeyFields(v, { depth: depth + 1 }); + } + return out; +} + +export function redactEnvDump(env = {}, secrets = []) { + const out = {}; + for (const [k, v] of Object.entries(env ?? {})) { + if (isSecretEnvKey(k)) { + out[k] = v == null || v === "" ? v : REDACTED; + continue; + } + out[k] = typeof v === "string" ? redactText(v, secrets) : v; + } + return out; +} + +export function redactArgvDump(argv = [], secrets = []) { + const out = []; + for (let i = 0; i < argv.length; i++) { + const prev = i > 0 ? String(argv[i - 1]) : ""; + if (SECRET_ARGV_FLAGS.has(prev) || /(?:^|-)(raw-key|private-key|mnemonic)$/i.test(prev)) { + out.push(REDACTED); + continue; + } + out.push(redactText(String(argv[i] ?? ""), secrets)); + } + return out; +} + +/** + * Serialize an error for logs / --json / SELAT_DEBUG. Redacts before the + * object is built so JSON.stringify cannot echo a key. Upstream 4xx/5xx + * bodies passed as `err.body` are redacted the same way. + */ +export function serializeError(err, { secrets = [] } = {}) { + if (err == null) return { name: "Error", message: "unknown error" }; + const rawMessage = err?.message ?? String(err); + const body = err?.body != null + ? (typeof err.body === "string" ? err.body : jsonStringifyRedacted(err.body, secrets)) + : null; + const message = redactText(rawMessage, secrets); + const stack = typeof err?.stack === "string" ? redactText(err.stack, secrets) : undefined; + const out = { + name: err?.name ?? "Error", + message + }; + if (err?.code != null) out.code = err.code; + if (err?.status != null) out.status = err.status; + if (body != null) out.body = redactText(body, secrets); + if (stack) out.stack = stack; + return out; +} + +export function jsonStringifyRedacted(value, secrets = []) { + const cleaned = withoutKeyFields(value); + return redactText(JSON.stringify(cleaned), secrets); +} + +/** + * Key-blind snapshot of env / argv / error for copy-debug bundles. + * Never include the raw key: secret env keys are placeholders, argv values + * after --raw-key/--private-key are placeholders, errors are serializeError(). + */ +export function copyDebugBundle({ env, argv, error, extra, secrets = [] } = {}) { + return { + argv: redactArgvDump(argv ?? [], secrets), + env: redactEnvDump(env ?? {}, secrets), + error: error != null ? serializeError(error, { secrets }) : null, + ...(extra && typeof extra === "object" ? withoutKeyFields(extra) : {}) + }; +} diff --git a/test/arc-fund-keyblind.test.mjs b/test/arc-fund-keyblind.test.mjs new file mode 100644 index 0000000..16261c0 --- /dev/null +++ b/test/arc-fund-keyblind.test.mjs @@ -0,0 +1,303 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + maskedFingerprint, + signArcFund, + toArcFundSignerWire, +} from "../lib/arc-fund-signer.mjs"; +import { + REDACTED, + copyDebugBundle, + jsonStringifyRedacted, + redactArgvDump, + redactEnvDump, + redactText, + serializeError, + withoutKeyFields, +} from "../lib/redact.mjs"; +import { + arcDepositSpawnEnv, + fund, + reprintRedacted, + resolveArcDepositEnv, +} from "../lib/commands/fund.mjs"; +import { refundPayArgv } from "../lib/commands/refund.mjs"; + +// SELAT-AI/selat-cli#189 — Arc fund raw-key cleanup. P0 = any raw key or +// mnemonic on these surfaces: signer wire, CLI stdout/stderr, serialized +// errors, env/argv dumps, copy-debug bundles, quote/claim objects. + +const KEY = "0x" + "ab".repeat(32); +const MNEMONIC = "legal winner thank year wave sausage worth useful legal winner thank yellow"; +const RPC = "https://example.arc-mainnet.invalid/token"; +const ADDRESS = "0x" + "11".repeat(20); +const SIGNATURE = "0x" + "cd".repeat(65); + +function assertNoSecret(haystack, secret, label = "surface") { + assert.equal( + String(haystack).includes(secret), + false, + `${label} must not contain the raw secret` + ); +} + +function quiet(t) { + const log = console.log; + const error = console.error; + console.log = () => {}; + console.error = () => {}; + t.after(() => { + console.log = log; + console.error = error; + }); +} + +function memoryStream() { + const chunks = []; + return { + write(chunk) { + chunks.push(String(chunk)); + return true; + }, + toString() { + return chunks.join(""); + } + }; +} + +function withArcFundEnv(t) { + const dir = mkdtempSync(join(tmpdir(), "selat-arc-keyblind-")); + mkdirSync(join(dir, "scripts")); + writeFileSync(join(dir, "scripts", "setup.mjs"), "process.exit(0);\n"); + const freeze = join(dir, "no-freeze.json"); + const xdg = mkdtempSync(join(tmpdir(), "selat-arc-cfg-")); + const prev = { + SELAT_SKILL_PATH: process.env.SELAT_SKILL_PATH, + SELAT_PAY_FREEZE_PATH: process.env.SELAT_PAY_FREEZE_PATH, + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + SELAT_PRIVATE_KEY: process.env.SELAT_PRIVATE_KEY, + ARC_RPC_URL: process.env.ARC_RPC_URL, + SELAT_AGENT_WALLET_ADDRESS: process.env.SELAT_AGENT_WALLET_ADDRESS, + CIRCLE_BIN: process.env.CIRCLE_BIN, + }; + process.env.SELAT_SKILL_PATH = dir; + process.env.SELAT_PAY_FREEZE_PATH = freeze; + process.env.XDG_CONFIG_HOME = xdg; + process.env.SELAT_PRIVATE_KEY = KEY; + process.env.ARC_RPC_URL = RPC; + delete process.env.SELAT_AGENT_WALLET_ADDRESS; + const stub = join(dir, "circle-stub"); + writeFileSync(stub, "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + process.env.CIRCLE_BIN = stub; + t.after(() => { + for (const [k, v] of Object.entries(prev)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); +} + +test("signer wire is signature + address only — no key field", async () => { + const wire = await signArcFund({ + digest: "0x" + "11".repeat(32), + privateKey: KEY, + address: ADDRESS, + sign: async () => SIGNATURE, + }); + assert.deepEqual(wire, { signature: SIGNATURE, address: ADDRESS }); + assert.equal("key" in wire, false); + assert.equal("privateKey" in wire, false); + assert.equal("mnemonic" in wire, false); + assertNoSecret(JSON.stringify(wire), KEY, "signer wire"); +}); + +test("signer wire falls back to a masked fingerprint, never the hex key", async () => { + const wire = await signArcFund({ + digest: "0x" + "11".repeat(32), + privateKey: KEY, + sign: async () => SIGNATURE, + }); + assert.equal(wire.signature, SIGNATURE); + assert.equal(wire.fingerprint, maskedFingerprint(KEY)); + assert.equal(wire.address, undefined); + assert.notEqual(wire.fingerprint, KEY); + assert.doesNotMatch(wire.fingerprint, /0x[0-9a-fA-F]{64}/); + assertNoSecret(JSON.stringify(wire), KEY, "fingerprint signer wire"); +}); + +test("toArcFundSignerWire drops an invented key field", () => { + const wire = toArcFundSignerWire({ + signature: SIGNATURE, + address: ADDRESS, + key: KEY, + privateKey: KEY, + mnemonic: MNEMONIC, + }); + assert.deepEqual(wire, { signature: SIGNATURE, address: ADDRESS }); + assertNoSecret(JSON.stringify(wire), KEY, "sanitized signer wire"); + assertNoSecret(JSON.stringify(wire), MNEMONIC, "sanitized signer wire"); +}); + +test("thrown signer errors redact the key before they serialize", async () => { + await assert.rejects( + () => signArcFund({ + digest: "0x11", + privateKey: KEY, + sign: async () => { + throw new Error(`sign failed for ${KEY}`); + }, + }), + (err) => { + const serialized = JSON.stringify(serializeError(err, { secrets: [KEY] })); + assertNoSecret(err.message, KEY, "thrown signer error"); + assertNoSecret(serialized, KEY, "serialized signer error"); + assert.match(serialized, new RegExp(REDACTED)); + return true; + } + ); +}); + +test("upstream 4xx/5xx bodies redact named key fields before serialize", () => { + const err = Object.assign(new Error("Gateway 500"), { + status: 500, + body: JSON.stringify({ error: "bad key", privateKey: KEY, key: KEY }), + }); + const serialized = JSON.stringify(serializeError(err, { secrets: [KEY] })); + assertNoSecret(serialized, KEY, "upstream 5xx serialize"); + assert.match(serialized, /500/); + assert.match(serialized, new RegExp(REDACTED)); +}); + +test("env dump and argv dump and copy-debug bundle never echo the raw key", () => { + const env = { + SELAT_PRIVATE_KEY: KEY, + ARC_RPC_URL: RPC, + PATH: "/usr/bin", + MNEMONIC: MNEMONIC, + }; + const argv = ["node", "setup.mjs", "deposit", "--raw-key", KEY, "--chain", "arc"]; + const dumpedEnv = redactEnvDump(env, [KEY, MNEMONIC]); + const dumpedArgv = redactArgvDump(argv, [KEY]); + const bundle = copyDebugBundle({ + env, + argv, + error: new Error(`fatal: ${KEY}`), + extra: { quoteId: "selatx123", key: KEY }, + secrets: [KEY, MNEMONIC], + }); + const blob = JSON.stringify({ dumpedEnv, dumpedArgv, bundle }); + assert.equal(dumpedEnv.SELAT_PRIVATE_KEY, REDACTED); + assert.equal(dumpedEnv.MNEMONIC, REDACTED); + assert.equal(dumpedEnv.ARC_RPC_URL, RPC); + assert.equal(dumpedArgv[dumpedArgv.indexOf("--raw-key") + 1], REDACTED); + assert.equal("key" in bundle, false); + assertNoSecret(blob, KEY, "copy-debug bundle"); + assertNoSecret(blob, MNEMONIC, "copy-debug bundle"); +}); + +test("quote/claim client objects do not invent or keep a key field", () => { + const quote = withoutKeyFields({ + quoteId: "selatx123", + price: { amount: "0.01", currency: "USDC" }, + payTo: ADDRESS, + key: KEY, + privateKey: KEY, + }); + const claim = withoutKeyFields({ + action: "claim", + quoteId: "selatx123", + mnemonic: MNEMONIC, + }); + assert.equal("key" in quote, false); + assert.equal("privateKey" in quote, false); + assert.equal(quote.quoteId, "selatx123"); + assert.equal("mnemonic" in claim, false); + assertNoSecret(jsonStringifyRedacted(quote, [KEY]), KEY, "quote wire"); + assertNoSecret(JSON.stringify(claim), MNEMONIC, "claim wire"); + const payArgv = refundPayArgv({ action: "claim", quoteId: "selatx123", rest: ["--chain", "base"] }); + assert.ok(!payArgv.includes("--raw-key")); + assert.ok(!payArgv.includes(KEY)); + assert.ok(!payArgv.includes("key")); +}); + +test("resolveArcDepositEnv JSON and fingerprints stay key-blind", () => { + const res = resolveArcDepositEnv({ + method: "direct", + env: { SELAT_PRIVATE_KEY: KEY, ARC_RPC_URL: RPC }, + }); + const wire = JSON.stringify(res); + assertNoSecret(wire, KEY, "resolveArcDepositEnv JSON"); + assert.equal(JSON.parse(wire).env.SELAT_PRIVATE_KEY, undefined); + assert.equal(res.fingerprint, maskedFingerprint(KEY)); + // In-process overlay still has the key so setup.mjs can sign — this object + // is not a wire type and must not be serialized by the CLI. + const overlay = arcDepositSpawnEnv(res); + assert.equal(overlay.SELAT_PRIVATE_KEY, KEY); + assertNoSecret(JSON.stringify(redactEnvDump(overlay, [KEY])), KEY, "redacted overlay dump"); +}); + +test("reprintRedacted strips a known key from child stdout/stderr", () => { + const chunks = []; + const stream = { write: (c) => chunks.push(String(c)) }; + reprintRedacted(`ok ${KEY} deposited\n`, stream, [KEY]); + const text = chunks.join(""); + assertNoSecret(text, KEY, "reprintRedacted stdout"); + assert.match(text, /ok \[redacted\] deposited/); +}); + +test("Arc fund reprints child streams key-free (verbose/debug + happy path)", async (t) => { + quiet(t); + withArcFundEnv(t); + const stdout = memoryStream(); + const stderr = memoryStream(); + let spawnOpts; + const code = await fund(["--chain", "arc", "--amount", "0.25", "--yes"], { + interactive: false, + stdout, + stderr, + run: async (_cmd, args, opts) => { + spawnOpts = { args, opts }; + return { + code: 0, + stdout: JSON.stringify({ ok: true, tx: "0x" + "ee".repeat(32), key: KEY, signature: SIGNATURE }) + "\n", + stderr: `SELAT_DEBUG key=${KEY}\n`, + }; + }, + }); + assert.equal(code, 0); + const printed = stdout.toString() + stderr.toString(); + assertNoSecret(printed, KEY, "Arc fund stdout/stderr"); + assert.ok(!spawnOpts.args.includes(KEY), "raw key must not appear on deposit argv"); + assert.equal(spawnOpts.opts.inherit, false, "Arc captures streams so they can be redacted"); + assert.equal(spawnOpts.opts.env.SELAT_PRIVATE_KEY, KEY, "in-process child overlay still signs"); + assert.match(printed, /\[redacted\]/); +}); + +test("Arc fund 5xx child output is redacted before it hits stderr", async (t) => { + quiet(t); + withArcFundEnv(t); + const stdout = memoryStream(); + const stderr = memoryStream(); + const code = await fund(["--chain", "arc", "--amount", "0.25", "--yes"], { + interactive: false, + stdout, + stderr, + run: async () => ({ + code: 1, + stdout: "", + stderr: `Fatal: privateKey=${KEY} upstream 502\n`, + }), + }); + assert.equal(code, 1); + assertNoSecret(stdout.toString() + stderr.toString(), KEY, "Arc fund 5xx stderr"); +}); + +test("redactText catches a mnemonic echoed in an error string", () => { + const text = redactText(`backup phrase: ${MNEMONIC}`, [MNEMONIC]); + assertNoSecret(text, MNEMONIC, "mnemonic error string"); + assert.match(text, new RegExp(REDACTED)); +}); diff --git a/test/fund-arc.test.mjs b/test/fund-arc.test.mjs index f2e5944..47dc409 100644 --- a/test/fund-arc.test.mjs +++ b/test/fund-arc.test.mjs @@ -1,25 +1,30 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { resolveArcDepositEnv } from "../lib/commands/fund.mjs"; +import { resolveArcDepositEnv, arcDepositSpawnEnv } from "../lib/commands/fund.mjs"; +import { maskedFingerprint } from "../lib/arc-fund-signer.mjs"; // Arc mainnet can't use the Circle agent wallet, so `selat fund --chain arc` -// deposits with a raw EOA key + a private RPC. These pin that the env those -// deposits sign with (SELAT_PRIVATE_KEY / ARC_RPC_URL) is resolved with shell -// env winning over the selat config .env, that eco is rejected, and that a -// missing credential fails loudly rather than silently falling back to the -// agent wallet. See the Arc raw-key deposit path (agent-payments). +// deposits with a raw EOA key + a private RPC. These pin that credentials +// resolve with shell env winning over the selat config .env, that eco is +// rejected, and that a missing credential fails loudly rather than silently +// falling back to the agent wallet. The resolve result is a key-blind wire +// type (SELAT-AI/selat-cli#189): identity is a fingerprint, not the raw key. const KEY = "0x" + "ab".repeat(32); const RPC = "https://example.arc-mainnet.invalid/token"; -test("resolves from shell env", () => { +test("resolves from shell env without putting the key on the wire type", () => { const res = resolveArcDepositEnv({ method: "direct", config: {}, env: { SELAT_PRIVATE_KEY: KEY, ARC_RPC_URL: RPC }, }); - assert.deepEqual(res, { ok: true, env: { SELAT_PRIVATE_KEY: KEY, ARC_RPC_URL: RPC } }); + assert.equal(res.ok, true); + assert.deepEqual(res.env, { ARC_RPC_URL: RPC }); + assert.equal(res.fingerprint, maskedFingerprint(KEY)); + assert.equal(res.env.SELAT_PRIVATE_KEY, undefined); + assert.equal(JSON.stringify(res).includes(KEY), false); }); test("falls back to the selat config when shell env is unset", () => { @@ -29,18 +34,21 @@ test("falls back to the selat config when shell env is unset", () => { env: {}, }); assert.ok(res.ok); - assert.deepEqual(res.env, { SELAT_PRIVATE_KEY: KEY, ARC_RPC_URL: RPC }); + assert.deepEqual(res.env, { ARC_RPC_URL: RPC }); + assert.equal(arcDepositSpawnEnv(res).SELAT_PRIVATE_KEY, KEY); + assert.equal(arcDepositSpawnEnv(res).ARC_RPC_URL, RPC); }); test("shell env wins over the config .env", () => { const res = resolveArcDepositEnv({ method: "direct", - config: { SELAT_PRIVATE_KEY: "0xconfig", ARC_RPC_URL: "https://config.invalid" }, + config: { SELAT_PRIVATE_KEY: "0x" + "cd".repeat(32), ARC_RPC_URL: "https://config.invalid" }, env: { SELAT_PRIVATE_KEY: KEY, ARC_RPC_URL: RPC }, }); assert.ok(res.ok); - assert.equal(res.env.SELAT_PRIVATE_KEY, KEY); assert.equal(res.env.ARC_RPC_URL, RPC); + assert.equal(arcDepositSpawnEnv(res).SELAT_PRIVATE_KEY, KEY); + assert.equal(arcDepositSpawnEnv(res).ARC_RPC_URL, RPC); }); test("rejects eco (gasless) on Arc before checking credentials", () => { @@ -71,6 +79,19 @@ test("names just the one missing credential", () => { assert.deepEqual(res.missing, ["ARC_RPC_URL"]); }); +test("refuses a malformed key without echoing it", () => { + const bad = "0xnot-a-key"; + const res = resolveArcDepositEnv({ + method: "direct", + config: {}, + env: { SELAT_PRIVATE_KEY: bad, ARC_RPC_URL: RPC }, + }); + assert.equal(res.ok, false); + assert.match(res.error, /0x-prefixed 32-byte hex/); + assert.equal(res.error.includes(bad), false); + assert.equal(JSON.stringify(res).includes(bad), false); +}); + test("a valueless --method is an error, not a silent direct deposit", async () => { // `selat fund --amount 5 --yes --method` (value forgotten) used to default // to "direct" — a gas-requiring deposit — with --yes skipping the one diff --git a/test/url-safety.test.mjs b/test/url-safety.test.mjs index 92e5c76..c9fecf8 100644 --- a/test/url-safety.test.mjs +++ b/test/url-safety.test.mjs @@ -64,16 +64,18 @@ test("parseSelatPayHint rejects a hint whose payment url is not https", () => { }); test("resolveArcDepositEnv rejects a plaintext ARC_RPC_URL", () => { - const env = { SELAT_PRIVATE_KEY: "0xabc", ARC_RPC_URL: "http://rpc.evil.example" }; + const env = { SELAT_PRIVATE_KEY: "0x" + "ab".repeat(32), ARC_RPC_URL: "http://rpc.evil.example" }; const res = resolveArcDepositEnv({ method: "direct", env }); assert.equal(res.ok, false); assert.match(res.error, /must be an https:\/\/ URL/); assert.equal(res.missing, undefined); }); -test("resolveArcDepositEnv accepts an https ARC_RPC_URL", () => { - const env = { SELAT_PRIVATE_KEY: "0xabc", ARC_RPC_URL: "https://rpc.arc.example" }; +test("resolveArcDepositEnv accepts an https ARC_RPC_URL without putting the key on the wire type", () => { + const key = "0x" + "ab".repeat(32); + const env = { SELAT_PRIVATE_KEY: key, ARC_RPC_URL: "https://rpc.arc.example" }; const res = resolveArcDepositEnv({ method: "direct", env }); assert.equal(res.ok, true); - assert.deepEqual(res.env, { SELAT_PRIVATE_KEY: "0xabc", ARC_RPC_URL: "https://rpc.arc.example" }); + assert.deepEqual(res.env, { ARC_RPC_URL: "https://rpc.arc.example" }); + assert.equal(JSON.stringify(res).includes(key), false); });