@inversealtruism/cairn-cli 0.3.5 → 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/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import * as csd from "./lib/csd.js";
7
7
  import { buildCommitment } from "./lib/item.js";
8
8
  import { buildGatewayRecord, buildPeerRecord, buildIdentityCommit, buildIdentityReveal } from "@inversealtruism/csd-registry";
9
9
  import { canonicalJson } from "@inversealtruism/csd-codec";
10
- import { cairnxGet, activeCairnxBase, buildTransferRecord, humanToBase, baseToHuman, CAIRNX_DOMAIN, CAIRNX_ANCHOR_FEE, TICKER_RE, NAME_RE } from "./lib/cairnx.js";
10
+ import { cairnxGet, activeCairnxBase, defaultBases, buildTransferRecord, humanToBase, baseToHuman, CAIRNX_DOMAIN, CAIRNX_ANCHOR_FEE, TICKER_RE, NAME_RE } from "./lib/cairnx.js";
11
11
  import { randomBytes } from "node:crypto";
12
12
  import { createInterface } from "node:readline";
13
13
  import { c, banner, bannerAnimated, rule, badge, bar, csd as csdFmt, ok, warn, err, key as kdim, pad, spinner, sleep, isTty, anim, clearScreen, cursorHome, san } from "./lib/ui.js";
@@ -53,8 +53,8 @@ function warnIfChangeCollapsed(inputValue, txValue, fee) {
53
53
  return; // pickInput already ensures coverage; defensive
54
54
  // a healthy spend leaves change ≫ fee unless the user genuinely picked a tight UTXO; flag the
55
55
  // case where the implied fee dwarfs the change (what an under-reporting proxy produces).
56
- if (change > 0 && fee > change * 4) {
57
- console.log(warn(`heads up: change (${csdToCoins(change)} CSD) is much smaller than the fee (${csdToCoins(fee)} CSD).`) +
56
+ if (change > 0 && fee > change) {
57
+ console.log(warn(`heads up: change (${csdToCoins(change)} CSD) is smaller than the fee (${csdToCoins(fee)} CSD).`) +
58
58
  c.gray(" If your proxy under-reports this input, extra value is burned as fee — verify with your own node, or use ") + c.cyan("--max-fee") + c.gray("."));
59
59
  }
60
60
  }
@@ -83,9 +83,19 @@ async function pickAndShow(addr, need, txValue, fee) {
83
83
  const verified = v.checked && v.ok === true;
84
84
  const inputValue = verified ? Number(v.value) : picked.value;
85
85
  const tag = verified ? c.green(" ✓ verified vs your node")
86
- : (CAIRN_RPC ? c.gray(" (independent value check unavailable)") : c.gray(" (set CAIRN_RPC to your own node to independently verify this value)"));
86
+ : (CAIRN_RPC ? c.gray(" (independent value check unavailable — CAIRN_RPC unreachable)") : c.gray(" (UNVERIFIED — set CAIRN_RPC)"));
87
87
  console.log(`${kdim("input")} ${csdToCoins(inputValue)} CSD ${c.gray("(one UTXO)")}${tag} ${kdim("change")} ${csdToCoins(Math.max(0, inputValue - txValue - fee))} CSD ${c.gray("back to you")}`);
88
88
  warnIfChangeCollapsed(inputValue, txValue, fee);
89
+ // CLI-C1: cairn-cli is node-less and has no independent view of the picked UTXO's REAL value, so a
90
+ // hostile/MITM'd CAIRN_API that under-reports it makes `csd` compute a tiny change and silently burn
91
+ // the rest of YOUR coin as miner fee (the recipient still gets the signed amount; the loss is your
92
+ // change). The on-chain fee is implicit (Σin−Σout) with NO chain-enforced maximum. The only robust
93
+ // defense is an INDEPENDENT value source: set CAIRN_RPC to your own node. Make its absence un-missable.
94
+ if (!verified) {
95
+ console.log(warn("input value is NOT independently verified.") +
96
+ c.gray(" A hostile proxy that under-reports it can burn the difference as fee. Set ") + c.cyan("CAIRN_RPC") +
97
+ c.gray(" to your own node to verify this value cryptographically before spending."));
98
+ }
89
99
  return picked.input;
90
100
  }
91
101
  // Resolve the user's PUBLIC address (to fetch inputs from the proxy). Never reads the key
@@ -126,8 +136,15 @@ async function resolveAddr(a) {
126
136
  if (cfg?.default_privkey) {
127
137
  const real = await csd.deriveAddr(cfg.default_privkey);
128
138
  if (real && /^0x[0-9a-fA-F]{40}$/.test(real)) {
139
+ const cached2 = saveLocalConfig({ address: real });
129
140
  console.log(warn("derived your address from the wallet key once.") + c.gray(" " + csd.keyExposureWarning));
130
- saveLocalConfig({ address: real });
141
+ // CLI-C2-DERIVEADDR: if the cache write FAILED (read-only HOME, unwritable CAIRN_CLI_CONFIG, …)
142
+ // the "derive at most once" guarantee is broken — every subsequent call would re-derive and
143
+ // re-expose the key on the csd argv. Surface it so the user can set a change address / CAIRN_ADDR.
144
+ if (!cached2)
145
+ console.log(warn("could NOT cache your address (config write failed)") +
146
+ c.gray(" — set a change address (") + c.cyan("csd wallet init --privkey <key>") + c.gray(") or ") + c.cyan("CAIRN_ADDR") +
147
+ c.gray(", otherwise the key is re-derived (and briefly re-exposed on the csd argv) every run."));
131
148
  return real;
132
149
  }
133
150
  }
@@ -148,7 +165,7 @@ async function resolveAddr(a) {
148
165
  async function signAndSubmit(csdArgs) {
149
166
  const r = await csd.run(csdArgs);
150
167
  if (!r.ok)
151
- return { ok: false, error: (r.stderr || r.stdout || "csd failed").trim().split("\n").slice(-1)[0] };
168
+ return { ok: false, error: san((r.stderr || r.stdout || "csd failed").trim().split("\n").slice(-1)[0]) };
152
169
  let out = null;
153
170
  try {
154
171
  out = JSON.parse(r.stdout);
@@ -158,15 +175,24 @@ async function signAndSubmit(csdArgs) {
158
175
  return { ok: false, error: "csd produced no signed transaction" };
159
176
  const txid = out.txid;
160
177
  const sub = await api.submitTx(out.tx).catch((e) => ({ ok: false, err: e.message }));
161
- if (sub.ok)
162
- return { ok: true, txid: sub.txid || txid };
178
+ // CLI-C3: the AUTHORITATIVE txid is the LOCALLY-signed one (csd computed it from the exact bytes
179
+ // it signed). NEVER adopt the proxy-reported sub.txid a hostile/MITM'd proxy could drop our tx
180
+ // and ack a DIFFERENT, already-mined chain tx; that substituted txid would then pass confirmMined
181
+ // AND an independent chainTxMined (it IS a real tx) and falsely read "confirmed on-chain",
182
+ // defeating the H-7 evidence-based cure. A divergent proxy txid is a hard failure.
183
+ const norm = (t) => String(t ?? "").toLowerCase().replace(/^0x/, "");
184
+ if (sub.ok) {
185
+ if (sub.txid && txid && norm(sub.txid) !== norm(txid))
186
+ return { ok: false, error: "proxy reported a different txid than the one we signed — refusing", txid };
187
+ return { ok: true, txid: txid || sub.txid };
188
+ }
163
189
  // Submit was NOT acked. Don't trust the error STRING — a forged "already present" can hide a
164
190
  // rejected/conflicting tx. Confirm against the chain: only if the node reports OUR exact txid
165
191
  // as mined is this our own prior submit (a genuine "already in"); otherwise the conflict is a
166
192
  // DIFFERENT spend (a real double-spend / hostile reply) and we surface it as a failure.
167
193
  if (txid && await api.confirmTxMined(txid))
168
194
  return { ok: true, txid };
169
- return { ok: false, error: sub.err || "submit rejected by node", txid };
195
+ return { ok: false, error: san(sub.err || "submit rejected by node"), txid };
170
196
  }
171
197
  // Freshness gate (R12): before building any value tx, consult the proxy's chain-view status so
172
198
  // we never sign against a FROZEN or forked tip (the proxy may be failing over to an honest-but-
@@ -237,7 +263,13 @@ function parse(argv) {
237
263
  // trustless independence we can't establish).
238
264
  function canonHost(h) {
239
265
  const x = h.toLowerCase().replace(/^\[|\]$/g, ""); // strip IPv6 brackets
240
- return (x === "localhost" || x === "127.0.0.1" || x === "::1" || x === "0.0.0.0") ? "localhost" : x;
266
+ // CLI-C3-CANONHOST (M-15): the ENTIRE 127.0.0.0/8 range is loopback (one machine), so 127.0.0.1
267
+ // and 127.0.0.2 must canonicalize equal — else a two-loopback-alias setup (board on 127.0.0.1, a
268
+ // sham "independent node" on 127.0.0.2) would falsely earn the "trustless via independent CAIRN_RPC"
269
+ // badge. Map every loopback alias to "localhost".
270
+ if (x === "localhost" || x === "::1" || x === "0.0.0.0" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(x))
271
+ return "localhost";
272
+ return x;
241
273
  }
242
274
  function sameHost(a, b) {
243
275
  try {
@@ -534,8 +566,13 @@ async function confirmMined(txid, label, wait) {
534
566
  console.log(warn(`${label} submitted — not mined yet; re-check with `) + c.cyan(`cairn show`) + warn(" once a block lands"));
535
567
  return;
536
568
  }
569
+ // CLI-C3-CONFIRM-SAMEHOST: only call it an INDEPENDENT confirmation when CAIRN_RPC is a different
570
+ // machine than CAIRN_API (a same-host node is the same operator, not an independent check) — the
571
+ // same discipline cmdVerify already applies to its trustless badge.
537
572
  if (indep === true)
538
- console.log(ok(`${label} confirmed on-chain`) + c.gray(" (verified against your independent node)"));
573
+ console.log(ok(`${label} confirmed on-chain`) + (sameHost(CAIRN_RPC, CAIRN_API)
574
+ ? c.gray(" ⚠ CAIRN_RPC shares a host with CAIRN_API — point it at an independent node for a trustless check")
575
+ : c.gray(" (verified against your independent node)")));
539
576
  else if (indep === false)
540
577
  console.log(err(`${label}: the proxy reports it mined but your node (CAIRN_RPC) does NOT — treat as UNCONFIRMED.`) + c.gray(" A hostile proxy can forge a 'mined' reply; trust your own node."));
541
578
  else
@@ -558,11 +595,11 @@ async function cmdPropose(a) {
558
595
  try {
559
596
  const r = await api.apiPropose({ domain, title, body, links, fee });
560
597
  sp.stop();
561
- console.log(r.ok ? ok(`proposed ${c.cyan(r.id)}`) + c.gray(" (operator)") : err(r.error || "failed"));
598
+ console.log(r.ok ? ok(`proposed ${c.cyan(san(r.id))}`) + c.gray(" (operator)") : err(san(r.error || "failed")));
562
599
  }
563
600
  catch (e) {
564
601
  sp.stop();
565
- console.log(err(e.message));
602
+ console.log(err(san(e.message)));
566
603
  }
567
604
  return;
568
605
  }
@@ -626,11 +663,11 @@ async function cmdSupport(a) {
626
663
  try {
627
664
  const r = await api.apiSupport({ id, fee, score, confidence });
628
665
  sp.stop();
629
- console.log(r.ok ? ok(`supported ${c.cyan(r.id)}`) + c.gray(" (operator)") : err(r.error || "failed"));
666
+ console.log(r.ok ? ok(`supported ${c.cyan(san(r.id))}`) + c.gray(" (operator)") : err(san(r.error || "failed")));
630
667
  }
631
668
  catch (e) {
632
669
  sp.stop();
633
- console.log(err(e.message));
670
+ console.log(err(san(e.message)));
634
671
  }
635
672
  return;
636
673
  }
@@ -869,12 +906,16 @@ async function main() {
869
906
  // ── L3 registry publish commands (build a signed record → anchor Propose → serve bytes) ──
870
907
  // Anchor a built registry record: Propose{domain, payloadHash} signed by the csd wallet,
871
908
  // then publish the EXACT canonical bytes to the content origin (self-certified on arrival).
872
- async function anchorRecord(rec, addr, fee, days, label) {
909
+ async function anchorRecord(a, rec, addr, fee, days, label) {
873
910
  // max-fee sanity: a fixed-floor record anchor should never burn more than the 1 CSD abs cap.
874
911
  if (fee > MAX_FEE_ABS) {
875
912
  console.log(err(`fee ${csdToCoins(fee)} CSD looks abnormally high for a ${label} record (cap ${csdToCoins(MAX_FEE_ABS)} CSD).`) + c.gray(" Lower ") + c.cyan("--fee") + c.gray("."));
876
913
  return false;
877
914
  }
915
+ // CLI-C6-ANCHOR-FRESHTIP: registry writes move value (the propose fee) + are in-process-signed, so
916
+ // they must NOT skip the stale/forked-tip gate every other value-write enforces.
917
+ if (!(await freshTip(a)))
918
+ return false;
878
919
  const uri = "csd:" + rec.domain.replace(/[^a-z]/gi, "").slice(0, 6) + ":v1:" + rec.payloadHash.slice(2, 14);
879
920
  const input = await pickAndShow(addr, fee, 0, fee);
880
921
  if (!input)
@@ -932,7 +973,7 @@ async function cmdGateway(a) {
932
973
  console.log(c.gray("\n[dry-run] not signed or submitted"));
933
974
  return;
934
975
  }
935
- await anchorRecord(rec, p.addr, fee, 10, "gateway");
976
+ await anchorRecord(a, rec, p.addr, fee, 10, "gateway");
936
977
  }
937
978
  async function cmdPeer(a) {
938
979
  if (a._[1] !== "announce") {
@@ -956,7 +997,7 @@ async function cmdPeer(a) {
956
997
  console.log(c.gray("\n[dry-run] not signed or submitted"));
957
998
  return;
958
999
  }
959
- await anchorRecord(rec, p.addr, fee, 10, "peer");
1000
+ await anchorRecord(a, rec, p.addr, fee, 10, "peer");
960
1001
  }
961
1002
  async function cmdIdentity(a) {
962
1003
  const sub = a._[1];
@@ -986,7 +1027,7 @@ async function cmdIdentity(a) {
986
1027
  console.log(c.gray("\n[dry-run] not signed"));
987
1028
  return;
988
1029
  }
989
- await anchorRecord(rec, p.addr, fee, 90, "identity reveal");
1030
+ await anchorRecord(a, rec, p.addr, fee, 90, "identity reveal");
990
1031
  return;
991
1032
  }
992
1033
  // default / --commit-only: step 1
@@ -997,7 +1038,7 @@ async function cmdIdentity(a) {
997
1038
  console.log(c.gray("\n[dry-run] not signed"));
998
1039
  return;
999
1040
  }
1000
- const okc = await anchorRecord(rec, p.addr, fee, 90, "identity commit");
1041
+ const okc = await anchorRecord(a, rec, p.addr, fee, 90, "identity commit");
1001
1042
  if (okc)
1002
1043
  console.log(c.gray("\n save this salt — reveal NEXT epoch (~1h): ") + c.cyan(`cairn identity claim ${handle} --reveal --salt ${salt}`));
1003
1044
  }
@@ -1006,11 +1047,25 @@ async function cmdIdentity(a) {
1006
1047
  // Display: base units → human, with thousands grouping. decimals===undefined (a ticker the
1007
1048
  // API doesn't know) falls back to raw base units rather than guessing a scale.
1008
1049
  const group = (s) => s.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
1050
+ // CLI-C4-AMOUNTDOS: read-API amounts are attacker-controlled strings; a non-numeric one would throw
1051
+ // inside BigInt()/baseToHuman() and crash the whole read/display. Parse defensively (0n on garbage)
1052
+ // so one hostile balance can't take down `tokens`/`token-info`.
1053
+ const big = (x) => { try {
1054
+ return BigInt(String(x ?? "0"));
1055
+ }
1056
+ catch {
1057
+ return 0n;
1058
+ } };
1009
1059
  function tokAmt(base, decimals) {
1010
1060
  if (decimals === undefined)
1011
1061
  return `${group(String(base))} (base units)`;
1012
- const [i, f] = baseToHuman(String(base), decimals).split(".");
1013
- return group(i) + (f ? "." + f : "");
1062
+ try {
1063
+ const [i, f] = baseToHuman(big(base), decimals).split(".");
1064
+ return group(i) + (f ? "." + f : "");
1065
+ }
1066
+ catch {
1067
+ return `${group(big(base).toString())} (base units)`;
1068
+ }
1014
1069
  }
1015
1070
  // The address a CairnX read targets: positional arg → --address/CAIRN_ADDR/csd wallet.
1016
1071
  async function resolveCairnxAddr(a, positional) {
@@ -1050,7 +1105,7 @@ async function cmdTokens(a) {
1050
1105
  if (!bals.length)
1051
1106
  console.log(c.gray(" no token balances"));
1052
1107
  for (const [ticker, b] of bals) {
1053
- const locked = BigInt(String(b.locked ?? "0"));
1108
+ const locked = big(b.locked);
1054
1109
  console.log(` ${c.cyan(pad(san(ticker), 14))} ${c.white(tokAmt(String(b.available ?? "0"), dec[ticker]))}${locked > 0n ? c.gray(` · ${tokAmt(String(b.locked), dec[ticker])} locked in open offers`) : ""}`);
1055
1110
  }
1056
1111
  const names = acct.names ?? [];
@@ -1062,7 +1117,7 @@ async function cmdTokenInfo(a) {
1062
1117
  console.log(warn("usage: ") + c.cyan("cairn token-info <TICKER>"));
1063
1118
  return;
1064
1119
  }
1065
- const t = await cairnxGet(`/token/${encodeURIComponent(ticker)}`).catch((e) => { console.log(e.status === 404 ? err(`unknown token ${ticker}`) : err(e.message)); return null; });
1120
+ const t = await cairnxGet(`/token/${encodeURIComponent(ticker)}`).catch((e) => { console.log(e.status === 404 ? err(`unknown token ${san(ticker)}`) : err(san(e.message))); return null; });
1066
1121
  if (!t)
1067
1122
  return;
1068
1123
  banner();
@@ -1071,13 +1126,13 @@ async function cmdTokenInfo(a) {
1071
1126
  row("name", c.white(san(t.name ?? t.ticker)));
1072
1127
  row("decimals", c.white(String(t.decimals)));
1073
1128
  row("supply", `${c.white(tokAmt(String(t.supply), t.decimals))} ${c.gray("max")}`);
1074
- const minted = BigInt(String(t.minted ?? "0")), supply = BigInt(String(t.supply ?? "0"));
1129
+ const minted = big(t.minted), supply = big(t.supply);
1075
1130
  row("minted", `${c.white(tokAmt(String(t.minted), t.decimals))}${supply > 0n ? c.gray(` · ${Number((minted * 10000n) / supply) / 100}% of supply`) : ""}`);
1076
1131
  row("mint", t.mint === "open" ? c.green("open") + c.gray(` · up to ${tokAmt(String(t.mintLimit ?? "0"), t.decimals)} per mint`) : c.gray("issuer-only"));
1077
1132
  row("deployer", c.gray(san(t.deployer)));
1078
1133
  row("deployed", c.gray(`height ${Number(t.height)} · id ${san(String(t.deployId ?? "")).slice(0, 22)}…`));
1079
1134
  // top-10 holders by total (available + locked) — the same reading the explorer shows
1080
- const holders = Object.entries(t.holders ?? {}).map(([h, b]) => ({ h, total: BigInt(String(b.available ?? "0")) + BigInt(String(b.locked ?? "0")) }))
1135
+ const holders = Object.entries(t.holders ?? {}).map(([h, b]) => ({ h, total: big(b.available) + big(b.locked) }))
1081
1136
  .filter((x) => x.total > 0n).sort((x, y) => (y.total > x.total ? 1 : y.total < x.total ? -1 : 0));
1082
1137
  console.log(`\n ${kdim("holders")} ${c.white(String(holders.length))}${holders.length > 10 ? c.gray(" · top 10") : ""}`);
1083
1138
  const max = holders[0]?.total ?? 1n;
@@ -1112,16 +1167,30 @@ async function cmdTokenSend(a) {
1112
1167
  console.log(err(`bad recipient: ${san(to)}`));
1113
1168
  return;
1114
1169
  }
1115
- // decimals are AUTHORITATIVE from the token's deploy record never guessed
1116
- const t = await cairnxGet(`/token/${encodeURIComponent(ticker)}`).catch((e) => { console.log(e.status === 404 ? err(`unknown token ${ticker}`) : err(e.message)); return null; });
1170
+ // CLI-C5-DECIMALS: `decimals` drives the human→base scale but is reported by an UNauthenticated
1171
+ // CairnX read API (no signature/SPV) a hostile gateway over-reporting it silently inflates the
1172
+ // MAGNITUDE of your transfer. So: (1) when not pinned to a single CAIRNX_API, cross-check decimals
1173
+ // across the default bases and refuse on disagreement (dual-source discipline); (2) always show +
1174
+ // confirm the exact base-unit integer so a wrong scale is visible before signing.
1175
+ const t = await cairnxGet(`/token/${encodeURIComponent(ticker)}`).catch((e) => { console.log(e.status === 404 ? err(`unknown token ${ticker}`) : err(san(e.message))); return null; });
1117
1176
  if (!t)
1118
1177
  return;
1178
+ const decimals = Number(t.decimals);
1179
+ const bases = defaultBases();
1180
+ if (bases.length > 1) {
1181
+ const seen = await Promise.all(bases.map((b) => cairnxGet(`/token/${encodeURIComponent(ticker)}`, [b]).then((x) => Number(x?.decimals)).catch(() => null)));
1182
+ const consistent = seen.filter((d) => Number.isInteger(d));
1183
+ if (consistent.length >= 2 && new Set(consistent).size > 1) {
1184
+ console.log(err(`CairnX sources DISAGREE on ${ticker} decimals (${[...new Set(consistent)].join(" vs ")}) — refusing to scale the amount against an ambiguous decimals (pin CAIRNX_API to a trusted base).`));
1185
+ return;
1186
+ }
1187
+ }
1119
1188
  let amount;
1120
1189
  try {
1121
- amount = humanToBase(amountStr, Number(t.decimals));
1190
+ amount = humanToBase(amountStr, decimals);
1122
1191
  }
1123
1192
  catch (e) {
1124
- console.log(err(e.message));
1193
+ console.log(err(san(e.message)));
1125
1194
  return;
1126
1195
  }
1127
1196
  if (amount <= 0n) {
@@ -1135,9 +1204,9 @@ async function cmdTokenSend(a) {
1135
1204
  }
1136
1205
  // balance check against the same state the resolver will apply the transfer to
1137
1206
  const acct = await cairnxGet(`/address/${encodeURIComponent(from.toLowerCase())}`);
1138
- const avail = BigInt(String(acct.balances?.[ticker]?.available ?? "0"));
1207
+ const avail = big(acct.balances?.[ticker]?.available);
1139
1208
  if (avail < amount) {
1140
- console.log(err(`insufficient ${ticker}: balance ${tokAmt(avail.toString(), t.decimals)}, tried to send ${tokAmt(amount.toString(), t.decimals)}${BigInt(String(acct.balances?.[ticker]?.locked ?? "0")) > 0n ? ` (${tokAmt(String(acct.balances[ticker].locked), t.decimals)} more is locked in open offers)` : ""}`));
1209
+ console.log(err(`insufficient ${ticker}: balance ${tokAmt(avail.toString(), decimals)}, tried to send ${tokAmt(amount.toString(), decimals)}${big(acct.balances?.[ticker]?.locked) > 0n ? ` (${tokAmt(String(acct.balances[ticker].locked), decimals)} more is locked in open offers)` : ""}`));
1141
1210
  return;
1142
1211
  }
1143
1212
  let built;
@@ -1145,14 +1214,16 @@ async function cmdTokenSend(a) {
1145
1214
  built = buildTransferRecord({ ticker, to, amount });
1146
1215
  }
1147
1216
  catch (e) {
1148
- console.log(err(e.message));
1217
+ console.log(err(san(e.message)));
1149
1218
  return;
1150
1219
  }
1151
- // clear-print exactly what will be anchored before anything signs
1152
- console.log(`${kdim("send")} ${c.white(tokAmt(amount.toString(), t.decimals))} ${c.cyan(ticker)} ${c.gray(`(${amount} base units · ${t.decimals} decimals)`)}`);
1220
+ // clear-print exactly what will be anchored before anything signs — the EXACT base-unit integer is
1221
+ // emphasized on its own line (CLI-C5: a wrong API-reported `decimals` shows up here as a magnitude).
1222
+ console.log(`${kdim("send")} ${c.white(tokAmt(amount.toString(), decimals))} ${c.cyan(ticker)}`);
1223
+ console.log(`${kdim("amount")} ${c.white(c.bold(amount.toString()))} ${c.gray(`base units · ${decimals} decimals (from the CairnX read API — verify this magnitude)`)}`);
1153
1224
  console.log(`${kdim("to")} ${c.cyan(to.toLowerCase())}`);
1154
- console.log(`${kdim("from")} ${c.cyan(from.toLowerCase())} ${c.gray(`· ${ticker} balance ${tokAmt(avail.toString(), t.decimals)}`)}`);
1155
- console.log(`${kdim("record")} ${c.white(built.uri)}`);
1225
+ console.log(`${kdim("from")} ${c.cyan(from.toLowerCase())} ${c.gray(`· ${ticker} balance ${tokAmt(avail.toString(), decimals)}`)}`);
1226
+ console.log(`${kdim("record")} ${c.white(san(built.uri))}`);
1156
1227
  console.log(`${kdim("hash")} ${c.magenta(built.payloadHash)}`);
1157
1228
  console.log(`${kdim("anchor")} ${c.gray(`Propose on ${CAIRNX_DOMAIN} — costs `)}${c.white(csdToCoins(CAIRNX_ANCHOR_FEE) + " CSD")}${c.gray(" (the chain fee; the tokens themselves move by record)")}`);
1158
1229
  if (a.flags["dry-run"]) {
@@ -1161,7 +1232,7 @@ async function cmdTokenSend(a) {
1161
1232
  }
1162
1233
  if (!(await requireCsd()))
1163
1234
  return;
1164
- if (!a.flags.yes && !(await confirmSend(`\nsend ${tokAmt(amount.toString(), t.decimals)} ${ticker} for ${csdToCoins(CAIRNX_ANCHOR_FEE)} CSD? [y/N] `))) {
1235
+ if (!a.flags.yes && !(await confirmSend(`\nsend ${tokAmt(amount.toString(), decimals)} ${ticker} (${amount} base units) for ${csdToCoins(CAIRNX_ANCHOR_FEE)} CSD? [y/N] `))) {
1165
1236
  console.log(c.gray("aborted"));
1166
1237
  return;
1167
1238
  }
@@ -1202,7 +1273,7 @@ async function cmdName(a) {
1202
1273
  console.log(warn("usage: ") + c.cyan("cairn name <name>") + c.gray(" (lowercase, 3–32 chars [a-z0-9-])"));
1203
1274
  return;
1204
1275
  }
1205
- const r = await cairnxGet(`/name/${encodeURIComponent(n)}`).catch((e) => { console.log(e.status === 404 ? err(`unregistered name "${n}"`) + c.gray(" — claimable via commit-reveal on /trade") : err(e.message)); return null; });
1276
+ const r = await cairnxGet(`/name/${encodeURIComponent(n)}`).catch((e) => { console.log(e.status === 404 ? err(`unregistered name "${san(n)}"`) + c.gray(" — claimable via commit-reveal on /trade") : err(san(e.message))); return null; });
1206
1277
  if (!r)
1207
1278
  return;
1208
1279
  banner();
@@ -1230,4 +1301,4 @@ async function cmdName(a) {
1230
1301
  else
1231
1302
  row("offer", c.gray("no open offer"));
1232
1303
  }
1233
- main().catch((e) => { console.error(err(String(e?.message ?? e))); process.exit(1); });
1304
+ main().catch((e) => { console.error(err(san(String(e?.message ?? e)))); process.exit(1); });
@@ -2,13 +2,15 @@
2
2
  // `cairnx:v1` domain. This file is the CLI's complete CairnX surface:
3
3
  // • a READ client for the CairnX state API (resolution order: $CAIRNX_API → the local
4
4
  // service → the public gateway, GET-only) with automatic fallback on network failure
5
- // • the canonical TRANSFER record builder — hand-rolled on the repo's own
6
- // stableStringify/sha256 (byte-exact-tested against cairnx-core's ground truth) so the
7
- // CLI takes NO dependency on the private cairnx repo.
8
- // TODO: swap to @inversealtruism/cairnx-core once it is published to npm.
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).
9
10
  // • exact human↔base-unit amount math as STRING/BigInt arithmetic — floats never touch
10
11
  // token amounts (no "1.1 * 1e8 = 110000000.00000001" class of bug, no silent truncation).
11
- import { stableStringify, sha256Hex } from "./item.js";
12
+ import { sha256Hex } from "./item.js";
13
+ import { canonicalJson } from "@inversealtruism/csd-codec";
12
14
  export const CAIRNX_DOMAIN = "cairnx:v1";
13
15
  export const CAIRNX_ANCHOR_FEE = 25_000_000; // 0.25 CSD — the consensus min Propose fee that anchors a record
14
16
  export const MAX_AMOUNT = (1n << 96n) - 1n; // CONVENTION.md: token amounts are ≤ 96-bit
@@ -28,7 +30,7 @@ export function buildTransferRecord(p) {
28
30
  if (p.amount > MAX_AMOUNT)
29
31
  throw new Error("amount exceeds the 96-bit token-amount limit");
30
32
  const record = { amount: p.amount.toString(), t: "transfer", ticker: p.ticker, to, v: 1 };
31
- const uri = stableStringify(record);
33
+ const uri = canonicalJson(record);
32
34
  if (Buffer.byteLength(uri, "utf8") > MAX_RECORD_BYTES)
33
35
  throw new Error("record exceeds 512 bytes"); // unreachable for a transfer, kept as a guard
34
36
  return { record, uri, payloadHash: sha256Hex(uri) };
@@ -23,11 +23,18 @@ export function loadLocalConfig() { try {
23
23
  catch {
24
24
  return {};
25
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).
26
30
  export function saveLocalConfig(patch) {
27
31
  try {
28
32
  mkdirSync(dirname(CFG_PATH), { recursive: true, mode: 0o700 });
29
33
  writeFileSync(CFG_PATH, JSON.stringify({ ...loadLocalConfig(), ...patch }, null, 2) + "\n", { mode: 0o600 });
30
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;
31
39
  }
32
- catch { /* best-effort */ }
33
40
  }
package/dist/lib/csd.js CHANGED
@@ -41,7 +41,10 @@ function insecureReason(abs) {
41
41
  const dir = dirname(abs);
42
42
  try {
43
43
  const dst = statSync(dir);
44
- if ((dst.mode & 0o002) && !(dst.mode & 0o1000))
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)
45
48
  return `is in a world-writable directory (${dir})`;
46
49
  }
47
50
  catch { /* dir unreadable — fall through */ }
@@ -81,20 +84,27 @@ export function resolveCsdBin() {
81
84
  abs = realpathSync(env);
82
85
  }
83
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.
84
90
  let warning;
85
- try {
86
- if (statSync(abs).mode & 0o002)
87
- warning = `CAIRN_CSD (${abs}) is world-writable — anyone could replace it with a key-stealing binary`;
88
- }
89
- catch { /* */ }
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`;
90
94
  return (_resolved = { path: abs, explicit: true, warning });
91
95
  }
92
96
  // Implicit resolution: canonical locations first (defeats PATH-order hijack), then PATH.
93
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.
94
101
  try {
95
102
  const st = statSync(cand);
96
- if (st.isFile() && (st.mode & 0o111) && !insecureReason(cand))
97
- return (_resolved = { path: realpathSync(cand), explicit: false });
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 });
98
108
  }
99
109
  catch { /* next */ }
100
110
  }
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 C0/C1 control chars (incl. ESC) from UNTRUSTED strings before printing them to a
70
- // TTY, so a hostile server/chain field (title, body, message, handle, bio, domain, id…)
71
- // can't inject ANSI/OSC escapes to spoof output — e.g. cursor-up + repaint to overwrite a
72
- // "✗ MISMATCH" verdict with "✓ VERIFIED", rewrite the window title, or hijack the terminal.
73
- // Display-only: NEVER apply to bytes that get hashed/verified (it would change the hash).
74
- const CTRL = new RegExp("[\\u0000-\\u001f\\u007f-\\u009f]", "g");
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.5",
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": {
@@ -41,7 +41,7 @@
41
41
  "typescript": "^5.7.2"
42
42
  },
43
43
  "dependencies": {
44
- "@inversealtruism/csd-codec": "0.1.3",
45
- "@inversealtruism/csd-registry": "0.1.3"
44
+ "@inversealtruism/csd-codec": "0.1.14",
45
+ "@inversealtruism/csd-registry": "0.1.14"
46
46
  }
47
47
  }