@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.
- package/LICENSE +21 -21
- package/dist/index.cjs +574 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +573 -9
- package/dist/index.d.ts +573 -9
- package/dist/index.js +562 -9
- package/dist/index.js.map +1 -1
- package/package.json +17 -3
- package/src/__tests__/bundle.test.ts +942 -860
- package/src/__tests__/governance-round4.test.ts +149 -0
- package/src/__tests__/governance.test.ts +382 -0
- package/src/__tests__/hardening-round2.test.ts +173 -0
- package/src/__tests__/hardening-round3.test.ts +208 -0
- package/src/__tests__/integration.test.ts +611 -611
- package/src/__tests__/merkle.test.ts +756 -756
- package/src/__tests__/model-manifest.test.ts +136 -0
- package/src/__tests__/rolling-hash.test.ts +622 -622
- package/src/__tests__/trace-builder.test.ts +1012 -1012
- package/src/__tests__/types.test.ts +420 -414
- package/src/bundle.ts +1004 -810
- package/src/disclosure.ts +527 -527
- package/src/governance.ts +711 -0
- package/src/index.ts +334 -265
- package/src/manifest.ts +687 -687
- package/src/merkle.ts +428 -428
- package/src/model-manifest.ts +0 -0
- package/src/rolling-hash.ts +375 -366
- package/src/trace-builder.ts +791 -725
- package/src/types.ts +713 -522
|
@@ -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
|
+
}
|