tersign 0.4.10 → 0.5.0

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.
@@ -1,43 +1,131 @@
1
1
  #!/usr/bin/env node
2
- /** tersign-verify — third-party receipt verification. No API key, no trust in Tersign:
3
- * signature recovery is local, and the ledger check only asks the public endpoint whether
4
- * the counter-signed hash-chain holds.
2
+ /** tersign-verify — third-party receipt verification. No API key. A receipt FILE is checked
3
+ * locally (signature recovery, canonical digest) with no trust in Tersign. A DIGEST lookup, and
4
+ * the optional --ledger step after a file, only ASK a ledger whether it holds the record and
5
+ * whether its counter-signed hash-chain holds: that answer is the ledger's own, nothing in it is
6
+ * re-verified here, and the output says so (`VALID (ledger-reported)`).
5
7
  *
6
- * tersign-verify <receipt.json> [--signer 0xseller] [--ledger https://…]
8
+ * tersign-verify <receipt.json> [--signer 0xissuer] [--ledger https://…]
7
9
  * tersign-verify <0xdigest> [--ledger https://…]
8
10
  *
11
+ * --signer binds the receipt to its issuer: pass the issuer's address, obtained out-of-band
12
+ * (from the issuer through a channel you trust, never from the receipt itself). Without it the
13
+ * signer is reported UNAUTHENTICATED, because a signature recovers to SOME address for any
14
+ * payload: an unbound VALID proves neither who signed nor that the receipt is unmodified.
15
+ * Receipts signed with a published test key are flagged either way. Until 2026-09-27 this
16
+ * printed `signature: OK (signer X)` and a bare `VALID` for exactly those receipts, and ignored
17
+ * a mistyped flag, which is the worst shape a verifier can have: not a wrong answer, an
18
+ * unearned confidence.
19
+ *
20
+ * A file is one of three shapes (verify-report.ts resolveReceiptFile): the receipt itself,
21
+ * {receipt, record}, or an evidence-bundle record file, whose verdict is qualified `record
22
+ * artifact only` because its chain fields are not checked here. Anything else that nests a
23
+ * receipt is refused, as are duplicate keys, non-integer number tokens, and a signed field in
24
+ * another JSON type.
25
+ *
26
+ * Exit status: 0 VALID (read the last line: an unbound, test-key or ledger-reported VALID is
27
+ * qualified there), 1 INVALID, 2 usage (an unknown, valueless or repeated flag, --signer with a
28
+ * digest, a malformed address, a file that is missing, a directory, or not UTF-8 JSON) — the
29
+ * same scheme as `python3 -m tersign verify`, NOT the same status on every input. Measured
30
+ * 2026-09-27 (this build vs sdk-py 0.1.7; 33 adversarial probe files, each run unbound and
31
+ * with two --signer values, plus three extra cases): the two agree except on these inputs,
32
+ * npm/Python exit —
33
+ * {receipt, record} and {receipt} wrapper files 0/1 (Python has no `receipt` wrapper)
34
+ * a top-level receipt that also carries a `receipt` key 1/0 (refused here; Python lists it unsigned)
35
+ * a UTF-8 byte-order mark before the JSON 0/2
36
+ * a non-integer number token in a record file's own fields 1/0
37
+ * a NaN token 2/1
38
+ * a receipt FILE with --ledger (deliberate: this checks its digest's chain after the local
39
+ * checks; Python keeps files offline) 0/2
40
+ * absurdly nested JSON 1/2
41
+ * The signature-encoding class was re-measured after both twins adopted one canonical encoding
42
+ * (final release pass, same day): 11 probe files (genuine; no 0x; the v 0/1 twin; upper-case hex,
43
+ * all or one digit; a space or a trailing newline in the hex; the high-s twin with v 27/28 and
44
+ * with v 0/1; an object; an array), each unbound and bound — 0/0 on the genuine file, 1/1 on the
45
+ * rest, with the same reason text. The no-0x difference measured earlier is gone.
46
+ * Any other difference is unmeasured, not absent.
47
+ *
9
48
  * A bare digest needs a ledger to check against, and with none named it uses the public
10
49
  * Tersign ledger — the one the published digests live on — rather than refusing. The
11
50
  * ledger actually used is always printed, so a reader can see which chain answered and
12
51
  * that the choice was theirs to change. `--ledger` still wins whenever it is given; a
13
- * receipt FILE with no `--ledger` verifies its signature locally and checks no chain,
14
- * which is unchanged.
52
+ * receipt FILE with no `--ledger` verifies its signature locally and checks no chain.
15
53
  */
16
54
  import { readFileSync } from 'node:fs';
17
55
  import { digestOf } from './canonical.js';
18
56
  import { verifyReceipt } from './receipt/eip712.js';
19
57
  import { verifyComplianceRecord } from './compliance/record.js';
20
- function arg(flag) {
21
- const i = process.argv.indexOf(flag);
22
- return i > 0 ? process.argv[i + 1] : undefined;
58
+ import { ADDRESS_RE, findLoneSurrogate, safeText, signerStatus } from './receipt/binding.js';
59
+ import { DIGEST_RE, UsageError, findDuplicateKey, findNonIntegerNumberToken, oneLine, parseVerifyArgs, resolveReceiptFile, signerExplanation, testKeyNote, unsignedNote, verdictHead, wantsHelp, } from './verify-report.js';
60
+ /** Where a bare digest is checked when the caller names no ledger. */
61
+ export const DEFAULT_LEDGER = 'https://tersign.ai';
62
+ const USAGE = 'usage: tersign-verify <receipt.json | 0xdigest> [--signer 0xaddr] [--ledger url]\n' +
63
+ ` a bare digest checks against ${DEFAULT_LEDGER} unless --ledger names another\n` +
64
+ " --signer binds a receipt file to its issuer's address (obtained out-of-band);\n" +
65
+ ' without it the signer is reported UNAUTHENTICATED\n' +
66
+ ' exit: 0 VALID (read the last line) · 1 INVALID · 2 usage';
67
+ function usage(msg) {
68
+ console.error(`usage: ${msg}\n\n${USAGE}`);
69
+ process.exit(2);
23
70
  }
24
71
  function fail(msg) {
25
- console.error(`INVALID: ${msg}`);
72
+ console.error(`INVALID: ${oneLine(msg)}`);
26
73
  process.exit(1);
27
74
  }
28
- /** Where a bare digest is checked when the caller names no ledger. */
29
- export const DEFAULT_LEDGER = 'https://tersign.ai';
30
- const target = process.argv[2];
31
- if (!target || target.startsWith('--')) {
32
- console.error('usage: tersign-verify <receipt.json | 0xdigest> [--signer 0xaddr] [--ledger url]');
33
- console.error(` a bare digest checks against ${DEFAULT_LEDGER} unless --ledger names another`);
75
+ const args = process.argv.slice(2);
76
+ if (args.length === 0) {
77
+ console.error(USAGE);
34
78
  process.exit(2);
35
79
  }
36
- const ledger = arg('--ledger');
37
- const expectedSigner = arg('--signer');
80
+ if (wantsHelp(args)) {
81
+ console.log(USAGE);
82
+ process.exit(0);
83
+ }
84
+ let target;
85
+ let ledger;
86
+ let expectedSigner;
87
+ try {
88
+ const parsed = parseVerifyArgs(args);
89
+ target = parsed.target;
90
+ ledger = parsed.opts['--ledger'];
91
+ expectedSigner = parsed.opts['--signer'];
92
+ if (ledger !== undefined) {
93
+ let u;
94
+ try {
95
+ u = new URL(ledger);
96
+ }
97
+ catch {
98
+ u = undefined;
99
+ }
100
+ if (!u || (u.protocol !== 'https:' && u.protocol !== 'http:')) {
101
+ throw new UsageError(`--ledger must be an http(s) URL, got ${safeText(ledger)}`);
102
+ }
103
+ }
104
+ if (DIGEST_RE.test(target)) {
105
+ if (expectedSigner !== undefined) {
106
+ throw new UsageError("--signer binds a receipt FILE's signature; a digest lookup checks the ledger's counter-signed " +
107
+ 'chain instead. Verify the receipt file with --signer.');
108
+ }
109
+ }
110
+ else if (expectedSigner !== undefined && !ADDRESS_RE.test(expectedSigner)) {
111
+ throw new UsageError(`--signer must be a 20-byte 0x-prefixed hex address, got ${JSON.stringify(expectedSigner)}`);
112
+ }
113
+ }
114
+ catch (e) {
115
+ if (e instanceof UsageError)
116
+ usage(e.message);
117
+ throw e;
118
+ }
38
119
  async function checkLedger(digest, url) {
39
- const res = await fetch(`${url.replace(/\/$/, '')}/v1/receipts/${digest}/verify`);
40
- const body = (await res.json());
120
+ let body;
121
+ try {
122
+ const res = await fetch(`${url.replace(/\/$/, '')}/v1/receipts/${digest}/verify`);
123
+ body = (await res.json());
124
+ }
125
+ catch (e) {
126
+ const cause = e instanceof Error && e.cause instanceof Error ? ` (${e.cause.message})` : '';
127
+ fail(`could not get an answer from the ledger at ${url}: ${e instanceof Error ? e.message : String(e)}${cause}`);
128
+ }
41
129
  // Name the ledger that answered, always — a verifier that hides which chain it consulted is
42
130
  // making the reader take its word for the one fact the check exists to establish. Nothing
43
131
  // beyond that: `--ledger` is documented in usage and the README, and the failure path is not
@@ -46,54 +134,132 @@ async function checkLedger(digest, url) {
46
134
  fail(`no record of ${digest} on the Tersign ledger (${url})`);
47
135
  if (!body.chainOk)
48
136
  fail('ledger record found but the counter-signed hash-chain does NOT verify');
137
+ // Strings from the ledger are rendered inert: a hostile --ledger must not be able to print
138
+ // a line of its own (a bare VALID, say) through a sellerId.
139
+ const t = (v) => safeText(String(v));
49
140
  console.log(`ledger: ${url}`);
50
- console.log(` counter-signed OK (seller ${body.sellerId}, seq ${body.seq}, ledger key ${body.ledgerSigner})`);
141
+ // "reports", never "OK": these are the server's own booleans. Nothing here checks the
142
+ // counter-signature against a pinned ledger key — a look-alike server can answer the same.
143
+ console.log(` reports: found, counter-signed chain intact (seller ${t(body.sellerId)}, seq ${t(body.seq)}, ledger key ${t(body.ledgerSigner)}) — not checked locally`);
51
144
  // Present once the record sits under an anchored chain commitment (anchors since 2026-08-28):
52
145
  // the accumulator covers every seq ≤ commitment.seq, so the anchor binds this record too.
53
146
  const c = body.commitment;
54
147
  if (c) {
55
- const block = c.bitcoinBlockHeight ? ` block ${c.bitcoinBlockHeight}` : '';
56
- console.log(` commitment: seq ≤ ${c.seq} committed (acc ${c.acc.slice(0, 10)}…) — ${c.status}${block}`);
148
+ const block = c.bitcoinBlockHeight ? ` block ${t(c.bitcoinBlockHeight)}` : '';
149
+ console.log(` commitment: seq ≤ ${t(c.seq)} committed (acc ${t(String(c.acc).slice(0, 10))}…) — ${t(c.status)}${block}`);
57
150
  }
58
151
  }
59
- if (/^0x[0-9a-f]{64}$/i.test(target)) {
60
- await checkLedger(target, ledger ?? DEFAULT_LEDGER);
61
- console.log('VALID');
152
+ if (DIGEST_RE.test(target)) {
153
+ const used = ledger ?? DEFAULT_LEDGER;
154
+ await checkLedger(target, used);
155
+ // Qualified: the verdict is the ledger's answer about itself. Verifying the receipt FILE
156
+ // (with --signer) is the local check.
157
+ console.log(`VALID (ledger-reported) — ${safeText(used)} reports the record and its counter-signed chain; nothing was verified locally`);
62
158
  process.exit(0);
63
159
  }
64
- let raw;
160
+ // ---- a receipt FILE: every check runs before anything is printed, so a failure prints only
161
+ // ---- the INVALID line and never a half-report that reads like a pass.
162
+ let bytes;
65
163
  try {
66
- raw = readFileSync(target, 'utf8');
164
+ bytes = readFileSync(target);
67
165
  }
68
- catch {
69
- fail(`cannot read '${target}' — pass a receipt JSON file that exists, or a 0x… digest with --ledger`);
166
+ catch (e) {
167
+ const code = e.code;
168
+ if (code === 'ENOENT')
169
+ usage(`no such file: ${target} — pass a receipt JSON file, or a 0x-prefixed 32-byte digest`);
170
+ if (code === 'EISDIR') {
171
+ usage(`${target} is a directory: pass a receipt JSON file. An evidence bundle directory is checked by ` +
172
+ 'the bundle verifier (verify/verify_bundle.py).');
173
+ }
174
+ usage(`cannot read ${target} (${code ?? (e instanceof Error ? e.message : String(e))})`);
70
175
  }
71
- let parsed;
176
+ let text;
72
177
  try {
73
- parsed = JSON.parse(raw);
178
+ // fatal: a lenient decode would swap bad bytes for U+FFFD and verify text the file does not hold
179
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
74
180
  }
75
181
  catch {
76
- fail(`'${target}' is not valid JSON — expected a signed receipt file`);
182
+ usage(`${target} is not UTF-8 text — expected a signed receipt JSON file`);
183
+ }
184
+ let doc;
185
+ try {
186
+ doc = JSON.parse(text);
187
+ }
188
+ catch (e) {
189
+ usage(`${target} is not JSON (${e instanceof Error ? e.message : 'parse error'}) — expected a signed receipt file`);
77
190
  }
78
- const receipt = 'payload' in parsed || 'signature' in parsed ? parsed : parsed.receipt;
79
- const record = 'receipt' in parsed ? parsed.record : undefined;
191
+ const dup = findDuplicateKey(text);
192
+ if (dup !== null)
193
+ fail(`duplicate key ${JSON.stringify(dup)} — one JSON object, two values for one name`);
194
+ const nonInteger = findNonIntegerNumberToken(text);
195
+ if (nonInteger !== null) {
196
+ fail(`number ${safeText(nonInteger, 40)} is not an integer — the canonical digest is defined over integers only, and this file's bytes do not survive a parse unchanged`);
197
+ }
198
+ // A lone surrogate anywhere (key or value) makes the file's text differ from the bytes that are
199
+ // signed and digested: UTF-8 cannot carry it, so the encoder substitutes U+FFFD. The Python twin
200
+ // refuses such a file too.
201
+ const lone = findLoneSurrogate(doc);
202
+ if (lone !== null) {
203
+ fail(`${safeText(lone, 64)} holds a lone UTF-16 surrogate (a \\uD800-\\uDFFF escape) that UTF-8 cannot carry — the signed and digested text would be U+FFFD, not what this file says`);
204
+ }
205
+ const file = resolveReceiptFile(doc);
206
+ if (!file.ok)
207
+ fail(file.reason);
208
+ const receipt = file.receipt;
80
209
  const result = await verifyReceipt(receipt, expectedSigner);
81
- if (!result.valid)
82
- fail(`receipt signature: ${result.reason}`);
83
- const digest = digestOf(receipt);
84
- console.log(`signature: OK (signer ${result.signer})`);
85
- console.log(`digest: ${digest}`);
86
- if (record) {
87
- const rec = await verifyComplianceRecord(record, expectedSigner);
88
- if (!rec.valid)
89
- fail(`compliance record: ${rec.reason}`);
210
+ if (!result.valid) {
211
+ fail(signerStatus(result, expectedSigner) === 'MISMATCH'
212
+ ? `signer MISMATCH — ${signerExplanation(result, expectedSigner, '--signer')}`
213
+ : `receipt signature: ${result.reason ?? 'invalid receipt'}`);
214
+ }
215
+ let digest;
216
+ try {
217
+ digest = digestOf(receipt);
218
+ }
219
+ catch (e) {
220
+ fail(`cannot compute the receipt's canonical digest: ${e instanceof Error ? e.message : String(e)}`);
221
+ }
222
+ let rec;
223
+ if (file.record !== undefined) {
224
+ const record = file.record;
225
+ rec = await verifyComplianceRecord(record, expectedSigner);
226
+ if (!rec.valid) {
227
+ fail(signerStatus(rec, expectedSigner) === 'MISMATCH'
228
+ ? `record signer MISMATCH — ${signerExplanation(rec, expectedSigner, '--signer', 'record')}`
229
+ : `compliance record: ${rec.reason ?? 'invalid record'}`);
230
+ }
90
231
  if (record.record.receiptDigest !== digest)
91
232
  fail('compliance record is bound to a DIFFERENT receipt');
92
- console.log(`record: OK (bound to receipt, signer ${rec.signer})`);
93
233
  }
234
+ const signer = result.signer;
235
+ console.log(`signature: recovers to ${signer}`);
236
+ console.log(`signer: ${signerStatus(result, expectedSigner)} — ${signerExplanation(result, expectedSigner, '--signer')}`);
237
+ if (result.testKey)
238
+ console.log(`test key: ${testKeyNote(signer, result.testKey)}`);
239
+ if (rec) {
240
+ console.log(`record: OK — bound to this receipt's digest; signed by ${rec.signer}, signer ${signerStatus(rec, expectedSigner)}`);
241
+ if (rec.testKey && rec.signer && rec.signer.toLowerCase() !== signer.toLowerCase()) {
242
+ console.log(`test key: ${testKeyNote(rec.signer, rec.testKey)}`);
243
+ }
244
+ }
245
+ const recordFile = file.under === 'artifact';
246
+ if (recordFile) {
247
+ console.log(`checked: the receipt under "artifact" only — the record's own fields (${file.recordFieldsNotChecked.join(', ')}) ` +
248
+ "were NOT checked: not its place in the chain, not the ledger's countersignature. The bundle verifier " +
249
+ '(verify/verify_bundle.py) checks those.');
250
+ }
251
+ else if (file.under) {
252
+ console.log(`checked: the receipt under ${JSON.stringify(file.under)}${rec ? ' and its compliance record' : ''} in this file`);
253
+ }
254
+ const unsigned = (result.unsignedFields ?? []).map((f) => (file.under ? `${file.under}.${f}` : f));
255
+ if (unsigned.length)
256
+ console.log(`unsigned: ${unsignedNote(unsigned)}`);
257
+ console.log(`digest: ${digest}`);
94
258
  // A receipt FILE carries its own signature, so it verifies with no network at all. Only
95
259
  // check a chain when the caller asked for one — defaulting here would turn an offline
96
260
  // verification into a silent network call, which is the opposite of the point.
97
261
  if (ledger)
98
262
  await checkLedger(digest, ledger);
99
- console.log('VALID');
263
+ // The verdict line. Bare `VALID` only when the signer is BOUND, no signer is a test key, and the
264
+ // file is the receipt itself or {receipt, record} (a bundle record file is qualified).
265
+ console.log(verdictHead(true, result.signerBound, Boolean(result.testKey || rec?.testKey), recordFile));
@@ -0,0 +1,75 @@
1
+ import { type SignerBinding } from './receipt/binding.js';
2
+ export declare const SIGNED_FIELDS_TEXT: string;
3
+ /** How the caller binds a signer on this surface: the CLI flag, or the MCP argument. */
4
+ export type BindHint = '--signer' | 'expectedSigner';
5
+ /** `VALID`, or VALID with every qualifier that applies; `INVALID` when not valid. The order is
6
+ * the Python CLI's: signer UNAUTHENTICATED, published test key, record artifact only. */
7
+ export declare function verdictHead(valid: boolean, signerBound: boolean, testKey: boolean, recordArtifactOnly?: boolean): string;
8
+ /** What the signer status means for a receipt, in one or two sentences. */
9
+ export declare function signerExplanation(r: SignerBinding, expected: string | undefined, hint: BindHint, what?: string): string;
10
+ export declare function testKeyNote(signer: string, label: string): string;
11
+ export declare function unsignedNote(fields: readonly string[]): string;
12
+ /** One sentence for machine readers (the MCP tools' `verdict`): head, then what it means. */
13
+ export declare function verdictSentence(r: SignerBinding & {
14
+ unsignedFields?: readonly string[];
15
+ }, expected: string | undefined, hint: BindHint, what?: string): string;
16
+ /** A library or viem reason, flattened to one line so it can never add an output line. */
17
+ export declare function oneLine(s: string): string;
18
+ /** Every option `tersign verify` accepts. Anything else is refused: an unknown flag used to be
19
+ * ignored, so `verify r.json --sigenr 0xWRONG` printed the same VALID as an unbound run and the
20
+ * operator believed a binding had been checked that never ran. */
21
+ export declare const VERIFY_OPTIONS: {
22
+ readonly '--signer': "--signer 0x<issuer address>";
23
+ readonly '--ledger': "--ledger https://tersign.ai";
24
+ };
25
+ export type VerifyOption = keyof typeof VERIFY_OPTIONS;
26
+ export declare class UsageError extends Error {
27
+ }
28
+ export declare const DIGEST_RE: RegExp;
29
+ export declare function wantsHelp(args: readonly string[]): boolean;
30
+ /** verify's arguments → { target, opts }. Throws UsageError on anything ambiguous: an unknown or
31
+ * valueless flag, a flag given twice, two targets, no target. Flags may come before or after
32
+ * the target. */
33
+ export declare function parseVerifyArgs(args: readonly string[]): {
34
+ target: string;
35
+ opts: Partial<Record<VerifyOption, string>>;
36
+ };
37
+ /** The first key that appears twice in one JSON object, or null. `text` must already have
38
+ * parsed as JSON. JSON.parse is last-wins while a human reader — and many first-wins parsers —
39
+ * take the FIRST, so two "resourceUrl" keys would let the file a reader sees differ from the
40
+ * value this CLI checked, under a VALID. The bundle verifier and the Python CLI refuse them too. */
41
+ export declare function findDuplicateKey(text: string): string | null;
42
+ /** The first number token outside a string that is not a plain integer (`1.0`, `1e2`), or null.
43
+ * JSON.parse collapses `1783761710.0` to `1783761710`, so a file whose bytes carry a float
44
+ * would otherwise verify with the SAME digest as the integer original — while the Python
45
+ * verifier (which parses it as a float) and the ledger (which scans these tokens at ingest)
46
+ * both refuse it. The canonical digest is defined over integers only; so is this CLI. */
47
+ export declare function findNonIntegerNumberToken(text: string): string | null;
48
+ /** The fields an evidence-bundle record file (records/NNNNNN.json) carries beside the signed
49
+ * receipt under `artifact`. The ONLY wrapper fields accepted there — the Python CLI's rule. */
50
+ export declare const RECORD_FIELDS: readonly ["seq", "format", "artifactDigest", "prevDigest", "linkDigest", "countersignature"];
51
+ export type ResolvedFile = {
52
+ ok: true;
53
+ receipt: unknown;
54
+ record?: unknown;
55
+ /** the wrapper key the receipt was found under, when it was not the file itself */
56
+ under?: 'receipt' | 'artifact';
57
+ /** for a bundle record file: its own fields, which this command does NOT check */
58
+ recordFieldsNotChecked: string[];
59
+ } | {
60
+ ok: false;
61
+ reason: string;
62
+ };
63
+ /** Which object in a parsed file is the receipt this run checks — never a guess, and never one
64
+ * the reader could mistake for another.
65
+ *
66
+ * {format, payload, signature} the receipt itself
67
+ * {receipt, record?} a receipt with its compliance record (both checked)
68
+ * {artifact, seq, format, artifactDigest, …} an evidence-bundle record file (RECORD_FIELDS)
69
+ *
70
+ * Anything else is refused, not dropped: a receipt at the top level AND under "receipt" /
71
+ * "artifact" (a fabricated top-level receipt borrowing a genuine nested signature), or a
72
+ * wrapper carrying any other field (a stray resourceUrl beside a genuine `artifact` reads as the
73
+ * receipt that was checked). The Python CLI accepts only the receipt and the record-file shapes,
74
+ * so a {receipt, record} file exits differently there (verify-bin.ts lists every measured case). */
75
+ export declare function resolveReceiptFile(doc: unknown): ResolvedFile;
@@ -0,0 +1,241 @@
1
+ /** The words a verify result is reported in — shared by `tersign verify` (verify-bin.ts) and the
2
+ * MCP verify tools, and the pure halves of the CLI (argument parsing, file-shape resolution,
3
+ * duplicate-key detection) so they can be tested without running the script.
4
+ *
5
+ * The rule every sentence here follows: a verdict never says more than the checks behind it.
6
+ * An unqualified VALID is printed only when the signer is BOUND to an address the caller
7
+ * supplied and that address is not a published test key. Every other PASS says, in the verdict
8
+ * itself, what it does not prove. Same rule as the Python twin (`python3 -m tersign verify`,
9
+ * sdk-py/tersign/__main__.py), in the same words where both have the case; the inputs on which the
10
+ * two exit differently are listed, as measured, in verify-bin.ts. */
11
+ import { SIGNED_RECEIPT_FIELDS } from './receipt/eip712.js';
12
+ import { byCodePoint, isPlainObject, quoteField } from './receipt/binding.js';
13
+ export const SIGNED_FIELDS_TEXT = SIGNED_RECEIPT_FIELDS.join(', ');
14
+ /** `VALID`, or VALID with every qualifier that applies; `INVALID` when not valid. The order is
15
+ * the Python CLI's: signer UNAUTHENTICATED, published test key, record artifact only. */
16
+ export function verdictHead(valid, signerBound, testKey, recordArtifactOnly = false) {
17
+ if (!valid)
18
+ return 'INVALID';
19
+ const q = [];
20
+ if (!signerBound)
21
+ q.push('signer UNAUTHENTICATED');
22
+ if (testKey)
23
+ q.push('published test key');
24
+ if (recordArtifactOnly)
25
+ q.push('record artifact only');
26
+ return q.length ? `VALID (${q.join(', ')})` : 'VALID';
27
+ }
28
+ /** What the signer status means for a receipt, in one or two sentences. */
29
+ export function signerExplanation(r, expected, hint, what = 'receipt') {
30
+ const s = r.signer ?? '(none)';
31
+ if (!r.valid && r.signer && expected) {
32
+ return `the signature recovers to ${s}, not to the ${hint} ${expected}: this ${what} was not signed by that key, or was altered after signing.`;
33
+ }
34
+ const receipt = what === 'receipt';
35
+ if (r.signerBound) {
36
+ const covered = receipt ? `the signed receipt fields (${SIGNED_FIELDS_TEXT})` : `this ${what}'s signed digests`;
37
+ return `${covered} were signed by ${s}, the address you supplied with ${hint}. The binding is only as strong as the channel that address came from.`;
38
+ }
39
+ const edit = receipt
40
+ ? "an edit to any signed field's value recovers a different address and reaches this same result"
41
+ : `anyone can edit the ${what}, recompute its digest and re-sign it with their own key, and reach this same result`;
42
+ return (`the signature recovers to ${s}, but no ${hint} was supplied, so nothing binds that address to the issuer. ` +
43
+ `This proves neither who signed nor that the ${what} is unmodified: ${edit}. For authorship, re-run with ` +
44
+ `${hint} <the issuer's address, obtained out-of-band>.`);
45
+ }
46
+ export function testKeyNote(signer, label) {
47
+ return `${signer} is a PUBLISHED test key (${label}): anyone can produce this signature, so it is not evidence of who issued it.`;
48
+ }
49
+ export function unsignedNote(fields) {
50
+ return `${fields.map(quoteField).join(', ')} — in this file but NOT covered by the signature, and not checked here`;
51
+ }
52
+ /** One sentence for machine readers (the MCP tools' `verdict`): head, then what it means. */
53
+ export function verdictSentence(r, expected, hint, what = 'receipt') {
54
+ if (!r.valid) {
55
+ const why = r.signer && expected ? signerExplanation(r, expected, hint, what) : oneLine(r.reason ?? `invalid ${what}`);
56
+ return `INVALID — ${why}`;
57
+ }
58
+ let s = `${verdictHead(true, r.signerBound, Boolean(r.testKey))} — ${signerExplanation(r, expected, hint, what)}`;
59
+ if (r.testKey && r.signer)
60
+ s += ` ${testKeyNote(r.signer, r.testKey)}`;
61
+ // A count, never the names: they are chosen by whoever wrote the file, and the verdict is the
62
+ // one sentence an agent reads as the tool's own. The names stay in `unsignedFields`.
63
+ const n = r.unsignedFields?.length ?? 0;
64
+ if (n)
65
+ s += ` ${n} field${n === 1 ? '' : 's'} the signature does not cover ${n === 1 ? 'is' : 'are'} listed in unsignedFields.`;
66
+ return s;
67
+ }
68
+ /** A library or viem reason, flattened to one line so it can never add an output line. */
69
+ export function oneLine(s) {
70
+ return s.replace(/\s*[\r\n\u2028\u2029]+\s*/g, ' ').trim();
71
+ }
72
+ // ---------------------------------------------------------------------------------------------
73
+ // CLI argument parsing
74
+ // ---------------------------------------------------------------------------------------------
75
+ /** Every option `tersign verify` accepts. Anything else is refused: an unknown flag used to be
76
+ * ignored, so `verify r.json --sigenr 0xWRONG` printed the same VALID as an unbound run and the
77
+ * operator believed a binding had been checked that never ran. */
78
+ export const VERIFY_OPTIONS = {
79
+ '--signer': '--signer 0x<issuer address>',
80
+ '--ledger': '--ledger https://tersign.ai',
81
+ };
82
+ export class UsageError extends Error {
83
+ }
84
+ export const DIGEST_RE = /^0x[0-9a-fA-F]{64}$/;
85
+ export function wantsHelp(args) {
86
+ return args[0] === 'help' || args.includes('--help') || args.includes('-h');
87
+ }
88
+ /** verify's arguments → { target, opts }. Throws UsageError on anything ambiguous: an unknown or
89
+ * valueless flag, a flag given twice, two targets, no target. Flags may come before or after
90
+ * the target. */
91
+ export function parseVerifyArgs(args) {
92
+ let target;
93
+ const opts = {};
94
+ for (let i = 0; i < args.length; i++) {
95
+ const a = args[i];
96
+ if (a.startsWith('-') && a !== '-') {
97
+ if (!Object.hasOwn(VERIFY_OPTIONS, a)) {
98
+ throw new UsageError(`unknown option ${a} (accepted: ${Object.keys(VERIFY_OPTIONS).sort().join(', ')})`);
99
+ }
100
+ const flag = a;
101
+ const v = args[i + 1];
102
+ if (v === undefined || v.startsWith('-'))
103
+ throw new UsageError(`${flag} requires a value, e.g. ${VERIFY_OPTIONS[flag]}`);
104
+ if (opts[flag] !== undefined)
105
+ throw new UsageError(`${flag} given twice`);
106
+ opts[flag] = v;
107
+ i++;
108
+ continue;
109
+ }
110
+ if (target !== undefined)
111
+ throw new UsageError(`one receipt path or digest per run (got ${target} and ${a})`);
112
+ target = a;
113
+ }
114
+ if (target === undefined)
115
+ throw new UsageError('verify needs a receipt path or a 0x-prefixed 32-byte digest');
116
+ return { target, opts };
117
+ }
118
+ // ---------------------------------------------------------------------------------------------
119
+ // Receipt files
120
+ // ---------------------------------------------------------------------------------------------
121
+ /** The first key that appears twice in one JSON object, or null. `text` must already have
122
+ * parsed as JSON. JSON.parse is last-wins while a human reader — and many first-wins parsers —
123
+ * take the FIRST, so two "resourceUrl" keys would let the file a reader sees differ from the
124
+ * value this CLI checked, under a VALID. The bundle verifier and the Python CLI refuse them too. */
125
+ export function findDuplicateKey(text) {
126
+ const stack = [];
127
+ for (let i = 0; i < text.length; i++) {
128
+ const c = text[i];
129
+ if (c === '"') {
130
+ let j = i + 1;
131
+ while (j < text.length && text[j] !== '"')
132
+ j += text[j] === '\\' ? 2 : 1;
133
+ const top = stack[stack.length - 1];
134
+ if (top?.keys && top.expectKey) {
135
+ const key = JSON.parse(text.slice(i, j + 1)); // decodes escapes: "a" and "\u0061" collide
136
+ if (top.keys.has(key))
137
+ return key;
138
+ top.keys.add(key);
139
+ top.expectKey = false;
140
+ }
141
+ i = j;
142
+ }
143
+ else if (c === '{') {
144
+ stack.push({ keys: new Set(), expectKey: true });
145
+ }
146
+ else if (c === '[') {
147
+ stack.push({ keys: null, expectKey: false });
148
+ }
149
+ else if (c === '}' || c === ']') {
150
+ stack.pop();
151
+ }
152
+ else if (c === ',') {
153
+ const top = stack[stack.length - 1];
154
+ if (top?.keys)
155
+ top.expectKey = true;
156
+ }
157
+ }
158
+ return null;
159
+ }
160
+ /** The first number token outside a string that is not a plain integer (`1.0`, `1e2`), or null.
161
+ * JSON.parse collapses `1783761710.0` to `1783761710`, so a file whose bytes carry a float
162
+ * would otherwise verify with the SAME digest as the integer original — while the Python
163
+ * verifier (which parses it as a float) and the ledger (which scans these tokens at ingest)
164
+ * both refuse it. The canonical digest is defined over integers only; so is this CLI. */
165
+ export function findNonIntegerNumberToken(text) {
166
+ for (let i = 0; i < text.length; i++) {
167
+ const c = text[i];
168
+ if (c === '"') {
169
+ i++;
170
+ while (i < text.length && text[i] !== '"')
171
+ i += text[i] === '\\' ? 2 : 1;
172
+ continue;
173
+ }
174
+ if (c === '-' || (c >= '0' && c <= '9')) {
175
+ const m = /^-?\d+(\.\d+)?([eE][+-]?\d+)?/.exec(text.slice(i, i + 400));
176
+ if (m && (m[1] !== undefined || m[2] !== undefined))
177
+ return m[0];
178
+ i += (m?.[0].length ?? 1) - 1;
179
+ }
180
+ }
181
+ return null;
182
+ }
183
+ /** The fields an evidence-bundle record file (records/NNNNNN.json) carries beside the signed
184
+ * receipt under `artifact`. The ONLY wrapper fields accepted there — the Python CLI's rule. */
185
+ export const RECORD_FIELDS = ['seq', 'format', 'artifactDigest', 'prevDigest', 'linkDigest', 'countersignature'];
186
+ /** Which object in a parsed file is the receipt this run checks — never a guess, and never one
187
+ * the reader could mistake for another.
188
+ *
189
+ * {format, payload, signature} the receipt itself
190
+ * {receipt, record?} a receipt with its compliance record (both checked)
191
+ * {artifact, seq, format, artifactDigest, …} an evidence-bundle record file (RECORD_FIELDS)
192
+ *
193
+ * Anything else is refused, not dropped: a receipt at the top level AND under "receipt" /
194
+ * "artifact" (a fabricated top-level receipt borrowing a genuine nested signature), or a
195
+ * wrapper carrying any other field (a stray resourceUrl beside a genuine `artifact` reads as the
196
+ * receipt that was checked). The Python CLI accepts only the receipt and the record-file shapes,
197
+ * so a {receipt, record} file exits differently there (verify-bin.ts lists every measured case). */
198
+ export function resolveReceiptFile(doc) {
199
+ if (!isPlainObject(doc)) {
200
+ return { ok: false, reason: 'not a signed receipt: expected a JSON object {format, payload, signature}' };
201
+ }
202
+ const own = (k) => Object.hasOwn(doc, k);
203
+ const atTop = own('payload') || own('signature');
204
+ const wrappers = ['receipt', 'artifact'].filter(own);
205
+ if (atTop && wrappers.length) {
206
+ return {
207
+ ok: false,
208
+ reason: `not one receipt: a receipt at the top level AND one under ${wrappers.map(quoteField).join(' and ')} — refusing to choose which one the reader means; nothing was verified`,
209
+ };
210
+ }
211
+ if (wrappers.length > 1) {
212
+ return { ok: false, reason: 'not one receipt: a receipt under both "receipt" and "artifact" — refusing to choose; nothing was verified' };
213
+ }
214
+ if (atTop)
215
+ return { ok: true, receipt: doc, recordFieldsNotChecked: [] };
216
+ const under = wrappers[0];
217
+ if (!under) {
218
+ return {
219
+ ok: false,
220
+ reason: 'not a signed receipt: expected {format, payload, signature}, {receipt, record}, or an evidence-bundle record file',
221
+ };
222
+ }
223
+ const allowed = new Set(under === 'receipt' ? ['receipt', 'record'] : ['artifact', ...RECORD_FIELDS]);
224
+ const extra = Object.keys(doc).filter((k) => !allowed.has(k)).sort(byCodePoint);
225
+ if (extra.length) {
226
+ const shape = under === 'receipt' ? '{receipt, record}' : 'evidence-bundle record';
227
+ return {
228
+ ok: false,
229
+ reason: `not one receipt: the file nests a signed ${quoteField(under)} and also carries ${extra.length} top-level ` +
230
+ `field${extra.length === 1 ? '' : 's'} no ${shape} has (first: ${quoteField(extra[0])}), so its top-level ` +
231
+ 'fields would read as the receipt while the nested one was checked; nothing was verified. Pass the receipt ' +
232
+ 'itself, or an unmodified records/NNNNNN.json.',
233
+ };
234
+ }
235
+ if (under === 'receipt') {
236
+ return own('record')
237
+ ? { ok: true, receipt: doc.receipt, record: doc.record, under, recordFieldsNotChecked: [] }
238
+ : { ok: true, receipt: doc.receipt, under, recordFieldsNotChecked: [] };
239
+ }
240
+ return { ok: true, receipt: doc.artifact, under, recordFieldsNotChecked: RECORD_FIELDS.filter(own) };
241
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tersign",
3
- "version": "0.4.10",
3
+ "version": "0.5.0",
4
4
  "description": "Tersign \u2014 the evidence layer for the agent economy. Counter-signed receipts, agent action records, idempotency enforcement, refunds, disputes, and jury-ready evidence envelopes for x402/agent-commerce sellers.",
5
5
  "license": "MIT",
6
6
  "type": "module",