@orangecheck/agent-core 0.1.0 → 0.3.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/dist/canonical.d.mts +5 -2
- package/dist/canonical.d.ts +5 -2
- package/dist/canonical.js +22 -0
- package/dist/canonical.js.map +1 -1
- package/dist/canonical.mjs +20 -1
- package/dist/canonical.mjs.map +1 -1
- package/dist/index.d.mts +60 -5
- package/dist/index.d.ts +60 -5
- package/dist/index.js +356 -29
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +347 -30
- package/dist/index.mjs.map +1 -1
- package/dist/types.d.mts +65 -4
- package/dist/types.d.ts +65 -4
- package/dist/types.js.map +1 -1
- package/dist/types.mjs.map +1 -1
- package/package.json +73 -69
- package/src/canonical.ts +23 -0
- package/src/index.ts +18 -0
- package/src/private-scope.test.ts +223 -0
- package/src/private-scope.ts +122 -0
- package/src/test-vectors.test.ts +358 -12
- package/src/types.ts +109 -4
- package/src/verify.ts +487 -40
package/src/verify.ts
CHANGED
|
@@ -7,12 +7,14 @@ import {
|
|
|
7
7
|
actionCanonicalBytes,
|
|
8
8
|
actionCanonicalMessage,
|
|
9
9
|
canonicalizeScopes,
|
|
10
|
+
computeSubdelegationId,
|
|
10
11
|
delegationCanonicalBytes,
|
|
11
12
|
delegationCanonicalMessage,
|
|
12
13
|
hexEncode,
|
|
13
14
|
revocationCanonicalBytes,
|
|
14
15
|
revocationCanonicalMessage,
|
|
15
16
|
} from './canonical.js';
|
|
17
|
+
import { unsealScopes } from './private-scope.js';
|
|
16
18
|
import {
|
|
17
19
|
canonicalizeScope,
|
|
18
20
|
isSubScope,
|
|
@@ -25,13 +27,19 @@ import {
|
|
|
25
27
|
ENVELOPE_VERSION,
|
|
26
28
|
type ActionEnvelope,
|
|
27
29
|
type AgentErrorCode,
|
|
30
|
+
type ChainLink,
|
|
28
31
|
type DelegationEnvelope,
|
|
29
32
|
type RevocationEnvelope,
|
|
33
|
+
type SubdelegationEnvelope,
|
|
30
34
|
type VerifyActionResult,
|
|
31
35
|
type VerifyDelegationResult,
|
|
32
36
|
type VerifyRevocationResult,
|
|
37
|
+
type VerifySubdelegationResult,
|
|
33
38
|
} from './types.js';
|
|
34
39
|
|
|
40
|
+
/** Default maximum chain depth, per SUB-DELEGATION.md §2.1. */
|
|
41
|
+
export const DEFAULT_MAX_CHAIN_DEPTH = 5;
|
|
42
|
+
|
|
35
43
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
36
44
|
// Shared options
|
|
37
45
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -61,6 +69,16 @@ export interface VerifyDelegationInput extends VerifyBase {
|
|
|
61
69
|
now?: Date;
|
|
62
70
|
/** Skip temporal checks entirely (useful for inspecting historical envelopes). */
|
|
63
71
|
skipTemporalCheck?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* v1.2 private-scope decryption key. When the envelope carries
|
|
74
|
+
* `scopes_encrypted`, the verifier MUST supply a device key matching one
|
|
75
|
+
* of the recipient entries to recover the plaintext scope list. Without
|
|
76
|
+
* this, verification returns `E_SCOPES_UNREADABLE`.
|
|
77
|
+
*/
|
|
78
|
+
decryptScopesWith?: {
|
|
79
|
+
device_id: string;
|
|
80
|
+
secretKey: Uint8Array;
|
|
81
|
+
};
|
|
64
82
|
}
|
|
65
83
|
|
|
66
84
|
export async function verifyDelegation(input: VerifyDelegationInput): Promise<VerifyDelegationResult> {
|
|
@@ -73,22 +91,86 @@ export async function verifyDelegation(input: VerifyDelegationInput): Promise<Ve
|
|
|
73
91
|
const shape = checkDelegationShape(env);
|
|
74
92
|
if (shape) return shape;
|
|
75
93
|
|
|
94
|
+
// ─── v1.2 PRE-VERIFICATION (PRIVATE-SCOPE.md §2 steps P1–P6) ───────────
|
|
95
|
+
// Steps P1 / P2: mutual exclusion + presence.
|
|
96
|
+
if (env.scopes !== undefined && env.scopes_encrypted !== undefined) {
|
|
97
|
+
return err(
|
|
98
|
+
'E_SCOPES_BOTH_PROVIDED',
|
|
99
|
+
'envelope carries both scopes and scopes_encrypted; pick one'
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (env.scopes === undefined && env.scopes_encrypted === undefined) {
|
|
103
|
+
return err(
|
|
104
|
+
'E_SCOPES_NEITHER_PROVIDED',
|
|
105
|
+
'envelope carries neither scopes nor scopes_encrypted'
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The plaintext scope list. Either copied from env.scopes (public mode)
|
|
110
|
+
// or recovered by decrypting env.scopes_encrypted (private mode).
|
|
111
|
+
let workingScopes: string[];
|
|
112
|
+
|
|
113
|
+
if (env.scopes_encrypted !== undefined) {
|
|
114
|
+
// Step P3: issuer binding — the inner Lock envelope's `from.address`
|
|
115
|
+
// must match the OC Agent envelope's principal.
|
|
116
|
+
if (env.scopes_encrypted.from?.address !== env.principal.address) {
|
|
117
|
+
return err(
|
|
118
|
+
'E_MALFORMED',
|
|
119
|
+
`scopes_encrypted.from.address (${env.scopes_encrypted.from?.address}) does not match principal.address (${env.principal.address})`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
// Step P5: decryption capability.
|
|
123
|
+
if (!input.decryptScopesWith) {
|
|
124
|
+
return err(
|
|
125
|
+
'E_SCOPES_UNREADABLE',
|
|
126
|
+
'envelope carries scopes_encrypted; no decryption key provided'
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
// Step P6: decrypt. Step P4 (inner-sig verify) is handled inside
|
|
130
|
+
// unsealScopes via the same BIP-322 verifier — but only when
|
|
131
|
+
// skipSignatureVerification is false.
|
|
132
|
+
try {
|
|
133
|
+
const r = await unsealScopes({
|
|
134
|
+
envelope: env.scopes_encrypted,
|
|
135
|
+
device: input.decryptScopesWith,
|
|
136
|
+
...(input.verifyBip322 ? { verifyBip322: input.verifyBip322 } : {}),
|
|
137
|
+
skipSenderVerification: !!input.skipSignatureVerification,
|
|
138
|
+
});
|
|
139
|
+
workingScopes = r.scopes;
|
|
140
|
+
} catch (e) {
|
|
141
|
+
const msg = (e as Error).message ?? String(e);
|
|
142
|
+
// Distinguish "I had a key but the cryptographic operation failed
|
|
143
|
+
// (bad envelope)" from "I had no matching recipient" — the latter
|
|
144
|
+
// is the more common case and gets E_SCOPES_UNREADABLE; the
|
|
145
|
+
// former gets E_BAD_LOCK_ENVELOPE.
|
|
146
|
+
if (/no matching recipient|no recipient|device_id/i.test(msg)) {
|
|
147
|
+
return err('E_SCOPES_UNREADABLE', msg);
|
|
148
|
+
}
|
|
149
|
+
return err('E_BAD_LOCK_ENVELOPE', msg);
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
workingScopes = env.scopes!;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ─── Standard verification (SPEC.md §8.1) ──────────────────────────────
|
|
76
156
|
// Scope grammar.
|
|
77
157
|
let canonicalScopes: string[];
|
|
78
158
|
try {
|
|
79
|
-
for (const s of
|
|
80
|
-
canonicalScopes = canonicalizeScopes(
|
|
159
|
+
for (const s of workingScopes) validateScope(parseScope(s), { mode: input.scopeMode ?? 'strict' });
|
|
160
|
+
canonicalScopes = canonicalizeScopes(workingScopes);
|
|
81
161
|
} catch (e) {
|
|
82
162
|
const msg = e instanceof ScopeParseError ? e.message : (e as Error).message;
|
|
83
163
|
return err('E_BAD_SCOPE_GRAMMAR', msg);
|
|
84
164
|
}
|
|
85
165
|
|
|
86
|
-
//
|
|
166
|
+
// In public mode the envelope's `scopes` array must already be in
|
|
167
|
+
// canonical sorted order. (Private mode: the decrypted plaintext is
|
|
168
|
+
// canonicalized at seal time, so this loop is a no-op for it.)
|
|
87
169
|
for (let i = 0; i < canonicalScopes.length; i++) {
|
|
88
|
-
if (
|
|
170
|
+
if (workingScopes[i] !== canonicalScopes[i]) {
|
|
89
171
|
return err(
|
|
90
172
|
'E_BAD_SCOPE_GRAMMAR',
|
|
91
|
-
`scope at index ${i} not in canonical form; expected ${canonicalScopes[i]} got ${
|
|
173
|
+
`scope at index ${i} not in canonical form; expected ${canonicalScopes[i]} got ${workingScopes[i]}`
|
|
92
174
|
);
|
|
93
175
|
}
|
|
94
176
|
}
|
|
@@ -132,9 +214,17 @@ export async function verifyDelegation(input: VerifyDelegationInput): Promise<Ve
|
|
|
132
214
|
if (now >= expires) return err('E_EXPIRED', `delegation expired at ${env.expires_at}`);
|
|
133
215
|
}
|
|
134
216
|
|
|
217
|
+
// In private mode, return a hydrated envelope that includes the recovered
|
|
218
|
+
// plaintext scopes so callers (verifyAction's chain walker, web-app
|
|
219
|
+
// consumers) don't have to repeat the decryption.
|
|
220
|
+
const returnedEnvelope: DelegationEnvelope =
|
|
221
|
+
env.scopes_encrypted !== undefined
|
|
222
|
+
? { ...env, scopes: canonicalScopes }
|
|
223
|
+
: env;
|
|
224
|
+
|
|
135
225
|
return {
|
|
136
226
|
ok: true,
|
|
137
|
-
envelope:
|
|
227
|
+
envelope: returnedEnvelope,
|
|
138
228
|
canonicalMessage: reconstructedMessage,
|
|
139
229
|
id: env.id,
|
|
140
230
|
};
|
|
@@ -146,32 +236,93 @@ export async function verifyDelegation(input: VerifyDelegationInput): Promise<Ve
|
|
|
146
236
|
|
|
147
237
|
export interface VerifyActionInput extends VerifyBase {
|
|
148
238
|
action: ActionEnvelope;
|
|
149
|
-
/** The delegation
|
|
239
|
+
/** The ROOT delegation rooting the authority chain. Always required. */
|
|
150
240
|
delegation: DelegationEnvelope;
|
|
151
241
|
/**
|
|
152
|
-
*
|
|
153
|
-
*
|
|
242
|
+
* Optional v1.1 sub-delegation chain from S_1 (immediate child of `delegation`)
|
|
243
|
+
* to S_leaf (the envelope `action.delegation_id` cites). When provided, the
|
|
244
|
+
* verifier walks each link checking parent-id linkage, principal-equals-
|
|
245
|
+
* parent-agent, scope containment, and temporal containment. The action's
|
|
246
|
+
* delegation_id MUST equal the leaf's id; the action's signer MUST equal
|
|
247
|
+
* the leaf's agent. See SUB-DELEGATION.md §2.2.
|
|
248
|
+
*/
|
|
249
|
+
subdelegationChain?: SubdelegationEnvelope[];
|
|
250
|
+
/**
|
|
251
|
+
* Maximum permitted chain depth (number of subdelegations).
|
|
252
|
+
* Default `DEFAULT_MAX_CHAIN_DEPTH` (5). Verifiers MAY lower; MUST NOT raise
|
|
253
|
+
* silently above their advertised cap. Chains exceeding this fail with
|
|
254
|
+
* E_SUBDELEGATION_DEPTH_EXCEEDED before any per-link work is performed.
|
|
255
|
+
*/
|
|
256
|
+
maxChainDepth?: number;
|
|
257
|
+
/**
|
|
258
|
+
* Known revocations targeting any envelope in the chain (root + each
|
|
259
|
+
* subdelegation). The verifier checks every link per SUB-DELEGATION.md §2.2
|
|
260
|
+
* step 5 — a revocation against ANY link invalidates the action.
|
|
154
261
|
*/
|
|
155
262
|
revocations?: RevocationEnvelope[];
|
|
156
263
|
content?: Uint8Array;
|
|
157
264
|
verifyOtsAnchor?: (proofB64: string, blockHeight: number, blockHash: string) => Promise<boolean>;
|
|
158
265
|
/** If action and revocation are both OTS-anchored, pass a function that returns the comparable block height of each via proof parsing. Defaults: use envelope.ots.block_height. */
|
|
159
266
|
resolveAnchorBlockHeight?: (env: ActionEnvelope | RevocationEnvelope) => number | null;
|
|
267
|
+
/**
|
|
268
|
+
* v1.2 private-scope decryption key. Applied to the root delegation AND
|
|
269
|
+
* every subdelegation in the chain that carries `scopes_encrypted`. If a
|
|
270
|
+
* link is private-mode and no key matches its recipients, verification
|
|
271
|
+
* fails E_SCOPES_UNREADABLE — the chain's transitive narrowing cannot be
|
|
272
|
+
* checked without the plaintext.
|
|
273
|
+
*/
|
|
274
|
+
decryptScopesWith?: {
|
|
275
|
+
device_id: string;
|
|
276
|
+
secretKey: Uint8Array;
|
|
277
|
+
};
|
|
160
278
|
}
|
|
161
279
|
|
|
162
280
|
export async function verifyAction(input: VerifyActionInput): Promise<VerifyActionResult> {
|
|
163
281
|
const a = input.action;
|
|
164
282
|
const d = input.delegation;
|
|
165
283
|
|
|
166
|
-
//
|
|
284
|
+
// 0. Chain-depth check (SUB-DELEGATION.md §2.1) — before any per-link work.
|
|
285
|
+
const chain: SubdelegationEnvelope[] = input.subdelegationChain ?? [];
|
|
286
|
+
const maxDepth = input.maxChainDepth ?? DEFAULT_MAX_CHAIN_DEPTH;
|
|
287
|
+
if (chain.length > maxDepth) {
|
|
288
|
+
return err(
|
|
289
|
+
'E_SUBDELEGATION_DEPTH_EXCEEDED',
|
|
290
|
+
`chain depth ${chain.length} exceeds maximum ${maxDepth}`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// 1. First verify the root delegation. verifyDelegation handles v1.2
|
|
295
|
+
// private-scope hydration internally — when the root is private-mode,
|
|
296
|
+
// the returned envelope already has `scopes` populated from the
|
|
297
|
+
// decrypted plaintext.
|
|
167
298
|
const dr = await verifyDelegation({
|
|
168
299
|
envelope: d,
|
|
169
300
|
verifyBip322: input.verifyBip322,
|
|
170
301
|
skipSignatureVerification: input.skipSignatureVerification,
|
|
171
302
|
scopeMode: input.scopeMode,
|
|
172
303
|
skipTemporalCheck: true, // action window check dominates
|
|
304
|
+
...(input.decryptScopesWith ? { decryptScopesWith: input.decryptScopesWith } : {}),
|
|
173
305
|
});
|
|
174
306
|
if (!dr.ok) return dr;
|
|
307
|
+
const rootHydrated: DelegationEnvelope = dr.envelope;
|
|
308
|
+
|
|
309
|
+
// 1b. Walk the sub-delegation chain (SUB-DELEGATION.md §2.2 step 3).
|
|
310
|
+
// Each link may independently be private-mode; we hydrate it before
|
|
311
|
+
// handing it to verifyChainLink so the chain walker sees plaintext
|
|
312
|
+
// scopes uniformly.
|
|
313
|
+
let parent: ChainLink = rootHydrated;
|
|
314
|
+
const hydratedChain: SubdelegationEnvelope[] = [];
|
|
315
|
+
for (const sub of chain) {
|
|
316
|
+
const hydrated = await hydrateSubdelegationScopes(sub, input);
|
|
317
|
+
if (!hydrated.ok) return hydrated;
|
|
318
|
+
const r = await verifyChainLink(hydrated.envelope, parent, input);
|
|
319
|
+
if (!r.ok) return r;
|
|
320
|
+
hydratedChain.push(hydrated.envelope);
|
|
321
|
+
parent = hydrated.envelope;
|
|
322
|
+
}
|
|
323
|
+
/** The envelope `action.delegation_id` should cite (root if no chain, leaf otherwise). */
|
|
324
|
+
const leaf: ChainLink =
|
|
325
|
+
hydratedChain.length > 0 ? hydratedChain[hydratedChain.length - 1]! : rootHydrated;
|
|
175
326
|
|
|
176
327
|
// 2. Core action checks.
|
|
177
328
|
if (a.v !== ENVELOPE_VERSION) {
|
|
@@ -201,30 +352,37 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
|
|
|
201
352
|
if (!ok) return err('E_BAD_ACTION_STAMP', 'action BIP-322 signature did not verify');
|
|
202
353
|
}
|
|
203
354
|
|
|
204
|
-
// 3. Authority chain
|
|
205
|
-
|
|
206
|
-
|
|
355
|
+
// 3. Authority chain — leaf-binding (action cites the leaf of the chain,
|
|
356
|
+
// which is the root delegation when no subdelegation chain is present).
|
|
357
|
+
if (a.delegation_id !== leaf.id) {
|
|
358
|
+
return err('E_DELEGATION_MISMATCH', `action.delegation_id (${a.delegation_id}) != leaf.id (${leaf.id})`);
|
|
207
359
|
}
|
|
208
|
-
if (a.signer.address !==
|
|
209
|
-
return err('E_AGENT_MISMATCH', `action signer (${a.signer.address}) !=
|
|
360
|
+
if (a.signer.address !== leaf.agent.address) {
|
|
361
|
+
return err('E_AGENT_MISMATCH', `action signer (${a.signer.address}) != leaf.agent (${leaf.agent.address})`);
|
|
210
362
|
}
|
|
211
363
|
|
|
212
|
-
// 4. Window.
|
|
213
|
-
const issued = new Date(
|
|
214
|
-
const expires = new Date(
|
|
364
|
+
// 4. Window — against the leaf.
|
|
365
|
+
const issued = new Date(leaf.issued_at).getTime();
|
|
366
|
+
const expires = new Date(leaf.expires_at).getTime();
|
|
215
367
|
const signed = new Date(a.signed_at).getTime();
|
|
216
368
|
if (Number.isNaN(issued) || Number.isNaN(expires) || Number.isNaN(signed)) {
|
|
217
369
|
return err('E_MALFORMED', 'unparseable ISO 8601 timestamp');
|
|
218
370
|
}
|
|
219
371
|
if (signed < issued || signed >= expires) {
|
|
220
|
-
return err('E_OUT_OF_WINDOW', `action.signed_at ${a.signed_at} is outside
|
|
372
|
+
return err('E_OUT_OF_WINDOW', `action.signed_at ${a.signed_at} is outside leaf window [${leaf.issued_at}, ${leaf.expires_at})`);
|
|
221
373
|
}
|
|
222
374
|
|
|
223
|
-
// 5. Scope containment.
|
|
375
|
+
// 5. Scope containment — against the leaf's granted set.
|
|
376
|
+
// Leaf is post-hydration, so `scopes` is guaranteed populated. The
|
|
377
|
+
// runtime guard satisfies the type checker and would only fire if a
|
|
378
|
+
// caller bypassed the hydration path.
|
|
379
|
+
if (!leaf.scopes) {
|
|
380
|
+
return err('E_SCOPES_NEITHER_PROVIDED', 'leaf has no plaintext scopes after hydration');
|
|
381
|
+
}
|
|
224
382
|
let exercised, accepted;
|
|
225
383
|
try {
|
|
226
384
|
exercised = canonicalizeScope(parseScope(a.scope_exercised));
|
|
227
|
-
const granted =
|
|
385
|
+
const granted = leaf.scopes.map((s) => parseScope(s));
|
|
228
386
|
const exercisedParsed = parseScope(a.scope_exercised);
|
|
229
387
|
validateScope(exercisedParsed, { mode: input.scopeMode ?? 'strict' });
|
|
230
388
|
accepted = granted.some((g) => isSubScope(exercisedParsed, g));
|
|
@@ -234,22 +392,29 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
|
|
|
234
392
|
}
|
|
235
393
|
if (!accepted) return err('E_SCOPE_DENIED', `scope_exercised (${exercised}) not a sub-scope of any granted scope`);
|
|
236
394
|
|
|
237
|
-
// 6. Revocation check
|
|
395
|
+
// 6. Revocation check — applies per-link to ALL envelopes in the chain
|
|
396
|
+
// (root + every subdelegation). Per SUB-DELEGATION.md §2.2 step 5, a
|
|
397
|
+
// revocation against ANY link invalidates the action.
|
|
238
398
|
if (input.revocations && input.revocations.length > 0) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
399
|
+
const allLinks: ChainLink[] = [rootHydrated, ...hydratedChain];
|
|
400
|
+
for (const link of allLinks) {
|
|
401
|
+
for (const rev of input.revocations) {
|
|
402
|
+
if (rev.delegation_id !== link.id) continue;
|
|
403
|
+
// Verify the revocation itself (signature + canonical + signer
|
|
404
|
+
// authorization). verifyRevocation accepts ChainLink, so the
|
|
405
|
+
// call shape is identical for root vs sub.
|
|
406
|
+
const rr = await verifyRevocation({
|
|
407
|
+
envelope: rev,
|
|
408
|
+
delegation: link,
|
|
409
|
+
verifyBip322: input.verifyBip322,
|
|
410
|
+
skipSignatureVerification: input.skipSignatureVerification,
|
|
411
|
+
});
|
|
412
|
+
if (!rr.ok) continue; // malformed revocations don't affect the action
|
|
413
|
+
const effective = effectiveRevocationTime(rev, input.resolveAnchorBlockHeight);
|
|
414
|
+
const actionTime = actionEffectiveTime(a, input.resolveAnchorBlockHeight);
|
|
415
|
+
if (compareTimes(effective, actionTime) <= 0) {
|
|
416
|
+
return err('E_REVOKED', `chain link ${link.id} was revoked by ${rev.id} before action was signed`);
|
|
417
|
+
}
|
|
253
418
|
}
|
|
254
419
|
}
|
|
255
420
|
}
|
|
@@ -290,20 +455,82 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
|
|
|
290
455
|
envelope: a,
|
|
291
456
|
canonicalMessage: reconstructedMessage,
|
|
292
457
|
id: a.id,
|
|
293
|
-
delegation:
|
|
458
|
+
delegation: rootHydrated,
|
|
459
|
+
chain: hydratedChain,
|
|
294
460
|
scopeExercised: exercised,
|
|
295
461
|
anchor,
|
|
296
462
|
};
|
|
297
463
|
}
|
|
298
464
|
|
|
465
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
466
|
+
// v1.2 chain-link hydration — decrypt scopes_encrypted on subdelegations
|
|
467
|
+
// before passing them to the chain walker so all containment checks operate
|
|
468
|
+
// on plaintext.
|
|
469
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
async function hydrateSubdelegationScopes(
|
|
472
|
+
sub: SubdelegationEnvelope,
|
|
473
|
+
input: VerifyBase & {
|
|
474
|
+
decryptScopesWith?: { device_id: string; secretKey: Uint8Array };
|
|
475
|
+
}
|
|
476
|
+
): Promise<{ ok: true; envelope: SubdelegationEnvelope } | VerifyErrResult> {
|
|
477
|
+
if (sub.scopes !== undefined && sub.scopes_encrypted !== undefined) {
|
|
478
|
+
return err(
|
|
479
|
+
'E_SCOPES_BOTH_PROVIDED',
|
|
480
|
+
'subdelegation carries both scopes and scopes_encrypted'
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
if (sub.scopes === undefined && sub.scopes_encrypted === undefined) {
|
|
484
|
+
return err(
|
|
485
|
+
'E_SCOPES_NEITHER_PROVIDED',
|
|
486
|
+
'subdelegation carries neither scopes nor scopes_encrypted'
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
if (sub.scopes_encrypted === undefined) {
|
|
490
|
+
return { ok: true, envelope: sub };
|
|
491
|
+
}
|
|
492
|
+
if (sub.scopes_encrypted.from?.address !== sub.principal.address) {
|
|
493
|
+
return err(
|
|
494
|
+
'E_MALFORMED',
|
|
495
|
+
`subdelegation.scopes_encrypted.from.address (${sub.scopes_encrypted.from?.address}) does not match principal.address (${sub.principal.address})`
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
if (!input.decryptScopesWith) {
|
|
499
|
+
return err(
|
|
500
|
+
'E_SCOPES_UNREADABLE',
|
|
501
|
+
'subdelegation carries scopes_encrypted; no decryption key provided for the chain'
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
const r = await unsealScopes({
|
|
506
|
+
envelope: sub.scopes_encrypted,
|
|
507
|
+
device: input.decryptScopesWith,
|
|
508
|
+
...(input.verifyBip322 ? { verifyBip322: input.verifyBip322 } : {}),
|
|
509
|
+
skipSenderVerification: !!input.skipSignatureVerification,
|
|
510
|
+
});
|
|
511
|
+
return { ok: true, envelope: { ...sub, scopes: r.scopes } };
|
|
512
|
+
} catch (e) {
|
|
513
|
+
const msg = (e as Error).message ?? String(e);
|
|
514
|
+
if (/no matching recipient|no recipient|device_id/i.test(msg)) {
|
|
515
|
+
return err('E_SCOPES_UNREADABLE', msg);
|
|
516
|
+
}
|
|
517
|
+
return err('E_BAD_LOCK_ENVELOPE', msg);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
299
521
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
300
522
|
// Revocation (SPEC §9, §8 transitive)
|
|
301
523
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
302
524
|
|
|
303
525
|
export interface VerifyRevocationInput extends VerifyBase {
|
|
304
526
|
envelope: RevocationEnvelope;
|
|
305
|
-
/**
|
|
306
|
-
|
|
527
|
+
/**
|
|
528
|
+
* The envelope targeted by the revocation. Required to check signer is
|
|
529
|
+
* authorized. May be a v1.0 root delegation OR a v1.1 sub-delegation —
|
|
530
|
+
* both have identical `principal`, `agent`, `id`, and `revocation.holders`
|
|
531
|
+
* field shapes per SUB-DELEGATION.md §3.
|
|
532
|
+
*/
|
|
533
|
+
delegation: ChainLink;
|
|
307
534
|
}
|
|
308
535
|
|
|
309
536
|
export async function verifyRevocation(input: VerifyRevocationInput): Promise<VerifyRevocationResult> {
|
|
@@ -359,7 +586,15 @@ function checkDelegationShape(env: DelegationEnvelope): VerifyDelegationResult |
|
|
|
359
586
|
if (!isHex64(env.id)) return err('E_MALFORMED', 'id must be 64 lowercase hex chars');
|
|
360
587
|
if (!env.principal?.address || env.principal.alg !== 'bip322') return err('E_MALFORMED', 'principal invalid');
|
|
361
588
|
if (!env.agent?.address || env.agent.alg !== 'bip322') return err('E_MALFORMED', 'agent invalid');
|
|
362
|
-
|
|
589
|
+
// v1.2: scopes OR scopes_encrypted (exactly one). Mutual exclusion +
|
|
590
|
+
// presence checks are performed by the verify-time PRE-VERIFICATION block
|
|
591
|
+
// (PRIVATE-SCOPE.md §2 steps P1–P2) so they can return their own error
|
|
592
|
+
// codes (E_SCOPES_BOTH_PROVIDED / E_SCOPES_NEITHER_PROVIDED) rather than
|
|
593
|
+
// collapsing into E_MALFORMED.
|
|
594
|
+
if (env.scopes !== undefined) {
|
|
595
|
+
if (!Array.isArray(env.scopes) || env.scopes.length === 0)
|
|
596
|
+
return err('E_MALFORMED', 'scopes must be non-empty array');
|
|
597
|
+
}
|
|
363
598
|
if (env.bond !== null) {
|
|
364
599
|
if (!Number.isInteger(env.bond.sats) || env.bond.sats < 0) return err('E_MALFORMED', 'bond.sats must be non-negative integer');
|
|
365
600
|
if (!isHex64(env.bond.attestation_id)) return err('E_MALFORMED', 'bond.attestation_id must be 64-hex');
|
|
@@ -400,6 +635,218 @@ function checkRevocationShape(env: RevocationEnvelope): VerifyRevocationResult |
|
|
|
400
635
|
return null;
|
|
401
636
|
}
|
|
402
637
|
|
|
638
|
+
function checkSubdelegationShape(env: SubdelegationEnvelope): VerifySubdelegationResult | null {
|
|
639
|
+
if (env.kind !== 'agent-subdelegation') return err('E_MALFORMED', 'kind must be "agent-subdelegation"');
|
|
640
|
+
if (!isHex64(env.id)) return err('E_MALFORMED', 'id must be 64 lowercase hex chars');
|
|
641
|
+
if (!isHex64(env.parent_id)) return err('E_MALFORMED', 'parent_id must be 64-hex');
|
|
642
|
+
if (!env.principal?.address || env.principal.alg !== 'bip322') return err('E_MALFORMED', 'principal invalid');
|
|
643
|
+
if (!env.agent?.address || env.agent.alg !== 'bip322') return err('E_MALFORMED', 'agent invalid');
|
|
644
|
+
// v1.2: scopes OR scopes_encrypted (post-hydration the chain walker
|
|
645
|
+
// always sees `scopes` populated). Mutual exclusion + presence checked
|
|
646
|
+
// by hydrateSubdelegationScopes.
|
|
647
|
+
if (env.scopes !== undefined) {
|
|
648
|
+
if (!Array.isArray(env.scopes) || env.scopes.length === 0)
|
|
649
|
+
return err('E_MALFORMED', 'scopes must be non-empty array');
|
|
650
|
+
}
|
|
651
|
+
// Sub-delegations MUST NOT carry a bond field (SUB-DELEGATION.md §1.3).
|
|
652
|
+
if ('bond' in env && (env as { bond?: unknown }).bond !== undefined) {
|
|
653
|
+
return err('E_MALFORMED', 'sub-delegation envelopes MUST NOT carry a bond field');
|
|
654
|
+
}
|
|
655
|
+
if (!isIsoUtc(env.issued_at)) return err('E_MALFORMED', 'issued_at must be ISO 8601 UTC');
|
|
656
|
+
if (!isIsoUtc(env.expires_at)) return err('E_MALFORMED', 'expires_at must be ISO 8601 UTC');
|
|
657
|
+
if (!/^[0-9a-f]{32}$/.test(env.nonce)) return err('E_MALFORMED', 'nonce must be 32 lowercase hex chars');
|
|
658
|
+
if (env.sig?.alg !== 'bip322' || typeof env.sig.value !== 'string') return err('E_MALFORMED', 'sig invalid');
|
|
659
|
+
if (env.sig.pubkey !== env.principal.address) return err('E_MALFORMED', 'sig.pubkey must equal principal.address');
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
664
|
+
// Sub-delegation chain link (SUB-DELEGATION.md §2.2 step 3)
|
|
665
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Verify a single sub-delegation envelope as a chain link from `parent`.
|
|
669
|
+
* Performs steps 3a–3g in order; returns the corresponding VerifyErr on
|
|
670
|
+
* failure or `{ ok: true, envelope: s }` on success.
|
|
671
|
+
*
|
|
672
|
+
* Skips the standalone temporal-validity check (SUB-DELEGATION.md §2.2 step
|
|
673
|
+
* 3d) — for action verification, the action-window check (step 4c) on the
|
|
674
|
+
* leaf is the binding temporal constraint. Callers that want a current-time
|
|
675
|
+
* "is this subdelegation active right now" check can use `verifySubdelegation`.
|
|
676
|
+
*/
|
|
677
|
+
async function verifyChainLink(
|
|
678
|
+
s: SubdelegationEnvelope,
|
|
679
|
+
parent: ChainLink,
|
|
680
|
+
input: VerifyBase
|
|
681
|
+
): Promise<VerifySubdelegationResult> {
|
|
682
|
+
if (s.v !== ENVELOPE_VERSION) {
|
|
683
|
+
return err('E_UNSUPPORTED_VERSION', `subdelegation version ${s.v} not supported`);
|
|
684
|
+
}
|
|
685
|
+
const shape = checkSubdelegationShape(s);
|
|
686
|
+
if (shape) return shape;
|
|
687
|
+
|
|
688
|
+
// verifyChainLink expects post-hydration scopes (callers run
|
|
689
|
+
// hydrateSubdelegationScopes upfront). The runtime guard makes the
|
|
690
|
+
// type-checker happy and protects against bypass.
|
|
691
|
+
if (!s.scopes) {
|
|
692
|
+
return err(
|
|
693
|
+
'E_SCOPES_NEITHER_PROVIDED',
|
|
694
|
+
'subdelegation has no plaintext scopes — caller must hydrate v1.2 envelopes before invoking verifyChainLink'
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Step 3a: canonical id.
|
|
699
|
+
let canonicalScopesList: string[];
|
|
700
|
+
try {
|
|
701
|
+
canonicalScopesList = canonicalizeScopes(s.scopes);
|
|
702
|
+
} catch (e) {
|
|
703
|
+
const msg = e instanceof ScopeParseError ? e.message : (e as Error).message;
|
|
704
|
+
return err('E_BAD_SCOPE_GRAMMAR', msg);
|
|
705
|
+
}
|
|
706
|
+
const canonInput = {
|
|
707
|
+
parent_id: s.parent_id,
|
|
708
|
+
principal: s.principal.address,
|
|
709
|
+
agent: s.agent.address,
|
|
710
|
+
scopes: canonicalScopesList,
|
|
711
|
+
issued_at: s.issued_at,
|
|
712
|
+
expires_at: s.expires_at,
|
|
713
|
+
nonce: s.nonce,
|
|
714
|
+
};
|
|
715
|
+
const reconstructedId = computeSubdelegationId(canonInput);
|
|
716
|
+
if (reconstructedId !== s.id) {
|
|
717
|
+
return err('E_BAD_ID', `reconstructed subdelegation id (${reconstructedId}) does not match envelope id (${s.id})`);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Step 3b: scope grammar validation (registry-aware).
|
|
721
|
+
let parsedScopes;
|
|
722
|
+
try {
|
|
723
|
+
parsedScopes = s.scopes.map((str) => parseScope(str));
|
|
724
|
+
for (const p of parsedScopes) validateScope(p, { mode: input.scopeMode ?? 'strict' });
|
|
725
|
+
} catch (e) {
|
|
726
|
+
const msg = e instanceof ScopeParseError ? e.message : (e as Error).message;
|
|
727
|
+
return err('E_BAD_SCOPE_GRAMMAR', msg);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Step 3c: BIP-322 signature.
|
|
731
|
+
if (!input.skipSignatureVerification) {
|
|
732
|
+
if (!input.verifyBip322) return err('E_BAD_SIG', 'no BIP-322 verifier supplied for subdelegation');
|
|
733
|
+
const ok = await input.verifyBip322(s.id, s.sig.value, s.principal.address);
|
|
734
|
+
if (!ok) return err('E_BAD_SIG', 'subdelegation BIP-322 signature did not verify');
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Step 3e: linkage.
|
|
738
|
+
if (s.parent_id !== parent.id) {
|
|
739
|
+
return err(
|
|
740
|
+
'E_SUBDELEGATION_PRINCIPAL_MISMATCH',
|
|
741
|
+
`subdelegation.parent_id (${s.parent_id}) does not match parent envelope id (${parent.id})`
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
if (s.principal.address !== parent.agent.address) {
|
|
745
|
+
return err(
|
|
746
|
+
'E_SUBDELEGATION_PRINCIPAL_MISMATCH',
|
|
747
|
+
`subdelegation.principal (${s.principal.address}) does not match parent.agent (${parent.agent.address})`
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Step 3f: temporal containment.
|
|
752
|
+
const sIssued = new Date(s.issued_at).getTime();
|
|
753
|
+
const sExpires = new Date(s.expires_at).getTime();
|
|
754
|
+
const pIssued = new Date(parent.issued_at).getTime();
|
|
755
|
+
const pExpires = new Date(parent.expires_at).getTime();
|
|
756
|
+
if (Number.isNaN(sIssued) || Number.isNaN(sExpires) || Number.isNaN(pIssued) || Number.isNaN(pExpires)) {
|
|
757
|
+
return err('E_MALFORMED', 'unparseable ISO 8601 timestamp in chain');
|
|
758
|
+
}
|
|
759
|
+
if (sExpires <= sIssued) {
|
|
760
|
+
return err('E_MALFORMED', 'subdelegation expires_at must be > issued_at');
|
|
761
|
+
}
|
|
762
|
+
if (sIssued < pIssued || sExpires > pExpires) {
|
|
763
|
+
return err(
|
|
764
|
+
'E_SUBDELEGATION_EXPIRES_EXTENDED',
|
|
765
|
+
`subdelegation window [${s.issued_at}, ${s.expires_at}) is not contained in parent's [${parent.issued_at}, ${parent.expires_at})`
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Step 3g: scope containment (transitive narrowing).
|
|
770
|
+
if (!parent.scopes) {
|
|
771
|
+
return err(
|
|
772
|
+
'E_SCOPES_NEITHER_PROVIDED',
|
|
773
|
+
'parent has no plaintext scopes — caller must hydrate v1.2 parents before invoking verifyChainLink'
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
const parentScopesParsed = parent.scopes.map((str) => parseScope(str));
|
|
777
|
+
for (let i = 0; i < parsedScopes.length; i++) {
|
|
778
|
+
const childScope = parsedScopes[i]!;
|
|
779
|
+
const containedBySomeParentScope = parentScopesParsed.some((p) => isSubScope(childScope, p));
|
|
780
|
+
if (!containedBySomeParentScope) {
|
|
781
|
+
return err(
|
|
782
|
+
'E_SUBDELEGATION_SCOPE_ESCALATED',
|
|
783
|
+
`subdelegation scope ${canonicalizeScope(childScope)} is not a sub-scope of any granted scope on the parent`
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
return {
|
|
789
|
+
ok: true,
|
|
790
|
+
envelope: s,
|
|
791
|
+
canonicalMessage: '', // not populated for chain links; computeSubdelegationId is the binding form
|
|
792
|
+
id: s.id,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
797
|
+
// Standalone subdelegation verification (no action context)
|
|
798
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
799
|
+
|
|
800
|
+
export interface VerifySubdelegationInput extends VerifyBase {
|
|
801
|
+
envelope: SubdelegationEnvelope;
|
|
802
|
+
/**
|
|
803
|
+
* The immediate parent envelope. Required for linkage / containment checks.
|
|
804
|
+
* If the parent is itself v1.2 private-mode (`scopes_encrypted`) the caller
|
|
805
|
+
* MUST pre-hydrate it (e.g., via verifyDelegation's returned envelope) —
|
|
806
|
+
* the chain walker reads `parent.scopes` directly.
|
|
807
|
+
*/
|
|
808
|
+
parent: ChainLink;
|
|
809
|
+
/** Skip the "now ∈ [issued, expires)" check. Useful for inspection. */
|
|
810
|
+
skipTemporalCheck?: boolean;
|
|
811
|
+
/** Defaults to new Date(). */
|
|
812
|
+
now?: Date;
|
|
813
|
+
/**
|
|
814
|
+
* v1.2 private-scope decryption key for the subdelegation envelope itself.
|
|
815
|
+
* Not used for the parent — the caller hydrates the parent.
|
|
816
|
+
*/
|
|
817
|
+
decryptScopesWith?: {
|
|
818
|
+
device_id: string;
|
|
819
|
+
secretKey: Uint8Array;
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Verify a single sub-delegation envelope against its immediate parent.
|
|
825
|
+
* Includes the standalone temporal-validity check (`now ∈ [issued, expires)`)
|
|
826
|
+
* unless `skipTemporalCheck` is set. Useful for pre-flighting a chain link
|
|
827
|
+
* outside of action verification.
|
|
828
|
+
*/
|
|
829
|
+
export async function verifySubdelegation(
|
|
830
|
+
input: VerifySubdelegationInput
|
|
831
|
+
): Promise<VerifySubdelegationResult> {
|
|
832
|
+
// v1.2: hydrate private-mode envelopes before chain-link checks so
|
|
833
|
+
// the walker uniformly reads plaintext scopes.
|
|
834
|
+
const hydrated = await hydrateSubdelegationScopes(input.envelope, input);
|
|
835
|
+
if (!hydrated.ok) return hydrated;
|
|
836
|
+
const r = await verifyChainLink(hydrated.envelope, input.parent, input);
|
|
837
|
+
if (!r.ok) return r;
|
|
838
|
+
|
|
839
|
+
if (!input.skipTemporalCheck) {
|
|
840
|
+
const now = (input.now ?? new Date()).getTime();
|
|
841
|
+
const issued = new Date(input.envelope.issued_at).getTime();
|
|
842
|
+
const expires = new Date(input.envelope.expires_at).getTime();
|
|
843
|
+
if (now < issued) return err('E_NOT_YET_VALID', `subdelegation issued_at ${input.envelope.issued_at} is in the future`);
|
|
844
|
+
if (now >= expires) return err('E_EXPIRED', `subdelegation expires_at ${input.envelope.expires_at} is past`);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
return r;
|
|
848
|
+
}
|
|
849
|
+
|
|
403
850
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
404
851
|
// Time comparison for revocation vs action (SPEC §9.3)
|
|
405
852
|
// ─────────────────────────────────────────────────────────────────────────────
|