@fluxpointstudios/orynq-sdk-tool-receipts 0.2.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/src/schemes.ts ADDED
@@ -0,0 +1,542 @@
1
+ /**
2
+ * @fileoverview Signature-scheme verifiers for tool-call receipts (issue #60).
3
+ *
4
+ * Each verifier checks that `receipt.signature` is a valid signature over
5
+ * `receipt.signedPayload` for `receipt.signer`, per its scheme:
6
+ *
7
+ * - `http-message-signatures` — RFC 9421 (signature base provided as signedPayload)
8
+ * - `stripe-webhook` — Stripe `Stripe-Signature` HMAC-SHA256 (timestamped; freshness-checked)
9
+ * - `github-webhook` — GitHub `X-Hub-Signature-256` HMAC-SHA256 (no signed
10
+ * timestamp; freshness-checked only when `params.timestamp` is recorded,
11
+ * otherwise anti-replay rests on the bundle Merkle commitment)
12
+ * - `jws` — compact JWS / JWT (HS*, RS*, PS*, ES*, EdDSA)
13
+ *
14
+ * Symmetric secrets (webhooks, HS*) MUST be supplied out-of-band via the
15
+ * {@link ToolReceiptVerifyContext} — never embedded in the trace. Asymmetric
16
+ * *public* keys may be embedded in `receipt.params.publicKey`.
17
+ */
18
+
19
+ import {
20
+ createHmac,
21
+ createPublicKey,
22
+ timingSafeEqual,
23
+ verify as cryptoVerify,
24
+ type KeyObject,
25
+ } from "node:crypto";
26
+ import type { ToolReceiptEvent } from "@fluxpointstudios/orynq-sdk-process-trace";
27
+ import { sha256StringHex } from "@fluxpointstudios/orynq-sdk-core/utils";
28
+
29
+ type MaybePromise<T> = T | Promise<T>;
30
+
31
+ /** Key/secret resolution + policy context for receipt verification. */
32
+ export interface ToolReceiptVerifyContext {
33
+ /** Verification keys/secrets keyed by `receipt.signer`. */
34
+ keys?: Record<string, string>;
35
+ /** Dynamic key/secret resolver (takes precedence over `keys`). */
36
+ resolveKey?: (event: ToolReceiptEvent) => MaybePromise<string | Uint8Array | undefined>;
37
+ /** Max age (seconds) for replay-protected schemes (Stripe). Default 300. */
38
+ toleranceSec?: number;
39
+ /** Epoch-seconds clock override (testing). */
40
+ nowSec?: number;
41
+ /**
42
+ * Optional per-signer expected-algorithm allow-list (defense-in-depth against
43
+ * algorithm confusion). When present for a signer, any receipt whose resolved
44
+ * `alg` is not listed is rejected — e.g. `{ "tee://oracle": ["EdDSA"] }` pins
45
+ * that signer to EdDSA so an attacker-set `alg:HS256` is refused.
46
+ */
47
+ keyAlgs?: Record<string, string[]>;
48
+ /**
49
+ * Accept a public key embedded in `receipt.params.publicKey` when no
50
+ * out-of-band key is configured. This is a CONVENIENCE for internal
51
+ * consistency checks only — it is NOT an authenticity guarantee, because the
52
+ * trace (and therefore the embedded key) is attacker-controlled. Default
53
+ * false; supply the signer's key via `keys`/`resolveKey` for a trustworthy
54
+ * verdict.
55
+ */
56
+ trustEmbeddedKeys?: boolean;
57
+ }
58
+
59
+ const textEncoder = new TextEncoder();
60
+
61
+ function utf8(s: string): Buffer {
62
+ return Buffer.from(textEncoder.encode(s));
63
+ }
64
+
65
+ function constantTimeEqualHex(a: string, b: string): boolean {
66
+ const ab = Buffer.from(a.toLowerCase(), "hex");
67
+ const bb = Buffer.from(b.toLowerCase(), "hex");
68
+ if (ab.length === 0 || ab.length !== bb.length) return false;
69
+ return timingSafeEqual(ab, bb);
70
+ }
71
+
72
+ async function resolveKey(
73
+ event: ToolReceiptEvent,
74
+ ctx: ToolReceiptVerifyContext | undefined,
75
+ { allowEmbedded }: { allowEmbedded: boolean }
76
+ ): Promise<string | Uint8Array | undefined> {
77
+ if (ctx?.resolveKey) {
78
+ const k = await ctx.resolveKey(event);
79
+ if (k !== undefined) return k;
80
+ }
81
+ if (ctx?.keys && Object.prototype.hasOwnProperty.call(ctx.keys, event.receipt.signer)) {
82
+ return ctx.keys[event.receipt.signer];
83
+ }
84
+ // An embedded public key lives in the untrusted trace, so it is NOT trusted
85
+ // for a passing verdict unless the caller explicitly opts in. Prefer an
86
+ // out-of-band key via keys/resolveKey.
87
+ if (allowEmbedded && ctx?.trustEmbeddedKeys === true) {
88
+ const p = event.receipt.params;
89
+ if (p && typeof p.publicKey === "string") return p.publicKey;
90
+ }
91
+ return undefined;
92
+ }
93
+
94
+ function keyToString(key: string | Uint8Array): string {
95
+ return typeof key === "string" ? key : Buffer.from(key).toString("utf8");
96
+ }
97
+
98
+ /** Algorithm-OID DER byte sequences that appear inside a SubjectPublicKeyInfo. */
99
+ const SPKI_ALG_OIDS: readonly (readonly number[])[] = [
100
+ // rsaEncryption 1.2.840.113549.1.1.1
101
+ [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01],
102
+ // id-ecPublicKey 1.2.840.10045.2.1
103
+ [0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01],
104
+ // id-Ed25519 1.3.101.112
105
+ [0x2b, 0x65, 0x70],
106
+ // id-Ed448 1.3.101.113
107
+ [0x2b, 0x65, 0x71],
108
+ ];
109
+
110
+ /**
111
+ * True when raw bytes are a DER-encoded SubjectPublicKeyInfo: an outer SEQUENCE
112
+ * (`0x30`) whose DER length header (short-form `< 0x80`, or long-form `0x81`/
113
+ * `0x82`) frames the whole buffer, carrying a known asymmetric algorithm OID.
114
+ * Covers Ed25519 (`30 2a`), EC P-256/P-384 (`30 59`/`30 76`), and RSA
115
+ * (`30 82 ..`). This catches a public key handed to the HMAC path as raw DER
116
+ * (Uint8Array/Buffer), which the PEM/JWK string checks above miss.
117
+ */
118
+ function looksLikeDerPublicKey(bytes: Uint8Array): boolean {
119
+ if (bytes.length < 8 || bytes[0] !== 0x30) return false;
120
+ const lenByte = bytes[1]!;
121
+ let contentStart: number;
122
+ let contentLen: number;
123
+ if (lenByte < 0x80) {
124
+ contentStart = 2;
125
+ contentLen = lenByte;
126
+ } else if (lenByte === 0x81) {
127
+ contentStart = 3;
128
+ contentLen = bytes[2]!;
129
+ } else if (lenByte === 0x82) {
130
+ contentStart = 4;
131
+ contentLen = (bytes[2]! << 8) | bytes[3]!;
132
+ } else {
133
+ return false;
134
+ }
135
+ // The length header must frame exactly the remaining bytes — a genuine DER doc,
136
+ // not arbitrary secret bytes that happen to start with 0x30.
137
+ if (contentStart + contentLen !== bytes.length) return false;
138
+ return SPKI_ALG_OIDS.some((oid) => indexOfBytes(bytes, oid) !== -1);
139
+ }
140
+
141
+ /** Index of a byte subsequence in a byte array, or -1. */
142
+ function indexOfBytes(haystack: Uint8Array, needle: readonly number[]): number {
143
+ outer: for (let i = 0; i + needle.length <= haystack.length; i++) {
144
+ for (let j = 0; j < needle.length; j++) {
145
+ if (haystack[i + j] !== needle[j]) continue outer;
146
+ }
147
+ return i;
148
+ }
149
+ return -1;
150
+ }
151
+
152
+ /**
153
+ * True when the resolved key material is an ASYMMETRIC PUBLIC key — a PEM
154
+ * SPKI/PKCS#1 public block, a JWK with an asymmetric `kty`, or raw DER-encoded
155
+ * SubjectPublicKeyInfo bytes. Such material is public (known to an attacker), so
156
+ * it must NEVER be fed into an HMAC branch: an attacker who sets `alg:HS256`
157
+ * could HMAC with the public key and forge a "valid" symmetric signature (JWT
158
+ * algorithm confusion).
159
+ */
160
+ function looksLikeAsymmetricPublicKey(key: string | Uint8Array): boolean {
161
+ if (typeof key !== "string" && looksLikeDerPublicKey(key)) return true;
162
+ const s = keyToString(key).trim();
163
+ if (
164
+ s.includes("-----BEGIN PUBLIC KEY-----") ||
165
+ s.includes("-----BEGIN RSA PUBLIC KEY-----")
166
+ ) {
167
+ return true;
168
+ }
169
+ if (s.startsWith("{")) {
170
+ try {
171
+ const jwk = JSON.parse(s) as { kty?: unknown };
172
+ const kty = typeof jwk.kty === "string" ? jwk.kty.toUpperCase() : "";
173
+ return kty === "RSA" || kty === "EC" || kty === "OKP";
174
+ } catch {
175
+ return false;
176
+ }
177
+ }
178
+ return false;
179
+ }
180
+
181
+ /**
182
+ * Guard an HMAC/symmetric verification against a key that is actually an
183
+ * asymmetric public key. Symmetric secrets are arbitrary bytes, so we only
184
+ * refuse material that is unambiguously a public key.
185
+ */
186
+ function assertSymmetricSecret(key: string | Uint8Array, algLabel: string): void {
187
+ if (looksLikeAsymmetricPublicKey(key)) {
188
+ throw new Error(
189
+ `${algLabel}: refusing to HMAC with an asymmetric public key (algorithm confusion). ` +
190
+ "Provide the signer's symmetric secret out-of-band, or pin the asymmetric alg via keyAlgs."
191
+ );
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Enforce the optional per-signer expected-algorithm allow-list (defense in
197
+ * depth). Throws when the signer is pinned and `alg` is not allowed.
198
+ */
199
+ function assertAlgAllowed(
200
+ event: ToolReceiptEvent,
201
+ ctx: ToolReceiptVerifyContext | undefined,
202
+ alg: string
203
+ ): void {
204
+ const allow = ctx?.keyAlgs?.[event.receipt.signer];
205
+ if (allow && !allow.includes(alg)) {
206
+ throw new Error(
207
+ `alg "${alg}" is not in the expected-algorithm allow-list for signer "${event.receipt.signer}"`
208
+ );
209
+ }
210
+ }
211
+
212
+ // =============================================================================
213
+ // Stripe webhook (HMAC-SHA256 over `${t}.${payload}`)
214
+ // =============================================================================
215
+
216
+ /** Parse a `t=...,v1=...` Stripe-Signature header (or fall back to a bare hex sig). */
217
+ function parseStripeSignature(
218
+ raw: string,
219
+ params: Record<string, unknown> | undefined
220
+ ): { t?: string; v1: string[] } {
221
+ if (raw.includes("v1=") || raw.includes("t=")) {
222
+ const parts = raw.split(",").map((p) => p.trim());
223
+ let t: string | undefined;
224
+ const v1: string[] = [];
225
+ for (const part of parts) {
226
+ const eq = part.indexOf("=");
227
+ if (eq === -1) continue;
228
+ const k = part.slice(0, eq);
229
+ const v = part.slice(eq + 1);
230
+ if (k === "t") t = v;
231
+ else if (k === "v1") v1.push(v);
232
+ }
233
+ return t !== undefined ? { t, v1 } : { v1 };
234
+ }
235
+ // Bare signature: timestamp must come from params.
236
+ const t = typeof params?.timestamp === "string" ? (params.timestamp as string) : undefined;
237
+ return t !== undefined ? { t, v1: [raw] } : { v1: [raw] };
238
+ }
239
+
240
+ export async function verifyStripeReceipt(
241
+ event: ToolReceiptEvent,
242
+ ctx?: ToolReceiptVerifyContext
243
+ ): Promise<boolean> {
244
+ const secret = await resolveKey(event, ctx, { allowEmbedded: false });
245
+ if (secret === undefined) {
246
+ throw new Error(
247
+ "stripe-webhook: signing secret not found — provide it via verify context (keys/resolveKey), not the trace"
248
+ );
249
+ }
250
+ const { t, v1 } = parseStripeSignature(event.receipt.signature, event.receipt.params);
251
+ if (t === undefined) throw new Error("stripe-webhook: missing timestamp (t)");
252
+ if (v1.length === 0) throw new Error("stripe-webhook: missing v1 signature");
253
+
254
+ const toleranceSec = ctx?.toleranceSec ?? 300;
255
+ const now = ctx?.nowSec ?? Math.floor(Date.now() / 1000);
256
+ const ts = Number(t);
257
+ if (!Number.isFinite(ts)) throw new Error("stripe-webhook: invalid timestamp");
258
+ if (Math.abs(now - ts) > toleranceSec) {
259
+ throw new Error(`stripe-webhook: timestamp outside tolerance (${toleranceSec}s)`);
260
+ }
261
+
262
+ const signedBase = `${t}.${event.receipt.signedPayload}`;
263
+ const expected = createHmac("sha256", keyToString(secret)).update(signedBase).digest("hex");
264
+ return v1.some((candidate) => constantTimeEqualHex(expected, candidate));
265
+ }
266
+
267
+ // =============================================================================
268
+ // GitHub webhook (HMAC-SHA256, `sha256=...`)
269
+ // =============================================================================
270
+
271
+ export async function verifyGitHubReceipt(
272
+ event: ToolReceiptEvent,
273
+ ctx?: ToolReceiptVerifyContext
274
+ ): Promise<boolean> {
275
+ const secret = await resolveKey(event, ctx, { allowEmbedded: false });
276
+ if (secret === undefined) {
277
+ throw new Error(
278
+ "github-webhook: signing secret not found — provide it via verify context (keys/resolveKey)"
279
+ );
280
+ }
281
+ // GitHub's signature carries no timestamp, so freshness can't be enforced
282
+ // cryptographically — a timestamp-less receipt's anti-replay is the bundle
283
+ // Merkle commitment. When the recorder DID capture `params.timestamp`, hold it
284
+ // to the same tolerance window Stripe uses so a stale receipt is rejected.
285
+ const ts = event.receipt.params?.timestamp;
286
+ if (typeof ts === "string" || typeof ts === "number") {
287
+ const tsNum = Number(ts);
288
+ if (!Number.isFinite(tsNum)) throw new Error("github-webhook: invalid timestamp");
289
+ const toleranceSec = ctx?.toleranceSec ?? 300;
290
+ const now = ctx?.nowSec ?? Math.floor(Date.now() / 1000);
291
+ if (Math.abs(now - tsNum) > toleranceSec) {
292
+ throw new Error(`github-webhook: timestamp outside tolerance (${toleranceSec}s)`);
293
+ }
294
+ }
295
+ const provided = event.receipt.signature.startsWith("sha256=")
296
+ ? event.receipt.signature.slice("sha256=".length)
297
+ : event.receipt.signature;
298
+ const expected = createHmac("sha256", keyToString(secret))
299
+ .update(event.receipt.signedPayload)
300
+ .digest("hex");
301
+ return constantTimeEqualHex(expected, provided);
302
+ }
303
+
304
+ // =============================================================================
305
+ // JWS / JWT (compact)
306
+ // =============================================================================
307
+
308
+ interface JwsParts {
309
+ signingInput: string;
310
+ signature: Buffer;
311
+ header: { alg?: string; [k: string]: unknown };
312
+ }
313
+
314
+ function parseJws(event: ToolReceiptEvent): JwsParts {
315
+ const sp = event.receipt.signedPayload;
316
+ const segments = sp.split(".");
317
+ let signingInput: string;
318
+ let sigB64: string;
319
+ if (segments.length === 3) {
320
+ // signedPayload is the full compact JWS.
321
+ signingInput = `${segments[0]}.${segments[1]}`;
322
+ sigB64 = segments[2]!;
323
+ } else if (segments.length === 2) {
324
+ // signedPayload is the signing input; signature carried separately.
325
+ signingInput = sp;
326
+ sigB64 = event.receipt.signature;
327
+ } else {
328
+ throw new Error("jws: signedPayload must be a compact JWS (h.p.s) or signing input (h.p)");
329
+ }
330
+ const headerJson = Buffer.from(segments[0]!, "base64url").toString("utf8");
331
+ const header = JSON.parse(headerJson) as { alg?: string };
332
+ return { signingInput, signature: Buffer.from(sigB64, "base64url"), header };
333
+ }
334
+
335
+ export async function verifyJwsReceipt(
336
+ event: ToolReceiptEvent,
337
+ ctx?: ToolReceiptVerifyContext
338
+ ): Promise<boolean> {
339
+ const { signingInput, signature, header } = parseJws(event);
340
+ const alg = header.alg;
341
+ if (!alg || alg === "none") throw new Error(`jws: unsupported alg "${alg}"`);
342
+ assertAlgAllowed(event, ctx, alg);
343
+ const data = utf8(signingInput);
344
+
345
+ if (alg.startsWith("HS")) {
346
+ const secret = await resolveKey(event, ctx, { allowEmbedded: false });
347
+ if (secret === undefined) throw new Error(`jws(${alg}): HMAC secret not found in verify context`);
348
+ // The alg comes from the attacker-controlled JWS header. Refuse to HMAC with
349
+ // an asymmetric public key (JWT algorithm confusion).
350
+ assertSymmetricSecret(secret, `jws(${alg})`);
351
+ const hashAlg = `sha${alg.slice(2)}`;
352
+ const expected = createHmac(hashAlg, keyToString(secret)).update(data).digest();
353
+ return expected.length === signature.length && timingSafeEqual(expected, signature);
354
+ }
355
+
356
+ // Asymmetric — public key may be embedded.
357
+ const keyMaterial = await resolveKey(event, ctx, { allowEmbedded: true });
358
+ if (keyMaterial === undefined) throw new Error(`jws(${alg}): public key not found`);
359
+ const publicKey = toPublicKey(keyMaterial);
360
+
361
+ if (alg.startsWith("RS")) {
362
+ return cryptoVerify(`sha${alg.slice(2)}`, data, publicKey, signature);
363
+ }
364
+ if (alg.startsWith("PS")) {
365
+ const bits = alg.slice(2);
366
+ return cryptoVerify(
367
+ `sha${bits}`,
368
+ data,
369
+ { key: publicKey, padding: 6 /* RSA_PKCS1_PSS_PADDING */, saltLength: Number(bits) / 8 },
370
+ signature
371
+ );
372
+ }
373
+ if (alg.startsWith("ES")) {
374
+ // JWS ECDSA signatures are raw r||s (IEEE-P1363).
375
+ return cryptoVerify(
376
+ `sha${alg.slice(2)}`,
377
+ data,
378
+ { key: publicKey, dsaEncoding: "ieee-p1363" },
379
+ signature
380
+ );
381
+ }
382
+ if (alg === "EdDSA") {
383
+ return cryptoVerify(null, data, publicKey, signature);
384
+ }
385
+ throw new Error(`jws: unsupported alg "${alg}"`);
386
+ }
387
+
388
+ // =============================================================================
389
+ // RFC 9421 — HTTP Message Signatures
390
+ // =============================================================================
391
+
392
+ /** RFC 9421 algorithm registry names we support. */
393
+ const RFC9421_HASH: Record<string, string> = {
394
+ "rsa-pss-sha512": "sha512",
395
+ "rsa-v1_5-sha256": "sha256",
396
+ "ecdsa-p256-sha256": "sha256",
397
+ "ecdsa-p384-sha384": "sha384",
398
+ };
399
+
400
+ export async function verifyHttpMessageReceipt(
401
+ event: ToolReceiptEvent,
402
+ ctx?: ToolReceiptVerifyContext
403
+ ): Promise<boolean> {
404
+ const params = event.receipt.params ?? {};
405
+ const alg = typeof params.alg === "string" ? (params.alg as string) : undefined;
406
+ if (!alg) {
407
+ throw new Error("http-message-signatures: receipt.params.alg is required (RFC 9421 alg id)");
408
+ }
409
+ assertAlgAllowed(event, ctx, alg);
410
+ // The signature base is the canonical signed bytes.
411
+ const data = utf8(event.receipt.signedPayload);
412
+ const signature = decodeSignature(event.receipt.signature);
413
+
414
+ if (alg === "ed25519") {
415
+ const keyMaterial = await resolveKey(event, ctx, { allowEmbedded: true });
416
+ if (keyMaterial === undefined) throw new Error("http-message-signatures(ed25519): public key not found");
417
+ return cryptoVerify(null, data, toPublicKey(keyMaterial), signature);
418
+ }
419
+
420
+ if (alg === "hmac-sha256") {
421
+ const secret = await resolveKey(event, ctx, { allowEmbedded: false });
422
+ if (secret === undefined) throw new Error("http-message-signatures(hmac-sha256): secret not found");
423
+ // The alg is attacker-controlled — refuse to HMAC with an asymmetric public
424
+ // key (algorithm confusion).
425
+ assertSymmetricSecret(secret, "http-message-signatures(hmac-sha256)");
426
+ const expected = createHmac("sha256", keyToString(secret)).update(data).digest();
427
+ return expected.length === signature.length && timingSafeEqual(expected, signature);
428
+ }
429
+
430
+ const hash = RFC9421_HASH[alg];
431
+ if (!hash) throw new Error(`http-message-signatures: unsupported alg "${alg}"`);
432
+ const keyMaterial = await resolveKey(event, ctx, { allowEmbedded: true });
433
+ if (keyMaterial === undefined) throw new Error(`http-message-signatures(${alg}): public key not found`);
434
+ const publicKey = toPublicKey(keyMaterial);
435
+
436
+ if (alg === "rsa-pss-sha512") {
437
+ return cryptoVerify(hash, data, { key: publicKey, padding: 6, saltLength: 64 }, signature);
438
+ }
439
+ if (alg === "rsa-v1_5-sha256") {
440
+ return cryptoVerify(hash, data, publicKey, signature);
441
+ }
442
+ // ECDSA — RFC 9421 uses raw (IEEE-P1363) signatures.
443
+ return cryptoVerify(hash, data, { key: publicKey, dsaEncoding: "ieee-p1363" }, signature);
444
+ }
445
+
446
+ // =============================================================================
447
+ // Helpers
448
+ // =============================================================================
449
+
450
+ function toPublicKey(material: string | Uint8Array): KeyObject {
451
+ if (typeof material === "string") {
452
+ const trimmed = material.trim();
453
+ if (trimmed.startsWith("{")) {
454
+ return createPublicKey({ key: JSON.parse(trimmed), format: "jwk" });
455
+ }
456
+ return createPublicKey(material);
457
+ }
458
+ return createPublicKey(Buffer.from(material));
459
+ }
460
+
461
+ /**
462
+ * Decode a signature string that may be 0x-hex, base64url, or standard base64
463
+ * (the encodings used by RFC 9421 / JWS receipts in the wild).
464
+ */
465
+ function decodeSignature(sig: string): Buffer {
466
+ if (sig.startsWith("0x")) return Buffer.from(sig.slice(2), "hex");
467
+ if (/[-_]/.test(sig)) return Buffer.from(sig, "base64url");
468
+ return Buffer.from(sig, "base64");
469
+ }
470
+
471
+ // =============================================================================
472
+ // Response binding — the signed content must commit to the recorded response
473
+ // =============================================================================
474
+
475
+ /**
476
+ * The sha-256 hex the signed material commits to, or `null` when the scheme's
477
+ * signed bytes structurally cannot bind the response (e.g. an RFC 9421
478
+ * signature base with no `content-digest` component). A `null` MUST cause
479
+ * verification to fail: a valid signature that does not cover the recorded
480
+ * response proves nothing about it.
481
+ */
482
+ export async function responseCommitmentHash(event: ToolReceiptEvent): Promise<string | null> {
483
+ const { scheme, signedPayload } = event.receipt;
484
+ switch (scheme) {
485
+ case "jws": {
486
+ // signedPayload is the JWS signing input `h.p[.s]`; the payload segment
487
+ // is the exact bytes the tool signed (canonical response body).
488
+ const segs = signedPayload.split(".");
489
+ if (segs.length < 2 || !segs[1]) return null;
490
+ const body = Buffer.from(segs[1], "base64url").toString("utf8");
491
+ return sha256StringHex(body);
492
+ }
493
+ case "stripe-webhook":
494
+ case "github-webhook":
495
+ // The signed webhook body IS the tool response.
496
+ return sha256StringHex(signedPayload);
497
+ case "http-message-signatures":
498
+ // RFC 9421 signs a signature base, not the body; the body is bound only
499
+ // via a `content-digest` component inside that base.
500
+ return contentDigestSha256Hex(signedPayload);
501
+ default:
502
+ return null;
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Read the signed call-binding context ({runId, requestHash}) from a self-signed
508
+ * JWS receipt's header, or `null` when the receipt is not a bound JWS. Because
509
+ * the header is part of the JWS signing input, these values are covered by the
510
+ * signature — lifting the receipt into another trace/request breaks the match.
511
+ */
512
+ export function jwsBindingContext(
513
+ event: ToolReceiptEvent
514
+ ): { runId: string; requestHash?: string } | null {
515
+ if (event.receipt.scheme !== "jws") return null;
516
+ const seg0 = event.receipt.signedPayload.split(".")[0];
517
+ if (!seg0) return null;
518
+ try {
519
+ const header = JSON.parse(Buffer.from(seg0, "base64url").toString("utf8")) as {
520
+ orynqBinding?: { runId?: unknown; requestHash?: unknown };
521
+ };
522
+ const b = header.orynqBinding;
523
+ if (!b || typeof b.runId !== "string") return null;
524
+ return typeof b.requestHash === "string"
525
+ ? { runId: b.runId, requestHash: b.requestHash }
526
+ : { runId: b.runId };
527
+ } catch {
528
+ return null;
529
+ }
530
+ }
531
+
532
+ /** Extract the sha-256 content-digest (hex) from an RFC 9421 signature base. */
533
+ function contentDigestSha256Hex(signatureBase: string): string | null {
534
+ for (const line of signatureBase.split("\n")) {
535
+ const m = /^"content-digest":\s*(.+)$/i.exec(line.trim());
536
+ if (!m) continue;
537
+ const d = /sha-256=:([A-Za-z0-9+/=]+):/.exec(m[1]!);
538
+ if (!d || !d[1]) return null;
539
+ return Buffer.from(d[1], "base64").toString("hex");
540
+ }
541
+ return null;
542
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * @fileoverview Signing-proxy helper — the "anti-lie" pattern (issue #60).
3
+ *
4
+ * Tools that do not sign their responses natively can be wrapped in a TEE/HSM-
5
+ * signed envelope so the wrapper's claim ("the tool returned X") becomes
6
+ * independently verifiable. This helper produces a JWS-signed
7
+ * {@link ToolReceiptEvent.receipt} from a tool response; pair it with
8
+ * `addToolReceipt` to record the receipt, and `verifyToolReceipts` to check it.
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * import { generateKeyPairSync } from "node:crypto";
13
+ * const { privateKey, publicKey } = generateKeyPairSync("ed25519");
14
+ * const proxy = createSigningProxy({
15
+ * signer: "tee://pricing-proxy",
16
+ * alg: "EdDSA",
17
+ * privateKey,
18
+ * publicKey: publicKey.export({ type: "spki", format: "pem" }).toString(),
19
+ * });
20
+ * const receipt = proxy.sign({ price: 4200, currency: "usd" });
21
+ * await addToolReceipt(run, span.id, { toolId, request, response, receipt });
22
+ * ```
23
+ */
24
+
25
+ import {
26
+ createHmac,
27
+ createPrivateKey,
28
+ sign as cryptoSign,
29
+ type KeyObject,
30
+ } from "node:crypto";
31
+ import { canonicalize } from "@fluxpointstudios/orynq-sdk-core/utils";
32
+ import type { ToolReceiptEvent } from "@fluxpointstudios/orynq-sdk-process-trace";
33
+
34
+ /** JWS algorithms supported by the signing proxy. */
35
+ export type JwsAlg =
36
+ | "EdDSA"
37
+ | "ES256"
38
+ | "ES384"
39
+ | "RS256"
40
+ | "RS512"
41
+ | "PS256"
42
+ | "PS512"
43
+ | "HS256";
44
+
45
+ export interface SigningProxyOptions {
46
+ /** Verifier-resolvable identity recorded as `receipt.signer` (URL / DID / keyId). */
47
+ signer: string;
48
+ alg: JwsAlg;
49
+ /** Private key (PEM or KeyObject) for asymmetric algorithms. */
50
+ privateKey?: string | KeyObject;
51
+ /** Shared secret for HS256. */
52
+ secret?: string;
53
+ /**
54
+ * Public key (PEM or JWK string) to embed in `receipt.params.publicKey` so a
55
+ * verifier can resolve it without out-of-band material. Safe to embed
56
+ * (public keys only).
57
+ */
58
+ publicKey?: string;
59
+ /**
60
+ * Call-binding context signed into the receipt header so a genuine receipt
61
+ * cannot be lifted into a different trace/request. Any signed value that
62
+ * disagrees with the enclosing trace is a hard verification failure. To make a
63
+ * receipt `callBound` (request cryptographically attributed), sign BOTH `runId`
64
+ * AND `requestHash` — binding only the runId scopes the trace but leaves the
65
+ * request unauthenticated, so the receipt verifies but is NOT `callBound`.
66
+ */
67
+ binding?: { runId: string; requestHash?: string };
68
+ }
69
+
70
+ function b64url(input: string | Buffer): string {
71
+ return Buffer.from(input).toString("base64url");
72
+ }
73
+
74
+ function signJws(opts: SigningProxyOptions, signingInput: string): Buffer {
75
+ const data = Buffer.from(signingInput, "utf8");
76
+
77
+ if (opts.alg === "HS256") {
78
+ if (!opts.secret) throw new Error("createSigningProxy(HS256): `secret` is required");
79
+ return createHmac("sha256", opts.secret).update(data).digest();
80
+ }
81
+
82
+ if (!opts.privateKey) {
83
+ throw new Error(`createSigningProxy(${opts.alg}): \`privateKey\` is required`);
84
+ }
85
+ const key =
86
+ typeof opts.privateKey === "string" ? createPrivateKey(opts.privateKey) : opts.privateKey;
87
+
88
+ if (opts.alg === "EdDSA") return cryptoSign(null, data, key);
89
+ if (opts.alg.startsWith("ES")) {
90
+ return cryptoSign(`sha${opts.alg.slice(2)}`, data, { key, dsaEncoding: "ieee-p1363" });
91
+ }
92
+ if (opts.alg.startsWith("PS")) {
93
+ return cryptoSign(`sha${opts.alg.slice(2)}`, data, {
94
+ key,
95
+ padding: 6 /* RSA_PKCS1_PSS_PADDING */,
96
+ saltLength: Number(opts.alg.slice(2)) / 8,
97
+ });
98
+ }
99
+ if (opts.alg.startsWith("RS")) {
100
+ return cryptoSign(`sha${opts.alg.slice(2)}`, data, key);
101
+ }
102
+ throw new Error(`createSigningProxy: unsupported alg "${opts.alg}"`);
103
+ }
104
+
105
+ export interface SigningProxy {
106
+ /** Wrap a tool response in a JWS-signed receipt ready for `addToolReceipt`. */
107
+ sign(payload: unknown): ToolReceiptEvent["receipt"];
108
+ }
109
+
110
+ /**
111
+ * Create a signing proxy that turns unsigned tool responses into verifiable
112
+ * JWS receipts. Use a TEE/HSM-held key in production.
113
+ */
114
+ export function createSigningProxy(opts: SigningProxyOptions): SigningProxy {
115
+ if (!opts.signer) throw new Error("createSigningProxy: `signer` is required");
116
+ return {
117
+ sign(payload: unknown): ToolReceiptEvent["receipt"] {
118
+ // The call-binding lives in the (signed) header so the payload segment
119
+ // stays the canonical response body the response-hash commitment covers.
120
+ const header: Record<string, unknown> = { alg: opts.alg, typ: "JWT", kid: opts.signer };
121
+ if (opts.binding) {
122
+ header.orynqBinding = {
123
+ runId: opts.binding.runId,
124
+ ...(opts.binding.requestHash !== undefined
125
+ ? { requestHash: opts.binding.requestHash }
126
+ : {}),
127
+ };
128
+ }
129
+ const h = b64url(JSON.stringify(header));
130
+ const body = typeof payload === "string" ? payload : canonicalize(payload);
131
+ const p = b64url(body);
132
+ const signingInput = `${h}.${p}`;
133
+ const sigB64 = b64url(signJws(opts, signingInput));
134
+ return {
135
+ // signedPayload is the JWS *signing input* (the bytes actually signed);
136
+ // the signature is carried separately so it is independently checkable.
137
+ scheme: "jws",
138
+ signer: opts.signer,
139
+ signature: sigB64,
140
+ signedPayload: signingInput,
141
+ ...(opts.publicKey ? { params: { publicKey: opts.publicKey } } : {}),
142
+ };
143
+ },
144
+ };
145
+ }