@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.
- package/CHANGELOG.md +92 -0
- package/README.md +3 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +5 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +6 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +98 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +8 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +492 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +865 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +47 -12
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py
ADDED
|
@@ -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()
|