tersign 0.4.11 → 0.6.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.
@@ -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.11",
3
+ "version": "0.6.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",