@inversealtruism/cairn-cli 0.3.2 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -9
- package/dist/cli.js +575 -80
- package/dist/lib/api.js +84 -0
- package/dist/lib/cairnx.js +110 -0
- package/dist/lib/config.js +15 -5
- package/dist/lib/csd.js +134 -7
- package/dist/lib/ui.js +11 -6
- package/package.json +4 -4
package/dist/lib/api.js
CHANGED
|
@@ -64,11 +64,73 @@ export async function pickInput(addr, minValue) {
|
|
|
64
64
|
const x = cand[0] ?? (j.utxos ?? []).find(ok);
|
|
65
65
|
return x ? { input: `${x.txid}:${Number(x.vout)}:${Number(x.value)}`, value: Number(x.value) } : null;
|
|
66
66
|
}
|
|
67
|
+
// UTXO-VALUE-1 cross-source check. cairn-cli has no codec to recompute an input's REAL value, so
|
|
68
|
+
// a hostile/MITM'd CAIRN_API that UNDER-reports a UTXO's value would make `csd` compute too-small
|
|
69
|
+
// a change and silently burn the difference as fee. If the user has configured an INDEPENDENT node
|
|
70
|
+
// (CAIRN_RPC, a different host than the proxy), confirm the picked outpoint's value against its
|
|
71
|
+
// authoritative UTXO set and refuse on a mismatch. Returns:
|
|
72
|
+
// { checked:false } — no independent RPC configured / unreachable (caller warns + relies on the fee cap)
|
|
73
|
+
// { checked:true, ok:true, value } — independent node agrees (display the verified value)
|
|
74
|
+
// { checked:true, ok:false, value } — DISAGREEMENT (hostile proxy) → caller refuses
|
|
75
|
+
// { checked:true, ok:false, missing:true } — outpoint absent on the independent node → suspicious, caller refuses
|
|
76
|
+
export async function verifyInputValue(addr, txid, vout, claimedValue) {
|
|
77
|
+
if (!CAIRN_RPC)
|
|
78
|
+
return { checked: false };
|
|
79
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(addr) || !HEX64.test(txid))
|
|
80
|
+
return { checked: false };
|
|
81
|
+
let j;
|
|
82
|
+
try {
|
|
83
|
+
const r = await fetch(`${CAIRN_RPC}/utxos/${encodeURIComponent(addr)}`, { redirect: "error", signal: AbortSignal.timeout(6000) });
|
|
84
|
+
if (!r.ok)
|
|
85
|
+
return { checked: false };
|
|
86
|
+
j = await r.json();
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return { checked: false };
|
|
90
|
+
}
|
|
91
|
+
const list = j.utxos ?? j.outputs ?? [];
|
|
92
|
+
const norm = (t) => String(t).toLowerCase().replace(/^0x/, "");
|
|
93
|
+
const hit = list.find((u) => norm(u.txid) === norm(txid) && Number(u.vout) === vout);
|
|
94
|
+
if (!hit)
|
|
95
|
+
return { checked: true, ok: false, missing: true };
|
|
96
|
+
const v = Number(hit.value);
|
|
97
|
+
return { checked: true, ok: Number.isSafeInteger(v) && v === claimedValue, value: v };
|
|
98
|
+
}
|
|
67
99
|
export async function confirmedBalance(addr) {
|
|
68
100
|
const j = await req(`/api/rpc/utxos-all/${encodeURIComponent(addr)}`);
|
|
69
101
|
return { balance: Number(j.confirmed_balance ?? 0), utxos: (j.utxos ?? []).length };
|
|
70
102
|
}
|
|
71
103
|
export async function tipHeight() { return Number((await req("/api/rpc/tip")).height ?? 0); }
|
|
104
|
+
// Chain-view freshness of the proxy's backend node: { stale, secondsSinceAdvance, staleSecsThreshold, height }.
|
|
105
|
+
// Used to refuse building a tx against a frozen/forked tip. Throws if the surface is unreachable
|
|
106
|
+
// (caller treats that as a soft warning, never a hard block — matches the 'cannot reach' UX).
|
|
107
|
+
export async function rpcStatus() {
|
|
108
|
+
return req("/api/rpc/status");
|
|
109
|
+
}
|
|
110
|
+
// Look up a tx by id on the node. Mined txs resolve to { ok:true, txid, block_hash, height };
|
|
111
|
+
// an unknown id resolves to { ok:false, err:"not found" }. (The node's /tx indexes MINED txs;
|
|
112
|
+
// a mempool-only tx returns not-found, so a not-found is "no proof yet", never "rejected".)
|
|
113
|
+
export async function txStatus(txid) {
|
|
114
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(txid))
|
|
115
|
+
return { ok: false }; // never splice an unshaped id into the URL
|
|
116
|
+
const j = await req(`/api/rpc/tx/${encodeURIComponent(txid)}`).catch(() => null);
|
|
117
|
+
return j ?? { ok: false };
|
|
118
|
+
}
|
|
119
|
+
// Has the node confirmed OUR exact txid (mined into the chain)? Polls a few times because a
|
|
120
|
+
// freshly-submitted tx is mempool-only until a block lands. Returns true ONLY on an exact
|
|
121
|
+
// txid match the node reports as known — evidence the user's own tx (not a different conflict)
|
|
122
|
+
// is on-chain. (Non-fatal on an unreachable node: simply never confirms.) The poll budget is
|
|
123
|
+
// overridable via CAIRN_CONFIRM_ATTEMPTS / CAIRN_CONFIRM_INTERVAL_MS (used by the test suite).
|
|
124
|
+
export async function confirmTxMined(txid, attempts = Number(process.env.CAIRN_CONFIRM_ATTEMPTS) || 4, intervalMs = Number(process.env.CAIRN_CONFIRM_INTERVAL_MS) || 7000) {
|
|
125
|
+
for (let i = 0; i < attempts; i++) {
|
|
126
|
+
const s = await txStatus(txid).catch(() => null);
|
|
127
|
+
if (s?.ok && typeof s.txid === "string" && s.txid.toLowerCase() === txid.toLowerCase())
|
|
128
|
+
return true;
|
|
129
|
+
if (i < attempts - 1)
|
|
130
|
+
await new Promise((res) => setTimeout(res, intervalMs));
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
72
134
|
// Submit a node-JSON tx through the proxy (for `csd spend`, which builds+signs but doesn't
|
|
73
135
|
// reliably submit to a proxy URL). Returns the node's response.
|
|
74
136
|
export async function submitTx(txNodeJson) { return req("/api/rpc/tx/submit", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ tx: txNodeJson }) }); }
|
|
@@ -99,6 +161,28 @@ export async function registerRawContent(bytes, txid, attempts = 20) {
|
|
|
99
161
|
}
|
|
100
162
|
return false;
|
|
101
163
|
}
|
|
164
|
+
// Independent confirmation (audit H-7): is this txid mined according to CAIRN_RPC, a node on a
|
|
165
|
+
// DIFFERENT host than the proxy? `confirmTxMined` above polls the proxy's own /api/rpc/tx, which a
|
|
166
|
+
// hostile/MITM'd CAIRN_API can string-echo to forge a "mined" reply. This cross-checks an
|
|
167
|
+
// independent node. Returns true (independent node confirms OUR exact txid mined), false (it does
|
|
168
|
+
// not), or null (no independent RPC configured / unreachable → "unverifiable", never a hard claim).
|
|
169
|
+
export async function chainTxMined(txid) {
|
|
170
|
+
if (!CAIRN_RPC)
|
|
171
|
+
return null;
|
|
172
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(txid))
|
|
173
|
+
return null;
|
|
174
|
+
try {
|
|
175
|
+
const r = await fetch(`${CAIRN_RPC}/tx/${encodeURIComponent(txid)}`, { redirect: "error", signal: AbortSignal.timeout(6000) });
|
|
176
|
+
if (!r.ok)
|
|
177
|
+
return null;
|
|
178
|
+
const j = await r.json();
|
|
179
|
+
const t = String(j.txid ?? j.tx?.txid ?? "").toLowerCase().replace(/^0x/, "");
|
|
180
|
+
return j.ok === true && t === txid.toLowerCase().replace(/^0x/, "");
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
102
186
|
// optional: query a raw csd node RPC (for trustless verify)
|
|
103
187
|
export async function chainProposal(id) {
|
|
104
188
|
if (!CAIRN_RPC)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// CairnX — the token + .csd-name layer that lives entirely in CSD Propose records on the
|
|
2
|
+
// `cairnx:v1` domain. This file is the CLI's complete CairnX surface:
|
|
3
|
+
// • a READ client for the CairnX state API (resolution order: $CAIRNX_API → the local
|
|
4
|
+
// service → the public gateway, GET-only) with automatic fallback on network failure
|
|
5
|
+
// • the canonical TRANSFER record builder — canonicalised with the SAME @inversealtruism/csd-codec
|
|
6
|
+
// `canonicalJson` the resolver uses (single source of truth), so the CLI's on-chain payload_hash
|
|
7
|
+
// is byte-identical to the resolver's by construction (CLI-C7: was a hand-rolled stableStringify,
|
|
8
|
+
// proven byte-identical to canonicalJson for the transfer-record shape, now consolidated to remove
|
|
9
|
+
// the latent dual-canonicaliser seam).
|
|
10
|
+
// • exact human↔base-unit amount math as STRING/BigInt arithmetic — floats never touch
|
|
11
|
+
// token amounts (no "1.1 * 1e8 = 110000000.00000001" class of bug, no silent truncation).
|
|
12
|
+
import { sha256Hex } from "./item.js";
|
|
13
|
+
import { canonicalJson } from "@inversealtruism/csd-codec";
|
|
14
|
+
export const CAIRNX_DOMAIN = "cairnx:v1";
|
|
15
|
+
export const CAIRNX_ANCHOR_FEE = 25_000_000; // 0.25 CSD — the consensus min Propose fee that anchors a record
|
|
16
|
+
export const MAX_AMOUNT = (1n << 96n) - 1n; // CONVENTION.md: token amounts are ≤ 96-bit
|
|
17
|
+
const MAX_RECORD_BYTES = 512; // consensus MAX_URI_BYTES — the record must fit in `uri`
|
|
18
|
+
// Validation shapes (mirrors CONVENTION.md §4 — kept in sync by the byte-exact fixtures).
|
|
19
|
+
export const TICKER_RE = /^[A-Z][A-Z0-9]{2,11}$/;
|
|
20
|
+
export const NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/; // a claimable .csd name
|
|
21
|
+
const ADDR_RE = /^0x[0-9a-f]{40}$/; // records carry LOWERCASE addresses
|
|
22
|
+
export function buildTransferRecord(p) {
|
|
23
|
+
const to = String(p.to).toLowerCase();
|
|
24
|
+
if (!TICKER_RE.test(p.ticker))
|
|
25
|
+
throw new Error(`bad ticker "${p.ticker}" — want 3–12 chars [A-Z0-9], starting with a letter`);
|
|
26
|
+
if (!ADDR_RE.test(to))
|
|
27
|
+
throw new Error(`bad recipient "${p.to}" — want a 0x… 20-byte address`);
|
|
28
|
+
if (p.amount <= 0n)
|
|
29
|
+
throw new Error("amount must be > 0");
|
|
30
|
+
if (p.amount > MAX_AMOUNT)
|
|
31
|
+
throw new Error("amount exceeds the 96-bit token-amount limit");
|
|
32
|
+
const record = { amount: p.amount.toString(), t: "transfer", ticker: p.ticker, to, v: 1 };
|
|
33
|
+
const uri = canonicalJson(record);
|
|
34
|
+
if (Buffer.byteLength(uri, "utf8") > MAX_RECORD_BYTES)
|
|
35
|
+
throw new Error("record exceeds 512 bytes"); // unreachable for a transfer, kept as a guard
|
|
36
|
+
return { record, uri, payloadHash: sha256Hex(uri) };
|
|
37
|
+
}
|
|
38
|
+
// ── exact amount math (strings + BigInt only) ─────────────────────────────────────────────
|
|
39
|
+
// "1.5" with decimals 8 → 150000000n. Fails LOUDLY instead of truncating: "1.5" on a
|
|
40
|
+
// 0-decimals token is an error, not 1. Trailing fractional zeros are exact and accepted
|
|
41
|
+
// ("1.50" @ 1 decimal → 15n).
|
|
42
|
+
export function humanToBase(human, decimals) {
|
|
43
|
+
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 8)
|
|
44
|
+
throw new Error(`bad token decimals ${decimals}`);
|
|
45
|
+
const s = String(human).trim();
|
|
46
|
+
if (s.length === 0 || s.length > 40)
|
|
47
|
+
throw new Error(`bad amount "${human}"`); // MAX_AMOUNT is 29 digits; cap before BigInt
|
|
48
|
+
const m = /^([0-9]+)?(?:\.([0-9]+))?$/.exec(s);
|
|
49
|
+
if (!m || (m[1] === undefined && m[2] === undefined))
|
|
50
|
+
throw new Error(`bad amount "${human}" — use plain digits like 1 or 1.25`);
|
|
51
|
+
const frac = (m[2] ?? "").replace(/0+$/, ""); // trailing zeros are exactly representable — drop them
|
|
52
|
+
if (frac.length > decimals)
|
|
53
|
+
throw new Error(`amount "${human}" has more decimal places than this token allows (${decimals})`);
|
|
54
|
+
const base = BigInt(m[1] ?? "0") * 10n ** BigInt(decimals) + BigInt(frac.padEnd(decimals, "0") || "0");
|
|
55
|
+
if (base > MAX_AMOUNT)
|
|
56
|
+
throw new Error(`amount "${human}" exceeds the 96-bit token-amount limit`);
|
|
57
|
+
return base;
|
|
58
|
+
}
|
|
59
|
+
// 150000000n @ 8 → "1.5" (exact inverse of humanToBase; no grouping — display adds that).
|
|
60
|
+
export function baseToHuman(base, decimals) {
|
|
61
|
+
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 8)
|
|
62
|
+
throw new Error(`bad token decimals ${decimals}`);
|
|
63
|
+
const v = typeof base === "bigint" ? base : BigInt(String(base));
|
|
64
|
+
if (v < 0n)
|
|
65
|
+
throw new Error("negative token amount");
|
|
66
|
+
const s = v.toString().padStart(decimals + 1, "0");
|
|
67
|
+
const int = decimals ? s.slice(0, -decimals) : s;
|
|
68
|
+
const fr = decimals ? s.slice(-decimals).replace(/0+$/, "") : "";
|
|
69
|
+
return fr ? `${int}.${fr}` : int;
|
|
70
|
+
}
|
|
71
|
+
// ── read API client (GET-only; never sees a key) ──────────────────────────────────────────
|
|
72
|
+
// Base resolution order: $CAIRNX_API (explicit choice — used exclusively), else the local
|
|
73
|
+
// CairnX service, else the public gateway. Read at call time (not import) so tests can vary it.
|
|
74
|
+
export function defaultBases() {
|
|
75
|
+
const env = (process.env.CAIRNX_API ?? "").trim().replace(/\/+$/, "");
|
|
76
|
+
if (env)
|
|
77
|
+
return [env];
|
|
78
|
+
return ["http://127.0.0.1:8794/cairnx", "https://cairn-substrate.com/trade/api/cairnx"];
|
|
79
|
+
}
|
|
80
|
+
// Remember the first base that answered (per base-list) so one command's N requests don't
|
|
81
|
+
// re-probe a dead localhost N times.
|
|
82
|
+
let active = null;
|
|
83
|
+
export const activeCairnxBase = () => active?.base ?? null;
|
|
84
|
+
export async function cairnxGet(path, bases = defaultBases()) {
|
|
85
|
+
const key = bases.join(" ");
|
|
86
|
+
const order = active?.key === key && bases.includes(active.base)
|
|
87
|
+
? [active.base, ...bases.filter((b) => b !== active.base)] : bases;
|
|
88
|
+
let lastErr = null;
|
|
89
|
+
for (const base of order) {
|
|
90
|
+
let res;
|
|
91
|
+
try {
|
|
92
|
+
res = await fetch(`${base}${path}`, { signal: AbortSignal.timeout(8000) }); // GET-only, no credentials — redirects are safe to follow
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
lastErr = e;
|
|
96
|
+
continue;
|
|
97
|
+
} // network failure → try the next base
|
|
98
|
+
// A reachable base's answer is authoritative — an HTTP 404 here is a real "not found",
|
|
99
|
+
// never a reason to fall through to a different (possibly divergent) view of state.
|
|
100
|
+
active = { key, base };
|
|
101
|
+
if (!res.ok) {
|
|
102
|
+
const j = await res.json().catch(() => null);
|
|
103
|
+
const e = new Error(j?.error ? String(j.error) : `CairnX API ${path} → HTTP ${res.status}`);
|
|
104
|
+
e.status = res.status;
|
|
105
|
+
throw e;
|
|
106
|
+
}
|
|
107
|
+
return res.json();
|
|
108
|
+
}
|
|
109
|
+
throw new Error(`cannot reach a CairnX API (tried ${bases.join(", ")}) — set CAIRNX_API (${lastErr?.message ?? lastErr})`);
|
|
110
|
+
}
|
package/dist/lib/config.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// cairn-cli configuration (env-overridable).
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join, dirname } from "node:path";
|
|
4
|
-
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
|
|
5
5
|
export const CAIRN_API = (process.env.CAIRN_API ?? "https://cairn-substrate.com").replace(/\/+$/, "");
|
|
6
6
|
export const CAIRN_TOKEN = process.env.CAIRN_TOKEN ?? ""; // optional operator write token (falls back to local csd wallet)
|
|
7
7
|
export const CAIRN_RPC = process.env.CAIRN_RPC ?? ""; // optional: a csd node RPC, enables trustless verify
|
|
@@ -13,7 +13,9 @@ export const MIN_FEE_ATTEST = 5_000_000; // 0.05 CSD
|
|
|
13
13
|
export function csdToCoins(base) {
|
|
14
14
|
return (base / CSD_PER_COIN).toLocaleString(undefined, { maximumFractionDigits: 4 });
|
|
15
15
|
}
|
|
16
|
-
// small local config: caches ONLY the user's public address (never a key).
|
|
16
|
+
// small local config: caches ONLY the user's public address (never a key). Written with
|
|
17
|
+
// owner-only perms (dir 0700, file 0600) so another local user can't poison the cached address
|
|
18
|
+
// to redirect `cairn address` output (F13/R18 — the wallet is still the source of truth).
|
|
17
19
|
const CFG_PATH = process.env.CAIRN_CLI_CONFIG ?? join(homedir(), ".config", "cairn-cli", "config.json");
|
|
18
20
|
export function loadLocalConfig() { try {
|
|
19
21
|
return JSON.parse(readFileSync(CFG_PATH, "utf8"));
|
|
@@ -21,10 +23,18 @@ export function loadLocalConfig() { try {
|
|
|
21
23
|
catch {
|
|
22
24
|
return {};
|
|
23
25
|
} }
|
|
26
|
+
// Returns true iff the address was durably persisted. CLI-C2: a SILENT failure here breaks the
|
|
27
|
+
// H-2 "derive the key-on-argv address at most once" guarantee — a read-only HOME / unwritable
|
|
28
|
+
// CAIRN_CLI_CONFIG / full disk would make every subsequent call re-derive (re-exposing the key on
|
|
29
|
+
// the csd argv). Callers surface a warning on false so the user can fix the cache (or stop deriving).
|
|
24
30
|
export function saveLocalConfig(patch) {
|
|
25
31
|
try {
|
|
26
|
-
mkdirSync(dirname(CFG_PATH), { recursive: true });
|
|
27
|
-
writeFileSync(CFG_PATH, JSON.stringify({ ...loadLocalConfig(), ...patch }, null, 2) + "\n");
|
|
32
|
+
mkdirSync(dirname(CFG_PATH), { recursive: true, mode: 0o700 });
|
|
33
|
+
writeFileSync(CFG_PATH, JSON.stringify({ ...loadLocalConfig(), ...patch }, null, 2) + "\n", { mode: 0o600 });
|
|
34
|
+
chmodSync(CFG_PATH, 0o600); // tighten even if the file pre-existed (writeFileSync mode is create-only)
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
28
39
|
}
|
|
29
|
-
catch { /* best-effort */ }
|
|
30
40
|
}
|
package/dist/lib/csd.js
CHANGED
|
@@ -1,19 +1,140 @@
|
|
|
1
1
|
// Thin wrapper around the user's INSTALLED `csd` CLI. cairn-cli never holds a private
|
|
2
2
|
// key: for any write it shells out to `csd`, which signs with the user's own csd wallet
|
|
3
3
|
// config key (CSD_SIG_V1). We only orchestrate — supply the input (fetched from the Cairn
|
|
4
|
-
// proxy, so no local node is required) + the Cairn payload — and `csd` does the signing
|
|
5
|
-
//
|
|
4
|
+
// proxy, so no local node is required) + the Cairn payload — and `csd` does the signing.
|
|
5
|
+
//
|
|
6
|
+
// SECURITY (audit H-1): which `csd` binary signs is a trust decision. A bare `csd` resolved
|
|
7
|
+
// by $PATH order lets a malicious binary planted earlier on PATH (dev env, npm postinstall,
|
|
8
|
+
// shared host) capture the wallet key the instant cairn shells out. So:
|
|
9
|
+
// • CAIRN_CSD, if set, is the user's EXPLICIT choice — honored, but MUST be absolute.
|
|
10
|
+
// • Otherwise we resolve `csd` from a list of CANONICAL absolute locations FIRST (not PATH
|
|
11
|
+
// order), fall back to a PATH search only if none exists, REFUSE a binary in a world-
|
|
12
|
+
// writable / transient / cwd location, and SURFACE the resolved absolute path so the user
|
|
13
|
+
// can see which binary signs.
|
|
6
14
|
import { execFile } from "node:child_process";
|
|
7
15
|
import { promisify } from "node:util";
|
|
16
|
+
import { statSync, realpathSync } from "node:fs";
|
|
17
|
+
import { isAbsolute, join, dirname, delimiter } from "node:path";
|
|
8
18
|
const pexec = promisify(execFile);
|
|
19
|
+
// Back-compat hint (the configured name/path); real resolution is resolveCsdBin().
|
|
9
20
|
export const CSD_BIN = process.env.CAIRN_CSD || "csd";
|
|
21
|
+
// Canonical absolute install locations, checked in order BEFORE any $PATH search so a binary
|
|
22
|
+
// planted earlier on PATH cannot win.
|
|
23
|
+
const HOME = process.env.HOME || "";
|
|
24
|
+
const CANONICAL = [
|
|
25
|
+
"/usr/local/bin/csd", "/usr/bin/csd", "/opt/substrate_miner/bin/csd",
|
|
26
|
+
...(HOME ? [join(HOME, ".cargo/bin/csd"), join(HOME, ".local/bin/csd")] : []),
|
|
27
|
+
];
|
|
28
|
+
// Is this resolved path attacker-plantable? Returns a human reason, or null if it looks safe.
|
|
29
|
+
function insecureReason(abs) {
|
|
30
|
+
let st;
|
|
31
|
+
try {
|
|
32
|
+
st = statSync(abs);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return "does not exist";
|
|
36
|
+
}
|
|
37
|
+
if (!st.isFile())
|
|
38
|
+
return "is not a regular file";
|
|
39
|
+
if (st.mode & 0o002)
|
|
40
|
+
return "is world-writable";
|
|
41
|
+
const dir = dirname(abs);
|
|
42
|
+
try {
|
|
43
|
+
const dst = statSync(dir);
|
|
44
|
+
// CLI-C2-STICKY: a trusted signing binary must never live in a world-writable directory —
|
|
45
|
+
// the sticky bit stops OTHER users deleting your file, but the dir's owner (the attacker, if
|
|
46
|
+
// they own the planted binary) is unaffected, so do NOT exempt sticky 1777 dirs from the check.
|
|
47
|
+
if (dst.mode & 0o002)
|
|
48
|
+
return `is in a world-writable directory (${dir})`;
|
|
49
|
+
}
|
|
50
|
+
catch { /* dir unreadable — fall through */ }
|
|
51
|
+
if (dir === process.cwd())
|
|
52
|
+
return "is in the current working directory (a cwd hijack vector)";
|
|
53
|
+
if (dir.startsWith("/tmp") || dir.startsWith("/var/tmp") || dir.startsWith("/dev/shm"))
|
|
54
|
+
return `is in a transient directory (${dir})`;
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
function pathSearch(name) {
|
|
58
|
+
for (const d of (process.env.PATH || "").split(delimiter).filter(Boolean)) {
|
|
59
|
+
const p = join(d, name);
|
|
60
|
+
try {
|
|
61
|
+
const st = statSync(p);
|
|
62
|
+
if (st.isFile() && (st.mode & 0o111))
|
|
63
|
+
return p;
|
|
64
|
+
}
|
|
65
|
+
catch { /* keep looking */ }
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
let _resolved = null;
|
|
70
|
+
// Resolve (and cache) the trusted `csd` path. See header for the policy.
|
|
71
|
+
export function resolveCsdBin() {
|
|
72
|
+
if (_resolved)
|
|
73
|
+
return _resolved;
|
|
74
|
+
const env = process.env.CAIRN_CSD;
|
|
75
|
+
if (env) {
|
|
76
|
+
// Explicit user choice. Require absolute (a relative explicit path is still PATH/cwd-hijackable);
|
|
77
|
+
// honor it even in an unusual location (the user told us exactly which binary to trust), but warn
|
|
78
|
+
// if world-writable. We do NOT refuse here — the test harness and power users point CAIRN_CSD at
|
|
79
|
+
// bespoke absolute paths deliberately.
|
|
80
|
+
if (!isAbsolute(env))
|
|
81
|
+
return (_resolved = { path: null, explicit: true, error: `CAIRN_CSD must be an ABSOLUTE path (got "${env}") — a relative csd binary is a key-theft risk` });
|
|
82
|
+
let abs = env;
|
|
83
|
+
try {
|
|
84
|
+
abs = realpathSync(env);
|
|
85
|
+
}
|
|
86
|
+
catch { /* may legitimately not exist yet — surfaced at run/available */ }
|
|
87
|
+
// CLI-C2-EXPLICIT-DIR: warn (don't refuse — it's the user's explicit choice) on ANY insecurity
|
|
88
|
+
// of the resolved path, not just a world-writable FILE: a world-writable / transient / cwd
|
|
89
|
+
// DIRECTORY is just as plantable. "does not exist" is normal (surfaced later), so skip it.
|
|
90
|
+
let warning;
|
|
91
|
+
const reason = insecureReason(abs);
|
|
92
|
+
if (reason && reason !== "does not exist")
|
|
93
|
+
warning = `CAIRN_CSD (${abs}) ${reason} — anyone could replace it with a key-stealing binary`;
|
|
94
|
+
return (_resolved = { path: abs, explicit: true, warning });
|
|
95
|
+
}
|
|
96
|
+
// Implicit resolution: canonical locations first (defeats PATH-order hijack), then PATH.
|
|
97
|
+
for (const cand of CANONICAL) {
|
|
98
|
+
// CLI-C2-SYMLINK-TOCTOU: resolve the symlink FIRST, then run the security check on the REAL
|
|
99
|
+
// target (insecureReason on a symlink validates the link's parent dir, not the target's) — so a
|
|
100
|
+
// canonical-path symlink can't point at a world-writable binary that slips the check.
|
|
101
|
+
try {
|
|
102
|
+
const st = statSync(cand);
|
|
103
|
+
if (!st.isFile() || !(st.mode & 0o111))
|
|
104
|
+
continue;
|
|
105
|
+
const real = realpathSync(cand);
|
|
106
|
+
if (!insecureReason(real))
|
|
107
|
+
return (_resolved = { path: real, explicit: false });
|
|
108
|
+
}
|
|
109
|
+
catch { /* next */ }
|
|
110
|
+
}
|
|
111
|
+
const found = pathSearch("csd");
|
|
112
|
+
if (!found)
|
|
113
|
+
return (_resolved = { path: null, explicit: false, error: "`csd` not found in any trusted location or on PATH — install it, or set CAIRN_CSD to its absolute path" });
|
|
114
|
+
let abs;
|
|
115
|
+
try {
|
|
116
|
+
abs = realpathSync(found);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return (_resolved = { path: null, explicit: false, error: `csd at ${found} could not be resolved` });
|
|
120
|
+
}
|
|
121
|
+
const bad = insecureReason(abs);
|
|
122
|
+
if (bad)
|
|
123
|
+
return (_resolved = { path: null, explicit: false, error: `refusing to run csd at ${abs} — it ${bad}. A malicious csd there could steal your wallet key. Move it to a trusted location (e.g. /usr/local/bin) or set CAIRN_CSD to a trusted absolute path.` });
|
|
124
|
+
return (_resolved = { path: abs, explicit: false });
|
|
125
|
+
}
|
|
126
|
+
// For display (the `setup` command shows the user exactly which binary will sign).
|
|
127
|
+
export function csdPathInfo() { return resolveCsdBin(); }
|
|
10
128
|
function extractTxid(s) {
|
|
11
129
|
const m = s.match(/txid["':\s]+0x([0-9a-fA-F]{64})/) || s.match(/0x[0-9a-fA-F]{64}/);
|
|
12
130
|
return m ? (m[1] ? "0x" + m[1] : m[0]) : undefined;
|
|
13
131
|
}
|
|
14
132
|
export async function run(args) {
|
|
133
|
+
const bin = resolveCsdBin();
|
|
134
|
+
if (!bin.path)
|
|
135
|
+
return { ok: false, stdout: "", stderr: bin.error || "csd unavailable" };
|
|
15
136
|
try {
|
|
16
|
-
const { stdout, stderr } = await pexec(
|
|
137
|
+
const { stdout, stderr } = await pexec(bin.path, args, { timeout: 30000 });
|
|
17
138
|
return { ok: true, stdout, stderr, txid: extractTxid(stdout + stderr) };
|
|
18
139
|
}
|
|
19
140
|
catch (e) {
|
|
@@ -21,9 +142,10 @@ export async function run(args) {
|
|
|
21
142
|
return { ok: false, stdout: e.stdout ?? "", stderr: e.stderr ?? String(e.message ?? e), txid: extractTxid(out) };
|
|
22
143
|
}
|
|
23
144
|
}
|
|
24
|
-
// Is `csd` installed + runnable?
|
|
25
|
-
export async function available() {
|
|
26
|
-
|
|
145
|
+
// Is `csd` installed + runnable (at the trusted path)?
|
|
146
|
+
export async function available() { const bin = resolveCsdBin(); if (!bin.path)
|
|
147
|
+
return false; try {
|
|
148
|
+
await pexec(bin.path, ["--version"], { timeout: 5000 });
|
|
27
149
|
return true;
|
|
28
150
|
}
|
|
29
151
|
catch {
|
|
@@ -37,5 +159,10 @@ export async function walletConfig() { const r = await run(["wallet", "config"])
|
|
|
37
159
|
catch {
|
|
38
160
|
return null;
|
|
39
161
|
} }
|
|
40
|
-
// Derive the public addr20 from a privkey via `csd wallet recover
|
|
162
|
+
// Derive the public addr20 from a privkey via `csd wallet recover`.
|
|
163
|
+
// SECURITY (audit H-2): this puts --privkey on the csd argv, briefly readable via /proc on a
|
|
164
|
+
// shared host. It is a LAST resort — resolveAddr() only calls it when the wallet has no
|
|
165
|
+
// default_change_addr20 AND we have no cached address, and the result is cached so it happens at
|
|
166
|
+
// most once. Callers surface keyExposureWarning and recommend setting a change address.
|
|
41
167
|
export async function deriveAddr(priv) { const r = await run(["wallet", "recover", "--privkey", priv]); const m = r.stdout.match(/addr20:\s*(0x[0-9a-fA-F]{40})/i); return m ? m[1] : null; }
|
|
168
|
+
export const keyExposureWarning = "deriving your address from the wallet key briefly exposes it on the `csd` command line (readable via /proc on a shared host). Set a change address once — `csd wallet init --privkey <key>` — so cairn never needs the key again.";
|
package/dist/lib/ui.js
CHANGED
|
@@ -66,12 +66,17 @@ export function bar(value, max, width = 16) {
|
|
|
66
66
|
export function csd(base) {
|
|
67
67
|
return c.green(`${(base / 1e8).toLocaleString(undefined, { maximumFractionDigits: 4 })}`) + c.gray(" CSD");
|
|
68
68
|
}
|
|
69
|
-
// Strip
|
|
70
|
-
//
|
|
71
|
-
// can't inject ANSI/OSC escapes to spoof output —
|
|
72
|
-
// "✗ MISMATCH"
|
|
73
|
-
//
|
|
74
|
-
|
|
69
|
+
// Strip dangerous characters from UNTRUSTED strings before printing them to a TTY, so a
|
|
70
|
+
// hostile server/chain field (title, body, message, ERROR string, txid/id, handle, bio,
|
|
71
|
+
// domain…) can't (a) inject ANSI/OSC escapes to spoof output — cursor-up + repaint to
|
|
72
|
+
// overwrite a "✗ MISMATCH" with "✓ VERIFIED", rewrite the window title, OSC-8 link spoof,
|
|
73
|
+
// OSC-52 clipboard write — or (b) use Unicode bidi-overrides / zero-width chars to spoof
|
|
74
|
+
// a displayed address/name/amount (CLI-9). Display-only: NEVER apply to bytes that get
|
|
75
|
+
// hashed/verified (it would change the hash).
|
|
76
|
+
// • C0/C1 control + DEL (incl. ESC 0x1b — the ANSI/OSC lead-in)
|
|
77
|
+
// • bidi controls: LRM/RLM U+200E/F, ALM U+061C, LRE..RLO U+202A-E, LRI..PDI U+2066-9
|
|
78
|
+
// • zero-width / joiners / BOM: ZWSP/ZWNJ/ZWJ U+200B-D, WJ U+2060, BOM U+FEFF
|
|
79
|
+
const CTRL = new RegExp("[\\u0000-\\u001f\\u007f-\\u009f\\u061c\\u200b-\\u200f\\u2060\\u2066-\\u2069\\u202a-\\u202e\\ufeff]", "g");
|
|
75
80
|
export function san(s) { return String(s ?? "").replace(CTRL, ""); }
|
|
76
81
|
export function ok(s) { return c.green("✓ ") + s; }
|
|
77
82
|
export function warn(s) { return c.gray("⚠ ") + s; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inversealtruism/cairn-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "CLI for Compute Substrate / Cairn — browse the board/wall/network, and send CSD, propose, attest, and place stones non-custodially by driving your own installed `csd` wallet (cairn never holds your key; works node-less via the Cairn proxy).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
26
26
|
"build": "npm run clean && tsc",
|
|
27
27
|
"dev": "tsx src/cli.ts",
|
|
28
|
-
"test": "npm run build && node test/security.mjs && node test/e2e.mjs",
|
|
28
|
+
"test": "npm run build && node test/security.mjs && node test/cairnx.mjs && node test/e2e.mjs",
|
|
29
29
|
"prepare": "npm run clean && tsc",
|
|
30
30
|
"prepublishOnly": "npm run clean && tsc"
|
|
31
31
|
},
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"typescript": "^5.7.2"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@inversealtruism/csd-codec": "0.1.
|
|
45
|
-
"@inversealtruism/csd-registry": "0.1.
|
|
44
|
+
"@inversealtruism/csd-codec": "0.1.14",
|
|
45
|
+
"@inversealtruism/csd-registry": "0.1.14"
|
|
46
46
|
}
|
|
47
47
|
}
|