@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.
- 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 +15 -1
- package/src/__tests__/bundle.test.ts +82 -0
- 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__/model-manifest.test.ts +136 -0
- package/src/__tests__/types.test.ts +8 -2
- package/src/bundle.ts +199 -5
- package/src/governance.ts +711 -0
- package/src/index.ts +69 -0
- package/src/model-manifest.ts +0 -0
- package/src/rolling-hash.ts +15 -6
- package/src/trace-builder.ts +68 -2
- package/src/types.ts +192 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fluxpointstudios/orynq-sdk-process-trace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Cryptographic process trace library for Proof-of-Intent SDK - schemas, hashing, Merkle trees, bundles, and selective disclosure",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -47,7 +47,21 @@
|
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@fluxpointstudios/orynq-sdk-core": "0.1.0"
|
|
49
49
|
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@polkadot/util": "^13.0.0",
|
|
52
|
+
"@polkadot/util-crypto": "^13.0.0"
|
|
53
|
+
},
|
|
54
|
+
"peerDependenciesMeta": {
|
|
55
|
+
"@polkadot/util": {
|
|
56
|
+
"optional": true
|
|
57
|
+
},
|
|
58
|
+
"@polkadot/util-crypto": {
|
|
59
|
+
"optional": true
|
|
60
|
+
}
|
|
61
|
+
},
|
|
50
62
|
"devDependencies": {
|
|
63
|
+
"@polkadot/util": "^13.5.0",
|
|
64
|
+
"@polkadot/util-crypto": "^13.5.0",
|
|
51
65
|
"tsup": "^8.0.1",
|
|
52
66
|
"typescript": "^5.3.3",
|
|
53
67
|
"vitest": "^1.2.0"
|
|
@@ -365,6 +365,88 @@ describe('verifyBundle', () => {
|
|
|
365
365
|
});
|
|
366
366
|
});
|
|
367
367
|
|
|
368
|
+
// -----------------------------------------------------------------------------
|
|
369
|
+
// Model-manifest pin binding (#59)
|
|
370
|
+
// -----------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
describe('verifyBundle model-manifest binding (#59)', () => {
|
|
373
|
+
const manifest = {
|
|
374
|
+
modelHash: 'sha256:' + '1'.repeat(64),
|
|
375
|
+
framework: 'anthropic',
|
|
376
|
+
modelId: 'claude-opus-4-8',
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
async function createManifestBundle(): Promise<TraceBundle> {
|
|
380
|
+
const run = await createTrace({ agentId: 'test-agent', manifest });
|
|
381
|
+
const span = addSpan(run, { name: 'infer', visibility: 'public' });
|
|
382
|
+
await addEvent(run, span.id, {
|
|
383
|
+
kind: 'command',
|
|
384
|
+
command: 'run inference',
|
|
385
|
+
visibility: 'public',
|
|
386
|
+
});
|
|
387
|
+
await closeSpan(run, span.id);
|
|
388
|
+
return finalizeTrace(run);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
it('honest manifest bundle verifies', async () => {
|
|
392
|
+
const bundle = await createManifestBundle();
|
|
393
|
+
const result = await verifyBundle(bundle);
|
|
394
|
+
expect(result.valid).toBe(true);
|
|
395
|
+
expect(result.checks.modelManifestValid).toBe(true);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('altered modelManifest (recorded hash unchanged) fails', async () => {
|
|
399
|
+
const bundle = await createManifestBundle();
|
|
400
|
+
// Attacker swaps the manifest but leaves the recorded commitment hash.
|
|
401
|
+
bundle.privateRun.modelManifest = {
|
|
402
|
+
...manifest,
|
|
403
|
+
modelHash: 'sha256:' + '2'.repeat(64),
|
|
404
|
+
};
|
|
405
|
+
if (bundle.modelManifest) {
|
|
406
|
+
bundle.modelManifest = bundle.privateRun.modelManifest;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const result = await verifyBundle(bundle);
|
|
410
|
+
expect(result.valid).toBe(false);
|
|
411
|
+
expect(result.checks.modelManifestValid).toBe(false);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('altered modelManifestHash (manifest unchanged) breaks the committed root', async () => {
|
|
415
|
+
const bundle = await createManifestBundle();
|
|
416
|
+
// Attacker swaps the recorded commitment hash to point at a different model.
|
|
417
|
+
const forged = 'sha256:' + '3'.repeat(64);
|
|
418
|
+
bundle.privateRun.modelManifestHash = forged;
|
|
419
|
+
bundle.modelManifestHash = forged;
|
|
420
|
+
|
|
421
|
+
const result = await verifyBundle(bundle);
|
|
422
|
+
expect(result.valid).toBe(false);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('manifest present but not bound into the root fails', async () => {
|
|
426
|
+
// Simulate a legacy/forged bundle where the manifest was recorded but the
|
|
427
|
+
// committed root was computed WITHOUT folding the manifest in.
|
|
428
|
+
const run = await createTrace({ agentId: 'test-agent', manifest });
|
|
429
|
+
const span = addSpan(run, { name: 'infer', visibility: 'public' });
|
|
430
|
+
await addEvent(run, span.id, {
|
|
431
|
+
kind: 'command',
|
|
432
|
+
command: 'run inference',
|
|
433
|
+
visibility: 'public',
|
|
434
|
+
});
|
|
435
|
+
await closeSpan(run, span.id);
|
|
436
|
+
const bundle = await finalizeTrace(run);
|
|
437
|
+
|
|
438
|
+
// Recompute the root WITHOUT the manifest and overwrite it, mimicking a
|
|
439
|
+
// bundle that never bound the pin.
|
|
440
|
+
const { computeRootHash } = await import('../rolling-hash.js');
|
|
441
|
+
const unbound = await computeRootHash(bundle.privateRun.rollingHash, bundle.privateRun.spans);
|
|
442
|
+
bundle.rootHash = unbound;
|
|
443
|
+
bundle.privateRun.rootHash = unbound;
|
|
444
|
+
|
|
445
|
+
const result = await verifyBundle(bundle);
|
|
446
|
+
expect(result.valid).toBe(false);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
368
450
|
// -----------------------------------------------------------------------------
|
|
369
451
|
// Public View Content Tests
|
|
370
452
|
// -----------------------------------------------------------------------------
|
|
@@ -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
|
+
});
|