@fluxpointstudios/orynq-sdk-process-trace 0.2.0 → 0.3.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.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @summary Round-4 hardening for eip712 governance attestations (#58/#77).
3
+ *
4
+ * For the eip712 scheme the signature is over the typed EIP-712 message, not the
5
+ * sr25519/ed25519 preimage — so `signedAt` was neither required in the pinned
6
+ * type nor cross-checked, leaving the attestation timestamp unbound (a signed
7
+ * sign-off could replay/backdate freely). This suite pins:
8
+ * - the verifier factory REJECTS a pinned type that omits `signedAt`,
9
+ * - a signed message whose `signedAt` != event.signedAt is rejected,
10
+ * - a stale attestation (outside the freshness window) is rejected,
11
+ * - a fresh, fully-bound attestation verifies.
12
+ */
13
+
14
+ import { describe, it, expect } from "vitest";
15
+ import {
16
+ createTrace,
17
+ addSpan,
18
+ addEvent,
19
+ closeSpan,
20
+ finalizeTrace,
21
+ verifyGovernanceAttestations,
22
+ createEip712GovernanceVerifier,
23
+ } from "../index.js";
24
+ import type { TraceRun, TraceBundle } from "../index.js";
25
+
26
+ const EIP712_ATTESTOR = "0x1111111111111111111111111111111111111111";
27
+ const EIP712_DOMAIN = { name: "Orynq", version: "1" };
28
+ const EIP712_TYPES = {
29
+ Attestation: [
30
+ { name: "role", type: "string" },
31
+ { name: "policyRef", type: "string" },
32
+ { name: "decisionRef", type: "string" },
33
+ { name: "runId", type: "string" },
34
+ { name: "signedAt", type: "string" },
35
+ ],
36
+ };
37
+
38
+ async function buildEip712Bundle(opts: {
39
+ eventSignedAt: string;
40
+ messageSignedAt: string;
41
+ }): Promise<TraceBundle> {
42
+ const run: TraceRun = await createTrace({ agentId: "agent-g4" });
43
+ const span = addSpan(run, { name: "approve", visibility: "public" });
44
+ await addEvent(run, span.id, {
45
+ kind: "governance-attestation",
46
+ visibility: "public",
47
+ role: "release-authority",
48
+ policyRef: "sha256:policy",
49
+ decisionRef: "decision-1",
50
+ attestor: { address: EIP712_ATTESTOR, signatureScheme: "eip712" },
51
+ signature: "0x" + "ab".repeat(65),
52
+ signedAt: opts.eventSignedAt,
53
+ eip712: {
54
+ domain: EIP712_DOMAIN,
55
+ types: EIP712_TYPES,
56
+ primaryType: "Attestation",
57
+ message: {
58
+ role: "release-authority",
59
+ policyRef: "sha256:policy",
60
+ decisionRef: "decision-1",
61
+ runId: run.id,
62
+ signedAt: opts.messageSignedAt,
63
+ },
64
+ },
65
+ });
66
+ await closeSpan(run, span.id);
67
+ return finalizeTrace(run);
68
+ }
69
+
70
+ describe("eip712 signedAt binding (#77 round-4)", () => {
71
+ it("the factory rejects a pinned type that omits signedAt", () => {
72
+ expect(() =>
73
+ createEip712GovernanceVerifier({
74
+ verifyTypedData: async () => true,
75
+ expectedDomain: EIP712_DOMAIN,
76
+ expectedPrimaryType: "Attestation",
77
+ expectedTypes: {
78
+ Attestation: [
79
+ { name: "role", type: "string" },
80
+ { name: "policyRef", type: "string" },
81
+ { name: "decisionRef", type: "string" },
82
+ { name: "runId", type: "string" },
83
+ // signedAt intentionally omitted
84
+ ],
85
+ },
86
+ })
87
+ ).toThrow(/signedAt/);
88
+ });
89
+
90
+ it("rejects an attestation whose signed message.signedAt != event.signedAt", async () => {
91
+ const now = "2026-07-11T00:00:00.000Z";
92
+ const bundle = await buildEip712Bundle({
93
+ eventSignedAt: now,
94
+ messageSignedAt: "2020-01-01T00:00:00.000Z", // backdated in the recorded event
95
+ });
96
+ const verifier = createEip712GovernanceVerifier({
97
+ verifyTypedData: async () => true, // signature accepted; binding must still reject
98
+ expectedDomain: EIP712_DOMAIN,
99
+ expectedPrimaryType: "Attestation",
100
+ expectedTypes: EIP712_TYPES,
101
+ nowMs: Date.parse(now),
102
+ });
103
+ const summaries = await verifyGovernanceAttestations(bundle, {
104
+ verifiers: { eip712: verifier },
105
+ authorizedAttestors: [EIP712_ATTESTOR],
106
+ });
107
+ expect(summaries[0]!.authorized).toBe(true);
108
+ expect(summaries[0]!.verified).toBe(false);
109
+ });
110
+
111
+ it("rejects a stale attestation outside the freshness window", async () => {
112
+ const stale = "2020-01-01T00:00:00.000Z";
113
+ const bundle = await buildEip712Bundle({
114
+ eventSignedAt: stale,
115
+ messageSignedAt: stale, // consistent, but far in the past
116
+ });
117
+ const verifier = createEip712GovernanceVerifier({
118
+ verifyTypedData: async () => true,
119
+ expectedDomain: EIP712_DOMAIN,
120
+ expectedPrimaryType: "Attestation",
121
+ expectedTypes: EIP712_TYPES,
122
+ nowMs: Date.parse("2026-07-11T00:00:00.000Z"),
123
+ freshnessToleranceMs: 5 * 60_000, // 5 minutes
124
+ });
125
+ const summaries = await verifyGovernanceAttestations(bundle, {
126
+ verifiers: { eip712: verifier },
127
+ authorizedAttestors: [EIP712_ATTESTOR],
128
+ });
129
+ expect(summaries[0]!.verified).toBe(false);
130
+ });
131
+
132
+ it("verifies a fresh, fully-bound attestation", async () => {
133
+ const now = "2026-07-11T00:00:00.000Z";
134
+ const bundle = await buildEip712Bundle({ eventSignedAt: now, messageSignedAt: now });
135
+ const verifier = createEip712GovernanceVerifier({
136
+ verifyTypedData: async () => true,
137
+ expectedDomain: EIP712_DOMAIN,
138
+ expectedPrimaryType: "Attestation",
139
+ expectedTypes: EIP712_TYPES,
140
+ nowMs: Date.parse(now),
141
+ freshnessToleranceMs: 5 * 60_000,
142
+ });
143
+ const summaries = await verifyGovernanceAttestations(bundle, {
144
+ verifiers: { eip712: verifier },
145
+ authorizedAttestors: [EIP712_ATTESTOR],
146
+ });
147
+ expect(summaries[0]!.verified).toBe(true);
148
+ });
149
+ });
@@ -0,0 +1,382 @@
1
+ /**
2
+ * @summary Tests for governance attestations (issue #58).
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach } from "vitest";
6
+ import {
7
+ createTrace,
8
+ addSpan,
9
+ addEvent,
10
+ closeSpan,
11
+ finalizeTrace,
12
+ verifyBundle,
13
+ addGovernanceAttestation,
14
+ createSr25519GovernanceSigner,
15
+ createEd25519GovernanceSigner,
16
+ verifyGovernanceAttestations,
17
+ createEip712GovernanceVerifier,
18
+ governanceAttestationPreimage,
19
+ } from "../index.js";
20
+ import type { TraceRun, TraceBundle, GovernanceAttestationEvent } from "../index.js";
21
+
22
+ const SEED_A = "0x" + "11".repeat(32);
23
+ const SEED_B = "0x" + "22".repeat(32);
24
+
25
+ // The release-authority address derived from SEED_A — the caller must allow-list
26
+ // the attestor for a governance verdict to pass (attestor identity is untrusted).
27
+ let AUTHORITY_SR25519 = "";
28
+ let AUTHORITY_ED25519 = "";
29
+
30
+ const EIP712_ATTESTOR = "0x1111111111111111111111111111111111111111";
31
+ const EIP712_DOMAIN = { name: "Orynq", version: "1" };
32
+ // `signedAt` is a REQUIRED signed field (#77 round-4): the pinned type must
33
+ // declare it so the signature commits to the timestamp, and the verifier
34
+ // cross-checks it against the event + a freshness window.
35
+ const EIP712_TYPES = {
36
+ Attestation: [
37
+ { name: "role", type: "string" },
38
+ { name: "policyRef", type: "string" },
39
+ { name: "decisionRef", type: "string" },
40
+ { name: "runId", type: "string" },
41
+ { name: "signedAt", type: "string" },
42
+ ],
43
+ };
44
+
45
+ async function buildAttestedBundle(scheme: "sr25519" | "ed25519"): Promise<TraceBundle> {
46
+ const run: TraceRun = await createTrace({ agentId: "agent-1" });
47
+ const span = addSpan(run, { name: "release", visibility: "public" });
48
+ const decision = await addEvent(run, span.id, {
49
+ kind: "decision",
50
+ decision: "ship model v2",
51
+ visibility: "public",
52
+ });
53
+
54
+ const signer =
55
+ scheme === "sr25519"
56
+ ? await createSr25519GovernanceSigner({ seed: SEED_A })
57
+ : await createEd25519GovernanceSigner({ seed: SEED_A });
58
+ if (scheme === "sr25519") AUTHORITY_SR25519 = signer.address;
59
+ else AUTHORITY_ED25519 = signer.address;
60
+
61
+ await addGovernanceAttestation(run, span.id, {
62
+ role: "release-authority",
63
+ policyRef: "sha256:policy-doc-hash",
64
+ decisionRef: decision.id,
65
+ signer,
66
+ });
67
+
68
+ await closeSpan(run, span.id);
69
+ return finalizeTrace(run);
70
+ }
71
+
72
+ describe("governanceAttestationPreimage", () => {
73
+ it("is deterministic and order-sensitive", () => {
74
+ const a = governanceAttestationPreimage({
75
+ role: "compliance",
76
+ policyRef: "p",
77
+ decisionRef: "d",
78
+ signedAt: "2026-01-01T00:00:00.000Z",
79
+ });
80
+ const b = governanceAttestationPreimage({
81
+ role: "compliance",
82
+ policyRef: "p",
83
+ decisionRef: "d",
84
+ signedAt: "2026-01-01T00:00:00.000Z",
85
+ });
86
+ const c = governanceAttestationPreimage({
87
+ role: "compliance",
88
+ policyRef: "p",
89
+ decisionRef: "d2",
90
+ signedAt: "2026-01-01T00:00:00.000Z",
91
+ });
92
+ expect(Buffer.from(a)).toEqual(Buffer.from(b));
93
+ expect(Buffer.from(a)).not.toEqual(Buffer.from(c));
94
+ });
95
+
96
+ it("is collision-resistant across attacker-controlled field boundaries", () => {
97
+ // role/policyRef are attacker-supplied. A raw delimiter (e.g. "\n") would let
98
+ // two DIFFERENT tuples serialize to the SAME bytes by sliding the boundary
99
+ // through a field value, so one signature could be re-bound to a different
100
+ // claim. Length-prefixing makes the boundary unambiguous.
101
+ const a = governanceAttestationPreimage({
102
+ role: "compliance",
103
+ policyRef: "policy\nDECISION",
104
+ decisionRef: "d",
105
+ signedAt: "2026-01-01T00:00:00.000Z",
106
+ });
107
+ const b = governanceAttestationPreimage({
108
+ role: "compliance\npolicy",
109
+ policyRef: "DECISION",
110
+ decisionRef: "d",
111
+ signedAt: "2026-01-01T00:00:00.000Z",
112
+ });
113
+ // Under naive "\n"-join these collide; under length-prefixing they must not.
114
+ expect(Buffer.from(a)).not.toEqual(Buffer.from(b));
115
+ });
116
+ });
117
+
118
+ describe("addGovernanceAttestation + verify (sr25519)", () => {
119
+ let bundle: TraceBundle;
120
+
121
+ beforeEach(async () => {
122
+ bundle = await buildAttestedBundle("sr25519");
123
+ });
124
+
125
+ it("records a governance-attestation event with the expected shape", () => {
126
+ const events = bundle.privateRun.events.filter(
127
+ (e): e is GovernanceAttestationEvent => e.kind === "governance-attestation"
128
+ );
129
+ expect(events).toHaveLength(1);
130
+ const ev = events[0]!;
131
+ expect(ev.role).toBe("release-authority");
132
+ expect(ev.attestor.signatureScheme).toBe("sr25519");
133
+ expect(ev.attestor.address.length).toBeGreaterThan(0);
134
+ expect(ev.signature.startsWith("0x")).toBe(true);
135
+ expect(ev.visibility).toBe("public");
136
+ });
137
+
138
+ it("the underlying bundle still verifies (event hashes intact)", async () => {
139
+ const result = await verifyBundle(bundle);
140
+ expect(result.valid).toBe(true);
141
+ });
142
+
143
+ it("verifyGovernanceAttestations returns verified=true for an allow-listed signer", async () => {
144
+ const summaries = await verifyGovernanceAttestations(bundle, {
145
+ authorizedAttestors: [AUTHORITY_SR25519],
146
+ });
147
+ expect(summaries).toHaveLength(1);
148
+ expect(summaries[0]!.verified).toBe(true);
149
+ expect(summaries[0]!.authorized).toBe(true);
150
+ expect(summaries[0]!.scheme).toBe("sr25519");
151
+ expect(summaries[0]!.role).toBe("release-authority");
152
+ });
153
+
154
+ it("verifyBundle({ governance: {...} }) sets governanceValid for an allow-listed signer", async () => {
155
+ const result = await verifyBundle(bundle, {
156
+ governance: { authorizedAttestors: [AUTHORITY_SR25519] },
157
+ });
158
+ expect(result.checks.governanceValid).toBe(true);
159
+ expect(result.valid).toBe(true);
160
+ });
161
+
162
+ it("flags a tampered signature", async () => {
163
+ const tampered = JSON.parse(JSON.stringify(bundle)) as TraceBundle;
164
+ const ev = tampered.privateRun.events.find(
165
+ (e) => e.kind === "governance-attestation"
166
+ ) as GovernanceAttestationEvent;
167
+ // Flip a hex nibble in the signature so it no longer verifies.
168
+ ev.signature = ev.signature.slice(0, -1) + (ev.signature.endsWith("0") ? "1" : "0");
169
+
170
+ // Allow-list the genuine signer so the failure is proven to come from the
171
+ // signature check, not the authorization gate.
172
+ const summaries = await verifyGovernanceAttestations(tampered, {
173
+ authorizedAttestors: [AUTHORITY_SR25519],
174
+ });
175
+ expect(summaries[0]!.authorized).toBe(true);
176
+ expect(summaries[0]!.verified).toBe(false);
177
+
178
+ const result = await verifyBundle(tampered, {
179
+ governance: { authorizedAttestors: [AUTHORITY_SR25519] },
180
+ });
181
+ expect(result.checks.governanceValid).toBe(false);
182
+ expect(result.valid).toBe(false);
183
+ });
184
+
185
+ it("rejects a signature from a different attestor address", async () => {
186
+ const otherSigner = await createSr25519GovernanceSigner({ seed: SEED_B });
187
+ const tampered = JSON.parse(JSON.stringify(bundle)) as TraceBundle;
188
+ const ev = tampered.privateRun.events.find(
189
+ (e) => e.kind === "governance-attestation"
190
+ ) as GovernanceAttestationEvent;
191
+ ev.attestor.address = otherSigner.address; // signature no longer matches the claimed signer
192
+
193
+ // Allow-list the spoofed address, so a pass would require the signature to
194
+ // actually verify under it — proving the crypto check (not just auth) rejects.
195
+ const summaries = await verifyGovernanceAttestations(tampered, {
196
+ authorizedAttestors: [otherSigner.address],
197
+ });
198
+ expect(summaries[0]!.authorized).toBe(true);
199
+ expect(summaries[0]!.verified).toBe(false);
200
+ });
201
+ });
202
+
203
+ describe("addGovernanceAttestation + verify (ed25519)", () => {
204
+ it("verifies an ed25519 attestation", async () => {
205
+ const bundle = await buildAttestedBundle("ed25519");
206
+ const summaries = await verifyGovernanceAttestations(bundle, {
207
+ authorizedAttestors: [AUTHORITY_ED25519],
208
+ });
209
+ expect(summaries[0]!.verified).toBe(true);
210
+ expect(summaries[0]!.scheme).toBe("ed25519");
211
+ });
212
+ });
213
+
214
+ describe("governance replay resistance (#58)", () => {
215
+ it("preimage binds runId so an attestation cannot replay into another trace", () => {
216
+ const a = governanceAttestationPreimage({
217
+ role: "compliance",
218
+ policyRef: "p",
219
+ decisionRef: "d",
220
+ signedAt: "2026-01-01T00:00:00.000Z",
221
+ runId: "run-A",
222
+ });
223
+ const b = governanceAttestationPreimage({
224
+ role: "compliance",
225
+ policyRef: "p",
226
+ decisionRef: "d",
227
+ signedAt: "2026-01-01T00:00:00.000Z",
228
+ runId: "run-B",
229
+ });
230
+ expect(Buffer.from(a)).not.toEqual(Buffer.from(b));
231
+ });
232
+
233
+ it("a genuine attestation copied into a different trace fails", async () => {
234
+ const donor = await buildAttestedBundle("sr25519");
235
+ const donorEvent = donor.privateRun.events.find(
236
+ (e) => e.kind === "governance-attestation"
237
+ ) as GovernanceAttestationEvent;
238
+ // Allow-list the genuine donor signer for both traces, so the replay failure
239
+ // is proven to come from the run-id preimage binding, not the auth gate.
240
+ const allow = { authorizedAttestors: [donorEvent.attestor.address] };
241
+
242
+ // In the donor trace it verifies.
243
+ const honest = await verifyGovernanceAttestations(donor, allow);
244
+ expect(honest[0]!.verified).toBe(true);
245
+
246
+ // Build a victim trace and splice the donor's genuine attestation into it.
247
+ const victimRun = await createTrace({ agentId: "agent-2" });
248
+ const span = addSpan(victimRun, { name: "release", visibility: "public" });
249
+ await addEvent(victimRun, span.id, {
250
+ kind: "decision",
251
+ decision: "unrelated decision",
252
+ visibility: "public",
253
+ });
254
+ // Splice the genuine (donor-signed) attestation directly into the victim run.
255
+ await addEvent(victimRun, span.id, {
256
+ kind: "governance-attestation",
257
+ visibility: "public",
258
+ role: donorEvent.role,
259
+ policyRef: donorEvent.policyRef,
260
+ decisionRef: donorEvent.decisionRef,
261
+ attestor: donorEvent.attestor,
262
+ signature: donorEvent.signature,
263
+ signedAt: donorEvent.signedAt,
264
+ });
265
+ await closeSpan(victimRun, span.id);
266
+ const victim = await finalizeTrace(victimRun);
267
+
268
+ const replayed = await verifyGovernanceAttestations(victim, allow);
269
+ expect(replayed[0]!.authorized).toBe(true);
270
+ expect(replayed[0]!.verified).toBe(false);
271
+ });
272
+ });
273
+
274
+ describe("eip712 governance verification", () => {
275
+ it("marks eip712 unverified when no verifier is registered", async () => {
276
+ const run = await createTrace({ agentId: "agent-1" });
277
+ const span = addSpan(run, { name: "approve", visibility: "public" });
278
+ const signedAt = "2026-07-11T00:00:00.000Z";
279
+ // Construct an eip712 attestation directly (caller-supplied signature + binding).
280
+ await addEvent(run, span.id, {
281
+ kind: "governance-attestation",
282
+ visibility: "public",
283
+ role: "compliance",
284
+ policyRef: "sha256:policy",
285
+ decisionRef: "decision-1",
286
+ attestor: {
287
+ address: "0x1111111111111111111111111111111111111111",
288
+ signatureScheme: "eip712",
289
+ },
290
+ signature: "0x" + "ab".repeat(65),
291
+ signedAt,
292
+ eip712: {
293
+ domain: { name: "Orynq", version: "1" },
294
+ types: EIP712_TYPES,
295
+ primaryType: "Attestation",
296
+ // The signed message MUST carry the event's own claim + trace context.
297
+ message: {
298
+ role: "compliance",
299
+ policyRef: "sha256:policy",
300
+ decisionRef: "decision-1",
301
+ runId: run.id,
302
+ signedAt,
303
+ },
304
+ },
305
+ });
306
+ await closeSpan(run, span.id);
307
+ const bundle = await finalizeTrace(run);
308
+
309
+ const allow = { authorizedAttestors: [EIP712_ATTESTOR] };
310
+ const noVerifier = await verifyGovernanceAttestations(bundle, allow);
311
+ expect(noVerifier[0]!.verified).toBe(false);
312
+ expect(noVerifier[0]!.error).toMatch(/no verifier/i);
313
+
314
+ // With an injected verifier (mocked viem.verifyTypedData), it passes.
315
+ const verifier = createEip712GovernanceVerifier({
316
+ verifyTypedData: async (args) => {
317
+ expect(args.primaryType).toBe("Attestation");
318
+ expect(args.address).toBe(EIP712_ATTESTOR);
319
+ return true;
320
+ },
321
+ expectedDomain: EIP712_DOMAIN,
322
+ expectedPrimaryType: "Attestation",
323
+ expectedTypes: EIP712_TYPES,
324
+ nowMs: Date.parse(signedAt),
325
+ });
326
+ const withVerifier = await verifyGovernanceAttestations(bundle, {
327
+ verifiers: { eip712: verifier },
328
+ ...allow,
329
+ });
330
+ expect(withVerifier[0]!.verified).toBe(true);
331
+ });
332
+
333
+ it("rejects a forged eip712 whose signed message.role != event.role", async () => {
334
+ const run = await createTrace({ agentId: "agent-1" });
335
+ const span = addSpan(run, { name: "approve", visibility: "public" });
336
+ // Attacker holds a signature over a message claiming role "data-steward"
337
+ // but records the event as the higher-privilege "release-authority".
338
+ await addEvent(run, span.id, {
339
+ kind: "governance-attestation",
340
+ visibility: "public",
341
+ role: "release-authority",
342
+ policyRef: "sha256:policy",
343
+ decisionRef: "decision-1",
344
+ attestor: {
345
+ address: "0x1111111111111111111111111111111111111111",
346
+ signatureScheme: "eip712",
347
+ },
348
+ signature: "0x" + "ab".repeat(65),
349
+ signedAt: "2026-07-11T00:00:00.000Z",
350
+ eip712: {
351
+ domain: { name: "Orynq", version: "1" },
352
+ types: EIP712_TYPES,
353
+ primaryType: "Attestation",
354
+ message: {
355
+ role: "data-steward", // != event.role
356
+ policyRef: "sha256:policy",
357
+ decisionRef: "decision-1",
358
+ runId: run.id,
359
+ signedAt: "2026-07-11T00:00:00.000Z",
360
+ },
361
+ },
362
+ });
363
+ await closeSpan(run, span.id);
364
+ const bundle = await finalizeTrace(run);
365
+
366
+ // Even with a verifier that accepts the raw signature (and the signer
367
+ // allow-listed), the field-binding check must reject it.
368
+ const verifier = createEip712GovernanceVerifier({
369
+ verifyTypedData: async () => true,
370
+ expectedDomain: EIP712_DOMAIN,
371
+ expectedPrimaryType: "Attestation",
372
+ expectedTypes: EIP712_TYPES,
373
+ nowMs: Date.parse("2026-07-11T00:00:00.000Z"),
374
+ });
375
+ const summaries = await verifyGovernanceAttestations(bundle, {
376
+ verifiers: { eip712: verifier },
377
+ authorizedAttestors: [EIP712_ATTESTOR],
378
+ });
379
+ expect(summaries[0]!.authorized).toBe(true);
380
+ expect(summaries[0]!.verified).toBe(false);
381
+ });
382
+ });