@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/README.md +3 -1
- package/dist/index.d.mts +9 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +47 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +45 -2
- package/dist/index.mjs.map +1 -1
- package/dist/types.d.mts +9 -1
- package/dist/types.d.ts +9 -1
- package/dist/types.js.map +1 -1
- package/dist/types.mjs.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +4 -0
- package/src/live-time.test.ts +151 -0
- package/src/revocation-authority.test.ts +15 -13
- package/src/revocation-failclosed.test.ts +1 -1
- package/src/test-vectors.test.ts +43 -8
- package/src/types.ts +9 -0
- package/src/verify.ts +100 -0
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
|
|