@orangecheck/agent-core 2.6.0 → 2.7.1

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.
@@ -18,7 +18,13 @@ import {
18
18
  subdelegationCanonicalMessage,
19
19
  } from './canonical.js';
20
20
  import { parseScope, ScopeParseError } from './scope.js';
21
- import { verifyAction, verifyDelegation, verifyRevocation, verifySubdelegation } from './verify.js';
21
+ import {
22
+ verifyAction,
23
+ verifyActionAsOfSignedAt,
24
+ verifyDelegation,
25
+ verifyRevocation,
26
+ verifySubdelegation,
27
+ } from './verify.js';
22
28
  import type {
23
29
  ActionEnvelope,
24
30
  AgentErrorCode,
@@ -127,6 +133,12 @@ interface NegativeActionVector extends NegativeVectorBase {
127
133
  scope_exercised: string;
128
134
  content_ref?: string | null;
129
135
  ots?: unknown;
136
+ /** Present on a live vector (§8.1 steps 6–7, §8.3 step 11a): the time of verification. */
137
+ verified_at?: string;
138
+ /** The freshness bound a live vector assumes, in seconds. */
139
+ max_skew_seconds?: number;
140
+ /** Revocations of the cited delegation, as the inputs of each envelope. */
141
+ revocations?: { address: string; reason: string; signed_at: string }[];
130
142
  };
131
143
  }
132
144
 
@@ -279,7 +291,8 @@ describe('oc-agent-protocol test vectors', () => {
279
291
  if (!delegation) {
280
292
  throw new Error(`action vector ${name} references missing delegation ${data.inputs.delegation_id}`);
281
293
  }
282
- const r = await verifyAction({
294
+ // A positive vector is the envelope as of its own signed_at.
295
+ const r = await verifyActionAsOfSignedAt({
283
296
  skipRevocationCheck: true,
284
297
  action: data.expected.envelope,
285
298
  delegation,
@@ -461,12 +474,34 @@ describe('oc-agent-protocol negative test vectors', () => {
461
474
  ots: null,
462
475
  sig: { alg: 'bip322', pubkey: v.inputs.address, value: 'AAAA' },
463
476
  };
464
- const r = await verifyAction({
465
- skipRevocationCheck: true,
466
- action,
467
- delegation,
468
- skipSignatureVerification: true,
469
- });
477
+ const revocations: RevocationEnvelope[] = (v.inputs.revocations ?? []).map((rv) => ({
478
+ v: 1,
479
+ kind: 'agent-revocation',
480
+ id: computeRevocationId({ ...rv, delegation_id: v.inputs.delegation_id }),
481
+ delegation_id: v.inputs.delegation_id,
482
+ signer: { address: rv.address, alg: 'bip322' },
483
+ reason: rv.reason,
484
+ signed_at: rv.signed_at,
485
+ ots: null,
486
+ sig: { alg: 'bip322', pubkey: rv.address, value: 'AAAA' },
487
+ }));
488
+ // A vector with verified_at is checked live at that time; one
489
+ // without is an audit of the envelope as of its signed_at.
490
+ const r = v.inputs.verified_at
491
+ ? await verifyAction({
492
+ action,
493
+ delegation,
494
+ skipSignatureVerification: true,
495
+ revocations,
496
+ now: new Date(v.inputs.verified_at),
497
+ maxSkewMs: (v.inputs.max_skew_seconds ?? 120) * 1000,
498
+ })
499
+ : await verifyActionAsOfSignedAt({
500
+ skipRevocationCheck: true,
501
+ action,
502
+ delegation,
503
+ skipSignatureVerification: true,
504
+ });
470
505
  expect(r.ok).toBe(false);
471
506
  if (!r.ok) {
472
507
  // v07 specifically allows either E_OUT_OF_WINDOW or E_EXPIRED
package/src/types.ts CHANGED
@@ -237,6 +237,7 @@ export type AgentErrorCode =
237
237
  | 'E_NOT_YET_VALID'
238
238
  | 'E_EXPIRED'
239
239
  | 'E_REVOKED'
240
+ | 'E_STALE'
240
241
  | 'E_DELEGATION_MISMATCH'
241
242
  | 'E_AGENT_MISMATCH'
242
243
  | 'E_OUT_OF_WINDOW'
@@ -298,6 +299,14 @@ export interface VerifyActionOkExtra {
298
299
  | { status: 'pending' }
299
300
  | { status: 'claimed'; blockHeight: number; blockHash: string; verified: false }
300
301
  | { status: 'confirmed'; blockHeight: number; blockHash: string; verified: true };
302
+ /**
303
+ * The time the verdict holds for. `now`: verifyAction, checked live.
304
+ * `signed_at`: verifyActionAsOfSignedAt, checked as of the time the agent
305
+ * claims; `anchored` is false when only the agent says so.
306
+ */
307
+ asOf:
308
+ | { basis: 'now'; now: string }
309
+ | { basis: 'signed_at'; signedAt: string; anchored: boolean };
301
310
  }
302
311
 
303
312
  export type VerifyActionResult =
package/src/verify.ts CHANGED
@@ -40,6 +40,9 @@ import {
40
40
  /** Default maximum chain depth, per SUB-DELEGATION.md §2.1. */
41
41
  export const DEFAULT_MAX_CHAIN_DEPTH = 5;
42
42
 
43
+ /** How far a live action's `signed_at` may lie from the verifier's clock, either way (SPEC §8.3 step 11a). */
44
+ export const DEFAULT_MAX_SKEW_MS = 120_000;
45
+
43
46
  // ─────────────────────────────────────────────────────────────────────────────
44
47
  // Shared options
45
48
  // ─────────────────────────────────────────────────────────────────────────────
@@ -324,9 +327,51 @@ export interface VerifyActionInput extends VerifyBase {
324
327
  device_id: string;
325
328
  secretKey: Uint8Array;
326
329
  };
330
+ /**
331
+ * The time of verification; defaults to new Date(). Every link of the
332
+ * chain must be in force at `now`, and a revocation in effect at or
333
+ * before `now` refuses the action, whatever `signed_at` it claims
334
+ * (SPEC §8.1 steps 6–7).
335
+ */
336
+ now?: Date;
337
+ /**
338
+ * How far `signed_at` may lie from `now`, either way, before the action
339
+ * is refused with E_STALE. Default DEFAULT_MAX_SKEW_MS (120 s). The agent
340
+ * chooses `signed_at`, so this bound is what makes it mean "just now".
341
+ */
342
+ maxSkewMs?: number;
327
343
  }
328
344
 
345
+ /** An audit of a past action: no `now`, no freshness bound. See verifyActionAsOfSignedAt. */
346
+ export type VerifyActionAsOfSignedAtInput = Omit<VerifyActionInput, 'now' | 'maxSkewMs'>;
347
+
348
+ /**
349
+ * Verify an action live, as of `now` (SPEC §8). This is the check for a
350
+ * server deciding whether to act on it. Replay is the caller's to stop: keep
351
+ * the ids of accepted actions (createReplayGuard) for as long as they could
352
+ * pass the freshness bound.
353
+ */
329
354
  export async function verifyAction(input: VerifyActionInput): Promise<VerifyActionResult> {
355
+ return verifyActionAt(input, input.now ?? new Date());
356
+ }
357
+
358
+ /**
359
+ * Verify a past action as of its own `signed_at`, for an audit (SPEC §8.6).
360
+ * The agent chose that time, so the verdict holds only if the claim is true:
361
+ * `asOf.anchored` is false unless a verified OTS anchor fixes it. Never use
362
+ * this to decide whether to act on an action now.
363
+ */
364
+ export async function verifyActionAsOfSignedAt(
365
+ input: VerifyActionAsOfSignedAtInput
366
+ ): Promise<VerifyActionResult> {
367
+ return verifyActionAt(input, null);
368
+ }
369
+
370
+ /** `now` null: the audit form, checked as of the action's own signed_at. */
371
+ async function verifyActionAt(
372
+ input: VerifyActionInput,
373
+ now: Date | null
374
+ ): Promise<VerifyActionResult> {
330
375
  const a = input.action;
331
376
  const d = input.delegation;
332
377
 
@@ -427,6 +472,19 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
427
472
  return err('E_OUT_OF_WINDOW', `action.signed_at ${a.signed_at} is outside leaf window [${leaf.issued_at}, ${leaf.expires_at})`);
428
473
  }
429
474
 
475
+ // 4b. Live: every link is in force now, not only at the claimed signed_at.
476
+ if (now) {
477
+ const t = now.getTime();
478
+ for (const link of [rootHydrated, ...hydratedChain]) {
479
+ if (t < new Date(link.issued_at).getTime()) {
480
+ return err('E_NOT_YET_VALID', `${link.id} is not valid until ${link.issued_at}`);
481
+ }
482
+ if (t >= new Date(link.expires_at).getTime()) {
483
+ return err('E_EXPIRED', `${link.id} expired at ${link.expires_at}`);
484
+ }
485
+ }
486
+ }
487
+
430
488
  // 5. Scope containment — against the leaf's granted set.
431
489
  // Leaf is post-hydration, so `scopes` is guaranteed populated. The
432
490
  // runtime guard satisfies the type checker and would only fire if a
@@ -489,10 +547,27 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
489
547
  if (revocationPrecedes(rev, revAnchor, a, actionAnchor)) {
490
548
  return err('E_REVOKED', `chain link ${link.id} was revoked by ${rev.id} before action was signed`);
491
549
  }
550
+ // Live: a revocation already in effect refuses the action, however
551
+ // early its signed_at claims to be.
552
+ if (now && (revAnchor !== null || new Date(rev.signed_at).getTime() <= now.getTime())) {
553
+ return err('E_REVOKED', `chain link ${link.id} was revoked by ${rev.id}`);
554
+ }
492
555
  }
493
556
  }
494
557
  }
495
558
 
559
+ // 6b. Live: signed_at is the agent's claim; only a bound on it makes the
560
+ // action recent.
561
+ if (now) {
562
+ const maxSkew = input.maxSkewMs ?? DEFAULT_MAX_SKEW_MS;
563
+ if (Math.abs(now.getTime() - signed) > maxSkew) {
564
+ return err(
565
+ 'E_STALE',
566
+ `action.signed_at ${a.signed_at} is more than ${maxSkew} ms from the time of verification ${now.toISOString()}`
567
+ );
568
+ }
569
+ }
570
+
496
571
  // 7. Content check.
497
572
  if (input.content) {
498
573
  const actualHash = 'sha256:' + hexEncode(sha256(input.content));
@@ -529,6 +604,31 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
529
604
  chain: hydratedChain,
530
605
  scopeExercised: exercised,
531
606
  anchor,
607
+ asOf: now
608
+ ? { basis: 'now', now: now.toISOString() }
609
+ : { basis: 'signed_at', signedAt: a.signed_at, anchored: actionAnchor !== null },
610
+ };
611
+ }
612
+
613
+ /**
614
+ * Remembers the ids of accepted actions, so one signed action is acted on
615
+ * once. An action passes the freshness bound for `maxSkewMs` either side of
616
+ * its signed_at, so its id is kept until signed_at + maxSkewMs. In-memory and
617
+ * per process: a server on several instances keeps the same record in a
618
+ * shared store. Two identical calls signed in the same second share an id.
619
+ */
620
+ export function createReplayGuard(maxSkewMs: number = DEFAULT_MAX_SKEW_MS): {
621
+ firstUse(action: Pick<ActionEnvelope, 'id' | 'signed_at'>, now?: Date): boolean;
622
+ } {
623
+ const keepUntil = new Map<string, number>();
624
+ return {
625
+ firstUse(action, now = new Date()) {
626
+ const t = now.getTime();
627
+ for (const [id, until] of keepUntil) if (until <= t) keepUntil.delete(id);
628
+ if (keepUntil.has(action.id)) return false;
629
+ keepUntil.set(action.id, new Date(action.signed_at).getTime() + maxSkewMs);
630
+ return true;
631
+ },
532
632
  };
533
633
  }
534
634
 
@@ -617,13 +717,14 @@ export async function verifyRevocation(input: VerifyRevocationInput): Promise<Ve
617
717
  return err('E_DELEGATION_MISMATCH', `revocation.delegation_id (${env.delegation_id}) != delegation.id (${d.id})`);
618
718
  }
619
719
 
620
- // The principal may always revoke (SPEC §9.5). `revocation.holders` is not
621
- // covered by the delegation signature, so it may only add the agent.
622
- const holders = d.revocation?.holders ?? ['principal'];
623
- const holderAddrs = new Set<string>([d.principal.address]);
624
- if (Array.isArray(holders) && holders.includes('agent')) holderAddrs.add(d.agent.address);
625
- if (!holderAddrs.has(env.signer.address)) {
626
- return err('E_REVOKER_UNAUTHORIZED', `revocation signer ${env.signer.address} not in delegation holders`);
720
+ // The principal and the agent may always revoke (SPEC §9.5). Revoking only
721
+ // removes authority, and `revocation.holders` is outside the delegation's
722
+ // signature, so no copy of it decides who may.
723
+ if (env.signer.address !== d.principal.address && env.signer.address !== d.agent.address) {
724
+ return err(
725
+ 'E_REVOKER_UNAUTHORIZED',
726
+ `revocation signer ${env.signer.address} is neither the principal nor the agent`
727
+ );
627
728
  }
628
729
 
629
730
  const canonInput = {