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.
- package/README.md +44 -20
- package/dist/adapter/x402.d.ts +59 -3
- package/dist/adapter/x402.js +168 -34
- package/dist/assure.d.ts +69 -7
- package/dist/assure.js +74 -16
- package/dist/canonical.d.ts +27 -0
- package/dist/canonical.js +46 -5
- package/dist/cli.js +7 -3
- package/dist/compliance/record.d.ts +18 -6
- package/dist/compliance/record.js +38 -14
- package/dist/index.d.ts +7 -4
- package/dist/index.js +6 -3
- package/dist/ledgerClient.d.ts +3 -0
- package/dist/ledgerClient.js +2 -0
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.js +28 -24
- package/dist/mcp/tools.d.ts +22 -4
- package/dist/mcp/tools.js +53 -5
- package/dist/receipt/binding.d.ts +75 -0
- package/dist/receipt/binding.js +161 -0
- package/dist/receipt/eip712.d.ts +23 -5
- package/dist/receipt/eip712.js +74 -9
- package/dist/receipt/known-keys.d.ts +23 -0
- package/dist/receipt/known-keys.js +59 -0
- package/dist/receipt/network.d.ts +31 -0
- package/dist/receipt/network.js +71 -0
- package/dist/types.d.ts +25 -12
- package/dist/types.js +3 -3
- package/dist/verify-bin.js +212 -46
- package/dist/verify-report.d.ts +75 -0
- package/dist/verify-report.js +241 -0
- package/package.json +1 -1
|
@@ -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
|
|
3
|
-
*
|
|
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
|
-
/**
|
|
40
|
-
*
|
|
41
|
-
|
|
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
|
-
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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))
|
|
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;
|
|
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
|
|
3
|
-
*
|
|
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 {};
|
package/dist/verify-bin.js
CHANGED
|
@@ -1,43 +1,131 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/** tersign-verify — third-party receipt verification. No API key
|
|
3
|
-
* signature recovery
|
|
4
|
-
* the
|
|
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
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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 (
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
+
bytes = readFileSync(target);
|
|
67
165
|
}
|
|
68
|
-
catch {
|
|
69
|
-
|
|
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
|
|
176
|
+
let text;
|
|
72
177
|
try {
|
|
73
|
-
|
|
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
|
-
|
|
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
|
|
79
|
-
|
|
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(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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;
|