@orangecheck/agent-core 2.5.0 → 2.6.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 +39 -17
- package/dist/index.js +16 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +16 -5
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/delegation-window.test.ts +82 -0
- package/src/federation.test.ts +24 -2
- package/src/federation.ts +5 -3
- package/src/private-scope.test.ts +4 -2
- package/src/revocation-failclosed.test.ts +46 -10
- package/src/scope.test.ts +20 -0
- package/src/test-vectors.test.ts +21 -0
- package/src/verify.ts +25 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orangecheck/agent-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "OC Agent canonical messages, envelope formats (delegation/action/revocation), scope grammar, and verification. See https://github.com/orangecheck/oc-agent-protocol.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bitcoin",
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// SPEC §4.4: expires_at > issued_at and expires_at - issued_at ≤ 365 days, checked at
|
|
2
|
+
// §8.1 step 2 (E_MALFORMED) whatever the clock says. Every envelope here has an honest
|
|
3
|
+
// id, so without the window check each one would verify.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { canonicalizeScopes, computeDelegationId } from './canonical.js';
|
|
8
|
+
import type { DelegationEnvelope } from './types.js';
|
|
9
|
+
import { verifyDelegation } from './verify.js';
|
|
10
|
+
|
|
11
|
+
const ISSUED = '2026-04-22T12:00:00Z';
|
|
12
|
+
const DAY = 86_400_000;
|
|
13
|
+
const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
14
|
+
|
|
15
|
+
function delegation(issued_at: string, expires_at: string): DelegationEnvelope {
|
|
16
|
+
const principal = 'bc1qprincipal000000000000000000000000000000';
|
|
17
|
+
const agent = 'bc1qagent0000000000000000000000000000000000';
|
|
18
|
+
const scopes = canonicalizeScopes(['ln:send(max_sats<=1000)']);
|
|
19
|
+
const nonce = '0123456789abcdef0123456789abcdef';
|
|
20
|
+
const id = computeDelegationId({
|
|
21
|
+
principal,
|
|
22
|
+
agent,
|
|
23
|
+
scopes,
|
|
24
|
+
bond_sats: 0,
|
|
25
|
+
bond_attestation: 'none',
|
|
26
|
+
issued_at,
|
|
27
|
+
expires_at,
|
|
28
|
+
nonce,
|
|
29
|
+
});
|
|
30
|
+
return {
|
|
31
|
+
v: 1,
|
|
32
|
+
kind: 'agent-delegation',
|
|
33
|
+
id,
|
|
34
|
+
principal: { address: principal, alg: 'bip322' },
|
|
35
|
+
agent: { address: agent, alg: 'bip322' },
|
|
36
|
+
scopes,
|
|
37
|
+
bond: null,
|
|
38
|
+
issued_at,
|
|
39
|
+
expires_at,
|
|
40
|
+
nonce,
|
|
41
|
+
revocation: { holders: ['principal'], ref: null },
|
|
42
|
+
sig: { alg: 'bip322', pubkey: principal, value: 'AAAA' },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const verify = (env: DelegationEnvelope, now?: Date) =>
|
|
47
|
+
verifyDelegation({
|
|
48
|
+
envelope: env,
|
|
49
|
+
skipSignatureVerification: true,
|
|
50
|
+
skipRevocationCheck: true,
|
|
51
|
+
...(now ? { now } : { skipTemporalCheck: true }),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('delegation window (SPEC §4.4)', () => {
|
|
55
|
+
const issuedMs = Date.parse(ISSUED);
|
|
56
|
+
|
|
57
|
+
it('accepts exactly 365 days', async () => {
|
|
58
|
+
expect((await verify(delegation(ISSUED, iso(issuedMs + 365 * DAY)))).ok).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('rejects 365 days and one second with E_MALFORMED', async () => {
|
|
62
|
+
const r = await verify(delegation(ISSUED, iso(issuedMs + 365 * DAY + 1000)));
|
|
63
|
+
expect(r).toMatchObject({ ok: false, code: 'E_MALFORMED' });
|
|
64
|
+
if (!r.ok) expect(r.message).toBe('expires_at is more than 365 days after issued_at (SPEC §4.4)');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('rejects 366 days even inside the window, so the clock cannot rescue it', async () => {
|
|
68
|
+
const r = await verify(delegation(ISSUED, iso(issuedMs + 366 * DAY)), new Date(issuedMs + DAY));
|
|
69
|
+
expect(r).toMatchObject({ ok: false, code: 'E_MALFORMED' });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('rejects expires_at equal to issued_at with temporal checks skipped', async () => {
|
|
73
|
+
const r = await verify(delegation(ISSUED, ISSUED));
|
|
74
|
+
expect(r).toMatchObject({ ok: false, code: 'E_MALFORMED' });
|
|
75
|
+
if (!r.ok) expect(r.message).toBe('expires_at must be after issued_at');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('rejects a timestamp that is shaped right but is no real time', async () => {
|
|
79
|
+
const r = await verify(delegation(ISSUED, '2026-13-01T00:00:00Z'));
|
|
80
|
+
expect(r).toMatchObject({ ok: false, code: 'E_MALFORMED' });
|
|
81
|
+
});
|
|
82
|
+
});
|
package/src/federation.test.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
type FederationDelegationEnvelope,
|
|
17
17
|
type FederationRevocationEnvelope,
|
|
18
18
|
} from './federation.js';
|
|
19
|
-
import { computeRevocationId } from './canonical.js';
|
|
19
|
+
import { computeDelegationId, computeRevocationId } from './canonical.js';
|
|
20
20
|
import { verifyDelegation } from './verify.js';
|
|
21
21
|
import type { DelegationEnvelope } from './types.js';
|
|
22
22
|
|
|
@@ -168,9 +168,29 @@ d('federation reject reasons (each invalid case fails for its named reason)', ()
|
|
|
168
168
|
expect(r.ok).toBe(false);
|
|
169
169
|
if (!r.ok) expect(r.code).toBe('E_THRESHOLD_MISMATCH');
|
|
170
170
|
});
|
|
171
|
+
it('366-day window → E_MALFORMED (FEDERATION.md §5 bounds it as SPEC §4.4 does)', async () => {
|
|
172
|
+
const v = await load('v19-federation-delegation-3of5-valid.json');
|
|
173
|
+
const env = { ...v.expected.envelope } as FederationDelegationEnvelope;
|
|
174
|
+
env.expires_at = new Date(Date.parse(env.issued_at) + 366 * 86_400_000)
|
|
175
|
+
.toISOString()
|
|
176
|
+
.replace(/\.\d{3}Z$/, 'Z');
|
|
177
|
+
// Honest id for the longer window, so the window is the only fault.
|
|
178
|
+
env.id = computeDelegationId({
|
|
179
|
+
principal: `federation:${env.principal.descriptor_id}`,
|
|
180
|
+
agent: env.agent.address,
|
|
181
|
+
scopes: env.scopes,
|
|
182
|
+
bond_sats: env.bond?.sats ?? 0,
|
|
183
|
+
bond_attestation: env.bond?.attestation_id ?? 'none',
|
|
184
|
+
issued_at: env.issued_at,
|
|
185
|
+
expires_at: env.expires_at,
|
|
186
|
+
nonce: env.nonce,
|
|
187
|
+
});
|
|
188
|
+
const r = await verifyFederationDelegation({ envelope: env, ...opts });
|
|
189
|
+
expect(r).toMatchObject({ ok: false, code: 'E_MALFORMED' });
|
|
190
|
+
});
|
|
171
191
|
});
|
|
172
192
|
|
|
173
|
-
// FEDERATION.md §4 / SECURITY §
|
|
193
|
+
// FEDERATION.md §4 / SECURITY §5 item 7: a revoked federation delegation must
|
|
174
194
|
// not verify, and skipping the revocation check has to be stated.
|
|
175
195
|
d('federation delegation revocation check', () => {
|
|
176
196
|
const base = { skipSignatureVerification: true, skipTemporalCheck: true } as const;
|
|
@@ -180,6 +200,8 @@ d('federation delegation revocation check', () => {
|
|
|
180
200
|
const r = await verifyFederationDelegation({ envelope: dv.expected.envelope, ...base });
|
|
181
201
|
expect(r.ok).toBe(false);
|
|
182
202
|
expect(r.ok === false && r.message).toMatch(/requires `revocations`/);
|
|
203
|
+
expect(r.ok === false && r.message).toMatch(/"#t": \[delegation id\]/);
|
|
204
|
+
expect(r.ok === false && r.message).not.toMatch(/#delegation/);
|
|
183
205
|
});
|
|
184
206
|
|
|
185
207
|
it('verifies with an empty revocation list', async () => {
|
package/src/federation.ts
CHANGED
|
@@ -32,6 +32,7 @@ import type {
|
|
|
32
32
|
DelegationRevocationRef,
|
|
33
33
|
RevocationEnvelope,
|
|
34
34
|
} from './types.js';
|
|
35
|
+
import { delegationWindowError } from './verify.js';
|
|
35
36
|
|
|
36
37
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
37
38
|
// Types (FEDERATION.md §2 / §3.2)
|
|
@@ -228,7 +229,7 @@ async function checkFederationQuorum(
|
|
|
228
229
|
export interface VerifyFederationDelegationInput extends VerifyFederationBase {
|
|
229
230
|
envelope: FederationDelegationEnvelope;
|
|
230
231
|
/**
|
|
231
|
-
* Revocations to check (kind-30085 by `#delegation
|
|
232
|
+
* Revocations to check (kind-30085 by `#t` with the delegation id, SPEC §9.4): federation revocations,
|
|
232
233
|
* and agent self-revocations when `revocation.holders` includes "agent".
|
|
233
234
|
* Required unless `skipRevocationCheck` states that revocation is not checked.
|
|
234
235
|
*/
|
|
@@ -251,6 +252,8 @@ export async function verifyFederationDelegation(
|
|
|
251
252
|
if (!ISO_UTC.test(env.issued_at) || !ISO_UTC.test(env.expires_at)) {
|
|
252
253
|
return fail('E_MALFORMED', 'issued_at / expires_at must be ISO 8601 UTC');
|
|
253
254
|
}
|
|
255
|
+
const windowError = delegationWindowError(env.issued_at, env.expires_at);
|
|
256
|
+
if (windowError) return fail('E_MALFORMED', windowError);
|
|
254
257
|
if (!/^[0-9a-f]{32}$/.test(env.nonce)) return fail('E_MALFORMED', 'nonce must be 32 hex chars');
|
|
255
258
|
|
|
256
259
|
// Canonical scopes (identical rules to v1) must be sorted on the envelope.
|
|
@@ -290,7 +293,7 @@ export async function verifyFederationDelegation(
|
|
|
290
293
|
if (!input.skipRevocationCheck && !input.revocations) {
|
|
291
294
|
return fail(
|
|
292
295
|
'E_MALFORMED',
|
|
293
|
-
'verifyFederationDelegation requires `revocations` (
|
|
296
|
+
'verifyFederationDelegation requires `revocations` (query kind 30085 with "#t": [delegation id], SPEC §9.4) ' +
|
|
294
297
|
'or an explicit skipRevocationCheck:true to state that revocation is not being checked'
|
|
295
298
|
);
|
|
296
299
|
}
|
|
@@ -305,7 +308,6 @@ export async function verifyFederationDelegation(
|
|
|
305
308
|
const now = input.now ?? new Date();
|
|
306
309
|
const issued = new Date(env.issued_at);
|
|
307
310
|
const expires = new Date(env.expires_at);
|
|
308
|
-
if (expires <= issued) return fail('E_MALFORMED', 'expires_at <= issued_at');
|
|
309
311
|
if (now < issued) return fail('E_NOT_YET_VALID', `delegation not valid until ${env.issued_at}`);
|
|
310
312
|
if (now >= expires) return fail('E_EXPIRED', `delegation expired at ${env.expires_at}`);
|
|
311
313
|
}
|
|
@@ -189,8 +189,10 @@ async function buildPrivateEnvelopeWithKey(
|
|
|
189
189
|
],
|
|
190
190
|
});
|
|
191
191
|
|
|
192
|
-
|
|
193
|
-
const
|
|
192
|
+
// Current window, inside SPEC §4.4's 365-day limit, so the temporal check passes.
|
|
193
|
+
const isoSeconds = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
194
|
+
const issued_at = isoSeconds(Date.now() - 86_400_000);
|
|
195
|
+
const expires_at = isoSeconds(Date.now() + 30 * 86_400_000);
|
|
194
196
|
const nonce = hexEncode(randomBytesN(16));
|
|
195
197
|
|
|
196
198
|
const canonInput = {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// SECURITY §
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// SECURITY §5 item 7: "Query revocation feeds (Nostr kind-30085 by `#t` with
|
|
2
|
+
// the cited delegation id) before reporting OK, **unless the caller explicitly
|
|
3
|
+
// opts out** of revocation checking."
|
|
4
4
|
//
|
|
5
5
|
// The default was inverted. verifyDelegation had NO revocations parameter at
|
|
6
6
|
// all — a revoked delegation returned ok:true and a caller had no way to say
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
// changed is that declining to is now a decision they have to state.
|
|
13
13
|
import { describe, expect, it } from 'vitest';
|
|
14
14
|
|
|
15
|
-
import {
|
|
16
|
-
import
|
|
15
|
+
import { computeActionId } from './canonical.js';
|
|
16
|
+
import { verifyAction, verifyDelegation } from './verify.js';
|
|
17
|
+
import type { ActionEnvelope, DelegationEnvelope } from './types.js';
|
|
17
18
|
|
|
18
19
|
// Inlined rather than read from oc-agent-protocol/test-vectors: this test is
|
|
19
20
|
// about the revocation guard, not vector conformance, and a relative path into
|
|
@@ -68,11 +69,46 @@ describe('verifyDelegation fails closed on revocation', () => {
|
|
|
68
69
|
});
|
|
69
70
|
expect(r.ok).toBe(true);
|
|
70
71
|
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// The refusal tells the caller how to fetch revocations. SPEC §9.4: relays index
|
|
75
|
+
// single-letter tags only and reject a `#delegation` filter (SPEC §10.4), so the
|
|
76
|
+
// message must name the `#t` query.
|
|
77
|
+
describe('the refusal names a query relays answer', () => {
|
|
78
|
+
const action: ActionEnvelope = (() => {
|
|
79
|
+
const canon = {
|
|
80
|
+
address: delegation.agent.address,
|
|
81
|
+
content_hash: 'sha256:' + '00'.repeat(32),
|
|
82
|
+
content_length: 0,
|
|
83
|
+
content_mime: 'text/plain',
|
|
84
|
+
signed_at: '2026-04-23T12:00:00Z',
|
|
85
|
+
delegation_id: delegation.id,
|
|
86
|
+
scope_exercised: delegation.scopes![0]!,
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
v: 1,
|
|
90
|
+
kind: 'agent-action',
|
|
91
|
+
id: computeActionId(canon),
|
|
92
|
+
content: { hash: canon.content_hash, length: 0, mime: 'text/plain', ref: null },
|
|
93
|
+
signer: { address: canon.address, alg: 'bip322' },
|
|
94
|
+
signed_at: canon.signed_at,
|
|
95
|
+
delegation_id: delegation.id,
|
|
96
|
+
scope_exercised: canon.scope_exercised,
|
|
97
|
+
ots: null,
|
|
98
|
+
sig: { alg: 'bip322', pubkey: canon.address, value: 'AAAA' },
|
|
99
|
+
} as ActionEnvelope;
|
|
100
|
+
})();
|
|
71
101
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
102
|
+
it('verifyDelegation', async () => {
|
|
103
|
+
const r = await verifyDelegation({ envelope: delegation, skipSignatureVerification: true, now: AT });
|
|
104
|
+
expect(r.ok === false && r.message).toMatch(/"#t": \[delegation id\]/);
|
|
105
|
+
expect(r.ok === false && r.message).not.toMatch(/#delegation/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('verifyAction', async () => {
|
|
109
|
+
const r = await verifyAction({ action, delegation, skipSignatureVerification: true });
|
|
110
|
+
expect(r.ok === false && r.code).toBe('E_MALFORMED');
|
|
111
|
+
expect(r.ok === false && r.message).toMatch(/"#t": \[id of every link in the chain\]/);
|
|
112
|
+
expect(r.ok === false && r.message).not.toMatch(/#delegation/);
|
|
77
113
|
});
|
|
78
114
|
});
|
package/src/scope.test.ts
CHANGED
|
@@ -257,3 +257,23 @@ describe('the adapter scopes are registered', () => {
|
|
|
257
257
|
expect(isSubScope(parseScope('anthropic:tool(name=getWeather)'), granted)).toBe(false);
|
|
258
258
|
});
|
|
259
259
|
});
|
|
260
|
+
|
|
261
|
+
// SPEC §7.2 rule 2 (errata 1.6.2): values keep their case on every key, not only the
|
|
262
|
+
// four marked case-sensitive. Folding would break base58 addresses and widen grants.
|
|
263
|
+
describe('scope values keep their case (SPEC §7.2)', () => {
|
|
264
|
+
for (const s of [
|
|
265
|
+
'lock:seal(recipient=1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2)',
|
|
266
|
+
'mcp:invoke(server=https://mcp.example.com/Tools,tool=getWeather)',
|
|
267
|
+
'http:request(method=GET,origin=https://api.example.com)',
|
|
268
|
+
]) {
|
|
269
|
+
it(`canonical form of ${s} is unchanged`, () => {
|
|
270
|
+
expect(canonicalizeScopeString(s)).toBe(s);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
it('a grant on a key not marked case-sensitive still matches case exactly', () => {
|
|
275
|
+
const granted = parseScope('mcp:invoke(tool=getWeather)');
|
|
276
|
+
expect(isSubScope(parseScope('mcp:invoke(tool=getWeather)'), granted)).toBe(true);
|
|
277
|
+
expect(isSubScope(parseScope('mcp:invoke(tool=GetWeather)'), granted)).toBe(false);
|
|
278
|
+
});
|
|
279
|
+
});
|
package/src/test-vectors.test.ts
CHANGED
|
@@ -155,6 +155,12 @@ interface NegativeDelegationVector extends NegativeVectorBase {
|
|
|
155
155
|
scope: string;
|
|
156
156
|
why: string;
|
|
157
157
|
}[];
|
|
158
|
+
// Present when the fault is in a well-formed, honestly-id'd envelope (v27).
|
|
159
|
+
expected: NegativeVectorBase['expected'] & {
|
|
160
|
+
canonical_message?: string;
|
|
161
|
+
id?: string;
|
|
162
|
+
envelope?: DelegationEnvelope;
|
|
163
|
+
};
|
|
158
164
|
}
|
|
159
165
|
|
|
160
166
|
interface NegativeSubdelegationVector extends NegativeVectorBase {
|
|
@@ -507,6 +513,21 @@ describe('oc-agent-protocol negative test vectors', () => {
|
|
|
507
513
|
});
|
|
508
514
|
expect(r.ok).toBe(false);
|
|
509
515
|
if (!r.ok) expect(r.code).toBe(v.expected.error_code);
|
|
516
|
+
} else if (data.kind === 'delegation' && (data as NegativeDelegationVector).expected.envelope) {
|
|
517
|
+
// The envelope is honest (its id reconstructs from inputs), so the
|
|
518
|
+
// verifier's rejection is the declared fault and nothing else.
|
|
519
|
+
const v = data as NegativeDelegationVector;
|
|
520
|
+
const asPositive = { kind: 'delegation', inputs: v.inputs } as unknown as PositiveVector;
|
|
521
|
+
expect(reconstructCanonical(asPositive)).toBe(v.expected.canonical_message);
|
|
522
|
+
expect(reconstructId(asPositive)).toBe(v.expected.id);
|
|
523
|
+
const r = await verifyDelegation({
|
|
524
|
+
envelope: v.expected.envelope!,
|
|
525
|
+
skipSignatureVerification: true,
|
|
526
|
+
skipRevocationCheck: true,
|
|
527
|
+
skipTemporalCheck: true,
|
|
528
|
+
});
|
|
529
|
+
expect(r.ok).toBe(false);
|
|
530
|
+
if (!r.ok) expect(r.code).toBe(v.expected.error_code);
|
|
510
531
|
} else if (data.kind === 'delegation') {
|
|
511
532
|
// Delegation negative vectors target the §7.1 grammar parser
|
|
512
533
|
// directly — that's where E_BAD_SCOPE_GRAMMAR fires per
|
package/src/verify.ts
CHANGED
|
@@ -51,9 +51,9 @@ export interface VerifyBase {
|
|
|
51
51
|
/**
|
|
52
52
|
* Verify WITHOUT consulting revocation feeds. Required to be explicit.
|
|
53
53
|
*
|
|
54
|
-
* SECURITY §
|
|
55
|
-
*
|
|
56
|
-
*
|
|
54
|
+
* SECURITY §5 item 7: "Query revocation feeds (Nostr kind-30085 by `#t`
|
|
55
|
+
* with the cited delegation id) before reporting OK, **unless the caller
|
|
56
|
+
* explicitly opts out**." The default was inverted — revocations
|
|
57
57
|
* were checked only when supplied, and omitting them skipped the check
|
|
58
58
|
* silently, so "checked, clean" was indistinguishable from "never checked".
|
|
59
59
|
*
|
|
@@ -63,7 +63,9 @@ export interface VerifyBase {
|
|
|
63
63
|
skipRevocationCheck?: boolean;
|
|
64
64
|
/**
|
|
65
65
|
* Revocations to evaluate against the delegation (or, for an action, every
|
|
66
|
-
* link in its chain). Fetch
|
|
66
|
+
* link in its chain). Fetch kind-30085 events with `"#t": [<delegation id>]`
|
|
67
|
+
* (SPEC §9.4); relays do not index the multi-letter `delegation` tag. Pass
|
|
68
|
+
* `[]` only when a relay answered with EOSE and none matched (SPEC §10.4).
|
|
67
69
|
*/
|
|
68
70
|
revocations?: RevocationEnvelope[];
|
|
69
71
|
}
|
|
@@ -222,15 +224,15 @@ export async function verifyDelegation(input: VerifyDelegationInput): Promise<Ve
|
|
|
222
224
|
if (!ok) return err('E_BAD_SIG', 'BIP-322 signature did not verify');
|
|
223
225
|
}
|
|
224
226
|
|
|
225
|
-
// Revocation. SECURITY §
|
|
227
|
+
// Revocation. SECURITY §5 item 7 — check unless the caller states otherwise.
|
|
226
228
|
// Before this, verifyDelegation had NO revocations parameter at all: a
|
|
227
229
|
// revoked delegation returned ok:true and a caller had no way to say
|
|
228
230
|
// otherwise.
|
|
229
231
|
if (!input.skipRevocationCheck && !input.revocations) {
|
|
230
232
|
return err(
|
|
231
233
|
'E_MALFORMED',
|
|
232
|
-
'verifyDelegation requires `revocations` (
|
|
233
|
-
'explicit skipRevocationCheck:true to state that revocation is not being checked',
|
|
234
|
+
'verifyDelegation requires `revocations` (query kind 30085 with "#t": [delegation id], ' +
|
|
235
|
+
'SPEC §9.4) or an explicit skipRevocationCheck:true to state that revocation is not being checked',
|
|
234
236
|
);
|
|
235
237
|
}
|
|
236
238
|
if (input.revocations) {
|
|
@@ -252,7 +254,6 @@ export async function verifyDelegation(input: VerifyDelegationInput): Promise<Ve
|
|
|
252
254
|
const now = input.now ?? new Date();
|
|
253
255
|
const issued = new Date(env.issued_at);
|
|
254
256
|
const expires = new Date(env.expires_at);
|
|
255
|
-
if (expires <= issued) return err('E_MALFORMED', 'expires_at <= issued_at');
|
|
256
257
|
if (now < issued) return err('E_NOT_YET_VALID', `delegation not valid until ${env.issued_at}`);
|
|
257
258
|
if (now >= expires) return err('E_EXPIRED', `delegation expired at ${env.expires_at}`);
|
|
258
259
|
}
|
|
@@ -452,8 +453,8 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
|
|
|
452
453
|
if (!input.skipRevocationCheck && !input.revocations) {
|
|
453
454
|
return err(
|
|
454
455
|
'E_MALFORMED',
|
|
455
|
-
'verifyAction requires `revocations` (
|
|
456
|
-
'in the chain) or an explicit skipRevocationCheck:true',
|
|
456
|
+
'verifyAction requires `revocations` (query kind 30085 with "#t": [id of every link ' +
|
|
457
|
+
'in the chain], SPEC §9.4) or an explicit skipRevocationCheck:true',
|
|
457
458
|
);
|
|
458
459
|
}
|
|
459
460
|
// Anchor heights only count once the proof is verified against the
|
|
@@ -670,12 +671,26 @@ function checkDelegationShape(env: DelegationEnvelope): VerifyDelegationResult |
|
|
|
670
671
|
}
|
|
671
672
|
if (!isIsoUtc(env.issued_at)) return err('E_MALFORMED', 'issued_at must be ISO 8601 UTC');
|
|
672
673
|
if (!isIsoUtc(env.expires_at)) return err('E_MALFORMED', 'expires_at must be ISO 8601 UTC');
|
|
674
|
+
const windowError = delegationWindowError(env.issued_at, env.expires_at);
|
|
675
|
+
if (windowError) return err('E_MALFORMED', windowError);
|
|
673
676
|
if (!/^[0-9a-f]{32}$/.test(env.nonce)) return err('E_MALFORMED', 'nonce must be 32 lowercase hex chars');
|
|
674
677
|
if (env.sig?.alg !== 'bip322' || typeof env.sig.value !== 'string') return err('E_MALFORMED', 'sig invalid');
|
|
675
678
|
if (env.sig.pubkey !== env.principal.address) return err('E_MALFORMED', 'sig.pubkey must equal principal.address');
|
|
676
679
|
return null;
|
|
677
680
|
}
|
|
678
681
|
|
|
682
|
+
const MAX_DELEGATION_MS = 365 * 86_400_000;
|
|
683
|
+
|
|
684
|
+
/** SPEC §4.4 window, checked at §8.1 step 2 whatever the clock says: issued < expires, at most 365 days. */
|
|
685
|
+
export function delegationWindowError(issuedAt: string, expiresAt: string): string | null {
|
|
686
|
+
const issued = Date.parse(issuedAt);
|
|
687
|
+
const expires = Date.parse(expiresAt);
|
|
688
|
+
if (Number.isNaN(issued) || Number.isNaN(expires)) return 'issued_at / expires_at must be real UTC times';
|
|
689
|
+
if (expires <= issued) return 'expires_at must be after issued_at';
|
|
690
|
+
if (expires - issued > MAX_DELEGATION_MS) return 'expires_at is more than 365 days after issued_at (SPEC §4.4)';
|
|
691
|
+
return null;
|
|
692
|
+
}
|
|
693
|
+
|
|
679
694
|
function checkActionShape(a: ActionEnvelope): VerifyActionResult | null {
|
|
680
695
|
if (a.kind !== 'agent-action') return err('E_MALFORMED', 'kind must be "agent-action"');
|
|
681
696
|
if (!isHex64(a.id)) return err('E_MALFORMED', 'id must be 64 lowercase hex chars');
|