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.
- aidr_verifier/__init__.py +60 -0
- aidr_verifier/agent.py +146 -0
- aidr_verifier/asgi.py +444 -0
- aidr_verifier/edge.py +140 -0
- aidr_verifier/fastapi.py +57 -0
- aidr_verifier/nonce.py +95 -0
- aidr_verifier/sync.py +232 -0
- aidr_verifier/trust.py +282 -0
- aidr_verifier/verifier.py +448 -0
- aidr_verifier-0.1.0.dist-info/METADATA +44 -0
- aidr_verifier-0.1.0.dist-info/RECORD +13 -0
- aidr_verifier-0.1.0.dist-info/WHEEL +4 -0
- aidr_verifier-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -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"]
|