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.
@@ -0,0 +1,72 @@
1
+ export declare const ADDRESS_RE: RegExp;
2
+ export declare const MALFORMED_EXPECTED_SIGNER = "expectedSigner is not a 20-byte 0x-prefixed hex address";
3
+ /** undefined, null and '' mean "not supplied". Anything else must be a 20-byte hex address:
4
+ * a malformed value is a FAILURE, never a silently skipped comparison (0, false, [] and {}
5
+ * included — a caller who passed something meant to bind). */
6
+ export declare function parseExpectedSigner(v: unknown): {
7
+ ok: true;
8
+ value: string | undefined;
9
+ } | {
10
+ ok: false;
11
+ reason: string;
12
+ };
13
+ export type SignerStatus = 'BOUND' | 'UNAUTHENTICATED' | 'MISMATCH';
14
+ /** The fields every binding-aware verify result carries. */
15
+ export interface SignerBinding {
16
+ valid: boolean;
17
+ signer?: `0x${string}`;
18
+ /** true only when an expected signer was supplied AND the signature recovers to exactly it.
19
+ * false with valid:true means `signer` is whatever the artifact's own signature recovers to:
20
+ * that proves neither who signed nor that the artifact is unmodified. */
21
+ signerBound: boolean;
22
+ /** present when the recovered signer is a PUBLISHED test key (see known-keys.ts): anyone can
23
+ * produce that signature, so even a bound match says nothing about who issued it. */
24
+ testKey?: string;
25
+ reason?: string;
26
+ }
27
+ /** BOUND: matched the expected signer. MISMATCH: an expected signer was supplied and the
28
+ * signature recovers to a different address. UNAUTHENTICATED: none was supplied. Undefined when
29
+ * nothing was recovered. Decided from the inputs, never from the reason text. */
30
+ export declare function signerStatus(r: SignerBinding, expected: string | undefined): SignerStatus | undefined;
31
+ /** Attach signerBound + testKey to a recovered signer, comparing against `expected` when given. */
32
+ export declare function bindSigner(signer: `0x${string}`, expected: string | undefined, mismatchReason: string): SignerBinding;
33
+ /** Why an EIP-712 `signature` is not THE canonical encoding, or undefined. Checked BEFORE
34
+ * recovery, with the same rules as the Python twin's verify_receipt (sdk-py tersign/verify.py
35
+ * signature_error). The canonical encoding is exactly one string per signer and message:
36
+ *
37
+ * "0x" + 130 LOWER-CASE hex digits = r (32 bytes) || s (32 bytes) || v (1 byte), with
38
+ * 1 <= r, s < n; s <= n/2 (low-s); v = 27 or 28 (0x1b / 0x1c) — what viem's
39
+ * serializeSignature emits, so every signature this package and the ledger produce.
40
+ *
41
+ * Everything else is refused, each with its own reason: a non-string ({r,s,v} object, 65-number
42
+ * array), a missing 0x, any other length, an upper-case hex digit, the recovery-id twin (v = 0/1
43
+ * for 27/28), an out-of-range r/s, and the high-s twin. viem accepts every one of those and
44
+ * recovers the real signer from each, and each re-encoding gives the file a different content
45
+ * digest — so without this one issuance had several "also valid" byte-distinct copies, each
46
+ * printing a bound VALID for signature bytes the issuer never produced (high-s, object and array
47
+ * found 2026-09-27; v 0/1 and upper-case hex found by the release review the same day).
48
+ *
49
+ * Scope: verifyReceipt, verifyComplianceRecord and the CLI and MCP tools built on them. The
50
+ * action-record, dispute and evidence verifiers (evidence/action.ts, dispute/sign.ts) do NOT call
51
+ * this and still accept what viem accepts (PUNT-REGISTER R8). Neither does the ledger's ingest
52
+ * (SECURITY-AUDIT A8), so a receipt the ledger counter-signed with a non-canonical signature is
53
+ * refused here. The reasons are package text only: nothing from the file is echoed. */
54
+ export declare function signatureError(sig: unknown): string | undefined;
55
+ /** A UTF-16 surrogate with no partner. JSON's \uD800-\uDFFF escapes can put one in a parsed string;
56
+ * UTF-8 cannot carry it, so the EIP-712 encoder (viem's TextEncoder) signs U+FFFD in its place and
57
+ * the text the file holds is not the text that was signed. The Python verifier refuses such a file
58
+ * outright ('utf-8' codec can't encode). */
59
+ export declare const LONE_SURROGATE: RegExp;
60
+ /** The first string (key or value) anywhere in `v` holding a lone surrogate, as a path, or null. */
61
+ export declare function findLoneSurrogate(v: unknown, path?: string): string | null;
62
+ export declare function isPlainObject(v: unknown): v is Record<string, unknown>;
63
+ /** Code-point order — the order Python's sorted() gives, so both verifiers list fields alike. */
64
+ export declare function byCodePoint(a: string, b: string): number;
65
+ /** Render text that came from the artifact (a field NAME, a ledger's string) so it can never
66
+ * forge output: control, format and line/paragraph-separator characters become \u escapes.
67
+ * Without this a payload key such as "x\nVALID" would print a bare VALID line of its own. */
68
+ export declare function safeText(s: string, max?: number): string;
69
+ /** A field name as a JSON string literal — `"payload.amount"` — clipped, quotes and
70
+ * backslashes escaped by JSON.stringify, and the invisible characters it leaves raw
71
+ * (U+2028/U+2029, bidi and other format controls) escaped too. */
72
+ export declare function quoteField(name: string): string;
@@ -0,0 +1,158 @@
1
+ /** What an EIP-712 verify can and cannot establish about WHO signed — shared by verifyReceipt,
2
+ * verifyComplianceRecord, the `tersign verify` CLI and the MCP verify tools, so those four
3
+ * surfaces cannot drift apart on the one question a verify result is read for. verifyActionRecord,
4
+ * verifyDispute and verifyEvidence do NOT use it: they have no signer binding and no canonical
5
+ * signature check (PUNT-REGISTER R8).
6
+ *
7
+ * ECDSA recovery yields an address for ANY payload and ANY well-formed signature. An edited
8
+ * receipt therefore still "verifies" — it recovers a different address. A recovered signer is
9
+ * evidence of authorship only when it is compared against an address the caller obtained
10
+ * somewhere else; without that comparison it is UNAUTHENTICATED, and every result says so. */
11
+ import { publishedKeyLabel } from './known-keys.js';
12
+ export const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
13
+ export const MALFORMED_EXPECTED_SIGNER = 'expectedSigner is not a 20-byte 0x-prefixed hex address';
14
+ /** undefined, null and '' mean "not supplied". Anything else must be a 20-byte hex address:
15
+ * a malformed value is a FAILURE, never a silently skipped comparison (0, false, [] and {}
16
+ * included — a caller who passed something meant to bind). */
17
+ export function parseExpectedSigner(v) {
18
+ if (v === undefined || v === null || v === '')
19
+ return { ok: true, value: undefined };
20
+ if (typeof v !== 'string' || !ADDRESS_RE.test(v))
21
+ return { ok: false, reason: MALFORMED_EXPECTED_SIGNER };
22
+ return { ok: true, value: v };
23
+ }
24
+ /** BOUND: matched the expected signer. MISMATCH: an expected signer was supplied and the
25
+ * signature recovers to a different address. UNAUTHENTICATED: none was supplied. Undefined when
26
+ * nothing was recovered. Decided from the inputs, never from the reason text. */
27
+ export function signerStatus(r, expected) {
28
+ if (!r.signer)
29
+ return undefined;
30
+ if (r.signerBound)
31
+ return 'BOUND';
32
+ return expected ? 'MISMATCH' : 'UNAUTHENTICATED';
33
+ }
34
+ /** Attach signerBound + testKey to a recovered signer, comparing against `expected` when given. */
35
+ export function bindSigner(signer, expected, mismatchReason) {
36
+ const out = { valid: true, signer, signerBound: expected !== undefined };
37
+ const label = publishedKeyLabel(signer);
38
+ if (label)
39
+ out.testKey = label;
40
+ if (expected !== undefined && signer.toLowerCase() !== expected.toLowerCase()) {
41
+ out.valid = false;
42
+ out.signerBound = false;
43
+ out.reason = mismatchReason;
44
+ }
45
+ return out;
46
+ }
47
+ /** secp256k1 group order. A signature's s must be at most N/2 (Ethereum's canonical low-s form). */
48
+ const SECP256K1_N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
49
+ /** Why an EIP-712 `signature` is not THE canonical encoding, or undefined. Checked BEFORE
50
+ * recovery, with the same rules as the Python twin's verify_receipt (sdk-py tersign/verify.py
51
+ * signature_error). The canonical encoding is exactly one string per signer and message:
52
+ *
53
+ * "0x" + 130 LOWER-CASE hex digits = r (32 bytes) || s (32 bytes) || v (1 byte), with
54
+ * 1 <= r, s < n; s <= n/2 (low-s); v = 27 or 28 (0x1b / 0x1c) — what viem's
55
+ * serializeSignature emits, so every signature this package and the ledger produce.
56
+ *
57
+ * Everything else is refused, each with its own reason: a non-string ({r,s,v} object, 65-number
58
+ * array), a missing 0x, any other length, an upper-case hex digit, the recovery-id twin (v = 0/1
59
+ * for 27/28), an out-of-range r/s, and the high-s twin. viem accepts every one of those and
60
+ * recovers the real signer from each, and each re-encoding gives the file a different content
61
+ * digest — so without this one issuance had several "also valid" byte-distinct copies, each
62
+ * printing a bound VALID for signature bytes the issuer never produced (high-s, object and array
63
+ * found 2026-09-27; v 0/1 and upper-case hex found by the release review the same day).
64
+ *
65
+ * Scope: verifyReceipt, verifyComplianceRecord and the CLI and MCP tools built on them. The
66
+ * action-record, dispute and evidence verifiers (evidence/action.ts, dispute/sign.ts) do NOT call
67
+ * this and still accept what viem accepts (PUNT-REGISTER R8). Neither does the ledger's ingest
68
+ * (SECURITY-AUDIT A8), so a receipt the ledger counter-signed with a non-canonical signature is
69
+ * refused here. The reasons are package text only: nothing from the file is echoed. */
70
+ export function signatureError(sig) {
71
+ if (typeof sig !== 'string') {
72
+ const t = sig === null ? 'null' : Array.isArray(sig) ? 'an array' : typeof sig === 'undefined' ? 'missing' : typeof sig === 'object' ? 'an object' : `a ${typeof sig}`;
73
+ return `signature must be a 0x-prefixed hex string of 65 bytes (r||s||v), not ${t}`;
74
+ }
75
+ if (!/^0x[0-9a-fA-F]{130}$/.test(sig))
76
+ return 'signature must be a 0x-prefixed hex string of 65 bytes (r||s||v)';
77
+ if (/[A-F]/.test(sig))
78
+ return 'signature hex must be lower-case (non-canonical)';
79
+ const r = BigInt(`0x${sig.slice(2, 66)}`);
80
+ const s = BigInt(`0x${sig.slice(66, 130)}`);
81
+ const v = Number.parseInt(sig.slice(130, 132), 16);
82
+ if (v === 0 || v === 1)
83
+ return `recovery id ${v} rejected (non-canonical): v must be 27 or 28`;
84
+ if (v !== 27 && v !== 28)
85
+ return `unsupported recovery id ${v}`;
86
+ if (r < 1n || r >= SECP256K1_N || s < 1n || s >= SECP256K1_N)
87
+ return 'r/s out of range';
88
+ if (s > SECP256K1_N / 2n)
89
+ return 'high-s signature rejected (non-canonical)';
90
+ return undefined;
91
+ }
92
+ /** A UTF-16 surrogate with no partner. JSON's \uD800-\uDFFF escapes can put one in a parsed string;
93
+ * UTF-8 cannot carry it, so the EIP-712 encoder (viem's TextEncoder) signs U+FFFD in its place and
94
+ * the text the file holds is not the text that was signed. The Python verifier refuses such a file
95
+ * outright ('utf-8' codec can't encode). */
96
+ export const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
97
+ /** The first string (key or value) anywhere in `v` holding a lone surrogate, as a path, or null. */
98
+ export function findLoneSurrogate(v, path = '$') {
99
+ if (typeof v === 'string')
100
+ return LONE_SURROGATE.test(v) ? path : null;
101
+ if (Array.isArray(v)) {
102
+ for (let i = 0; i < v.length; i++) {
103
+ const hit = findLoneSurrogate(v[i], `${path}[${i}]`);
104
+ if (hit)
105
+ return hit;
106
+ }
107
+ return null;
108
+ }
109
+ if (isPlainObject(v)) {
110
+ for (const [k, x] of Object.entries(v)) {
111
+ if (LONE_SURROGATE.test(k))
112
+ return `${path} (a key)`;
113
+ const hit = findLoneSurrogate(x, `${path}.${k}`);
114
+ if (hit)
115
+ return hit;
116
+ }
117
+ }
118
+ return null;
119
+ }
120
+ export function isPlainObject(v) {
121
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
122
+ }
123
+ /** Code-point order — the order Python's sorted() gives, so both verifiers list fields alike. */
124
+ export function byCodePoint(a, b) {
125
+ const x = a[Symbol.iterator]();
126
+ const y = b[Symbol.iterator]();
127
+ for (;;) {
128
+ const p = x.next();
129
+ const q = y.next();
130
+ if (p.done || q.done)
131
+ return (p.done ? 0 : 1) - (q.done ? 0 : 1);
132
+ const d = (p.value.codePointAt(0) ?? 0) - (q.value.codePointAt(0) ?? 0);
133
+ if (d !== 0)
134
+ return d;
135
+ }
136
+ }
137
+ /** Render text that came from the artifact (a field NAME, a ledger's string) so it can never
138
+ * forge output: control, format and line/paragraph-separator characters become \u escapes.
139
+ * Without this a payload key such as "x\nVALID" would print a bare VALID line of its own. */
140
+ export function safeText(s, max = 96) {
141
+ return escapeInvisible(clip(s, max));
142
+ }
143
+ /** A field name as a JSON string literal — `"payload.amount"` — clipped, quotes and
144
+ * backslashes escaped by JSON.stringify, and the invisible characters it leaves raw
145
+ * (U+2028/U+2029, bidi and other format controls) escaped too. */
146
+ export function quoteField(name) {
147
+ return escapeInvisible(JSON.stringify(clip(name, 64)));
148
+ }
149
+ function clip(s, max) {
150
+ const cps = [...s];
151
+ return cps.length > max ? cps.slice(0, max).join('') + '…' : s;
152
+ }
153
+ function escapeInvisible(s) {
154
+ return s.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, (ch) => {
155
+ const cp = ch.codePointAt(0) ?? 0;
156
+ return cp > 0xffff ? `\\u{${cp.toString(16)}}` : `\\u${cp.toString(16).padStart(4, '0')}`;
157
+ });
158
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Account } from 'viem/accounts';
2
2
  import type { OfferPayload, ReceiptPayload, SignedOffer, SignedReceipt } from '../types.js';
3
+ import { type SignerBinding } from './binding.js';
3
4
  /** Canonical EIP-712 material from the merged offer-receipt extension. Domain chainId is
4
5
  * hardcoded to 1 by spec (off-chain signing format; payment network lives in payload.network). */
5
6
  export declare const RECEIPT_DOMAIN: {
@@ -62,11 +63,28 @@ export declare const OFFER_TYPES: {
62
63
  };
63
64
  export declare function signReceipt(payload: ReceiptPayload, account: Account): Promise<SignedReceipt>;
64
65
  export declare function signOffer(payload: OfferPayload, account: Account, acceptIndex?: number): Promise<SignedOffer>;
65
- export interface VerifyResult {
66
- valid: boolean;
67
- signer?: `0x${string}`;
68
- reason?: string;
66
+ /** What the EIP-712 Receipt signature covers. Anything else in the artifact rides along
67
+ * unsigned: it changes the canonical digest (the ledger's content address) but not the
68
+ * recovered signer. */
69
+ export declare const SIGNED_RECEIPT_FIELDS: ("version" | "network" | "resourceUrl" | "payer" | "issuedAt" | "transaction")[];
70
+ /** Fields present in `artifact` that the signature does not cover, as paths relative to the
71
+ * artifact — `payload.<k>` first, then top-level keys — in code-point order. */
72
+ export declare function unsignedReceiptFields(artifact: Record<string, unknown>): string[];
73
+ /** valid the signature is well-formed and recovers to an address — and, when
74
+ * expectedSigner is given, to exactly that address.
75
+ * signerBound true only when expectedSigner was supplied and matched. When false, `signer`
76
+ * is whatever the receipt's own signature recovers to, and recovery yields an
77
+ * address for ANY payload: an edited receipt still returns valid:true, with a
78
+ * different signer. valid:true with signerBound:false proves neither who signed
79
+ * nor that the payload is unmodified.
80
+ * testKey the recovered signer is a PUBLISHED test key (known-keys.ts).
81
+ * unsignedFields fields present in the artifact that the signature does not cover.
82
+ * The same result shape as the Python twin's verify_receipt (minus its digest, which the
83
+ * callers here compute themselves). */
84
+ export interface VerifyResult extends SignerBinding {
85
+ unsignedFields?: string[];
69
86
  }
70
87
  /** Verify an EIP-712 receipt. `expectedSigner` implements the spec's payTo-key authorization
71
- * model; pass the seller's payTo address (or a registry-resolved key) to enforce it. */
88
+ * model; pass the seller's payTo address (or a registry-resolved key), obtained out-of-band,
89
+ * to enforce it. Without it the recovered signer is UNAUTHENTICATED (signerBound:false). */
72
90
  export declare function verifyReceipt(artifact: SignedReceipt, expectedSigner?: string): Promise<VerifyResult>;
@@ -1,4 +1,5 @@
1
1
  import { recoverTypedDataAddress } from 'viem';
2
+ import { LONE_SURROGATE, bindSigner, byCodePoint, isPlainObject, parseExpectedSigner, signatureError } from './binding.js';
2
3
  /** Canonical EIP-712 material from the merged offer-receipt extension. Domain chainId is
3
4
  * hardcoded to 1 by spec (off-chain signing format; payment network lives in payload.network). */
4
5
  export const RECEIPT_DOMAIN = { name: 'x402 receipt', version: '1', chainId: 1n };
@@ -71,25 +72,89 @@ export async function signOffer(payload, account, acceptIndex) {
71
72
  ? { format: 'eip712', payload, signature }
72
73
  : { format: 'eip712', payload, signature, acceptIndex };
73
74
  }
75
+ /** What the EIP-712 Receipt signature covers. Anything else in the artifact rides along
76
+ * unsigned: it changes the canonical digest (the ledger's content address) but not the
77
+ * recovered signer. */
78
+ export const SIGNED_RECEIPT_FIELDS = RECEIPT_TYPES.Receipt.map((f) => f.name);
79
+ const ENVELOPE_FIELDS = new Set(['format', 'payload', 'signature']);
80
+ const SIGNED = new Set(SIGNED_RECEIPT_FIELDS);
81
+ /** Fields present in `artifact` that the signature does not cover, as paths relative to the
82
+ * artifact — `payload.<k>` first, then top-level keys — in code-point order. */
83
+ export function unsignedReceiptFields(artifact) {
84
+ const payload = isPlainObject(artifact.payload) ? artifact.payload : {};
85
+ return [
86
+ ...Object.keys(payload).filter((k) => !SIGNED.has(k)).sort(byCodePoint).map((k) => `payload.${k}`),
87
+ ...Object.keys(artifact).filter((k) => !ENVELOPE_FIELDS.has(k)).sort(byCodePoint),
88
+ ];
89
+ }
90
+ function typeName(v) {
91
+ if (v === null)
92
+ return 'null';
93
+ if (Array.isArray(v))
94
+ return 'array';
95
+ if (typeof v === 'number' && !Number.isInteger(v))
96
+ return 'a non-integer number';
97
+ if (typeof v === 'number')
98
+ return v < 0 ? 'a negative integer' : 'an integer outside the safe range';
99
+ return typeof v;
100
+ }
101
+ /** The payload's signed fields, checked by JSON type before anything is recovered. BigInt()
102
+ * coerces true, "1", " 1783761710 " and "0x6a4…" to the signed number: each such edit recovered
103
+ * the real signer under a DIFFERENT digest, and nothing reported it. The ledger counter-signs
104
+ * only JSON safe integers here, and the Python verifier applies the same rule. */
105
+ function signedFieldError(p) {
106
+ for (const k of ['version', 'issuedAt']) {
107
+ const v = p[k];
108
+ const ok = (typeof v === 'number' && Number.isSafeInteger(v) && v >= 0) || (typeof v === 'bigint' && v >= 0n && v < 2n ** 256n);
109
+ if (!ok)
110
+ return `payload.${k} must be a JSON integer, not ${typeName(v)}: the signature covers the number, so another spelling of it is unsigned text`;
111
+ }
112
+ for (const k of ['network', 'resourceUrl', 'payer', 'transaction']) {
113
+ if (typeof p[k] !== 'string')
114
+ return `payload.${k} must be a string, not ${typeName(p[k])}`;
115
+ if (LONE_SURROGATE.test(p[k])) {
116
+ return `payload.${k} holds a lone UTF-16 surrogate (a \\uD800-\\uDFFF escape) that UTF-8 cannot carry: the signature covers U+FFFD in its place, not this text`;
117
+ }
118
+ }
119
+ return undefined;
120
+ }
74
121
  /** Verify an EIP-712 receipt. `expectedSigner` implements the spec's payTo-key authorization
75
- * model; pass the seller's payTo address (or a registry-resolved key) to enforce it. */
122
+ * model; pass the seller's payTo address (or a registry-resolved key), obtained out-of-band,
123
+ * to enforce it. Without it the recovered signer is UNAUTHENTICATED (signerBound:false). */
76
124
  export async function verifyReceipt(artifact, expectedSigner) {
77
- if (artifact.format !== 'eip712')
78
- return { valid: false, reason: 'jws verification not implemented in v0' };
125
+ const expected = parseExpectedSigner(expectedSigner);
126
+ if (!expected.ok)
127
+ return { valid: false, signerBound: false, reason: expected.reason };
128
+ const notReceipt = { valid: false, signerBound: false, reason: 'not a signed receipt: expected {format, payload{...}, signature}' };
129
+ if (!isPlainObject(artifact))
130
+ return notReceipt;
131
+ if (artifact.format !== 'eip712') {
132
+ return { valid: false, signerBound: false, reason: 'jws verification not implemented in v0' };
133
+ }
134
+ if (!isPlainObject(artifact.payload))
135
+ return notReceipt;
136
+ const badField = signedFieldError(artifact.payload);
137
+ if (badField)
138
+ return { valid: false, signerBound: false, reason: badField };
139
+ const badSig = signatureError(artifact.signature);
140
+ if (badSig)
141
+ return { valid: false, signerBound: false, reason: badSig };
142
+ let signer;
79
143
  try {
80
- const signer = await recoverTypedDataAddress({
144
+ signer = await recoverTypedDataAddress({
81
145
  domain: RECEIPT_DOMAIN,
82
146
  types: RECEIPT_TYPES,
83
147
  primaryType: 'Receipt',
84
148
  message: receiptMessage(artifact.payload),
85
149
  signature: artifact.signature,
86
150
  });
87
- if (expectedSigner && signer.toLowerCase() !== expectedSigner.toLowerCase()) {
88
- return { valid: false, signer, reason: 'signer does not match expected authorization key' };
89
- }
90
- return { valid: true, signer };
91
151
  }
92
152
  catch (e) {
93
- return { valid: false, reason: e instanceof Error ? e.message : 'signature recovery failed' };
153
+ return { valid: false, signerBound: false, reason: e instanceof Error ? e.message : 'signature recovery failed' };
94
154
  }
155
+ const out = bindSigner(signer, expected.value, 'signer does not match expected authorization key');
156
+ const unsigned = unsignedReceiptFields(artifact);
157
+ if (unsigned.length)
158
+ out.unsignedFields = unsigned;
159
+ return out;
95
160
  }
@@ -0,0 +1,23 @@
1
+ /** Signer addresses whose private keys are PUBLISHED — anyone can sign as them.
2
+ *
3
+ * A signature from one of these keys recovers and verifies like any other, so a verifier that
4
+ * stays silent about it hands a stranger a receipt that looks authored when anyone could have
5
+ * minted it. They are what worked examples and test suites sign with (ours included: the
6
+ * synthetic evidence bundle uses dev-mnemonic accounts #0 and #1), which is exactly why a
7
+ * real-looking artifact carrying one must be flagged rather than passed quietly.
8
+ *
9
+ * Twin of `sdk-py/tersign/known_keys.py`. Neither copy is transcribed from the other: each is
10
+ * DERIVED from the published secrets and pinned by its own suite — test/known-keys.test.ts
11
+ * re-derives every address here with viem, the Python suite re-derives its table with its own
12
+ * secp256k1 — and the same test compares the two tables entry by entry (address AND label)
13
+ * wherever the monorepo carries both. A key added to one side without the other fails there.
14
+ * Deliberately narrow: keys whose secret is published as a convention, not every key that has
15
+ * ever leaked. */
16
+ /** BIP-39 mnemonic shipped as the default dev account set by Hardhat (20 accounts) and Anvil
17
+ * (10), derived at m/44'/60'/0'/0/i. */
18
+ export declare const DEV_MNEMONIC = "test test test test test test test test test test test junk";
19
+ /** lowercase address → the published secret it comes from. A Map, not an object literal: a
20
+ * lookup of 'constructor' or '__proto__' on a plain object answers with a prototype member. */
21
+ export declare const PUBLISHED_TEST_KEYS: ReadonlyMap<string, string>;
22
+ /** The published-secret label for `address`, or undefined when it is not a known test key. */
23
+ export declare function publishedKeyLabel(address: unknown): string | undefined;
@@ -0,0 +1,59 @@
1
+ /** Signer addresses whose private keys are PUBLISHED — anyone can sign as them.
2
+ *
3
+ * A signature from one of these keys recovers and verifies like any other, so a verifier that
4
+ * stays silent about it hands a stranger a receipt that looks authored when anyone could have
5
+ * minted it. They are what worked examples and test suites sign with (ours included: the
6
+ * synthetic evidence bundle uses dev-mnemonic accounts #0 and #1), which is exactly why a
7
+ * real-looking artifact carrying one must be flagged rather than passed quietly.
8
+ *
9
+ * Twin of `sdk-py/tersign/known_keys.py`. Neither copy is transcribed from the other: each is
10
+ * DERIVED from the published secrets and pinned by its own suite — test/known-keys.test.ts
11
+ * re-derives every address here with viem, the Python suite re-derives its table with its own
12
+ * secp256k1 — and the same test compares the two tables entry by entry (address AND label)
13
+ * wherever the monorepo carries both. A key added to one side without the other fails there.
14
+ * Deliberately narrow: keys whose secret is published as a convention, not every key that has
15
+ * ever leaked. */
16
+ /** BIP-39 mnemonic shipped as the default dev account set by Hardhat (20 accounts) and Anvil
17
+ * (10), derived at m/44'/60'/0'/0/i. */
18
+ export const DEV_MNEMONIC = 'test test test test test test test test test test test junk';
19
+ const DEV_MNEMONIC_ADDRESSES = [
20
+ '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266',
21
+ '0x70997970c51812dc3a010c7d01b50e0d17dc79c8',
22
+ '0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc',
23
+ '0x90f79bf6eb2c4f870365e785982e1f101e93b906',
24
+ '0x15d34aaf54267db7d7c367839aaf71a00a2c6a65',
25
+ '0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc',
26
+ '0x976ea74026e726554db657fa54763abd0c3a0aa9',
27
+ '0x14dc79964da2c08b23698b3d3cc7ca32193d9955',
28
+ '0x23618e81e3f5cdf7f54c3d65f7fbc0abf5b21e8f',
29
+ '0xa0ee7a142d267c1f36714e4a8f75612f20a79720',
30
+ '0xbcd4042de499d14e55001ccbb24a551f3b954096',
31
+ '0x71be63f3384f5fb98995898a86b02fb2426c5788',
32
+ '0xfabb0ac9d68b0b445fb7357272ff202c5651694a',
33
+ '0x1cbd3b2770909d4e10f157cabc84c7264073c9ec',
34
+ '0xdf3e18d64bc6a983f673ab319ccae4f1a57c7097',
35
+ '0xcd3b766ccdd6ae721141f452c550ca635964ce71',
36
+ '0x2546bcd3c84621e976d8185a91a922ae77ecec30',
37
+ '0xbda5747bfd65f08deb54cb465eb87d40e51b197e',
38
+ '0xdd2fd4581271e230360230f9337d5c0430bf44c0',
39
+ '0x8626f6940e2eb28930efb4cef49b2d1f2c9c1199',
40
+ ];
41
+ /** Private keys 1, 2 and 3 — the other convention for "obviously a test key". Only these
42
+ * three: a receipt signed with private key 4 is NOT flagged. */
43
+ const SMALL_SCALAR_ADDRESSES = [
44
+ '0x7e5f4552091a69125d5dfcb7b8c2659029395bdf',
45
+ '0x2b5ad5c4795c026514f8317c7a215e218dccd6cf',
46
+ '0x6813eb9362372eef6200f3b1dbc3f819671cba69',
47
+ ];
48
+ /** lowercase address → the published secret it comes from. A Map, not an object literal: a
49
+ * lookup of 'constructor' or '__proto__' on a plain object answers with a prototype member. */
50
+ export const PUBLISHED_TEST_KEYS = new Map([
51
+ ...DEV_MNEMONIC_ADDRESSES.map((a, i) => [a, `Hardhat/Anvil default dev mnemonic, account #${i}`]),
52
+ ...SMALL_SCALAR_ADDRESSES.map((a, i) => [a, `private key 0x${(i + 1).toString(16)} (a small scalar)`]),
53
+ ]);
54
+ /** The published-secret label for `address`, or undefined when it is not a known test key. */
55
+ export function publishedKeyLabel(address) {
56
+ if (typeof address !== 'string')
57
+ return undefined;
58
+ return PUBLISHED_TEST_KEYS.get(address.toLowerCase());
59
+ }