@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.
Files changed (80) hide show
  1. package/README.md +19 -2
  2. package/dist/authority-chain.d.ts +59 -0
  3. package/dist/authority-chain.d.ts.map +1 -0
  4. package/dist/authority-chain.js +70 -0
  5. package/dist/authority-chain.js.map +1 -0
  6. package/dist/caveats.d.ts +196 -1
  7. package/dist/caveats.d.ts.map +1 -1
  8. package/dist/caveats.js +374 -2
  9. package/dist/caveats.js.map +1 -1
  10. package/dist/client.d.ts +1 -1
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +2 -2
  13. package/dist/client.js.map +1 -1
  14. package/dist/evaluator.d.ts +1 -1
  15. package/dist/evaluator.d.ts.map +1 -1
  16. package/dist/evaluator.js +102 -2
  17. package/dist/evaluator.js.map +1 -1
  18. package/dist/hash.d.ts +1 -1
  19. package/dist/hash.d.ts.map +1 -1
  20. package/dist/index.d.ts +38 -11
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +43 -8
  23. package/dist/index.js.map +1 -1
  24. package/dist/invocation-proof.d.ts +133 -0
  25. package/dist/invocation-proof.d.ts.map +1 -0
  26. package/dist/invocation-proof.js +157 -0
  27. package/dist/invocation-proof.js.map +1 -0
  28. package/dist/lineage.d.ts +70 -0
  29. package/dist/lineage.d.ts.map +1 -0
  30. package/dist/lineage.js +65 -0
  31. package/dist/lineage.js.map +1 -0
  32. package/dist/mandate-handlers.d.ts +15 -0
  33. package/dist/mandate-handlers.d.ts.map +1 -0
  34. package/dist/mandate-handlers.js +279 -0
  35. package/dist/mandate-handlers.js.map +1 -0
  36. package/dist/mandate.d.ts +265 -0
  37. package/dist/mandate.d.ts.map +1 -0
  38. package/dist/mandate.js +275 -0
  39. package/dist/mandate.js.map +1 -0
  40. package/dist/method-authorization.d.ts +120 -0
  41. package/dist/method-authorization.d.ts.map +1 -0
  42. package/dist/method-authorization.js +173 -0
  43. package/dist/method-authorization.js.map +1 -0
  44. package/dist/onchain.d.ts +40 -4
  45. package/dist/onchain.d.ts.map +1 -1
  46. package/dist/onchain.js +140 -6
  47. package/dist/onchain.js.map +1 -1
  48. package/dist/presentation-release.d.ts +65 -0
  49. package/dist/presentation-release.d.ts.map +1 -0
  50. package/dist/presentation-release.js +81 -0
  51. package/dist/presentation-release.js.map +1 -0
  52. package/dist/quorum.d.ts +26 -0
  53. package/dist/quorum.d.ts.map +1 -0
  54. package/dist/quorum.js +80 -0
  55. package/dist/quorum.js.map +1 -0
  56. package/dist/session-delegation.d.ts +34 -0
  57. package/dist/session-delegation.d.ts.map +1 -0
  58. package/dist/session-delegation.js +33 -0
  59. package/dist/session-delegation.js.map +1 -0
  60. package/dist/session-manager.d.ts +1 -2
  61. package/dist/session-manager.d.ts.map +1 -1
  62. package/dist/session-manager.js +15 -6
  63. package/dist/session-manager.js.map +1 -1
  64. package/dist/standing.d.ts +101 -0
  65. package/dist/standing.d.ts.map +1 -0
  66. package/dist/standing.js +132 -0
  67. package/dist/standing.js.map +1 -0
  68. package/dist/token.d.ts +89 -1
  69. package/dist/token.d.ts.map +1 -1
  70. package/dist/token.js +485 -123
  71. package/dist/token.js.map +1 -1
  72. package/dist/types.d.ts +141 -0
  73. package/dist/types.d.ts.map +1 -1
  74. package/dist/types.js +7 -1
  75. package/dist/types.js.map +1 -1
  76. package/dist/verify-authorization.d.ts +28 -0
  77. package/dist/verify-authorization.d.ts.map +1 -0
  78. package/dist/verify-authorization.js +73 -0
  79. package/dist/verify-authorization.js.map +1 -0
  80. 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
- function base64urlEncode(s) {
21
- return Buffer.from(s, 'utf8').toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
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 base64urlDecodeStr(s) {
37
+ function base64urlToBytes(s) {
24
38
  let padded = s.replace(/-/g, '+').replace(/_/g, '/');
25
39
  while (padded.length % 4)
26
40
  padded += '=';
27
- return Buffer.from(padded, 'base64').toString('utf8');
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 Buffer.from(b).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
54
+ return bytesToBase64url(b);
31
55
  }
32
56
  function base64urlDecodeBytes(s) {
33
- let padded = s.replace(/-/g, '+').replace(/_/g, '/');
34
- while (padded.length % 4)
35
- padded += '=';
36
- return new Uint8Array(Buffer.from(padded, 'base64'));
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
- const seen = new WeakSet();
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
- return '[' + v.map((x) => (x === undefined ? 'null' : stringify(x))).join(',') + ']';
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
- return '{' + keys.map((k) => JSON.stringify(k) + ':' + stringify(obj[k])).join(',') + '}';
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).toRawBytes(false);
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 = new Uint8Array(Buffer.from(sigHex, 'hex'));
205
+ const sigBytes = hexToBytes(sigHex);
160
206
  const token = `${base64urlEncode(canonical)}.${base64urlEncodeBytes(sigBytes)}`;
161
- // Audit emit (C3 pass 3c). Fail-soft. JTI is logged because it's the
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
- try {
165
- await opts.auditSink.write(buildEvent({
166
- action: 'delegation.mint',
167
- outcome: 'success',
168
- correlationId: opts.correlationId,
169
- actor: { type: 'user', id: claims.delegation.delegator },
170
- subject: { type: 'jti', id: jti },
171
- audience: claims.aud,
172
- context: {
173
- iss: claims.iss,
174
- sub: claims.sub,
175
- sessionKeyAddress: claims.sessionKeyAddress,
176
- ttlSeconds: ttl,
177
- usageLimit: claims.usageLimit ?? null,
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: 'isAcceptedSessionDelegation',
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
- await opts.auditSink.write(buildEvent({
254
- action: outcome === 'success'
255
- ? 'delegation.verify.accept'
256
- : 'delegation.verify.reject',
257
- outcome,
258
- correlationId: opts.correlationId,
259
- actor: principal ? { type: 'user', id: principal } : { type: 'unknown' },
260
- subject: delegationDigest
261
- ? { type: 'delegation', id: delegationDigest }
262
- : undefined,
263
- audience: opts.audience,
264
- chainId: opts.chainId,
265
- digest: delegationDigest,
266
- reason,
267
- context: opts.toolName ? { tool: opts.toolName } : undefined,
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
- try {
309
- const revoked = (await publicClient.readContract({
310
- address: opts.delegationManager,
311
- abi: DELEGATION_MANAGER_ABI,
312
- functionName: 'isRevoked',
313
- args: [eip712Hash],
314
- }));
315
- if (revoked) {
316
- return rejectWith('delegation revoked', claims.delegation.delegator, eip712Hash);
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
- catch (e) {
320
- if (revocationFailMode === 'closed') {
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
- void e;
324
- }
325
- try {
326
- const code = await publicClient.getCode({ address: claims.delegation.delegator });
327
- const isDeployed = !!(code && code !== '0x' && code.length > 2);
328
- if (!isDeployed) {
329
- if (requireDeployed) {
330
- return rejectWith(`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.`, claims.delegation.delegator, eip712Hash);
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
- // Tolerated: caller opted into the undeployed path explicitly.
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
- else {
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
- if (magic.toLowerCase() !== ERC1271_MAGIC) {
342
- return rejectWith('erc1271 validation failed', claims.delegation.delegator, eip712Hash);
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
- catch (e) {
347
- return rejectWith(`erc1271 call reverted: ${e instanceof Error ? e.message : e}`, claims.delegation.delegator, eip712Hash);
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
- // Off-chain verification is a planned mode; the wire-format
384
- // (payloadHash + packedSignatures) is reserved here so consumers
385
- // can migrate when the implementation lands. Failing closed
386
- // until the spec'd off-chain payload format is implemented.
387
- return rejectWith('quorum_off_chain_not_implemented', claims.delegation.delegator, eip712Hash);
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.isAcceptedSessionDelegation(hash)
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
- try {
400
- acceptedOnChain = (await publicClient.readContract({
401
- address: claims.delegation.delegator,
402
- abi: ACCEPTED_SESSION_DELEGATION_ABI,
403
- functionName: 'isAcceptedSessionDelegation',
404
- args: [eip712Hash],
405
- }));
406
- }
407
- catch (e) {
408
- return rejectWith(`acceptSessionDelegation check reverted: ${e instanceof Error ? e.message : e}`, claims.delegation.delegator, eip712Hash);
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
- if (!acceptedOnChain) {
411
- return rejectWith('tier requires on-chain acceptSessionDelegation blessing; not present', claims.delegation.delegator, eip712Hash);
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
- const usage = await opts.jtiStore.trackUsage(claims.jti, limit);
417
- if (!usage.allowed) {
418
- return rejectWith('token usage limit exceeded', claims.delegation.delegator, eip712Hash);
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
- /* fail-soft */
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
- return { principal: claims.delegation.delegator };
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