@orangecheck/agent-core 0.1.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/src/types.ts ADDED
@@ -0,0 +1,189 @@
1
+ // Wire types for OC Agent v1 envelopes. See SPEC.md §4, §5, §9.
2
+
3
+ export const ENVELOPE_VERSION = 1 as const;
4
+
5
+ export type EnvelopeKind = 'agent-delegation' | 'agent-action' | 'agent-revocation';
6
+
7
+ // ─────────────────────────────────────────────────────────────────────────────
8
+ // Shared building blocks
9
+ // ─────────────────────────────────────────────────────────────────────────────
10
+
11
+ export interface ActorRef {
12
+ /** mainnet Bitcoin address (P2WPKH, P2TR, or P2PKH). */
13
+ address: string;
14
+ alg: 'bip322';
15
+ }
16
+
17
+ export interface Signature {
18
+ alg: 'bip322';
19
+ pubkey: string; // equals the producing actor's address
20
+ value: string; // base64 BIP-322 signature over hex(id)
21
+ }
22
+
23
+ export type RevocationHolder = 'principal' | 'agent';
24
+
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+ // Delegation (SPEC §4)
27
+ // ─────────────────────────────────────────────────────────────────────────────
28
+
29
+ export interface DelegationBond {
30
+ /** Non-negative sats declared as bonded at issuance time. */
31
+ sats: number;
32
+ /** SHA-256 hex of the OrangeCheck canonical message signed by principal.address. */
33
+ attestation_id: string;
34
+ }
35
+
36
+ export interface DelegationRevocationRef {
37
+ /** Who MAY publish a revocation. Default ["principal"]. */
38
+ holders: RevocationHolder[];
39
+ /** Optional Nostr-addressable pointer to a published revocation. Non-cryptographic. */
40
+ ref: string | null;
41
+ }
42
+
43
+ export interface DelegationEnvelope {
44
+ v: typeof ENVELOPE_VERSION;
45
+ kind: 'agent-delegation';
46
+ id: string; // 64-hex sha256(canonical_message)
47
+ principal: ActorRef;
48
+ agent: ActorRef;
49
+ /** Sorted lexicographically in the canonical message; stored in sorted order on the envelope too. */
50
+ scopes: string[];
51
+ bond: DelegationBond | null;
52
+ issued_at: string; // ISO 8601 UTC
53
+ expires_at: string; // ISO 8601 UTC
54
+ nonce: string; // 32-hex random
55
+ revocation: DelegationRevocationRef;
56
+ sig: Signature;
57
+ }
58
+
59
+ export interface DelegationCanonicalInput {
60
+ principal: string;
61
+ agent: string;
62
+ scopes: string[]; // pre-canonicalized, pre-sorted
63
+ bond_sats: number;
64
+ /** 64-hex attestation id or the literal string "none". */
65
+ bond_attestation: string;
66
+ issued_at: string;
67
+ expires_at: string;
68
+ nonce: string;
69
+ }
70
+
71
+ // ─────────────────────────────────────────────────────────────────────────────
72
+ // Agent-action (SPEC §5) — strict extension of OC Stamp
73
+ // ─────────────────────────────────────────────────────────────────────────────
74
+
75
+ export interface ActionContent {
76
+ hash: string; // "sha256:<64-hex>"
77
+ length: number;
78
+ mime: string;
79
+ ref: string | null;
80
+ }
81
+
82
+ export interface ActionOts {
83
+ status: 'pending' | 'confirmed';
84
+ proof: string;
85
+ calendars: string[];
86
+ block_height: number | null;
87
+ block_hash: string | null;
88
+ upgraded_at: string | null;
89
+ }
90
+
91
+ export interface ActionEnvelope {
92
+ v: typeof ENVELOPE_VERSION;
93
+ kind: 'agent-action';
94
+ id: string;
95
+ content: ActionContent;
96
+ signer: ActorRef; // agent
97
+ signed_at: string;
98
+ delegation_id: string; // 64-hex
99
+ scope_exercised: string; // a sub-scope of some granted scope
100
+ ots: ActionOts | null;
101
+ sig: Signature;
102
+ }
103
+
104
+ export interface ActionCanonicalInput {
105
+ address: string; // agent address
106
+ content_hash: string;
107
+ content_length: number;
108
+ content_mime: string;
109
+ signed_at: string;
110
+ delegation_id: string;
111
+ scope_exercised: string;
112
+ }
113
+
114
+ // ─────────────────────────────────────────────────────────────────────────────
115
+ // Revocation (SPEC §9)
116
+ // ─────────────────────────────────────────────────────────────────────────────
117
+
118
+ export interface RevocationEnvelope {
119
+ v: typeof ENVELOPE_VERSION;
120
+ kind: 'agent-revocation';
121
+ id: string;
122
+ delegation_id: string;
123
+ signer: ActorRef;
124
+ /** Short ASCII rationale, <= 128 bytes. Empty string if omitted. */
125
+ reason: string;
126
+ signed_at: string;
127
+ ots: ActionOts | null;
128
+ sig: Signature;
129
+ }
130
+
131
+ export interface RevocationCanonicalInput {
132
+ address: string;
133
+ delegation_id: string;
134
+ reason: string;
135
+ signed_at: string;
136
+ }
137
+
138
+ // ─────────────────────────────────────────────────────────────────────────────
139
+ // Error codes (SPEC §11)
140
+ // ─────────────────────────────────────────────────────────────────────────────
141
+
142
+ export type AgentErrorCode =
143
+ | 'E_UNSUPPORTED_VERSION'
144
+ | 'E_MALFORMED'
145
+ | 'E_BAD_ID'
146
+ | 'E_BAD_SIG'
147
+ | 'E_BAD_SCOPE_GRAMMAR'
148
+ | 'E_NOT_YET_VALID'
149
+ | 'E_EXPIRED'
150
+ | 'E_REVOKED'
151
+ | 'E_DELEGATION_MISMATCH'
152
+ | 'E_AGENT_MISMATCH'
153
+ | 'E_OUT_OF_WINDOW'
154
+ | 'E_SCOPE_DENIED'
155
+ | 'E_BAD_ACTION_STAMP'
156
+ | 'E_NO_BOND'
157
+ | 'E_BOND_UNMET'
158
+ | 'E_BOND_UNVERIFIED'
159
+ | 'E_REVOKER_UNAUTHORIZED'
160
+ | 'E_CALENDAR_UNREACHABLE';
161
+
162
+ export interface VerifyOk<T> {
163
+ ok: true;
164
+ envelope: T;
165
+ canonicalMessage: string;
166
+ id: string;
167
+ }
168
+
169
+ export interface VerifyErr {
170
+ ok: false;
171
+ code: AgentErrorCode;
172
+ message: string;
173
+ }
174
+
175
+ export type VerifyDelegationResult = VerifyOk<DelegationEnvelope> | VerifyErr;
176
+ export type VerifyRevocationResult = VerifyOk<RevocationEnvelope> | VerifyErr;
177
+
178
+ export interface VerifyActionOkExtra {
179
+ delegation: DelegationEnvelope;
180
+ scopeExercised: string;
181
+ anchor:
182
+ | { status: 'none' }
183
+ | { status: 'pending' }
184
+ | { status: 'confirmed'; blockHeight: number; blockHash: string; verified: boolean };
185
+ }
186
+
187
+ export type VerifyActionResult =
188
+ | (VerifyOk<ActionEnvelope> & VerifyActionOkExtra)
189
+ | VerifyErr;
package/src/verify.ts ADDED
@@ -0,0 +1,460 @@
1
+ // verifyDelegation / verifyAction / verifyRevocation — reference implementation
2
+ // of OC Agent v1 verification. SPEC §8.
3
+
4
+ import { sha256 } from '@noble/hashes/sha256';
5
+
6
+ import {
7
+ actionCanonicalBytes,
8
+ actionCanonicalMessage,
9
+ canonicalizeScopes,
10
+ delegationCanonicalBytes,
11
+ delegationCanonicalMessage,
12
+ hexEncode,
13
+ revocationCanonicalBytes,
14
+ revocationCanonicalMessage,
15
+ } from './canonical.js';
16
+ import {
17
+ canonicalizeScope,
18
+ isSubScope,
19
+ parseScope,
20
+ ScopeParseError,
21
+ validateScope,
22
+ type ValidationOptions,
23
+ } from './scope.js';
24
+ import {
25
+ ENVELOPE_VERSION,
26
+ type ActionEnvelope,
27
+ type AgentErrorCode,
28
+ type DelegationEnvelope,
29
+ type RevocationEnvelope,
30
+ type VerifyActionResult,
31
+ type VerifyDelegationResult,
32
+ type VerifyRevocationResult,
33
+ } from './types.js';
34
+
35
+ // ─────────────────────────────────────────────────────────────────────────────
36
+ // Shared options
37
+ // ─────────────────────────────────────────────────────────────────────────────
38
+
39
+ export interface VerifyBase {
40
+ verifyBip322?: (msg: string, signatureB64: string, address: string) => Promise<boolean>;
41
+ skipSignatureVerification?: boolean;
42
+ scopeMode?: ValidationOptions['mode'];
43
+ }
44
+
45
+ export class AgentError extends Error {
46
+ code: AgentErrorCode;
47
+ constructor(code: AgentErrorCode, message: string) {
48
+ super(message);
49
+ this.code = code;
50
+ this.name = 'AgentError';
51
+ }
52
+ }
53
+
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+ // Delegation (SPEC §8.1)
56
+ // ─────────────────────────────────────────────────────────────────────────────
57
+
58
+ export interface VerifyDelegationInput extends VerifyBase {
59
+ envelope: DelegationEnvelope;
60
+ /** Current time for temporal checks; defaults to new Date(). */
61
+ now?: Date;
62
+ /** Skip temporal checks entirely (useful for inspecting historical envelopes). */
63
+ skipTemporalCheck?: boolean;
64
+ }
65
+
66
+ export async function verifyDelegation(input: VerifyDelegationInput): Promise<VerifyDelegationResult> {
67
+ const env = input.envelope;
68
+
69
+ if (env.v !== ENVELOPE_VERSION) {
70
+ return err('E_UNSUPPORTED_VERSION', `delegation version ${env.v} not supported`);
71
+ }
72
+
73
+ const shape = checkDelegationShape(env);
74
+ if (shape) return shape;
75
+
76
+ // Scope grammar.
77
+ let canonicalScopes: string[];
78
+ try {
79
+ for (const s of env.scopes) validateScope(parseScope(s), { mode: input.scopeMode ?? 'strict' });
80
+ canonicalScopes = canonicalizeScopes(env.scopes);
81
+ } catch (e) {
82
+ const msg = e instanceof ScopeParseError ? e.message : (e as Error).message;
83
+ return err('E_BAD_SCOPE_GRAMMAR', msg);
84
+ }
85
+
86
+ // The envelope's `scopes` array must already be in canonical sorted order.
87
+ for (let i = 0; i < canonicalScopes.length; i++) {
88
+ if (env.scopes[i] !== canonicalScopes[i]) {
89
+ return err(
90
+ 'E_BAD_SCOPE_GRAMMAR',
91
+ `scope at index ${i} not in canonical form; expected ${canonicalScopes[i]} got ${env.scopes[i]}`
92
+ );
93
+ }
94
+ }
95
+
96
+ // Canonical message reconstruction.
97
+ const bondSats = env.bond?.sats ?? 0;
98
+ const bondAttestation = env.bond?.attestation_id ?? 'none';
99
+ const canonInput = {
100
+ principal: env.principal.address,
101
+ agent: env.agent.address,
102
+ scopes: canonicalScopes,
103
+ bond_sats: bondSats,
104
+ bond_attestation: bondAttestation,
105
+ issued_at: env.issued_at,
106
+ expires_at: env.expires_at,
107
+ nonce: env.nonce,
108
+ };
109
+ const reconstructedMessage = delegationCanonicalMessage(canonInput);
110
+ const reconstructedId = hexEncode(sha256(delegationCanonicalBytes(canonInput)));
111
+ if (reconstructedId !== env.id) {
112
+ return err(
113
+ 'E_BAD_ID',
114
+ `reconstructed id (${reconstructedId}) does not match envelope.id (${env.id})`
115
+ );
116
+ }
117
+
118
+ // Signature.
119
+ if (!input.skipSignatureVerification) {
120
+ if (!input.verifyBip322) return err('E_BAD_SIG', 'no BIP-322 verifier supplied');
121
+ const ok = await input.verifyBip322(env.id, env.sig.value, env.principal.address);
122
+ if (!ok) return err('E_BAD_SIG', 'BIP-322 signature did not verify');
123
+ }
124
+
125
+ // Temporal.
126
+ if (!input.skipTemporalCheck) {
127
+ const now = input.now ?? new Date();
128
+ const issued = new Date(env.issued_at);
129
+ const expires = new Date(env.expires_at);
130
+ if (expires <= issued) return err('E_MALFORMED', 'expires_at <= issued_at');
131
+ if (now < issued) return err('E_NOT_YET_VALID', `delegation not valid until ${env.issued_at}`);
132
+ if (now >= expires) return err('E_EXPIRED', `delegation expired at ${env.expires_at}`);
133
+ }
134
+
135
+ return {
136
+ ok: true,
137
+ envelope: env,
138
+ canonicalMessage: reconstructedMessage,
139
+ id: env.id,
140
+ };
141
+ }
142
+
143
+ // ─────────────────────────────────────────────────────────────────────────────
144
+ // Action (SPEC §8.2–8.3)
145
+ // ─────────────────────────────────────────────────────────────────────────────
146
+
147
+ export interface VerifyActionInput extends VerifyBase {
148
+ action: ActionEnvelope;
149
+ /** The delegation cited by action.delegation_id. Required. */
150
+ delegation: DelegationEnvelope;
151
+ /**
152
+ * Known revocations targeting the delegation. The verifier scans for one whose
153
+ * effective time precedes the action (SPEC §9.3).
154
+ */
155
+ revocations?: RevocationEnvelope[];
156
+ content?: Uint8Array;
157
+ verifyOtsAnchor?: (proofB64: string, blockHeight: number, blockHash: string) => Promise<boolean>;
158
+ /** If action and revocation are both OTS-anchored, pass a function that returns the comparable block height of each via proof parsing. Defaults: use envelope.ots.block_height. */
159
+ resolveAnchorBlockHeight?: (env: ActionEnvelope | RevocationEnvelope) => number | null;
160
+ }
161
+
162
+ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActionResult> {
163
+ const a = input.action;
164
+ const d = input.delegation;
165
+
166
+ // 1. First verify the delegation.
167
+ const dr = await verifyDelegation({
168
+ envelope: d,
169
+ verifyBip322: input.verifyBip322,
170
+ skipSignatureVerification: input.skipSignatureVerification,
171
+ scopeMode: input.scopeMode,
172
+ skipTemporalCheck: true, // action window check dominates
173
+ });
174
+ if (!dr.ok) return dr;
175
+
176
+ // 2. Core action checks.
177
+ if (a.v !== ENVELOPE_VERSION) {
178
+ return err('E_UNSUPPORTED_VERSION', `action version ${a.v} not supported`);
179
+ }
180
+ const shape = checkActionShape(a);
181
+ if (shape) return shape;
182
+
183
+ const canonInput = {
184
+ address: a.signer.address,
185
+ content_hash: a.content.hash,
186
+ content_length: a.content.length,
187
+ content_mime: a.content.mime,
188
+ signed_at: a.signed_at,
189
+ delegation_id: a.delegation_id,
190
+ scope_exercised: a.scope_exercised,
191
+ };
192
+ const reconstructedMessage = actionCanonicalMessage(canonInput);
193
+ const reconstructedId = hexEncode(sha256(actionCanonicalBytes(canonInput)));
194
+ if (reconstructedId !== a.id) {
195
+ return err('E_BAD_ID', `reconstructed id (${reconstructedId}) does not match action.id (${a.id})`);
196
+ }
197
+
198
+ if (!input.skipSignatureVerification) {
199
+ if (!input.verifyBip322) return err('E_BAD_SIG', 'no BIP-322 verifier supplied');
200
+ const ok = await input.verifyBip322(a.id, a.sig.value, a.signer.address);
201
+ if (!ok) return err('E_BAD_ACTION_STAMP', 'action BIP-322 signature did not verify');
202
+ }
203
+
204
+ // 3. Authority chain.
205
+ if (a.delegation_id !== d.id) {
206
+ return err('E_DELEGATION_MISMATCH', `action.delegation_id (${a.delegation_id}) != delegation.id (${d.id})`);
207
+ }
208
+ if (a.signer.address !== d.agent.address) {
209
+ return err('E_AGENT_MISMATCH', `action signer (${a.signer.address}) != delegation.agent (${d.agent.address})`);
210
+ }
211
+
212
+ // 4. Window.
213
+ const issued = new Date(d.issued_at).getTime();
214
+ const expires = new Date(d.expires_at).getTime();
215
+ const signed = new Date(a.signed_at).getTime();
216
+ if (Number.isNaN(issued) || Number.isNaN(expires) || Number.isNaN(signed)) {
217
+ return err('E_MALFORMED', 'unparseable ISO 8601 timestamp');
218
+ }
219
+ if (signed < issued || signed >= expires) {
220
+ return err('E_OUT_OF_WINDOW', `action.signed_at ${a.signed_at} is outside delegation window [${d.issued_at}, ${d.expires_at})`);
221
+ }
222
+
223
+ // 5. Scope containment.
224
+ let exercised, accepted;
225
+ try {
226
+ exercised = canonicalizeScope(parseScope(a.scope_exercised));
227
+ const granted = d.scopes.map((s) => parseScope(s));
228
+ const exercisedParsed = parseScope(a.scope_exercised);
229
+ validateScope(exercisedParsed, { mode: input.scopeMode ?? 'strict' });
230
+ accepted = granted.some((g) => isSubScope(exercisedParsed, g));
231
+ } catch (e) {
232
+ const msg = e instanceof ScopeParseError ? e.message : (e as Error).message;
233
+ return err('E_BAD_SCOPE_GRAMMAR', msg);
234
+ }
235
+ if (!accepted) return err('E_SCOPE_DENIED', `scope_exercised (${exercised}) not a sub-scope of any granted scope`);
236
+
237
+ // 6. Revocation check.
238
+ if (input.revocations && input.revocations.length > 0) {
239
+ for (const rev of input.revocations) {
240
+ if (rev.delegation_id !== d.id) continue;
241
+ // Verify the revocation itself (signature + canonical) with the same BIP-322 verifier.
242
+ const rr = await verifyRevocation({
243
+ envelope: rev,
244
+ delegation: d,
245
+ verifyBip322: input.verifyBip322,
246
+ skipSignatureVerification: input.skipSignatureVerification,
247
+ });
248
+ if (!rr.ok) continue; // malformed revocations don't affect the action
249
+ const effective = effectiveRevocationTime(rev, input.resolveAnchorBlockHeight);
250
+ const actionTime = actionEffectiveTime(a, input.resolveAnchorBlockHeight);
251
+ if (compareTimes(effective, actionTime) <= 0) {
252
+ return err('E_REVOKED', `delegation was revoked by ${rev.id} before action was signed`);
253
+ }
254
+ }
255
+ }
256
+
257
+ // 7. Content check.
258
+ if (input.content) {
259
+ const actualHash = 'sha256:' + hexEncode(sha256(input.content));
260
+ if (actualHash !== a.content.hash) {
261
+ return err('E_BAD_ACTION_STAMP', `content hash (${actualHash}) != action.content.hash (${a.content.hash})`);
262
+ }
263
+ }
264
+
265
+ // 8. Anchor info.
266
+ let anchor: VerifyActionResult extends infer R ? R extends { anchor: infer X } ? X : never : never;
267
+ if (a.ots === null) {
268
+ anchor = { status: 'none' } as typeof anchor;
269
+ } else if (a.ots.status === 'pending') {
270
+ anchor = { status: 'pending' } as typeof anchor;
271
+ } else {
272
+ const h = a.ots.block_height;
273
+ const hash = a.ots.block_hash;
274
+ if (h === null || hash === null) {
275
+ return err('E_MALFORMED', 'confirmed OTS proof missing block_height or block_hash');
276
+ }
277
+ let verified = false;
278
+ if (input.verifyOtsAnchor) {
279
+ try {
280
+ verified = await input.verifyOtsAnchor(a.ots.proof, h, hash);
281
+ } catch (e) {
282
+ return err('E_MALFORMED', `anchor verifier threw: ${(e as Error).message}`);
283
+ }
284
+ }
285
+ anchor = { status: 'confirmed', blockHeight: h, blockHash: hash, verified } as typeof anchor;
286
+ }
287
+
288
+ return {
289
+ ok: true,
290
+ envelope: a,
291
+ canonicalMessage: reconstructedMessage,
292
+ id: a.id,
293
+ delegation: d,
294
+ scopeExercised: exercised,
295
+ anchor,
296
+ };
297
+ }
298
+
299
+ // ─────────────────────────────────────────────────────────────────────────────
300
+ // Revocation (SPEC §9, §8 transitive)
301
+ // ─────────────────────────────────────────────────────────────────────────────
302
+
303
+ export interface VerifyRevocationInput extends VerifyBase {
304
+ envelope: RevocationEnvelope;
305
+ /** The delegation targeted by the revocation. Required to check signer is authorized. */
306
+ delegation: DelegationEnvelope;
307
+ }
308
+
309
+ export async function verifyRevocation(input: VerifyRevocationInput): Promise<VerifyRevocationResult> {
310
+ const env = input.envelope;
311
+ const d = input.delegation;
312
+
313
+ if (env.v !== ENVELOPE_VERSION) {
314
+ return err('E_UNSUPPORTED_VERSION', `revocation version ${env.v} not supported`);
315
+ }
316
+ const shape = checkRevocationShape(env);
317
+ if (shape) return shape;
318
+
319
+ if (env.delegation_id !== d.id) {
320
+ return err('E_DELEGATION_MISMATCH', `revocation.delegation_id (${env.delegation_id}) != delegation.id (${d.id})`);
321
+ }
322
+
323
+ // Signer must be authorized per delegation.revocation.holders.
324
+ const holders = d.revocation?.holders ?? ['principal'];
325
+ const holderAddrs = new Set<string>();
326
+ if (holders.includes('principal')) holderAddrs.add(d.principal.address);
327
+ if (holders.includes('agent')) holderAddrs.add(d.agent.address);
328
+ if (!holderAddrs.has(env.signer.address)) {
329
+ return err('E_REVOKER_UNAUTHORIZED', `revocation signer ${env.signer.address} not in delegation holders`);
330
+ }
331
+
332
+ const canonInput = {
333
+ address: env.signer.address,
334
+ delegation_id: env.delegation_id,
335
+ reason: env.reason,
336
+ signed_at: env.signed_at,
337
+ };
338
+ const reconstructedMessage = revocationCanonicalMessage(canonInput);
339
+ const reconstructedId = hexEncode(sha256(revocationCanonicalBytes(canonInput)));
340
+ if (reconstructedId !== env.id) {
341
+ return err('E_BAD_ID', `reconstructed id (${reconstructedId}) does not match revocation.id (${env.id})`);
342
+ }
343
+
344
+ if (!input.skipSignatureVerification) {
345
+ if (!input.verifyBip322) return err('E_BAD_SIG', 'no BIP-322 verifier supplied');
346
+ const ok = await input.verifyBip322(env.id, env.sig.value, env.signer.address);
347
+ if (!ok) return err('E_BAD_SIG', 'revocation BIP-322 signature did not verify');
348
+ }
349
+
350
+ return { ok: true, envelope: env, canonicalMessage: reconstructedMessage, id: env.id };
351
+ }
352
+
353
+ // ─────────────────────────────────────────────────────────────────────────────
354
+ // Shape checks
355
+ // ─────────────────────────────────────────────────────────────────────────────
356
+
357
+ function checkDelegationShape(env: DelegationEnvelope): VerifyDelegationResult | null {
358
+ if (env.kind !== 'agent-delegation') return err('E_MALFORMED', 'kind must be "agent-delegation"');
359
+ if (!isHex64(env.id)) return err('E_MALFORMED', 'id must be 64 lowercase hex chars');
360
+ if (!env.principal?.address || env.principal.alg !== 'bip322') return err('E_MALFORMED', 'principal invalid');
361
+ if (!env.agent?.address || env.agent.alg !== 'bip322') return err('E_MALFORMED', 'agent invalid');
362
+ if (!Array.isArray(env.scopes) || env.scopes.length === 0) return err('E_MALFORMED', 'scopes must be non-empty array');
363
+ if (env.bond !== null) {
364
+ if (!Number.isInteger(env.bond.sats) || env.bond.sats < 0) return err('E_MALFORMED', 'bond.sats must be non-negative integer');
365
+ if (!isHex64(env.bond.attestation_id)) return err('E_MALFORMED', 'bond.attestation_id must be 64-hex');
366
+ }
367
+ if (!isIsoUtc(env.issued_at)) return err('E_MALFORMED', 'issued_at must be ISO 8601 UTC');
368
+ if (!isIsoUtc(env.expires_at)) return err('E_MALFORMED', 'expires_at must be ISO 8601 UTC');
369
+ if (!/^[0-9a-f]{32}$/.test(env.nonce)) return err('E_MALFORMED', 'nonce must be 32 lowercase hex chars');
370
+ if (env.sig?.alg !== 'bip322' || typeof env.sig.value !== 'string') return err('E_MALFORMED', 'sig invalid');
371
+ if (env.sig.pubkey !== env.principal.address) return err('E_MALFORMED', 'sig.pubkey must equal principal.address');
372
+ return null;
373
+ }
374
+
375
+ function checkActionShape(a: ActionEnvelope): VerifyActionResult | null {
376
+ if (a.kind !== 'agent-action') return err('E_MALFORMED', 'kind must be "agent-action"');
377
+ if (!isHex64(a.id)) return err('E_MALFORMED', 'id must be 64 lowercase hex chars');
378
+ if (!a.content || typeof a.content.hash !== 'string' || !a.content.hash.startsWith('sha256:')) {
379
+ return err('E_MALFORMED', 'content.hash must start with "sha256:"');
380
+ }
381
+ if (!Number.isInteger(a.content.length) || a.content.length < 0) return err('E_MALFORMED', 'content.length invalid');
382
+ if (!a.signer?.address || a.signer.alg !== 'bip322') return err('E_MALFORMED', 'signer invalid');
383
+ if (!isIsoUtc(a.signed_at)) return err('E_MALFORMED', 'signed_at must be ISO 8601 UTC');
384
+ if (!isHex64(a.delegation_id)) return err('E_MALFORMED', 'delegation_id must be 64-hex');
385
+ if (typeof a.scope_exercised !== 'string' || a.scope_exercised.length === 0) return err('E_MALFORMED', 'scope_exercised required');
386
+ if (a.sig?.alg !== 'bip322' || typeof a.sig.value !== 'string') return err('E_MALFORMED', 'sig invalid');
387
+ if (a.sig.pubkey !== a.signer.address) return err('E_MALFORMED', 'sig.pubkey must equal signer.address');
388
+ return null;
389
+ }
390
+
391
+ function checkRevocationShape(env: RevocationEnvelope): VerifyRevocationResult | null {
392
+ if (env.kind !== 'agent-revocation') return err('E_MALFORMED', 'kind must be "agent-revocation"');
393
+ if (!isHex64(env.id)) return err('E_MALFORMED', 'id must be 64 lowercase hex chars');
394
+ if (!isHex64(env.delegation_id)) return err('E_MALFORMED', 'delegation_id must be 64-hex');
395
+ if (!env.signer?.address || env.signer.alg !== 'bip322') return err('E_MALFORMED', 'signer invalid');
396
+ if (typeof env.reason !== 'string' || env.reason.length > 128) return err('E_MALFORMED', 'reason must be a string <=128 bytes');
397
+ if (!isIsoUtc(env.signed_at)) return err('E_MALFORMED', 'signed_at must be ISO 8601 UTC');
398
+ if (env.sig?.alg !== 'bip322' || typeof env.sig.value !== 'string') return err('E_MALFORMED', 'sig invalid');
399
+ if (env.sig.pubkey !== env.signer.address) return err('E_MALFORMED', 'sig.pubkey must equal signer.address');
400
+ return null;
401
+ }
402
+
403
+ // ─────────────────────────────────────────────────────────────────────────────
404
+ // Time comparison for revocation vs action (SPEC §9.3)
405
+ // ─────────────────────────────────────────────────────────────────────────────
406
+
407
+ type EffectiveTime =
408
+ | { kind: 'anchor'; blockHeight: number }
409
+ | { kind: 'signed'; ms: number };
410
+
411
+ function actionEffectiveTime(
412
+ a: ActionEnvelope,
413
+ resolve?: (env: ActionEnvelope | RevocationEnvelope) => number | null
414
+ ): EffectiveTime {
415
+ if (a.ots?.status === 'confirmed') {
416
+ const h = resolve ? resolve(a) : a.ots.block_height;
417
+ if (h !== null && h !== undefined) return { kind: 'anchor', blockHeight: h };
418
+ }
419
+ return { kind: 'signed', ms: new Date(a.signed_at).getTime() };
420
+ }
421
+
422
+ function effectiveRevocationTime(
423
+ r: RevocationEnvelope,
424
+ resolve?: (env: ActionEnvelope | RevocationEnvelope) => number | null
425
+ ): EffectiveTime {
426
+ if (r.ots?.status === 'confirmed') {
427
+ const h = resolve ? resolve(r) : r.ots.block_height;
428
+ if (h !== null && h !== undefined) return { kind: 'anchor', blockHeight: h };
429
+ }
430
+ return { kind: 'signed', ms: new Date(r.signed_at).getTime() };
431
+ }
432
+
433
+ /** Returns <0 if a < b, 0 if equal, >0 if a > b. Anchored always beats signed-only. */
434
+ function compareTimes(a: EffectiveTime, b: EffectiveTime): number {
435
+ if (a.kind === 'anchor' && b.kind === 'anchor') return a.blockHeight - b.blockHeight;
436
+ // If only one anchored, the anchored one is authoritative: an unanchored action cannot
437
+ // prove priority against an anchored revocation, so the anchored side is treated as "earlier".
438
+ if (a.kind === 'anchor') return -1;
439
+ if (b.kind === 'anchor') return 1;
440
+ return a.ms - b.ms;
441
+ }
442
+
443
+ // ─────────────────────────────────────────────────────────────────────────────
444
+
445
+ function err(code: AgentErrorCode, message: string): VerifyErrResult {
446
+ return { ok: false, code, message };
447
+ }
448
+
449
+ type VerifyErrResult = { ok: false; code: AgentErrorCode; message: string };
450
+
451
+ function isHex64(s: unknown): s is string {
452
+ return typeof s === 'string' && /^[0-9a-f]{64}$/.test(s);
453
+ }
454
+
455
+ function isIsoUtc(s: unknown): s is string {
456
+ return (
457
+ typeof s === 'string' &&
458
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/.test(s)
459
+ );
460
+ }