@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.
- package/README.md +4 -2
- package/dist/index.d.mts +9 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +52 -10
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +50 -11
- 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/federation.test.ts +3 -3
- package/src/federation.ts +3 -8
- package/src/index.ts +4 -0
- package/src/live-time.test.ts +151 -0
- package/src/revocation-authority.test.ts +32 -28
- 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 +108 -7
package/src/test-vectors.test.ts
CHANGED
|
@@ -18,7 +18,13 @@ import {
|
|
|
18
18
|
subdelegationCanonicalMessage,
|
|
19
19
|
} from './canonical.js';
|
|
20
20
|
import { parseScope, ScopeParseError } from './scope.js';
|
|
21
|
-
import {
|
|
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
|
-
|
|
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
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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).
|
|
621
|
-
//
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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 = {
|