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.
- package/README.md +24 -13
- 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 +7 -1
- package/dist/compliance/record.js +28 -10
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/keystore.js +13 -0
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.js +22 -18
- package/dist/mcp/tools.d.ts +19 -2
- package/dist/mcp/tools.js +51 -3
- package/dist/receipt/binding.d.ts +72 -0
- package/dist/receipt/binding.js +158 -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/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
package/README.md
CHANGED
|
@@ -24,10 +24,19 @@ npx tersign verify 0xe5874f1ffe87f0a6dd9eb157730f67b86ee4538b125fe30fcc4e165213d
|
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
```text
|
|
27
|
-
ledger:
|
|
27
|
+
ledger: https://tersign.ai
|
|
28
|
+
reports: found, counter-signed chain intact (seller tersign-first, seq 1, …) — not checked locally
|
|
29
|
+
VALID (ledger-reported) — https://tersign.ai reports the record and its counter-signed chain; nothing was verified locally
|
|
28
30
|
```
|
|
29
31
|
|
|
30
|
-
`npx tersign verify <receipt.json | 0xdigest> [--
|
|
32
|
+
`npx tersign verify <receipt.json | 0xdigest> [--signer 0xaddr] [--ledger url]` takes a receipt file or a digest.
|
|
33
|
+
|
|
34
|
+
- A **receipt file** is checked locally and touches no network unless you add `--ledger`. The EIP-712 signature is recovered, and `--signer` compares it with the issuer's address, which you take from the issuer through a channel you trust, never from the receipt. Without `--signer` the signer is reported `UNAUTHENTICATED` and the verdict reads `VALID (signer UNAUTHENTICATED)`: recovery yields an address for any payload, so an edited receipt reaches that same line with a different address.
|
|
35
|
+
- A receipt signed with a **published test key** (the 20 Hardhat/Anvil default dev-mnemonic accounts, or private key 1, 2 or 3) is flagged `published test key` either way: anyone can produce that signature. Fields in the file that the signature does not cover are listed by name, and a signed field in another JSON type (`"version": "1"`) is refused. The signature must be the one canonical encoding (`0x` + 130 lower-case hex digits, v 27/28, low-s): a re-encoding of a genuine signature (v 0/1, upper-case hex, the high-s twin) also recovers the issuer, under a different digest, so it is refused.
|
|
36
|
+
- The file is a receipt, `{receipt, record}`, or an evidence-bundle record file (`records/NNNNNN.json`, verdict qualified `record artifact only`: its chain fields are not checked here). Anything else that nests a receipt — a second receipt at the top level, or any other field beside it — is refused, because a reader would take those fields for the receipt that was checked. Duplicate keys and non-integer numbers are refused too.
|
|
37
|
+
- A **digest** is looked up on the Tersign ledger unless `--ledger` names another; the answer is that ledger's own counter-signed chain check, nothing in it is re-verified locally, and the verdict says so (`VALID (ledger-reported)`). The ledger consulted is always printed. The local check is the receipt file with `--signer`.
|
|
38
|
+
|
|
39
|
+
Exit status: `0` valid (read the last line — for a file, a bare `VALID` means a bound, non-test-key signer on a receipt or `{receipt, record}`; a digest is always `VALID (ledger-reported)`), `1` invalid, `2` usage (an unknown or valueless flag, `--signer` with a digest, a missing or non-JSON file). Prefer raw HTTP? The same proof, no CLI:
|
|
31
40
|
|
|
32
41
|
```sh
|
|
33
42
|
curl https://tersign.ai/v1/receipts/0xe5874f1ffe87f0a6dd9eb157730f67b86ee4538b125fe30fcc4e165213dd3fc4/verify
|
|
@@ -41,7 +50,7 @@ Counter-signed evidence that your agent presented a disclosure — one command,
|
|
|
41
50
|
npx tersign disclose "You are chatting with an AI assistant." --medium chat --agent-id my-agent
|
|
42
51
|
```
|
|
43
52
|
|
|
44
|
-
The text is digested **locally** (only the digest travels — data-minimization by construction). Your key signs the record; the ledger counter-signs it into a per-signer hash chain whose head is submitted for Bitcoin anchoring on a six-hourly cron.
|
|
53
|
+
The text is digested **locally** (only the digest travels — data-minimization by construction). Your key signs the record; the ledger counter-signs it into a per-signer hash chain whose head is submitted for Bitcoin anchoring on a six-hourly cron. The first call self-provisions a free signer-keyed account bound set-once to your key, unless that key is already registered to an API-key ledger account (409: submit through that account) or the day's provisioning caps are reached (429). Key resolution: `TERSIGN_SELLER_KEY` env → macOS keychain `tersign-signer` → `~/.tersign/signer.key`, created on first use. Free tier is quota- and rate-limited — [limits](https://tersign.ai/pricing). What this is: independently verifiable evidence the disclosure was attested at that time. What it is not: a compliance certification.
|
|
45
54
|
|
|
46
55
|
## Chain of Custody
|
|
47
56
|
|
|
@@ -112,25 +121,27 @@ when that stabilizes. It makes no conformance claim to that draft.
|
|
|
112
121
|
"mcpServers": {
|
|
113
122
|
"tersign": {
|
|
114
123
|
"command": "npx",
|
|
115
|
-
"args": ["tersign"]
|
|
116
|
-
"env": { "TERSIGN_SELLER_KEY": "0x<your-seller-key>" }
|
|
124
|
+
"args": ["tersign"]
|
|
117
125
|
}
|
|
118
126
|
}
|
|
119
127
|
}
|
|
120
128
|
```
|
|
121
129
|
|
|
130
|
+
No configuration is needed. With `TERSIGN_SELLER_KEY` unset or empty, the server signs with the key in the macOS keychain (`tersign-signer`) or `~/.tersign/signer.key`, and generates one there on first run; the key never leaves your machine. Set `TERSIGN_SELLER_KEY` only to bring your own.
|
|
131
|
+
|
|
122
132
|
**Tools** — `issue_receipt` · `verify_receipt` · `verify_compliance_record` · `record_disclosure` · `record_refund` · `open_dispute` · `submit_dispute_evidence` · `adjudicate_dispute` · `get_dispute`
|
|
123
133
|
|
|
124
134
|
| Env var | Required | Purpose |
|
|
125
135
|
|---|---|---|
|
|
126
|
-
| `TERSIGN_SELLER_KEY` |
|
|
127
|
-
| `TERSIGN_LEDGER_URL` | no |
|
|
128
|
-
| `TERSIGN_LEDGER_API_KEY` | no | your seller API key on that ledger |
|
|
129
|
-
| `TERSIGN_LEDGER_SELLER_ID` | no | your seller id on that ledger |
|
|
136
|
+
| `TERSIGN_SELLER_KEY` | no | your own 0x-prefixed signing key; unset or empty, the keychain or keyfile key is used (generated on first run). If your ledger account has a registered signing key, set that key here: the ledger rejects respondent dispute evidence signed by any other key |
|
|
137
|
+
| `TERSIGN_LEDGER_URL` | no | ledger for counter-signing + chain checks; needed by the dispute tools; `record_disclosure` defaults to `https://tersign.ai` |
|
|
138
|
+
| `TERSIGN_LEDGER_API_KEY` | no | your seller API key on that ledger; with the seller id, enables `record_refund` and chained `issue_receipt`; also authenticates respondent dispute evidence |
|
|
139
|
+
| `TERSIGN_LEDGER_SELLER_ID` | no | your seller id on that ledger; with the API key, enables `record_refund` and chained `issue_receipt` |
|
|
130
140
|
| `TERSIGN_ISSUER_NAME` | no | issuer name stamped on action records |
|
|
131
141
|
| `TERSIGN_ISSUER_JURISDICTION` | no | issuer jurisdiction stamped on action records |
|
|
142
|
+
| `TERSIGN_ISSUER_TAX_ID` | no | issuer tax / business-registration id stamped on action records |
|
|
132
143
|
|
|
133
|
-
Cold to counter-signed in one session: call `
|
|
144
|
+
Cold to counter-signed in one session, with no configuration: call `record_disclosure`, then check the `digest` it returns with `npx tersign verify <digest>` (a bare digest checks against https://tersign.ai). `issue_receipt` counter-signs as well once `TERSIGN_LEDGER_URL`, `TERSIGN_LEDGER_API_KEY` and `TERSIGN_LEDGER_SELLER_ID` are set; without them it returns a locally signed, unchained receipt.
|
|
134
145
|
|
|
135
146
|
The agent skill `tersign-evidence` ships at [tersignhq/skills](https://github.com/tersignhq/skills).
|
|
136
147
|
|
|
@@ -148,7 +159,7 @@ Full URLs, readable without auth. If you are an agent, start here.
|
|
|
148
159
|
| Surface | Address |
|
|
149
160
|
|---|---|
|
|
150
161
|
| npm package | `tersign` — https://www.npmjs.com/package/tersign |
|
|
151
|
-
| MCP registry | `io.github.tersignhq/evidence` — `npx tersign` needs no configuration;
|
|
162
|
+
| MCP registry | `io.github.tersignhq/evidence` — `npx tersign` needs no configuration; `record_disclosure`'s first call self-provisions a signer-keyed account, unless the key is registered to an API-key account (409) or the daily provisioning caps are reached (429) |
|
|
152
163
|
| ARD catalog (Agentic Resource Discovery) | https://tersign.ai/.well-known/ai-catalog.json |
|
|
153
164
|
| Verify API | `GET https://tersign.ai/v1/receipts/{digest}/verify` |
|
|
154
165
|
| Envelope API | `GET https://tersign.ai/v1/receipts/{digest}/envelope?venue={internet-court\|kleros\|uma\|generic}` |
|
|
@@ -156,7 +167,7 @@ Full URLs, readable without auth. If you are an agent, start here.
|
|
|
156
167
|
| Ledger signer | `GET https://tersign.ai/v1/ledger` |
|
|
157
168
|
| Bundle verifier, out-of-band | https://tersign.ai/verify/v1/ — `verify_bundle.py` · `keccak.py` · `secp256k1.py` · `SHA256SUMS`. A bundle ships its own checker; for evidence from an interested party fetch this copy and diff the two. |
|
|
158
169
|
| llms.txt | https://raw.githubusercontent.com/tersignhq/tersign-js/main/llms.txt |
|
|
159
|
-
| Conformance vectors (RFC 8785 + keccak256) | https://github.com/tersignhq/
|
|
170
|
+
| Conformance vectors (RFC 8785 + keccak256, two-sided) | https://github.com/tersignhq/evidence-record-conformance |
|
|
160
171
|
| Sample action record + digests | https://github.com/tersignhq/tersign-js/blob/main/test/fixtures/compliance-record.json |
|
|
161
172
|
| Genesis verify | `npx tersign verify 0xe5874f1ffe87f0a6dd9eb157730f67b86ee4538b125fe30fcc4e165213dd3fc4` |
|
|
162
173
|
|
|
@@ -189,7 +200,7 @@ If a maintainer ever asks you to reopen work elsewhere because of this, that is
|
|
|
189
200
|
<img src="https://raw.githubusercontent.com/tersignhq/.github/main/assets/seal.svg" alt="Tersign seal" width="72">
|
|
190
201
|
</p>
|
|
191
202
|
|
|
192
|
-
<p align="center"><sub>MIT · built and published from <a href="https://github.com/tersignhq/tersign-js">tersignhq/tersign-js</a> via trusted-publishing CI, provenance attested · <code>tersign</code
|
|
203
|
+
<p align="center"><sub>MIT · built and published from <a href="https://github.com/tersignhq/tersign-js">tersignhq/tersign-js</a> via trusted-publishing CI, provenance attested · Python verifier: <code>pip install tersign</code></sub></p>
|
|
193
204
|
|
|
194
205
|
<p align="center"><sub><b>Venues rotate. The transcript endures.</b></sub></p>
|
|
195
206
|
|
package/dist/canonical.d.ts
CHANGED
|
@@ -8,6 +8,33 @@ import { type Hex } from 'viem';
|
|
|
8
8
|
* sort-then-stringify round-trip (JCS orders them "1","10","2"). Byte-identical to the old
|
|
9
9
|
* serializer for every shape without integer-like or control-char keys — the genesis receipt
|
|
10
10
|
* digest and all pinned wire vectors are unchanged. */
|
|
11
|
+
/** Nesting budget for untrusted inputs, byte-for-byte the ledger's MAX_CANONICAL_DEPTH and
|
|
12
|
+
* canonical.py's MAX_DEPTH. The SDK copy shipped without one: a 65-deep value digested here
|
|
13
|
+
* and was refused by every Python verifier, so the SDK could sign a record no independent
|
|
14
|
+
* checker could ever verify. */
|
|
15
|
+
export declare const MAX_CANONICAL_DEPTH = 64;
|
|
16
|
+
export declare class CanonicalDepthError extends Error {
|
|
17
|
+
constructor();
|
|
18
|
+
}
|
|
19
|
+
/** The digest domain is ints/strings/bools/null/objects/arrays — canonical.py has said so
|
|
20
|
+
* since it was written; this side simply never enforced it, and `JSON.stringify` is not a
|
|
21
|
+
* specification. Two consequences, both confirmed against the running code:
|
|
22
|
+
*
|
|
23
|
+
* digestOf({amount: 9007199254740993}) === digestOf({amount: 9007199254740992})
|
|
24
|
+
* digestOf({n: 1e400}) === digestOf({n: null})
|
|
25
|
+
*
|
|
26
|
+
* Distinct records, identical bytes, identical digest, identical signature. For a system whose
|
|
27
|
+
* whole claim is "this digest commits to this record", a non-injective canonical form is the
|
|
28
|
+
* deepest defect available, and it is reachable: 2^53-1 is about 9.007e15, below any
|
|
29
|
+
* 18-decimal token amount over 0.009 ETH and below any nanosecond timestamp.
|
|
30
|
+
*
|
|
31
|
+
* Floats are rejected for the reason canonical.py already states — the domain boundary is the
|
|
32
|
+
* number TOKEN, and a float that survives a parse round-trip is precisely the divergence class
|
|
33
|
+
* the conformance suite exists to kill. Number.isSafeInteger settles all four cases at once:
|
|
34
|
+
* non-integer, |n| >= 2^53, NaN and Infinity. */
|
|
35
|
+
export declare class CanonicalDomainError extends Error {
|
|
36
|
+
constructor(value: number);
|
|
37
|
+
}
|
|
11
38
|
export declare function canonicalStringify(value: unknown): string;
|
|
12
39
|
export declare function digestOf(value: unknown): `0x${string}`;
|
|
13
40
|
/** Hash-chain link, byte-identical to the hosted ledger's recompute: the ledger
|
package/dist/canonical.js
CHANGED
|
@@ -8,26 +8,67 @@ import { concatHex, keccak256, numberToHex, stringToHex, toBytes } from 'viem';
|
|
|
8
8
|
* sort-then-stringify round-trip (JCS orders them "1","10","2"). Byte-identical to the old
|
|
9
9
|
* serializer for every shape without integer-like or control-char keys — the genesis receipt
|
|
10
10
|
* digest and all pinned wire vectors are unchanged. */
|
|
11
|
+
/** Nesting budget for untrusted inputs, byte-for-byte the ledger's MAX_CANONICAL_DEPTH and
|
|
12
|
+
* canonical.py's MAX_DEPTH. The SDK copy shipped without one: a 65-deep value digested here
|
|
13
|
+
* and was refused by every Python verifier, so the SDK could sign a record no independent
|
|
14
|
+
* checker could ever verify. */
|
|
15
|
+
export const MAX_CANONICAL_DEPTH = 64;
|
|
16
|
+
export class CanonicalDepthError extends Error {
|
|
17
|
+
constructor() {
|
|
18
|
+
super(`value nests deeper than ${MAX_CANONICAL_DEPTH} levels`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** The digest domain is ints/strings/bools/null/objects/arrays — canonical.py has said so
|
|
22
|
+
* since it was written; this side simply never enforced it, and `JSON.stringify` is not a
|
|
23
|
+
* specification. Two consequences, both confirmed against the running code:
|
|
24
|
+
*
|
|
25
|
+
* digestOf({amount: 9007199254740993}) === digestOf({amount: 9007199254740992})
|
|
26
|
+
* digestOf({n: 1e400}) === digestOf({n: null})
|
|
27
|
+
*
|
|
28
|
+
* Distinct records, identical bytes, identical digest, identical signature. For a system whose
|
|
29
|
+
* whole claim is "this digest commits to this record", a non-injective canonical form is the
|
|
30
|
+
* deepest defect available, and it is reachable: 2^53-1 is about 9.007e15, below any
|
|
31
|
+
* 18-decimal token amount over 0.009 ETH and below any nanosecond timestamp.
|
|
32
|
+
*
|
|
33
|
+
* Floats are rejected for the reason canonical.py already states — the domain boundary is the
|
|
34
|
+
* number TOKEN, and a float that survives a parse round-trip is precisely the divergence class
|
|
35
|
+
* the conformance suite exists to kill. Number.isSafeInteger settles all four cases at once:
|
|
36
|
+
* non-integer, |n| >= 2^53, NaN and Infinity. */
|
|
37
|
+
export class CanonicalDomainError extends Error {
|
|
38
|
+
constructor(value) {
|
|
39
|
+
super(`${Number.isFinite(value) ? value : String(value)} is outside the Tersign digest domain ` +
|
|
40
|
+
'(integers within +/-(2^53-1) only: no floats, NaN or Infinity)');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
11
43
|
export function canonicalStringify(value) {
|
|
12
|
-
return serialize(value);
|
|
44
|
+
return serialize(value, 0);
|
|
13
45
|
}
|
|
14
|
-
function serialize(value) {
|
|
46
|
+
function serialize(value, depth) {
|
|
47
|
+
if (depth > MAX_CANONICAL_DEPTH)
|
|
48
|
+
throw new CanonicalDepthError();
|
|
15
49
|
if (value === null)
|
|
16
50
|
return 'null';
|
|
17
51
|
if (Array.isArray(value)) {
|
|
18
|
-
return '[' + Array.from(value, (v) => serialize(v) ?? 'null').join(',') + ']';
|
|
52
|
+
return '[' + Array.from(value, (v) => serialize(v, depth + 1) ?? 'null').join(',') + ']';
|
|
19
53
|
}
|
|
20
54
|
if (typeof value === 'object') {
|
|
21
55
|
const obj = value;
|
|
22
56
|
const parts = [];
|
|
23
57
|
for (const key of Object.keys(obj).sort()) {
|
|
24
|
-
const s = serialize(obj[key]);
|
|
58
|
+
const s = serialize(obj[key], depth + 1);
|
|
25
59
|
if (s !== undefined)
|
|
26
60
|
parts.push(JSON.stringify(key) + ':' + s);
|
|
27
61
|
}
|
|
28
62
|
return '{' + parts.join(',') + '}';
|
|
29
63
|
}
|
|
30
|
-
//
|
|
64
|
+
// Numbers carry the domain guard: JSON.stringify would silently round 2^53+1 down and fold
|
|
65
|
+
// Infinity to null, and both of those are collisions, not encodings.
|
|
66
|
+
if (typeof value === 'number') {
|
|
67
|
+
if (!Number.isSafeInteger(value))
|
|
68
|
+
throw new CanonicalDomainError(value);
|
|
69
|
+
return String(value);
|
|
70
|
+
}
|
|
71
|
+
// string/boolean serialize per JCS; undefined/function/symbol yield undefined (dropped)
|
|
31
72
|
return JSON.stringify(value);
|
|
32
73
|
}
|
|
33
74
|
export function digestOf(value) {
|
package/dist/cli.js
CHANGED
|
@@ -16,8 +16,10 @@ if (sub === undefined || sub === 'mcp') {
|
|
|
16
16
|
console.error('tersign: this starts the MCP server, which speaks JSON-RPC over stdin — nothing to see\n' +
|
|
17
17
|
'at a prompt. Wire it into your MCP client config:\n\n' +
|
|
18
18
|
' { "mcpServers": { "tersign": { "command": "npx", "args": ["tersign"] } } }\n\n' +
|
|
19
|
-
'No key needed:
|
|
20
|
-
'
|
|
19
|
+
'No key needed: a key is generated and kept in the OS keychain (else ~/.tersign/signer.key),\n' +
|
|
20
|
+
"and record_disclosure's first call self-provisions a signer-keyed account on the ledger,\n" +
|
|
21
|
+
'unless that key is registered to an API-key account (409) or the daily provisioning caps\n' +
|
|
22
|
+
'are reached (429). Set TERSIGN_SELLER_KEY to use your own key.\n\n' +
|
|
21
23
|
"Just exploring? Try: tersign help · tersign verify <receipt.json | 0xdigest> [--ledger url]");
|
|
22
24
|
process.exit(1);
|
|
23
25
|
}
|
|
@@ -40,7 +42,9 @@ else if (sub === 'help' || sub === '--help' || sub === '-h') {
|
|
|
40
42
|
' tersign start the MCP server (stdio)\n' +
|
|
41
43
|
' tersign mcp same, explicit\n' +
|
|
42
44
|
' tersign verify <receipt.json | 0xdigest> [--signer 0xaddr] [--ledger url]\n' +
|
|
43
|
-
' verify a receipt
|
|
45
|
+
' verify a receipt FILE locally: --signer binds it to the\n' +
|
|
46
|
+
" issuer's address (without it the signer is reported\n" +
|
|
47
|
+
' UNAUTHENTICATED); or ask a ledger about a DIGEST\n' +
|
|
44
48
|
' tersign disclose "<text>" [--medium chat] [--agent-id id] [--url resourceUrl]\n' +
|
|
45
49
|
' counter-signed disclosure evidence — text digested locally,\n' +
|
|
46
50
|
' only the digest travels; key created on first use\n' +
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Account } from 'viem/accounts';
|
|
2
2
|
import type { Adjustment, ComplianceRecordV1, SignedComplianceRecord, SignedReceipt, VerifyLike } from './types.js';
|
|
3
|
+
import { type SignerBinding } from '../receipt/binding.js';
|
|
3
4
|
/** Canonical domain per the compliance-fields extension spec (x402-foundation/x402#2853).
|
|
4
5
|
* Migrated from the vendor domain 'tersign compliance-record' on 2026-07-14 while ZERO
|
|
5
6
|
* production compliance records existed — a free change then, a breaking wire change after. */
|
|
@@ -49,4 +50,9 @@ export interface MinimalRecordInput {
|
|
|
49
50
|
export declare function buildMinimalRecord(issuer: IssuerConfig, input: MinimalRecordInput): ComplianceRecordV1;
|
|
50
51
|
export declare function recordDigest(record: ComplianceRecordV1): `0x${string}`;
|
|
51
52
|
export declare function signComplianceRecord(record: ComplianceRecordV1, account: Account): Promise<SignedComplianceRecord>;
|
|
52
|
-
|
|
53
|
+
/** VerifyLike plus the signer binding: signerBound is true only when expectedSigner was supplied
|
|
54
|
+
* and matched; testKey flags a published test key. Without a bound signer a PASS proves the
|
|
55
|
+
* record and attestation are internally consistent and nothing else — anyone can edit a record,
|
|
56
|
+
* recompute its digest and re-sign the attestation with their own key. */
|
|
57
|
+
export type RecordVerifyResult = VerifyLike & SignerBinding;
|
|
58
|
+
export declare function verifyComplianceRecord(signed: SignedComplianceRecord, expectedSigner?: string): Promise<RecordVerifyResult>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { recoverTypedDataAddress } from 'viem';
|
|
2
2
|
import { digestOf } from '../canonical.js';
|
|
3
|
+
import { bindSigner, isPlainObject, parseExpectedSigner, signatureError } from '../receipt/binding.js';
|
|
3
4
|
/** Canonical domain per the compliance-fields extension spec (x402-foundation/x402#2853).
|
|
4
5
|
* Migrated from the vendor domain 'tersign compliance-record' on 2026-07-14 while ZERO
|
|
5
6
|
* production compliance records existed — a free change then, a breaking wire change after. */
|
|
@@ -71,17 +72,37 @@ export async function signComplianceRecord(record, account) {
|
|
|
71
72
|
return { record, attestation: { format: 'eip712', payload, signature } };
|
|
72
73
|
}
|
|
73
74
|
export async function verifyComplianceRecord(signed, expectedSigner) {
|
|
75
|
+
const expected = parseExpectedSigner(expectedSigner);
|
|
76
|
+
if (!expected.ok)
|
|
77
|
+
return { valid: false, signerBound: false, reason: expected.reason };
|
|
78
|
+
if (!isPlainObject(signed) ||
|
|
79
|
+
!isPlainObject(signed.record) ||
|
|
80
|
+
!isPlainObject(signed.attestation) ||
|
|
81
|
+
!isPlainObject(signed.attestation.payload)) {
|
|
82
|
+
return { valid: false, signerBound: false, reason: 'not a signed action record: expected {record, attestation{format, payload, signature}}' };
|
|
83
|
+
}
|
|
74
84
|
const { attestation, record } = signed;
|
|
75
85
|
if (attestation.format !== 'eip712')
|
|
76
|
-
return { valid: false, reason: 'jws not implemented in v0' };
|
|
77
|
-
|
|
78
|
-
|
|
86
|
+
return { valid: false, signerBound: false, reason: 'jws not implemented in v0' };
|
|
87
|
+
let digest;
|
|
88
|
+
try {
|
|
89
|
+
digest = recordDigest(record);
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
return { valid: false, signerBound: false, reason: `cannot compute the record digest: ${e instanceof Error ? e.message : String(e)}` };
|
|
93
|
+
}
|
|
94
|
+
if (attestation.payload.recordDigest !== digest) {
|
|
95
|
+
return { valid: false, signerBound: false, reason: 'record digest mismatch — record was altered after signing' };
|
|
79
96
|
}
|
|
80
97
|
if (attestation.payload.receiptDigest !== record.receiptDigest) {
|
|
81
|
-
return { valid: false, reason: 'attestation/receipt digest mismatch' };
|
|
98
|
+
return { valid: false, signerBound: false, reason: 'attestation/receipt digest mismatch' };
|
|
82
99
|
}
|
|
100
|
+
const badSig = signatureError(attestation.signature);
|
|
101
|
+
if (badSig)
|
|
102
|
+
return { valid: false, signerBound: false, reason: `attestation ${badSig}` };
|
|
103
|
+
let signer;
|
|
83
104
|
try {
|
|
84
|
-
|
|
105
|
+
signer = await recoverTypedDataAddress({
|
|
85
106
|
domain: COMPLIANCE_DOMAIN,
|
|
86
107
|
types: COMPLIANCE_TYPES,
|
|
87
108
|
primaryType: 'ComplianceAttestation',
|
|
@@ -93,12 +114,9 @@ export async function verifyComplianceRecord(signed, expectedSigner) {
|
|
|
93
114
|
},
|
|
94
115
|
signature: attestation.signature,
|
|
95
116
|
});
|
|
96
|
-
if (expectedSigner && signer.toLowerCase() !== expectedSigner.toLowerCase()) {
|
|
97
|
-
return { valid: false, signer, reason: 'unexpected signer' };
|
|
98
|
-
}
|
|
99
|
-
return { valid: true, signer };
|
|
100
117
|
}
|
|
101
118
|
catch (e) {
|
|
102
|
-
return { valid: false, reason: e instanceof Error ? e.message : 'signature recovery failed' };
|
|
119
|
+
return { valid: false, signerBound: false, reason: e instanceof Error ? e.message : 'signature recovery failed' };
|
|
103
120
|
}
|
|
121
|
+
return bindSigner(signer, expected.value, 'unexpected signer');
|
|
104
122
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export * from './types.js';
|
|
2
2
|
export { canonicalStringify, digestOf, GENESIS_DIGEST, chainLinkDigest, CHAIN_COMMITMENT_SCHEMA, ACC_GENESIS, chainAccumulatorStep, chainCommitment, commitmentDigest, foldAccumulator, verifyCommitment, ChainIntegrityError, type ChainCommitment, type ChainRecordLike, type CommitmentVerifyResult, } from './canonical.js';
|
|
3
|
-
export { RECEIPT_DOMAIN, RECEIPT_TYPES, OFFER_DOMAIN, OFFER_TYPES, signReceipt, signOffer, verifyReceipt, type VerifyResult, } from './receipt/eip712.js';
|
|
4
|
-
export {
|
|
3
|
+
export { RECEIPT_DOMAIN, RECEIPT_TYPES, OFFER_DOMAIN, OFFER_TYPES, signReceipt, signOffer, verifyReceipt, SIGNED_RECEIPT_FIELDS, type VerifyResult, } from './receipt/eip712.js';
|
|
4
|
+
export { PUBLISHED_TEST_KEYS, publishedKeyLabel } from './receipt/known-keys.js';
|
|
5
|
+
export { signerStatus, type SignerBinding, type SignerStatus } from './receipt/binding.js';
|
|
6
|
+
export { COMPLIANCE_DOMAIN, COMPLIANCE_TYPES, COMPLIANCE_WIRE_VECTOR, buildMinimalRecord, recordDigest, signComplianceRecord, verifyComplianceRecord, type RecordVerifyResult, type IssuerConfig, type MinimalRecordInput, } from './compliance/record.js';
|
|
5
7
|
export { MemoryIdempotencyStore, checkIdempotency, extractPaymentId, fingerprint, REPLAY_HEADER, type IdempotencyStore, type IdempotencyOutcome, type IdempotencyOptions, type CachedResponse, type FingerprintParts, } from './idempotency/middleware.js';
|
|
6
8
|
export { D1IdempotencyStore, D1_IDEMPOTENCY_DDL, type D1Like } from './idempotency/d1.js';
|
|
7
9
|
export type { DisputeReason, DisputeVerdict, DisputeStatus, DisputePayloadV1, CriterionV1, AcceptanceCriteriaV1, EvidenceArtifactRef, EvidencePayloadV1, DisputeAttestationPayload, EvidenceAttestationPayload, CriteriaAttestationPayload, SignedDispute, SignedEvidence, SignedCriteria, } from './dispute/types.js';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export * from './types.js';
|
|
2
2
|
export { canonicalStringify, digestOf, GENESIS_DIGEST, chainLinkDigest, CHAIN_COMMITMENT_SCHEMA, ACC_GENESIS, chainAccumulatorStep, chainCommitment, commitmentDigest, foldAccumulator, verifyCommitment, ChainIntegrityError, } from './canonical.js';
|
|
3
|
-
export { RECEIPT_DOMAIN, RECEIPT_TYPES, OFFER_DOMAIN, OFFER_TYPES, signReceipt, signOffer, verifyReceipt, } from './receipt/eip712.js';
|
|
3
|
+
export { RECEIPT_DOMAIN, RECEIPT_TYPES, OFFER_DOMAIN, OFFER_TYPES, signReceipt, signOffer, verifyReceipt, SIGNED_RECEIPT_FIELDS, } from './receipt/eip712.js';
|
|
4
|
+
export { PUBLISHED_TEST_KEYS, publishedKeyLabel } from './receipt/known-keys.js';
|
|
5
|
+
export { signerStatus } from './receipt/binding.js';
|
|
4
6
|
export { COMPLIANCE_DOMAIN, COMPLIANCE_TYPES, COMPLIANCE_WIRE_VECTOR, buildMinimalRecord, recordDigest, signComplianceRecord, verifyComplianceRecord, } from './compliance/record.js';
|
|
5
7
|
export { MemoryIdempotencyStore, checkIdempotency, extractPaymentId, fingerprint, REPLAY_HEADER, } from './idempotency/middleware.js';
|
|
6
8
|
export { D1IdempotencyStore, D1_IDEMPOTENCY_DDL } from './idempotency/d1.js';
|
package/dist/keystore.js
CHANGED
|
@@ -25,6 +25,19 @@ function readKeychain() {
|
|
|
25
25
|
function writeKeychain(key) {
|
|
26
26
|
if (process.platform !== 'darwin')
|
|
27
27
|
return false;
|
|
28
|
+
// Preflight, added 2026-08-30. `security add-generic-password` does not merely FAIL when no
|
|
29
|
+
// login keychain is reachable — it raises a MODAL macOS dialog ("A keychain cannot be found
|
|
30
|
+
// to store …"), which blocks a headless run and ambushes anyone whose first command is
|
|
31
|
+
// `npx tersign`. Seen for real while probing first-run behaviour with a sandboxed HOME; the
|
|
32
|
+
// same shape hits a Mac CI runner, an ssh session with a locked keychain, and any sandbox.
|
|
33
|
+
// `security default-keychain` answers the question silently, so ask it before writing and
|
|
34
|
+
// fall through to the 0600 keyfile when the answer is no.
|
|
35
|
+
try {
|
|
36
|
+
execFileSync('security', ['default-keychain'], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
28
41
|
try {
|
|
29
42
|
execFileSync('security', ['add-generic-password', '-s', KEYCHAIN_SERVICE, '-a', userInfo().username, '-w', key, '-U'], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
30
43
|
return true;
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -8,6 +8,6 @@ export declare function envDeps(env?: Record<string, string | undefined>): McpDe
|
|
|
8
8
|
* every client; mcp.test.ts pins it against package.json so a release bump can't drift it. */
|
|
9
9
|
export declare const MCP_SERVER_IDENTITY: {
|
|
10
10
|
readonly name: "tersign";
|
|
11
|
-
readonly version: "0.
|
|
11
|
+
readonly version: "0.5.0";
|
|
12
12
|
};
|
|
13
13
|
export declare function buildServer(deps: McpDeps): McpServer;
|
package/dist/mcp/server.js
CHANGED
|
@@ -14,7 +14,11 @@ export function envDeps(env = process.env) {
|
|
|
14
14
|
// that first call self-provisions a signer-keyed account; before this the MCP entry point threw
|
|
15
15
|
// instead, so `npx tersign` died on first run for anyone who had not already exported a key —
|
|
16
16
|
// and no directory or sandbox could introspect the server at all.
|
|
17
|
-
|
|
17
|
+
// `||`, not `??`: an EMPTY TERSIGN_SELLER_KEY means unset, exactly as the keystore reads it.
|
|
18
|
+
// The listing marks the key optional, and a client that fills a blank optional secret with ""
|
|
19
|
+
// (which clients do is unmeasured) got a crash: `??` handed "" to privateKeyToAccount
|
|
20
|
+
// (fixed 2026-09-27). test/listing.test.ts starts the registry command with the key set to "".
|
|
21
|
+
const key = env.TERSIGN_SELLER_KEY || resolveSignerKey({ create: true }).key;
|
|
18
22
|
const account = privateKeyToAccount(key);
|
|
19
23
|
const assure = new Assure({
|
|
20
24
|
signer: account,
|
|
@@ -49,14 +53,14 @@ function json(value) {
|
|
|
49
53
|
}
|
|
50
54
|
/** MUST match package.json name/version — the MCP handshake self-reports this identity to
|
|
51
55
|
* every client; mcp.test.ts pins it against package.json so a release bump can't drift it. */
|
|
52
|
-
export const MCP_SERVER_IDENTITY = { name: 'tersign', version: '0.
|
|
56
|
+
export const MCP_SERVER_IDENTITY = { name: 'tersign', version: '0.5.0' };
|
|
53
57
|
export function buildServer(deps) {
|
|
54
58
|
const server = new McpServer(MCP_SERVER_IDENTITY);
|
|
55
59
|
server.registerTool('issue_receipt', {
|
|
56
60
|
title: 'Issue signed receipt',
|
|
57
|
-
description: 'Issue an x402 offer-receipt (EIP-712) plus a Tersign
|
|
61
|
+
description: 'Issue an x402 offer-receipt (EIP-712) plus a Tersign compliance record (returned as `compliance`; verify_compliance_record checks it) for a payment that has ALREADY settled, and counter-sign both into your hash chain when a ledger is configured. ' +
|
|
58
62
|
'Use this for money that moved; use record_disclosure for a non-payment agent action. ' +
|
|
59
|
-
'Side effects: signs with TERSIGN_SELLER_KEY, and performs ONE network write to the ledger when TERSIGN_LEDGER_URL/_API_KEY/_SELLER_ID are set (without them it signs locally and returns an unchained artifact). ' +
|
|
63
|
+
'Side effects: signs with your signing key (TERSIGN_SELLER_KEY when set, else the one generated and kept locally on first run), and performs ONE network write to the ledger when TERSIGN_LEDGER_URL/_API_KEY/_SELLER_ID are set (without them it signs locally and returns an unchained artifact). ' +
|
|
60
64
|
'Returns the signed receipt artifact, its keccak256 canonical digest, and — when chained — the ledger counter-signature and sequence number.',
|
|
61
65
|
inputSchema: {
|
|
62
66
|
network: z.string().describe('settlement network as CAIP-2, e.g. "eip155:8453" for Base mainnet'),
|
|
@@ -78,10 +82,11 @@ export function buildServer(deps) {
|
|
|
78
82
|
}, async (args) => json(await issueReceiptTool(deps, args)));
|
|
79
83
|
server.registerTool('verify_receipt', {
|
|
80
84
|
title: 'Verify signed receipt',
|
|
81
|
-
description: 'Verify an offer-receipt artifact: recover the EIP-712 signature
|
|
82
|
-
'
|
|
83
|
-
'
|
|
84
|
-
'
|
|
85
|
+
description: 'Verify an x402 offer-receipt artifact, fully OFFLINE (no network, no API key, no account; checks no ledger or chain): recover the address whose key produced its EIP-712 signature over the six signed payload fields (version, network, resourceUrl, payer, issuedAt, transaction), and compute its canonical digest (the content address a ledger records it under). ' +
|
|
86
|
+
'Recovery yields SOME address for any payload, so without expectedSigner the signer is UNAUTHENTICATED: valid:true then proves neither who signed nor that the receipt is unmodified — an edited receipt recovers a different address and still returns valid:true. ' +
|
|
87
|
+
'Pass expectedSigner, the issuer\'s address obtained out-of-band, to bind it: valid:true then means the signed fields were signed by that key; a mismatch returns valid:false with the recovered signer so you can see who actually signed. ' +
|
|
88
|
+
'Use this for a receipt (money); use verify_compliance_record for the compliance record issue_receipt returns beside a receipt. ' +
|
|
89
|
+
'Returns { verdict, valid, signer, signerStatus: BOUND | UNAUTHENTICATED | MISMATCH, signerBound, testKey? (the signer is a PUBLISHED test key — anyone can sign as it), digest, unsignedFields? (present in the artifact but not covered by the signature), reason? }. Read verdict first.',
|
|
85
90
|
inputSchema: {
|
|
86
91
|
artifact: z
|
|
87
92
|
.record(z.unknown())
|
|
@@ -89,12 +94,12 @@ export function buildServer(deps) {
|
|
|
89
94
|
expectedSigner: z
|
|
90
95
|
.string()
|
|
91
96
|
.optional()
|
|
92
|
-
.describe('0x address the receipt MUST be signed by — obtain it out-of-band, never from the artifact. Omit to recover the signer without
|
|
97
|
+
.describe('0x address the receipt MUST be signed by — obtain it out-of-band, never from the artifact. Omit to recover the signer without binding it (it is then reported UNAUTHENTICATED)'),
|
|
93
98
|
},
|
|
94
99
|
}, async ({ artifact, expectedSigner }) => json(await verifyReceiptTool(artifact, expectedSigner)));
|
|
95
100
|
server.registerTool('record_disclosure', {
|
|
96
101
|
title: 'Record counter-signed disclosure',
|
|
97
|
-
description: 'One-call disclosure evidence (EU AI Act Art 50 dialect): digests the disclosure text LOCALLY, signs an action record with your key, and the public ledger counter-signs it into your per-signer hash chain. No API key needed — first call self-provisions a free signer-keyed account.',
|
|
102
|
+
description: 'One-call disclosure evidence (EU AI Act Art 50 dialect): digests the disclosure text LOCALLY, signs an action record with your key, and the public ledger counter-signs it into your per-signer hash chain. No API key needed — the first call self-provisions a free signer-keyed account, except when your key is already registered to an API-key account (409) or the daily provisioning caps are reached (429; https://tersign.ai/pricing).',
|
|
98
103
|
inputSchema: {
|
|
99
104
|
text: z.string().optional().describe('the disclosure text as presented — digested locally, never transmitted'),
|
|
100
105
|
textDigest: z.string().regex(/^0x[0-9a-fA-F]{64}$/).optional().describe('pre-computed digest (wins over text)'),
|
|
@@ -109,22 +114,21 @@ export function buildServer(deps) {
|
|
|
109
114
|
}, async (args) => json(await recordDisclosureTool(deps, args)));
|
|
110
115
|
server.registerTool('verify_compliance_record', {
|
|
111
116
|
title: 'Verify compliance record',
|
|
112
|
-
description: 'Verify a
|
|
113
|
-
'
|
|
114
|
-
'
|
|
115
|
-
'
|
|
116
|
-
'Returns { valid, signer, digest }; on mismatch, valid:false plus the recovered signer and the recomputed digest.',
|
|
117
|
+
description: 'Verify ONE record type: the compliance record that issue_receipt returns beside a receipt (`compliance`: a ComplianceRecordV1 `record` plus its ComplianceAttestation `attestation`, EIP-712 domain "compliance-fields"), fully OFFLINE (no network, no API key, no account): recompute the record\'s canonical digest, check that the attestation\'s signed payload names that exact digest and the same receiptDigest as the record, and recover the address that signed the attestation. ' +
|
|
118
|
+
'It does NOT verify the disclosure record record_disclosure returns: that is a different record type (an action record, EIP-712 domain "tersign action-record"), and passed here it fails with a digest mismatch that says nothing about tampering. Use verify_receipt for the payment receipt itself. ' +
|
|
119
|
+
'Without expectedSigner the signer is UNAUTHENTICATED and valid:true proves internal consistency only — anyone can edit a record, recompute its digest and re-sign the attestation with their own key, and still get valid:true with a different signer. Pass expectedSigner, obtained out-of-band, to bind authorship. ' +
|
|
120
|
+
'Returns { verdict, valid, signer, signerStatus: BOUND | UNAUTHENTICATED | MISMATCH, signerBound, testKey? (a PUBLISHED test key — anyone can sign as it), digest (the recomputed record digest), reason? }. Read verdict first.',
|
|
117
121
|
inputSchema: {
|
|
118
122
|
record: z
|
|
119
123
|
.record(z.unknown())
|
|
120
|
-
.describe('the
|
|
124
|
+
.describe('the compliance record exactly as issue_receipt returned it (`compliance.record`, ComplianceRecordV1 shape). Pass the object, not a JSON string; any field edit changes the digest and fails verification — which is the point'),
|
|
121
125
|
attestation: z
|
|
122
126
|
.record(z.unknown())
|
|
123
|
-
.describe('the attestation
|
|
127
|
+
.describe('the attestation returned with it (`compliance.attestation`): the seller\'s EIP-712 signature over the record digest'),
|
|
124
128
|
expectedSigner: z
|
|
125
129
|
.string()
|
|
126
130
|
.optional()
|
|
127
|
-
.describe('0x address the record MUST be signed by, obtained out-of-band
|
|
131
|
+
.describe('0x address the record MUST be signed by: the SELLER\'s signing address (the key that signed the receipt and this record), obtained out-of-band — not the ledger\'s counter-signing key, which never signs compliance records. Omit to recover the signer without binding it (it is then reported UNAUTHENTICATED)'),
|
|
128
132
|
},
|
|
129
133
|
}, async ({ record, attestation, expectedSigner }) => json(await verifyRecordTool(record, attestation, expectedSigner)));
|
|
130
134
|
server.registerTool('record_refund', {
|
package/dist/mcp/tools.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Account } from 'viem/accounts';
|
|
2
2
|
import type { Assure } from '../assure.js';
|
|
3
|
+
import { type SignerStatus } from '../receipt/binding.js';
|
|
3
4
|
import type { DisputeReason, EvidenceArtifactRef } from '../dispute/types.js';
|
|
4
5
|
import type { LedgerClient } from '../ledgerClient.js';
|
|
5
6
|
import type { ComplianceRecordV1, SignedComplianceRecord, SignedReceipt } from '../types.js';
|
|
@@ -31,8 +32,24 @@ export interface IssueReceiptArgs {
|
|
|
31
32
|
principal?: string | undefined;
|
|
32
33
|
}
|
|
33
34
|
export declare function issueReceiptTool(deps: McpDeps, args: IssueReceiptArgs): Promise<import("../assure.js").IssuedReceipt>;
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
/** What the MCP verify tools return: the library result led by a one-sentence `verdict` and an
|
|
36
|
+
* explicit `signerStatus`, so an agent that reads only the first field is told what was NOT
|
|
37
|
+
* checked. Same shape as `python3 -m tersign verify`'s JSON (its verdict says PASS/FAIL where
|
|
38
|
+
* this says VALID/INVALID, the npm CLI's words). */
|
|
39
|
+
export interface VerifyToolResult {
|
|
40
|
+
verdict: string;
|
|
41
|
+
valid: boolean;
|
|
42
|
+
signer?: `0x${string}`;
|
|
43
|
+
signerStatus?: SignerStatus;
|
|
44
|
+
signerBound: boolean;
|
|
45
|
+
expectedSigner?: string;
|
|
46
|
+
testKey?: string;
|
|
47
|
+
digest?: `0x${string}`;
|
|
48
|
+
unsignedFields?: string[];
|
|
49
|
+
reason?: string;
|
|
50
|
+
}
|
|
51
|
+
export declare function verifyReceiptTool(artifact: SignedReceipt, expectedSigner?: string): Promise<VerifyToolResult>;
|
|
52
|
+
export declare function verifyRecordTool(record: ComplianceRecordV1, attestation: SignedComplianceRecord['attestation'], expectedSigner?: string): Promise<VerifyToolResult>;
|
|
36
53
|
export declare function recordRefundTool(deps: McpDeps, originalDigest: `0x${string}`, amount: string, reason: string): Promise<{
|
|
37
54
|
id: string;
|
|
38
55
|
}>;
|
package/dist/mcp/tools.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { verifyReceipt } from '../receipt/eip712.js';
|
|
2
|
-
import { verifyComplianceRecord } from '../compliance/record.js';
|
|
2
|
+
import { recordDigest, verifyComplianceRecord } from '../compliance/record.js';
|
|
3
|
+
import { digestOf } from '../canonical.js';
|
|
4
|
+
import { findLoneSurrogate, signerStatus } from '../receipt/binding.js';
|
|
5
|
+
import { verdictSentence } from '../verify-report.js';
|
|
3
6
|
import { signDispute, signEvidence } from '../dispute/sign.js';
|
|
4
7
|
async function ledgerFetch(base, path, init) {
|
|
5
8
|
const url = `${base.replace(/\/$/, '')}${path}`;
|
|
@@ -30,11 +33,56 @@ export async function issueReceiptTool(deps, args) {
|
|
|
30
33
|
};
|
|
31
34
|
return deps.assure.issueFor(ctx);
|
|
32
35
|
}
|
|
36
|
+
function report(r, expectedSigner, digest, what) {
|
|
37
|
+
const status = signerStatus(r, expectedSigner);
|
|
38
|
+
// Key order is the Python CLI's: verdict first, the signer and its status next to each other.
|
|
39
|
+
return {
|
|
40
|
+
verdict: verdictSentence(r, expectedSigner, 'expectedSigner', what),
|
|
41
|
+
valid: r.valid,
|
|
42
|
+
...(r.signer ? { signer: r.signer } : {}),
|
|
43
|
+
...(status ? { signerStatus: status } : {}),
|
|
44
|
+
signerBound: r.signerBound,
|
|
45
|
+
...(expectedSigner ? { expectedSigner } : {}),
|
|
46
|
+
...(r.testKey ? { testKey: r.testKey } : {}),
|
|
47
|
+
...(digest ? { digest } : {}),
|
|
48
|
+
...(r.unsignedFields?.length ? { unsignedFields: r.unsignedFields } : {}),
|
|
49
|
+
...(r.reason ? { reason: r.reason } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** A digest failure (a float, an out-of-range integer) is a FAIL: an artifact with no canonical
|
|
53
|
+
* content address cannot be the one a ledger recorded. The Python twin decides it the same way. */
|
|
54
|
+
function tryDigest(f) {
|
|
55
|
+
try {
|
|
56
|
+
return { digest: f() };
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** A lone UTF-16 surrogate anywhere in what is verified: UTF-8 cannot carry it, so the signed and
|
|
63
|
+
* digested text is U+FFFD, not what the object says. Package text only — no path, since key names
|
|
64
|
+
* are chosen by whoever wrote the object. */
|
|
65
|
+
const LONE_SURROGATE_REASON = 'a string in the object 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 this text';
|
|
33
66
|
export async function verifyReceiptTool(artifact, expectedSigner) {
|
|
34
|
-
|
|
67
|
+
if (findLoneSurrogate(artifact) !== null) {
|
|
68
|
+
return report({ valid: false, signerBound: false, reason: LONE_SURROGATE_REASON }, expectedSigner, undefined, 'receipt');
|
|
69
|
+
}
|
|
70
|
+
const r = await verifyReceipt(artifact, expectedSigner);
|
|
71
|
+
if (!r.signer)
|
|
72
|
+
return report(r, expectedSigner, undefined, 'receipt');
|
|
73
|
+
const d = tryDigest(() => digestOf(artifact));
|
|
74
|
+
if (d.error !== undefined) {
|
|
75
|
+
return report({ valid: false, signerBound: false, reason: `cannot compute the canonical digest: ${d.error}` }, expectedSigner, undefined, 'receipt');
|
|
76
|
+
}
|
|
77
|
+
return report(r, expectedSigner, d.digest, 'receipt');
|
|
35
78
|
}
|
|
36
79
|
export async function verifyRecordTool(record, attestation, expectedSigner) {
|
|
37
|
-
|
|
80
|
+
if (findLoneSurrogate({ record, attestation }) !== null) {
|
|
81
|
+
return report({ valid: false, signerBound: false, reason: LONE_SURROGATE_REASON }, expectedSigner, undefined, 'compliance record');
|
|
82
|
+
}
|
|
83
|
+
const r = await verifyComplianceRecord({ record, attestation }, expectedSigner);
|
|
84
|
+
const d = tryDigest(() => recordDigest(record));
|
|
85
|
+
return report(r, expectedSigner, d.digest, 'compliance record');
|
|
38
86
|
}
|
|
39
87
|
export async function recordRefundTool(deps, originalDigest, amount, reason) {
|
|
40
88
|
if (!deps.ledger)
|