@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/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
|
+
}
|