@orangecheck/agent-core 2.1.0 → 2.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangecheck/agent-core",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "OC Agent canonical messages, envelope formats (delegation/action/revocation), scope grammar, and verification. See https://github.com/orangecheck/oc-agent-protocol.",
5
5
  "keywords": [
6
6
  "bitcoin",
@@ -97,13 +97,39 @@ d('federation delegation vectors (v19–v24, v26)', () => {
97
97
  d('federation revocation (v25)', () => {
98
98
  it('3-of-5 threshold met → valid', async () => {
99
99
  const v = await load('v25-federation-revocation-3of5-valid.json');
100
+ const dv = await load('v19-federation-delegation-3of5-valid.json');
100
101
  const result = await verifyFederationRevocation({
101
102
  envelope: v.expected.envelope as FederationRevocationEnvelope,
103
+ delegation: dv.expected.envelope as FederationDelegationEnvelope,
102
104
  skipSignatureVerification: true,
103
105
  });
104
106
  expect(result.ok).toBe(true);
105
107
  if (result.ok) expect(result.id).toBe(v.expected.envelope.id);
106
108
  });
109
+
110
+ it('is bound to the delegation it revokes and to that delegation\'s federation', async () => {
111
+ const v = await load('v25-federation-revocation-3of5-valid.json');
112
+ const dv = await load('v19-federation-delegation-3of5-valid.json');
113
+ const rev = v.expected.envelope as FederationRevocationEnvelope;
114
+ const del = dv.expected.envelope as FederationDelegationEnvelope;
115
+ const otherDelegation = await verifyFederationRevocation({
116
+ envelope: rev,
117
+ delegation: { ...del, id: 'f'.repeat(64) },
118
+ skipSignatureVerification: true,
119
+ });
120
+ expect(otherDelegation.ok === false && otherDelegation.code).toBe('E_DELEGATION_MISMATCH');
121
+ const otherFederation = await verifyFederationRevocation({
122
+ envelope: rev,
123
+ delegation: { ...del, principal: { ...del.principal, descriptor_id: 'e'.repeat(64) } },
124
+ skipSignatureVerification: true,
125
+ });
126
+ expect(otherFederation.ok === false && otherFederation.code).toBe('E_REVOKER_UNAUTHORIZED');
127
+ const missing = await verifyFederationRevocation({
128
+ envelope: rev,
129
+ skipSignatureVerification: true,
130
+ } as Parameters<typeof verifyFederationRevocation>[0]);
131
+ expect(missing.ok).toBe(false);
132
+ });
107
133
  });
108
134
 
109
135
  d('federation reject reasons (each invalid case fails for its named reason)', () => {
package/src/federation.ts CHANGED
@@ -291,6 +291,8 @@ export async function verifyFederationDelegation(
291
291
 
292
292
  export interface VerifyFederationRevocationInput extends VerifyFederationBase {
293
293
  envelope: FederationRevocationEnvelope;
294
+ /** The delegation being revoked. Its federation is the one that may revoke it. */
295
+ delegation: FederationDelegationEnvelope;
294
296
  }
295
297
 
296
298
  export async function verifyFederationRevocation(
@@ -305,6 +307,18 @@ export async function verifyFederationRevocation(
305
307
  }
306
308
  if (!ISO_UTC.test(env.signed_at)) return fail('E_MALFORMED', 'signed_at must be ISO 8601 UTC');
307
309
 
310
+ const d = input.delegation;
311
+ if (!d || !HEX64.test(d.id ?? '')) return fail('E_MALFORMED', 'the revoked delegation is required');
312
+ if (env.delegation_id !== d.id) {
313
+ return fail('E_DELEGATION_MISMATCH', `revocation.delegation_id (${env.delegation_id}) != delegation.id (${d.id})`);
314
+ }
315
+ if (d.principal?.alg !== 'federation' || env.signer?.descriptor_id !== d.principal.descriptor_id) {
316
+ return fail(
317
+ 'E_REVOKER_UNAUTHORIZED',
318
+ `revoking federation (${env.signer?.descriptor_id}) is not the delegation's principal`
319
+ );
320
+ }
321
+
308
322
  // §4 — the `address:` line carries the `federation:<descriptor_id>`
309
323
  // substitution; everything else is the v1 revocation canonical message.
310
324
  const canonInput = {
@@ -0,0 +1,258 @@
1
+ // SPEC §9.5 / §9.3: who may revoke a delegation, and when a revocation takes
2
+ // effect against an action. `revocation.holders` and `ots` are both outside the
3
+ // signatures they sit next to, so neither may narrow what a signed revocation
4
+ // does.
5
+ import { describe, expect, it } from 'vitest';
6
+
7
+ import { computeActionId, computeDelegationId, computeRevocationId } from './canonical.js';
8
+ import { verifyAction, verifyDelegation, verifyRevocation } from './verify.js';
9
+ import type { ActionEnvelope, ActionOts, DelegationEnvelope, RevocationEnvelope } from './types.js';
10
+
11
+ const PRINCIPAL = 'bc1qprincipal000000000000000000000000000000';
12
+ const AGENT = 'bc1qagent0000000000000000000000000000000000';
13
+ const SCOPE = 'lock:seal(recipient=bc1qalice000000000000000000000000000000000)';
14
+
15
+ function delegation(holders: unknown): DelegationEnvelope {
16
+ const canon = {
17
+ principal: PRINCIPAL,
18
+ agent: AGENT,
19
+ scopes: [SCOPE],
20
+ bond_sats: 0,
21
+ bond_attestation: 'none',
22
+ issued_at: '2026-04-22T12:00:00Z',
23
+ expires_at: '2026-04-29T12:00:00Z',
24
+ nonce: '0123456789abcdef0123456789abcdef',
25
+ };
26
+ return {
27
+ v: 1,
28
+ kind: 'agent-delegation',
29
+ id: computeDelegationId(canon),
30
+ principal: { address: PRINCIPAL, alg: 'bip322' },
31
+ agent: { address: AGENT, alg: 'bip322' },
32
+ scopes: [SCOPE],
33
+ bond: null,
34
+ issued_at: canon.issued_at,
35
+ expires_at: canon.expires_at,
36
+ nonce: canon.nonce,
37
+ revocation: { holders, ref: null },
38
+ sig: { alg: 'bip322', pubkey: PRINCIPAL, value: 'AAAA' },
39
+ } as DelegationEnvelope;
40
+ }
41
+
42
+ function revocation(d: DelegationEnvelope, signer: string, signedAt: string, ots: ActionOts | null = null): RevocationEnvelope {
43
+ const id = computeRevocationId({ address: signer, delegation_id: d.id, reason: '', signed_at: signedAt });
44
+ return {
45
+ v: 1,
46
+ kind: 'agent-revocation',
47
+ id,
48
+ delegation_id: d.id,
49
+ signer: { address: signer, alg: 'bip322' },
50
+ reason: '',
51
+ signed_at: signedAt,
52
+ ots,
53
+ sig: { alg: 'bip322', pubkey: signer, value: 'AAAA' },
54
+ };
55
+ }
56
+
57
+ function action(d: DelegationEnvelope, signedAt: string, ots: ActionOts | null = null): ActionEnvelope {
58
+ const canon = {
59
+ address: AGENT,
60
+ content_hash: 'sha256:' + '00'.repeat(32),
61
+ content_length: 0,
62
+ content_mime: 'text/plain',
63
+ signed_at: signedAt,
64
+ delegation_id: d.id,
65
+ scope_exercised: SCOPE,
66
+ };
67
+ return {
68
+ v: 1,
69
+ kind: 'agent-action',
70
+ id: computeActionId(canon),
71
+ content: { hash: canon.content_hash, length: 0, mime: 'text/plain', ref: null },
72
+ signer: { address: AGENT, alg: 'bip322' },
73
+ signed_at: signedAt,
74
+ delegation_id: d.id,
75
+ scope_exercised: SCOPE,
76
+ ots,
77
+ sig: { alg: 'bip322', pubkey: AGENT, value: 'AAAA' },
78
+ } as ActionEnvelope;
79
+ }
80
+
81
+ function confirmed(height: number): ActionOts {
82
+ return {
83
+ status: 'confirmed',
84
+ proof: 'AAAA',
85
+ calendars: [],
86
+ block_height: height,
87
+ block_hash: 'ab'.repeat(32),
88
+ upgraded_at: null,
89
+ };
90
+ }
91
+
92
+ const AT = new Date('2026-04-23T12:00:00Z');
93
+
94
+ describe('revocation authority always includes the principal', () => {
95
+ for (const holders of [['agent'], [], ['principal'], undefined, 'agent']) {
96
+ it(`principal revocation is honoured with holders=${JSON.stringify(holders)}`, async () => {
97
+ const d = delegation(holders);
98
+ const rev = revocation(d, PRINCIPAL, '2026-04-23T00:00:00Z');
99
+ const rr = await verifyRevocation({ envelope: rev, delegation: d, skipSignatureVerification: true });
100
+ expect(rr.ok).toBe(true);
101
+ const dr = await verifyDelegation({
102
+ envelope: d,
103
+ revocations: [rev],
104
+ skipSignatureVerification: true,
105
+ now: AT,
106
+ });
107
+ expect(dr.ok === false && dr.code).toBe('E_REVOKED');
108
+ const ar = await verifyAction({
109
+ action: action(d, '2026-04-23T06:00:00Z'),
110
+ delegation: d,
111
+ revocations: [rev],
112
+ skipSignatureVerification: true,
113
+ });
114
+ expect(ar.ok === false && ar.code).toBe('E_REVOKED');
115
+ });
116
+ }
117
+
118
+ it('the agent may revoke only when holders lists it', async () => {
119
+ const open = delegation(['principal', 'agent']);
120
+ const closed = delegation(['principal']);
121
+ const okR = await verifyRevocation({
122
+ envelope: revocation(open, AGENT, '2026-04-23T00:00:00Z'),
123
+ delegation: open,
124
+ skipSignatureVerification: true,
125
+ });
126
+ expect(okR.ok).toBe(true);
127
+ const noR = await verifyRevocation({
128
+ envelope: revocation(closed, AGENT, '2026-04-23T00:00:00Z'),
129
+ delegation: closed,
130
+ skipSignatureVerification: true,
131
+ });
132
+ expect(noR.ok === false && noR.code).toBe('E_REVOKER_UNAUTHORIZED');
133
+ });
134
+
135
+ it('a third party is never an authorised revoker', async () => {
136
+ const d = delegation(['principal', 'agent']);
137
+ const r = await verifyRevocation({
138
+ envelope: revocation(d, 'bc1qother0000000000000000000000000000000000', '2026-04-23T00:00:00Z'),
139
+ delegation: d,
140
+ skipSignatureVerification: true,
141
+ });
142
+ expect(r.ok === false && r.code).toBe('E_REVOKER_UNAUTHORIZED');
143
+ });
144
+ });
145
+
146
+ describe('revocation priority uses only verified anchors', () => {
147
+ const d = delegation(['principal']);
148
+ // Revocation signed before the action: revoked unless the action proves priority.
149
+ const rev = revocation(d, PRINCIPAL, '2026-04-23T00:00:00Z');
150
+ const later = '2026-04-23T06:00:00Z';
151
+
152
+ it('an action anchor with no anchor verifier does not outrank a revocation', async () => {
153
+ const r = await verifyAction({
154
+ action: action(d, later, confirmed(1)),
155
+ delegation: d,
156
+ revocations: [rev],
157
+ skipSignatureVerification: true,
158
+ });
159
+ expect(r.ok === false && r.code).toBe('E_REVOKED');
160
+ });
161
+
162
+ it('an action anchor the verifier rejects does not outrank a revocation', async () => {
163
+ const r = await verifyAction({
164
+ action: action(d, later, confirmed(1)),
165
+ delegation: d,
166
+ revocations: [rev],
167
+ skipSignatureVerification: true,
168
+ verifyOtsAnchor: async () => false,
169
+ });
170
+ expect(r.ok === false && r.code).toBe('E_REVOKED');
171
+ const clean = await verifyAction({
172
+ action: action(d, later, confirmed(1)),
173
+ delegation: d,
174
+ revocations: [],
175
+ skipSignatureVerification: true,
176
+ verifyOtsAnchor: async () => false,
177
+ });
178
+ expect(clean.ok && clean.anchor).toEqual({
179
+ status: 'confirmed',
180
+ blockHeight: 1,
181
+ blockHash: 'ab'.repeat(32),
182
+ verified: false,
183
+ });
184
+ });
185
+
186
+ it('the anchor verifier is asked about the envelope it belongs to', async () => {
187
+ const a = action(d, later, confirmed(100));
188
+ const revAnchored = revocation(d, PRINCIPAL, '2026-04-23T00:00:00Z', confirmed(200));
189
+ const seen: string[] = [];
190
+ const r = await verifyAction({
191
+ action: a,
192
+ delegation: d,
193
+ revocations: [revAnchored],
194
+ skipSignatureVerification: true,
195
+ verifyOtsAnchor: async (_p, _h, _b, id) => {
196
+ seen.push(id);
197
+ return true;
198
+ },
199
+ });
200
+ expect(seen).toEqual([a.id, revAnchored.id]);
201
+ // Both verified: action at 100 precedes revocation at 200 (§9.3).
202
+ expect(r.ok).toBe(true);
203
+ });
204
+
205
+ it('a verified action anchor against an unanchored revocation falls back to signed times', async () => {
206
+ const r = await verifyAction({
207
+ action: action(d, later, confirmed(1)),
208
+ delegation: d,
209
+ revocations: [rev],
210
+ skipSignatureVerification: true,
211
+ verifyOtsAnchor: async () => true,
212
+ });
213
+ expect(r.ok === false && r.code).toBe('E_REVOKED');
214
+ const before = await verifyAction({
215
+ action: action(d, '2026-04-22T18:00:00Z', confirmed(1)),
216
+ delegation: d,
217
+ revocations: [rev],
218
+ skipSignatureVerification: true,
219
+ verifyOtsAnchor: async () => true,
220
+ });
221
+ expect(before.ok).toBe(true);
222
+ });
223
+
224
+ it('a revocation anchor that does not verify cannot move the revocation later', async () => {
225
+ const revFarFuture = revocation(d, PRINCIPAL, '2026-04-23T00:00:00Z', confirmed(10_000_000));
226
+ const a = action(d, later, confirmed(100));
227
+ const r = await verifyAction({
228
+ action: a,
229
+ delegation: d,
230
+ revocations: [revFarFuture],
231
+ skipSignatureVerification: true,
232
+ verifyOtsAnchor: async (_p, _h, _b, id) => id === a.id,
233
+ });
234
+ expect(r.ok === false && r.code).toBe('E_REVOKED');
235
+ });
236
+
237
+ it('both anchors verified: the later block does not revoke the earlier action', async () => {
238
+ const a = action(d, later, confirmed(300));
239
+ const revAt = (h: number) => revocation(d, PRINCIPAL, '2026-04-23T00:00:00Z', confirmed(h));
240
+ const verifyOtsAnchor = async () => true;
241
+ const earlierRev = await verifyAction({
242
+ action: a,
243
+ delegation: d,
244
+ revocations: [revAt(300)],
245
+ skipSignatureVerification: true,
246
+ verifyOtsAnchor,
247
+ });
248
+ expect(earlierRev.ok === false && earlierRev.code).toBe('E_REVOKED');
249
+ const laterRev = await verifyAction({
250
+ action: a,
251
+ delegation: d,
252
+ revocations: [revAt(301)],
253
+ skipSignatureVerification: true,
254
+ verifyOtsAnchor,
255
+ });
256
+ expect(laterRev.ok).toBe(true);
257
+ });
258
+ });
package/src/verify.ts CHANGED
@@ -298,8 +298,19 @@ export interface VerifyActionInput extends VerifyBase {
298
298
  */
299
299
  maxChainDepth?: number;
300
300
  content?: Uint8Array;
301
- verifyOtsAnchor?: (proofB64: string, blockHeight: number, blockHash: string) => Promise<boolean>;
302
- /** 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. */
301
+ /**
302
+ * Verify an OTS proof commits `envelopeId` into the block at `blockHeight`.
303
+ * Same shape as @orangecheck/stamp-core. An anchor is used for revocation
304
+ * priority (SPEC §9.3) only when this returns true; without it every
305
+ * anchor is treated as absent.
306
+ */
307
+ verifyOtsAnchor?: (
308
+ proofB64: string,
309
+ blockHeight: number,
310
+ blockHash: string,
311
+ envelopeId: string
312
+ ) => Promise<boolean>;
313
+ /** 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. The height is still checked by verifyOtsAnchor. */
303
314
  resolveAnchorBlockHeight?: (env: ActionEnvelope | RevocationEnvelope) => number | null;
304
315
  /**
305
316
  * v1.2 private-scope decryption key. Applied to the root delegation AND
@@ -445,6 +456,14 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
445
456
  'in the chain) or an explicit skipRevocationCheck:true',
446
457
  );
447
458
  }
459
+ // Anchor heights only count once the proof is verified against the
460
+ // envelope's own id; `ots` is outside the signature.
461
+ let actionAnchor: number | null;
462
+ try {
463
+ actionAnchor = await verifiedAnchorHeight(a, input);
464
+ } catch (e) {
465
+ return err('E_MALFORMED', `anchor verifier threw: ${(e as Error).message}`);
466
+ }
448
467
  if (input.revocations && input.revocations.length > 0) {
449
468
  const allLinks: ChainLink[] = [rootHydrated, ...hydratedChain];
450
469
  for (const link of allLinks) {
@@ -460,9 +479,13 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
460
479
  skipSignatureVerification: input.skipSignatureVerification,
461
480
  });
462
481
  if (!rr.ok) continue; // malformed revocations don't affect the action
463
- const effective = effectiveRevocationTime(rev, input.resolveAnchorBlockHeight);
464
- const actionTime = actionEffectiveTime(a, input.resolveAnchorBlockHeight);
465
- if (compareTimes(effective, actionTime) <= 0) {
482
+ let revAnchor: number | null;
483
+ try {
484
+ revAnchor = await verifiedAnchorHeight(rev, input);
485
+ } catch (e) {
486
+ return err('E_MALFORMED', `anchor verifier threw: ${(e as Error).message}`);
487
+ }
488
+ if (revocationPrecedes(rev, revAnchor, a, actionAnchor)) {
466
489
  return err('E_REVOKED', `chain link ${link.id} was revoked by ${rev.id} before action was signed`);
467
490
  }
468
491
  }
@@ -489,14 +512,7 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
489
512
  if (h === null || hash === null) {
490
513
  return err('E_MALFORMED', 'confirmed OTS proof missing block_height or block_hash');
491
514
  }
492
- let verified = false;
493
- if (input.verifyOtsAnchor) {
494
- try {
495
- verified = await input.verifyOtsAnchor(a.ots.proof, h, hash);
496
- } catch (e) {
497
- return err('E_MALFORMED', `anchor verifier threw: ${(e as Error).message}`);
498
- }
499
- }
515
+ const verified = actionAnchor !== null;
500
516
  anchor = { status: 'confirmed', blockHeight: h, blockHash: hash, verified } as typeof anchor;
501
517
  }
502
518
 
@@ -597,11 +613,11 @@ export async function verifyRevocation(input: VerifyRevocationInput): Promise<Ve
597
613
  return err('E_DELEGATION_MISMATCH', `revocation.delegation_id (${env.delegation_id}) != delegation.id (${d.id})`);
598
614
  }
599
615
 
600
- // Signer must be authorized per delegation.revocation.holders.
616
+ // The principal may always revoke (SPEC §9.5). `revocation.holders` is not
617
+ // covered by the delegation signature, so it may only add the agent.
601
618
  const holders = d.revocation?.holders ?? ['principal'];
602
- const holderAddrs = new Set<string>();
603
- if (holders.includes('principal')) holderAddrs.add(d.principal.address);
604
- if (holders.includes('agent')) holderAddrs.add(d.agent.address);
619
+ const holderAddrs = new Set<string>([d.principal.address]);
620
+ if (Array.isArray(holders) && holders.includes('agent')) holderAddrs.add(d.agent.address);
605
621
  if (!holderAddrs.has(env.signer.address)) {
606
622
  return err('E_REVOKER_UNAUTHORIZED', `revocation signer ${env.signer.address} not in delegation holders`);
607
623
  }
@@ -901,40 +917,36 @@ export async function verifySubdelegation(
901
917
  // Time comparison for revocation vs action (SPEC §9.3)
902
918
  // ─────────────────────────────────────────────────────────────────────────────
903
919
 
904
- type EffectiveTime =
905
- | { kind: 'anchor'; blockHeight: number }
906
- | { kind: 'signed'; ms: number };
907
-
908
- function actionEffectiveTime(
909
- a: ActionEnvelope,
910
- resolve?: (env: ActionEnvelope | RevocationEnvelope) => number | null
911
- ): EffectiveTime {
912
- if (a.ots?.status === 'confirmed') {
913
- const h = resolve ? resolve(a) : a.ots.block_height;
914
- if (h !== null && h !== undefined) return { kind: 'anchor', blockHeight: h };
915
- }
916
- return { kind: 'signed', ms: new Date(a.signed_at).getTime() };
917
- }
918
-
919
- function effectiveRevocationTime(
920
- r: RevocationEnvelope,
921
- resolve?: (env: ActionEnvelope | RevocationEnvelope) => number | null
922
- ): EffectiveTime {
923
- if (r.ots?.status === 'confirmed') {
924
- const h = resolve ? resolve(r) : r.ots.block_height;
925
- if (h !== null && h !== undefined) return { kind: 'anchor', blockHeight: h };
926
- }
927
- return { kind: 'signed', ms: new Date(r.signed_at).getTime() };
920
+ /**
921
+ * The block height of an envelope's OTS anchor, or null unless the proof has
922
+ * been verified to commit this envelope's id at that height.
923
+ */
924
+ async function verifiedAnchorHeight(
925
+ env: ActionEnvelope | RevocationEnvelope,
926
+ input: Pick<VerifyActionInput, 'verifyOtsAnchor' | 'resolveAnchorBlockHeight'>
927
+ ): Promise<number | null> {
928
+ if (env.ots?.status !== 'confirmed' || !input.verifyOtsAnchor) return null;
929
+ const h = input.resolveAnchorBlockHeight ? input.resolveAnchorBlockHeight(env) : env.ots.block_height;
930
+ const hash = env.ots.block_hash;
931
+ if (h === null || h === undefined || !Number.isInteger(h) || !hash) return null;
932
+ return (await input.verifyOtsAnchor(env.ots.proof, h, hash, env.id)) ? h : null;
928
933
  }
929
934
 
930
- /** Returns <0 if a < b, 0 if equal, >0 if a > b. Anchored always beats signed-only. */
931
- function compareTimes(a: EffectiveTime, b: EffectiveTime): number {
932
- if (a.kind === 'anchor' && b.kind === 'anchor') return a.blockHeight - b.blockHeight;
933
- // If only one anchored, the anchored one is authoritative: an unanchored action cannot
934
- // prove priority against an anchored revocation, so the anchored side is treated as "earlier".
935
- if (a.kind === 'anchor') return -1;
936
- if (b.kind === 'anchor') return 1;
937
- return a.ms - b.ms;
935
+ /**
936
+ * True when the revocation takes effect at or before the action (SPEC §9.3).
937
+ * Only verified anchors count for priority. An action without one cannot prove
938
+ * priority over a revocation that claims an anchor; an anchored action against
939
+ * an unanchored revocation falls back to the signed times.
940
+ */
941
+ function revocationPrecedes(
942
+ rev: RevocationEnvelope,
943
+ revAnchor: number | null,
944
+ action: ActionEnvelope,
945
+ actionAnchor: number | null
946
+ ): boolean {
947
+ if (revAnchor !== null && actionAnchor !== null) return revAnchor <= actionAnchor;
948
+ if (actionAnchor === null && rev.ots?.status === 'confirmed') return true;
949
+ return new Date(rev.signed_at).getTime() <= new Date(action.signed_at).getTime();
938
950
  }
939
951
 
940
952
  // ─────────────────────────────────────────────────────────────────────────────