diff --git a/bin/selat.mjs b/bin/selat.mjs index 6682b17..e45ca7c 100755 --- a/bin/selat.mjs +++ b/bin/selat.mjs @@ -25,7 +25,6 @@ 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 @@ -131,8 +130,7 @@ main(process.argv) // flush both streams and exit when the event loop drains. .then((code) => { process.exitCode = code ?? 0; }) .catch((err) => { - const safe = serializeError(err); - console.error(fmt.error(`fatal: ${safe.message}`)); - if (process.env.SELAT_DEBUG === "1" && safe.stack) console.error(safe.stack); + console.error(fmt.error(`fatal: ${err?.message ?? err}`)); + if (process.env.SELAT_DEBUG === "1" && err?.stack) console.error(err.stack); process.exitCode = 1; }); diff --git a/lib/arc-fund-signer.mjs b/lib/arc-fund-signer.mjs deleted file mode 100644 index 3226629..0000000 --- a/lib/arc-fund-signer.mjs +++ /dev/null @@ -1,68 +0,0 @@ -/** - * 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 fa41ad2..2f443c5 100644 --- a/lib/commands/fund.mjs +++ b/lib/commands/fund.mjs @@ -25,8 +25,6 @@ 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, @@ -99,7 +97,7 @@ function wantsFundHelp(args) { return args.some((a) => a === "-h" || a === "--help"); } -export async function fund(args, { interactive = stdinIsInteractive(), run = sh, stdout = process.stdout, stderr = process.stderr } = {}) { +export async function fund(args, { interactive = stdinIsInteractive() } = {}) { // 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. @@ -189,22 +187,17 @@ export async function fund(args, { interactive = stdinIsInteractive(), run = sh, // 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(redactText(res.error, arcSecrets))); + console.error(fmt.error(res.error)); 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; } - // 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]; + depositEnv = res.env; } // Resolve the payer wallet once: the spending-policy line, the empty-wallet @@ -361,28 +354,17 @@ export async function fund(args, { interactive = stdinIsInteractive(), run = sh, // 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); - // 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( + const depositCode = (await sh( "node", - 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; + [ + join(skill.path, "scripts", "setup.mjs"), + skillCommand, + "--chain", chainArg, + "--amount", String(amount), + "--confirm", phrase + ], + { inherit: true, ...(depositEnv ? { env: depositEnv } : {}) } + )).code; if (depositCode !== 0) return depositCode; if (!walletAddr) { @@ -822,36 +804,22 @@ 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. * - * 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`. + * 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`. */ -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 = String(env.SELAT_PRIVATE_KEY || config.SELAT_PRIVATE_KEY || "").trim(); - const rpcUrl = String(env.ARC_RPC_URL || config.ARC_RPC_URL || "").trim(); + const privateKey = env.SELAT_PRIVATE_KEY || config.SELAT_PRIVATE_KEY; + const rpcUrl = env.ARC_RPC_URL || config.ARC_RPC_URL; 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. @@ -861,33 +829,7 @@ export function resolveArcDepositEnv({ method, config = {}, env = process.env } error: `ARC_RPC_URL "${rpcUrl}" must be an https:// URL (http:// is allowed only for localhost).` }; } - 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)); + return { ok: true, env: { SELAT_PRIVATE_KEY: privateKey, ARC_RPC_URL: rpcUrl } }; } /** diff --git a/lib/redact.mjs b/lib/redact.mjs deleted file mode 100644 index a85b988..0000000 --- a/lib/redact.mjs +++ /dev/null @@ -1,171 +0,0 @@ -/** - * 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 deleted file mode 100644 index 16261c0..0000000 --- a/test/arc-fund-keyblind.test.mjs +++ /dev/null @@ -1,303 +0,0 @@ -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 47dc409..f2e5944 100644 --- a/test/fund-arc.test.mjs +++ b/test/fund-arc.test.mjs @@ -1,30 +1,25 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { resolveArcDepositEnv, arcDepositSpawnEnv } from "../lib/commands/fund.mjs"; -import { maskedFingerprint } from "../lib/arc-fund-signer.mjs"; +import { resolveArcDepositEnv } from "../lib/commands/fund.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 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. +// 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). const KEY = "0x" + "ab".repeat(32); const RPC = "https://example.arc-mainnet.invalid/token"; -test("resolves from shell env without putting the key on the wire type", () => { +test("resolves from shell env", () => { const res = resolveArcDepositEnv({ method: "direct", config: {}, 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); + assert.deepEqual(res, { ok: true, env: { SELAT_PRIVATE_KEY: KEY, ARC_RPC_URL: RPC } }); }); test("falls back to the selat config when shell env is unset", () => { @@ -34,21 +29,18 @@ test("falls back to the selat config when shell env is unset", () => { env: {}, }); assert.ok(res.ok); - assert.deepEqual(res.env, { ARC_RPC_URL: RPC }); - assert.equal(arcDepositSpawnEnv(res).SELAT_PRIVATE_KEY, KEY); - assert.equal(arcDepositSpawnEnv(res).ARC_RPC_URL, RPC); + assert.deepEqual(res.env, { SELAT_PRIVATE_KEY: KEY, ARC_RPC_URL: RPC }); }); test("shell env wins over the config .env", () => { const res = resolveArcDepositEnv({ method: "direct", - config: { SELAT_PRIVATE_KEY: "0x" + "cd".repeat(32), ARC_RPC_URL: "https://config.invalid" }, + config: { SELAT_PRIVATE_KEY: "0xconfig", 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", () => { @@ -79,19 +71,6 @@ 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 c9fecf8..92e5c76 100644 --- a/test/url-safety.test.mjs +++ b/test/url-safety.test.mjs @@ -64,18 +64,16 @@ test("parseSelatPayHint rejects a hint whose payment url is not https", () => { }); test("resolveArcDepositEnv rejects a plaintext ARC_RPC_URL", () => { - const env = { SELAT_PRIVATE_KEY: "0x" + "ab".repeat(32), ARC_RPC_URL: "http://rpc.evil.example" }; + const env = { SELAT_PRIVATE_KEY: "0xabc", 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 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" }; +test("resolveArcDepositEnv accepts an https ARC_RPC_URL", () => { + const env = { SELAT_PRIVATE_KEY: "0xabc", ARC_RPC_URL: "https://rpc.arc.example" }; const res = resolveArcDepositEnv({ method: "direct", env }); assert.equal(res.ok, true); - assert.deepEqual(res.env, { ARC_RPC_URL: "https://rpc.arc.example" }); - assert.equal(JSON.stringify(res).includes(key), false); + assert.deepEqual(res.env, { SELAT_PRIVATE_KEY: "0xabc", ARC_RPC_URL: "https://rpc.arc.example" }); });