@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/LICENSE +21 -0
- package/dist/index.cjs +518 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +272 -0
- package/dist/index.d.ts +272 -0
- package/dist/index.js +503 -0
- package/dist/index.js.map +1 -0
- package/package.json +73 -0
- package/src/__tests__/hardening-round2.test.ts +233 -0
- package/src/__tests__/hardening-round3.test.ts +93 -0
- package/src/__tests__/hardening-round4.test.ts +265 -0
- package/src/__tests__/tool-receipts.test.ts +296 -0
- package/src/index.ts +64 -0
- package/src/record.ts +60 -0
- package/src/schemes.ts +542 -0
- package/src/signing-proxy.ts +145 -0
- package/src/verify.ts +245 -0
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
|
+
}
|