@orangecheck/agent-core 0.2.0 → 1.0.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/index.d.mts +47 -2
- package/dist/index.d.ts +47 -2
- package/dist/index.js +184 -16
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +180 -17
- package/dist/index.mjs.map +1 -1
- package/dist/types.d.mts +39 -4
- package/dist/types.d.ts +39 -4
- package/dist/types.js.map +1 -1
- package/dist/types.mjs.map +1 -1
- package/package.json +3 -2
- package/src/index.ts +12 -0
- package/src/private-scope.test.ts +223 -0
- package/src/private-scope.ts +122 -0
- package/src/test-vectors.test.ts +185 -1
- package/src/types.ts +59 -5
- package/src/verify.ts +234 -18
package/src/verify.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
revocationCanonicalBytes,
|
|
15
15
|
revocationCanonicalMessage,
|
|
16
16
|
} from './canonical.js';
|
|
17
|
+
import { unsealScopes } from './private-scope.js';
|
|
17
18
|
import {
|
|
18
19
|
canonicalizeScope,
|
|
19
20
|
isSubScope,
|
|
@@ -68,6 +69,16 @@ export interface VerifyDelegationInput extends VerifyBase {
|
|
|
68
69
|
now?: Date;
|
|
69
70
|
/** Skip temporal checks entirely (useful for inspecting historical envelopes). */
|
|
70
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
|
+
};
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
export async function verifyDelegation(input: VerifyDelegationInput): Promise<VerifyDelegationResult> {
|
|
@@ -80,22 +91,86 @@ export async function verifyDelegation(input: VerifyDelegationInput): Promise<Ve
|
|
|
80
91
|
const shape = checkDelegationShape(env);
|
|
81
92
|
if (shape) return shape;
|
|
82
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) ──────────────────────────────
|
|
83
156
|
// Scope grammar.
|
|
84
157
|
let canonicalScopes: string[];
|
|
85
158
|
try {
|
|
86
|
-
for (const s of
|
|
87
|
-
canonicalScopes = canonicalizeScopes(
|
|
159
|
+
for (const s of workingScopes) validateScope(parseScope(s), { mode: input.scopeMode ?? 'strict' });
|
|
160
|
+
canonicalScopes = canonicalizeScopes(workingScopes);
|
|
88
161
|
} catch (e) {
|
|
89
162
|
const msg = e instanceof ScopeParseError ? e.message : (e as Error).message;
|
|
90
163
|
return err('E_BAD_SCOPE_GRAMMAR', msg);
|
|
91
164
|
}
|
|
92
165
|
|
|
93
|
-
//
|
|
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.)
|
|
94
169
|
for (let i = 0; i < canonicalScopes.length; i++) {
|
|
95
|
-
if (
|
|
170
|
+
if (workingScopes[i] !== canonicalScopes[i]) {
|
|
96
171
|
return err(
|
|
97
172
|
'E_BAD_SCOPE_GRAMMAR',
|
|
98
|
-
`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]}`
|
|
99
174
|
);
|
|
100
175
|
}
|
|
101
176
|
}
|
|
@@ -139,9 +214,17 @@ export async function verifyDelegation(input: VerifyDelegationInput): Promise<Ve
|
|
|
139
214
|
if (now >= expires) return err('E_EXPIRED', `delegation expired at ${env.expires_at}`);
|
|
140
215
|
}
|
|
141
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
|
+
|
|
142
225
|
return {
|
|
143
226
|
ok: true,
|
|
144
|
-
envelope:
|
|
227
|
+
envelope: returnedEnvelope,
|
|
145
228
|
canonicalMessage: reconstructedMessage,
|
|
146
229
|
id: env.id,
|
|
147
230
|
};
|
|
@@ -181,6 +264,17 @@ export interface VerifyActionInput extends VerifyBase {
|
|
|
181
264
|
verifyOtsAnchor?: (proofB64: string, blockHeight: number, blockHash: string) => Promise<boolean>;
|
|
182
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. */
|
|
183
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
|
+
};
|
|
184
278
|
}
|
|
185
279
|
|
|
186
280
|
export async function verifyAction(input: VerifyActionInput): Promise<VerifyActionResult> {
|
|
@@ -197,25 +291,38 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
|
|
|
197
291
|
);
|
|
198
292
|
}
|
|
199
293
|
|
|
200
|
-
// 1. First verify the root delegation.
|
|
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.
|
|
201
298
|
const dr = await verifyDelegation({
|
|
202
299
|
envelope: d,
|
|
203
300
|
verifyBip322: input.verifyBip322,
|
|
204
301
|
skipSignatureVerification: input.skipSignatureVerification,
|
|
205
302
|
scopeMode: input.scopeMode,
|
|
206
303
|
skipTemporalCheck: true, // action window check dominates
|
|
304
|
+
...(input.decryptScopesWith ? { decryptScopesWith: input.decryptScopesWith } : {}),
|
|
207
305
|
});
|
|
208
306
|
if (!dr.ok) return dr;
|
|
307
|
+
const rootHydrated: DelegationEnvelope = dr.envelope;
|
|
209
308
|
|
|
210
309
|
// 1b. Walk the sub-delegation chain (SUB-DELEGATION.md §2.2 step 3).
|
|
211
|
-
|
|
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[] = [];
|
|
212
315
|
for (const sub of chain) {
|
|
213
|
-
const
|
|
316
|
+
const hydrated = await hydrateSubdelegationScopes(sub, input);
|
|
317
|
+
if (!hydrated.ok) return hydrated;
|
|
318
|
+
const r = await verifyChainLink(hydrated.envelope, parent, input);
|
|
214
319
|
if (!r.ok) return r;
|
|
215
|
-
|
|
320
|
+
hydratedChain.push(hydrated.envelope);
|
|
321
|
+
parent = hydrated.envelope;
|
|
216
322
|
}
|
|
217
323
|
/** The envelope `action.delegation_id` should cite (root if no chain, leaf otherwise). */
|
|
218
|
-
const leaf: ChainLink =
|
|
324
|
+
const leaf: ChainLink =
|
|
325
|
+
hydratedChain.length > 0 ? hydratedChain[hydratedChain.length - 1]! : rootHydrated;
|
|
219
326
|
|
|
220
327
|
// 2. Core action checks.
|
|
221
328
|
if (a.v !== ENVELOPE_VERSION) {
|
|
@@ -266,6 +373,12 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
|
|
|
266
373
|
}
|
|
267
374
|
|
|
268
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
|
+
}
|
|
269
382
|
let exercised, accepted;
|
|
270
383
|
try {
|
|
271
384
|
exercised = canonicalizeScope(parseScope(a.scope_exercised));
|
|
@@ -283,7 +396,7 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
|
|
|
283
396
|
// (root + every subdelegation). Per SUB-DELEGATION.md §2.2 step 5, a
|
|
284
397
|
// revocation against ANY link invalidates the action.
|
|
285
398
|
if (input.revocations && input.revocations.length > 0) {
|
|
286
|
-
const allLinks: ChainLink[] = [
|
|
399
|
+
const allLinks: ChainLink[] = [rootHydrated, ...hydratedChain];
|
|
287
400
|
for (const link of allLinks) {
|
|
288
401
|
for (const rev of input.revocations) {
|
|
289
402
|
if (rev.delegation_id !== link.id) continue;
|
|
@@ -342,13 +455,69 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
|
|
|
342
455
|
envelope: a,
|
|
343
456
|
canonicalMessage: reconstructedMessage,
|
|
344
457
|
id: a.id,
|
|
345
|
-
delegation:
|
|
346
|
-
chain,
|
|
458
|
+
delegation: rootHydrated,
|
|
459
|
+
chain: hydratedChain,
|
|
347
460
|
scopeExercised: exercised,
|
|
348
461
|
anchor,
|
|
349
462
|
};
|
|
350
463
|
}
|
|
351
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
|
+
|
|
352
521
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
353
522
|
// Revocation (SPEC §9, §8 transitive)
|
|
354
523
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -417,7 +586,15 @@ function checkDelegationShape(env: DelegationEnvelope): VerifyDelegationResult |
|
|
|
417
586
|
if (!isHex64(env.id)) return err('E_MALFORMED', 'id must be 64 lowercase hex chars');
|
|
418
587
|
if (!env.principal?.address || env.principal.alg !== 'bip322') return err('E_MALFORMED', 'principal invalid');
|
|
419
588
|
if (!env.agent?.address || env.agent.alg !== 'bip322') return err('E_MALFORMED', 'agent invalid');
|
|
420
|
-
|
|
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
|
+
}
|
|
421
598
|
if (env.bond !== null) {
|
|
422
599
|
if (!Number.isInteger(env.bond.sats) || env.bond.sats < 0) return err('E_MALFORMED', 'bond.sats must be non-negative integer');
|
|
423
600
|
if (!isHex64(env.bond.attestation_id)) return err('E_MALFORMED', 'bond.attestation_id must be 64-hex');
|
|
@@ -464,7 +641,13 @@ function checkSubdelegationShape(env: SubdelegationEnvelope): VerifySubdelegatio
|
|
|
464
641
|
if (!isHex64(env.parent_id)) return err('E_MALFORMED', 'parent_id must be 64-hex');
|
|
465
642
|
if (!env.principal?.address || env.principal.alg !== 'bip322') return err('E_MALFORMED', 'principal invalid');
|
|
466
643
|
if (!env.agent?.address || env.agent.alg !== 'bip322') return err('E_MALFORMED', 'agent invalid');
|
|
467
|
-
|
|
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
|
+
}
|
|
468
651
|
// Sub-delegations MUST NOT carry a bond field (SUB-DELEGATION.md §1.3).
|
|
469
652
|
if ('bond' in env && (env as { bond?: unknown }).bond !== undefined) {
|
|
470
653
|
return err('E_MALFORMED', 'sub-delegation envelopes MUST NOT carry a bond field');
|
|
@@ -502,6 +685,16 @@ async function verifyChainLink(
|
|
|
502
685
|
const shape = checkSubdelegationShape(s);
|
|
503
686
|
if (shape) return shape;
|
|
504
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
|
+
|
|
505
698
|
// Step 3a: canonical id.
|
|
506
699
|
let canonicalScopesList: string[];
|
|
507
700
|
try {
|
|
@@ -574,6 +767,12 @@ async function verifyChainLink(
|
|
|
574
767
|
}
|
|
575
768
|
|
|
576
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
|
+
}
|
|
577
776
|
const parentScopesParsed = parent.scopes.map((str) => parseScope(str));
|
|
578
777
|
for (let i = 0; i < parsedScopes.length; i++) {
|
|
579
778
|
const childScope = parsedScopes[i]!;
|
|
@@ -600,12 +799,25 @@ async function verifyChainLink(
|
|
|
600
799
|
|
|
601
800
|
export interface VerifySubdelegationInput extends VerifyBase {
|
|
602
801
|
envelope: SubdelegationEnvelope;
|
|
603
|
-
/**
|
|
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
|
+
*/
|
|
604
808
|
parent: ChainLink;
|
|
605
809
|
/** Skip the "now ∈ [issued, expires)" check. Useful for inspection. */
|
|
606
810
|
skipTemporalCheck?: boolean;
|
|
607
811
|
/** Defaults to new Date(). */
|
|
608
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
|
+
};
|
|
609
821
|
}
|
|
610
822
|
|
|
611
823
|
/**
|
|
@@ -617,7 +829,11 @@ export interface VerifySubdelegationInput extends VerifyBase {
|
|
|
617
829
|
export async function verifySubdelegation(
|
|
618
830
|
input: VerifySubdelegationInput
|
|
619
831
|
): Promise<VerifySubdelegationResult> {
|
|
620
|
-
|
|
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);
|
|
621
837
|
if (!r.ok) return r;
|
|
622
838
|
|
|
623
839
|
if (!input.skipTemporalCheck) {
|