@orangecheck/agent-core 2.0.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.
@@ -10,19 +10,30 @@
10
10
  //
11
11
  // This library has no network by design, so the caller fetches the feed. What
12
12
  // changed is that declining to is now a decision they have to state.
13
- import { readFileSync } from 'node:fs';
14
- import { dirname, join } from 'node:path';
15
- import { fileURLToPath } from 'node:url';
16
-
17
13
  import { describe, expect, it } from 'vitest';
18
14
 
19
15
  import { verifyDelegation } from './verify.js';
20
16
  import type { DelegationEnvelope } from './types.js';
21
17
 
22
- const vectors = join(dirname(fileURLToPath(import.meta.url)), '../../../oc-agent-protocol/test-vectors');
23
- const delegation = JSON.parse(
24
- readFileSync(join(vectors, 'v01-delegation-minimal.json'), 'utf8'),
25
- ).expected.envelope as DelegationEnvelope;
18
+ // Inlined rather than read from oc-agent-protocol/test-vectors: this test is
19
+ // about the revocation guard, not vector conformance, and a relative path into
20
+ // a sibling repo resolves locally but not in CI, where the spec repo is not
21
+ // checked out beside this one. test-vectors.test.ts is the place that depends
22
+ // on the real vectors, and CI checks them out for it.
23
+ const delegation: DelegationEnvelope = {
24
+ v: 1,
25
+ kind: 'agent-delegation',
26
+ id: '36d79600191db871baa3fc9aa3b5e77750a5c423b1f620ec26cf16bd122e19a7',
27
+ principal: { address: 'bc1qprincipal000000000000000000000000000000', alg: 'bip322' },
28
+ agent: { address: 'bc1qagent0000000000000000000000000000000000', alg: 'bip322' },
29
+ scopes: ['lock:seal(recipient=bc1qalice000000000000000000000000000000000)'],
30
+ bond: null,
31
+ issued_at: '2026-04-22T12:00:00Z',
32
+ expires_at: '2026-04-29T12:00:00Z',
33
+ nonce: '0123456789abcdef0123456789abcdef',
34
+ revocation: { holders: ['principal'], ref: null },
35
+ sig: { alg: 'bip322', pubkey: 'bc1qprincipal000000000000000000000000000000', value: 'AAAA' },
36
+ } as DelegationEnvelope;
26
37
 
27
38
  const AT = new Date('2026-04-23T12:00:00Z'); // inside the delegation's window
28
39
 
package/src/scope.test.ts CHANGED
@@ -160,3 +160,47 @@ describe('isSubScope (SPEC §7.4)', () => {
160
160
  ).toBe(false);
161
161
  });
162
162
  });
163
+
164
+ // SPEC §7.4: "values MUST be decimal integers for ordered ops". `Number()` is
165
+ // much looser and accepted 1e3, 0x3e8, +1000, " 1000 " and 1.5 — plus "", which
166
+ // becomes 0 and turns an empty constraint into a silent zero bound. A verifier
167
+ // with a strict decimal parser rejects all of them, so identical bytes produced
168
+ // different accept/reject answers across implementations, which SECURITY §5
169
+ // rules out.
170
+ describe('ordered-op values must be decimal integers (SPEC §7.4)', () => {
171
+ const granted = parseScope('ln:send(max_sats<=1000)');
172
+
173
+ it('still admits a plain decimal integer', () => {
174
+ expect(isSubScope(parseScope('ln:send(max_sats<=500)'), granted)).toBe(true);
175
+ expect(isSubScope(parseScope('ln:send(max_sats<=1000)'), granted)).toBe(true);
176
+ });
177
+
178
+ it('still rejects a decimal integer outside the granted range', () => {
179
+ expect(isSubScope(parseScope('ln:send(max_sats<=1001)'), granted)).toBe(false);
180
+ });
181
+
182
+ // Each of these previously compared numerically and could be ADMITTED.
183
+ for (const v of ['1e3', '0x3e8', '+1000', '1.5']) {
184
+ it(`rejects ${JSON.stringify(v)} rather than coercing it`, () => {
185
+ let exercised;
186
+ try {
187
+ exercised = parseScope(`ln:send(max_sats<=${v})`);
188
+ } catch {
189
+ return; // rejected at parse time is also a rejection
190
+ }
191
+ expect(isSubScope(exercised, granted)).toBe(false);
192
+ });
193
+ }
194
+
195
+ // The sharpest case: Number('') === 0, so an empty constraint became
196
+ // max_sats<=0 — a real bound nobody wrote.
197
+ it('does not silently read an empty value as zero', () => {
198
+ let exercised;
199
+ try {
200
+ exercised = parseScope('ln:send(max_sats<=)');
201
+ } catch {
202
+ return;
203
+ }
204
+ expect(isSubScope(exercised, parseScope('ln:send(max_sats<=0)'))).toBe(false);
205
+ });
206
+ });
package/src/scope.ts CHANGED
@@ -309,10 +309,25 @@ function rangeSubset(ex: ScopeConstraint, g: ScopeConstraint): boolean {
309
309
  return gRange.lo <= exRange.lo && exRange.hi <= gRange.hi;
310
310
  }
311
311
 
312
+ /**
313
+ * SPEC §7.4: "values MUST be decimal integers for ordered ops".
314
+ *
315
+ * `Number()` is far more permissive than that and accepts `1e3`, `0x3e8`,
316
+ * `+1000`, `" 1000 "`, `1.5` — and `""`, which becomes 0, turning an empty
317
+ * constraint into a silent zero bound. A verifier using a strict decimal parser
318
+ * rejects every one of those, so the same bytes produced different accept/reject
319
+ * answers on different implementations. SECURITY §5 requires they not.
320
+ */
321
+ function parseDecimalInteger(value: string): number | null {
322
+ if (!/^-?\d+$/.test(value)) return null;
323
+ const n = Number(value);
324
+ return Number.isSafeInteger(n) ? n : null;
325
+ }
326
+
312
327
  function opToRange(c: ScopeConstraint): { lo: number; hi: number } | null {
313
328
  if (c.value === undefined) return null;
314
- const n = Number(c.value);
315
- if (!Number.isFinite(n)) return null;
329
+ const n = parseDecimalInteger(c.value);
330
+ if (n === null) return null;
316
331
  switch (c.op) {
317
332
  case '=':
318
333
  return { lo: n, hi: n };
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
  // ─────────────────────────────────────────────────────────────────────────────