@fluxpointstudios/orynq-sdk-process-trace 0.1.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,711 @@
1
+ /**
2
+ * @fileoverview Governance attestations (issue #58).
3
+ *
4
+ * Location: packages/process-trace/src/governance.ts
5
+ *
6
+ * A `governance-attestation` event records a verifiable, role-scoped sign-off
7
+ * inside a trace (compliance review, release approval, data-steward sign-off),
8
+ * so an auditor can answer "who governed this decision?" without trusting the
9
+ * wrapper that recorded it.
10
+ *
11
+ * Signing model:
12
+ * - The signature covers a canonical, domain-separated preimage of
13
+ * `(role || policyRef || decisionRef || signedAt)` — see
14
+ * {@link governanceAttestationPreimage}.
15
+ * - `sr25519` and `ed25519` are verified in-package via the OPTIONAL peer
16
+ * dependencies `@polkadot/util-crypto` + `@polkadot/util` (loaded with a
17
+ * dynamic `import()` so base installs stay lean).
18
+ * - `eip712` is verified via a pluggable {@link GovernanceVerifier} so this
19
+ * package never needs a hard dependency on viem. See
20
+ * {@link createEip712GovernanceVerifier}.
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * const signer = await createSr25519GovernanceSigner({ seed: "0x" + "11".repeat(32) });
25
+ * await addGovernanceAttestation(run, span.id, {
26
+ * role: "compliance",
27
+ * policyRef: "sha256:...",
28
+ * decisionRef: eventId,
29
+ * signer,
30
+ * });
31
+ * // ...later, during audit: the attestor identity comes from the untrusted
32
+ * // trace, so the caller MUST allow-list the authorized signer(s) — a valid
33
+ * // self-signed attestation from an arbitrary key is not a real sign-off.
34
+ * const summary = await verifyGovernanceAttestations(bundle, {
35
+ * authorizedAttestors: [signer.address],
36
+ * });
37
+ * // [{ role: "compliance", attestor: "5...", scheme: "sr25519", verified: true, authorized: true }]
38
+ * ```
39
+ */
40
+
41
+ import type {
42
+ TraceRun,
43
+ TraceEvent,
44
+ TraceBundle,
45
+ Visibility,
46
+ GovernanceAttestationEvent,
47
+ GovernanceSignatureScheme,
48
+ GovernanceEip712Binding,
49
+ } from "./types.js";
50
+ import { HASH_DOMAIN_PREFIXES } from "./types.js";
51
+ import { addEvent } from "./trace-builder.js";
52
+
53
+ // =============================================================================
54
+ // PREIMAGE
55
+ // =============================================================================
56
+
57
+ /** The fields that are bound by a governance signature. */
58
+ export interface GovernanceAttestationFields {
59
+ role: string;
60
+ policyRef: string;
61
+ decisionRef: string;
62
+ signedAt: string;
63
+ /**
64
+ * Trace run id this attestation is scoped to. Binding it prevents replaying a
65
+ * genuine attestation from trace X into an unrelated trace Y (#58).
66
+ */
67
+ runId: string;
68
+ }
69
+
70
+ /**
71
+ * Build the canonical, domain-separated preimage signed by sr25519/ed25519
72
+ * governance signers. Deterministic — a verifier reconstructs identical bytes
73
+ * from the recorded event fields plus the enclosing run id.
74
+ *
75
+ * Each field is LENGTH-PREFIXED (4-byte big-endian byte length) rather than
76
+ * delimiter-joined: a raw `\n` separator let two different tuples produce
77
+ * identical bytes when a field value (role/policyRef/decisionRef are
78
+ * attacker-supplied) itself contained `\n`, so one signature re-sliced to bind
79
+ * a different claim. Length-prefixing makes field boundaries unambiguous
80
+ * regardless of the field contents.
81
+ */
82
+ export function governanceAttestationPreimage(fields: GovernanceAttestationFields): Uint8Array {
83
+ const enc = new TextEncoder();
84
+ const domain = enc.encode(HASH_DOMAIN_PREFIXES.governance);
85
+ const parts = [
86
+ fields.runId,
87
+ fields.role,
88
+ fields.policyRef,
89
+ fields.decisionRef,
90
+ fields.signedAt,
91
+ ].map((v) => enc.encode(v));
92
+ const total = domain.length + parts.reduce((n, p) => n + 4 + p.length, 0);
93
+ const out = new Uint8Array(total);
94
+ let off = 0;
95
+ out.set(domain, off);
96
+ off += domain.length;
97
+ for (const p of parts) {
98
+ out[off] = (p.length >>> 24) & 0xff;
99
+ out[off + 1] = (p.length >>> 16) & 0xff;
100
+ out[off + 2] = (p.length >>> 8) & 0xff;
101
+ out[off + 3] = p.length & 0xff;
102
+ off += 4;
103
+ out.set(p, off);
104
+ off += p.length;
105
+ }
106
+ return out;
107
+ }
108
+
109
+ // =============================================================================
110
+ // SIGNER CONTRACT
111
+ // =============================================================================
112
+
113
+ /** Context passed to a {@link GovernanceSigner}. */
114
+ export interface GovernanceSignContext {
115
+ /** Canonical preimage bytes (sr25519/ed25519 signers sign these). */
116
+ preimage: Uint8Array;
117
+ /** The raw fields, for signers (e.g. eip712) that build their own payload. */
118
+ fields: GovernanceAttestationFields;
119
+ /** Present iff the caller supplied an eip712 binding. */
120
+ eip712?: GovernanceEip712Binding;
121
+ }
122
+
123
+ /**
124
+ * A governance signer. Consumers provide an implementation (HSM, KMS, wallet,
125
+ * or one of the built-in {@link createSr25519GovernanceSigner} /
126
+ * {@link createEd25519GovernanceSigner} factories).
127
+ */
128
+ export interface GovernanceSigner {
129
+ /** Verifier-resolvable identity (SS58 address for substrate, 0x-address for evm). */
130
+ address: string;
131
+ signatureScheme: GovernanceSignatureScheme;
132
+ /** Return the signature as a hex string (with or without `0x`). */
133
+ sign(ctx: GovernanceSignContext): Promise<string> | string;
134
+ }
135
+
136
+ // =============================================================================
137
+ // HELPER: addGovernanceAttestation
138
+ // =============================================================================
139
+
140
+ export interface AddGovernanceAttestationOptions {
141
+ role: GovernanceAttestationEvent["role"];
142
+ policyRef: string;
143
+ decisionRef: string;
144
+ signer: GovernanceSigner;
145
+ /** ISO 8601 timestamp; defaults to now. Included in the signed preimage. */
146
+ signedAt?: string;
147
+ /** Event visibility; defaults to "public" (governance is auditable). */
148
+ visibility?: Visibility;
149
+ /** EIP-712 binding — required when the signer scheme is "eip712". */
150
+ eip712?: GovernanceEip712Binding;
151
+ }
152
+
153
+ /**
154
+ * Sign and append a `governance-attestation` event to a span.
155
+ *
156
+ * @returns the recorded {@link GovernanceAttestationEvent} (with runtime fields).
157
+ */
158
+ export async function addGovernanceAttestation(
159
+ run: TraceRun,
160
+ spanId: string,
161
+ opts: AddGovernanceAttestationOptions
162
+ ): Promise<GovernanceAttestationEvent> {
163
+ if (!opts.role) throw new Error("addGovernanceAttestation: role is required");
164
+ if (!opts.policyRef) throw new Error("addGovernanceAttestation: policyRef is required");
165
+ if (!opts.decisionRef) throw new Error("addGovernanceAttestation: decisionRef is required");
166
+ if (!opts.signer) throw new Error("addGovernanceAttestation: signer is required");
167
+
168
+ if (opts.signer.signatureScheme === "eip712" && opts.eip712 === undefined) {
169
+ throw new Error(
170
+ "addGovernanceAttestation: an `eip712` binding is required for eip712 signers"
171
+ );
172
+ }
173
+
174
+ const signedAt = opts.signedAt ?? new Date().toISOString();
175
+ const fields: GovernanceAttestationFields = {
176
+ role: opts.role,
177
+ policyRef: opts.policyRef,
178
+ decisionRef: opts.decisionRef,
179
+ signedAt,
180
+ runId: run.id,
181
+ };
182
+ const preimage = governanceAttestationPreimage(fields);
183
+
184
+ const ctx: GovernanceSignContext = opts.eip712
185
+ ? { preimage, fields, eip712: opts.eip712 }
186
+ : { preimage, fields };
187
+ const signature = await opts.signer.sign(ctx);
188
+
189
+ const event: Omit<GovernanceAttestationEvent, "id" | "seq" | "timestamp" | "hash"> = {
190
+ kind: "governance-attestation",
191
+ visibility: opts.visibility ?? "public",
192
+ role: opts.role,
193
+ policyRef: opts.policyRef,
194
+ decisionRef: opts.decisionRef,
195
+ attestor: {
196
+ address: opts.signer.address,
197
+ signatureScheme: opts.signer.signatureScheme,
198
+ },
199
+ signature,
200
+ signedAt,
201
+ ...(opts.eip712 ? { eip712: opts.eip712 } : {}),
202
+ };
203
+
204
+ const recorded = await addEvent(run, spanId, event);
205
+ return recorded as GovernanceAttestationEvent;
206
+ }
207
+
208
+ // =============================================================================
209
+ // BUILT-IN SUBSTRATE SIGNERS (optional @polkadot peer dep)
210
+ // =============================================================================
211
+
212
+ interface PolkadotCrypto {
213
+ cryptoWaitReady: () => Promise<boolean>;
214
+ sr25519PairFromSeed: (seed: Uint8Array) => { publicKey: Uint8Array; secretKey: Uint8Array };
215
+ sr25519Sign: (
216
+ message: Uint8Array,
217
+ pair: { publicKey: Uint8Array; secretKey: Uint8Array }
218
+ ) => Uint8Array;
219
+ sr25519Verify: (message: Uint8Array, signature: Uint8Array, publicKey: Uint8Array) => boolean;
220
+ ed25519PairFromSeed: (seed: Uint8Array) => { publicKey: Uint8Array; secretKey: Uint8Array };
221
+ ed25519Sign: (
222
+ message: Uint8Array,
223
+ pair: { publicKey: Uint8Array; secretKey: Uint8Array }
224
+ ) => Uint8Array;
225
+ ed25519Verify: (message: Uint8Array, signature: Uint8Array, publicKey: Uint8Array) => boolean;
226
+ encodeAddress: (key: Uint8Array, ss58Format?: number) => string;
227
+ decodeAddress: (address: string) => Uint8Array;
228
+ }
229
+
230
+ interface PolkadotUtil {
231
+ u8aToHex: (value: Uint8Array) => string;
232
+ hexToU8a: (value: string) => Uint8Array;
233
+ }
234
+
235
+ let polkadotPromise: Promise<{ crypto: PolkadotCrypto; util: PolkadotUtil }> | null = null;
236
+
237
+ async function loadPolkadot(): Promise<{ crypto: PolkadotCrypto; util: PolkadotUtil }> {
238
+ if (!polkadotPromise) {
239
+ polkadotPromise = (async () => {
240
+ let crypto: PolkadotCrypto;
241
+ let util: PolkadotUtil;
242
+ try {
243
+ crypto = (await import("@polkadot/util-crypto")) as unknown as PolkadotCrypto;
244
+ util = (await import("@polkadot/util")) as unknown as PolkadotUtil;
245
+ } catch {
246
+ throw new Error(
247
+ "Built-in sr25519/ed25519 governance support requires the optional peer " +
248
+ "dependencies '@polkadot/util-crypto' and '@polkadot/util'. Install them, " +
249
+ "or pass a custom GovernanceSigner / verifier."
250
+ );
251
+ }
252
+ await crypto.cryptoWaitReady();
253
+ return { crypto, util };
254
+ })();
255
+ }
256
+ return polkadotPromise;
257
+ }
258
+
259
+ /** Default SS58 prefix used across the Orynq/Materios ecosystem. */
260
+ export const SS58_PREFIX = 42;
261
+
262
+ export interface SubstrateGovernanceSignerOptions {
263
+ /** 32-byte seed as bytes or 0x-hex. Provide this OR (secretKey + publicKey). */
264
+ seed?: Uint8Array | string;
265
+ /** Expanded secret key (with publicKey). */
266
+ secretKey?: Uint8Array;
267
+ publicKey?: Uint8Array;
268
+ /** Override the derived SS58 address. */
269
+ address?: string;
270
+ /** SS58 format for the derived address (default 42). */
271
+ ss58Format?: number;
272
+ }
273
+
274
+ function resolveSeed(seed: Uint8Array | string, util: PolkadotUtil): Uint8Array {
275
+ if (typeof seed === "string") {
276
+ return util.hexToU8a(seed.startsWith("0x") ? seed : "0x" + seed);
277
+ }
278
+ return seed;
279
+ }
280
+
281
+ /**
282
+ * Create an sr25519 governance signer backed by `@polkadot/util-crypto`.
283
+ * Pass a 32-byte `seed` (bytes or 0x-hex) or an explicit `secretKey`+`publicKey`.
284
+ */
285
+ export async function createSr25519GovernanceSigner(
286
+ opts: SubstrateGovernanceSignerOptions
287
+ ): Promise<GovernanceSigner> {
288
+ const { crypto, util } = await loadPolkadot();
289
+ let publicKey: Uint8Array;
290
+ let secretKey: Uint8Array;
291
+ if (opts.secretKey && opts.publicKey) {
292
+ publicKey = opts.publicKey;
293
+ secretKey = opts.secretKey;
294
+ } else if (opts.seed !== undefined) {
295
+ const pair = crypto.sr25519PairFromSeed(resolveSeed(opts.seed, util));
296
+ publicKey = pair.publicKey;
297
+ secretKey = pair.secretKey;
298
+ } else {
299
+ throw new Error("createSr25519GovernanceSigner: provide `seed` or `secretKey`+`publicKey`");
300
+ }
301
+ const address = opts.address ?? crypto.encodeAddress(publicKey, opts.ss58Format ?? SS58_PREFIX);
302
+ return {
303
+ address,
304
+ signatureScheme: "sr25519",
305
+ sign(ctx) {
306
+ return util.u8aToHex(crypto.sr25519Sign(ctx.preimage, { publicKey, secretKey }));
307
+ },
308
+ };
309
+ }
310
+
311
+ /**
312
+ * Create an ed25519 governance signer backed by `@polkadot/util-crypto`.
313
+ * Pass a 32-byte `seed` (bytes or 0x-hex) or an explicit `secretKey`+`publicKey`.
314
+ */
315
+ export async function createEd25519GovernanceSigner(
316
+ opts: SubstrateGovernanceSignerOptions
317
+ ): Promise<GovernanceSigner> {
318
+ const { crypto, util } = await loadPolkadot();
319
+ let publicKey: Uint8Array;
320
+ let secretKey: Uint8Array;
321
+ if (opts.secretKey && opts.publicKey) {
322
+ publicKey = opts.publicKey;
323
+ secretKey = opts.secretKey;
324
+ } else if (opts.seed !== undefined) {
325
+ const pair = crypto.ed25519PairFromSeed(resolveSeed(opts.seed, util));
326
+ publicKey = pair.publicKey;
327
+ secretKey = pair.secretKey;
328
+ } else {
329
+ throw new Error("createEd25519GovernanceSigner: provide `seed` or `secretKey`+`publicKey`");
330
+ }
331
+ const address = opts.address ?? crypto.encodeAddress(publicKey, opts.ss58Format ?? SS58_PREFIX);
332
+ return {
333
+ address,
334
+ signatureScheme: "ed25519",
335
+ sign(ctx) {
336
+ return util.u8aToHex(crypto.ed25519Sign(ctx.preimage, { publicKey, secretKey }));
337
+ },
338
+ };
339
+ }
340
+
341
+ // =============================================================================
342
+ // VERIFICATION
343
+ // =============================================================================
344
+
345
+ /** Context passed to a {@link GovernanceVerifier} alongside the event. */
346
+ export interface GovernanceVerifyContext {
347
+ /** Canonical preimage bytes bound to this trace's run id (#58). */
348
+ preimage: Uint8Array;
349
+ /** The enclosing trace run id — verifiers MUST bind signatures to it. */
350
+ runId: string;
351
+ }
352
+
353
+ /** A pluggable verifier for a single governance signature scheme. */
354
+ export type GovernanceVerifier = (
355
+ event: GovernanceAttestationEvent,
356
+ context: GovernanceVerifyContext
357
+ ) => Promise<boolean> | boolean;
358
+
359
+ export interface VerifyGovernanceOptions {
360
+ /**
361
+ * Per-scheme verifier overrides. An `eip712` verifier MUST be supplied here
362
+ * (e.g. via {@link createEip712GovernanceVerifier}); sr25519/ed25519 fall back
363
+ * to the built-in @polkadot verifiers when not overridden.
364
+ */
365
+ verifiers?: Partial<Record<GovernanceSignatureScheme, GovernanceVerifier>>;
366
+ /**
367
+ * The set of attestor identities (SS58 / 0x-address, case-insensitive) that
368
+ * are authorized to sign governance attestations. The attestor identity comes
369
+ * from the untrusted trace, so a cryptographically valid self-signed
370
+ * attestation from an arbitrary key is NOT a real sign-off — only a key on
371
+ * this list counts. When OMITTED, governance verification FAILS CLOSED: every
372
+ * attestation is `authorized: false` / `verified: false`, so an
373
+ * "anyone can sign" attestation can never fold into a passing bundle verdict.
374
+ * Optionally scope keys to a role via {@link authorizedAttestorsByRole}.
375
+ */
376
+ authorizedAttestors?: string[];
377
+ /**
378
+ * Per-role authorized attestors (case-insensitive). When present for an
379
+ * attestation's role, the signer must be listed under THAT role — a
380
+ * data-steward key cannot pass off a release-authority sign-off. Falls back to
381
+ * {@link authorizedAttestors} for roles not present here.
382
+ */
383
+ authorizedAttestorsByRole?: Record<string, string[]>;
384
+ }
385
+
386
+ /** Per-attestation verification result. */
387
+ export interface GovernanceAttestationSummary {
388
+ eventId: string;
389
+ role: string;
390
+ attestor: string;
391
+ scheme: GovernanceSignatureScheme;
392
+ policyRef: string;
393
+ decisionRef: string;
394
+ /** The signature cryptographically verifies AND the signer is authorized. */
395
+ verified: boolean;
396
+ /** The attestor is on the caller-supplied authorized-signer allow-list. */
397
+ authorized: boolean;
398
+ error?: string;
399
+ }
400
+
401
+ /**
402
+ * Verify every `governance-attestation` event in a bundle and return a summary
403
+ * tuple per attestation. Auditors get governance provenance "for free" — this
404
+ * is also invoked by `verifyBundle(bundle, { governance: true })`.
405
+ */
406
+ export async function verifyGovernanceAttestations(
407
+ bundle: TraceBundle,
408
+ opts: VerifyGovernanceOptions = {}
409
+ ): Promise<GovernanceAttestationSummary[]> {
410
+ const events = bundle.privateRun.events.filter(
411
+ (e): e is GovernanceAttestationEvent & TraceEvent => e.kind === "governance-attestation"
412
+ );
413
+
414
+ const runId = bundle.privateRun.id;
415
+ const summaries: GovernanceAttestationSummary[] = [];
416
+ for (const event of events) {
417
+ const scheme = event.attestor.signatureScheme;
418
+ // Reconstruct the preimage bound to THIS trace's run id (#58) — a genuine
419
+ // attestation from another trace produces a different preimage and fails.
420
+ const preimage = governanceAttestationPreimage({
421
+ role: event.role,
422
+ policyRef: event.policyRef,
423
+ decisionRef: event.decisionRef,
424
+ signedAt: event.signedAt,
425
+ runId,
426
+ });
427
+
428
+ const base = {
429
+ eventId: event.id,
430
+ role: event.role,
431
+ attestor: event.attestor.address,
432
+ scheme,
433
+ policyRef: event.policyRef,
434
+ decisionRef: event.decisionRef,
435
+ };
436
+
437
+ // The attestor identity is attacker-controlled (it rides in the trace), so a
438
+ // valid self-signed attestation from an arbitrary key is not a real sign-off.
439
+ // Fail closed unless the caller allow-lists the signer for this role (#58).
440
+ const authorized = attestorAuthorized(event.attestor.address, event.role, opts);
441
+ if (!authorized) {
442
+ summaries.push({
443
+ ...base,
444
+ authorized: false,
445
+ verified: false,
446
+ error:
447
+ opts.authorizedAttestors === undefined && opts.authorizedAttestorsByRole === undefined
448
+ ? "no authorized-attestor allow-list supplied — governance verification fails closed (pass authorizedAttestors)"
449
+ : `attestor ${event.attestor.address} is not authorized for role "${event.role}"`,
450
+ });
451
+ continue;
452
+ }
453
+
454
+ try {
455
+ const override = opts.verifiers?.[scheme];
456
+ let signatureValid: boolean;
457
+ if (override) {
458
+ signatureValid = await override(event, { preimage, runId });
459
+ } else if (scheme === "sr25519" || scheme === "ed25519") {
460
+ signatureValid = await verifySubstrateSignature(scheme, event, preimage);
461
+ } else {
462
+ summaries.push({
463
+ ...base,
464
+ authorized: true,
465
+ verified: false,
466
+ error: `no verifier registered for scheme "${scheme}" (pass one via verifiers)`,
467
+ });
468
+ continue;
469
+ }
470
+ summaries.push({ ...base, authorized: true, verified: signatureValid });
471
+ } catch (error) {
472
+ summaries.push({
473
+ ...base,
474
+ authorized: true,
475
+ verified: false,
476
+ error: error instanceof Error ? error.message : String(error),
477
+ });
478
+ }
479
+ }
480
+ return summaries;
481
+ }
482
+
483
+ /**
484
+ * True when `address` is on the caller-supplied authorized-attestor allow-list
485
+ * for `role` (case-insensitive). A per-role list takes precedence for its role;
486
+ * otherwise the flat list applies. With NEITHER list configured this returns
487
+ * false — governance verification fails closed.
488
+ */
489
+ function attestorAuthorized(
490
+ address: string,
491
+ role: string,
492
+ opts: VerifyGovernanceOptions
493
+ ): boolean {
494
+ const norm = (s: string) => s.toLowerCase();
495
+ const roleList = opts.authorizedAttestorsByRole?.[role];
496
+ if (roleList !== undefined) {
497
+ return roleList.map(norm).includes(norm(address));
498
+ }
499
+ if (opts.authorizedAttestors !== undefined) {
500
+ return opts.authorizedAttestors.map(norm).includes(norm(address));
501
+ }
502
+ return false;
503
+ }
504
+
505
+ async function verifySubstrateSignature(
506
+ scheme: "sr25519" | "ed25519",
507
+ event: GovernanceAttestationEvent,
508
+ preimage: Uint8Array
509
+ ): Promise<boolean> {
510
+ const { crypto, util } = await loadPolkadot();
511
+ const publicKey = crypto.decodeAddress(event.attestor.address);
512
+ const sig = util.hexToU8a(
513
+ event.signature.startsWith("0x") ? event.signature : "0x" + event.signature
514
+ );
515
+ return scheme === "sr25519"
516
+ ? crypto.sr25519Verify(preimage, sig, publicKey)
517
+ : crypto.ed25519Verify(preimage, sig, publicKey);
518
+ }
519
+
520
+ /** The message fields an eip712 attestation's signature MUST provably commit to. */
521
+ const REQUIRED_EIP712_FIELDS = ["role", "policyRef", "decisionRef", "runId", "signedAt"] as const;
522
+
523
+ /** Default freshness window for eip712 attestations (24h). */
524
+ const DEFAULT_EIP712_FRESHNESS_MS = 24 * 60 * 60_000;
525
+
526
+ /**
527
+ * Build an `eip712` {@link GovernanceVerifier} from an injected
528
+ * `verifyTypedData` (e.g. viem's). Keeps viem out of this package's deps.
529
+ *
530
+ * The schema pins (`expectedDomain`/`expectedPrimaryType`/`expectedTypes`) are
531
+ * MANDATORY: the event's `eip712.{domain,primaryType,types}` are attacker-
532
+ * controlled, so without pins an attacker signs an EMPTY struct
533
+ * (`types:{Attestation:[]}`) with their own key and smuggles the claim fields as
534
+ * untyped message extras the signature never commits to. The pinned primaryType
535
+ * must also declare `role`, `policyRef`, `decisionRef`, `runId`, and `signedAt`
536
+ * so the signature provably binds them. `signedAt` is then cross-checked against
537
+ * the recorded event and held to a freshness window so a signed sign-off cannot
538
+ * be replayed or backdated.
539
+ *
540
+ * @example
541
+ * ```typescript
542
+ * import { verifyTypedData } from "viem";
543
+ * const summary = await verifyGovernanceAttestations(bundle, {
544
+ * verifiers: {
545
+ * eip712: createEip712GovernanceVerifier({
546
+ * verifyTypedData,
547
+ * expectedDomain: { name: "Orynq", version: "1" },
548
+ * expectedPrimaryType: "Attestation",
549
+ * expectedTypes: {
550
+ * Attestation: [
551
+ * { name: "role", type: "string" },
552
+ * { name: "policyRef", type: "string" },
553
+ * { name: "decisionRef", type: "string" },
554
+ * { name: "runId", type: "string" },
555
+ * { name: "signedAt", type: "string" },
556
+ * ],
557
+ * },
558
+ * }),
559
+ * },
560
+ * authorizedAttestors: ["0x<release-authority>"],
561
+ * });
562
+ * ```
563
+ */
564
+ export function createEip712GovernanceVerifier(deps: {
565
+ verifyTypedData: (args: {
566
+ address: `0x${string}`;
567
+ domain: Record<string, unknown>;
568
+ types: Record<string, Array<{ name: string; type: string }>>;
569
+ primaryType: string;
570
+ message: Record<string, unknown>;
571
+ signature: `0x${string}`;
572
+ }) => Promise<boolean> | boolean;
573
+ /**
574
+ * Expected EIP-712 domain (name/version/chainId/verifyingContract). The
575
+ * event's `eip712.domain` is attacker-controlled, so the verifier requires an
576
+ * EXACT match on every field — a swapped verifyingContract/chainId/name is
577
+ * rejected before the signature is trusted.
578
+ */
579
+ expectedDomain: Record<string, unknown>;
580
+ /** Expected `primaryType`; a mismatch is rejected. */
581
+ expectedPrimaryType: string;
582
+ /** Expected `types` map; the event's must deep-equal it. */
583
+ expectedTypes: Record<string, Array<{ name: string; type: string }>>;
584
+ /**
585
+ * Max age of an attestation, in ms, before it is rejected as stale — measured
586
+ * from `signedAt` to `nowMs`. Also rejects far-future timestamps beyond the
587
+ * same window (clock-skew tolerance). Defaults to {@link DEFAULT_EIP712_FRESHNESS_MS}.
588
+ */
589
+ freshnessToleranceMs?: number;
590
+ /** Epoch-ms clock override (testing). Defaults to `Date.now()`. */
591
+ nowMs?: number;
592
+ }): GovernanceVerifier {
593
+ if (
594
+ deps.expectedDomain === undefined ||
595
+ deps.expectedPrimaryType === undefined ||
596
+ deps.expectedTypes === undefined
597
+ ) {
598
+ throw new Error(
599
+ "createEip712GovernanceVerifier: expectedDomain, expectedPrimaryType, and expectedTypes are required — " +
600
+ "an unpinned verifier accepts an empty attacker-signed struct (forgery)"
601
+ );
602
+ }
603
+ const declared = deps.expectedTypes[deps.expectedPrimaryType];
604
+ if (!declared) {
605
+ throw new Error(
606
+ `createEip712GovernanceVerifier: expectedTypes has no entry for primaryType "${deps.expectedPrimaryType}"`
607
+ );
608
+ }
609
+ const declaredNames = new Set(declared.map((f) => f.name));
610
+ const missing = REQUIRED_EIP712_FIELDS.filter((f) => !declaredNames.has(f));
611
+ if (missing.length > 0) {
612
+ throw new Error(
613
+ `createEip712GovernanceVerifier: the pinned "${deps.expectedPrimaryType}" type must include ` +
614
+ `${missing.join(", ")} so the signature commits to them`
615
+ );
616
+ }
617
+
618
+ return async (event, context) => {
619
+ if (!event.eip712) {
620
+ throw new Error("eip712 governance attestation is missing its `eip712` binding");
621
+ }
622
+
623
+ // Pin the attacker-controlled typed-data schema BEFORE trusting the
624
+ // signature. A signature over an unexpected domain/type proves nothing about
625
+ // an Orynq governance attestation.
626
+ if (event.eip712.primaryType !== deps.expectedPrimaryType) {
627
+ return false;
628
+ }
629
+ if (!domainMatches(deps.expectedDomain, event.eip712.domain)) {
630
+ return false;
631
+ }
632
+ if (!typesMatch(deps.expectedTypes, event.eip712.types)) {
633
+ return false;
634
+ }
635
+
636
+ const signature = (
637
+ event.signature.startsWith("0x") ? event.signature : "0x" + event.signature
638
+ ) as `0x${string}`;
639
+ const message = event.eip712.message ?? {};
640
+
641
+ const sigValid = await deps.verifyTypedData({
642
+ address: event.attestor.address as `0x${string}`,
643
+ domain: event.eip712.domain,
644
+ types: event.eip712.types,
645
+ primaryType: event.eip712.primaryType,
646
+ message,
647
+ signature,
648
+ });
649
+ if (!sigValid) return false;
650
+
651
+ // A valid signature over an attacker-chosen message is not enough (#58): the
652
+ // signed message MUST correspond to the recorded claim (role/policyRef/
653
+ // decisionRef/signedAt) and be scoped to THIS trace's run id. Otherwise any
654
+ // valid signature over any message forges an attestation for this event.
655
+ const claimBound =
656
+ message.role === event.role &&
657
+ message.policyRef === event.policyRef &&
658
+ message.decisionRef === event.decisionRef &&
659
+ message.runId === context.runId &&
660
+ message.signedAt === event.signedAt;
661
+ if (!claimBound) return false;
662
+
663
+ // The signed timestamp is now bound to the recorded event; hold it to a
664
+ // freshness window so a genuine sign-off cannot be replayed or backdated.
665
+ const toleranceMs = deps.freshnessToleranceMs ?? DEFAULT_EIP712_FRESHNESS_MS;
666
+ const nowMs = deps.nowMs ?? Date.now();
667
+ const signedAtMs = Date.parse(String(message.signedAt));
668
+ if (!Number.isFinite(signedAtMs)) return false;
669
+ if (Math.abs(nowMs - signedAtMs) > toleranceMs) return false;
670
+
671
+ return true;
672
+ };
673
+ }
674
+
675
+ /**
676
+ * True when every field of the EXPECTED domain is present and strictly equal in
677
+ * the ACTUAL (event-supplied) domain. The actual domain may carry no extra
678
+ * fields beyond the expected ones — extra fields (e.g. an injected
679
+ * verifyingContract) are rejected, closing the domain-substitution vector.
680
+ */
681
+ function domainMatches(
682
+ expected: Record<string, unknown>,
683
+ actual: Record<string, unknown>
684
+ ): boolean {
685
+ const expectedKeys = Object.keys(expected);
686
+ const actualKeys = Object.keys(actual);
687
+ if (actualKeys.length !== expectedKeys.length) return false;
688
+ for (const key of expectedKeys) {
689
+ if (actual[key] !== expected[key]) return false;
690
+ }
691
+ return true;
692
+ }
693
+
694
+ /** Deep-equal for an EIP-712 `types` map (order-insensitive per type). */
695
+ function typesMatch(
696
+ expected: Record<string, Array<{ name: string; type: string }>>,
697
+ actual: Record<string, Array<{ name: string; type: string }>>
698
+ ): boolean {
699
+ const norm = (t: Record<string, Array<{ name: string; type: string }>>): string =>
700
+ JSON.stringify(
701
+ Object.fromEntries(
702
+ Object.keys(t)
703
+ .sort()
704
+ .map((k) => [
705
+ k,
706
+ [...t[k]!].sort((a, b) => a.name.localeCompare(b.name)).map((f) => `${f.name}:${f.type}`),
707
+ ])
708
+ )
709
+ );
710
+ return norm(expected) === norm(actual);
711
+ }