@passioncode-ai/fabric-agent-adapter 0.10.0 → 0.11.1

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.
@@ -0,0 +1,761 @@
1
+ #!/usr/bin/env python3
2
+ """The callee's side of fabric-delegation/0.1 (Fabric Agent Contract DEC-0039, Fabric ADR-0144): check a
3
+ delegation token or an approval offline against the estate's published keys, and answer `ok` or one refusal
4
+ code. A port of the contract's `src/delegation.ts` at the pinned revision; the shared vectors in
5
+ `test/fixtures/delegation/vectors.json` hold this module to that verifier's exact answers.
6
+
7
+ Standard library only. Ed25519 (RFC 8032) is verified here in plain Python: verification uses only public data,
8
+ so it needs no constant-time arithmetic; it is cofactorless and strict as OpenSSL is (S below L, A decodes,
9
+ R compared by its encoding).
10
+
11
+ from fabric_delegation import JwksCache, delegation_gate
12
+ gate = delegation_gate(audience="example-agent", jwks=JwksCache(well_known["auth"]["delegation"]["jwks"]),
13
+ effects={"example.write": "draft"}, issuer=well_known["auth"]["delegation"]["issuer"])
14
+ server = fabric_interop.McpToolServer("example-agent", VERSION, authorize=gate)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import hashlib
21
+ import ipaddress
22
+ import json
23
+ import math
24
+ import os
25
+ from pathlib import Path
26
+ import re
27
+ import secrets
28
+ import threading
29
+ import time
30
+ from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
31
+ import urllib.error
32
+ import urllib.parse
33
+ import urllib.request
34
+
35
+ REFUSAL_CODES = (
36
+ "unauthenticated", "token-expired", "wrong-audience", "scope-not-granted", "effect-above-ceiling", "no-grant",
37
+ "grant-expired", "grant-revoked", "limit-reached", "approval-required", "approval-invalid", "approval-reused",
38
+ "principal-unproven", "credential-source-refused", "chain-too-deep", "issuer-unavailable", "unknown-state",
39
+ )
40
+ EFFECT_CLASSES = ("none", "draft", "publish", "send", "reply", "charge", "delete", "merge", "deploy", "change-policy")
41
+ DELEGATION_HEADER = "Fabric-Delegation"
42
+ DELEGATION_TYP = "fabric-delegation+jwt"
43
+ APPROVAL_TYP = "fabric-approval+jwt"
44
+ MAX_DELEGATION_LIFETIME_SECONDS = 600
45
+ MAX_APPROVAL_LIFETIME_SECONDS = 86_400
46
+ MAX_CHAIN_DEPTH = 4
47
+ LEEWAY_SECONDS = 60
48
+ # DL-15: a callee caches the key set at most 10 minutes and re-reads it at most once a minute for an unknown kid.
49
+ JWKS_MAX_AGE_SECONDS = 600
50
+ JWKS_REREAD_SECONDS = 60
51
+ # After a failed read, wait this long before trying again, so a down issuer does not cost every call a fetch.
52
+ JWKS_RETRY_SECONDS = 10
53
+ # JavaScript's String.prototype.trim set, so a header is trimmed as the contract's verifier trims it.
54
+ _JS_WHITESPACE = "\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
55
+
56
+ # DL-19: the HTTP status of each refusal, and the sentence that goes in error.message.
57
+ REFUSAL_STATUS = {"unauthenticated": 401, "token-expired": 401, "principal-unproven": 401,
58
+ "issuer-unavailable": 503, "unknown-state": 503}
59
+ REFUSAL_MESSAGES = {
60
+ "unauthenticated": "No delegation token, or one not signed by a published key or not of this profile.",
61
+ "token-expired": "The delegation token has expired; mint a new one.",
62
+ "wrong-audience": "The delegation token is for another callee.",
63
+ "scope-not-granted": "The called capability is not in the token's scope.",
64
+ "effect-above-ceiling": "The capability's effect is above the token's ceiling.",
65
+ "no-grant": "No live grant covers this call.",
66
+ "grant-expired": "The grant has expired.",
67
+ "grant-revoked": "The grant was revoked.",
68
+ "limit-reached": "A spend window is exhausted.",
69
+ "approval-required": "This action needs an approval; ask the hub for one with the action digest.",
70
+ "approval-invalid": "The approval is not valid for this action, callee or time.",
71
+ "approval-reused": "The approval was already used.",
72
+ "principal-unproven": "The caller's proof is missing or stale.",
73
+ "credential-source-refused": "The grant names a credential source that is not allowed.",
74
+ "chain-too-deep": "The actor chain is deeper than four.",
75
+ "issuer-unavailable": "The estate's published keys cannot be read.",
76
+ "unknown-state": "The state cannot be established, so the call is refused.",
77
+ }
78
+
79
+ _B64URL = re.compile(r"^[A-Za-z0-9_-]+$")
80
+ _MISSING = object()
81
+
82
+
83
+ def _refuse(code: str, detail: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
84
+ out: Dict[str, Any] = {"ok": False, "code": code, "detail": detail}
85
+ if data:
86
+ out["data"] = data
87
+ return out
88
+
89
+
90
+ def _is_string(value: Any) -> bool:
91
+ return isinstance(value, str) and len(value) > 0
92
+
93
+
94
+ def _is_time(value: Any) -> bool:
95
+ if isinstance(value, bool):
96
+ return False
97
+ if isinstance(value, int):
98
+ return value >= 0
99
+ return isinstance(value, float) and math.isfinite(value) and value.is_integer() and value >= 0
100
+
101
+
102
+ # --- Ed25519 (RFC 8032 §5.1) ----------------------------------------------------------------------------
103
+
104
+ _P = 2 ** 255 - 19
105
+ _L = 2 ** 252 + 27742317777372353535851937790883648493
106
+ _D = (-121665 * pow(121666, _P - 2, _P)) % _P
107
+ _SQRT_M1 = pow(2, (_P - 1) // 4, _P)
108
+
109
+
110
+ def _add(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> Tuple[int, int, int, int]:
111
+ x1, y1, z1, t1 = a
112
+ x2, y2, z2, t2 = b
113
+ aa = (y1 - x1) * (y2 - x2) % _P
114
+ bb = (y1 + x1) * (y2 + x2) % _P
115
+ cc = 2 * t1 * t2 * _D % _P
116
+ dd = 2 * z1 * z2 % _P
117
+ e, f, g, h = bb - aa, dd - cc, dd + cc, bb + aa
118
+ return (e * f % _P, g * h % _P, f * g % _P, e * h % _P)
119
+
120
+
121
+ def _mul(scalar: int, point: Tuple[int, int, int, int]) -> Tuple[int, int, int, int]:
122
+ result = (0, 1, 1, 0)
123
+ while scalar:
124
+ if scalar & 1:
125
+ result = _add(result, point)
126
+ point = _add(point, point)
127
+ scalar >>= 1
128
+ return result
129
+
130
+
131
+ def _decode_point(data: bytes) -> Optional[Tuple[int, int, int, int]]:
132
+ if len(data) != 32:
133
+ return None
134
+ y = int.from_bytes(data, "little")
135
+ sign = y >> 255
136
+ y &= (1 << 255) - 1
137
+ if y >= _P:
138
+ return None
139
+ x2 = (y * y - 1) * pow(_D * y * y + 1, _P - 2, _P) % _P
140
+ if x2 == 0:
141
+ if sign:
142
+ return None
143
+ return (0, y, 1, 0)
144
+ x = pow(x2, (_P + 3) // 8, _P)
145
+ if (x * x - x2) % _P:
146
+ x = x * _SQRT_M1 % _P
147
+ if (x * x - x2) % _P:
148
+ return None
149
+ if x & 1 != sign:
150
+ x = _P - x
151
+ return (x, y, 1, x * y % _P)
152
+
153
+
154
+ def _encode_point(point: Tuple[int, int, int, int]) -> bytes:
155
+ x, y, z, _ = point
156
+ zi = pow(z, _P - 2, _P)
157
+ x, y = x * zi % _P, y * zi % _P
158
+ return (y | ((x & 1) << 255)).to_bytes(32, "little")
159
+
160
+
161
+ _G = _decode_point(bytes.fromhex("5866666666666666666666666666666666666666666666666666666666666666"))
162
+ _IDENTITY = (1).to_bytes(32, "little")
163
+
164
+
165
+ def ed25519_verify(public_key: bytes, message: bytes, signature: bytes) -> bool:
166
+ """RFC 8032 §5.1.7, cofactorless: [S]B == R + [k]A, compared by R's encoding. False, never an exception."""
167
+ if len(public_key) != 32 or len(signature) != 64:
168
+ return False
169
+ s = int.from_bytes(signature[32:], "little")
170
+ if s >= _L:
171
+ return False
172
+ a = _decode_point(public_key)
173
+ if a is None or _encode_point(_mul(8, a)) == _IDENTITY:
174
+ return False # a small-order key would accept R = [S]B for a quarter or more of all messages; OpenSSL refuses it
175
+ k = int.from_bytes(hashlib.sha512(signature[:32] + public_key + message).digest(), "little") % _L
176
+ neg_a = ((-a[0]) % _P, a[1], a[2], (-a[3]) % _P)
177
+ return secrets.compare_digest(_encode_point(_add(_mul(s, _G), _mul(k, neg_a))), signature[:32])
178
+
179
+
180
+ # --- the compact document ------------------------------------------------------------------------------
181
+
182
+ def _b64decode(segment: str) -> bytes:
183
+ """Base64url without padding, as Node reads it: a dangling sixth bit group is ignored."""
184
+ if len(segment) % 4 == 1:
185
+ segment = segment[:-1]
186
+ return base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4))
187
+
188
+
189
+ class _Duplicate(ValueError):
190
+ pass
191
+
192
+
193
+ def _no_duplicates(pairs: List[Tuple[str, Any]]) -> Dict[str, Any]:
194
+ seen: Dict[str, Any] = {}
195
+ for key, value in pairs:
196
+ if key in seen:
197
+ raise _Duplicate(key)
198
+ seen[key] = value
199
+ return seen
200
+
201
+
202
+ def _no_constant(name: str) -> Any:
203
+ raise ValueError(name) # NaN and Infinity are not JSON
204
+
205
+
206
+ def _decode_segment(segment: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
207
+ if not _B64URL.match(segment):
208
+ return None, "malformed"
209
+ try:
210
+ text = _b64decode(segment).decode("utf-8", "replace")
211
+ value = json.loads(text, object_pairs_hook=_no_duplicates, parse_constant=_no_constant)
212
+ except _Duplicate:
213
+ try:
214
+ value = json.loads(text, parse_constant=_no_constant)
215
+ except (ValueError, RecursionError):
216
+ return None, "malformed"
217
+ return (None, "duplicate-member") if isinstance(value, dict) else (None, "malformed")
218
+ except (ValueError, TypeError, RecursionError):
219
+ return None, "malformed"
220
+ if not isinstance(value, dict):
221
+ return None, "malformed"
222
+ return value, None
223
+
224
+
225
+ def _utf16_length(text: str) -> int:
226
+ return len(text.encode("utf-16-le", "surrogatepass")) // 2
227
+
228
+
229
+ def _parse_compact(token: Any) -> Dict[str, Any]:
230
+ if not isinstance(token, str) or _utf16_length(token) > 8192:
231
+ return {"problem": "malformed"}
232
+ parts = token.split(".")
233
+ if len(parts) != 3:
234
+ return {"problem": "malformed"}
235
+ h, p, s = parts
236
+ header, header_problem = _decode_segment(h)
237
+ payload, payload_problem = _decode_segment(p)
238
+ if not _B64URL.match(s):
239
+ return {"problem": "malformed"}
240
+ for problem in (header_problem, payload_problem):
241
+ if problem:
242
+ return {"problem": problem}
243
+ try:
244
+ signature = _b64decode(s)
245
+ except ValueError:
246
+ signature = b""
247
+ return {"header": header, "payload": payload, "signing_input": ("%s.%s" % (h, p)).encode("ascii"), "signature": signature}
248
+
249
+
250
+ def _header_problem(header: Dict[str, Any], typ: str) -> Optional[str]:
251
+ if header.get("alg") != "EdDSA":
252
+ return "alg-not-eddsa"
253
+ if header.get("typ") != typ:
254
+ return "wrong-typ"
255
+ if not _is_string(header.get("kid")):
256
+ return "no-kid"
257
+ for member in ("crit", "jku", "jwk", "x5u", "x5c"):
258
+ if member in header:
259
+ return "header-" + member
260
+ return None
261
+
262
+
263
+ def _key_for(jwks: Dict[str, Any], kid: str) -> Dict[str, Any]:
264
+ matches = [k for k in jwks["keys"] if isinstance(k, dict) and k.get("kid") == kid]
265
+ if len(matches) > 1:
266
+ return {"problem": "duplicate-kid"}
267
+ if not matches:
268
+ return {"problem": "unknown-key"}
269
+ jwk = matches[0]
270
+ # A key published for another use or algorithm is never a candidate (RFC 7517 §4.2, §4.4).
271
+ if ("use" in jwk and jwk["use"] != "sig") or ("alg" in jwk and jwk["alg"] != "EdDSA"):
272
+ return {"problem": "key-not-for-signing"}
273
+ if jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519" or not _is_string(jwk.get("x")) or "d" in jwk:
274
+ return {"problem": "unusable-key"}
275
+ if not _B64URL.match(jwk["x"]):
276
+ return {"problem": "unusable-key"}
277
+ try:
278
+ raw = _b64decode(jwk["x"])
279
+ except ValueError:
280
+ return {"problem": "unusable-key"}
281
+ if len(raw) != 32:
282
+ return {"problem": "unusable-key"}
283
+ return {"key": raw}
284
+
285
+
286
+ def _has_keys(jwks: Any) -> bool:
287
+ return isinstance(jwks, dict) and isinstance(jwks.get("keys"), list) and len(jwks["keys"]) > 0
288
+
289
+
290
+ def _authentic(token: Any, jwks: Dict[str, Any], typ: str) -> Dict[str, Any]:
291
+ """Parse, check the header, find the key, verify the signature. A fault of the published keys is the issuer's."""
292
+ compact = _parse_compact(token)
293
+ if "problem" in compact:
294
+ return compact
295
+ problem = _header_problem(compact["header"], typ)
296
+ if problem:
297
+ return {"problem": problem}
298
+ kid = compact["header"]["kid"]
299
+ found = _key_for(jwks, kid)
300
+ if "problem" in found:
301
+ return {"problem": found["problem"], "issuer": found["problem"] in ("unusable-key", "duplicate-kid")}
302
+ if len(compact["signature"]) != 64 or not ed25519_verify(found["key"], compact["signing_input"], compact["signature"]):
303
+ return {"problem": "bad-signature"}
304
+ return {"compact": compact, "kid": kid}
305
+
306
+
307
+ def chain_depth(act: Any) -> float:
308
+ """Depth of an actor chain; infinity for a chain that is not one (a missing or non-string sub at any level)."""
309
+ depth = 0
310
+ node = act
311
+ while node is not _MISSING:
312
+ if not isinstance(node, dict) or not _is_string(node.get("sub")):
313
+ return math.inf
314
+ depth += 1
315
+ if depth > 64:
316
+ return depth
317
+ node = node.get("act", _MISSING)
318
+ return depth
319
+
320
+
321
+ def _chain_of(act: Dict[str, Any]) -> List[str]:
322
+ chain = []
323
+ node: Any = act
324
+ while isinstance(node, dict):
325
+ chain.append(node["sub"])
326
+ node = node.get("act")
327
+ return chain
328
+
329
+
330
+ def effect_closure(ceiling: List[str]) -> set:
331
+ """`none` is below every class and `draft` below every external one; external classes are not ordered."""
332
+ admitted = set(ceiling)
333
+ if ceiling:
334
+ admitted.add("none")
335
+ if any(effect != "none" for effect in ceiling):
336
+ admitted.add("draft")
337
+ return admitted
338
+
339
+
340
+ def effect_within(effect: str, ceiling: List[str]) -> bool:
341
+ return effect in effect_closure(ceiling)
342
+
343
+
344
+ def _delegation_shape(payload: Dict[str, Any]) -> bool:
345
+ if not all(_is_string(payload.get(k)) for k in ("iss", "sub", "aud", "scope", "jti")):
346
+ return False
347
+ if not _is_time(payload.get("iat")) or not _is_time(payload.get("exp")):
348
+ return False
349
+ effect, grant = payload.get("effect"), payload.get("grant")
350
+ if not isinstance(effect, list) or not effect or not all(isinstance(e, str) and e in EFFECT_CLASSES for e in effect):
351
+ return False
352
+ if not isinstance(grant, list) or not grant or not all(_is_string(g) for g in grant):
353
+ return False
354
+ return isinstance(payload.get("act"), dict)
355
+
356
+
357
+ def _approval_shape(payload: Dict[str, Any]) -> bool:
358
+ if not all(_is_string(payload.get(k)) for k in ("iss", "actionDigest", "aud", "approver", "jti")):
359
+ return False
360
+ if not _is_time(payload.get("iat")) or not _is_time(payload.get("exp")):
361
+ return False
362
+ return payload.get("method") in ("prompt", "operator-channel", "device")
363
+
364
+
365
+ def verify_delegation(token: Any, *, audience: str, jwks: Any, now: float, capability: str, effect: str,
366
+ issuer: Optional[str] = None, read_only: bool = False) -> Dict[str, Any]:
367
+ """DL-13, in its fixed order: nothing about a token is believed before its signature is.
368
+ `now` is seconds since the epoch. Returns {ok: True, claims, principal, caller, chain, kid} or a refusal
369
+ {ok: False, code, detail[, data]} whose detail is a short machine word, never the token."""
370
+ if not _has_keys(jwks):
371
+ return _refuse("issuer-unavailable", "no-published-keys")
372
+ checked = _authentic(token, jwks, DELEGATION_TYP)
373
+ if "problem" in checked:
374
+ return _refuse("issuer-unavailable" if checked.get("issuer") else "unauthenticated", checked["problem"])
375
+ claims = checked["compact"]["payload"]
376
+ if not _delegation_shape(claims):
377
+ return _refuse("unauthenticated", "malformed-claims")
378
+ if issuer is not None and claims["iss"] != issuer:
379
+ return _refuse("unauthenticated", "wrong-issuer")
380
+ if claims["exp"] <= claims["iat"] or claims["exp"] - claims["iat"] > MAX_DELEGATION_LIFETIME_SECONDS:
381
+ return _refuse("unauthenticated", "lifetime-above-profile")
382
+ if claims["iat"] > now + LEEWAY_SECONDS:
383
+ return _refuse("unauthenticated", "issued-in-future")
384
+ if "nbf" in claims and (not _is_time(claims["nbf"]) or claims["nbf"] > now + LEEWAY_SECONDS):
385
+ return _refuse("unauthenticated", "not-yet-valid")
386
+ if now > claims["exp"] + LEEWAY_SECONDS:
387
+ return _refuse("token-expired", "expired")
388
+ if claims["aud"] != audience:
389
+ return _refuse("wrong-audience", "audience-mismatch")
390
+ depth = chain_depth(claims["act"])
391
+ if not math.isfinite(depth):
392
+ return _refuse("unauthenticated", "malformed-chain")
393
+ if depth > MAX_CHAIN_DEPTH:
394
+ return _refuse("chain-too-deep", "depth-%d" % depth)
395
+ if capability not in claims["scope"].split(" "):
396
+ return _refuse("scope-not-granted", "capability-not-in-scope")
397
+ if read_only and effect != "none":
398
+ return _refuse("effect-above-ceiling", "read-only-door")
399
+ if not effect_within(effect, claims["effect"]):
400
+ return _refuse("effect-above-ceiling", "effect-%s" % effect)
401
+ return {"ok": True, "claims": claims, "principal": claims["sub"], "caller": claims["act"]["sub"],
402
+ "chain": _chain_of(claims["act"]), "kid": checked["kid"]}
403
+
404
+
405
+ class MemoryJtiStore:
406
+ """Consumed approval ids in memory: for tests and a single process that never restarts mid-approval."""
407
+
408
+ def __init__(self) -> None:
409
+ self._seen: Dict[str, float] = {}
410
+ self._lock = threading.Lock()
411
+
412
+ def consume(self, jti: str, expires_at: float, now: float) -> bool:
413
+ with self._lock:
414
+ for key, until in list(self._seen.items()):
415
+ if until + LEEWAY_SECONDS < now:
416
+ del self._seen[key]
417
+ if jti in self._seen:
418
+ return False
419
+ self._seen[jti] = expires_at
420
+ return True
421
+
422
+
423
+ class JtiStoreError(RuntimeError):
424
+ """A jti store that cannot tell whether an id was consumed; the verifier answers unknown-state."""
425
+
426
+
427
+ class FileJtiStore:
428
+ """Consumed approval ids in a 0600 JSON file, so a second use is refused across a restart (DL-18). One
429
+ service instance writes it (the kit's instance lock keeps other processes out); threads are serialised."""
430
+
431
+ _locks: Dict[str, threading.Lock] = {}
432
+ _locks_guard = threading.Lock()
433
+
434
+ def __init__(self, path: Path) -> None:
435
+ self.path = Path(path)
436
+ with FileJtiStore._locks_guard:
437
+ self._lock = FileJtiStore._locks.setdefault(str(self.path.resolve()), threading.Lock())
438
+
439
+ def consume(self, jti: str, expires_at: float, now: float) -> bool:
440
+ """True the first time; False for a used id. Raises JtiStoreError when the file cannot be read: forgetting
441
+ every consumed id would let each approval be replayed."""
442
+ with self._lock:
443
+ try:
444
+ seen = json.loads(self.path.read_text())
445
+ except FileNotFoundError:
446
+ seen = {}
447
+ except (OSError, ValueError) as exc:
448
+ raise JtiStoreError("the consumed-approval file cannot be read: %s" % exc.__class__.__name__) from None
449
+ if not isinstance(seen, dict):
450
+ raise JtiStoreError("the consumed-approval file is not a JSON object")
451
+ seen = {k: v for k, v in seen.items() if isinstance(v, (int, float)) and v + LEEWAY_SECONDS >= now}
452
+ if jti in seen:
453
+ return False
454
+ seen[jti] = expires_at
455
+ self.path.parent.mkdir(parents=True, exist_ok=True)
456
+ temp = self.path.with_name(".%s.%s.tmp" % (self.path.name, secrets.token_hex(4)))
457
+ fd = os.open(str(temp), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
458
+ with os.fdopen(fd, "w") as handle:
459
+ json.dump(seen, handle)
460
+ handle.flush()
461
+ os.fsync(handle.fileno())
462
+ os.replace(str(temp), str(self.path))
463
+ return True
464
+
465
+
466
+ class _NoReplayProtection:
467
+ def consume(self, jti: str, expires_at: float, now: float) -> bool: # noqa: ARG002
468
+ return True
469
+
470
+
471
+ # Every approval may be replayed until it expires. Only on purpose, by name.
472
+ UNSAFE_NO_JTI_STORE = _NoReplayProtection()
473
+
474
+
475
+ def verify_approval(approval: Optional[str], *, audience: str, jwks: Any, now: float, action_digest: str,
476
+ jti_store: Any, issuer: Optional[str] = None) -> Dict[str, Any]:
477
+ """DL-18: a missing approval is approval-required carrying the action digest; any fault of the document is
478
+ approval-invalid; a second use is approval-reused. The jti is consumed last."""
479
+ if approval is None or approval == "":
480
+ return _refuse("approval-required", "no-approval", {"actionDigest": action_digest})
481
+ if jti_store is None or not callable(getattr(jti_store, "consume", None)):
482
+ return _refuse("unknown-state", "no-jti-store")
483
+ if not _has_keys(jwks):
484
+ return _refuse("issuer-unavailable", "no-published-keys")
485
+ checked = _authentic(approval, jwks, APPROVAL_TYP)
486
+ if "problem" in checked:
487
+ return _refuse("issuer-unavailable" if checked.get("issuer") else "approval-invalid", checked["problem"])
488
+ claims = checked["compact"]["payload"]
489
+ if not _approval_shape(claims):
490
+ return _refuse("approval-invalid", "malformed-claims")
491
+ if issuer is not None and claims["iss"] != issuer:
492
+ return _refuse("approval-invalid", "wrong-issuer")
493
+ if claims["exp"] <= claims["iat"] or claims["exp"] - claims["iat"] > MAX_APPROVAL_LIFETIME_SECONDS:
494
+ return _refuse("approval-invalid", "lifetime-above-profile")
495
+ if claims["iat"] > now + LEEWAY_SECONDS:
496
+ return _refuse("approval-invalid", "issued-in-future")
497
+ if now > claims["exp"] + LEEWAY_SECONDS:
498
+ return _refuse("approval-invalid", "expired")
499
+ if claims["aud"] != audience:
500
+ return _refuse("approval-invalid", "audience-mismatch")
501
+ if claims["actionDigest"] != action_digest:
502
+ return _refuse("approval-invalid", "digest-mismatch", {"actionDigest": action_digest})
503
+ try:
504
+ fresh = jti_store.consume(claims["jti"], claims["exp"], now)
505
+ except Exception: # a store that cannot answer is an unknown state, never a pass (DL-19)
506
+ return _refuse("unknown-state", "jti-store-failed")
507
+ if not fresh:
508
+ return _refuse("approval-reused", "jti-consumed")
509
+ return {"ok": True, "claims": claims, "approver": claims["approver"], "method": claims["method"], "kid": checked["kid"]}
510
+
511
+
512
+ def _header(headers: Any, name: str) -> Optional[str]:
513
+ if headers is None:
514
+ return None
515
+ if hasattr(headers, "get_all"): # http.server's and email's message: case-insensitive
516
+ values = headers.get_all(name)
517
+ return values[0] if values else None
518
+ if isinstance(headers, Mapping):
519
+ for key, value in headers.items():
520
+ if isinstance(key, str) and key.lower() == name.lower():
521
+ if isinstance(value, (list, tuple)):
522
+ return str(value[0]) if value else ""
523
+ return value
524
+ return None
525
+
526
+
527
+ def read_delegation_token(headers: Any) -> Optional[Dict[str, str]]:
528
+ """DL-9, DL-10: the hub's Fabric-Delegation header first (present but empty is still the token, refused
529
+ later, never a fall-through), then a delegation-typ JWT in Authorization: Bearer."""
530
+ forwarded = _header(headers, DELEGATION_HEADER)
531
+ if forwarded is not None:
532
+ return {"token": forwarded.strip(_JS_WHITESPACE), "via": "fabric-delegation"}
533
+ match = re.fullmatch(r"Bearer\s+(\S+)", _header(headers, "Authorization") or "", re.IGNORECASE)
534
+ if match:
535
+ parsed = _parse_compact(match.group(1))
536
+ if "problem" not in parsed and parsed["header"].get("typ") == DELEGATION_TYP:
537
+ return {"token": match.group(1), "via": "authorization"}
538
+ return None
539
+
540
+
541
+ # --- RFC 8785 canonical JSON --------------------------------------------------------------------------------
542
+
543
+ def _number(value: float) -> str:
544
+ """ECMAScript Number::toString, which RFC 8785 §3.2.2.3 prescribes."""
545
+ if value == 0:
546
+ return "0"
547
+ if value < 0:
548
+ return "-" + _number(-value)
549
+ digits, exponent = _shortest(value)
550
+ k, n = len(digits), len(digits) + exponent
551
+ if k <= n <= 21:
552
+ return digits + "0" * (n - k)
553
+ if 0 < n <= 21:
554
+ return digits[:n] + "." + digits[n:]
555
+ if -6 < n <= 0:
556
+ return "0." + "0" * (-n) + digits
557
+ e = n - 1
558
+ mantissa = digits if k == 1 else digits[0] + "." + digits[1:]
559
+ return "%se%s%d" % (mantissa, "+" if e >= 0 else "-", abs(e))
560
+
561
+
562
+ def _shortest(value: float) -> Tuple[str, int]:
563
+ """The shortest round-trip digits of a positive finite double (Python's repr is shortest round-trip)."""
564
+ text = repr(value)
565
+ mantissa, _, exp = text.partition("e")
566
+ whole, _, frac = mantissa.partition(".")
567
+ digits = (whole + frac).lstrip("0")
568
+ exponent = (int(exp) if exp else 0) - len(frac)
569
+ if not digits:
570
+ return "0", 0
571
+ stripped = digits.rstrip("0")
572
+ exponent += len(digits) - len(stripped)
573
+ return stripped, exponent
574
+
575
+
576
+ _ESCAPES = {'"': '\\"', "\\": "\\\\", "\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t"}
577
+
578
+
579
+ def _string(value: str) -> str:
580
+ out = ['"']
581
+ for ch in value:
582
+ code = ord(ch)
583
+ if 0xD800 <= code <= 0xDFFF:
584
+ raise TypeError("a lone surrogate has no canonical form")
585
+ if ch in _ESCAPES:
586
+ out.append(_ESCAPES[ch])
587
+ elif code < 0x20:
588
+ out.append("\\u%04x" % code)
589
+ else:
590
+ out.append(ch)
591
+ out.append('"')
592
+ return "".join(out)
593
+
594
+
595
+ def jcs(value: Any) -> str:
596
+ """RFC 8785: members sorted by UTF-16 code units, numbers in ECMAScript form, strings as JSON.stringify
597
+ escapes them. Refuses what has no canonical form: a non-finite number, a lone surrogate, a non-JSON value.
598
+ A JSON number is a double: an integer beyond 2**53 is written as JavaScript would write it."""
599
+ if value is None:
600
+ return "null"
601
+ if value is True:
602
+ return "true"
603
+ if value is False:
604
+ return "false"
605
+ if isinstance(value, int):
606
+ if abs(value) < 2 ** 53:
607
+ return str(value)
608
+ try:
609
+ return _number(float(value))
610
+ except OverflowError:
611
+ raise TypeError("a number beyond a double has no canonical form") from None
612
+ if isinstance(value, float):
613
+ if not math.isfinite(value):
614
+ raise TypeError("a non-finite number has no canonical form")
615
+ return _number(value)
616
+ if isinstance(value, str):
617
+ return _string(value)
618
+ if isinstance(value, list):
619
+ return "[" + ",".join(jcs(item) for item in value) + "]"
620
+ if isinstance(value, dict):
621
+ if not all(isinstance(key, str) for key in value):
622
+ raise TypeError("only string member names have a canonical form")
623
+ keys = sorted(value, key=lambda key: key.encode("utf-16-be", "surrogatepass"))
624
+ return "{" + ",".join(_string(key) + ":" + jcs(value[key]) for key in keys) + "}"
625
+ raise TypeError("%s has no canonical form" % type(value).__name__)
626
+
627
+
628
+ def action_digest(action: Any) -> str:
629
+ """The digest an approval binds: sha256: and the hex SHA-256 of the action's RFC 8785 form."""
630
+ return "sha256:" + hashlib.sha256(jcs(action).encode("utf-8")).hexdigest()
631
+
632
+
633
+ # --- the estate's keys ----------------------------------------------------------------------------------------
634
+
635
+ def _loopback(host: str) -> bool:
636
+ if host == "localhost":
637
+ return True
638
+ try:
639
+ return ipaddress.ip_address(host).is_loopback
640
+ except ValueError:
641
+ return False
642
+
643
+
644
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
645
+ """A redirect could leave https or loopback for any scheme or host: none is followed."""
646
+
647
+ def redirect_request(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
648
+ return None
649
+
650
+
651
+ _OPENER = urllib.request.build_opener(_NoRedirect)
652
+
653
+
654
+ def _fetch(source: str) -> Any:
655
+ if source.startswith(("https://", "http://")):
656
+ request = urllib.request.Request(source, headers={"Accept": "application/json"})
657
+ with _OPENER.open(request, timeout=5) as response: # https or loopback http only, no redirect
658
+ return json.loads(response.read(65537)[:65536])
659
+ return json.loads(Path(source).expanduser().read_text())
660
+
661
+
662
+ class JwksCache:
663
+ """The estate's key set from `auth.delegation.jwks` (https, http on loopback, or a file), cached as DL-15
664
+ says: at most 10 minutes, re-read at most once a minute for a kid it does not hold. `get()` answers None
665
+ when no set younger than 10 minutes can be had, which the verifier reports as issuer-unavailable."""
666
+
667
+ def __init__(self, source: str, *, fetch: Optional[Callable[[str], Any]] = None, clock: Callable[[], float] = time.time):
668
+ if not isinstance(source, str) or not source:
669
+ raise ValueError("the key set needs a source")
670
+ if source.startswith(("https://", "http://")):
671
+ parts = urllib.parse.urlsplit(source)
672
+ if parts.username or parts.password:
673
+ raise ValueError("a key set URL carries no credentials")
674
+ if parts.scheme == "http" and not _loopback(parts.hostname or ""):
675
+ raise ValueError("a key set is read over https, or over http on loopback only")
676
+ elif "://" in source:
677
+ raise ValueError("a key set is an https URL, a loopback http URL or a file path")
678
+ self.source = source
679
+ self._fetch = fetch or _fetch
680
+ self._clock = clock
681
+ self._keys: Optional[Dict[str, Any]] = None
682
+ self._read_at = -math.inf
683
+ self._tried_at = -math.inf
684
+ self._reread_at = -math.inf # the last re-read for a kid the set did not hold
685
+ self._lock = threading.Lock()
686
+
687
+ def _read(self, now: float) -> None:
688
+ self._tried_at = now
689
+ try:
690
+ value = self._fetch(self.source)
691
+ except (OSError, ValueError):
692
+ return
693
+ if _has_keys(value):
694
+ self._keys, self._read_at = value, now
695
+
696
+ def get(self, kid: Optional[str] = None) -> Optional[Dict[str, Any]]:
697
+ with self._lock:
698
+ now = self._clock()
699
+ fresh = now - self._read_at < JWKS_MAX_AGE_SECONDS
700
+ known = self._keys is not None and (kid is None or any(isinstance(k, dict) and k.get("kid") == kid for k in self._keys["keys"]))
701
+ if not fresh:
702
+ if now - self._tried_at >= JWKS_RETRY_SECONDS:
703
+ self._read(now)
704
+ elif not known and now - self._reread_at >= JWKS_REREAD_SECONDS:
705
+ self._reread_at = now
706
+ self._read(now)
707
+ return self._keys if now - self._read_at < JWKS_MAX_AGE_SECONDS else None
708
+
709
+
710
+ def _kid_of(token: str) -> Optional[str]:
711
+ parsed = _parse_compact(token)
712
+ kid = parsed.get("header", {}).get("kid") if "problem" not in parsed else None
713
+ return kid if isinstance(kid, str) else None
714
+
715
+
716
+ # --- the gate in front of a service's tools ------------------------------------------------------------------
717
+
718
+ def delegation_gate(*, audience: str, jwks: Any, effects: Mapping[str, str], issuer: Optional[str] = None,
719
+ read_only: bool = False, clock: Callable[[], float] = time.time) -> Callable[[str, Any], Dict[str, Any]]:
720
+ """A check for one tools/call: `gate(tool, headers)` answers the accepted delegation or a DL-19 refusal.
721
+ `effects` maps each tool to its declared effect class (its manifest entry); a tool with none is refused
722
+ rather than guessed. `jwks` is a JwksCache or a key set."""
723
+
724
+ def gate(tool: str, headers: Any) -> Dict[str, Any]:
725
+ effect = effects.get(tool)
726
+ if effect not in EFFECT_CLASSES:
727
+ return _refuse("unknown-state", "no-declared-effect")
728
+ carried = read_delegation_token(headers)
729
+ if carried is None:
730
+ return _refuse("unauthenticated", "no-token")
731
+ keys = jwks.get(_kid_of(carried["token"])) if isinstance(jwks, JwksCache) else jwks
732
+ return verify_delegation(carried["token"], audience=audience, jwks=keys, now=clock(), capability=tool,
733
+ effect=effect, issuer=issuer, read_only=read_only)
734
+
735
+ return gate
736
+
737
+
738
+ def refusal_error(refusal: Dict[str, Any]) -> Dict[str, Any]:
739
+ """The DL-19 body: {error: {code, message, data: {detail, ...}}}; never a token or a key."""
740
+ data = {"detail": refusal["detail"]}
741
+ data.update(refusal.get("data") or {})
742
+ return {"error": {"code": refusal["code"], "message": REFUSAL_MESSAGES.get(refusal["code"], "Refused."), "data": data}}
743
+
744
+
745
+ def refusal_tool_result(refusal: Dict[str, Any], traceparent: Optional[str] = None) -> Dict[str, Any]:
746
+ """An MCP tools/call result: the refusal as structuredContent with isError true (DL-19)."""
747
+ structured = refusal_error(refusal)
748
+ result: Dict[str, Any] = {"resultType": "complete", "content": [{"type": "text", "text": json.dumps(structured, ensure_ascii=False)}],
749
+ "structuredContent": structured, "isError": True}
750
+ if traceparent:
751
+ result["_meta"] = {"traceparent": traceparent}
752
+ return result
753
+
754
+
755
+ def refusal_http(refusal: Dict[str, Any]) -> Tuple[int, Dict[str, str], bytes]:
756
+ """An HTTP route's answer: status, headers and JSON body; a 401 names the bearer scheme (RFC 6750 §3)."""
757
+ status = REFUSAL_STATUS.get(refusal["code"], 403)
758
+ headers = {"Content-Type": "application/json", "Cache-Control": "no-store"}
759
+ if status == 401:
760
+ headers["WWW-Authenticate"] = 'Bearer error="invalid_token"'
761
+ return status, headers, json.dumps(refusal_error(refusal)).encode()