@agenticprimitives/delegation 1.0.0-alpha.4 → 1.0.0-alpha.40
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 +19 -2
- package/dist/authority-chain.d.ts +59 -0
- package/dist/authority-chain.d.ts.map +1 -0
- package/dist/authority-chain.js +70 -0
- package/dist/authority-chain.js.map +1 -0
- package/dist/caveats.d.ts +196 -1
- package/dist/caveats.d.ts.map +1 -1
- package/dist/caveats.js +374 -2
- package/dist/caveats.js.map +1 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +2 -2
- package/dist/client.js.map +1 -1
- package/dist/evaluator.d.ts +1 -1
- package/dist/evaluator.d.ts.map +1 -1
- package/dist/evaluator.js +102 -2
- package/dist/evaluator.js.map +1 -1
- package/dist/hash.d.ts +1 -1
- package/dist/hash.d.ts.map +1 -1
- package/dist/index.d.ts +38 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +43 -8
- package/dist/index.js.map +1 -1
- package/dist/invocation-proof.d.ts +133 -0
- package/dist/invocation-proof.d.ts.map +1 -0
- package/dist/invocation-proof.js +157 -0
- package/dist/invocation-proof.js.map +1 -0
- package/dist/lineage.d.ts +70 -0
- package/dist/lineage.d.ts.map +1 -0
- package/dist/lineage.js +65 -0
- package/dist/lineage.js.map +1 -0
- package/dist/mandate-handlers.d.ts +15 -0
- package/dist/mandate-handlers.d.ts.map +1 -0
- package/dist/mandate-handlers.js +279 -0
- package/dist/mandate-handlers.js.map +1 -0
- package/dist/mandate.d.ts +265 -0
- package/dist/mandate.d.ts.map +1 -0
- package/dist/mandate.js +275 -0
- package/dist/mandate.js.map +1 -0
- package/dist/method-authorization.d.ts +120 -0
- package/dist/method-authorization.d.ts.map +1 -0
- package/dist/method-authorization.js +173 -0
- package/dist/method-authorization.js.map +1 -0
- package/dist/onchain.d.ts +40 -4
- package/dist/onchain.d.ts.map +1 -1
- package/dist/onchain.js +140 -6
- package/dist/onchain.js.map +1 -1
- package/dist/presentation-release.d.ts +65 -0
- package/dist/presentation-release.d.ts.map +1 -0
- package/dist/presentation-release.js +81 -0
- package/dist/presentation-release.js.map +1 -0
- package/dist/quorum.d.ts +26 -0
- package/dist/quorum.d.ts.map +1 -0
- package/dist/quorum.js +80 -0
- package/dist/quorum.js.map +1 -0
- package/dist/session-delegation.d.ts +34 -0
- package/dist/session-delegation.d.ts.map +1 -0
- package/dist/session-delegation.js +33 -0
- package/dist/session-delegation.js.map +1 -0
- package/dist/session-manager.d.ts +1 -2
- package/dist/session-manager.d.ts.map +1 -1
- package/dist/session-manager.js +15 -6
- package/dist/session-manager.js.map +1 -1
- package/dist/standing.d.ts +101 -0
- package/dist/standing.d.ts.map +1 -0
- package/dist/standing.js +132 -0
- package/dist/standing.js.map +1 -0
- package/dist/token.d.ts +89 -1
- package/dist/token.d.ts.map +1 -1
- package/dist/token.js +485 -123
- package/dist/token.js.map +1 -1
- package/dist/types.d.ts +141 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +7 -1
- package/dist/types.js.map +1 -1
- package/dist/verify-authorization.d.ts +28 -0
- package/dist/verify-authorization.d.ts.map +1 -0
- package/dist/verify-authorization.js +73 -0
- package/dist/verify-authorization.js.map +1 -0
- package/package.json +25 -16
package/dist/token.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ERC1271_MAGIC_VALUE as ERC1271_MAGIC } from '@agenticprimitives/types';
|
|
1
2
|
// DelegationToken envelope (per spec 202 §4):
|
|
2
3
|
// base64url(canonicalJSON(claims)) + '.' + base64url(sessionKeySig)
|
|
3
4
|
//
|
|
@@ -10,30 +11,58 @@
|
|
|
10
11
|
// 6. evaluateCaveats (fail-closed) — pass toolName via opts for tool-scope
|
|
11
12
|
// 7. atomic JTI usage tracking
|
|
12
13
|
// 8. return { principal: delegator }
|
|
13
|
-
import { secp256k1 } from '@noble/curves/secp256k1';
|
|
14
|
-
import { keccak_256 } from '@noble/hashes/sha3';
|
|
14
|
+
import { secp256k1 } from '@noble/curves/secp256k1.js';
|
|
15
|
+
import { keccak_256 } from '@noble/hashes/sha3.js';
|
|
15
16
|
import { createPublicClient, http } from 'viem';
|
|
16
|
-
import { hashDelegation } from './hash';
|
|
17
|
-
import { evaluateCaveats } from './evaluator';
|
|
17
|
+
import { hashDelegation } from './hash.js';
|
|
18
|
+
import { evaluateCaveats } from './evaluator.js';
|
|
19
|
+
import { DATA_SCOPE_ENFORCER, decodeDataScopeTerms, VAULT_RECORD_SCOPE_ENFORCER, decodeVaultRecordScopeTerms, readSessionAudience } from './caveats.js';
|
|
20
|
+
import { verifyUniversalSignature } from './onchain.js';
|
|
21
|
+
import { verifyOffChainQuorum } from './quorum.js';
|
|
18
22
|
import { buildEvent } from '@agenticprimitives/audit';
|
|
19
23
|
// ─── base64url + canonical JSON helpers ──────────────────────────────────
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
//
|
|
25
|
+
// ISOMORPHIC base64 — `btoa`/`atob` + `TextEncoder/Decoder` are available in every target runtime
|
|
26
|
+
// (browser, Cloudflare Workers, Node ≥16). Node's `Buffer` is NOT defined in the browser, and demo-jp
|
|
27
|
+
// now mints tokens CLIENT-SIDE (spec 270 v4 client-mint), so a `Buffer.from(...)` here threw
|
|
28
|
+
// `ReferenceError: Buffer is not defined` before any network call. The byte output is identical to the
|
|
29
|
+
// old Buffer path (standard base64), so existing signatures still verify — byte-stability is a SECURITY
|
|
30
|
+
// invariant (see `canonicalJSON`), and standard base64 is the same across all runtimes.
|
|
31
|
+
function bytesToBase64url(bytes) {
|
|
32
|
+
let bin = '';
|
|
33
|
+
for (let i = 0; i < bytes.length; i++)
|
|
34
|
+
bin += String.fromCharCode(bytes[i]);
|
|
35
|
+
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
22
36
|
}
|
|
23
|
-
function
|
|
37
|
+
function base64urlToBytes(s) {
|
|
24
38
|
let padded = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
25
39
|
while (padded.length % 4)
|
|
26
40
|
padded += '=';
|
|
27
|
-
|
|
41
|
+
const bin = atob(padded);
|
|
42
|
+
const out = new Uint8Array(bin.length);
|
|
43
|
+
for (let i = 0; i < bin.length; i++)
|
|
44
|
+
out[i] = bin.charCodeAt(i);
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
function base64urlEncode(s) {
|
|
48
|
+
return bytesToBase64url(new TextEncoder().encode(s));
|
|
49
|
+
}
|
|
50
|
+
function base64urlDecodeStr(s) {
|
|
51
|
+
return new TextDecoder().decode(base64urlToBytes(s));
|
|
28
52
|
}
|
|
29
53
|
function base64urlEncodeBytes(b) {
|
|
30
|
-
return
|
|
54
|
+
return bytesToBase64url(b);
|
|
31
55
|
}
|
|
32
56
|
function base64urlDecodeBytes(s) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
57
|
+
return base64urlToBytes(s);
|
|
58
|
+
}
|
|
59
|
+
/** Isomorphic hex → bytes (replaces `Buffer.from(hex, 'hex')`, which is Node-only). */
|
|
60
|
+
function hexToBytes(hex) {
|
|
61
|
+
const clean = hex.startsWith('0x') ? hex.slice(2) : hex;
|
|
62
|
+
const out = new Uint8Array(clean.length >> 1);
|
|
63
|
+
for (let i = 0; i < out.length; i++)
|
|
64
|
+
out[i] = parseInt(clean.substr(i * 2, 2), 16);
|
|
65
|
+
return out;
|
|
37
66
|
}
|
|
38
67
|
/** Sorted-key JSON; BigInt → numeric string. Both sides must produce the
|
|
39
68
|
* exact same bytes for the signature to round-trip.
|
|
@@ -44,7 +73,19 @@ function base64urlDecodeBytes(s) {
|
|
|
44
73
|
* claims hash to different signatures, breaking token verification
|
|
45
74
|
* silently. */
|
|
46
75
|
export function canonicalJSON(value) {
|
|
47
|
-
|
|
76
|
+
// The cycle guard tracks the ANCESTOR PATH, not everything ever visited. A value reachable twice through
|
|
77
|
+
// DIFFERENT branches is a shared reference — ordinary JSON that must serialize twice — while only a value
|
|
78
|
+
// containing ITSELF is circular. A visited-set conflates the two: it rejected a payload holding a card
|
|
79
|
+
// release beside the draft it was cut from (the release shallow-copies the card, so both share the same
|
|
80
|
+
// nested `capabilities` / `skills` / `supportedInterfaces` objects), which surfaced to the user as
|
|
81
|
+
// "circular reference" on a first release. Arrays are guarded too — an array containing itself would
|
|
82
|
+
// otherwise recurse until the stack died, which is a worse failure than the error it should raise.
|
|
83
|
+
const path = new WeakSet();
|
|
84
|
+
const enter = (v) => {
|
|
85
|
+
if (path.has(v))
|
|
86
|
+
throw new Error('canonicalJSON: circular reference');
|
|
87
|
+
path.add(v);
|
|
88
|
+
};
|
|
48
89
|
const stringify = (v) => {
|
|
49
90
|
if (v === null)
|
|
50
91
|
return 'null';
|
|
@@ -57,16 +98,19 @@ export function canonicalJSON(value) {
|
|
|
57
98
|
if (typeof v === 'string')
|
|
58
99
|
return JSON.stringify(v);
|
|
59
100
|
if (Array.isArray(v)) {
|
|
60
|
-
|
|
101
|
+
enter(v);
|
|
102
|
+
const out = '[' + v.map((x) => (x === undefined ? 'null' : stringify(x))).join(',') + ']';
|
|
103
|
+
path.delete(v);
|
|
104
|
+
return out;
|
|
61
105
|
}
|
|
62
106
|
if (typeof v === 'object') {
|
|
63
|
-
if (seen.has(v))
|
|
64
|
-
throw new Error('canonicalJSON: circular reference');
|
|
65
|
-
seen.add(v);
|
|
66
107
|
const obj = v;
|
|
108
|
+
enter(obj);
|
|
67
109
|
// Drop undefined keys (standard JSON behavior).
|
|
68
110
|
const keys = Object.keys(obj).filter((k) => obj[k] !== undefined).sort();
|
|
69
|
-
|
|
111
|
+
const out = '{' + keys.map((k) => JSON.stringify(k) + ':' + stringify(obj[k])).join(',') + '}';
|
|
112
|
+
path.delete(obj);
|
|
113
|
+
return out;
|
|
70
114
|
}
|
|
71
115
|
throw new Error(`canonicalJSON: unsupported type ${typeof v}`);
|
|
72
116
|
};
|
|
@@ -90,7 +134,7 @@ function recoverEoaFromEip191Sig(message, signature) {
|
|
|
90
134
|
const recovery = v >= 27 ? v - 27 : v;
|
|
91
135
|
const eipSig = new secp256k1.Signature(r, s).addRecoveryBit(recovery);
|
|
92
136
|
const digest = eip191Digest(message);
|
|
93
|
-
const pub = eipSig.recoverPublicKey(digest).
|
|
137
|
+
const pub = eipSig.recoverPublicKey(digest).toBytes(false);
|
|
94
138
|
const hash = keccak_256(pub.slice(1));
|
|
95
139
|
let hex = '0x';
|
|
96
140
|
for (const b of hash.slice(12))
|
|
@@ -148,6 +192,8 @@ export async function mintDelegationToken(claims, signMessage, opts) {
|
|
|
148
192
|
sub: claims.sub,
|
|
149
193
|
delegation: claims.delegation,
|
|
150
194
|
sessionKeyAddress: claims.sessionKeyAddress,
|
|
195
|
+
// DEL-001: carry the session-delegate-binding leaf when the minter produced one.
|
|
196
|
+
...(claims.sessionDelegation ? { sessionDelegation: claims.sessionDelegation } : {}),
|
|
151
197
|
jti,
|
|
152
198
|
iat,
|
|
153
199
|
exp,
|
|
@@ -156,31 +202,31 @@ export async function mintDelegationToken(claims, signMessage, opts) {
|
|
|
156
202
|
const canonical = canonicalJSON(full);
|
|
157
203
|
const sig = await signMessage(canonical);
|
|
158
204
|
const sigHex = sig.startsWith('0x') ? sig.slice(2) : sig;
|
|
159
|
-
const sigBytes =
|
|
205
|
+
const sigBytes = hexToBytes(sigHex);
|
|
160
206
|
const token = `${base64urlEncode(canonical)}.${base64urlEncodeBytes(sigBytes)}`;
|
|
161
|
-
// Audit emit (C3 pass 3c).
|
|
207
|
+
// Audit emit (C3 pass 3c). R11.1 / N16: NO try/catch around the sink
|
|
208
|
+
// write — the sink composition (`composeSinks` fail-soft vs
|
|
209
|
+
// `composeFailHardSinks` fail-hard) expresses the caller's intent.
|
|
210
|
+
// For production callers using fail-hard sinks, a `delegation.mint`
|
|
211
|
+
// emission failure aborts the mint flow — the token is NOT returned
|
|
212
|
+
// without a recorded audit row. JTI is logged because it's the
|
|
162
213
|
// correlation primitive for downstream verify events — not secret.
|
|
163
214
|
if (opts?.auditSink) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}));
|
|
180
|
-
}
|
|
181
|
-
catch {
|
|
182
|
-
/* fail-soft */
|
|
183
|
-
}
|
|
215
|
+
await opts.auditSink.write(buildEvent({
|
|
216
|
+
action: 'delegation.mint',
|
|
217
|
+
outcome: 'success',
|
|
218
|
+
correlationId: opts.correlationId,
|
|
219
|
+
actor: { type: 'user', id: claims.delegation.delegator },
|
|
220
|
+
subject: { type: 'jti', id: jti },
|
|
221
|
+
audience: claims.aud,
|
|
222
|
+
context: {
|
|
223
|
+
iss: claims.iss,
|
|
224
|
+
sub: claims.sub,
|
|
225
|
+
sessionKeyAddress: claims.sessionKeyAddress,
|
|
226
|
+
ttlSeconds: ttl,
|
|
227
|
+
usageLimit: claims.usageLimit ?? null,
|
|
228
|
+
},
|
|
229
|
+
}));
|
|
184
230
|
}
|
|
185
231
|
return { token, jti };
|
|
186
232
|
}
|
|
@@ -206,16 +252,34 @@ const ACCOUNT_ABI_ERC1271 = [
|
|
|
206
252
|
outputs: [{ name: '', type: 'bytes4' }],
|
|
207
253
|
},
|
|
208
254
|
];
|
|
255
|
+
/** spec 270 v4 — the deployed UniversalSignatureValidator: ONE connection-agnostic surface that validates
|
|
256
|
+
* a signature for a deployed SA (ERC-1271), a counterfactual SA (ERC-6492 deploy-on-verify), or a raw EOA
|
|
257
|
+
* (ECDSA). `isValidSig` is state-changing on-chain (the 6492 path may deploy), but we call it via
|
|
258
|
+
* `eth_call` — marking it `view` here makes viem `readContract` execute it ephemerally + decode the bool,
|
|
259
|
+
* the standard off-chain ERC-6492 pattern. Routing the delegation + leaf checks through this is what makes
|
|
260
|
+
* the verifier never branch on HOW the user connected — every credential strategy validates via one surface. */
|
|
261
|
+
const UNIVERSAL_SIG_VALIDATOR_ABI = [
|
|
262
|
+
{
|
|
263
|
+
type: 'function',
|
|
264
|
+
name: 'isValidSig',
|
|
265
|
+
stateMutability: 'view',
|
|
266
|
+
inputs: [
|
|
267
|
+
{ name: 'signer', type: 'address' },
|
|
268
|
+
{ name: 'hash', type: 'bytes32' },
|
|
269
|
+
{ name: 'sig', type: 'bytes' },
|
|
270
|
+
],
|
|
271
|
+
outputs: [{ name: '', type: 'bool' }],
|
|
272
|
+
},
|
|
273
|
+
];
|
|
209
274
|
const ACCEPTED_SESSION_DELEGATION_ABI = [
|
|
210
275
|
{
|
|
211
276
|
type: 'function',
|
|
212
|
-
name: '
|
|
277
|
+
name: 'hasAcceptedSessionDelegation',
|
|
213
278
|
stateMutability: 'view',
|
|
214
279
|
inputs: [{ name: 'sessionDelegationHash', type: 'bytes32' }],
|
|
215
280
|
outputs: [{ name: '', type: 'bool' }],
|
|
216
281
|
},
|
|
217
282
|
];
|
|
218
|
-
const ERC1271_MAGIC = '0x1626ba7e';
|
|
219
283
|
function decodeToken(token) {
|
|
220
284
|
const parts = token.split('.');
|
|
221
285
|
if (parts.length !== 2)
|
|
@@ -239,6 +303,37 @@ function decodeToken(token) {
|
|
|
239
303
|
sigHex += b.toString(16).padStart(2, '0');
|
|
240
304
|
return { claims, canonical, signature: sigHex };
|
|
241
305
|
}
|
|
306
|
+
/**
|
|
307
|
+
* DEL-001 (spec 270 v4) — the chain-free part of the session-key binding (testable without an RPC).
|
|
308
|
+
* Returns an error reason, or `null` when the leaf links correctly: `sessionDelegation` present, its
|
|
309
|
+
* delegator is the outer delegation's DELEGATOR (the principal's SA — the canonical identity every
|
|
310
|
+
* connection strategy signs for), and its delegate is the presenting session key. The signature proof
|
|
311
|
+
* (the delegator SA authorized the leaf — validated via the UniversalSignatureValidator, so any
|
|
312
|
+
* credential type works) is checked separately in `verifyDelegationToken`.
|
|
313
|
+
*
|
|
314
|
+
* v4 binds to the DELEGATOR (was the delegate in v3): the SA's own authority — recoverable at connect
|
|
315
|
+
* under every credential strategy alike — vouches for the session key.
|
|
316
|
+
*/
|
|
317
|
+
export function sessionDelegateBindingError(delegation, sessionDelegation, sessionKeyAddress) {
|
|
318
|
+
if (!sessionDelegation)
|
|
319
|
+
return 'session-delegation required (DEL-001)';
|
|
320
|
+
if (sessionDelegation.delegator.toLowerCase() !== delegation.delegator.toLowerCase()) {
|
|
321
|
+
return 'session-delegation delegator != delegation delegator (DEL-001)';
|
|
322
|
+
}
|
|
323
|
+
if (sessionDelegation.delegate.toLowerCase() !== sessionKeyAddress.toLowerCase()) {
|
|
324
|
+
return 'session key is not the session-delegation delegate (DEL-001)';
|
|
325
|
+
}
|
|
326
|
+
// Spec 408 §2.1 (R917-P-2) — THE ROOT'S DELEGATE IS BOUND. The leaf names the delegates this session key
|
|
327
|
+
// may present the principal's delegations to; a delegation to anyone else is refused, however valid it
|
|
328
|
+
// is on its own. A leaf that names nobody is refused outright: no knob re-admits the old shape.
|
|
329
|
+
const audience = readSessionAudience(sessionDelegation.caveats);
|
|
330
|
+
if (!audience)
|
|
331
|
+
return 'session-delegation names no audience — a leaf must say which delegates it may present to (spec 408 §2.1)';
|
|
332
|
+
if (!audience.includes(delegation.delegate.toLowerCase())) {
|
|
333
|
+
return `session-delegation does not admit presenting to ${delegation.delegate} — the leaf was issued for other delegates (spec 408 §2.1)`;
|
|
334
|
+
}
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
242
337
|
/**
|
|
243
338
|
* Full verification pipeline. Caller passes `toolName` in opts for tool-scope
|
|
244
339
|
* caveat evaluation; without it the mcp-tool-scope caveat denies.
|
|
@@ -249,27 +344,24 @@ export async function verifyDelegationToken(token, opts) {
|
|
|
249
344
|
const emit = async (outcome, reason, principal, delegationDigest) => {
|
|
250
345
|
if (!opts.auditSink)
|
|
251
346
|
return;
|
|
252
|
-
try
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
catch {
|
|
271
|
-
/* fail-soft */
|
|
272
|
-
}
|
|
347
|
+
// R11.1 / N16: NO try/catch — sink composition determines fail-hard
|
|
348
|
+
// vs fail-soft. See `mintDelegationToken` doc-comment for rationale.
|
|
349
|
+
await opts.auditSink.write(buildEvent({
|
|
350
|
+
action: outcome === 'success'
|
|
351
|
+
? 'delegation.verify.accept'
|
|
352
|
+
: 'delegation.verify.reject',
|
|
353
|
+
outcome,
|
|
354
|
+
correlationId: opts.correlationId,
|
|
355
|
+
actor: principal ? { type: 'user', id: principal } : { type: 'unknown' },
|
|
356
|
+
subject: delegationDigest
|
|
357
|
+
? { type: 'delegation', id: delegationDigest }
|
|
358
|
+
: undefined,
|
|
359
|
+
audience: opts.audience,
|
|
360
|
+
chainId: opts.chainId,
|
|
361
|
+
digest: delegationDigest,
|
|
362
|
+
reason,
|
|
363
|
+
context: opts.toolName ? { tool: opts.toolName } : undefined,
|
|
364
|
+
}));
|
|
273
365
|
};
|
|
274
366
|
const rejectWith = async (reason, principal, delegationDigest) => {
|
|
275
367
|
await emit('denied', reason, principal, delegationDigest);
|
|
@@ -297,6 +389,14 @@ export async function verifyDelegationToken(token, opts) {
|
|
|
297
389
|
}
|
|
298
390
|
// 2. EIP-712 hash
|
|
299
391
|
const eip712Hash = hashDelegation(claims.delegation, opts.chainId, opts.delegationManager);
|
|
392
|
+
// NEW-DELEGATION-JTI-AMP-1: short-circuit a REPLAYED token (already at its usage limit) BEFORE the on-chain
|
|
393
|
+
// read set (sig + isRevoked + acceptance) — otherwise a replay amplifies each verify into several eth_calls
|
|
394
|
+
// (DoS) only to be rejected by the JTI dedup below. Read-only peek; a store without it keeps the old order.
|
|
395
|
+
if (claims.jti && opts.jtiStore.peekConsumed) {
|
|
396
|
+
if (await opts.jtiStore.peekConsumed(claims.jti, claims.usageLimit ?? 10)) {
|
|
397
|
+
return rejectWith('jti already consumed');
|
|
398
|
+
}
|
|
399
|
+
}
|
|
300
400
|
// 3. on-chain checks
|
|
301
401
|
const requireDeployed = opts.requireDeployed ?? true;
|
|
302
402
|
// H7-B.9 / XPKG-005: default fail-CLOSED regardless of runtime. The
|
|
@@ -305,46 +405,210 @@ export async function verifyDelegationToken(token, opts) {
|
|
|
305
405
|
// permissive behavior MUST set `revocationFailMode: 'open'` explicitly.
|
|
306
406
|
const revocationFailMode = opts.revocationFailMode ?? 'closed';
|
|
307
407
|
const publicClient = createPublicClient({ transport: http(opts.rpcUrl) });
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
408
|
+
// spec 270 v4 — validate an SA's signature through the UniversalSignatureValidator (one surface for
|
|
409
|
+
// ERC-1271 / ERC-6492 / ECDSA), called via eth_call. `null` when no USV is configured (legacy path).
|
|
410
|
+
const validateSaSig = opts.universalSignatureValidator
|
|
411
|
+
? async (signer, hash, sig) => (await publicClient.readContract({
|
|
412
|
+
address: opts.universalSignatureValidator,
|
|
413
|
+
abi: UNIVERSAL_SIG_VALIDATOR_ABI,
|
|
414
|
+
functionName: 'isValidSig',
|
|
415
|
+
args: [signer, hash, sig],
|
|
416
|
+
}))
|
|
417
|
+
: null;
|
|
418
|
+
// spec 289 §5 (W3) — validate an SA signature through the resilient, evidence-bearing port when a
|
|
419
|
+
// `chainSignatureReader` is injected. The port surfaces FACTS ({valid, deployed}); this closure keeps
|
|
420
|
+
// the POLICY: fail-closed/throw → reject (signature is critical); !valid → reject; requireDeployed &&
|
|
421
|
+
// !deployed → reject (counterfactual needs explicit requireDeployed=false). Returns null on success,
|
|
422
|
+
// else a rejection reason. Used by BOTH signature sites below.
|
|
423
|
+
const validateSaSigViaReader = async (signer, hash, sig) => {
|
|
424
|
+
let valid = false;
|
|
425
|
+
let deployed = false;
|
|
426
|
+
let failClosed = false;
|
|
427
|
+
try {
|
|
428
|
+
const r = await opts.chainSignatureReader.verifySmartAgentSignature({ signer, digest: hash, signature: sig });
|
|
429
|
+
valid = r.valid;
|
|
430
|
+
deployed = r.deployed;
|
|
431
|
+
failClosed = r.evidence?.source?.startsWith('fail-closed') ?? false;
|
|
317
432
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
return rejectWith('revocation check unavailable; refusing to verify (set revocationFailMode=open only for explicit dev/demo paths)', claims.delegation.delegator, eip712Hash);
|
|
433
|
+
catch {
|
|
434
|
+
// A throwing reader is itself an unconfirmable signature check.
|
|
435
|
+
failClosed = true;
|
|
322
436
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
437
|
+
if (failClosed)
|
|
438
|
+
return 'signature check unavailable; refusing to verify';
|
|
439
|
+
if (!valid)
|
|
440
|
+
return 'signature validation failed';
|
|
441
|
+
if (requireDeployed && !deployed) {
|
|
442
|
+
return 'delegator smart account is not deployed — cannot honor requireDeployed. Set verifyDelegationToken opt requireDeployed=false only for explicit counterfactual-demo use cases.';
|
|
443
|
+
}
|
|
444
|
+
return null;
|
|
445
|
+
};
|
|
446
|
+
// DEL-002 (seam audit): the SAME fail-closed revocation check, applied to a given delegation hash so it
|
|
447
|
+
// can gate BOTH the root delegation AND the DEL-001 session-delegation LEAF. Returns a rejection reason
|
|
448
|
+
// or null. spec 289 §5: the resilient reader is fail-closed by construction (an unconfirmable read comes
|
|
449
|
+
// back revoked:true with evidence.source='fail-closed:*'); map that to the inline read's RPC-throw —
|
|
450
|
+
// reject under 'closed', tolerate under 'open'.
|
|
451
|
+
const revocationRejection = async (hash) => {
|
|
452
|
+
if (opts.chainRevocationReader) {
|
|
453
|
+
let revoked = true;
|
|
454
|
+
let failClosed = false;
|
|
455
|
+
try {
|
|
456
|
+
const r = await opts.chainRevocationReader.isDelegationRevoked(hash);
|
|
457
|
+
revoked = r.revoked;
|
|
458
|
+
failClosed = r.evidence?.source?.startsWith('fail-closed') ?? false;
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
revoked = true;
|
|
462
|
+
failClosed = true;
|
|
331
463
|
}
|
|
332
|
-
|
|
464
|
+
if (revoked) {
|
|
465
|
+
if (failClosed && revocationFailMode === 'open')
|
|
466
|
+
return null; // dev/demo tolerates an unconfirmable read
|
|
467
|
+
return failClosed
|
|
468
|
+
? 'revocation check unavailable; refusing to verify (set revocationFailMode=open only for explicit dev/demo paths)'
|
|
469
|
+
: 'delegation revoked';
|
|
470
|
+
}
|
|
471
|
+
return null;
|
|
333
472
|
}
|
|
334
|
-
|
|
473
|
+
try {
|
|
474
|
+
const revoked = (await publicClient.readContract({
|
|
475
|
+
address: opts.delegationManager,
|
|
476
|
+
abi: DELEGATION_MANAGER_ABI,
|
|
477
|
+
functionName: 'isRevoked',
|
|
478
|
+
args: [hash],
|
|
479
|
+
}));
|
|
480
|
+
return revoked ? 'delegation revoked' : null;
|
|
481
|
+
}
|
|
482
|
+
catch (e) {
|
|
483
|
+
void e;
|
|
484
|
+
return revocationFailMode === 'closed'
|
|
485
|
+
? 'revocation check unavailable; refusing to verify (set revocationFailMode=open only for explicit dev/demo paths)'
|
|
486
|
+
: null;
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
// THE ROOT'S SIGNATURE — one of three surfaces, the same policy. Returns a rejection reason or null.
|
|
490
|
+
const rootSignatureRejection = async () => {
|
|
491
|
+
if (opts.chainSignatureReader) {
|
|
492
|
+
// spec 289 §5 (W3) — main delegator signature through the resilient port.
|
|
493
|
+
return validateSaSigViaReader(claims.delegation.delegator, eip712Hash, claims.delegation.signature);
|
|
494
|
+
}
|
|
495
|
+
if (validateSaSig) {
|
|
496
|
+
// spec 270 v4 — one connection-agnostic surface; counterfactual delegators verify via ERC-6492, so
|
|
497
|
+
// no requireDeployed hard-reject.
|
|
498
|
+
try {
|
|
499
|
+
if (!(await validateSaSig(claims.delegation.delegator, eip712Hash, claims.delegation.signature))) {
|
|
500
|
+
return 'signature validation failed';
|
|
501
|
+
}
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
catch (e) {
|
|
505
|
+
return `signature validation reverted: ${e instanceof Error ? e.message : e}`;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
try {
|
|
509
|
+
const code = await publicClient.getCode({ address: claims.delegation.delegator });
|
|
510
|
+
const isDeployed = !!(code && code !== '0x' && code.length > 2);
|
|
511
|
+
if (!isDeployed) {
|
|
512
|
+
if (requireDeployed) {
|
|
513
|
+
return `delegator smart account ${claims.delegation.delegator} is not deployed — cannot verify ERC-1271. Set verifyDelegationToken opt requireDeployed=false only for explicit counterfactual-demo use cases.`;
|
|
514
|
+
}
|
|
515
|
+
return null; // Tolerated: caller opted into the undeployed path explicitly.
|
|
516
|
+
}
|
|
335
517
|
const magic = (await publicClient.readContract({
|
|
336
518
|
address: claims.delegation.delegator,
|
|
337
519
|
abi: ACCOUNT_ABI_ERC1271,
|
|
338
520
|
functionName: 'isValidSignature',
|
|
339
521
|
args: [eip712Hash, claims.delegation.signature],
|
|
340
522
|
}));
|
|
341
|
-
|
|
342
|
-
|
|
523
|
+
return magic.toLowerCase() !== ERC1271_MAGIC ? 'erc1271 validation failed' : null;
|
|
524
|
+
}
|
|
525
|
+
catch (e) {
|
|
526
|
+
return `erc1271 call reverted: ${e instanceof Error ? e.message : e}`;
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
// 3.5. DEL-001 (spec 270 v4) — session-key↔identity binding.
|
|
530
|
+
//
|
|
531
|
+
// The full signed `delegation` travels in cleartext inside every token, so a verifier that only
|
|
532
|
+
// checks "some session key signed these claims" lets anyone who OBSERVES a token re-mint it with their
|
|
533
|
+
// own session key and impersonate the delegator. We close that by requiring a `sessionDelegation` LEAF
|
|
534
|
+
// whose delegator is `delegation.delegator` (the principal's SA — the canonical identity, v4) and whose
|
|
535
|
+
// delegate IS the presenting session key, signed by that SA. The signature is validated through the
|
|
536
|
+
// UniversalSignatureValidator (ERC-1271 / ERC-6492 / ECDSA), so it works for ANY connection strategy and
|
|
537
|
+
// an attacker can't forge the SA's authorization of THEIR key. `principal` stays `delegation.delegator`.
|
|
538
|
+
// DEL-001 (ADR-0036): binding is ENFORCED BY DEFAULT — fail-closed. Only an explicit
|
|
539
|
+
// `allowUnboundSessionToken: true` (legacy / non-client-minted path) skips it.
|
|
540
|
+
//
|
|
541
|
+
// The chain-free binding checks (leaf present, delegator-link, delegate === presenting session key) are
|
|
542
|
+
// computed now and JUDGED after the root signature, where they always were; a leaf that fails them has
|
|
543
|
+
// no signature worth reading.
|
|
544
|
+
const bindErr = !opts.allowUnboundSessionToken ? sessionDelegateBindingError(claims.delegation, claims.sessionDelegation, recovered) : null;
|
|
545
|
+
const sd = !opts.allowUnboundSessionToken && !bindErr ? claims.sessionDelegation : null;
|
|
546
|
+
const sdHash = sd ? hashDelegation(sd, opts.chainId, opts.delegationManager) : null;
|
|
547
|
+
// The leaf's signature — the delegator SA (the principal's canonical identity) actually authorized THIS
|
|
548
|
+
// session key. Validated via the UniversalSignatureValidator when configured (v4 — connection-agnostic:
|
|
549
|
+
// every credential strategy passes through one surface), else legacy deployed-ERC-1271.
|
|
550
|
+
const leafSignatureRejection = async () => {
|
|
551
|
+
if (!sd || !sdHash)
|
|
552
|
+
return null;
|
|
553
|
+
try {
|
|
554
|
+
if (opts.chainSignatureReader) {
|
|
555
|
+
// spec 289 §5 (W3) — DEL-001 leaf signature through the resilient port.
|
|
556
|
+
const reason = await validateSaSigViaReader(sd.delegator, sdHash, sd.signature);
|
|
557
|
+
return reason ? `session-delegation ${reason} (DEL-001)` : null;
|
|
558
|
+
}
|
|
559
|
+
if (validateSaSig) {
|
|
560
|
+
return (await validateSaSig(sd.delegator, sdHash, sd.signature)) ? null : 'session-delegation signature validation failed (DEL-001)';
|
|
343
561
|
}
|
|
562
|
+
const sdCode = await publicClient.getCode({ address: sd.delegator });
|
|
563
|
+
if (!sdCode || sdCode === '0x' || sdCode.length <= 2) {
|
|
564
|
+
return requireDeployed ? 'session-delegation delegator SA not deployed — cannot verify ERC-1271 (DEL-001)' : null;
|
|
565
|
+
}
|
|
566
|
+
const magic = (await publicClient.readContract({
|
|
567
|
+
address: sd.delegator,
|
|
568
|
+
abi: ACCOUNT_ABI_ERC1271,
|
|
569
|
+
functionName: 'isValidSignature',
|
|
570
|
+
args: [sdHash, sd.signature],
|
|
571
|
+
}));
|
|
572
|
+
return magic.toLowerCase() !== ERC1271_MAGIC ? 'session-delegation erc1271 validation failed (DEL-001)' : null;
|
|
344
573
|
}
|
|
574
|
+
catch (e) {
|
|
575
|
+
return `session-delegation signature check reverted: ${e instanceof Error ? e.message : e}`;
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
// ONE ROUND TRIP, NOT FOUR. Root revocation, leaf revocation (DEL-002: the session-key binding is
|
|
579
|
+
// separately revocable and was once never checked — a revoked, e.g. compromised, session key is rejected
|
|
580
|
+
// as tightly as a revoked root), root signature and leaf signature are four independent reads; issued in
|
|
581
|
+
// series they were the verify's whole latency on every gated tool call — four provider round trips before
|
|
582
|
+
// a single record byte moved. They are issued together and JUDGED in the order they always were: root
|
|
583
|
+
// revocation, leaf revocation, root signature, the leaf's binding, leaf signature. A check that is not
|
|
584
|
+
// first to fail still ran, which it did before too; nothing is skipped and no verdict is weakened.
|
|
585
|
+
{
|
|
586
|
+
const leafRevocationHash = claims.sessionDelegation ? hashDelegation(claims.sessionDelegation, opts.chainId, opts.delegationManager) : null;
|
|
587
|
+
const [rootReason, leafReason, rootSig, leafSig] = await Promise.all([
|
|
588
|
+
revocationRejection(eip712Hash),
|
|
589
|
+
leafRevocationHash ? revocationRejection(leafRevocationHash) : Promise.resolve(null),
|
|
590
|
+
rootSignatureRejection(),
|
|
591
|
+
leafSignatureRejection(),
|
|
592
|
+
]);
|
|
593
|
+
if (rootReason)
|
|
594
|
+
return rejectWith(rootReason, claims.delegation.delegator, eip712Hash);
|
|
595
|
+
if (leafReason)
|
|
596
|
+
return rejectWith(`session-delegation leaf: ${leafReason}`, claims.delegation.delegator, leafRevocationHash);
|
|
597
|
+
if (rootSig)
|
|
598
|
+
return rejectWith(rootSig, claims.delegation.delegator, eip712Hash);
|
|
599
|
+
if (bindErr)
|
|
600
|
+
return rejectWith(bindErr, claims.delegation.delegator, eip712Hash);
|
|
601
|
+
if (leafSig)
|
|
602
|
+
return rejectWith(leafSig, claims.delegation.delegator, eip712Hash);
|
|
345
603
|
}
|
|
346
|
-
|
|
347
|
-
|
|
604
|
+
if (sd) {
|
|
605
|
+
// Spec 408 §2.1 — THE LEAF'S OWN CAVEATS ARE JUDGED, strictly: its window (an expired leaf is refused as a
|
|
606
|
+
// leaf, not discovered as a root), its value (a token presentation moves nothing: 0), its audience
|
|
607
|
+
// (shape here; membership above). Before this, the leaf's caveats were never read.
|
|
608
|
+
const leafVerdicts = evaluateCaveats(sd.caveats, { timestamp: now, value: 0n }, opts.enforcerMap, {});
|
|
609
|
+
const leafDenied = leafVerdicts.find((v) => !v.allowed);
|
|
610
|
+
if (leafDenied)
|
|
611
|
+
return rejectWith(`session-delegation caveat denied: ${leafDenied.reason ?? 'denied'} (DEL-001)`, claims.delegation.delegator, eip712Hash);
|
|
348
612
|
}
|
|
349
613
|
// 4. caveat evaluation
|
|
350
614
|
//
|
|
@@ -380,54 +644,113 @@ export async function verifyDelegationToken(token, opts) {
|
|
|
380
644
|
return rejectWith('quorum caveat present but no quorumProof supplied — refusing to assume on-chain enforcement', claims.delegation.delegator, eip712Hash);
|
|
381
645
|
}
|
|
382
646
|
if (opts.quorumProof.mode === 'off-chain') {
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
|
|
647
|
+
// Spec 291 §6b — verify the QuorumCaveat off-chain (threshold + authorized
|
|
648
|
+
// signers + signatures bound to THIS delegation's hash). For tools that
|
|
649
|
+
// never redeem on chain. Requires the UniversalSignatureValidator surface
|
|
650
|
+
// so any signer credential strategy (EOA / ERC-1271 / ERC-6492) works.
|
|
651
|
+
if (!opts.universalSignatureValidator) {
|
|
652
|
+
return rejectWith('quorum_off_chain_requires_universalSignatureValidator', claims.delegation.delegator, eip712Hash);
|
|
653
|
+
}
|
|
654
|
+
const usv = opts.universalSignatureValidator;
|
|
655
|
+
const quorumCaveat = claims.delegation.caveats.find((c) => c.enforcer.toLowerCase() === target);
|
|
656
|
+
if (!quorumCaveat) {
|
|
657
|
+
// Defensive — hasQuorum above already guaranteed presence.
|
|
658
|
+
return rejectWith('quorum caveat vanished', claims.delegation.delegator, eip712Hash);
|
|
659
|
+
}
|
|
660
|
+
const quorumResult = await verifyOffChainQuorum({
|
|
661
|
+
caveatTerms: quorumCaveat.terms,
|
|
662
|
+
proof: opts.quorumProof,
|
|
663
|
+
// Bind the quorum to THIS delegation (its EIP-712 hash) — replay onto a
|
|
664
|
+
// different delegation fails the payload-binding check.
|
|
665
|
+
expectedPayloadHash: eip712Hash,
|
|
666
|
+
verifySignature: (signer, digest, signature) => verifyUniversalSignature({ signer, digest, signature, rpcUrl: opts.rpcUrl, universalSignatureValidator: usv }),
|
|
667
|
+
});
|
|
668
|
+
if (!quorumResult.ok) {
|
|
669
|
+
return rejectWith(`quorum_off_chain: ${quorumResult.reason}`, claims.delegation.delegator, eip712Hash);
|
|
670
|
+
}
|
|
671
|
+
// Verified — fall through to the rest of the pipeline.
|
|
388
672
|
}
|
|
389
673
|
// mode === 'on-chain-redeemed' — caller asserts the call will
|
|
390
674
|
// pass through QuorumEnforcer.beforeHook on chain. Accept; the
|
|
391
675
|
// on-chain layer is responsible for the actual signature check.
|
|
392
676
|
}
|
|
393
|
-
// - requireAcceptedOnChain: account.
|
|
677
|
+
// - requireAcceptedOnChain: account.hasAcceptedSessionDelegation(hash)
|
|
394
678
|
// must return true. Spec § 6 high-risk gate. Single extra chain
|
|
395
679
|
// read; only fires when the tool-policy decision flips this on
|
|
396
680
|
// (typically critical-tier tools).
|
|
397
681
|
let acceptedOnChain = false;
|
|
398
682
|
if (opts.requireAcceptedOnChain) {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
683
|
+
if (opts.chainAcceptanceReader) {
|
|
684
|
+
// spec 289 §5 (W2) — acceptance through the resilient, evidence-bearing port. Inherently
|
|
685
|
+
// fail-closed: not-accepted OR an unconfirmable read (fail-closed evidence / throw) both reject,
|
|
686
|
+
// mapping the inline read's RPC-throw → reject (this gate has no 'open' mode — the tier requires it).
|
|
687
|
+
let failClosed = false;
|
|
688
|
+
try {
|
|
689
|
+
const r = await opts.chainAcceptanceReader.isSessionDelegationAccepted(claims.delegation.delegator, eip712Hash);
|
|
690
|
+
acceptedOnChain = r.accepted;
|
|
691
|
+
failClosed = r.evidence?.source?.startsWith('fail-closed') ?? false;
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
acceptedOnChain = false;
|
|
695
|
+
failClosed = true;
|
|
696
|
+
}
|
|
697
|
+
if (!acceptedOnChain) {
|
|
698
|
+
return rejectWith(failClosed
|
|
699
|
+
? 'acceptSessionDelegation check unavailable; refusing to verify'
|
|
700
|
+
: 'tier requires on-chain acceptSessionDelegation blessing; not present', claims.delegation.delegator, eip712Hash);
|
|
701
|
+
}
|
|
409
702
|
}
|
|
410
|
-
|
|
411
|
-
|
|
703
|
+
else {
|
|
704
|
+
try {
|
|
705
|
+
acceptedOnChain = (await publicClient.readContract({
|
|
706
|
+
address: claims.delegation.delegator,
|
|
707
|
+
abi: ACCEPTED_SESSION_DELEGATION_ABI,
|
|
708
|
+
functionName: 'hasAcceptedSessionDelegation',
|
|
709
|
+
args: [eip712Hash],
|
|
710
|
+
}));
|
|
711
|
+
}
|
|
712
|
+
catch (e) {
|
|
713
|
+
return rejectWith(`acceptSessionDelegation check reverted: ${e instanceof Error ? e.message : e}`, claims.delegation.delegator, eip712Hash);
|
|
714
|
+
}
|
|
715
|
+
if (!acceptedOnChain) {
|
|
716
|
+
return rejectWith('tier requires on-chain acceptSessionDelegation blessing; not present', claims.delegation.delegator, eip712Hash);
|
|
717
|
+
}
|
|
412
718
|
}
|
|
413
719
|
}
|
|
414
|
-
// 5. JTI replay
|
|
720
|
+
// 5. JTI replay + usage accounting.
|
|
415
721
|
const limit = claims.usageLimit ?? 10;
|
|
416
|
-
|
|
417
|
-
if (
|
|
418
|
-
|
|
722
|
+
let usageHandle;
|
|
723
|
+
if (opts.usageMode === 'reserve') {
|
|
724
|
+
// Two-phase (spec 290 §3): reserve in-flight now; caller commits on success,
|
|
725
|
+
// releases on a later reject so a post-verify failure doesn't burn a use.
|
|
726
|
+
if (!opts.jtiStore.reserveUsage) {
|
|
727
|
+
return rejectWith("usageMode='reserve' requires a JtiStore implementing reserveUsage", claims.delegation.delegator, eip712Hash);
|
|
728
|
+
}
|
|
729
|
+
const reserved = await opts.jtiStore.reserveUsage(claims.jti, limit);
|
|
730
|
+
if (!reserved.allowed) {
|
|
731
|
+
return rejectWith('token usage limit exceeded', claims.delegation.delegator, eip712Hash);
|
|
732
|
+
}
|
|
733
|
+
usageHandle = reserved.reservation;
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
const usage = await opts.jtiStore.trackUsage(claims.jti, limit);
|
|
737
|
+
if (!usage.allowed) {
|
|
738
|
+
return rejectWith('token usage limit exceeded', claims.delegation.delegator, eip712Hash);
|
|
739
|
+
}
|
|
419
740
|
}
|
|
420
741
|
// Accept emit — extended context to capture the threshold-policy
|
|
421
742
|
// outcome (`acceptedOnChain`) for forensics + dashboards. Reuses the
|
|
422
743
|
// same `emit` shape as the reject path but with extra context fields.
|
|
744
|
+
// R11.1 / N16: NO try/catch — sink composition determines fail-hard
|
|
745
|
+
// vs fail-soft.
|
|
423
746
|
if (opts.auditSink) {
|
|
747
|
+
const context = {};
|
|
748
|
+
if (opts.toolName)
|
|
749
|
+
context.tool = opts.toolName;
|
|
750
|
+
if (opts.requireAcceptedOnChain !== undefined) {
|
|
751
|
+
context.acceptedOnChain = acceptedOnChain;
|
|
752
|
+
}
|
|
424
753
|
try {
|
|
425
|
-
const context = {};
|
|
426
|
-
if (opts.toolName)
|
|
427
|
-
context.tool = opts.toolName;
|
|
428
|
-
if (opts.requireAcceptedOnChain !== undefined) {
|
|
429
|
-
context.acceptedOnChain = acceptedOnChain;
|
|
430
|
-
}
|
|
431
754
|
await opts.auditSink.write(buildEvent({
|
|
432
755
|
action: 'delegation.verify.accept',
|
|
433
756
|
outcome: 'success',
|
|
@@ -440,11 +763,50 @@ export async function verifyDelegationToken(token, opts) {
|
|
|
440
763
|
context: Object.keys(context).length > 0 ? context : undefined,
|
|
441
764
|
}));
|
|
442
765
|
}
|
|
443
|
-
catch {
|
|
444
|
-
|
|
766
|
+
catch (e) {
|
|
767
|
+
// A fail-hard audit sink throwing AFTER we reserved must not leak the
|
|
768
|
+
// in-flight slot — release before propagating (spec 290 §3).
|
|
769
|
+
if (usageHandle) {
|
|
770
|
+
try {
|
|
771
|
+
await usageHandle.release();
|
|
772
|
+
}
|
|
773
|
+
catch { /* best-effort */ }
|
|
774
|
+
}
|
|
775
|
+
throw e;
|
|
445
776
|
}
|
|
446
777
|
}
|
|
447
|
-
|
|
778
|
+
// Off-chain DATA_SCOPE caveats (field-scoping, spec 291-A): decode any present into `grants` so the
|
|
779
|
+
// resource server (the vault/MCP runtime) can RESTRICT the field projection to what the issuer granted.
|
|
780
|
+
// The on-chain evaluator treats the sentinel as inert; enforcement is here, off-chain (ADR-0041 — the
|
|
781
|
+
// scope rides in the SAME signed delegation that conveys authority, so it can't be replayed without it).
|
|
782
|
+
// Authentic (decoded only AFTER the signature verified) — a malformed scope in a signed delegation throws.
|
|
783
|
+
const grants = [];
|
|
784
|
+
// Off-chain VAULT_RECORD_SCOPE caveats (spec 317 §3.2): decode any present into `recordScopes` so the
|
|
785
|
+
// resource server gates each vault OP (resource + op) against them, deny-by-default when present. Same
|
|
786
|
+
// authenticity guarantee as DATA_SCOPE — decoded only AFTER the signature verified, so it cannot be
|
|
787
|
+
// stripped or forged without invalidating the delegation.
|
|
788
|
+
const recordScopes = [];
|
|
789
|
+
for (const cav of claims.delegation.caveats ?? []) {
|
|
790
|
+
const enforcer = cav.enforcer.toLowerCase();
|
|
791
|
+
if (enforcer === DATA_SCOPE_ENFORCER.toLowerCase()) {
|
|
792
|
+
grants.push(...decodeDataScopeTerms(cav.terms));
|
|
793
|
+
}
|
|
794
|
+
else if (enforcer === VAULT_RECORD_SCOPE_ENFORCER.toLowerCase()) {
|
|
795
|
+
recordScopes.push(...decodeVaultRecordScopeTerms(cav.terms));
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
// `sessionKeyAddress` is surfaced (additive) so callers can cross-check a
|
|
799
|
+
// per-call proof's signer against the bound session key (spec 287 §6). It
|
|
800
|
+
// equals `recovered` (verified above to match `claims.sessionKeyAddress`).
|
|
801
|
+
// `usage` is present only in reserve mode — the caller commits on success,
|
|
802
|
+
// releases on a later reject (spec 290 §3).
|
|
803
|
+
return {
|
|
804
|
+
principal: claims.delegation.delegator,
|
|
805
|
+
sessionKeyAddress: claims.sessionKeyAddress,
|
|
806
|
+
...(grants.length ? { grants } : {}),
|
|
807
|
+
...(recordScopes.length ? { recordScopes } : {}),
|
|
808
|
+
...(usageHandle ? { usage: usageHandle } : {}),
|
|
809
|
+
};
|
|
448
810
|
}
|
|
449
811
|
// H7-B.8 (XPKG-002 / EXT-024 closure) — `verifyCrossDelegation` was a
|
|
450
812
|
// public stub that unconditionally returned a "not implemented" error
|