@orangecheck/agent-core 2.6.0 → 2.7.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/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