@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/verify.ts ADDED
@@ -0,0 +1,245 @@
1
+ /**
2
+ * @fileoverview Verification dispatch for tool-call receipts (issue #60).
3
+ *
4
+ * `verifyToolReceipts(bundle, ctx)` verifies every `tool-receipt` event in a
5
+ * bundle; `verifyTrace(bundle, ctx)` additionally runs the full process-trace
6
+ * `verifyBundle()` and folds the receipt result into its `checks` so a single
7
+ * call gives auditors "did the trace verify AND did every tool actually return
8
+ * what the wrapper claims".
9
+ */
10
+
11
+ import type {
12
+ ToolReceiptEvent,
13
+ TraceBundle,
14
+ TraceVerificationResult,
15
+ } from "@fluxpointstudios/orynq-sdk-process-trace";
16
+ import { verifyBundle } from "@fluxpointstudios/orynq-sdk-process-trace";
17
+ import {
18
+ verifyStripeReceipt,
19
+ verifyGitHubReceipt,
20
+ verifyJwsReceipt,
21
+ verifyHttpMessageReceipt,
22
+ responseCommitmentHash,
23
+ jwsBindingContext,
24
+ type ToolReceiptVerifyContext,
25
+ } from "./schemes.js";
26
+ import { hashToolPayload } from "./record.js";
27
+
28
+ /** A verifier for a single receipt scheme. */
29
+ export type ToolReceiptSchemeVerifier = (
30
+ event: ToolReceiptEvent,
31
+ ctx?: ToolReceiptVerifyContext
32
+ ) => Promise<boolean>;
33
+
34
+ /** Built-in verifiers keyed by `receipt.scheme`. */
35
+ export const BUILTIN_TOOL_RECEIPT_VERIFIERS: Record<string, ToolReceiptSchemeVerifier> = {
36
+ "stripe-webhook": verifyStripeReceipt,
37
+ "github-webhook": verifyGitHubReceipt,
38
+ jws: verifyJwsReceipt,
39
+ "http-message-signatures": verifyHttpMessageReceipt,
40
+ };
41
+
42
+ /** Per-receipt verification result. */
43
+ export interface ToolReceiptVerificationResult {
44
+ eventId: string;
45
+ toolId: string;
46
+ scheme: string;
47
+ signer: string;
48
+ verified: boolean;
49
+ /**
50
+ * True ONLY when the signature cryptographically binds this receipt to THIS
51
+ * trace's runId AND the recorded request hash (self-signed JWS anti-lie path,
52
+ * with a signed `orynqBinding.{runId,requestHash}` that both matched). A JWS
53
+ * with no binding, or one that binds only the runId, is `false` — the request
54
+ * is not authenticated. Always `false` for external webhook schemes
55
+ * (stripe/github/rfc9421): those prove authenticity of the response body but
56
+ * cannot cover our runId/request, so call-binding is not provable by the
57
+ * signature and request-attribution must not be inferred — anti-replay for them
58
+ * relies on the bundle Merkle commitment + the scheme's own timestamp window.
59
+ */
60
+ callBound: boolean;
61
+ /** When not verified, a short machine-readable reason. */
62
+ reason?: string;
63
+ error?: string;
64
+ }
65
+
66
+ /** Case-insensitive, 0x-tolerant hex equality. */
67
+ function hexEq(a: string, b: string): boolean {
68
+ const na = a.startsWith("0x") ? a.slice(2) : a;
69
+ const nb = b.startsWith("0x") ? b.slice(2) : b;
70
+ return na.toLowerCase() === nb.toLowerCase();
71
+ }
72
+
73
+ /** Context for {@link verifyToolReceipts}; extends key resolution with custom schemes. */
74
+ export interface VerifyToolReceiptsContext extends ToolReceiptVerifyContext {
75
+ /** Register or override scheme verifiers (e.g. a custom/internal scheme). */
76
+ verifiers?: Record<string, ToolReceiptSchemeVerifier>;
77
+ /**
78
+ * The enclosing trace's run id. Self-signed JWS receipts commit to it (and to
79
+ * the request hash) so a genuine receipt cannot be lifted into another trace.
80
+ * {@link verifyToolReceipts} supplies it automatically from the bundle.
81
+ */
82
+ runId?: string;
83
+ /**
84
+ * When true, a receipt whose signature does not provably bind THIS call
85
+ * (runId + request hash) is `verified: false` with reason `call-binding-required`.
86
+ * Use this when request-attribution must be cryptographic: it rejects unbound
87
+ * JWS receipts and all webhook schemes (whose external signatures cannot cover
88
+ * our request). Default false — call-binding is reported via `callBound` but not
89
+ * required.
90
+ */
91
+ requireCallBinding?: boolean;
92
+ }
93
+
94
+ /** Extract all `tool-receipt` events from a bundle (ordered by seq). */
95
+ export function extractToolReceipts(bundle: TraceBundle): ToolReceiptEvent[] {
96
+ return bundle.privateRun.events.filter(
97
+ (e): e is ToolReceiptEvent => e.kind === "tool-receipt"
98
+ );
99
+ }
100
+
101
+ /** Verify a single tool-receipt event. Never throws — failures land in `error`. */
102
+ export async function verifyToolReceipt(
103
+ event: ToolReceiptEvent,
104
+ ctx?: VerifyToolReceiptsContext
105
+ ): Promise<ToolReceiptVerificationResult> {
106
+ const scheme = event.receipt.scheme;
107
+ const base = {
108
+ eventId: event.id,
109
+ toolId: event.toolId,
110
+ scheme,
111
+ signer: event.receipt.signer,
112
+ };
113
+ // `callBound` reflects PROVEN call-binding, computed after the checks below —
114
+ // never assumed from the scheme. Only a self-signed JWS carrying a signed
115
+ // `orynqBinding.{runId,requestHash}` that both match can be call-bound; external
116
+ // webhook schemes never can (their signature cannot cover our runId/request).
117
+ const callBound = false;
118
+ const verifier = ctx?.verifiers?.[scheme] ?? BUILTIN_TOOL_RECEIPT_VERIFIERS[scheme];
119
+ if (!verifier) {
120
+ return {
121
+ ...base,
122
+ callBound,
123
+ verified: false,
124
+ error: `no verifier registered for scheme "${scheme}"`,
125
+ };
126
+ }
127
+ try {
128
+ const sigValid = await verifier(event, ctx);
129
+ if (!sigValid) return { ...base, callBound, verified: false, reason: "signature-invalid" };
130
+ // A valid signature is necessary but NOT sufficient: the signed content
131
+ // must commit to the recorded response, else a genuine receipt for output
132
+ // A can be paired with a fabricated response B (issue #60's core guarantee).
133
+ const commit = await responseCommitmentHash(event);
134
+ if (commit === null) {
135
+ return { ...base, callBound, verified: false, reason: "response-not-bound" };
136
+ }
137
+ if (!hexEq(commit, event.response.hash)) {
138
+ return { ...base, callBound, verified: false, reason: "response-binding-mismatch" };
139
+ }
140
+ // Auditors read the retained human-readable `response.payload`; when present
141
+ // it MUST hash to the bound `response.hash`, else an honest signature can be
142
+ // paired with a fabricated payload the auditor sees.
143
+ if (event.response.payload !== undefined) {
144
+ const payloadHash = await hashToolPayload(event.response.payload);
145
+ if (!hexEq(payloadHash, event.response.hash)) {
146
+ return { ...base, callBound, verified: false, reason: "response-payload-mismatch" };
147
+ }
148
+ }
149
+ // Self-signed JWS receipts additionally commit to a binding context
150
+ // {runId, requestHash}. When present, the enclosing trace's runId AND the
151
+ // recorded request hash MUST both match — this authenticates the request and
152
+ // blocks lifting a genuine receipt into a different trace/request. A JWS that
153
+ // binds only runId (or nothing) is NOT call-bound: its request is
154
+ // unauthenticated. External webhooks can never cover runId/request, so their
155
+ // anti-replay is the bundle Merkle commitment + timestamp.
156
+ let provenCallBound = false;
157
+ if (scheme === "jws") {
158
+ const bound = jwsBindingContext(event);
159
+ if (bound !== null) {
160
+ // A signed binding is a commitment: any mismatch is a hard failure, not a
161
+ // downgrade to "unbound" — the signer attested to a specific call.
162
+ if (ctx?.runId !== undefined && bound.runId !== ctx.runId) {
163
+ return { ...base, callBound, verified: false, reason: "call-binding-mismatch" };
164
+ }
165
+ if (bound.requestHash !== undefined && !hexEq(bound.requestHash, event.request.hash)) {
166
+ return { ...base, callBound, verified: false, reason: "call-binding-mismatch" };
167
+ }
168
+ // Call-binding is PROVEN only when the request hash was signed and matched
169
+ // AND the runId was verified against this trace. Binding only the runId
170
+ // leaves the request unauthenticated → not call-bound.
171
+ provenCallBound =
172
+ bound.requestHash !== undefined &&
173
+ hexEq(bound.requestHash, event.request.hash) &&
174
+ ctx?.runId !== undefined &&
175
+ bound.runId === ctx.runId;
176
+ }
177
+ }
178
+ if (ctx?.requireCallBinding && !provenCallBound) {
179
+ return { ...base, callBound: provenCallBound, verified: false, reason: "call-binding-required" };
180
+ }
181
+ return { ...base, callBound: provenCallBound, verified: true };
182
+ } catch (error) {
183
+ return {
184
+ ...base,
185
+ callBound,
186
+ verified: false,
187
+ error: error instanceof Error ? error.message : String(error),
188
+ };
189
+ }
190
+ }
191
+
192
+ /** Aggregate outcome over all tool receipts in a bundle. */
193
+ export interface ToolReceiptsVerifyOutcome {
194
+ valid: boolean;
195
+ errors: string[];
196
+ results: ToolReceiptVerificationResult[];
197
+ }
198
+
199
+ /** Verify every tool-receipt in a bundle. A trace with no receipts is `valid: true`. */
200
+ export async function verifyToolReceipts(
201
+ bundle: TraceBundle,
202
+ ctx?: VerifyToolReceiptsContext
203
+ ): Promise<ToolReceiptsVerifyOutcome> {
204
+ const events = extractToolReceipts(bundle);
205
+ // Bind receipt verification to THIS trace's run id (self-signed anti-replay).
206
+ // An explicit ctx.runId takes precedence for advanced callers.
207
+ const boundCtx: VerifyToolReceiptsContext = {
208
+ ...ctx,
209
+ runId: ctx?.runId ?? bundle.privateRun.id,
210
+ };
211
+ const results: ToolReceiptVerificationResult[] = [];
212
+ for (const event of events) {
213
+ results.push(await verifyToolReceipt(event, boundCtx));
214
+ }
215
+ const failed = results.filter((r) => !r.verified);
216
+ return {
217
+ valid: failed.length === 0,
218
+ errors: failed.map(
219
+ (f) =>
220
+ `tool-receipt "${f.toolId}" (${f.scheme}) failed${f.reason ? ` [${f.reason}]` : ""}${f.error ? `: ${f.error}` : ""}`
221
+ ),
222
+ results,
223
+ };
224
+ }
225
+
226
+ /** Combined result of {@link verifyTrace}. */
227
+ export interface VerifyTraceResult extends TraceVerificationResult {
228
+ toolReceipts: ToolReceiptsVerifyOutcome;
229
+ }
230
+
231
+ /**
232
+ * Verify the whole trace AND its tool receipts in one call. The receipt outcome
233
+ * is folded into `checks.toolReceiptsValid` (so `valid` reflects receipts too)
234
+ * and also returned in full under `toolReceipts`.
235
+ */
236
+ export async function verifyTrace(
237
+ bundle: TraceBundle,
238
+ ctx?: VerifyToolReceiptsContext
239
+ ): Promise<VerifyTraceResult> {
240
+ const outcome = await verifyToolReceipts(bundle, ctx);
241
+ const result = await verifyBundle(bundle, {
242
+ toolReceipts: () => ({ valid: outcome.valid, errors: outcome.errors }),
243
+ });
244
+ return { ...result, toolReceipts: outcome };
245
+ }