regent-httpsig 0.5.1__py3-none-any.whl → 0.7.0__py3-none-any.whl
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.
- regent_httpsig/__init__.py +33 -7
- regent_httpsig/budget.py +104 -4
- regent_httpsig/config.py +31 -16
- regent_httpsig/fastapi.py +189 -106
- regent_httpsig/sfv.py +58 -6
- regent_httpsig/sign.py +232 -24
- regent_httpsig/usage.py +119 -14
- regent_httpsig/verify.py +456 -202
- {regent_httpsig-0.5.1.dist-info → regent_httpsig-0.7.0.dist-info}/METADATA +67 -24
- regent_httpsig-0.7.0.dist-info/RECORD +16 -0
- {regent_httpsig-0.5.1.dist-info → regent_httpsig-0.7.0.dist-info}/WHEEL +1 -1
- regent_httpsig-0.5.1.dist-info/RECORD +0 -16
- {regent_httpsig-0.5.1.dist-info → regent_httpsig-0.7.0.dist-info}/entry_points.txt +0 -0
- {regent_httpsig-0.5.1.dist-info → regent_httpsig-0.7.0.dist-info}/licenses/LICENSE +0 -0
regent_httpsig/sign.py
CHANGED
|
@@ -1,39 +1,63 @@
|
|
|
1
|
-
"""Outbound RFC 9421 signing
|
|
1
|
+
"""Outbound RFC 9421 signing — give your agent (or your server) a verifiable identity.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
publish at ``{signature_agent}/.well-known/http-message-signatures-directory``.
|
|
5
|
-
Verifiers that speak Web Bot Auth (Cloudflare, AWS WAF, Vercel, this library…)
|
|
6
|
-
then see a *signed agent* instead of an anonymous bot.
|
|
3
|
+
Three signers, one key format (a 32-byte Ed25519 seed, base64url):
|
|
7
4
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
- :class:`EgressSigner` signs as a **Web Bot Auth** agent: ``tag="web-bot-auth"``,
|
|
6
|
+
key published at ``{signature_agent}/.well-known/http-message-signatures-directory``.
|
|
7
|
+
Verifiers that speak Web Bot Auth (Cloudflare, AWS WAF, Vercel, this library…)
|
|
8
|
+
then see a *signed agent* instead of an anonymous bot. The ``Signature-Agent``
|
|
9
|
+
header is emitted in the LEGACY sf-string form (``"https://…"``) — the form
|
|
10
|
+
OpenAI ships in production, accepted by every deployed verifier today.
|
|
11
|
+
- :meth:`EgressSigner.sign_aauth` signs as an **AAuth** agent (-11 §11.3.3): the
|
|
12
|
+
token rides in ``Signature-Key`` under the ``jwt`` scheme, the signature covers
|
|
13
|
+
``@method @authority @path signature-key`` (plus ``content-digest`` and
|
|
14
|
+
``content-type`` when there is a body), ``created`` only — no ``keyid``, no
|
|
15
|
+
``tag``, no ``alg`` parameter (the key comes from the token's ``cnf.jwk``).
|
|
16
|
+
- :class:`ServerSigner` signs as an AAuth **server** (-11 §11.3.2): a PS or AS
|
|
17
|
+
calling a resource's usage or revocation endpoint, under the ``jwks_uri``
|
|
18
|
+
scheme with ``id``, ``dwk`` and ``kid``.
|
|
12
19
|
|
|
13
20
|
Unlike a service-level integration, this library FAILS LOUD: a bad seed raises
|
|
14
|
-
at construction and a signing error raises from
|
|
15
|
-
|
|
21
|
+
at construction and a signing error raises from the ``sign`` methods. Wrap in
|
|
22
|
+
try/except yourself if your egress path must never break on signing.
|
|
16
23
|
"""
|
|
17
24
|
|
|
18
25
|
from __future__ import annotations
|
|
19
26
|
|
|
27
|
+
import base64
|
|
28
|
+
import hashlib
|
|
20
29
|
import secrets
|
|
30
|
+
import time
|
|
31
|
+
from collections.abc import Mapping
|
|
21
32
|
from datetime import datetime, timedelta
|
|
22
33
|
from typing import Any
|
|
34
|
+
from urllib.parse import urlsplit
|
|
23
35
|
|
|
36
|
+
from cryptography.hazmat.primitives import hashes
|
|
37
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
24
38
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
39
|
+
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
|
|
25
40
|
from http_message_signatures import ( # type: ignore[attr-defined]
|
|
26
41
|
HTTPMessageSigner,
|
|
27
42
|
HTTPSignatureKeyResolver,
|
|
28
43
|
)
|
|
29
44
|
|
|
45
|
+
from regent_httpsig.config import AAUTH_REQUIRED_COMPONENTS
|
|
30
46
|
from regent_httpsig.jwk import b64url, b64url_decode, jwk_thumbprint
|
|
31
47
|
from regent_httpsig.sfv import ED25519, Message
|
|
32
48
|
|
|
33
|
-
__all__ = [
|
|
49
|
+
__all__ = [
|
|
50
|
+
"DIRECTORY_MEDIA_TYPE",
|
|
51
|
+
"EgressSigner",
|
|
52
|
+
"ServerSigner",
|
|
53
|
+
"content_digest",
|
|
54
|
+
"generate_seed",
|
|
55
|
+
"sign_request",
|
|
56
|
+
]
|
|
34
57
|
|
|
35
58
|
DIRECTORY_MEDIA_TYPE = "application/http-message-signatures-directory+json"
|
|
36
59
|
_DEFAULT_COVERED = ("@method", "@authority", "@path", "signature-agent")
|
|
60
|
+
_BODY_COMPONENTS = ("content-digest", "content-type")
|
|
37
61
|
|
|
38
62
|
|
|
39
63
|
def generate_seed() -> str:
|
|
@@ -41,6 +65,119 @@ def generate_seed() -> str:
|
|
|
41
65
|
return b64url(secrets.token_bytes(32))
|
|
42
66
|
|
|
43
67
|
|
|
68
|
+
def content_digest(body: bytes) -> str:
|
|
69
|
+
"""RFC 9530 ``Content-Digest`` value for ``body`` (sha-256, sf-byte-sequence)."""
|
|
70
|
+
return "sha-256=:" + base64.b64encode(hashlib.sha256(body).digest()).decode() + ":"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _load_seed(seed: str) -> Ed25519PrivateKey:
|
|
74
|
+
raw = b64url_decode(seed)
|
|
75
|
+
if len(raw) != 32:
|
|
76
|
+
raise ValueError("seed must be 32 bytes (base64url-encoded)")
|
|
77
|
+
return Ed25519PrivateKey.from_private_bytes(raw)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _ed25519_jwk(key: Ed25519PrivateKey) -> dict[str, Any]:
|
|
81
|
+
return {"kty": "OKP", "crv": "Ed25519", "x": b64url(key.public_key().public_bytes_raw())}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ── RFC 9421 signature base, request side ─────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _field(headers: Mapping[str, str], name: str) -> str:
|
|
88
|
+
"""RFC 9421 §2.1: the field value, whitespace trimmed, instances joined by ', '."""
|
|
89
|
+
values = [v.strip() for k, v in headers.items() if k.lower() == name]
|
|
90
|
+
if not values:
|
|
91
|
+
raise ValueError(f'covered header "{name}" is not in the message')
|
|
92
|
+
return ", ".join(values)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _derived(name: str, method: str, url: str) -> str:
|
|
96
|
+
parts = urlsplit(url)
|
|
97
|
+
if name == "@method":
|
|
98
|
+
return method.upper()
|
|
99
|
+
if name == "@authority":
|
|
100
|
+
host = (parts.hostname or "").lower()
|
|
101
|
+
port = parts.port
|
|
102
|
+
default = {"https": 443, "http": 80}.get(parts.scheme)
|
|
103
|
+
return host if port is None or port == default else f"{host}:{port}"
|
|
104
|
+
if name == "@path":
|
|
105
|
+
return parts.path or "/"
|
|
106
|
+
if name == "@query":
|
|
107
|
+
return "?" + parts.query
|
|
108
|
+
if name == "@target-uri":
|
|
109
|
+
return url
|
|
110
|
+
if name == "@scheme":
|
|
111
|
+
return parts.scheme
|
|
112
|
+
raise ValueError(f"unsupported derived component {name!r}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _signature_base(method: str, url: str, headers: Mapping[str, str],
|
|
116
|
+
covered: tuple[str, ...], params: str) -> str:
|
|
117
|
+
lines = []
|
|
118
|
+
for component in covered:
|
|
119
|
+
value = (_derived(component, method, url) if component.startswith("@")
|
|
120
|
+
else _field(headers, component))
|
|
121
|
+
lines.append(f'"{component}": {value}')
|
|
122
|
+
lines.append(f'"@signature-params": {params}')
|
|
123
|
+
return "\n".join(lines)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _raw_sign(key: Any, data: bytes) -> bytes:
|
|
127
|
+
"""RFC 9421 §3.3 signature bytes: Ed25519 raw, ECDSA as raw ``r || s``."""
|
|
128
|
+
if isinstance(key, Ed25519PrivateKey):
|
|
129
|
+
return key.sign(data)
|
|
130
|
+
if isinstance(key, ec.EllipticCurvePrivateKey):
|
|
131
|
+
r, s = decode_dss_signature(key.sign(data, ec.ECDSA(hashes.SHA256())))
|
|
132
|
+
size = (key.curve.key_size + 7) // 8
|
|
133
|
+
return r.to_bytes(size, "big") + s.to_bytes(size, "big")
|
|
134
|
+
raise TypeError("key must be an Ed25519 or EC private key")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def sign_request(
|
|
138
|
+
key: Any,
|
|
139
|
+
method: str,
|
|
140
|
+
url: str,
|
|
141
|
+
headers: Mapping[str, str],
|
|
142
|
+
*,
|
|
143
|
+
covered: tuple[str, ...],
|
|
144
|
+
label: str = "sig",
|
|
145
|
+
created: int | None = None,
|
|
146
|
+
expires: int | None = None,
|
|
147
|
+
) -> dict[str, str]:
|
|
148
|
+
"""Sign ``(method, url, headers)`` the AAuth way: the covered components as
|
|
149
|
+
given, ``created`` (and ``expires`` if asked), no ``keyid``/``alg``/``tag``.
|
|
150
|
+
Returns ``headers`` plus ``Signature-Input`` and ``Signature``. Every
|
|
151
|
+
covered header must already be present (``Signature-Key``, ``Content-Digest``,
|
|
152
|
+
``Content-Type``…) — the caller adds them first."""
|
|
153
|
+
created = int(created if created is not None else time.time())
|
|
154
|
+
inner = "(" + " ".join(f'"{c}"' for c in covered) + f");created={created}"
|
|
155
|
+
if expires is not None:
|
|
156
|
+
inner += f";expires={int(expires)}"
|
|
157
|
+
base = _signature_base(method, url, headers, covered, inner)
|
|
158
|
+
signature = base64.b64encode(_raw_sign(key, base.encode())).decode()
|
|
159
|
+
out = dict(headers)
|
|
160
|
+
out["Signature-Input"] = f"{label}={inner}"
|
|
161
|
+
out["Signature"] = f"{label}=:{signature}:"
|
|
162
|
+
return out
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _covered_for(body: bytes | None, headers: Mapping[str, str]) -> tuple[str, ...]:
|
|
166
|
+
has_body = body is not None and len(body) > 0
|
|
167
|
+
return AAUTH_REQUIRED_COMPONENTS + (_BODY_COMPONENTS if has_body else ())
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _with_body(headers: dict[str, str], body: bytes | None) -> dict[str, str]:
|
|
171
|
+
if body:
|
|
172
|
+
headers["Content-Digest"] = content_digest(body)
|
|
173
|
+
if not any(k.lower() == "content-type" for k in headers):
|
|
174
|
+
headers["Content-Type"] = "application/json"
|
|
175
|
+
return headers
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ── Web Bot Auth / AAuth agent ────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
|
|
44
181
|
class _Resolver(HTTPSignatureKeyResolver):
|
|
45
182
|
def __init__(self, key: Ed25519PrivateKey):
|
|
46
183
|
self._key = key
|
|
@@ -53,7 +190,8 @@ class _Resolver(HTTPSignatureKeyResolver):
|
|
|
53
190
|
|
|
54
191
|
|
|
55
192
|
class EgressSigner:
|
|
56
|
-
"""Sign outbound requests as
|
|
193
|
+
"""Sign outbound requests as an agent — Web Bot Auth (:meth:`sign`) or
|
|
194
|
+
AAuth (:meth:`sign_aauth`) with the same Ed25519 key.
|
|
57
195
|
|
|
58
196
|
Usage::
|
|
59
197
|
|
|
@@ -65,25 +203,22 @@ class EgressSigner:
|
|
|
65
203
|
Publish ``signer.directory()`` as JSON at
|
|
66
204
|
``https://myagent.example/.well-known/http-message-signatures-directory``
|
|
67
205
|
(or run ``regent-httpsig keygen`` to generate both the seed and the files).
|
|
206
|
+
For AAuth, register ``signer.public_jwk`` as the ``cnf.jwk`` of your agent
|
|
207
|
+
token and call :meth:`sign_aauth` with the token.
|
|
68
208
|
"""
|
|
69
209
|
|
|
70
210
|
def __init__(self, *, seed: str, signature_agent: str, ttl_minutes: int = 5):
|
|
71
|
-
|
|
72
|
-
if len(raw) != 32:
|
|
73
|
-
raise ValueError("seed must be 32 bytes (base64url-encoded)")
|
|
74
|
-
self._key = Ed25519PrivateKey.from_private_bytes(raw)
|
|
211
|
+
self._key = _load_seed(seed)
|
|
75
212
|
self.signature_agent = signature_agent
|
|
76
213
|
self._ttl = ttl_minutes
|
|
77
|
-
self._jwk =
|
|
78
|
-
"kty": "OKP",
|
|
79
|
-
"crv": "Ed25519",
|
|
80
|
-
"x": b64url(self._key.public_key().public_bytes_raw()),
|
|
81
|
-
}
|
|
214
|
+
self._jwk = _ed25519_jwk(self._key)
|
|
82
215
|
self.keyid = jwk_thumbprint(self._jwk)
|
|
83
216
|
|
|
84
217
|
@property
|
|
85
218
|
def public_jwk(self) -> dict[str, Any]:
|
|
86
|
-
|
|
219
|
+
"""The public key as a JWK with a fully-specified ``alg`` (RFC 9864) —
|
|
220
|
+
what an AAuth token's ``cnf.jwk`` must carry under -11."""
|
|
221
|
+
return {**self._jwk, "alg": "Ed25519"}
|
|
87
222
|
|
|
88
223
|
def directory(self) -> dict[str, Any]:
|
|
89
224
|
"""The JWKS document to serve at
|
|
@@ -101,7 +236,7 @@ class EgressSigner:
|
|
|
101
236
|
covered: tuple[str, ...] = _DEFAULT_COVERED,
|
|
102
237
|
label: str = "sig1",
|
|
103
238
|
) -> dict[str, str]:
|
|
104
|
-
"""
|
|
239
|
+
"""Web Bot Auth: ``headers`` + ``Signature-Agent``/``Signature-Input``/``Signature``."""
|
|
105
240
|
out = dict(headers or {})
|
|
106
241
|
out["Signature-Agent"] = f'"{self.signature_agent}"' # legacy sf-string form
|
|
107
242
|
message = Message(method.upper(), url, out)
|
|
@@ -119,3 +254,76 @@ class EgressSigner:
|
|
|
119
254
|
covered_component_ids=covered,
|
|
120
255
|
)
|
|
121
256
|
return dict(message.headers)
|
|
257
|
+
|
|
258
|
+
def sign_aauth(
|
|
259
|
+
self,
|
|
260
|
+
method: str,
|
|
261
|
+
url: str,
|
|
262
|
+
headers: dict[str, str] | None = None,
|
|
263
|
+
*,
|
|
264
|
+
token: str,
|
|
265
|
+
body: bytes | None = None,
|
|
266
|
+
label: str = "sig",
|
|
267
|
+
created: int | None = None,
|
|
268
|
+
) -> dict[str, str]:
|
|
269
|
+
"""AAuth -11 §11.3.3: carry ``token`` (an agent, person or auth token
|
|
270
|
+
whose ``cnf.jwk`` is this signer's key) in ``Signature-Key`` under the
|
|
271
|
+
``jwt`` scheme and sign ``@method @authority @path signature-key`` —
|
|
272
|
+
plus ``content-digest content-type`` when ``body`` is given, with
|
|
273
|
+
``Content-Digest`` computed here. Send ``body`` exactly as passed."""
|
|
274
|
+
out = _with_body(dict(headers or {}), body)
|
|
275
|
+
out["Signature-Key"] = f'{label}=jwt;jwt="{token}"'
|
|
276
|
+
return sign_request(self._key, method, url, out,
|
|
277
|
+
covered=_covered_for(body, out), label=label, created=created)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ── AAuth server (PS / AS / resource calling another server) ──────────────────
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class ServerSigner:
|
|
284
|
+
"""Sign requests as an AAuth server under the ``jwks_uri`` scheme (-11
|
|
285
|
+
§11.3.2): ``Signature-Key: sig=jwks_uri;id="<server_id>";dwk="<dwk>";kid="…"``.
|
|
286
|
+
The recipient fetches ``{server_id}/.well-known/{dwk}``, follows its
|
|
287
|
+
``jwks_uri`` and finds ``kid`` — serve :meth:`jwks` there.
|
|
288
|
+
|
|
289
|
+
``dwk`` names the metadata document you publish: ``aauth-person.json`` for
|
|
290
|
+
a PS, ``aauth-access.json`` for an AS, ``aauth-resource.json`` for a
|
|
291
|
+
resource, ``aauth-agent.json`` for an agent provider.
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
def __init__(self, *, seed: str, server_id: str, dwk: str,
|
|
295
|
+
kid: str | None = None, label: str = "sig") -> None:
|
|
296
|
+
self._key = _load_seed(seed)
|
|
297
|
+
self.server_id = server_id.rstrip("/")
|
|
298
|
+
self.dwk = dwk
|
|
299
|
+
self.label = label
|
|
300
|
+
self._jwk = _ed25519_jwk(self._key)
|
|
301
|
+
self.keyid = kid or jwk_thumbprint(self._jwk)
|
|
302
|
+
|
|
303
|
+
@property
|
|
304
|
+
def public_jwk(self) -> dict[str, Any]:
|
|
305
|
+
return {**self._jwk, "kid": self.keyid, "alg": "Ed25519", "use": "sig"}
|
|
306
|
+
|
|
307
|
+
def jwks(self) -> dict[str, Any]:
|
|
308
|
+
"""The JWKS to serve at your metadata document's ``jwks_uri``."""
|
|
309
|
+
return {"keys": [self.public_jwk]}
|
|
310
|
+
|
|
311
|
+
def signature_key_header(self) -> str:
|
|
312
|
+
return (f'{self.label}=jwks_uri;id="{self.server_id}";'
|
|
313
|
+
f'dwk="{self.dwk}";kid="{self.keyid}"')
|
|
314
|
+
|
|
315
|
+
def sign(
|
|
316
|
+
self,
|
|
317
|
+
method: str,
|
|
318
|
+
url: str,
|
|
319
|
+
headers: dict[str, str] | None = None,
|
|
320
|
+
*,
|
|
321
|
+
body: bytes | None = None,
|
|
322
|
+
created: int | None = None,
|
|
323
|
+
) -> dict[str, str]:
|
|
324
|
+
"""Return ``headers`` + ``Signature-Key``/``Signature-Input``/``Signature``
|
|
325
|
+
(+ ``Content-Digest`` when ``body`` is given)."""
|
|
326
|
+
out = _with_body(dict(headers or {}), body)
|
|
327
|
+
out["Signature-Key"] = self.signature_key_header()
|
|
328
|
+
return sign_request(self._key, method, url, out,
|
|
329
|
+
covered=_covered_for(body, out), label=self.label, created=created)
|
regent_httpsig/usage.py
CHANGED
|
@@ -36,7 +36,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
|
36
36
|
from regent_httpsig.jwk import b64url_decode, jwk_thumbprint
|
|
37
37
|
|
|
38
38
|
__all__ = ["UsageQueryError", "ResponseSigner", "build_usage_response",
|
|
39
|
-
"parse_usage_request", "make_usage_endpoint",
|
|
39
|
+
"parse_usage_request", "make_usage_endpoint", "make_revocation_endpoint",
|
|
40
40
|
"validate_budget_grant"]
|
|
41
41
|
|
|
42
42
|
_SCOPE_KEYS = ("sub", "tenant", "mission_s256")
|
|
@@ -109,19 +109,25 @@ async def build_usage_response(
|
|
|
109
109
|
class ResponseSigner:
|
|
110
110
|
"""Signs a usage response per §The Signed Response: an Ed25519 HTTP Sig
|
|
111
111
|
covering ``@status``, ``content-type``, ``content-digest``, bound to the
|
|
112
|
-
request via ``@authority``/``@path`` with the ``req`` parameter.
|
|
112
|
+
request via ``@authority``/``@path`` with the ``req`` parameter. The key is
|
|
113
|
+
identified the -11 way (§11.3.2): ``Signature-Key: sig=jwks_uri;
|
|
114
|
+
id="<server_id>";dwk="aauth-resource.json";kid="…"`` — the PS fetches
|
|
115
|
+
``{server_id}/.well-known/aauth-resource.json``, follows ``jwks_uri`` and
|
|
116
|
+
finds ``kid`` there (serve :meth:`jwks`).
|
|
113
117
|
|
|
114
118
|
Hand-built base string: response signing with request-bound components is
|
|
115
119
|
beyond the request-oriented helper libraries, the component set is fixed by
|
|
116
120
|
the draft, and the golden tests freeze every byte of it.
|
|
117
121
|
"""
|
|
118
122
|
|
|
119
|
-
def __init__(self, *, seed: str,
|
|
123
|
+
def __init__(self, *, seed: str, server_id: str, dwk: str = "aauth-resource.json",
|
|
124
|
+
kid: str | None = None, label: str = "sig") -> None:
|
|
120
125
|
raw = b64url_decode(seed)
|
|
121
126
|
if len(raw) != 32:
|
|
122
127
|
raise ValueError("seed must be 32 bytes (base64url-encoded)")
|
|
123
128
|
self._key = Ed25519PrivateKey.from_private_bytes(raw)
|
|
124
|
-
self.
|
|
129
|
+
self.server_id = server_id.rstrip("/")
|
|
130
|
+
self.dwk = dwk
|
|
125
131
|
self._label = label
|
|
126
132
|
self.public_jwk = {
|
|
127
133
|
"kty": "OKP", "crv": "Ed25519",
|
|
@@ -129,7 +135,15 @@ class ResponseSigner:
|
|
|
129
135
|
self._key.public_key().public_bytes_raw()
|
|
130
136
|
).rstrip(b"=").decode(),
|
|
131
137
|
}
|
|
132
|
-
self.keyid = jwk_thumbprint(self.public_jwk)
|
|
138
|
+
self.keyid = kid or jwk_thumbprint(self.public_jwk)
|
|
139
|
+
|
|
140
|
+
def jwks(self) -> dict[str, Any]:
|
|
141
|
+
"""The JWKS to serve at the ``jwks_uri`` your metadata document names."""
|
|
142
|
+
return {"keys": [{**self.public_jwk, "kid": self.keyid, "alg": "Ed25519", "use": "sig"}]}
|
|
143
|
+
|
|
144
|
+
def signature_key_header(self) -> str:
|
|
145
|
+
return (f'{self._label}=jwks_uri;id="{self.server_id}";'
|
|
146
|
+
f'dwk="{self.dwk}";kid="{self.keyid}"')
|
|
133
147
|
|
|
134
148
|
def sign(self, *, status: int, content_type: str, body: bytes,
|
|
135
149
|
authority: str, path: str,
|
|
@@ -157,10 +171,41 @@ class ResponseSigner:
|
|
|
157
171
|
"Content-Digest": digest,
|
|
158
172
|
"Signature-Input": f"{self._label}={inner}",
|
|
159
173
|
"Signature": f"{self._label}=:{sig}:",
|
|
160
|
-
"Signature-Key":
|
|
174
|
+
"Signature-Key": self.signature_key_header(),
|
|
161
175
|
}
|
|
162
176
|
|
|
163
177
|
|
|
178
|
+
def _problem(status: int, error: str, detail: str,
|
|
179
|
+
headers: dict[str, str] | None = None) -> Any:
|
|
180
|
+
"""-11 §11.9.2: ``application/problem+json`` with the REQUIRED ``error`` member.
|
|
181
|
+
Typed ``Any`` because starlette is an optional extra; it is a ``JSONResponse``."""
|
|
182
|
+
from starlette.responses import JSONResponse
|
|
183
|
+
|
|
184
|
+
return JSONResponse(
|
|
185
|
+
status_code=status, media_type="application/problem+json",
|
|
186
|
+
content={"error": error, "status": status, "title": error.replace("_", " "),
|
|
187
|
+
"detail": detail},
|
|
188
|
+
headers=headers or {},
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def _caller(authenticate: Callable[[Any], Awaitable[Any]],
|
|
193
|
+
request: Any) -> tuple[str | None, Any]:
|
|
194
|
+
"""Normalize what ``authenticate_ps`` returns: an issuer string, a
|
|
195
|
+
:class:`VerifiedSignature` (its ``agent``), a :class:`VerificationError`
|
|
196
|
+
(→ 401 + Signature-Error) or ``None`` (→ 401 invalid_signature)."""
|
|
197
|
+
from regent_httpsig.verify import VerificationError, VerifiedSignature
|
|
198
|
+
|
|
199
|
+
result = await authenticate(request)
|
|
200
|
+
if isinstance(result, VerifiedSignature):
|
|
201
|
+
return result.agent, None
|
|
202
|
+
if isinstance(result, str) and result:
|
|
203
|
+
return result, None
|
|
204
|
+
error = result if isinstance(result, VerificationError) else VerificationError(
|
|
205
|
+
"invalid_signature", "sign the request as a server (jwks_uri scheme)")
|
|
206
|
+
return None, _problem(401, error.code, error.detail, error.headers())
|
|
207
|
+
|
|
208
|
+
|
|
164
209
|
def make_usage_endpoint(
|
|
165
210
|
meter: Any,
|
|
166
211
|
*,
|
|
@@ -171,19 +216,19 @@ def make_usage_endpoint(
|
|
|
171
216
|
) -> Callable[[Any], Awaitable[Any]]:
|
|
172
217
|
"""Build an ASGI-framework-agnostic handler: ``handler(request)`` returns a
|
|
173
218
|
Starlette/FastAPI ``Response``. ``authenticate_ps(request)`` verifies the
|
|
174
|
-
calling person server's signature (
|
|
175
|
-
|
|
176
|
-
|
|
219
|
+
calling person server's signature (-11 §11.3.2, jwks_uri scheme) and
|
|
220
|
+
returns its identity — a ``VerifiedSignature`` from
|
|
221
|
+
:meth:`HttpsigVerifier.verify_server`, or the issuer string — or a
|
|
222
|
+
``VerificationError``/``None`` to refuse with 401 + ``Signature-Error``.
|
|
223
|
+
The resource MUST only answer for values seen in tokens from that PS,
|
|
177
224
|
which the ``iss``-keyed counters enforce structurally."""
|
|
178
225
|
from starlette.responses import JSONResponse, Response
|
|
179
226
|
|
|
180
227
|
async def handler(request: Any) -> Response:
|
|
181
|
-
iss = await authenticate_ps
|
|
228
|
+
iss, refused = await _caller(authenticate_ps, request)
|
|
182
229
|
if iss is None:
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
"message": "Sign the query as a person server (jwks_uri scheme).",
|
|
186
|
-
})
|
|
230
|
+
refused_response: Response = refused
|
|
231
|
+
return refused_response
|
|
187
232
|
try:
|
|
188
233
|
payload = json.loads(await request.body() or b"{}")
|
|
189
234
|
scope_key, scope_value, jkts = parse_usage_request(payload)
|
|
@@ -221,3 +266,63 @@ def validate_budget_grant(unit: str, decimals: int,
|
|
|
221
266
|
f"(declared in budget_units), got {decimals}")
|
|
222
267
|
return
|
|
223
268
|
raise ValueError(f"unit {unit!r} is not declared in budget_units")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def make_revocation_endpoint(
|
|
272
|
+
meter: Any,
|
|
273
|
+
*,
|
|
274
|
+
authenticate_ps: Callable[[Any], Awaitable[Any]],
|
|
275
|
+
accepted_issuers: Callable[[str], bool] | None = None,
|
|
276
|
+
max_token_lifetime: float = 86400.0,
|
|
277
|
+
clock_skew: float = 60.0,
|
|
278
|
+
) -> Callable[[Any], Awaitable[Any]]:
|
|
279
|
+
"""The base protocol's revocation endpoint (-11 §11.12) for the budgets
|
|
280
|
+
meter: a signed ``POST {"jti", "exp"}``. The issuer is NOT a request
|
|
281
|
+
parameter — it is the identity verified on the caller's server signature
|
|
282
|
+
(§11.3.2), so a caller revokes only its own tokens. The pair ``(iss, jti)``
|
|
283
|
+
is recorded whether or not the token was ever seen here (a resource that
|
|
284
|
+
verifies statelessly still answers 200), and the answer is always an empty
|
|
285
|
+
``200``: there is no "not found" (Appendix C.2.7).
|
|
286
|
+
|
|
287
|
+
Errors (§11.12.3, problem+json): ``invalid_request`` 400 for malformed
|
|
288
|
+
JSON or a missing/malformed ``jti``/``exp`` — including an ``exp`` further
|
|
289
|
+
ahead than ``max_token_lifetime`` + ``clock_skew``, the longest lifetime
|
|
290
|
+
this resource accepts; ``unsupported_iss`` 403 when ``accepted_issuers``
|
|
291
|
+
says no (default: any verified caller); a signature that does not verify
|
|
292
|
+
is 401 + ``Signature-Error``. ``rate_limited`` and ``server_error`` are
|
|
293
|
+
the application's to raise.
|
|
294
|
+
|
|
295
|
+
What revocation does to the meter is :meth:`InMemoryMeter.record_revocation`:
|
|
296
|
+
no new spend, in-flight requests complete, and the token's consumption
|
|
297
|
+
record is withheld until they have (AAuth #151); a later presentation of
|
|
298
|
+
the token is refused with ``revoked_jwt`` (§11.12.5)."""
|
|
299
|
+
from starlette.responses import Response
|
|
300
|
+
|
|
301
|
+
async def handler(request: Any) -> Response:
|
|
302
|
+
caller, refused = await _caller(authenticate_ps, request)
|
|
303
|
+
if caller is None:
|
|
304
|
+
refused_response: Response = refused
|
|
305
|
+
return refused_response
|
|
306
|
+
if accepted_issuers is not None and not accepted_issuers(caller):
|
|
307
|
+
unsupported: Response = _problem(
|
|
308
|
+
403, "unsupported_iss",
|
|
309
|
+
"this resource does not accept revocations from that issuer")
|
|
310
|
+
return unsupported
|
|
311
|
+
try:
|
|
312
|
+
payload = json.loads(await request.body() or b"")
|
|
313
|
+
if not isinstance(payload, dict):
|
|
314
|
+
raise ValueError("body must be a JSON object")
|
|
315
|
+
jti, exp = payload.get("jti"), payload.get("exp")
|
|
316
|
+
if not isinstance(jti, str) or not jti or len(jti) > 512:
|
|
317
|
+
raise ValueError("jti is REQUIRED (string)")
|
|
318
|
+
if isinstance(exp, bool) or not isinstance(exp, int | float):
|
|
319
|
+
raise ValueError("exp is REQUIRED (integer seconds)")
|
|
320
|
+
if exp > time.time() + max_token_lifetime + clock_skew:
|
|
321
|
+
raise ValueError("exp is further ahead than any token this resource accepts")
|
|
322
|
+
except (ValueError, json.JSONDecodeError) as exc:
|
|
323
|
+
invalid: Response = _problem(400, "invalid_request", str(exc)[:200])
|
|
324
|
+
return invalid
|
|
325
|
+
await meter.record_revocation(caller, jti, float(exp))
|
|
326
|
+
return Response(status_code=200)
|
|
327
|
+
|
|
328
|
+
return handler
|