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,71 @@
1
+ /** x402 v1 network names → CAIP-2, for the receipt payload.
2
+ *
3
+ * The offer-receipt extension requires CAIP-2 in the signed payload whatever the protocol version:
4
+ * "Servers MUST convert v1 network identifiers (e.g., "base-sepolia") to CAIP-2 format (e.g.,
5
+ * "eip155:84532") in the receipt payload" (x402-foundation/x402 specs/extensions/
6
+ * extension-offer-and-receipt.md, receipt section; present at main 6b6ee91fee02, read 2026-09-29).
7
+ * A v1 settlement response carries the v1 name (`"base"`), so a receipt built from it without this
8
+ * step signs a payload that fails the spec.
9
+ *
10
+ * The EVM table is upstream `@x402/evm`'s `EVM_NETWORK_CHAIN_ID_MAP`, copied as written
11
+ * (typescript/packages/mechanisms/evm/src/constants.ts, blob ab7db42cc1c0: main 6b6ee91fee02, last
12
+ * changed a9955ae5538e, read 2026-09-29). Those 24 names are the ones upstream's v1 EVM facilitator
13
+ * settles and reports back as `network`, so each can reach a receipt. The offer-receipt reference's
14
+ * own table (typescript/packages/extensions/src/offer-receipt/signing.ts `V1_EVM_NETWORK_CHAIN_IDS`,
15
+ * same commit) holds 17 of them, with the same ids. The Solana table is signing.ts
16
+ * `V1_SOLANA_NETWORKS`, identical to `@x402/svm`'s `V1_TO_V2_NETWORK_MAP`
17
+ * (mechanisms/svm/src/constants.ts, same commit). The rules are signing.ts
18
+ * `convertNetworkStringToCAIP2`'s: a string containing `:` passes through unchanged, a known v1 name
19
+ * is looked up case-insensitively, anything else throws. One difference: the lookup reads own keys
20
+ * only, so an inherited object key such as `constructor` throws instead of resolving. Re-check
21
+ * against both upstream files when a v1 network is added there; test/network.test.ts pins both
22
+ * tables by exact equality.
23
+ *
24
+ * The tables are exported for that test only; the package entry re-exports `toCaip2Network`, not
25
+ * them. */
26
+ export const V1_EVM_NETWORK_CHAIN_IDS = {
27
+ ethereum: 1,
28
+ sepolia: 11155111,
29
+ abstract: 2741,
30
+ 'abstract-testnet': 11124,
31
+ 'base-sepolia': 84532,
32
+ base: 8453,
33
+ 'avalanche-fuji': 43113,
34
+ avalanche: 43114,
35
+ iotex: 4689,
36
+ sei: 1329,
37
+ 'sei-testnet': 1328,
38
+ polygon: 137,
39
+ 'polygon-amoy': 80002,
40
+ peaq: 3338,
41
+ story: 1514,
42
+ educhain: 41923,
43
+ 'skale-base-sepolia': 324705682,
44
+ megaeth: 4326,
45
+ monad: 143,
46
+ 'monad-testnet': 10143,
47
+ stable: 988,
48
+ 'stable-testnet': 2201,
49
+ celo: 42220,
50
+ flare: 14,
51
+ };
52
+ export const V1_SOLANA_NETWORKS = {
53
+ solana: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
54
+ 'solana-devnet': 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1',
55
+ 'solana-testnet': 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z',
56
+ };
57
+ /** A CAIP-2 identifier passes through; a known x402 v1 name (`"base"`, `"base-sepolia"`,
58
+ * `"solana"`, …) becomes its CAIP-2 form; anything else throws, because a receipt cannot carry
59
+ * it. */
60
+ export function toCaip2Network(network) {
61
+ if (network.includes(':'))
62
+ return network;
63
+ const key = network.toLowerCase();
64
+ const chainId = Object.hasOwn(V1_EVM_NETWORK_CHAIN_IDS, key) ? V1_EVM_NETWORK_CHAIN_IDS[key] : undefined;
65
+ if (chainId !== undefined)
66
+ return `eip155:${chainId}`;
67
+ const solana = Object.hasOwn(V1_SOLANA_NETWORKS, key) ? V1_SOLANA_NETWORKS[key] : undefined;
68
+ if (solana !== undefined)
69
+ return solana;
70
+ throw new Error(`Unknown network identifier: "${network}". A receipt needs CAIP-2 (e.g. "eip155:8453") or an x402 v1 name (e.g. "base", "solana").`);
71
+ }
package/dist/types.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /** Wire types for the merged x402 `offer-receipt` extension (spec: x402-foundation/x402
2
- * specs/extensions/extension-offer-and-receipt.md, fetched 2026-07-07). The wire shape is
3
- * declared unstable upstream; everything outside this module treats these as opaque via
4
- * the codec functions, so upstream churn lands here only. */
2
+ * specs/extensions/extension-offer-and-receipt.md, fetched 2026-07-07; receipt payload fields
3
+ * and response placement re-read 2026-09-29 at main 5eee1e3c35). Everything outside this module
4
+ * treats these as opaque via the codec functions, so upstream churn lands here only. */
5
5
  export interface ReceiptPayload {
6
6
  version: 1;
7
7
  /** CAIP-2, e.g. "eip155:8453" */
@@ -36,9 +36,10 @@ export type SignedArtifact<P> = {
36
36
  };
37
37
  export type SignedReceipt = SignedArtifact<ReceiptPayload>;
38
38
  export type SignedOffer = SignedArtifact<OfferPayload>;
39
- /** ACP/UCP converged adjustment vocabulary (verified against both specs 2026-07-07).
40
- * Adopted verbatim so records round-trip card-rail order objects unchanged. */
41
- export type AdjustmentType = 'refund' | 'return' | 'credit' | 'price_adjustment' | 'dispute' | 'cancellation';
39
+ /** Adjustment vocabulary as the proposed compliance-fields extension takes it from UCP: `type` is
40
+ * an OPEN string whose typical values are listed here, and only `status` is a fixed enum — so any
41
+ * other `type` string is accepted. */
42
+ export type AdjustmentType = 'refund' | 'return' | 'credit' | 'price_adjustment' | 'dispute' | 'cancellation' | (string & {});
42
43
  export type AdjustmentStatus = 'pending' | 'completed' | 'failed';
43
44
  export interface Adjustment {
44
45
  type: AdjustmentType;
@@ -50,15 +51,24 @@ export interface Adjustment {
50
51
  /** digest of the receipt/record this adjusts — the hash-chain link */
51
52
  adjusts: `0x${string}`;
52
53
  }
53
- /** Tersign compliance record v1 — a SEPARATE artifact bound to the base receipt by digest.
54
- * The base receipt's EIP-712 schema is fixed upstream; extending it would break signatures,
55
- * so compliance data composes by reference. MINIMAL tier ≈ EU VAT Art 226b simplified-invoice
56
- * content (legally sufficient sub-€100); FULL tier adds EN 16931-aligned fields. */
54
+ /** Compliance record v1 in the shape the `compliance-fields` extension proposes
55
+ * (x402-foundation/x402#2853, open — not settled spec) — a SEPARATE artifact bound to the base
56
+ * receipt by digest. The base receipt's EIP-712 schema is fixed upstream; extending it would
57
+ * break signatures, so compliance data composes by reference. The MINIMAL tier is isomorphic to
58
+ * the EU VAT Art 226b simplified-invoice content; whether a MINIMAL record suffices as an invoice
59
+ * is decided by the invoicing rules that apply to the supply (Art 219a), not by this record.
60
+ * FULL adds EN 16931-aligned fields. */
57
61
  export interface ComplianceRecordV1 {
58
62
  version: 1;
63
+ /** 1 = RFC 8785 (JCS) serialization, recordDigest = keccak256(utf8(canonical(record))).
64
+ * A MINIMAL member of the proposed extension; `buildMinimalRecord` sets it. Records built by
65
+ * tersign ≤0.5 omit it — readers accept both. */
66
+ canonicalizationVersion?: 1;
59
67
  /** keccak256 of the canonicalized base receipt artifact */
60
68
  receiptDigest: `0x${string}`;
61
- /** sequential per issuer (Art 226(2)); assigned by the ledger when countersigned */
69
+ /** sequential number per issuer series (Art 226(2)), assigned by the ISSUER before the record
70
+ * is attested. A sequence attested only by its issuer evidences ordering only — never that no
71
+ * record was omitted. `buildMinimalRecord` does not set it. */
62
72
  seq?: number;
63
73
  issuedAt: number;
64
74
  issuer: {
@@ -109,7 +119,10 @@ export interface ComplianceRecordV1 {
109
119
  /** hash-chain to the record this corrects/refunds (Art 226b(e); ViDA corrective-invoice ref) */
110
120
  refundOf?: `0x${string}`;
111
121
  adjustment?: Adjustment;
112
- /** retention floor in years; default 7 (HK IRO s.51C ≥ MiCA 5+2) */
122
+ /** retention floor in years the issuer commits to; set it to at least the longest period
123
+ * that applies to the issuer (Art 247(1) leaves it to each Member State — e.g. DE § 14b UStG:
124
+ * eight years). 7 is the default where no longer period applies (HK IRO s.51C, MiCA 5+2), not
125
+ * a statement that 7 suffices. */
113
126
  retentionYears: number;
114
127
  }
115
128
  /** EIP-712-signed attestation over a compliance record. The record itself is JSON (schema may
package/dist/types.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Wire types for the merged x402 `offer-receipt` extension (spec: x402-foundation/x402
2
- * specs/extensions/extension-offer-and-receipt.md, fetched 2026-07-07). The wire shape is
3
- * declared unstable upstream; everything outside this module treats these as opaque via
4
- * the codec functions, so upstream churn lands here only. */
2
+ * specs/extensions/extension-offer-and-receipt.md, fetched 2026-07-07; receipt payload fields
3
+ * and response placement re-read 2026-09-29 at main 5eee1e3c35). Everything outside this module
4
+ * treats these as opaque via the codec functions, so upstream churn lands here only. */
5
5
  export {};
@@ -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;