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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,865 @@
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
+ # DL-2's grammar, as the contract's verifier types the claims (DEC-0045): an agent id, an opaque session or task id,
81
+ # a URI, a jti and a scope.
82
+ AGENT_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}(\.[a-z][a-z0-9-]{0,31})?$")
83
+ OPAQUE_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
84
+ _URI = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:\S+$")
85
+ _JTI = re.compile(r"^[A-Za-z0-9._~-]{16,128}$")
86
+ _SCOPE = re.compile(r"^[a-z][a-z0-9._-]{1,127}( [a-z][a-z0-9._-]{1,127})*$")
87
+ _SPEND_WINDOWS = ("total", "task", "project", "day", "week", "month")
88
+ # The small-order points of edwards25519 and their non-canonical encodings, sign bit cleared (the contract's
89
+ # src/ed25519.ts; libsodium's has_small_order list).
90
+ SMALL_ORDER_ENCODINGS = (
91
+ "0000000000000000000000000000000000000000000000000000000000000000", "0100000000000000000000000000000000000000000000000000000000000000",
92
+ "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05", "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a",
93
+ "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f",
94
+ "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f",
95
+ )
96
+ _MISSING = object()
97
+
98
+
99
+ def is_small_order_key(raw: bytes) -> bool:
100
+ """Whether a 32-byte Ed25519 public key is a point of small order, in any encoding (CT-29)."""
101
+ if len(raw) != 32:
102
+ return True
103
+ return (raw[:31] + bytes([raw[31] & 0x7F])).hex() in SMALL_ORDER_ENCODINGS
104
+
105
+
106
+ def _full(pattern: "re.Pattern[str]", value: Any) -> bool:
107
+ return isinstance(value, str) and pattern.fullmatch(value) is not None
108
+
109
+
110
+ def _refuse(code: str, detail: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
111
+ out: Dict[str, Any] = {"ok": False, "code": code, "detail": detail}
112
+ if data:
113
+ out["data"] = data
114
+ return out
115
+
116
+
117
+ def _is_string(value: Any) -> bool:
118
+ return isinstance(value, str) and len(value) > 0
119
+
120
+
121
+ def _is_time(value: Any) -> bool:
122
+ if isinstance(value, bool):
123
+ return False
124
+ if isinstance(value, int):
125
+ return value >= 0
126
+ return isinstance(value, float) and math.isfinite(value) and value.is_integer() and value >= 0
127
+
128
+
129
+ # --- Ed25519 (RFC 8032 §5.1) ----------------------------------------------------------------------------
130
+
131
+ _P = 2 ** 255 - 19
132
+ _L = 2 ** 252 + 27742317777372353535851937790883648493
133
+ _D = (-121665 * pow(121666, _P - 2, _P)) % _P
134
+ _SQRT_M1 = pow(2, (_P - 1) // 4, _P)
135
+
136
+
137
+ def _add(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> Tuple[int, int, int, int]:
138
+ x1, y1, z1, t1 = a
139
+ x2, y2, z2, t2 = b
140
+ aa = (y1 - x1) * (y2 - x2) % _P
141
+ bb = (y1 + x1) * (y2 + x2) % _P
142
+ cc = 2 * t1 * t2 * _D % _P
143
+ dd = 2 * z1 * z2 % _P
144
+ e, f, g, h = bb - aa, dd - cc, dd + cc, bb + aa
145
+ return (e * f % _P, g * h % _P, f * g % _P, e * h % _P)
146
+
147
+
148
+ def _mul(scalar: int, point: Tuple[int, int, int, int]) -> Tuple[int, int, int, int]:
149
+ result = (0, 1, 1, 0)
150
+ while scalar:
151
+ if scalar & 1:
152
+ result = _add(result, point)
153
+ point = _add(point, point)
154
+ scalar >>= 1
155
+ return result
156
+
157
+
158
+ def _decode_point(data: bytes) -> Optional[Tuple[int, int, int, int]]:
159
+ if len(data) != 32:
160
+ return None
161
+ y = int.from_bytes(data, "little")
162
+ sign = y >> 255
163
+ y &= (1 << 255) - 1
164
+ if y >= _P:
165
+ return None
166
+ x2 = (y * y - 1) * pow(_D * y * y + 1, _P - 2, _P) % _P
167
+ if x2 == 0:
168
+ if sign:
169
+ return None
170
+ return (0, y, 1, 0)
171
+ x = pow(x2, (_P + 3) // 8, _P)
172
+ if (x * x - x2) % _P:
173
+ x = x * _SQRT_M1 % _P
174
+ if (x * x - x2) % _P:
175
+ return None
176
+ if x & 1 != sign:
177
+ x = _P - x
178
+ return (x, y, 1, x * y % _P)
179
+
180
+
181
+ def _encode_point(point: Tuple[int, int, int, int]) -> bytes:
182
+ x, y, z, _ = point
183
+ zi = pow(z, _P - 2, _P)
184
+ x, y = x * zi % _P, y * zi % _P
185
+ return (y | ((x & 1) << 255)).to_bytes(32, "little")
186
+
187
+
188
+ _G = _decode_point(bytes.fromhex("5866666666666666666666666666666666666666666666666666666666666666"))
189
+ _IDENTITY = (1).to_bytes(32, "little")
190
+
191
+
192
+ def ed25519_verify(public_key: bytes, message: bytes, signature: bytes) -> bool:
193
+ """RFC 8032 §5.1.7, cofactorless: [S]B == R + [k]A, compared by R's encoding. False, never an exception."""
194
+ if len(public_key) != 32 or len(signature) != 64:
195
+ return False
196
+ s = int.from_bytes(signature[32:], "little")
197
+ if s >= _L:
198
+ return False
199
+ a = _decode_point(public_key)
200
+ if a is None or _encode_point(_mul(8, a)) == _IDENTITY:
201
+ return False # a small-order key would accept R = [S]B for a quarter or more of all messages; OpenSSL refuses it
202
+ k = int.from_bytes(hashlib.sha512(signature[:32] + public_key + message).digest(), "little") % _L
203
+ neg_a = ((-a[0]) % _P, a[1], a[2], (-a[3]) % _P)
204
+ return secrets.compare_digest(_encode_point(_add(_mul(s, _G), _mul(k, neg_a))), signature[:32])
205
+
206
+
207
+ # --- the compact document ------------------------------------------------------------------------------
208
+
209
+ def _b64decode(segment: str) -> bytes:
210
+ """Base64url without padding, as Node reads it: a dangling sixth bit group is ignored."""
211
+ if len(segment) % 4 == 1:
212
+ segment = segment[:-1]
213
+ return base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4))
214
+
215
+
216
+ class _Duplicate(ValueError):
217
+ pass
218
+
219
+
220
+ def _no_duplicates(pairs: List[Tuple[str, Any]]) -> Dict[str, Any]:
221
+ seen: Dict[str, Any] = {}
222
+ for key, value in pairs:
223
+ if key in seen:
224
+ raise _Duplicate(key)
225
+ seen[key] = value
226
+ return seen
227
+
228
+
229
+ def _no_constant(name: str) -> Any:
230
+ raise ValueError(name) # NaN and Infinity are not JSON
231
+
232
+
233
+ def _decode_segment(segment: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
234
+ if not _B64URL.match(segment):
235
+ return None, "malformed"
236
+ try:
237
+ text = _b64decode(segment).decode("utf-8", "replace")
238
+ value = json.loads(text, object_pairs_hook=_no_duplicates, parse_constant=_no_constant)
239
+ except _Duplicate:
240
+ try:
241
+ value = json.loads(text, parse_constant=_no_constant)
242
+ except (ValueError, RecursionError):
243
+ return None, "malformed"
244
+ return (None, "duplicate-member") if isinstance(value, dict) else (None, "malformed")
245
+ except (ValueError, TypeError, RecursionError):
246
+ return None, "malformed"
247
+ if not isinstance(value, dict):
248
+ return None, "malformed"
249
+ return value, None
250
+
251
+
252
+ def _utf16_length(text: str) -> int:
253
+ return len(text.encode("utf-16-le", "surrogatepass")) // 2
254
+
255
+
256
+ def _parse_compact(token: Any) -> Dict[str, Any]:
257
+ if not isinstance(token, str) or _utf16_length(token) > 8192:
258
+ return {"problem": "malformed"}
259
+ parts = token.split(".")
260
+ if len(parts) != 3:
261
+ return {"problem": "malformed"}
262
+ h, p, s = parts
263
+ header, header_problem = _decode_segment(h)
264
+ payload, payload_problem = _decode_segment(p)
265
+ if not _B64URL.match(s):
266
+ return {"problem": "malformed"}
267
+ for problem in (header_problem, payload_problem):
268
+ if problem:
269
+ return {"problem": problem}
270
+ try:
271
+ signature = _b64decode(s)
272
+ except ValueError:
273
+ signature = b""
274
+ return {"header": header, "payload": payload, "signing_input": ("%s.%s" % (h, p)).encode("ascii"), "signature": signature}
275
+
276
+
277
+ def _header_problem(header: Dict[str, Any], typ: str) -> Optional[str]:
278
+ if header.get("alg") != "EdDSA":
279
+ return "alg-not-eddsa"
280
+ if header.get("typ") != typ:
281
+ return "wrong-typ"
282
+ if not _is_string(header.get("kid")):
283
+ return "no-kid"
284
+ for member in ("crit", "jku", "jwk", "x5u", "x5c"):
285
+ if member in header:
286
+ return "header-" + member
287
+ return None
288
+
289
+
290
+ def _key_for(jwks: Dict[str, Any], kid: str) -> Dict[str, Any]:
291
+ matches = [k for k in jwks["keys"] if isinstance(k, dict) and k.get("kid") == kid]
292
+ if len(matches) > 1:
293
+ return {"problem": "duplicate-kid"}
294
+ if not matches:
295
+ return {"problem": "unknown-key"}
296
+ jwk = matches[0]
297
+ # A key published for another use or algorithm is never a candidate (RFC 7517 §4.2, §4.4).
298
+ if ("use" in jwk and jwk["use"] != "sig") or ("alg" in jwk and jwk["alg"] != "EdDSA"):
299
+ return {"problem": "key-not-for-signing"}
300
+ if jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519" or not _is_string(jwk.get("x")) or "d" in jwk:
301
+ return {"problem": "unusable-key"}
302
+ if not _B64URL.match(jwk["x"]):
303
+ return {"problem": "unusable-key"}
304
+ try:
305
+ raw = _b64decode(jwk["x"])
306
+ except ValueError:
307
+ return {"problem": "unusable-key"}
308
+ if len(raw) != 32:
309
+ return {"problem": "unusable-key"}
310
+ if is_small_order_key(raw):
311
+ return {"problem": "small-order-key"} # refused before any verification (DEC-0045, CT-29)
312
+ return {"key": raw}
313
+
314
+
315
+ def _has_keys(jwks: Any) -> bool:
316
+ return isinstance(jwks, dict) and isinstance(jwks.get("keys"), list) and len(jwks["keys"]) > 0
317
+
318
+
319
+ def _authentic(token: Any, jwks: Dict[str, Any], typ: str) -> Dict[str, Any]:
320
+ """Parse, check the header, find the key, verify the signature. A fault of the published keys is the issuer's."""
321
+ compact = _parse_compact(token)
322
+ if "problem" in compact:
323
+ return compact
324
+ problem = _header_problem(compact["header"], typ)
325
+ if problem:
326
+ return {"problem": problem}
327
+ kid = compact["header"]["kid"]
328
+ found = _key_for(jwks, kid)
329
+ if "problem" in found:
330
+ # Only an unknown kid is the token's fault; a published key that cannot be used is the issuer's (DL-13 row 3).
331
+ return {"problem": found["problem"], "issuer": found["problem"] != "unknown-key"}
332
+ if len(compact["signature"]) != 64 or not ed25519_verify(found["key"], compact["signing_input"], compact["signature"]):
333
+ return {"problem": "bad-signature"}
334
+ return {"compact": compact, "kid": kid}
335
+
336
+
337
+ def chain_depth(act: Any) -> float:
338
+ """Depth of an actor chain; infinity for a chain that is not one (a missing or non-string sub at any level)."""
339
+ depth = 0
340
+ node = act
341
+ while node is not _MISSING:
342
+ if not isinstance(node, dict) or not _is_string(node.get("sub")):
343
+ return math.inf
344
+ depth += 1
345
+ if depth > 64:
346
+ return depth
347
+ node = node.get("act", _MISSING)
348
+ return depth
349
+
350
+
351
+ def _chain_of(act: Dict[str, Any]) -> List[str]:
352
+ chain = []
353
+ node: Any = act
354
+ while isinstance(node, dict):
355
+ chain.append(node["sub"])
356
+ node = node.get("act")
357
+ return chain
358
+
359
+
360
+ def effect_closure(ceiling: List[str]) -> set:
361
+ """`none` is below every class and `draft` below every external one; external classes are not ordered."""
362
+ admitted = set(ceiling)
363
+ if ceiling:
364
+ admitted.add("none")
365
+ if any(effect != "none" for effect in ceiling):
366
+ admitted.add("draft")
367
+ return admitted
368
+
369
+
370
+ def effect_within(effect: str, ceiling: List[str]) -> bool:
371
+ return effect in effect_closure(ceiling)
372
+
373
+
374
+ def _actor_chain_depth(act: Any) -> Optional[int]:
375
+ """The chain's depth when every actor is DL-2's (an agent id `sub`, an optional session id), else None. A token
376
+ with no `act` has no caller, so it is no chain either."""
377
+ if act is _MISSING:
378
+ return None
379
+ depth = 0
380
+ node = act
381
+ while node is not _MISSING:
382
+ if not isinstance(node, dict) or not _full(AGENT_ID, node.get("sub")):
383
+ return None
384
+ if "session" in node and not _full(OPAQUE_ID, node["session"]):
385
+ return None
386
+ depth += 1
387
+ node = node.get("act", _MISSING)
388
+ return depth
389
+
390
+
391
+ def _is_amount(value: Any) -> bool:
392
+ return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) and value >= 0
393
+
394
+
395
+ def _spend_shaped(spend: Any) -> bool:
396
+ if not isinstance(spend, dict):
397
+ return False
398
+ if spend.get("mode") == "unlimited":
399
+ return True
400
+ remaining = spend.get("remaining")
401
+ if spend.get("mode") != "limited" or not isinstance(remaining, list) or not 1 <= len(remaining) <= 6:
402
+ return False
403
+ if not all(isinstance(e, dict) and e.get("window") in _SPEND_WINDOWS and _is_amount(e.get("remainingUsd")) for e in remaining):
404
+ return False
405
+ return "perCallMaxUsd" not in spend or _is_amount(spend["perCallMaxUsd"])
406
+
407
+
408
+ def _delegation_shape(payload: Dict[str, Any]) -> Tuple[Optional[str], Optional[int]]:
409
+ """DL-13 row 5 as DEC-0045 types it: (problem, depth). A claim the profile does not know is ignored."""
410
+ depth = _actor_chain_depth(payload.get("act", _MISSING))
411
+ if depth is None:
412
+ return "malformed-chain", None
413
+ effect, grant = payload.get("effect"), payload.get("grant")
414
+ typed = (_full(_URI, payload.get("iss")) and _full(_URI, payload.get("sub")) and _full(AGENT_ID, payload.get("aud"))
415
+ and isinstance(payload.get("scope"), str) and len(payload["scope"]) <= 4096 and _full(_SCOPE, payload["scope"])
416
+ and isinstance(effect, list) and len(effect) > 0 and all(isinstance(e, str) and e in EFFECT_CLASSES for e in effect)
417
+ and len(set(effect)) == len(effect)
418
+ and isinstance(grant, list) and len(grant) == 1 and isinstance(grant[0], str) and 1 <= _utf16_length(grant[0]) <= 256
419
+ and ("spend" not in payload or _spend_shaped(payload["spend"]))
420
+ and ("project" not in payload or _full(_URI, payload["project"]))
421
+ and ("task" not in payload or _full(OPAQUE_ID, payload["task"]))
422
+ and _is_time(payload.get("iat")) and _is_time(payload.get("exp"))
423
+ and ("nbf" not in payload or _is_time(payload["nbf"])) and _full(_JTI, payload.get("jti")))
424
+ return (None, depth) if typed else ("malformed-claims", None)
425
+
426
+
427
+ def _approval_shape(payload: Dict[str, Any]) -> bool:
428
+ if not all(_is_string(payload.get(k)) for k in ("iss", "actionDigest", "aud", "approver", "jti")):
429
+ return False
430
+ if not _is_time(payload.get("iat")) or not _is_time(payload.get("exp")):
431
+ return False
432
+ return payload.get("method") in ("prompt", "operator-channel") # DEC-0045: `device` is removed
433
+
434
+
435
+ def verify_delegation(token: Any, *, audience: str, jwks: Any, now: float, capability: str, effect: str,
436
+ issuer: Optional[str], read_only: bool = False) -> Dict[str, Any]:
437
+ """DL-13, in its fixed order: nothing about a token is believed before its signature is.
438
+ `now` is seconds since the epoch. Returns {ok: True, claims, principal, caller, chain, kid} or a refusal
439
+ {ok: False, code, detail[, data]} whose detail is a short machine word, never the token. `issuer` is required."""
440
+ if not _is_string(issuer):
441
+ return _refuse("issuer-unavailable", "no-trusted-issuer")
442
+ if not _has_keys(jwks):
443
+ return _refuse("issuer-unavailable", "no-published-keys")
444
+ checked = _authentic(token, jwks, DELEGATION_TYP)
445
+ if "problem" in checked:
446
+ return _refuse("issuer-unavailable" if checked.get("issuer") else "unauthenticated", checked["problem"])
447
+ claims = checked["compact"]["payload"]
448
+ problem, depth = _delegation_shape(claims)
449
+ if problem:
450
+ return _refuse("unauthenticated", problem)
451
+ if claims["iss"] != issuer:
452
+ return _refuse("unauthenticated", "wrong-issuer")
453
+ if claims["exp"] <= claims["iat"] or claims["exp"] - claims["iat"] > MAX_DELEGATION_LIFETIME_SECONDS:
454
+ return _refuse("unauthenticated", "lifetime-above-profile")
455
+ if claims["iat"] > now + LEEWAY_SECONDS:
456
+ return _refuse("unauthenticated", "issued-in-future")
457
+ if "nbf" in claims and claims["nbf"] > now + LEEWAY_SECONDS:
458
+ return _refuse("unauthenticated", "not-yet-valid")
459
+ if now > claims["exp"] + LEEWAY_SECONDS:
460
+ return _refuse("token-expired", "expired")
461
+ if claims["aud"] != audience:
462
+ return _refuse("wrong-audience", "audience-mismatch")
463
+ if depth > MAX_CHAIN_DEPTH:
464
+ return _refuse("chain-too-deep", "depth-%d" % depth)
465
+ if capability not in claims["scope"].split(" "):
466
+ return _refuse("scope-not-granted", "capability-not-in-scope")
467
+ if read_only and effect != "none":
468
+ return _refuse("effect-above-ceiling", "read-only-door")
469
+ if not effect_within(effect, claims["effect"]):
470
+ return _refuse("effect-above-ceiling", "effect-%s" % effect)
471
+ return {"ok": True, "claims": claims, "principal": claims["sub"], "caller": claims["act"]["sub"],
472
+ "chain": _chain_of(claims["act"]), "kid": checked["kid"]}
473
+
474
+
475
+ class MemoryJtiStore:
476
+ """Consumed approval ids in memory: for tests and a single process that never restarts mid-approval."""
477
+
478
+ def __init__(self) -> None:
479
+ self._seen: Dict[str, float] = {}
480
+ self._lock = threading.Lock()
481
+
482
+ def consume(self, jti: str, expires_at: float, now: float) -> bool:
483
+ with self._lock:
484
+ for key, until in list(self._seen.items()):
485
+ if until + LEEWAY_SECONDS < now:
486
+ del self._seen[key]
487
+ if jti in self._seen:
488
+ return False
489
+ self._seen[jti] = expires_at
490
+ return True
491
+
492
+
493
+ class JtiStoreError(RuntimeError):
494
+ """A jti store that cannot tell whether an id was consumed; the verifier answers unknown-state."""
495
+
496
+
497
+ class FileJtiStore:
498
+ """Consumed approval ids in a 0600 JSON file, so a second use is refused across a restart (DL-18). One
499
+ service instance writes it (the kit's instance lock keeps other processes out); threads are serialised."""
500
+
501
+ _locks: Dict[str, threading.Lock] = {}
502
+ _locks_guard = threading.Lock()
503
+
504
+ def __init__(self, path: Path) -> None:
505
+ self.path = Path(path)
506
+ with FileJtiStore._locks_guard:
507
+ self._lock = FileJtiStore._locks.setdefault(str(self.path.resolve()), threading.Lock())
508
+
509
+ def consume(self, jti: str, expires_at: float, now: float) -> bool:
510
+ """True the first time; False for a used id. Raises JtiStoreError when the file cannot be read: forgetting
511
+ every consumed id would let each approval be replayed."""
512
+ with self._lock:
513
+ try:
514
+ seen = json.loads(self.path.read_text())
515
+ except FileNotFoundError:
516
+ seen = {}
517
+ except (OSError, ValueError) as exc:
518
+ raise JtiStoreError("the consumed-approval file cannot be read: %s" % exc.__class__.__name__) from None
519
+ if not isinstance(seen, dict):
520
+ raise JtiStoreError("the consumed-approval file is not a JSON object")
521
+ seen = {k: v for k, v in seen.items() if isinstance(v, (int, float)) and v + LEEWAY_SECONDS >= now}
522
+ if jti in seen:
523
+ return False
524
+ seen[jti] = expires_at
525
+ self.path.parent.mkdir(parents=True, exist_ok=True)
526
+ temp = self.path.with_name(".%s.%s.tmp" % (self.path.name, secrets.token_hex(4)))
527
+ fd = os.open(str(temp), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
528
+ with os.fdopen(fd, "w") as handle:
529
+ json.dump(seen, handle)
530
+ handle.flush()
531
+ os.fsync(handle.fileno())
532
+ os.replace(str(temp), str(self.path))
533
+ return True
534
+
535
+
536
+ class _NoReplayProtection:
537
+ def consume(self, jti: str, expires_at: float, now: float) -> bool: # noqa: ARG002
538
+ return True
539
+
540
+
541
+ # Every approval may be replayed until it expires. Only on purpose, by name.
542
+ UNSAFE_NO_JTI_STORE = _NoReplayProtection()
543
+
544
+
545
+ def verify_approval(approval: Optional[str], *, audience: str, jwks: Any, now: float, action_digest: str,
546
+ jti_store: Any, issuer: Optional[str]) -> Dict[str, Any]:
547
+ """DL-18: a missing approval is approval-required carrying the action digest; any fault of the document is
548
+ approval-invalid; a second use is approval-reused. The jti is consumed last."""
549
+ if approval is None or approval == "":
550
+ return _refuse("approval-required", "no-approval", {"actionDigest": action_digest})
551
+ if jti_store is None or not callable(getattr(jti_store, "consume", None)):
552
+ return _refuse("unknown-state", "no-jti-store")
553
+ if not _is_string(issuer):
554
+ return _refuse("issuer-unavailable", "no-trusted-issuer")
555
+ if not _has_keys(jwks):
556
+ return _refuse("issuer-unavailable", "no-published-keys")
557
+ checked = _authentic(approval, jwks, APPROVAL_TYP)
558
+ if "problem" in checked:
559
+ return _refuse("issuer-unavailable" if checked.get("issuer") else "approval-invalid", checked["problem"])
560
+ claims = checked["compact"]["payload"]
561
+ if not _approval_shape(claims):
562
+ return _refuse("approval-invalid", "malformed-claims")
563
+ if claims["iss"] != issuer:
564
+ return _refuse("approval-invalid", "wrong-issuer")
565
+ if claims["exp"] <= claims["iat"] or claims["exp"] - claims["iat"] > MAX_APPROVAL_LIFETIME_SECONDS:
566
+ return _refuse("approval-invalid", "lifetime-above-profile")
567
+ if claims["iat"] > now + LEEWAY_SECONDS:
568
+ return _refuse("approval-invalid", "issued-in-future")
569
+ if now > claims["exp"] + LEEWAY_SECONDS:
570
+ return _refuse("approval-invalid", "expired")
571
+ if claims["aud"] != audience:
572
+ return _refuse("approval-invalid", "audience-mismatch")
573
+ if claims["actionDigest"] != action_digest:
574
+ return _refuse("approval-invalid", "digest-mismatch", {"actionDigest": action_digest})
575
+ try:
576
+ fresh = jti_store.consume(claims["jti"], claims["exp"], now)
577
+ except Exception: # a store that cannot answer is an unknown state, never a pass (DL-19)
578
+ return _refuse("unknown-state", "jti-store-failed")
579
+ if not fresh:
580
+ return _refuse("approval-reused", "jti-consumed")
581
+ return {"ok": True, "claims": claims, "approver": claims["approver"], "method": claims["method"], "kid": checked["kid"]}
582
+
583
+
584
+ def _header(headers: Any, name: str) -> Optional[str]:
585
+ if headers is None:
586
+ return None
587
+ if hasattr(headers, "get_all"): # http.server's and email's message: case-insensitive
588
+ values = headers.get_all(name)
589
+ return values[0] if values else None
590
+ if isinstance(headers, Mapping):
591
+ for key, value in headers.items():
592
+ if isinstance(key, str) and key.lower() == name.lower():
593
+ if isinstance(value, (list, tuple)):
594
+ return str(value[0]) if value else ""
595
+ return value
596
+ return None
597
+
598
+
599
+ def read_delegation_token(headers: Any) -> Optional[Dict[str, str]]:
600
+ """DL-9, DL-10: the hub's Fabric-Delegation header first (present but empty is still the token, refused
601
+ later, never a fall-through), then a delegation-typ JWT in Authorization: Bearer."""
602
+ forwarded = _header(headers, DELEGATION_HEADER)
603
+ if forwarded is not None:
604
+ return {"token": forwarded.strip(_JS_WHITESPACE), "via": "fabric-delegation"}
605
+ match = re.fullmatch(r"Bearer\s+(\S+)", _header(headers, "Authorization") or "", re.IGNORECASE)
606
+ if match:
607
+ parsed = _parse_compact(match.group(1))
608
+ if "problem" not in parsed and parsed["header"].get("typ") == DELEGATION_TYP:
609
+ return {"token": match.group(1), "via": "authorization"}
610
+ return None
611
+
612
+
613
+ # --- RFC 8785 canonical JSON --------------------------------------------------------------------------------
614
+
615
+ def _number(value: float) -> str:
616
+ """ECMAScript Number::toString, which RFC 8785 §3.2.2.3 prescribes."""
617
+ if value == 0:
618
+ return "0"
619
+ if value < 0:
620
+ return "-" + _number(-value)
621
+ digits, exponent = _shortest(value)
622
+ k, n = len(digits), len(digits) + exponent
623
+ if k <= n <= 21:
624
+ return digits + "0" * (n - k)
625
+ if 0 < n <= 21:
626
+ return digits[:n] + "." + digits[n:]
627
+ if -6 < n <= 0:
628
+ return "0." + "0" * (-n) + digits
629
+ e = n - 1
630
+ mantissa = digits if k == 1 else digits[0] + "." + digits[1:]
631
+ return "%se%s%d" % (mantissa, "+" if e >= 0 else "-", abs(e))
632
+
633
+
634
+ def _shortest(value: float) -> Tuple[str, int]:
635
+ """The shortest round-trip digits of a positive finite double (Python's repr is shortest round-trip)."""
636
+ text = repr(value)
637
+ mantissa, _, exp = text.partition("e")
638
+ whole, _, frac = mantissa.partition(".")
639
+ digits = (whole + frac).lstrip("0")
640
+ exponent = (int(exp) if exp else 0) - len(frac)
641
+ if not digits:
642
+ return "0", 0
643
+ stripped = digits.rstrip("0")
644
+ exponent += len(digits) - len(stripped)
645
+ return stripped, exponent
646
+
647
+
648
+ _ESCAPES = {'"': '\\"', "\\": "\\\\", "\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t"}
649
+
650
+
651
+ def _string(value: str) -> str:
652
+ out = ['"']
653
+ for ch in value:
654
+ code = ord(ch)
655
+ if 0xD800 <= code <= 0xDFFF:
656
+ raise TypeError("a lone surrogate has no canonical form")
657
+ if ch in _ESCAPES:
658
+ out.append(_ESCAPES[ch])
659
+ elif code < 0x20:
660
+ out.append("\\u%04x" % code)
661
+ else:
662
+ out.append(ch)
663
+ out.append('"')
664
+ return "".join(out)
665
+
666
+
667
+ def jcs(value: Any) -> str:
668
+ """RFC 8785: members sorted by UTF-16 code units, numbers in ECMAScript form, strings as JSON.stringify
669
+ escapes them. Refuses what has no canonical form: a non-finite number, a lone surrogate, a non-JSON value.
670
+ A JSON number is a double: an integer beyond 2**53 is written as JavaScript would write it."""
671
+ if value is None:
672
+ return "null"
673
+ if value is True:
674
+ return "true"
675
+ if value is False:
676
+ return "false"
677
+ if isinstance(value, int):
678
+ if abs(value) < 2 ** 53:
679
+ return str(value)
680
+ try:
681
+ return _number(float(value))
682
+ except OverflowError:
683
+ raise TypeError("a number beyond a double has no canonical form") from None
684
+ if isinstance(value, float):
685
+ if not math.isfinite(value):
686
+ raise TypeError("a non-finite number has no canonical form")
687
+ return _number(value)
688
+ if isinstance(value, str):
689
+ return _string(value)
690
+ if isinstance(value, list):
691
+ return "[" + ",".join(jcs(item) for item in value) + "]"
692
+ if isinstance(value, dict):
693
+ if not all(isinstance(key, str) for key in value):
694
+ raise TypeError("only string member names have a canonical form")
695
+ keys = sorted(value, key=lambda key: key.encode("utf-16-be", "surrogatepass"))
696
+ return "{" + ",".join(_string(key) + ":" + jcs(value[key]) for key in keys) + "}"
697
+ raise TypeError("%s has no canonical form" % type(value).__name__)
698
+
699
+
700
+ def action_digest(action: Any) -> str:
701
+ """The digest an approval binds: sha256: and the hex SHA-256 of the action's RFC 8785 form."""
702
+ return "sha256:" + hashlib.sha256(jcs(action).encode("utf-8")).hexdigest()
703
+
704
+
705
+ # --- the estate's keys ----------------------------------------------------------------------------------------
706
+
707
+ def _loopback(host: str) -> bool:
708
+ if host == "localhost":
709
+ return True
710
+ try:
711
+ return ipaddress.ip_address(host).is_loopback
712
+ except ValueError:
713
+ return False
714
+
715
+
716
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
717
+ """A redirect could leave https or loopback for any scheme or host: none is followed."""
718
+
719
+ def redirect_request(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
720
+ return None
721
+
722
+
723
+ _OPENER = urllib.request.build_opener(_NoRedirect)
724
+
725
+
726
+ def _fetch(source: str) -> Any:
727
+ if source.startswith(("https://", "http://")):
728
+ request = urllib.request.Request(source, headers={"Accept": "application/json"})
729
+ try:
730
+ with _OPENER.open(request, timeout=5) as response: # https or loopback http only, no redirect
731
+ return json.loads(response.read(65537)[:65536])
732
+ except urllib.error.HTTPError as exc:
733
+ exc.close()
734
+ raise
735
+ return json.loads(Path(source).expanduser().read_text())
736
+
737
+
738
+ class JwksCache:
739
+ """The estate's key set from `auth.delegation.jwks` (https, http on loopback, or a file), cached as DL-15
740
+ says: at most 10 minutes, re-read at most once a minute for a kid it does not hold. `get()` answers None
741
+ when no set younger than 10 minutes can be had, which the verifier reports as issuer-unavailable."""
742
+
743
+ def __init__(self, source: str, *, fetch: Optional[Callable[[str], Any]] = None, clock: Callable[[], float] = time.time):
744
+ if not isinstance(source, str) or not source:
745
+ raise ValueError("the key set needs a source")
746
+ if source.startswith(("https://", "http://")):
747
+ parts = urllib.parse.urlsplit(source)
748
+ if parts.username or parts.password:
749
+ raise ValueError("a key set URL carries no credentials")
750
+ if parts.scheme == "http" and not _loopback(parts.hostname or ""):
751
+ raise ValueError("a key set is read over https, or over http on loopback only")
752
+ elif "://" in source:
753
+ raise ValueError("a key set is an https URL, a loopback http URL or a file path")
754
+ self.source = source
755
+ self._fetch = fetch or _fetch
756
+ self._clock = clock
757
+ self._keys: Optional[Dict[str, Any]] = None
758
+ self._read_at = -math.inf
759
+ self._tried_at = -math.inf
760
+ self._reread_at = -math.inf # the last re-read for a kid the set did not hold
761
+ self._lock = threading.Lock()
762
+
763
+ def _read(self, now: float) -> None:
764
+ self._tried_at = now
765
+ try:
766
+ value = self._fetch(self.source)
767
+ except (OSError, ValueError):
768
+ return
769
+ if _has_keys(value):
770
+ self._keys, self._read_at = value, now
771
+
772
+ def get(self, kid: Optional[str] = None) -> Optional[Dict[str, Any]]:
773
+ with self._lock:
774
+ now = self._clock()
775
+ fresh = now - self._read_at < JWKS_MAX_AGE_SECONDS
776
+ 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"]))
777
+ if not fresh:
778
+ if now - self._tried_at >= JWKS_RETRY_SECONDS:
779
+ self._read(now)
780
+ elif not known and now - self._reread_at >= JWKS_REREAD_SECONDS:
781
+ self._reread_at = now
782
+ self._read(now)
783
+ return self._keys if now - self._read_at < JWKS_MAX_AGE_SECONDS else None
784
+
785
+
786
+ def _kid_of(token: str) -> Optional[str]:
787
+ parsed = _parse_compact(token)
788
+ kid = parsed.get("header", {}).get("kid") if "problem" not in parsed else None
789
+ return kid if isinstance(kid, str) else None
790
+
791
+
792
+ # --- the gate in front of a service's tools ------------------------------------------------------------------
793
+
794
+ TIERS = ("T0", "T1", "T2")
795
+
796
+
797
+ def delegation_gate(*, audience: str, jwks: Any, effects: Mapping[str, str], issuer: str,
798
+ read_only: bool = False, clock: Callable[[], float] = time.time) -> Callable[..., Dict[str, Any]]:
799
+ """A check for one tools/call: `gate(tool, headers, tier="T2")` answers the accepted delegation or a DL-19
800
+ refusal. `effects` maps each tool to its declared effect class (its manifest entry); a tool with none is
801
+ refused rather than guessed. `jwks` is a JwksCache or a key set; `issuer` (auth.delegation.issuer) is required.
802
+
803
+ DL-25, DL-26: `tier` is the caller's origin. T0 is a loopback caller under the owner's account that the service's
804
+ own local floor has authenticated; T1 an enrolled machine of the same owner; T2 everything else, and any call
805
+ relayed from off the machine. A T0 or T1 caller is never required to present a token: with none, the call
806
+ passes with no delegation ({ok: True, tier, principal: None, ...}). A present token is verified at every tier
807
+ and refused when invalid. Unknown tiers count as T2."""
808
+ if not _is_string(issuer):
809
+ raise ValueError("the gate needs the issuer it trusts (auth.delegation.issuer)")
810
+
811
+ def gate(tool: str, headers: Any, tier: str = "T2") -> Dict[str, Any]:
812
+ effect = effects.get(tool)
813
+ if effect not in EFFECT_CLASSES:
814
+ return _refuse("unknown-state", "no-declared-effect")
815
+ carried = read_delegation_token(headers)
816
+ if carried is None:
817
+ if tier in ("T0", "T1"):
818
+ return {"ok": True, "tier": tier, "principal": None, "caller": None, "chain": [], "claims": None, "kid": None}
819
+ return _refuse("unauthenticated", "no-token")
820
+ keys = jwks.get(_kid_of(carried["token"])) if isinstance(jwks, JwksCache) else jwks
821
+ verdict = verify_delegation(carried["token"], audience=audience, jwks=keys, now=clock(), capability=tool,
822
+ effect=effect, issuer=issuer, read_only=read_only)
823
+ if verdict["ok"]:
824
+ verdict["tier"] = tier if tier in TIERS else "T2"
825
+ return verdict
826
+
827
+ return gate
828
+
829
+
830
+ def refusal_from_denial(denial: Dict[str, Any]) -> Dict[str, Any]:
831
+ """DL-21 step 6 (DEC-0045): the refusal a callee answers after the meter denied a grant check. The meter's code
832
+ goes unchanged into error.code; its window, level (when set) and approvalRequest into error.data."""
833
+ data = {k: denial[k] for k in ("window", "approvalRequest") if denial.get(k) is not None}
834
+ if denial.get("level") is not None:
835
+ data["level"] = denial["level"]
836
+ error: Dict[str, Any] = {"code": denial["code"], "message": ("the meter denied the spend: %s" % denial.get("reason", ""))[:500]}
837
+ if data:
838
+ error["data"] = data
839
+ return {"error": error}
840
+
841
+
842
+ def refusal_error(refusal: Dict[str, Any]) -> Dict[str, Any]:
843
+ """The DL-19 body: {error: {code, message, data: {detail, ...}}}; never a token or a key."""
844
+ data = {"detail": refusal["detail"]}
845
+ data.update(refusal.get("data") or {})
846
+ return {"error": {"code": refusal["code"], "message": REFUSAL_MESSAGES.get(refusal["code"], "Refused."), "data": data}}
847
+
848
+
849
+ def refusal_tool_result(refusal: Dict[str, Any], traceparent: Optional[str] = None) -> Dict[str, Any]:
850
+ """An MCP tools/call result: the refusal as structuredContent with isError true (DL-19)."""
851
+ structured = refusal_error(refusal)
852
+ result: Dict[str, Any] = {"resultType": "complete", "content": [{"type": "text", "text": json.dumps(structured, ensure_ascii=False)}],
853
+ "structuredContent": structured, "isError": True}
854
+ if traceparent:
855
+ result["_meta"] = {"traceparent": traceparent}
856
+ return result
857
+
858
+
859
+ def refusal_http(refusal: Dict[str, Any]) -> Tuple[int, Dict[str, str], bytes]:
860
+ """An HTTP route's answer: status, headers and JSON body; a 401 names the bearer scheme (RFC 6750 §3)."""
861
+ status = REFUSAL_STATUS.get(refusal["code"], 403)
862
+ headers = {"Content-Type": "application/json", "Cache-Control": "no-store"}
863
+ if status == 401:
864
+ headers["WWW-Authenticate"] = 'Bearer error="invalid_token"'
865
+ return status, headers, json.dumps(refusal_error(refusal)).encode()