tersign 0.6.1 → 0.6.3

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 CHANGED
@@ -178,7 +178,7 @@ Full URLs, readable without auth. If you are an agent, start here.
178
178
  | Envelope API | `GET https://tersign.ai/v1/receipts/{digest}/envelope?venue={internet-court\|kleros\|uma\|generic}` |
179
179
  | Ledger stats | `GET https://tersign.ai/v1/stats` |
180
180
  | Ledger signer | `GET https://tersign.ai/v1/ledger` |
181
- | 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. |
181
+ | Bundle verifier, out-of-band | Fetch the checker from the address the archive's `VERIFY.md` section 0 names: the content address of the release the archive was built with, `https://tersign.ai/verify/sha256/<sha256 of its SHA256SUMS>/` (archives whose VERIFY.md names https://tersign.ai/verify/v1/ use that). Each release serves `verify_bundle.py` · `keccak.py` · `secp256k1.py` · `SHA256SUMS`, and its files never change; https://tersign.ai/verify/releases.json lists every release. A bundle ships its own checker; for evidence from an interested party fetch that release and diff the two. |
182
182
  | llms.txt | https://raw.githubusercontent.com/tersignhq/tersign-js/main/llms.txt |
183
183
  | Conformance vectors (RFC 8785 + keccak256, two-sided) | https://github.com/tersignhq/evidence-record-conformance |
184
184
  | Sample compliance-fields record + digests | https://github.com/tersignhq/tersign-js/blob/main/test/fixtures/compliance-record.json |
@@ -25,7 +25,7 @@ import { DEFAULT_EVENTS, McpCapture } from './intercept/capture.js';
25
25
  import { parseInterceptFlags } from './intercept/flags.js';
26
26
  import { startIntercept } from './intercept/proxy.js';
27
27
  import { EvidenceSink } from './intercept/sink.js';
28
- import { resolveSignerKey } from './keystore.js';
28
+ import { ledgerApiKeyUnlessPlaceholder, resolveSignerKey } from './keystore.js';
29
29
  const USAGE = 'usage: tersign intercept [--events tools/call,prompts/get] [--agent-id id] [--ledger url] -- <mcp server command> [args…]\n' +
30
30
  'hosted ledger mode (TERSIGN_LEDGER_API_KEY + TERSIGN_LEDGER_SELLER_ID) requires the signer key\n' +
31
31
  'registered for that sellerId — set TERSIGN_SELLER_KEY; rejected records fall back to ~/.tersign.\n' +
@@ -53,10 +53,12 @@ for (const e of (flags.events ?? '').split(',')) {
53
53
  const envAgentId = process.env.TERSIGN_AGENT_ID;
54
54
  const agentId = flags.agentId ?? (envAgentId !== undefined && envAgentId !== '' ? envAgentId : 'mcp-intercept');
55
55
  const ledgerUrl = flags.ledger ?? process.env.TERSIGN_LEDGER_URL ?? 'https://tersign.ai';
56
- const apiKey = process.env.TERSIGN_LEDGER_API_KEY;
56
+ // An MCP client launches this process, so an unsubstituted placeholder such as
57
+ // `${TERSIGN_LEDGER_API_KEY}` counts as unset here, as it does for the MCP server.
58
+ const apiKey = ledgerApiKeyUnlessPlaceholder(process.env.TERSIGN_LEDGER_API_KEY);
57
59
  const sellerId = process.env.TERSIGN_LEDGER_SELLER_ID;
58
60
  try {
59
- const { key, source } = resolveSignerKey({ create: true });
61
+ const { key, source } = resolveSignerKey({ create: true, placeholderAsUnset: true });
60
62
  const account = privateKeyToAccount(key);
61
63
  console.error(`signing key: ${account.address} (${source})`);
62
64
  const sink = new EvidenceSink({
@@ -4,7 +4,9 @@
4
4
  * deployer by construction.
5
5
  *
6
6
  * Priority (first hit wins):
7
- * 1. TERSIGN_SELLER_KEY env — explicit override; headless/CI/agent contract.
7
+ * 1. TERSIGN_SELLER_KEY env — explicit override; headless/CI/agent contract. Empty counts as
8
+ * unset; so does an unsubstituted placeholder such as `${TERSIGN_SELLER_KEY}`, but only for a
9
+ * caller that passes `placeholderAsUnset` (a process an MCP client launches).
8
10
  * 2. macOS keychain, service `tersign-signer` — the at-rest default on darwin.
9
11
  * 3. keyfile ~/.tersign/signer.key (0600) — portable fallback; created with a warning
10
12
  * recommending the env/keychain paths.
@@ -17,7 +19,17 @@ export interface ResolvedSignerKey {
17
19
  }
18
20
  /** Resolve the deployer signing key. With `create: true`, a missing key is generated and
19
21
  * persisted (keychain on darwin, else a 0600 keyfile); without it, resolution failure throws
20
- * with the wiring instructions. */
22
+ * with the wiring instructions. `env` is where TERSIGN_SELLER_KEY is read (default
23
+ * process.env). With `placeholderAsUnset: true` (passed only where an MCP client launches the
24
+ * process: the MCP server and `tersign intercept`), an unsubstituted placeholder there is treated
25
+ * as unset, and one stderr line says which key was used instead (stderr, never stdout: stdout is
26
+ * the MCP stdio channel). Without it, a placeholder gets the clear error like any malformed key. */
21
27
  export declare function resolveSignerKey(opts?: {
22
28
  create?: boolean;
29
+ env?: Record<string, string | undefined>;
30
+ placeholderAsUnset?: boolean;
23
31
  }): ResolvedSignerKey;
32
+ /** TERSIGN_LEDGER_API_KEY as given, except that an unsubstituted placeholder is treated as unset,
33
+ * with one stderr line, so it is never sent as a credential. Only for processes an MCP client
34
+ * launches (the MCP server and `tersign intercept`); `""` and every other value pass through. */
35
+ export declare function ledgerApiKeyUnlessPlaceholder(value: string | undefined): string | undefined;
package/dist/keystore.js CHANGED
@@ -2,9 +2,25 @@ import { execFileSync } from 'node:child_process';
2
2
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { homedir, userInfo } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { generatePrivateKey } from 'viem/accounts';
5
+ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
6
6
  const KEY_PATTERN = /^0x[0-9a-fA-F]{64}$/;
7
7
  const KEYCHAIN_SERVICE = 'tersign-signer';
8
+ // A client config can carry `"TERSIGN_SELLER_KEY": "${TERSIGN_SELLER_KEY}"` for every secret a
9
+ // server declares. When that variable is not set where the client runs, a client can pass the
10
+ // text through unexpanded, so the server receives the placeholder itself.
11
+ // Only a WHOLE value of one of these shapes counts: `${NAME}`, `${NAME:-default}`,
12
+ // `${NAME-default}` or `$NAME`.
13
+ const PLACEHOLDER_PATTERN = /^\$\{[A-Za-z_][A-Za-z0-9_]*(:?-[^}]*)?\}$|^\$[A-Za-z_][A-Za-z0-9_]*$/;
14
+ /** True when an environment value is an unexpanded variable reference rather than a value. */
15
+ function isUnexpandedPlaceholder(value) {
16
+ return PLACEHOLDER_PATTERN.test(value);
17
+ }
18
+ /** The placeholder as it may be printed: a `:-default` / `-default` part is elided, since a
19
+ * default can itself be a secret. */
20
+ function shownPlaceholder(value) {
21
+ const withDefault = /^\$\{([A-Za-z_][A-Za-z0-9_]*)(:?-)/.exec(value);
22
+ return withDefault ? `\${${withDefault[1]}${withDefault[2]}…}` : value;
23
+ }
8
24
  function keyfilePath() {
9
25
  return join(homedir(), '.tersign', 'signer.key');
10
26
  }
@@ -48,35 +64,66 @@ function writeKeychain(key) {
48
64
  }
49
65
  /** Resolve the deployer signing key. With `create: true`, a missing key is generated and
50
66
  * persisted (keychain on darwin, else a 0600 keyfile); without it, resolution failure throws
51
- * with the wiring instructions. */
67
+ * with the wiring instructions. `env` is where TERSIGN_SELLER_KEY is read (default
68
+ * process.env). With `placeholderAsUnset: true` (passed only where an MCP client launches the
69
+ * process: the MCP server and `tersign intercept`), an unsubstituted placeholder there is treated
70
+ * as unset, and one stderr line says which key was used instead (stderr, never stdout: stdout is
71
+ * the MCP stdio channel). Without it, a placeholder gets the clear error like any malformed key. */
52
72
  export function resolveSignerKey(opts = {}) {
53
- const env = process.env.TERSIGN_SELLER_KEY;
54
- if (env !== undefined && env !== '') {
55
- if (!KEY_PATTERN.test(env))
73
+ const value = (opts.env ?? process.env).TERSIGN_SELLER_KEY;
74
+ const placeholder = opts.placeholderAsUnset === true && value !== undefined && isUnexpandedPlaceholder(value) ? value : undefined;
75
+ if (value !== undefined && value !== '' && placeholder === undefined) {
76
+ if (!KEY_PATTERN.test(value))
56
77
  throw new Error('TERSIGN_SELLER_KEY must be a 0x-prefixed 32-byte hex key');
57
- return { key: env, source: 'env' };
78
+ return { key: value, source: 'env' };
79
+ }
80
+ const resolved = resolveStoredOrNew(opts.create === true, placeholder);
81
+ if (placeholder !== undefined) {
82
+ const where = resolved.generated
83
+ ? `a newly generated key (kept in the ${resolved.source === 'keychain' ? 'OS keychain' : `keyfile ${keyfilePath()}`})`
84
+ : resolved.source === 'keychain'
85
+ ? `the key from the OS keychain (service ${KEYCHAIN_SERVICE})`
86
+ : `the key from the keyfile ${keyfilePath()}`;
87
+ console.error(`tersign: TERSIGN_SELLER_KEY held the literal, unsubstituted text ${shownPlaceholder(placeholder)} and was ` +
88
+ `treated as unset; signing with ${where}, address ${privateKeyToAccount(resolved.key).address}.`);
58
89
  }
90
+ return { key: resolved.key, source: resolved.source };
91
+ }
92
+ /** TERSIGN_LEDGER_API_KEY as given, except that an unsubstituted placeholder is treated as unset,
93
+ * with one stderr line, so it is never sent as a credential. Only for processes an MCP client
94
+ * launches (the MCP server and `tersign intercept`); `""` and every other value pass through. */
95
+ export function ledgerApiKeyUnlessPlaceholder(value) {
96
+ if (value === undefined || !isUnexpandedPlaceholder(value))
97
+ return value;
98
+ console.error(`tersign: TERSIGN_LEDGER_API_KEY held the literal, unsubstituted text ${shownPlaceholder(value)} and was ` +
99
+ 'treated as unset; no API key is sent to the ledger.');
100
+ return undefined;
101
+ }
102
+ function resolveStoredOrNew(create, placeholder) {
59
103
  const fromKeychain = readKeychain();
60
104
  if (fromKeychain)
61
- return { key: fromKeychain, source: 'keychain' };
105
+ return { key: fromKeychain, source: 'keychain', generated: false };
62
106
  const file = keyfilePath();
63
107
  if (existsSync(file)) {
64
108
  const raw = readFileSync(file, 'utf8').trim();
65
109
  if (!KEY_PATTERN.test(raw))
66
110
  throw new Error(`${file} does not contain a 0x-prefixed 32-byte hex key`);
67
- return { key: raw, source: 'keyfile' };
111
+ return { key: raw, source: 'keyfile', generated: false };
68
112
  }
69
- if (!opts.create) {
70
- throw new Error('no signing key found — set TERSIGN_SELLER_KEY, store one in the macOS keychain ' +
113
+ if (!create) {
114
+ throw new Error((placeholder !== undefined
115
+ ? `TERSIGN_SELLER_KEY held the unexpanded placeholder ${shownPlaceholder(placeholder)} and was treated as unset; `
116
+ : '') +
117
+ 'no signing key found — set TERSIGN_SELLER_KEY, store one in the macOS keychain ' +
71
118
  `(security add-generic-password -s ${KEYCHAIN_SERVICE} -a $USER -w 0x…), or rerun with key creation enabled`);
72
119
  }
73
120
  const key = generatePrivateKey();
74
121
  if (writeKeychain(key))
75
- return { key, source: 'keychain' };
122
+ return { key, source: 'keychain', generated: true };
76
123
  mkdirSync(join(homedir(), '.tersign'), { recursive: true, mode: 0o700 });
77
124
  writeFileSync(file, `${key}\n`, { mode: 0o600 });
78
125
  chmodSync(file, 0o600);
79
126
  console.error(`tersign: generated a new signing key at ${file} (0600). This key IS your evidence identity — ` +
80
127
  'back it up, and prefer TERSIGN_SELLER_KEY or the OS keychain on shared machines.');
81
- return { key, source: 'keyfile' };
128
+ return { key, source: 'keyfile', generated: true };
82
129
  }
@@ -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.6.1";
11
+ readonly version: "0.6.3";
12
12
  };
13
13
  export declare function buildServer(deps: McpDeps): McpServer;
@@ -3,7 +3,7 @@ import { z } from 'zod';
3
3
  import { privateKeyToAccount } from 'viem/accounts';
4
4
  import { Assure } from '../assure.js';
5
5
  import { LedgerClient } from '../ledgerClient.js';
6
- import { resolveSignerKey } from '../keystore.js';
6
+ import { ledgerApiKeyUnlessPlaceholder, resolveSignerKey } from '../keystore.js';
7
7
  import { adjudicateDisputeTool, getDisputeTool, issueReceiptTool, openDisputeTool, recordDisclosureTool, recordRefundTool, submitEvidenceTool, verifyReceiptTool, verifyRecordTool, } from './tools.js';
8
8
  /** MCP packaging: exposes assure as tools any MCP-speaking agent can call, so an agent
9
9
  * (or its framework) can issue, verify, and chain receipts without importing the SDK.
@@ -14,12 +14,19 @@ 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
- // `||`, 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;
22
- const account = privateKeyToAccount(key);
17
+ // Key precedence as in 0.6.2: `env`'s TERSIGN_SELLER_KEY when non-empty, else process.env's
18
+ // (`||`: an EMPTY value means unset — a client may fill a blank optional secret with ""; fixed
19
+ // 2026-09-27). The value is then read by the keystore itself, never handed raw to
20
+ // privateKeyToAccount, so a malformed value gets the keystore's clear error instead of a crypto
21
+ // library's. An MCP client launches this process, so an unsubstituted placeholder such as
22
+ // `${TERSIGN_SELLER_KEY}` also counts as unset (until 0.6.3 that text went straight to
23
+ // privateKeyToAccount and the server died at startup). test/listing.test.ts starts the registry
24
+ // command with the key set to "" and to the placeholder.
25
+ const sellerKey = env.TERSIGN_SELLER_KEY || process.env.TERSIGN_SELLER_KEY;
26
+ const account = privateKeyToAccount(resolveSignerKey({ create: true, env: { TERSIGN_SELLER_KEY: sellerKey }, placeholderAsUnset: true }).key);
27
+ // The listing marks the ledger API key secret too, so the same placeholder can reach it: treat
28
+ // it as unset rather than send it as a credential. "" keeps its old meaning.
29
+ const apiKey = ledgerApiKeyUnlessPlaceholder(env.TERSIGN_LEDGER_API_KEY);
23
30
  const assure = new Assure({
24
31
  signer: account,
25
32
  issuer: {
@@ -27,12 +34,12 @@ export function envDeps(env = process.env) {
27
34
  jurisdiction: env.TERSIGN_ISSUER_JURISDICTION ?? 'unknown',
28
35
  ...(env.TERSIGN_ISSUER_TAX_ID !== undefined ? { taxId: env.TERSIGN_ISSUER_TAX_ID } : {}),
29
36
  },
30
- ...(env.TERSIGN_LEDGER_URL && env.TERSIGN_LEDGER_API_KEY && env.TERSIGN_LEDGER_SELLER_ID
31
- ? { ledger: { url: env.TERSIGN_LEDGER_URL, apiKey: env.TERSIGN_LEDGER_API_KEY, sellerId: env.TERSIGN_LEDGER_SELLER_ID } }
37
+ ...(env.TERSIGN_LEDGER_URL && apiKey && env.TERSIGN_LEDGER_SELLER_ID
38
+ ? { ledger: { url: env.TERSIGN_LEDGER_URL, apiKey, sellerId: env.TERSIGN_LEDGER_SELLER_ID } }
32
39
  : {}),
33
40
  });
34
- const ledger = env.TERSIGN_LEDGER_URL && env.TERSIGN_LEDGER_API_KEY && env.TERSIGN_LEDGER_SELLER_ID
35
- ? new LedgerClient({ url: env.TERSIGN_LEDGER_URL, apiKey: env.TERSIGN_LEDGER_API_KEY, sellerId: env.TERSIGN_LEDGER_SELLER_ID })
41
+ const ledger = env.TERSIGN_LEDGER_URL && apiKey && env.TERSIGN_LEDGER_SELLER_ID
42
+ ? new LedgerClient({ url: env.TERSIGN_LEDGER_URL, apiKey, sellerId: env.TERSIGN_LEDGER_SELLER_ID })
36
43
  : undefined;
37
44
  return {
38
45
  assure,
@@ -42,7 +49,7 @@ export function envDeps(env = process.env) {
42
49
  ? {
43
50
  ledgerHttp: {
44
51
  url: env.TERSIGN_LEDGER_URL,
45
- ...(env.TERSIGN_LEDGER_API_KEY !== undefined ? { apiKey: env.TERSIGN_LEDGER_API_KEY } : {}),
52
+ ...(apiKey !== undefined ? { apiKey } : {}),
46
53
  },
47
54
  }
48
55
  : {}),
@@ -53,7 +60,7 @@ function json(value) {
53
60
  }
54
61
  /** MUST match package.json name/version — the MCP handshake self-reports this identity to
55
62
  * every client; mcp.test.ts pins it against package.json so a release bump can't drift it. */
56
- export const MCP_SERVER_IDENTITY = { name: 'tersign', version: '0.6.1' };
63
+ export const MCP_SERVER_IDENTITY = { name: 'tersign', version: '0.6.3' };
57
64
  export function buildServer(deps) {
58
65
  const server = new McpServer(MCP_SERVER_IDENTITY);
59
66
  server.registerTool('issue_receipt', {
@@ -51,7 +51,7 @@ export declare function bindSigner(signer: `0x${string}`, expected: string | und
51
51
  *
52
52
  * Scope: verifyReceipt, verifyComplianceRecord and the CLI and MCP tools built on them. The
53
53
  * action-record, dispute and evidence verifiers (evidence/action.ts, dispute/sign.ts) do NOT call
54
- * this and still accept what viem accepts (PUNT-REGISTER R8). Neither does the ledger's ingest
54
+ * this and still accept what viem accepts. Neither does the ledger's ingest
55
55
  * (SECURITY-AUDIT A8), so a receipt the ledger counter-signed with a non-canonical signature is
56
56
  * refused here. The reasons are package text only: nothing from the file is echoed. */
57
57
  export declare function signatureError(sig: unknown): string | undefined;
@@ -2,7 +2,7 @@
2
2
  * verifyComplianceRecord, the `tersign verify` CLI and the MCP verify tools, so those four
3
3
  * surfaces cannot drift apart on the one question a verify result is read for. verifyActionRecord,
4
4
  * verifyDispute and verifyEvidence do NOT use it: they have no signer binding and no canonical
5
- * signature check (PUNT-REGISTER R8).
5
+ * signature check.
6
6
  *
7
7
  * ECDSA recovery yields an address for ANY payload and ANY well-formed signature. An edited
8
8
  * receipt therefore still "verifies" — it recovers a different address. A recovered signer is
@@ -67,7 +67,7 @@ const SECP256K1_N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0
67
67
  *
68
68
  * Scope: verifyReceipt, verifyComplianceRecord and the CLI and MCP tools built on them. The
69
69
  * action-record, dispute and evidence verifiers (evidence/action.ts, dispute/sign.ts) do NOT call
70
- * this and still accept what viem accepts (PUNT-REGISTER R8). Neither does the ledger's ingest
70
+ * this and still accept what viem accepts. Neither does the ledger's ingest
71
71
  * (SECURITY-AUDIT A8), so a receipt the ledger counter-signed with a non-canonical signature is
72
72
  * refused here. The reasons are package text only: nothing from the file is echoed. */
73
73
  export function signatureError(sig) {
@@ -118,6 +118,17 @@ function signedFieldError(p) {
118
118
  }
119
119
  return undefined;
120
120
  }
121
+ /** x402 offer-and-receipt §5.5 step 2: payload.version selects the EIP-712 types, and "currently
122
+ * only version 1 is defined". Without this a version-2 payload recovered under the version-1
123
+ * types and verified valid. Runs after signedFieldError, so version is already a JSON integer
124
+ * (a string "1" is refused there with its own reason); the comparison is strict anyway, so it
125
+ * does not lean on that order. The Python twin raises the same text. */
126
+ function receiptVersionError(p) {
127
+ const v = p.version;
128
+ if (v === 1 || v === 1n)
129
+ return undefined;
130
+ return `payload.version ${String(v)} is not supported: version 1 is the only receipt version the x402 offer-and-receipt extension defines`;
131
+ }
121
132
  /** Verify an EIP-712 receipt. `expectedSigner` implements the spec's payTo-key authorization
122
133
  * model; pass the seller's payTo address (or a registry-resolved key), obtained out-of-band,
123
134
  * to enforce it. Without it the recovered signer is UNAUTHENTICATED (signerBound:false). */
@@ -136,6 +147,9 @@ export async function verifyReceipt(artifact, expectedSigner) {
136
147
  const badField = signedFieldError(artifact.payload);
137
148
  if (badField)
138
149
  return { valid: false, signerBound: false, reason: badField };
150
+ const badVersion = receiptVersionError(artifact.payload);
151
+ if (badVersion)
152
+ return { valid: false, signerBound: false, reason: badVersion };
139
153
  const badSig = signatureError(artifact.signature);
140
154
  if (badSig)
141
155
  return { valid: false, signerBound: false, reason: badSig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tersign",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Tersign \u2014 the evidence layer for the agent economy. Counter-signed receipts, agent action records, idempotency enforcement, refunds, disputes, and jury-ready evidence envelopes for x402/agent-commerce sellers.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",