aidr-verifier 0.1.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.
@@ -0,0 +1,60 @@
1
+ """AIDR relying-party SDK — verify agent identity offline.
2
+
3
+ Minimal integration::
4
+
5
+ from aidr_verifier import AgentVerifier, VerificationPolicy, build_trust_store, NonceCache
6
+
7
+ trust = build_trust_store(jwks, delta_token=delta, expected_issuer="https://registry.aidr.example")
8
+ verifier = AgentVerifier(trust, policy=VerificationPolicy(min_assurance=1),
9
+ nonce_seen=NonceCache())
10
+
11
+ result = verifier.verify(method="GET", url="https://shop.example/api", headers=request.headers)
12
+
13
+ Refresh ``jwks`` hourly and the revocation delta every 60 seconds, in the background. Nothing in
14
+ the request path contacts the registry. For ASGI applications, :class:`AidrMiddleware` does all of
15
+ this — background sync, shared replay protection, and the anonymous / verified / refused decision —
16
+ and puts the result in ``request.state.aidr`` (see ``aidr_verifier.asgi``).
17
+ """
18
+
19
+ from .agent import AgentClient, AgentCredentialHolder
20
+ from .asgi import AidrIdentity, AidrMiddleware, RelyingPartyConfig
21
+ from .edge import EdgeContextMiddleware, verify_edge_context
22
+ from .nonce import MemoryNonceStore, NonceStore, NonceStoreUnavailable, RedisNonceStore
23
+ from .sync import TrustSync
24
+ from .trust import (
25
+ JWKS_REFRESH_SECONDS,
26
+ REVOCATION_REFRESH_SECONDS,
27
+ KeySet,
28
+ NonceCache,
29
+ RevocationState,
30
+ TrustStore,
31
+ build_trust_store,
32
+ )
33
+ from .verifier import AgentVerifier, VerificationPolicy, VerificationResult
34
+
35
+ __version__ = "0.1.0"
36
+
37
+ __all__ = [
38
+ "AgentClient",
39
+ "AgentCredentialHolder",
40
+ "AgentVerifier",
41
+ "VerificationPolicy",
42
+ "VerificationResult",
43
+ "TrustStore",
44
+ "KeySet",
45
+ "RevocationState",
46
+ "NonceCache",
47
+ "build_trust_store",
48
+ "AidrMiddleware",
49
+ "RelyingPartyConfig",
50
+ "AidrIdentity",
51
+ "EdgeContextMiddleware",
52
+ "verify_edge_context",
53
+ "TrustSync",
54
+ "NonceStore",
55
+ "NonceStoreUnavailable",
56
+ "MemoryNonceStore",
57
+ "RedisNonceStore",
58
+ "JWKS_REFRESH_SECONDS",
59
+ "REVOCATION_REFRESH_SECONDS",
60
+ ]
aidr_verifier/agent.py ADDED
@@ -0,0 +1,146 @@
1
+ """Agent-side helpers: hold a key, hold a credential, sign outgoing requests.
2
+
3
+ This runs on the **agent operator's** infrastructure, never on the registry's. The private key is
4
+ generated here, stays here, and is never transmitted. The registry only ever sees the public half,
5
+ which is why a total compromise of the registry gives an attacker no ability to impersonate any
6
+ agent.
7
+
8
+ Usage::
9
+
10
+ private_key, public_key = generate_keypair()
11
+ holder = AgentCredentialHolder(aid, private_key, credential_token, expires_at)
12
+ headers = holder.sign_headers("GET", "https://shop.example/api/products")
13
+ response = httpx.get("https://shop.example/api/products", headers=headers)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from collections.abc import Awaitable, Callable, Mapping
20
+ from dataclasses import dataclass
21
+ from datetime import datetime, timedelta, timezone
22
+ from typing import Any
23
+
24
+ from aidr_core.errors import CredentialExpired
25
+ from aidr_crypto.keys import algorithm_for_key, generate_keypair, public_key_to_jwk
26
+ from aidr_crypto.signatures import CREDENTIAL_HEADER, DEFAULT_COMPONENTS, sign_request
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ #: Renew this long before expiry. A credential that expires mid-flight produces a failure the
31
+ #: agent cannot retry its way out of, so renewal is always early rather than just-in-time.
32
+ RENEWAL_MARGIN = timedelta(minutes=3)
33
+
34
+
35
+ @dataclass
36
+ class AgentCredentialHolder:
37
+ """An agent identity in use: an identifier, a private key, and a current credential."""
38
+
39
+ aid: str
40
+ private_key: Any
41
+ credential: str
42
+ expires_at: datetime
43
+
44
+ def is_expired(self, *, now: datetime | None = None) -> bool:
45
+ return (now or datetime.now(timezone.utc)) >= self.expires_at
46
+
47
+ def needs_renewal(self, *, now: datetime | None = None,
48
+ margin: timedelta = RENEWAL_MARGIN) -> bool:
49
+ return (now or datetime.now(timezone.utc)) >= self.expires_at - margin
50
+
51
+ def sign_headers(
52
+ self,
53
+ method: str,
54
+ url: str,
55
+ *,
56
+ headers: Mapping[str, str] | None = None,
57
+ components: tuple[str, ...] = DEFAULT_COMPONENTS,
58
+ now: datetime | None = None,
59
+ algorithm: Any = None,
60
+ ) -> dict[str, str]:
61
+ """Return the headers to attach to one outgoing request.
62
+
63
+ A fresh nonce and timestamp are generated per request, so the headers are single-use.
64
+ Reusing them across requests would defeat the replay protection the verifier relies on.
65
+ """
66
+ if self.is_expired(now=now):
67
+ raise CredentialExpired(
68
+ "credential has expired — obtain a new one before signing", aid=self.aid
69
+ )
70
+
71
+ signed = sign_request(
72
+ method=method,
73
+ url=url,
74
+ headers=headers or {},
75
+ private_key=self.private_key,
76
+ keyid=self.aid,
77
+ components=components,
78
+ created=now,
79
+ # Derived from the key itself rather than configured. An agent holding a P-256 key
80
+ # cannot accidentally advertise ed25519 and have every verifier reject it.
81
+ algorithm=algorithm or algorithm_for_key(self.private_key),
82
+ )
83
+ signed[CREDENTIAL_HEADER] = self.credential
84
+ return signed
85
+
86
+ def public_jwk(self) -> dict[str, Any]:
87
+ return public_key_to_jwk(self.private_key.public_key())
88
+
89
+
90
+ class AgentClient:
91
+ """An agent identity with automatic credential renewal.
92
+
93
+ ``renew`` is supplied by the operator: it calls the registry's issuance endpoint with whatever
94
+ authentication that deployment uses, and returns ``(credential_token, expires_at)``. Keeping
95
+ it injectable means this SDK carries no HTTP client and no opinion about authentication.
96
+ """
97
+
98
+ def __init__(
99
+ self,
100
+ aid: str,
101
+ private_key: Any,
102
+ renew: Callable[[], Awaitable[tuple[str, datetime]]],
103
+ *,
104
+ renewal_margin: timedelta = RENEWAL_MARGIN,
105
+ ) -> None:
106
+ self.aid = aid
107
+ self.private_key = private_key
108
+ self._renew = renew
109
+ self._margin = renewal_margin
110
+ self._holder: AgentCredentialHolder | None = None
111
+
112
+ @classmethod
113
+ def with_new_keypair(cls, aid: str,
114
+ renew: Callable[[], Awaitable[tuple[str, datetime]]]) -> AgentClient:
115
+ private_key, _ = generate_keypair()
116
+ return cls(aid, private_key, renew)
117
+
118
+ async def ensure_credential(self, *, now: datetime | None = None) -> AgentCredentialHolder:
119
+ moment = now or datetime.now(timezone.utc)
120
+ if self._holder is None or self._holder.needs_renewal(now=moment, margin=self._margin):
121
+ token, expires_at = await self._renew()
122
+ self._holder = AgentCredentialHolder(
123
+ aid=self.aid, private_key=self.private_key,
124
+ credential=token, expires_at=expires_at,
125
+ )
126
+ logger.debug("renewed credential for %s, expires %s", self.aid, expires_at.isoformat())
127
+ return self._holder
128
+
129
+ async def sign_headers(self, method: str, url: str, **kwargs: Any) -> dict[str, str]:
130
+ holder = await self.ensure_credential()
131
+ return holder.sign_headers(method, url, **kwargs)
132
+
133
+ @property
134
+ def credential_expires_at(self) -> datetime | None:
135
+ return self._holder.expires_at if self._holder else None
136
+
137
+ def public_jwk(self) -> dict[str, Any]:
138
+ return public_key_to_jwk(self.private_key.public_key())
139
+
140
+ def forget_credential(self) -> None:
141
+ """Drop the cached credential, forcing renewal on next use.
142
+
143
+ Call after a 401 from a relying party: the credential may have been revoked, and the agent
144
+ should not keep presenting one that has already been refused.
145
+ """
146
+ self._holder = None
aidr_verifier/asgi.py ADDED
@@ -0,0 +1,444 @@
1
+ """ASGI middleware: verify AI agents in front of any ASGI application.
2
+
3
+ Works with FastAPI, Starlette, and Django under ASGI::
4
+
5
+ from aidr_verifier.asgi import AidrMiddleware, RelyingPartyConfig
6
+
7
+ app.add_middleware(AidrMiddleware, config=RelyingPartyConfig(
8
+ issuer="https://registry.aidr.example",
9
+ public_origin="https://shop.example",
10
+ min_agal=1,
11
+ bypass=[r"/health", r"/static/.*"],
12
+ ))
13
+
14
+ @app.get("/api/products")
15
+ async def products(request: Request):
16
+ identity = request.state.aidr # always present on HTTP requests
17
+ if identity.is_agent:
18
+ ...identity.organization, identity.agal, identity.org_domains...
19
+
20
+ Every HTTP request ends in exactly one of these outcomes:
21
+
22
+ * **anonymous** — none of ``Agent-Credential``, ``Signature``, ``Signature-Input`` is present. Passed
23
+ through: this is a person, or a bot that did not identify itself, and the site's other rules
24
+ apply to it as before.
25
+ * **verified** — the agent's identity was proven and meets the site's policy. Passed through with
26
+ ``request.state.aidr`` populated.
27
+ * **refused** — the request claims to be an agent and is not a valid one (401), is a revoked or
28
+ out-of-policy one (403, with a reason code), or cannot be checked right now (503).
29
+
30
+ A request carrying *some* of the headers is a claim to be an agent, and is judged as one. Treating
31
+ it as anonymous would let an agent whose credential fails simply fall back to being a stranger —
32
+ which is the site's call to make for strangers, not ours to make for a failed identity.
33
+
34
+ The identity is placed in the ASGI scope, never in request headers, so there is nothing a client
35
+ could forge on the way in.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import asyncio
41
+ import contextvars
42
+ import json
43
+ import logging
44
+ import math
45
+ import posixpath
46
+ import re
47
+ from collections.abc import Callable, Iterable, Sequence
48
+ from dataclasses import dataclass, field
49
+ from datetime import datetime, timezone
50
+ from typing import Any, Literal
51
+ from urllib.parse import unquote, urlsplit
52
+
53
+ from aidr_core.errors import (
54
+ AgentRevoked,
55
+ AidrError,
56
+ AssuranceTooLow,
57
+ CapabilityDenied,
58
+ CredentialRevoked,
59
+ MalformedToken,
60
+ PolicyViolation,
61
+ RegistryUnavailable,
62
+ ReplayDetected,
63
+ UnknownIssuer,
64
+ )
65
+ from aidr_crypto.signatures import (
66
+ CREDENTIAL_HEADER,
67
+ DEFAULT_MAX_AGE_SECONDS,
68
+ SIGNATURE_HEADER,
69
+ SIGNATURE_INPUT_HEADER,
70
+ effective_request_uri,
71
+ )
72
+
73
+ from .nonce import MemoryNonceStore, NonceStore
74
+ from .sync import TrustSync
75
+ from .trust import KeySet, TrustStore
76
+ from .verifier import AgentVerifier, VerificationPolicy, VerificationResult
77
+
78
+ logger = logging.getLogger("aidr.middleware")
79
+
80
+ AGENT_HEADERS = tuple(h.lower() for h in (CREDENTIAL_HEADER, SIGNATURE_HEADER,
81
+ SIGNATURE_INPUT_HEADER))
82
+
83
+ #: Refusals that mean "you are an agent, and not one this site will serve" rather than "you did
84
+ #: not prove who you are".
85
+ _FORBIDDEN = (AgentRevoked, CredentialRevoked, AssuranceTooLow, CapabilityDenied, PolicyViolation)
86
+
87
+
88
+ # ==============================================================================================
89
+ # Configuration
90
+ # ==============================================================================================
91
+
92
+ @dataclass
93
+ class RelyingPartyConfig:
94
+ #: The registry, exactly as it appears in credentials' ``iss``. Trust material is discovered
95
+ #: from ``<issuer>/.well-known/aiaf-registry`` and must live on this origin.
96
+ issuer: str
97
+
98
+ #: This site's public origin, e.g. ``https://shop.example``. When set, the URI a signature is
99
+ #: checked against is built from it rather than from the Host header and scheme, which a
100
+ #: reverse proxy may have rewritten. Strongly recommended behind any proxy. It is also the
101
+ #: default expected audience.
102
+ public_origin: str | None = None
103
+
104
+ #: Assurance floors. AgAL is min(OAL, KAL); the separate floors let a site say which axis it
105
+ #: actually cares about — "a verified company" (OAL) or "a hardware-held key" (KAL).
106
+ min_agal: int = 0
107
+ min_oal: int = 0
108
+ min_kal: int = 0
109
+
110
+ required_capabilities: Sequence[str] = ()
111
+ max_upheld_abuse_reports: int | None = None
112
+
113
+ #: Audience this site accepts for audience-bound credentials. Defaults to ``public_origin``.
114
+ expected_audience: str | None = None
115
+
116
+ #: Regular expressions for paths where agent verification does not run at all. Each is
117
+ #: matched with ``fullmatch`` against the *normalised* path (decoded, dot segments resolved,
118
+ #: slashes collapsed), so ``/health`` never matches ``/health/../admin`` or ``/x/health``.
119
+ #: A path that does not normalise unambiguously is never bypassed.
120
+ bypass: Iterable[str] = ()
121
+
122
+ #: Replay protection. The default is correct only for a single worker process; any site with
123
+ #: more than one must pass a shared store (:class:`~aidr_verifier.nonce.RedisNonceStore`).
124
+ nonce_store: NonceStore | None = None
125
+
126
+ max_signature_age_seconds: int = DEFAULT_MAX_AGE_SECONDS
127
+
128
+ #: Pre-built trust. When given with ``sync=False``, nothing is fetched — for tests and for
129
+ #: sites that manage trust material themselves.
130
+ trust: TrustStore | None = None
131
+ sync: bool = True
132
+ #: An ``httpx.AsyncClient`` for the registry, e.g. one configured with a private CA.
133
+ http_client: Any = None
134
+ initial_sync_timeout: float = 10.0
135
+
136
+ #: Injected clock, for replaying time-bound test vectors. Leave unset in production.
137
+ clock: Callable[[], datetime] | None = None
138
+
139
+
140
+ # ==============================================================================================
141
+ # The identity handed to the application
142
+ # ==============================================================================================
143
+
144
+ @dataclass(frozen=True)
145
+ class AidrIdentity:
146
+ """What the application sees in ``request.state.aidr``."""
147
+
148
+ status: Literal["anonymous", "verified", "bypassed"]
149
+ result: VerificationResult | None = None
150
+ warnings: tuple[str, ...] = field(default_factory=tuple)
151
+
152
+ @property
153
+ def is_agent(self) -> bool:
154
+ return self.status == "verified"
155
+
156
+ @property
157
+ def aid(self) -> str | None:
158
+ return self.result.aid if self.result else None
159
+
160
+ @property
161
+ def organization(self) -> str | None:
162
+ return self.result.organization if self.result else None
163
+
164
+ @property
165
+ def org_domains(self) -> list[str]:
166
+ """Proven domains. Key trust decisions on these, not on the ``organization`` string."""
167
+ return list(self.result.org_domains) if self.result else []
168
+
169
+ @property
170
+ def agal(self) -> int:
171
+ return self.result.agal if self.result else 0
172
+
173
+ @property
174
+ def oal(self) -> int:
175
+ return self.result.oal if self.result else 0
176
+
177
+ @property
178
+ def kal(self) -> int:
179
+ return self.result.kal if self.result else 0
180
+
181
+ @property
182
+ def capabilities(self) -> list[str]:
183
+ return list(self.result.capabilities) if self.result else []
184
+
185
+ @property
186
+ def declared(self) -> dict[str, Any]:
187
+ """Self-asserted by the operator. Not checked by the registry."""
188
+ return dict(self.result.declared) if self.result else {}
189
+
190
+
191
+ ANONYMOUS = AidrIdentity("anonymous")
192
+ BYPASSED = AidrIdentity("bypassed")
193
+
194
+
195
+ # ==============================================================================================
196
+ # Paths
197
+ # ==============================================================================================
198
+
199
+ def normalise_path(raw_path: str) -> str | None:
200
+ """The path a bypass rule is judged against, or ``None`` if it is ambiguous.
201
+
202
+ Decoded once; a ``%`` still present after that (double encoding such as ``%252e``, a literal
203
+ ``%25``, or a malformed escape) is ambiguous, as are invalid UTF-8, control characters and
204
+ backslashes, which servers disagree about. Dot segments are resolved and repeated slashes
205
+ collapsed, so the result is the path the application's router will most plausibly act on.
206
+ The contract is shared with the edge worker: sdk/testdata/path-normalisation.json.
207
+ """
208
+ try:
209
+ decoded = unquote(raw_path, errors="strict")
210
+ except UnicodeDecodeError:
211
+ return None
212
+ if "%" in decoded or "\\" in decoded:
213
+ return None
214
+ if any(ord(c) < 0x20 or ord(c) == 0x7F for c in decoded):
215
+ return None
216
+ if not decoded.startswith("/"):
217
+ decoded = "/" + decoded
218
+ trailing = decoded.endswith("/") and decoded != "/"
219
+ resolved = posixpath.normpath(re.sub(r"/{2,}", "/", decoded))
220
+ if resolved.startswith("//"):
221
+ resolved = "/" + resolved.lstrip("/")
222
+ return resolved + "/" if trailing and resolved != "/" else resolved
223
+
224
+
225
+ # ==============================================================================================
226
+ # The middleware
227
+ # ==============================================================================================
228
+
229
+ _collected_nonce: contextvars.ContextVar[list[str] | None] = contextvars.ContextVar(
230
+ "aidr_collected_nonce", default=None)
231
+
232
+
233
+ def _collect_nonce(nonce: str) -> bool:
234
+ """Handed to the verifier as its replay check: remember the nonce, decide nothing yet.
235
+
236
+ The core verifier consults its replay check only after the signature verifies, but before the
237
+ site's own floors, and it can only call a synchronous check. Deferring the claim to the end
238
+ means a request refused for any reason never occupies a nonce, and lets the claim be async
239
+ (a shared store).
240
+ """
241
+ sink = _collected_nonce.get()
242
+ if sink is not None:
243
+ sink.append(nonce)
244
+ return False
245
+
246
+
247
+ class AidrMiddleware:
248
+ def __init__(self, app: Any, config: RelyingPartyConfig) -> None:
249
+ self.app = app
250
+ self.config = config
251
+ self.issuer = config.issuer.rstrip("/")
252
+ self.trust = config.trust or TrustStore(KeySet(keys={}), expected_issuer=self.issuer)
253
+ if self.trust.expected_issuer is None:
254
+ self.trust.expected_issuer = self.issuer
255
+ self.sync = (TrustSync(self.issuer, self.trust, http_client=config.http_client)
256
+ if config.sync else None)
257
+ self.nonce_store = config.nonce_store or MemoryNonceStore()
258
+ self.bypass = [re.compile(pattern) for pattern in config.bypass]
259
+
260
+ audience = config.expected_audience or (config.public_origin.rstrip("/")
261
+ if config.public_origin else None)
262
+ self.policy = VerificationPolicy(
263
+ required_capabilities=list(config.required_capabilities),
264
+ max_signature_age_seconds=config.max_signature_age_seconds,
265
+ require_nonce=True,
266
+ expected_issuer=self.issuer,
267
+ expected_audience=audience,
268
+ max_upheld_abuse_reports=config.max_upheld_abuse_reports,
269
+ # Fail closed on unknown revocation. Not configurable here on purpose.
270
+ allow_unknown_revocation=False,
271
+ )
272
+ self.verifier = AgentVerifier(self.trust, policy=self.policy, nonce_seen=_collect_nonce)
273
+ self._started = False
274
+ self._start_lock = asyncio.Lock()
275
+
276
+ # -- lifecycle -------------------------------------------------------------------------------
277
+
278
+ async def startup(self) -> None:
279
+ async with self._start_lock:
280
+ if self._started:
281
+ return
282
+ self._started = True
283
+ synced = await self.sync.start(initial_timeout=self.config.initial_sync_timeout) \
284
+ if self.sync else bool(self.trust.key_set.keys)
285
+ health = self.trust.health()
286
+ logger.info(
287
+ "AIDR relying party: issuer=%s trust=%s keys=%d revocation_serial=%d exact=%s | "
288
+ "policy AgAL>=%d OAL>=%d KAL>=%d | bypass=%d rule(s) | nonce store=%s",
289
+ self.issuer, "ready" if synced else "NOT READY (agent requests get 503)",
290
+ health["keys"], health["revocation_serial"], health["revocation_exact"],
291
+ self.config.min_agal, self.config.min_oal, self.config.min_kal,
292
+ len(self.bypass), type(self.nonce_store).__name__)
293
+ if isinstance(self.nonce_store, MemoryNonceStore):
294
+ logger.info("AIDR: in-memory nonce store — correct for ONE worker process only; "
295
+ "use RedisNonceStore when running more")
296
+
297
+ async def shutdown(self) -> None:
298
+ if self.sync:
299
+ await self.sync.stop()
300
+
301
+ def health(self) -> dict[str, Any]:
302
+ return self.sync.health() if self.sync else self.trust.health()
303
+
304
+ # -- ASGI --------------------------------------------------------------------------------------
305
+
306
+ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
307
+ if scope["type"] == "lifespan":
308
+ await self.app(scope, self._lifespan_receive(receive), send)
309
+ return
310
+ if scope["type"] != "http":
311
+ await self.app(scope, receive, send)
312
+ return
313
+ if not self._started:
314
+ await self.startup()
315
+
316
+ identity, refusal = await self._authenticate(scope)
317
+ if refusal is not None:
318
+ await self._refuse(send, *refusal)
319
+ return
320
+ scope.setdefault("state", {})["aidr"] = identity
321
+ await self.app(scope, receive, send)
322
+
323
+ def _lifespan_receive(self, receive: Any) -> Any:
324
+ async def wrapped() -> dict[str, Any]:
325
+ message = await receive()
326
+ if message["type"] == "lifespan.startup":
327
+ await self.startup()
328
+ elif message["type"] == "lifespan.shutdown":
329
+ await self.shutdown()
330
+ return message
331
+ return wrapped
332
+
333
+ # -- the decision ------------------------------------------------------------------------------
334
+
335
+ def _headers(self, scope: dict[str, Any]) -> dict[str, str]:
336
+ headers: dict[str, str] = {}
337
+ for raw_name, raw_value in scope.get("headers", []):
338
+ name = raw_name.decode("latin-1").lower()
339
+ value = raw_value.decode("latin-1")
340
+ # Repeated fields combine per RFC 9110 §5.3; a doubled credential then fails to parse
341
+ # instead of one copy being silently chosen.
342
+ headers[name] = f"{headers[name]}, {value}" if name in headers else value
343
+ return headers
344
+
345
+ def _raw_path(self, scope: dict[str, Any]) -> str:
346
+ raw = scope.get("raw_path")
347
+ return raw.decode("latin-1") if isinstance(raw, bytes) and raw else scope.get("path", "/")
348
+
349
+ def _is_bypassed(self, raw_path: str) -> bool:
350
+ if not self.bypass:
351
+ return False
352
+ path = normalise_path(raw_path)
353
+ return path is not None and any(rule.fullmatch(path) for rule in self.bypass)
354
+
355
+ def _effective_url(self, scope: dict[str, Any], headers: dict[str, str], raw_path: str) -> str:
356
+ query = scope.get("query_string", b"")
357
+ target = raw_path + (f"?{query.decode('latin-1')}" if query else "")
358
+ if self.config.public_origin:
359
+ origin = urlsplit(self.config.public_origin)
360
+ return effective_request_uri(origin.scheme, origin.netloc, target)
361
+ return effective_request_uri(scope.get("scheme", "https"), headers.get("host", ""), target)
362
+
363
+ async def _authenticate(
364
+ self, scope: dict[str, Any]
365
+ ) -> tuple[AidrIdentity, tuple[int, str, str] | None]:
366
+ headers = self._headers(scope)
367
+ raw_path = self._raw_path(scope)
368
+
369
+ if self._is_bypassed(raw_path):
370
+ return BYPASSED, None
371
+ if not any(name in headers for name in AGENT_HEADERS):
372
+ return ANONYMOUS, None
373
+ if self.sync is not None and not self.sync.ready:
374
+ return ANONYMOUS, (503, RegistryUnavailable.code,
375
+ "agent verification is not ready: registry trust not yet loaded")
376
+
377
+ now = self.config.clock() if self.config.clock else datetime.now(timezone.utc)
378
+ sink: list[str] = []
379
+ token = _collected_nonce.set(sink)
380
+ try:
381
+ result = self.verifier.verify(
382
+ method=scope.get("method", "GET"),
383
+ url=self._effective_url(scope, headers, raw_path),
384
+ headers=headers, now=now)
385
+ self._check_floors(result)
386
+ if not sink:
387
+ raise MalformedToken("the signature carries no nonce")
388
+ ttl = self._nonce_ttl(result, now)
389
+ if not await self.nonce_store.claim(result.aid, sink[0], ttl):
390
+ raise ReplayDetected("signature nonce has already been used")
391
+ except AidrError as exc:
392
+ if isinstance(exc, UnknownIssuer) and exc.context.get("kid") and self.sync:
393
+ self.sync.request_key_refresh()
394
+ return ANONYMOUS, self._classify(exc, scope)
395
+ except Exception: # noqa: BLE001 - never let an unexpected input become a pass or a 500
396
+ logger.exception("AIDR: unexpected error verifying a request; refusing it")
397
+ return ANONYMOUS, (401, MalformedToken.code, "request could not be verified")
398
+ finally:
399
+ _collected_nonce.reset(token)
400
+
401
+ if result.warnings:
402
+ logger.warning("AIDR: verified %s with warnings: %s", result.aid,
403
+ "; ".join(result.warnings))
404
+ return AidrIdentity("verified", result, tuple(result.warnings)), None
405
+
406
+ def _check_floors(self, result: VerificationResult) -> None:
407
+ for axis, have, need in (("AgAL", result.agal, self.config.min_agal),
408
+ ("OAL", result.oal, self.config.min_oal),
409
+ ("KAL", result.kal, self.config.min_kal)):
410
+ if have < need:
411
+ raise AssuranceTooLow(f"agent {axis} {have} is below this site's required {need}",
412
+ aid=result.aid, axis=axis, have=have, required=need)
413
+
414
+ def _nonce_ttl(self, result: VerificationResult, now: datetime) -> int:
415
+ """Remember the nonce exactly as long as its signature could still be accepted."""
416
+ created = result.signature_created_at or now
417
+ remaining = (created - now).total_seconds() + self.config.max_signature_age_seconds
418
+ return max(1, math.ceil(remaining) + 1)
419
+
420
+ def _classify(self, exc: AidrError, scope: dict[str, Any]) -> tuple[int, str, str]:
421
+ if isinstance(exc, RegistryUnavailable):
422
+ status = 503
423
+ elif isinstance(exc, _FORBIDDEN):
424
+ status = 403
425
+ else:
426
+ status = 401
427
+ # Never log the Signature header or the credential: within the signature's lifetime a
428
+ # logged pair is a replayable request on any instance without a shared nonce store.
429
+ logger.info("AIDR: refused %s %s: %d %s", scope.get("method"), scope.get("path"),
430
+ status, exc.code)
431
+ return status, exc.code, exc.message
432
+
433
+ async def _refuse(self, send: Any, status: int, code: str, reason: str) -> None:
434
+ body = json.dumps({"error": code, "reason": reason}).encode()
435
+ headers = [(b"content-type", b"application/json"), (b"cache-control", b"no-store"),
436
+ (b"content-length", str(len(body)).encode())]
437
+ if status == 503:
438
+ headers.append((b"retry-after", b"30"))
439
+ await send({"type": "http.response.start", "status": status, "headers": headers})
440
+ await send({"type": "http.response.body", "body": body})
441
+
442
+
443
+ __all__ = ["AidrMiddleware", "RelyingPartyConfig", "AidrIdentity", "ANONYMOUS", "BYPASSED",
444
+ "normalise_path"]