@zanii/core 0.5.1 → 0.5.2

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/src/bundle.ts CHANGED
@@ -1,176 +1,214 @@
1
- /**
2
- * Audit bundles — SPEC §8. A self-contained, offline-verifiable export of one
3
- * agent's complete action history: receipts, proofs, revocations, anchors.
4
- * verifyAuditBundle() is pure: no network, no trusted party.
5
- */
6
- import { hexToBytes } from '@noble/hashes/utils';
7
- import { canonicalBytes, jcsHash } from './encoding.js';
8
- import { certHash, verifyRevocation, type RevocationRecord } from './delegation.js';
9
- import { leafHash, verifyConsistency, verifyInclusion } from './merkle.js';
10
- import { verifySTH, type SignedTreeHead } from './sth.js';
11
- import { verifyReceipt, type Receipt } from './receipt.js';
12
- import { participantsOf, prevFor, verifyA2AReceipt, type A2AReceipt } from './a2a.js';
13
-
14
- export interface BundledReceipt {
15
- receipt: Receipt | A2AReceipt;
16
- hash: string;
17
- index: number;
18
- proof: string[];
19
- }
20
-
21
- export interface BundledRevocation {
22
- revocation: RevocationRecord;
23
- cert_hash: string;
24
- index: number;
25
- proof: string[];
26
- }
27
-
28
- export interface BundledAnchor {
29
- sth: SignedTreeHead;
30
- backend: string;
31
- ref: string;
32
- block?: number;
33
- anchored_at: string;
34
- /** Consistency proof from the anchored tree to this bundle's STH. */
35
- consistency_proof: string[];
36
- }
37
-
38
- export interface AuditBundle {
39
- v: 1;
40
- bundle_type: 'zanii-audit-bundle';
41
- generated_at: string;
42
- log_id: string;
43
- agent_id: string;
44
- sth: SignedTreeHead;
45
- receipts: BundledReceipt[];
46
- revocations: BundledRevocation[];
47
- anchors: BundledAnchor[];
48
- }
49
-
50
- export interface BundleCheck {
51
- label: string;
52
- ok: boolean;
53
- detail?: string;
54
- }
55
-
56
- export interface BundleReport {
57
- ok: boolean;
58
- checks: BundleCheck[];
59
- stats: {
60
- receipts: number;
61
- first_ts: string | null;
62
- last_ts: string | null;
63
- revoked_certs: number;
64
- anchored_through: number | null;
65
- };
66
- }
67
-
68
- const root = (sth: SignedTreeHead) => hexToBytes(sth.root.replace('sha256:', ''));
69
-
70
- /** Fully verify an audit bundle offline. Every failure is reported, none throws. */
71
- export function verifyAuditBundle(bundle: AuditBundle): BundleReport {
72
- const checks: BundleCheck[] = [];
73
- const push = (label: string, ok: boolean, detail?: string) => checks.push({ label, ok, detail });
74
-
75
- try {
76
- push('bundle format', bundle.v === 1 && bundle.bundle_type === 'zanii-audit-bundle');
77
- push(
78
- 'signed tree head signature and log identity',
79
- verifySTH(bundle.sth) && bundle.sth.log_id === bundle.log_id,
80
- bundle.log_id,
81
- );
82
-
83
- // Receipts: signature/delegation/scope, identity, hash, inclusion, prev-chain continuity.
84
- let receiptsOk = true;
85
- let chainOk = true;
86
- let expectedPrev: string | null = null;
87
- const sorted = [...bundle.receipts].sort((a, b) => a.index - b.index);
88
- for (const [i, br] of sorted.entries()) {
89
- // revocation reported separately below
90
- const check =
91
- (br.receipt as A2AReceipt).type === 'a2a'
92
- ? verifyA2AReceipt(br.receipt as A2AReceipt)
93
- : verifyReceipt(br.receipt as Receipt);
94
- const identityOk = participantsOf(br.receipt).includes(bundle.agent_id);
95
- const hashOk = jcsHash(br.receipt) === br.hash;
96
- const inclusionOk = verifyInclusion(
97
- leafHash(canonicalBytes(br.receipt)),
98
- br.index,
99
- bundle.sth.size,
100
- br.proof.map(hexToBytes),
101
- root(bundle.sth),
102
- );
103
- if (!check.ok || !identityOk || !hashOk || !inclusionOk) {
104
- receiptsOk = false;
105
- push(`receipt #${i} (leaf ${br.index})`, false, check.error ?? 'hash/identity/inclusion mismatch');
106
- }
107
- if (prevFor(br.receipt, bundle.agent_id) !== expectedPrev) chainOk = false;
108
- expectedPrev = br.hash;
109
- }
110
- push(`all ${sorted.length} receipts: signature, delegation, scope, hash, Merkle inclusion`, receiptsOk);
111
- push('per-agent hash chain is complete and unbroken (full history, no gaps)', chainOk);
112
-
113
- // Revocations: issuer-signed and present in the tree.
114
- let revocationsOk = true;
115
- for (const bv of bundle.revocations) {
116
- const sigOk = verifyRevocation(bv.revocation) && certHash(bv.revocation.cert) === bv.cert_hash;
117
- const inclusionOk = verifyInclusion(
118
- leafHash(canonicalBytes(bv.revocation)),
119
- bv.index,
120
- bundle.sth.size,
121
- bv.proof.map(hexToBytes),
122
- root(bundle.sth),
123
- );
124
- if (!sigOk || !inclusionOk) {
125
- revocationsOk = false;
126
- push(`revocation of ${bv.cert_hash.slice(0, 24)}…`, false);
127
- }
128
- }
129
- push(`all ${bundle.revocations.length} revocation records: issuer signature and Merkle inclusion`, revocationsOk);
130
-
131
- // Anchors: each anchored STH verifies and the bundle tree is an append-only extension of it.
132
- let anchorsOk = true;
133
- let anchoredThrough: number | null = null;
134
- for (const a of bundle.anchors) {
135
- const sthOk = verifySTH(a.sth) && a.sth.log_id === bundle.log_id;
136
- const consistent = verifyConsistency(
137
- a.sth.size,
138
- bundle.sth.size,
139
- root(a.sth),
140
- root(bundle.sth),
141
- a.consistency_proof.map(hexToBytes),
142
- );
143
- if (!sthOk || !consistent) {
144
- anchorsOk = false;
145
- push(`anchor ${a.ref}`, false, 'STH signature or consistency proof failed');
146
- } else {
147
- anchoredThrough = Math.max(anchoredThrough ?? 0, a.sth.size);
148
- }
149
- }
150
- push(
151
- `all ${bundle.anchors.length} anchors: anchored STH valid, bundle tree is append-only extension`,
152
- anchorsOk,
153
- anchoredThrough !== null ? `history immutable through leaf ${anchoredThrough - 1}` : undefined,
154
- );
155
-
156
- const revokedCertHashes = new Set(bundle.revocations.map((r) => r.cert_hash));
157
- return {
158
- ok: checks.every((c) => c.ok),
159
- checks,
160
- stats: {
161
- receipts: sorted.length,
162
- first_ts: sorted[0]?.receipt.ts ?? null,
163
- last_ts: sorted[sorted.length - 1]?.receipt.ts ?? null,
164
- revoked_certs: revokedCertHashes.size,
165
- anchored_through: anchoredThrough,
166
- },
167
- };
168
- } catch (err) {
169
- push('bundle parses and verifies without errors', false, String(err));
170
- return {
171
- ok: false,
172
- checks,
173
- stats: { receipts: 0, first_ts: null, last_ts: null, revoked_certs: 0, anchored_through: null },
174
- };
175
- }
176
- }
1
+ /**
2
+ * Audit bundles — SPEC §8. A self-contained, offline-verifiable export of one
3
+ * agent's complete action history: receipts, proofs, revocations, anchors.
4
+ * verifyAuditBundle() is pure: no network, no trusted party.
5
+ */
6
+ import { hexToBytes } from '@noble/hashes/utils';
7
+ import { canonicalBytes, jcsHash } from './encoding.js';
8
+ import { certHash, verifyRevocation, type RevocationRecord } from './delegation.js';
9
+ import { leafHash, verifyConsistency, verifyInclusion } from './merkle.js';
10
+ import { verifySTH, type SignedTreeHead } from './sth.js';
11
+ import { isSessionClose, verifyReceipt, type Receipt } from './receipt.js';
12
+ import { participantsOf, prevFor, verifyA2AReceipt, type A2AReceipt } from './a2a.js';
13
+ import { verifySwarmReceipt, type SwarmLedgerReceipt } from './swarm.js';
14
+
15
+ /** A receipt's timestamp: top-level on solo and a2a receipts, inside `body` on swarm ones. */
16
+ function receiptTs(r: unknown): string | null {
17
+ const o = r as { ts?: unknown; body?: { ts?: unknown } };
18
+ if (typeof o?.ts === 'string') return o.ts;
19
+ return typeof o?.body?.ts === 'string' ? o.body.ts : null;
20
+ }
21
+
22
+ export interface BundledReceipt {
23
+ receipt: Receipt | A2AReceipt;
24
+ hash: string;
25
+ index: number;
26
+ proof: string[];
27
+ }
28
+
29
+ export interface BundledRevocation {
30
+ revocation: RevocationRecord;
31
+ cert_hash: string;
32
+ index: number;
33
+ proof: string[];
34
+ }
35
+
36
+ export interface BundledAnchor {
37
+ sth: SignedTreeHead;
38
+ backend: string;
39
+ ref: string;
40
+ block?: number;
41
+ anchored_at: string;
42
+ /** Consistency proof from the anchored tree to this bundle's STH. */
43
+ consistency_proof: string[];
44
+ }
45
+
46
+ export interface AuditBundle {
47
+ v: 1;
48
+ bundle_type: 'zanii-audit-bundle';
49
+ generated_at: string;
50
+ log_id: string;
51
+ agent_id: string;
52
+ sth: SignedTreeHead;
53
+ receipts: BundledReceipt[];
54
+ revocations: BundledRevocation[];
55
+ anchors: BundledAnchor[];
56
+ }
57
+
58
+ export interface BundleCheck {
59
+ label: string;
60
+ ok: boolean;
61
+ detail?: string;
62
+ }
63
+
64
+ export interface BundleReport {
65
+ ok: boolean;
66
+ checks: BundleCheck[];
67
+ stats: {
68
+ receipts: number;
69
+ first_ts: string | null;
70
+ last_ts: string | null;
71
+ revoked_certs: number;
72
+ anchored_through: number | null;
73
+ /** The newest receipt closes a session (SPEC §15). */
74
+ session_closed: boolean;
75
+ /** When the newest receipt closed its session, else null. */
76
+ closed_at: string | null;
77
+ };
78
+ }
79
+
80
+ const root = (sth: SignedTreeHead) => hexToBytes(sth.root.replace('sha256:', ''));
81
+
82
+ /** Fully verify an audit bundle offline. Every failure is reported, none throws. */
83
+ export function verifyAuditBundle(bundle: AuditBundle): BundleReport {
84
+ const checks: BundleCheck[] = [];
85
+ const push = (label: string, ok: boolean, detail?: string) => checks.push({ label, ok, detail });
86
+
87
+ try {
88
+ push('bundle format', bundle.v === 1 && bundle.bundle_type === 'zanii-audit-bundle');
89
+ push(
90
+ 'signed tree head signature and log identity',
91
+ verifySTH(bundle.sth) && bundle.sth.log_id === bundle.log_id,
92
+ bundle.log_id,
93
+ );
94
+
95
+ // Receipts: signature/delegation/scope, identity, hash, inclusion, prev-chain continuity.
96
+ let receiptsOk = true;
97
+ let chainOk = true;
98
+ let expectedPrev: string | null = null;
99
+ const sorted = [...bundle.receipts].sort((a, b) => a.index - b.index);
100
+ for (const [i, br] of sorted.entries()) {
101
+ // revocation reported separately below
102
+ // Each receipt is checked by the rules of its own type. A swarm receipt checked as a
103
+ // solo one failed signature and chain checks, so an honest swarm participant's
104
+ // history was reported as broken.
105
+ const kind = (br.receipt as { type?: string }).type;
106
+ const check =
107
+ kind === 'a2a'
108
+ ? verifyA2AReceipt(br.receipt as A2AReceipt)
109
+ : kind === 'swarm'
110
+ ? verifySwarmReceipt(br.receipt as unknown as SwarmLedgerReceipt)
111
+ : verifyReceipt(br.receipt as Receipt);
112
+ const identityOk = participantsOf(br.receipt).includes(bundle.agent_id);
113
+ const hashOk = jcsHash(br.receipt) === br.hash;
114
+ const inclusionOk = verifyInclusion(
115
+ leafHash(canonicalBytes(br.receipt)),
116
+ br.index,
117
+ bundle.sth.size,
118
+ br.proof.map(hexToBytes),
119
+ root(bundle.sth),
120
+ );
121
+ if (!check.ok || !identityOk || !hashOk || !inclusionOk) {
122
+ receiptsOk = false;
123
+ push(`receipt #${i} (leaf ${br.index})`, false, check.error ?? 'hash/identity/inclusion mismatch');
124
+ }
125
+ if (prevFor(br.receipt, bundle.agent_id) !== expectedPrev) chainOk = false;
126
+ expectedPrev = br.hash;
127
+ }
128
+ push(`all ${sorted.length} receipts: signature, delegation, scope, hash, Merkle inclusion`, receiptsOk);
129
+ push('per-agent hash chain is complete and unbroken (full history, no gaps)', chainOk);
130
+
131
+ // Revocations: issuer-signed and present in the tree.
132
+ let revocationsOk = true;
133
+ for (const bv of bundle.revocations) {
134
+ const sigOk = verifyRevocation(bv.revocation) && certHash(bv.revocation.cert) === bv.cert_hash;
135
+ const inclusionOk = verifyInclusion(
136
+ leafHash(canonicalBytes(bv.revocation)),
137
+ bv.index,
138
+ bundle.sth.size,
139
+ bv.proof.map(hexToBytes),
140
+ root(bundle.sth),
141
+ );
142
+ if (!sigOk || !inclusionOk) {
143
+ revocationsOk = false;
144
+ push(`revocation of ${bv.cert_hash.slice(0, 24)}…`, false);
145
+ }
146
+ }
147
+ push(`all ${bundle.revocations.length} revocation records: issuer signature and Merkle inclusion`, revocationsOk);
148
+
149
+ // Anchors: each anchored STH verifies and the bundle tree is an append-only extension of it.
150
+ let anchorsOk = true;
151
+ let anchoredThrough: number | null = null;
152
+ for (const a of bundle.anchors) {
153
+ const sthOk = verifySTH(a.sth) && a.sth.log_id === bundle.log_id;
154
+ const consistent = verifyConsistency(
155
+ a.sth.size,
156
+ bundle.sth.size,
157
+ root(a.sth),
158
+ root(bundle.sth),
159
+ a.consistency_proof.map(hexToBytes),
160
+ );
161
+ if (!sthOk || !consistent) {
162
+ anchorsOk = false;
163
+ push(`anchor ${a.ref}`, false, 'STH signature or consistency proof failed');
164
+ } else {
165
+ anchoredThrough = Math.max(anchoredThrough ?? 0, a.sth.size);
166
+ }
167
+ }
168
+ push(
169
+ `all ${bundle.anchors.length} anchors: anchored STH valid, bundle tree is append-only extension`,
170
+ anchorsOk,
171
+ anchoredThrough !== null ? `history immutable through leaf ${anchoredThrough - 1}` : undefined,
172
+ );
173
+
174
+ // Session close (SPEC §15) is reported, never failed: an agent mid-session is normal.
175
+ // It is mentioned only for agents that use sessions, so a bundle from an agent that
176
+ // never closes one reads exactly as it always has.
177
+ const newest = sorted[sorted.length - 1]?.receipt;
178
+ const sessionClosed = newest !== undefined && isSessionClose(newest);
179
+ const closedAt = sessionClosed ? receiptTs(newest) : null;
180
+ if (sorted.some((br) => isSessionClose(br.receipt))) {
181
+ push(
182
+ sessionClosed
183
+ ? `the newest receipt closes a session (${closedAt})`
184
+ : 'the newest receipt is not a session close: the agent may still be running, or receipts after its last one were withheld',
185
+ true,
186
+ );
187
+ }
188
+
189
+ const revokedCertHashes = new Set(bundle.revocations.map((r) => r.cert_hash));
190
+ return {
191
+ ok: checks.every((c) => c.ok),
192
+ checks,
193
+ stats: {
194
+ receipts: sorted.length,
195
+ first_ts: sorted.length ? receiptTs(sorted[0]!.receipt) : null,
196
+ last_ts: sorted.length ? receiptTs(sorted[sorted.length - 1]!.receipt) : null,
197
+ revoked_certs: revokedCertHashes.size,
198
+ anchored_through: anchoredThrough,
199
+ session_closed: sessionClosed,
200
+ closed_at: closedAt,
201
+ },
202
+ };
203
+ } catch (err) {
204
+ push('bundle parses and verifies without errors', false, String(err));
205
+ return {
206
+ ok: false,
207
+ checks,
208
+ stats: {
209
+ receipts: 0, first_ts: null, last_ts: null, revoked_certs: 0, anchored_through: null,
210
+ session_closed: false, closed_at: null,
211
+ },
212
+ };
213
+ }
214
+ }
package/src/index.ts CHANGED
@@ -52,6 +52,11 @@ export {
52
52
  saltedPayloadHash,
53
53
  verifyPayload,
54
54
  verifyPayloadLegacy,
55
+ RISK_LEVELS,
56
+ SESSION_CLOSE_TARGET,
57
+ isSessionClose,
58
+ isRiskLevel,
59
+ type RiskLevel,
55
60
  type PayloadDialect,
56
61
  verifyReceipt,
57
62
  verifyReceiptSignature,
@@ -69,3 +74,4 @@ export {
69
74
  verifyCoSignature,
70
75
  type ReceiptCoSignature,
71
76
  } from './cosign.js';
77
+ export { verifySwarmReceipt, type SwarmBody, type SwarmLedgerReceipt, type SwarmSignerEnvelope } from './swarm.js';
package/src/receipt.ts CHANGED
@@ -5,6 +5,27 @@ import { canonicalBytes, decodeSig, encodeSig, jcsHash } from './encoding.js';
5
5
  import { publicKeyFromDid } from './keys.js';
6
6
  import { scopesAllow, verifyChain, type DelegationCert } from './delegation.js';
7
7
 
8
+ /**
9
+ * The target of a session-close receipt (SPEC §15). An agent records one when it ends a
10
+ * session deliberately, so a verifier can tell a run that finished from one that was cut
11
+ * off. The per-agent `prev` chain already proves nothing is missing between receipts;
12
+ * this marks where a session was declared over.
13
+ */
14
+ export const SESSION_CLOSE_TARGET = 'zanii.session.close';
15
+
16
+ /** True for a solo receipt that closes a session. */
17
+ export function isSessionClose(receipt: unknown): boolean {
18
+ const r = receipt as { type?: string; target?: string; action?: string } | null;
19
+ return !!r && r.type === undefined && r.target === SESSION_CLOSE_TARGET && r.action === 'session.close';
20
+ }
21
+
22
+ /** What is at stake in an action (SPEC §3). A label the recorder sets, signed with the receipt: useful for policy and triage, and a control only where something independent of the agent enforces it. */
23
+ export const RISK_LEVELS = ['low', 'medium', 'high', 'critical'] as const;
24
+ export type RiskLevel = (typeof RISK_LEVELS)[number];
25
+ export function isRiskLevel(v: unknown): v is RiskLevel {
26
+ return typeof v === 'string' && (RISK_LEVELS as readonly string[]).includes(v);
27
+ }
28
+
8
29
  export interface Receipt {
9
30
  v: 1;
10
31
  agent_id: string;
@@ -30,6 +51,8 @@ export interface Receipt {
30
51
  * are checked with `verifyPayloadLegacy`, never with `verifyPayload`.
31
52
  */
32
53
  payload_encoding?: 'jcs';
54
+ /** What is at stake (SPEC §3). Signed; absent means the recorder did not say. */
55
+ risk?: RiskLevel;
33
56
  sig: string;
34
57
  }
35
58
 
@@ -59,6 +82,13 @@ function assertPayloadCanonicalizable(v: unknown, path = 'payload'): void {
59
82
  'would encode it differently. Use integer minor units (see @zanii/payments) or a string.',
60
83
  );
61
84
  }
85
+ if (!Number.isSafeInteger(v)) {
86
+ // JavaScript has already rounded it by now; another SDK would keep it exact.
87
+ throw new Error(
88
+ `${path}: integer ${v} is outside +/-(2^53-1): not every JSON implementation can ` +
89
+ 'represent it exactly, so the SDKs would disagree on its bytes. Pass it as a string.',
90
+ );
91
+ }
62
92
  return;
63
93
  }
64
94
  if (Array.isArray(v)) {
@@ -216,10 +246,15 @@ export function createReceipt(
216
246
  subjectTag?: string;
217
247
  /** How `payloadHash` committed to the payload. Set `'jcs'` when it came from `payloadBytes`. */
218
248
  payloadEncoding?: 'jcs';
249
+ /** What is at stake. Signed into the receipt when set. */
250
+ risk?: RiskLevel;
219
251
  },
220
252
  agentPrivateKey: Uint8Array,
221
253
  ): Receipt {
222
254
  if (Number.isNaN(Date.parse(fields.ts))) throw new Error(`invalid ts timestamp: ${fields.ts}`);
255
+ if (fields.risk !== undefined && !isRiskLevel(fields.risk)) {
256
+ throw new Error(`invalid risk '${String(fields.risk)}': expected one of low, medium, high, critical`);
257
+ }
223
258
  const unsigned = {
224
259
  v: 1 as const,
225
260
  agent_id: fields.agentId,
@@ -234,6 +269,7 @@ export function createReceipt(
234
269
  ...(fields.manifestHash ? { manifest_hash: fields.manifestHash } : {}),
235
270
  ...(fields.subjectTag ? { subject_tag: fields.subjectTag } : {}),
236
271
  ...(fields.payloadEncoding ? { payload_encoding: fields.payloadEncoding } : {}),
272
+ ...(fields.risk ? { risk: fields.risk } : {}),
237
273
  };
238
274
  const sig = ed25519.sign(canonicalBytes(unsigned), agentPrivateKey);
239
275
  return { ...unsigned, sig: encodeSig(sig) };
package/src/swarm.ts ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * N-party (swarm) receipts as they sit in the log (SPEC §14): the verification every
3
+ * consumer of the log needs — the bundle verifier included — so it lives in core.
4
+ * `@zanii/swarm` re-exports these, and adds the client-side building helpers.
5
+ */
6
+ import { ed25519 } from '@noble/curves/ed25519';
7
+ import { scopesAllow, verifyChain, type DelegationCert } from './delegation.js';
8
+ import { canonicalBytes, decodeSig } from './encoding.js';
9
+ import { publicKeyFromDid } from './keys.js';
10
+ import type { VerifyResult } from './receipt.js';
11
+
12
+ export interface SwarmBody {
13
+ v: 1;
14
+ type: 'swarm';
15
+ /** What the team did, e.g. `deploy.release`. */
16
+ task: string;
17
+ target: string;
18
+ /** Hash of the action payload (contents stay off-receipt). */
19
+ payload_hash?: string;
20
+ ts: string;
21
+ /** The team's `did:key`s — distinct, sorted (canonical). */
22
+ participants: string[];
23
+ /** M-of-N: how many of `participants` must sign for the receipt to be valid. */
24
+ threshold: number;
25
+ }
26
+
27
+ export interface SwarmSignerEnvelope {
28
+ agent_id: string;
29
+ /** The signer's delegation chain proving its owner authorized it for the target scope. */
30
+ delegation: DelegationCert[];
31
+ /** The signer's previous log head (`null` = genesis). */
32
+ prev: string | null;
33
+ /** `ed25519:<hex>` over the canonical body — same as `signSwarm`. */
34
+ sig: string;
35
+ }
36
+
37
+ export interface SwarmLedgerReceipt {
38
+ v: 1;
39
+ type: 'swarm';
40
+ body: SwarmBody;
41
+ signers: SwarmSignerEnvelope[];
42
+ }
43
+
44
+ /**
45
+ * Authority-complete verification of an on-ledger swarm receipt (SPEC §14) — the exact
46
+ * mirror of the server's `verify_swarm_receipt`. Every listed signer must be a distinct
47
+ * declared participant with a valid signature, an owner-rooted unrevoked/unexpired
48
+ * delegation, the target in scope, and `prev` matching its chain head. Then valid signers
49
+ * ≥ threshold, and (default) ≥ threshold DISTINCT owners — real segregation of duties, so
50
+ * one owner's N agents cannot fake an M-of-N. Any failure hard-rejects.
51
+ */
52
+ export function verifySwarmReceipt(
53
+ receipt: SwarmLedgerReceipt | undefined,
54
+ opts: { revoked?: Set<string>; expectedPrevFor?: Record<string, string | null>; requireDistinctOwners?: boolean } = {},
55
+ ): VerifyResult {
56
+ const requireDistinctOwners = opts.requireDistinctOwners ?? true;
57
+ if (!receipt || receipt.v !== 1 || receipt.type !== 'swarm') return { ok: false, error: 'not a swarm receipt' };
58
+ const body = receipt.body;
59
+ if (!body || body.v !== 1 || body.type !== 'swarm') return { ok: false, error: 'invalid swarm body' };
60
+ const parts = body.participants;
61
+ const threshold = body.threshold;
62
+ const ts = new Date(body.ts);
63
+ if (!Array.isArray(parts) || parts.length < 3) return { ok: false, error: 'swarm needs at least 3 participants' };
64
+ if (new Set(parts).size !== parts.length) return { ok: false, error: 'participants must be distinct' };
65
+ if (!Number.isInteger(threshold) || threshold < 1 || threshold > parts.length) return { ok: false, error: 'invalid threshold' };
66
+ if (Number.isNaN(ts.getTime())) return { ok: false, error: 'invalid ts' };
67
+
68
+ const participantSet = new Set(parts);
69
+ const target = body.target ?? '';
70
+ const msg = canonicalBytes(body);
71
+ const signers = receipt.signers ?? [];
72
+ const seen = new Set<string>();
73
+ const owners: string[] = [];
74
+ for (const s of signers) {
75
+ const aid = s.agent_id;
76
+ if (!participantSet.has(aid)) return { ok: false, error: `signer ${aid} is not a declared participant` };
77
+ if (seen.has(aid)) return { ok: false, error: `signer ${aid} appears twice` };
78
+ seen.add(aid);
79
+ const pub = publicKeyFromDid(aid);
80
+ const sig = decodeSig(s.sig);
81
+ if (!pub || !sig) return { ok: false, error: `signer ${aid}: malformed key or signature` };
82
+ let sigOk = false;
83
+ try {
84
+ sigOk = ed25519.verify(sig, msg, pub);
85
+ } catch {
86
+ sigOk = false;
87
+ }
88
+ if (!sigOk) return { ok: false, error: `signer ${aid}: bad signature` };
89
+ const chain = verifyChain(s.delegation ?? [], { agentId: aid, revoked: opts.revoked, at: ts });
90
+ if (!chain.ok) return { ok: false, error: `signer ${aid} delegation: ${chain.error}` };
91
+ if (!scopesAllow(chain.effectiveScopes ?? [], target)) return { ok: false, error: `signer ${aid}: target ${target} outside effective scopes` };
92
+ const expected = opts.expectedPrevFor?.[aid];
93
+ if (expected !== undefined && s.prev !== expected) return { ok: false, error: `signer ${aid}: prev hash does not match chain head (chain broken)` };
94
+ const root = s.delegation?.[0]?.issuer;
95
+ if (root) owners.push(root);
96
+ }
97
+ if (seen.size < threshold) return { ok: false, error: `threshold not met: ${seen.size} valid of ${threshold} required` };
98
+ if (requireDistinctOwners && new Set(owners).size < threshold) {
99
+ return { ok: false, error: `distinct-owner threshold not met: ${new Set(owners).size} owners of ${threshold} required` };
100
+ }
101
+ return { ok: true };
102
+ }