@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/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@fluxpointstudios/orynq-sdk-tool-receipts",
3
+ "version": "0.2.0",
4
+ "description": "Verifiable tool-call receipts for the Orynq process-trace SDK — RFC 9421, webhook-signature, and JWS verifiers",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "require": {
16
+ "types": "./dist/index.d.cts",
17
+ "default": "./dist/index.cjs"
18
+ }
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "src"
24
+ ],
25
+ "keywords": [
26
+ "poi",
27
+ "proof-of-intent",
28
+ "orynq",
29
+ "tool-receipts",
30
+ "tool-call",
31
+ "rfc9421",
32
+ "jws",
33
+ "webhook",
34
+ "verifiable",
35
+ "audit"
36
+ ],
37
+ "author": "Flux Point Studios",
38
+ "license": "MIT",
39
+ "publishConfig": {
40
+ "access": "public",
41
+ "registry": "https://registry.npmjs.org"
42
+ },
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "https://github.com/Flux-Point-Studios/orynq-sdk",
46
+ "directory": "packages/tool-receipts"
47
+ },
48
+ "homepage": "https://github.com/Flux-Point-Studios/orynq-sdk#readme",
49
+ "bugs": "https://github.com/Flux-Point-Studios/orynq-sdk/issues",
50
+ "dependencies": {
51
+ "@fluxpointstudios/orynq-sdk-core": "0.1.0",
52
+ "@fluxpointstudios/orynq-sdk-process-trace": "0.3.0"
53
+ },
54
+ "devDependencies": {
55
+ "tsup": "^8.0.1",
56
+ "typescript": "^5.3.3",
57
+ "vitest": "^1.2.0"
58
+ },
59
+ "engines": {
60
+ "node": ">=18.0.0"
61
+ },
62
+ "sideEffects": false,
63
+ "scripts": {
64
+ "build": "tsup",
65
+ "dev": "tsup --watch",
66
+ "test": "vitest run",
67
+ "test:watch": "vitest",
68
+ "typecheck": "tsc --noEmit",
69
+ "lint": "eslint src --ext .ts",
70
+ "lint:fix": "eslint src --ext .ts --fix",
71
+ "clean": "rimraf dist"
72
+ }
73
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * @summary Round-2 hardening regression tests for tool-call receipts.
3
+ *
4
+ * Each suite reproduces an exploit an adversarial re-review surfaced, then
5
+ * asserts the receipt now FAILS to verify:
6
+ * 1. response.payload↔hash gap — auditors read `response.payload`, so an
7
+ * honest `response.hash` paired with a fabricated `response.payload` must
8
+ * not verify.
9
+ * 2. JWT algorithm confusion — an asymmetric public key must never be fed into
10
+ * an HMAC branch (alg:HS256 over a known public-key PEM).
11
+ * 3. Replay / call-binding — a self-signed JWS receipt lifted into a different
12
+ * runId/request must fail; external webhook receipts are authenticity-only
13
+ * and flagged not-call-bound.
14
+ */
15
+
16
+ import { describe, it, expect } from "vitest";
17
+ import { createHmac, generateKeyPairSync } from "node:crypto";
18
+ import {
19
+ createTrace,
20
+ addSpan,
21
+ closeSpan,
22
+ finalizeTrace,
23
+ } from "@fluxpointstudios/orynq-sdk-process-trace";
24
+ import type {
25
+ TraceRun,
26
+ TraceBundle,
27
+ ToolReceiptEvent,
28
+ } from "@fluxpointstudios/orynq-sdk-process-trace";
29
+ import {
30
+ addToolReceipt,
31
+ hashToolPayload,
32
+ verifyToolReceipts,
33
+ createSigningProxy,
34
+ } from "../index.js";
35
+
36
+ const pem = (k: ReturnType<typeof generateKeyPairSync>["publicKey"]): string =>
37
+ k.export({ type: "spki", format: "pem" }).toString();
38
+
39
+ /** Record a receipt with an explicit request/response, returning the bundle. */
40
+ async function traceWith(opts: {
41
+ receipt: ToolReceiptEvent["receipt"];
42
+ request: { hash: string };
43
+ response: { hash: string; payload?: unknown };
44
+ }): Promise<TraceBundle> {
45
+ const run: TraceRun = await createTrace({ agentId: "agent-h2" });
46
+ const span = addSpan(run, { name: "tool-call", visibility: "public" });
47
+ await addToolReceipt(run, span.id, {
48
+ toolId: "demo.tool",
49
+ request: opts.request,
50
+ response: opts.response,
51
+ receipt: opts.receipt,
52
+ });
53
+ await closeSpan(run, span.id);
54
+ return finalizeTrace(run);
55
+ }
56
+
57
+ describe("response.payload vs response.hash binding", () => {
58
+ it("rejects an honest response.hash paired with a fabricated response.payload", async () => {
59
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
60
+ const proxy = createSigningProxy({
61
+ signer: "tee://oracle",
62
+ alg: "EdDSA",
63
+ privateKey,
64
+ publicKey: pem(publicKey),
65
+ });
66
+ const honest = { price: 100, currency: "usd" };
67
+ const receipt = proxy.sign(honest);
68
+
69
+ // Signature binds to the honest hash, but the retained human-readable
70
+ // payload auditors actually read is fabricated.
71
+ const bundle = await traceWith({
72
+ receipt,
73
+ request: { hash: await hashToolPayload({ q: 1 }) },
74
+ response: { hash: await hashToolPayload(honest), payload: { price: 1, currency: "usd" } },
75
+ });
76
+
77
+ const outcome = await verifyToolReceipts(bundle, {
78
+ keys: { "tee://oracle": pem(publicKey) },
79
+ });
80
+ expect(outcome.results[0]!.verified).toBe(false);
81
+ expect(outcome.results[0]!.reason).toBe("response-payload-mismatch");
82
+ });
83
+
84
+ it("accepts a payload that hashes to response.hash", async () => {
85
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
86
+ const proxy = createSigningProxy({
87
+ signer: "tee://oracle",
88
+ alg: "EdDSA",
89
+ privateKey,
90
+ publicKey: pem(publicKey),
91
+ });
92
+ const honest = { price: 100, currency: "usd" };
93
+ const receipt = proxy.sign(honest);
94
+ const bundle = await traceWith({
95
+ receipt,
96
+ request: { hash: await hashToolPayload({ q: 1 }) },
97
+ response: { hash: await hashToolPayload(honest), payload: honest },
98
+ });
99
+ const outcome = await verifyToolReceipts(bundle, {
100
+ keys: { "tee://oracle": pem(publicKey) },
101
+ });
102
+ expect(outcome.results[0]!.verified).toBe(true);
103
+ });
104
+ });
105
+
106
+ describe("JWT algorithm confusion (HS* over an asymmetric public key)", () => {
107
+ it("refuses to HMAC-verify with a pinned asymmetric public key (alg:HS256 forgery)", async () => {
108
+ const { publicKey } = generateKeyPairSync("ed25519");
109
+ const pubPem = pem(publicKey);
110
+
111
+ // Attacker forges a JWS with alg:HS256 and HMACs the signing input using the
112
+ // VICTIM'S PUBLIC KEY PEM as the "secret" (public = known to the attacker).
113
+ const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
114
+ const body = { price: 999_999, note: "FORGED" };
115
+ const payloadSeg = Buffer.from(JSON.stringify(body)).toString("base64url");
116
+ const signingInput = `${header}.${payloadSeg}`;
117
+ const forgedSig = createHmac("sha256", pubPem).update(signingInput).digest("base64url");
118
+
119
+ const receipt: ToolReceiptEvent["receipt"] = {
120
+ scheme: "jws",
121
+ signer: "tee://asym-oracle",
122
+ signature: forgedSig,
123
+ signedPayload: signingInput,
124
+ };
125
+ const bundle = await traceWith({
126
+ receipt,
127
+ request: { hash: await hashToolPayload({ q: 1 }) },
128
+ response: { hash: await hashToolPayload(body), payload: body },
129
+ });
130
+
131
+ // Auditor pins the ASYMMETRIC public key. The HMAC branch must NOT accept it.
132
+ const outcome = await verifyToolReceipts(bundle, {
133
+ keys: { "tee://asym-oracle": pubPem },
134
+ });
135
+ expect(outcome.results[0]!.verified).toBe(false);
136
+ expect(outcome.results[0]!.error ?? "").toMatch(/public key|asymmetric|hmac/i);
137
+ });
138
+
139
+ it("optional keyAlgs allow-list rejects an alg outside it", async () => {
140
+ // A genuine HS256 receipt, but the auditor pins keyAlgs to EdDSA only.
141
+ const proxy = createSigningProxy({ signer: "hs-signer", alg: "HS256", secret: "topsecret" });
142
+ const payload = { data: "y" };
143
+ const receipt = proxy.sign(payload);
144
+ const bundle = await traceWith({
145
+ receipt,
146
+ request: { hash: await hashToolPayload({ q: 1 }) },
147
+ response: { hash: await hashToolPayload(payload), payload },
148
+ });
149
+ const outcome = await verifyToolReceipts(bundle, {
150
+ keys: { "hs-signer": "topsecret" },
151
+ keyAlgs: { "hs-signer": ["EdDSA"] },
152
+ });
153
+ expect(outcome.results[0]!.verified).toBe(false);
154
+ expect(outcome.results[0]!.error ?? "").toMatch(/alg|allow/i);
155
+ });
156
+ });
157
+
158
+ describe("self-signed JWS call-binding (anti-replay)", () => {
159
+ it("a genuine receipt copied into a different runId/request fails", async () => {
160
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
161
+
162
+ // Signer commits to the response AND the run/request binding context.
163
+ const run: TraceRun = await createTrace({ agentId: "agent-src" });
164
+ const span = addSpan(run, { name: "tool-call", visibility: "public" });
165
+ const request = { q: "price?" };
166
+ const response = { price: 100 };
167
+ const requestHash = await hashToolPayload(request);
168
+ const proxy = createSigningProxy({
169
+ signer: "tee://oracle",
170
+ alg: "EdDSA",
171
+ privateKey,
172
+ publicKey: pem(publicKey),
173
+ binding: { runId: run.id, requestHash },
174
+ });
175
+ const receipt = proxy.sign(response);
176
+ await addToolReceipt(run, span.id, {
177
+ toolId: "demo.tool",
178
+ request: { hash: requestHash },
179
+ response: { hash: await hashToolPayload(response), payload: response },
180
+ receipt,
181
+ });
182
+ await closeSpan(run, span.id);
183
+ const srcBundle = await finalizeTrace(run);
184
+
185
+ // In the ORIGINAL run it is call-bound and verifies.
186
+ const honest = await verifyToolReceipts(srcBundle, {
187
+ keys: { "tee://oracle": pem(publicKey) },
188
+ });
189
+ expect(honest.results[0]!.verified).toBe(true);
190
+ expect(honest.results[0]!.callBound).toBe(true);
191
+
192
+ // Lift the genuine receipt into a DIFFERENT run (different runId).
193
+ const victimRun: TraceRun = await createTrace({ agentId: "agent-victim" });
194
+ const vspan = addSpan(victimRun, { name: "tool-call", visibility: "public" });
195
+ await addToolReceipt(victimRun, vspan.id, {
196
+ toolId: "demo.tool",
197
+ request: { hash: requestHash },
198
+ response: { hash: await hashToolPayload(response), payload: response },
199
+ receipt, // same genuine, honestly-signed receipt
200
+ });
201
+ await closeSpan(victimRun, vspan.id);
202
+ const victimBundle = await finalizeTrace(victimRun);
203
+
204
+ const replayed = await verifyToolReceipts(victimBundle, {
205
+ keys: { "tee://oracle": pem(publicKey) },
206
+ });
207
+ expect(replayed.results[0]!.verified).toBe(false);
208
+ expect(replayed.results[0]!.reason).toBe("call-binding-mismatch");
209
+ });
210
+
211
+ it("external webhook receipts verify for authenticity but are marked not-call-bound", async () => {
212
+ const secret = "whsec_test_secret";
213
+ const body = JSON.stringify({ id: "evt_1", type: "charge.succeeded" });
214
+ const t = 1_900_000_000;
215
+ const v1 = createHmac("sha256", secret).update(`${t}.${body}`).digest("hex");
216
+ const bundle = await traceWith({
217
+ receipt: {
218
+ scheme: "stripe-webhook",
219
+ signer: "acct_123",
220
+ signature: `t=${t},v1=${v1}`,
221
+ signedPayload: body,
222
+ },
223
+ request: { hash: await hashToolPayload({ q: 1 }) },
224
+ response: { hash: await hashToolPayload(body), payload: body },
225
+ });
226
+ const outcome = await verifyToolReceipts(bundle, {
227
+ keys: { acct_123: secret },
228
+ nowSec: t + 10,
229
+ });
230
+ expect(outcome.results[0]!.verified).toBe(true);
231
+ expect(outcome.results[0]!.callBound).toBe(false);
232
+ });
233
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @summary Round-3 hardening regression tests for tool-call receipts (#60).
3
+ *
4
+ * GitHub webhook signatures carry no timestamp, so freshness cannot be enforced
5
+ * cryptographically — a genuine receipt replays across traces. When the caller
6
+ * DOES record a `receipt.params.timestamp`, the GitHub verifier must enforce the
7
+ * same tolerance window Stripe uses, so a stale receipt is rejected. Without a
8
+ * timestamp the scheme stays authenticity-only (anti-replay via the bundle
9
+ * Merkle commitment), which we assert still verifies.
10
+ */
11
+
12
+ import { describe, it, expect } from "vitest";
13
+ import { createHmac } from "node:crypto";
14
+ import {
15
+ createTrace,
16
+ addSpan,
17
+ closeSpan,
18
+ finalizeTrace,
19
+ } from "@fluxpointstudios/orynq-sdk-process-trace";
20
+ import type {
21
+ TraceRun,
22
+ TraceBundle,
23
+ ToolReceiptEvent,
24
+ } from "@fluxpointstudios/orynq-sdk-process-trace";
25
+ import { addToolReceipt, hashToolPayload, verifyToolReceipts } from "../index.js";
26
+
27
+ async function traceWith(receipt: ToolReceiptEvent["receipt"], body: string): Promise<TraceBundle> {
28
+ const run: TraceRun = await createTrace({ agentId: "agent-h3" });
29
+ const span = addSpan(run, { name: "tool-call", visibility: "public" });
30
+ await addToolReceipt(run, span.id, {
31
+ toolId: "gh.webhook",
32
+ request: { hash: await hashToolPayload({ q: 1 }) },
33
+ response: { hash: await hashToolPayload(body), payload: body },
34
+ receipt,
35
+ });
36
+ await closeSpan(run, span.id);
37
+ return finalizeTrace(run);
38
+ }
39
+
40
+ describe("GitHub webhook freshness when a timestamp is recorded (#60 round-3)", () => {
41
+ const secret = "gh_webhook_secret";
42
+ const body = JSON.stringify({ action: "opened", number: 42 });
43
+ const t = 1_900_000_000;
44
+ const sig = "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
45
+
46
+ it("rejects a stale GitHub receipt when params.timestamp is outside tolerance", async () => {
47
+ const bundle = await traceWith(
48
+ {
49
+ scheme: "github-webhook",
50
+ signer: "gh:org/repo",
51
+ signature: sig,
52
+ signedPayload: body,
53
+ params: { timestamp: String(t) },
54
+ },
55
+ body
56
+ );
57
+ const outcome = await verifyToolReceipts(bundle, {
58
+ keys: { "gh:org/repo": secret },
59
+ nowSec: t + 10_000, // far outside the 300s window
60
+ toleranceSec: 300,
61
+ });
62
+ expect(outcome.results[0]!.verified).toBe(false);
63
+ expect(outcome.results[0]!.error).toMatch(/tolerance/i);
64
+ });
65
+
66
+ it("verifies a fresh GitHub receipt when params.timestamp is within tolerance", async () => {
67
+ const bundle = await traceWith(
68
+ {
69
+ scheme: "github-webhook",
70
+ signer: "gh:org/repo",
71
+ signature: sig,
72
+ signedPayload: body,
73
+ params: { timestamp: String(t) },
74
+ },
75
+ body
76
+ );
77
+ const outcome = await verifyToolReceipts(bundle, {
78
+ keys: { "gh:org/repo": secret },
79
+ nowSec: t + 30,
80
+ toleranceSec: 300,
81
+ });
82
+ expect(outcome.results[0]!.verified).toBe(true);
83
+ });
84
+
85
+ it("still verifies a GitHub receipt with no timestamp (Merkle-committed anti-replay)", async () => {
86
+ const bundle = await traceWith(
87
+ { scheme: "github-webhook", signer: "gh:org/repo", signature: sig, signedPayload: body },
88
+ body
89
+ );
90
+ const outcome = await verifyToolReceipts(bundle, { keys: { "gh:org/repo": secret } });
91
+ expect(outcome.results[0]!.verified).toBe(true);
92
+ });
93
+ });
@@ -0,0 +1,265 @@
1
+ /**
2
+ * @summary Round-4 hardening regression tests for tool-call receipts (#60).
3
+ *
4
+ * Closes the request-attribution + call-binding gaps left after round 3:
5
+ * - A genuine signed JWS response must not be attributable to a FABRICATED
6
+ * request. A JWS that binds only `runId` (no `requestHash`) is NOT call-bound,
7
+ * and `callBound` must reflect that.
8
+ * - `callBound` must be TRUE only when a binding was actually signed AND both
9
+ * the runId and requestHash matched — never hard-coded per scheme.
10
+ * - A tampered JWS signature must be caught deterministically (decoded-byte flip).
11
+ * - The algorithm-confusion guard must reject a DER-encoded SPKI public key fed
12
+ * into the HMAC path (raw Uint8Array, not just PEM/JWK).
13
+ */
14
+
15
+ import { describe, it, expect } from "vitest";
16
+ import { generateKeyPairSync } from "node:crypto";
17
+ import {
18
+ createTrace,
19
+ addSpan,
20
+ closeSpan,
21
+ finalizeTrace,
22
+ } from "@fluxpointstudios/orynq-sdk-process-trace";
23
+ import type {
24
+ TraceRun,
25
+ TraceBundle,
26
+ ToolReceiptEvent,
27
+ } from "@fluxpointstudios/orynq-sdk-process-trace";
28
+ import {
29
+ addToolReceipt,
30
+ hashToolPayload,
31
+ verifyToolReceipts,
32
+ createSigningProxy,
33
+ } from "../index.js";
34
+
35
+ const pem = (k: ReturnType<typeof generateKeyPairSync>["publicKey"]): string =>
36
+ k.export({ type: "spki", format: "pem" }).toString();
37
+
38
+ /** Record a receipt whose request/response payloads are as given. */
39
+ async function traceWithReceipt(
40
+ receipt: ToolReceiptEvent["receipt"],
41
+ requestPayload: unknown,
42
+ responsePayload: unknown
43
+ ): Promise<TraceBundle> {
44
+ const run: TraceRun = await createTrace({ agentId: "agent-h4" });
45
+ const span = addSpan(run, { name: "tool-call", visibility: "public" });
46
+ await addToolReceipt(run, span.id, {
47
+ toolId: "demo.tool",
48
+ request: { hash: await hashToolPayload(requestPayload) },
49
+ response: { hash: await hashToolPayload(responsePayload), payload: responsePayload },
50
+ receipt,
51
+ });
52
+ await closeSpan(run, span.id);
53
+ return finalizeTrace(run);
54
+ }
55
+
56
+ describe("JWS request-attribution (#60 round-4)", () => {
57
+ it("a JWS binding only runId is NOT call-bound (request is unauthenticated)", async () => {
58
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
59
+ const run: TraceRun = await createTrace({ agentId: "agent-h4" });
60
+ // Bind only runId — no requestHash — mimicking a signer that scopes the run
61
+ // but leaves the request unauthenticated.
62
+ const proxy = createSigningProxy({
63
+ signer: "tee://oracle",
64
+ alg: "EdDSA",
65
+ privateKey,
66
+ publicKey: pem(publicKey),
67
+ binding: { runId: run.id },
68
+ });
69
+ const payload = { price: 100 };
70
+ const receipt = proxy.sign(payload);
71
+ const span = addSpan(run, { name: "tool-call", visibility: "public" });
72
+ await addToolReceipt(run, span.id, {
73
+ toolId: "demo.tool",
74
+ request: { hash: await hashToolPayload({ q: "genuine" }) },
75
+ response: { hash: await hashToolPayload(payload), payload },
76
+ receipt,
77
+ });
78
+ await closeSpan(run, span.id);
79
+ const bundle = await finalizeTrace(run);
80
+
81
+ const outcome = await verifyToolReceipts(bundle, { keys: { "tee://oracle": pem(publicKey) } });
82
+ // Signature + response binding are honest, so the receipt still verifies...
83
+ expect(outcome.results[0]!.verified).toBe(true);
84
+ // ...but the request was never signed, so it MUST NOT be reported call-bound.
85
+ expect(outcome.results[0]!.callBound).toBe(false);
86
+ });
87
+
88
+ it("a fully-bound JWS whose signed requestHash != recorded request.hash is rejected", async () => {
89
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
90
+ const run: TraceRun = await createTrace({ agentId: "agent-h4" });
91
+ const payload = { price: 100 };
92
+ // Signer commits to the hash of the GENUINE request it actually served.
93
+ const genuineRequestHash = await hashToolPayload({ q: "genuine" });
94
+ const proxy = createSigningProxy({
95
+ signer: "tee://oracle",
96
+ alg: "EdDSA",
97
+ privateKey,
98
+ publicKey: pem(publicKey),
99
+ binding: { runId: run.id, requestHash: genuineRequestHash },
100
+ });
101
+ const receipt = proxy.sign(payload);
102
+ // Attacker records the genuine signed response next to a FABRICATED request.
103
+ const span = addSpan(run, { name: "tool-call", visibility: "public" });
104
+ await addToolReceipt(run, span.id, {
105
+ toolId: "demo.tool",
106
+ request: { hash: await hashToolPayload({ q: "FABRICATED" }) },
107
+ response: { hash: await hashToolPayload(payload), payload },
108
+ receipt,
109
+ });
110
+ await closeSpan(run, span.id);
111
+ const bundle = await finalizeTrace(run);
112
+
113
+ const outcome = await verifyToolReceipts(bundle, { keys: { "tee://oracle": pem(publicKey) } });
114
+ expect(outcome.results[0]!.verified).toBe(false);
115
+ expect(outcome.results[0]!.reason).toBe("call-binding-mismatch");
116
+ });
117
+
118
+ it("a fully-bound JWS matching runId + requestHash is call-bound", async () => {
119
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
120
+ const run: TraceRun = await createTrace({ agentId: "agent-h4" });
121
+ const payload = { price: 100 };
122
+ const req = { q: "genuine" };
123
+ const requestHash = await hashToolPayload(req);
124
+ const proxy = createSigningProxy({
125
+ signer: "tee://oracle",
126
+ alg: "EdDSA",
127
+ privateKey,
128
+ publicKey: pem(publicKey),
129
+ binding: { runId: run.id, requestHash },
130
+ });
131
+ const receipt = proxy.sign(payload);
132
+ const span = addSpan(run, { name: "tool-call", visibility: "public" });
133
+ await addToolReceipt(run, span.id, {
134
+ toolId: "demo.tool",
135
+ request: { hash: requestHash },
136
+ response: { hash: await hashToolPayload(payload), payload },
137
+ receipt,
138
+ });
139
+ await closeSpan(run, span.id);
140
+ const bundle = await finalizeTrace(run);
141
+
142
+ const outcome = await verifyToolReceipts(bundle, { keys: { "tee://oracle": pem(publicKey) } });
143
+ expect(outcome.results[0]!.verified).toBe(true);
144
+ expect(outcome.results[0]!.callBound).toBe(true);
145
+ });
146
+ });
147
+
148
+ describe("callBound reflects reality, not scheme (#60 round-4)", () => {
149
+ it("a no-binding JWS is verified but NOT call-bound", async () => {
150
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
151
+ const proxy = createSigningProxy({
152
+ signer: "tee://oracle",
153
+ alg: "EdDSA",
154
+ privateKey,
155
+ publicKey: pem(publicKey),
156
+ // no binding
157
+ });
158
+ const payload = { price: 100 };
159
+ const bundle = await traceWithReceipt(proxy.sign(payload), { q: 1 }, payload);
160
+ const outcome = await verifyToolReceipts(bundle, { keys: { "tee://oracle": pem(publicKey) } });
161
+ expect(outcome.results[0]!.verified).toBe(true);
162
+ expect(outcome.results[0]!.callBound).toBe(false);
163
+ });
164
+
165
+ it("requireCallBinding rejects a JWS receipt that did not bind the call", async () => {
166
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
167
+ const proxy = createSigningProxy({
168
+ signer: "tee://oracle",
169
+ alg: "EdDSA",
170
+ privateKey,
171
+ publicKey: pem(publicKey),
172
+ });
173
+ const payload = { price: 100 };
174
+ const bundle = await traceWithReceipt(proxy.sign(payload), { q: 1 }, payload);
175
+ const outcome = await verifyToolReceipts(bundle, {
176
+ keys: { "tee://oracle": pem(publicKey) },
177
+ requireCallBinding: true,
178
+ });
179
+ expect(outcome.results[0]!.verified).toBe(false);
180
+ expect(outcome.results[0]!.reason).toBe("call-binding-required");
181
+ });
182
+
183
+ it("webhook receipts are never reported call-bound", async () => {
184
+ const { createHmac } = await import("node:crypto");
185
+ const secret = "gh_webhook_secret";
186
+ const body = JSON.stringify({ action: "opened", number: 42 });
187
+ const sig = "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
188
+ const bundle = await traceWithReceipt(
189
+ { scheme: "github-webhook", signer: "gh:org/repo", signature: sig, signedPayload: body },
190
+ { q: 1 },
191
+ body
192
+ );
193
+ const outcome = await verifyToolReceipts(bundle, { keys: { "gh:org/repo": secret } });
194
+ expect(outcome.results[0]!.verified).toBe(true);
195
+ expect(outcome.results[0]!.callBound).toBe(false);
196
+ });
197
+
198
+ it("requireCallBinding rejects a webhook receipt (request-attribution not provable)", async () => {
199
+ const { createHmac } = await import("node:crypto");
200
+ const secret = "gh_webhook_secret";
201
+ const body = JSON.stringify({ action: "opened", number: 42 });
202
+ const sig = "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
203
+ const bundle = await traceWithReceipt(
204
+ { scheme: "github-webhook", signer: "gh:org/repo", signature: sig, signedPayload: body },
205
+ { q: 1 },
206
+ body
207
+ );
208
+ const outcome = await verifyToolReceipts(bundle, {
209
+ keys: { "gh:org/repo": secret },
210
+ requireCallBinding: true,
211
+ });
212
+ expect(outcome.results[0]!.verified).toBe(false);
213
+ expect(outcome.results[0]!.reason).toBe("call-binding-required");
214
+ });
215
+ });
216
+
217
+ describe("tampered JWS signature is caught deterministically (#60 round-4)", () => {
218
+ it("flipping a decoded signature byte fails verification", async () => {
219
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
220
+ const proxy = createSigningProxy({ signer: "tee://x", alg: "EdDSA", privateKey, publicKey: pem(publicKey) });
221
+ const payload = { a: 1 };
222
+ const receipt = proxy.sign(payload);
223
+ // Deterministic tamper: flip a byte in the DECODED signature, then re-encode.
224
+ const sigBytes = Buffer.from(receipt.signature, "base64url");
225
+ sigBytes[Math.floor(sigBytes.length / 2)] ^= 0xff;
226
+ receipt.signature = sigBytes.toString("base64url");
227
+ const bundle = await traceWithReceipt(receipt, { q: 1 }, payload);
228
+ const outcome = await verifyToolReceipts(bundle, { keys: { "tee://x": pem(publicKey) } });
229
+ expect(outcome.results[0]!.verified).toBe(false);
230
+ });
231
+ });
232
+
233
+ describe("algorithm-confusion guard covers raw DER SPKI keys (#60 round-4)", () => {
234
+ it("rejects an HS256 JWS whose HMAC 'secret' is a DER-encoded public key (Uint8Array)", async () => {
235
+ // The victim's real ES256 public key, exported as DER SPKI bytes. An attacker
236
+ // sets alg:HS256 and hopes the verifier HMACs with this public material.
237
+ const { publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
238
+ const derSpki = publicKey.export({ type: "spki", format: "der" }) as Buffer;
239
+
240
+ // Craft an HS256 JWS the attacker "signs" with the public DER bytes as key.
241
+ const { createHmac } = await import("node:crypto");
242
+ const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
243
+ const body = Buffer.from(JSON.stringify({ price: 1 })).toString("base64url");
244
+ const signingInput = `${header}.${body}`;
245
+ const forgedSig = createHmac("sha256", derSpki).update(signingInput).digest("base64url");
246
+
247
+ const bundle = await traceWithReceipt(
248
+ {
249
+ scheme: "jws",
250
+ signer: "tee://victim",
251
+ signature: forgedSig,
252
+ signedPayload: signingInput,
253
+ },
254
+ { q: 1 },
255
+ { price: 1 }
256
+ );
257
+ // Auditor resolves the victim's key as raw DER bytes (Uint8Array).
258
+ const outcome = await verifyToolReceipts(bundle, {
259
+ resolveKey: () => new Uint8Array(derSpki),
260
+ });
261
+ expect(outcome.results[0]!.verified).toBe(false);
262
+ // The guard fired (algorithm confusion), landing in `error`, not a silent pass.
263
+ expect(outcome.results[0]!.error ?? "").toMatch(/algorithm confusion|asymmetric public key/i);
264
+ });
265
+ });