ubag 0.2.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.
ubag/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """
2
+ UBAG Protocol — Agent identity and routing middleware.
3
+
4
+ The missing identity layer for MCP agents.
5
+ """
6
+ from ubag._routing import RoutingBranch, resolve_branch
7
+ from ubag._credential import issue_credential, validate_credential, CREDENTIAL_HEADER
8
+ from ubag._challenge import generate_challenge, verify_challenge
9
+ from ubag._agents_json import build_agents_json
10
+ from ubag._keys import (
11
+ generate_agent_keypair,
12
+ generate_issuer_keypair,
13
+ issuer_public_from_private,
14
+ agent_id,
15
+ agent_sign,
16
+ agent_verify,
17
+ build_jwks,
18
+ )
19
+ from ubag.agent import AgentCredential
20
+
21
+ __version__ = "0.2.0"
22
+ __all__ = [
23
+ "UBAGMiddleware",
24
+ "AgentCredential",
25
+ "RoutingBranch",
26
+ "resolve_branch",
27
+ "issue_credential",
28
+ "validate_credential",
29
+ "generate_challenge",
30
+ "verify_challenge",
31
+ "build_agents_json",
32
+ "CREDENTIAL_HEADER",
33
+ "generate_agent_keypair",
34
+ "generate_issuer_keypair",
35
+ "issuer_public_from_private",
36
+ "agent_id",
37
+ "agent_sign",
38
+ "agent_verify",
39
+ "build_jwks",
40
+ ]
41
+
42
+ # Lazy import so FastAPI/Starlette is optional
43
+ def __getattr__(name: str):
44
+ if name == "UBAGMiddleware":
45
+ from ubag.middleware.fastapi import UBAGMiddleware
46
+ return UBAGMiddleware
47
+ raise AttributeError(f"module 'ubag' has no attribute {name!r}")
ubag/_agents_json.py ADDED
@@ -0,0 +1,60 @@
1
+ """
2
+ ubag.json — machine-readable agent discovery file.
3
+
4
+ Served automatically at /.well-known/ubag.json on every UBAG-enabled site
5
+ (with /agents.json kept as a legacy alias). Tells MCP agents how to authenticate
6
+ and what they can access. Deliberately *not* named agents.json — that filename is
7
+ already used by unrelated specs (Wildcard, ARD, etc.); ubag.json avoids the clash.
8
+ """
9
+ from __future__ import annotations
10
+
11
+
12
+ def build_agents_json(
13
+ host: str,
14
+ credential_endpoint: str = "",
15
+ contact: str = "",
16
+ custom_fields: dict | None = None,
17
+ ) -> dict:
18
+ """
19
+ Build the ubag.json discovery document for a given host.
20
+
21
+ MCP agents should fetch this before making requests to understand
22
+ what credentials are required and what data is available.
23
+
24
+ `credential_endpoint` is where an agent obtains a credential. A self-issuing
25
+ site (one configured with an issuer key) mints credentials at its own
26
+ `/ubag/verify`, so when no endpoint is given we advertise that — no hosted
27
+ central registry is required.
28
+ """
29
+ credential_endpoint = credential_endpoint or f"https://{host}/ubag/verify"
30
+ doc = {
31
+ "ubag_version": "1.0",
32
+ "host": host,
33
+ "credential_endpoint": credential_endpoint,
34
+ "branches": {
35
+ "B-AGENT": {
36
+ "description": "Authorized MCP agents — receive clean JSON-LD structured data",
37
+ "requires": "X-UBAG-Credential header with valid JWT",
38
+ "content_type": "application/ld+json",
39
+ },
40
+ "A-HUMAN": {
41
+ "description": "Human browsers — transparently proxied to origin",
42
+ "requires": "None",
43
+ },
44
+ "C-SANDBOX": {
45
+ "description": "Unknown agents — Ed25519 nonce-signature challenge",
46
+ "requires": "None — solve challenge to get credentialed",
47
+ "challenge_endpoint": "/ubag/verify",
48
+ },
49
+ },
50
+ "discovery": {
51
+ "ubag_json": f"https://{host}/.well-known/ubag.json",
52
+ "verify_endpoint": f"https://{host}/ubag/verify",
53
+ "jwks_endpoint": f"https://{host}/.well-known/jwks.json",
54
+ },
55
+ }
56
+ if contact:
57
+ doc["contact"] = contact
58
+ if custom_fields:
59
+ doc.update(custom_fields)
60
+ return doc
ubag/_challenge.py ADDED
@@ -0,0 +1,111 @@
1
+ """
2
+ Branch C — agent identity challenge (asymmetric).
3
+
4
+ An unknown agent is issued a one-time nonce. It proves control of its identity
5
+ key by signing the nonce with its Ed25519 PRIVATE key; the server verifies with
6
+ the agent's PUBLIC key. That is what establishes *who* the agent is — knowledge
7
+ of a shared secret never could.
8
+
9
+ The nonce carries a server-side HMAC `stamp` so the server can confirm it issued
10
+ the nonce without storing it (stateless issuance). That HMAC is the server signing
11
+ to *itself* — it is not part of the identity proof.
12
+
13
+ Replay: nonces are one-time. Provide a shared `store` (Redis/DB) in multi-process
14
+ deployments; the in-memory default only protects a single process.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import hmac
20
+ import secrets
21
+ import time
22
+ from typing import Optional, Protocol
23
+
24
+ from ubag._keys import agent_id, agent_verify
25
+
26
+
27
+ class UBAGNonceStore(Protocol):
28
+ def exists(self, nonce: str) -> bool: ...
29
+ def mark_used(self, nonce: str) -> None: ...
30
+
31
+
32
+ class _MemoryNonceStore:
33
+ def __init__(self) -> None:
34
+ self._used: set[str] = set()
35
+
36
+ def exists(self, nonce: str) -> bool:
37
+ return nonce in self._used
38
+
39
+ def mark_used(self, nonce: str) -> None:
40
+ self._used.add(nonce)
41
+
42
+
43
+ _default_store = _MemoryNonceStore()
44
+
45
+
46
+ def _stamp(server_secret: str, nonce: str, ts: int) -> str:
47
+ return hmac.new(
48
+ server_secret.encode(), f"{nonce}:{ts}".encode(), hashlib.sha256
49
+ ).hexdigest()
50
+
51
+
52
+ def generate_challenge(server_secret: str, ttl: int = 120) -> dict:
53
+ """Generate a nonce challenge for an unknown agent (sent in the 429 body).
54
+
55
+ The agent must:
56
+ 1. read `nonce`
57
+ 2. sign the nonce bytes with its Ed25519 private key
58
+ 3. POST {nonce, timestamp, stamp, agent_public, signature} to /ubag/verify
59
+ """
60
+ nonce = secrets.token_urlsafe(32)
61
+ ts = int(time.time())
62
+ return {
63
+ "nonce": nonce,
64
+ "timestamp": ts,
65
+ "ttl": ttl,
66
+ "algo": "Ed25519",
67
+ "stamp": _stamp(server_secret, nonce, ts),
68
+ "instructions": (
69
+ "Sign the `nonce` bytes with your Ed25519 private key and POST "
70
+ "{nonce, timestamp, stamp, agent_public, signature} to /ubag/verify."
71
+ ),
72
+ }
73
+
74
+
75
+ def verify_challenge(
76
+ server_secret: str,
77
+ nonce: str,
78
+ timestamp: int,
79
+ stamp: str,
80
+ agent_public: str,
81
+ signature: str,
82
+ ttl: int = 120,
83
+ store: Optional[UBAGNonceStore] = None,
84
+ ) -> tuple[bool, str, Optional[str]]:
85
+ """Verify a challenge response. Returns (ok, reason, agent_id).
86
+
87
+ 1. Replay — nonce not already used
88
+ 2. Stamp — the server issued this exact (nonce, ts), untampered
89
+ 3. Expiry — within TTL
90
+ 4. Identity — agent's signature over the nonce verifies under agent_public
91
+ """
92
+ nonce_store = store or _default_store
93
+
94
+ if not nonce or not agent_public or not signature:
95
+ return False, "missing_fields", None
96
+
97
+ if nonce_store.exists(nonce):
98
+ return False, "nonce_already_used", None
99
+
100
+ if not hmac.compare_digest(_stamp(server_secret, nonce, timestamp), stamp):
101
+ return False, "invalid_stamp", None
102
+
103
+ if int(time.time()) - timestamp > ttl:
104
+ return False, "nonce_expired", None
105
+
106
+ # The identity proof: only the holder of the matching private key can produce this.
107
+ if not agent_verify(agent_public, nonce.encode(), signature):
108
+ return False, "bad_signature", None
109
+
110
+ nonce_store.mark_used(nonce)
111
+ return True, "authorized", agent_id(agent_public)
ubag/_credential.py ADDED
@@ -0,0 +1,58 @@
1
+ """
2
+ Credential issuance and validation (asymmetric, ES256).
3
+
4
+ A credential is a short-lived JWT signed by the ISSUER's private key and verified
5
+ with the issuer's PUBLIC key. Because verification needs only the public key
6
+ (distributable via JWKS), any independent site can validate a credential without
7
+ holding any secret — the OAuth/OIDC model.
8
+
9
+ The credential is bound to the agent's identity key via the `cnf` (confirmation)
10
+ claim, so a verifier can later require proof-of-possession of the agent key.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ from typing import Optional
16
+
17
+ import jwt
18
+
19
+ from ubag._keys import agent_id
20
+
21
+ CREDENTIAL_HEADER = "X-UBAG-Credential"
22
+ _DEFAULT_TTL = 300 # 5 minutes
23
+ _ALG = "ES256"
24
+
25
+
26
+ def issue_credential(
27
+ subject: str,
28
+ issuer_private_pem: str,
29
+ agent_public: Optional[str] = None,
30
+ agent_class: str = "authorized_agent",
31
+ ttl: int = _DEFAULT_TTL,
32
+ allowed_paths: list[str] | None = None,
33
+ issuer: str = "https://ubagprotocol.com",
34
+ kid: str = "ubag-issuer-1",
35
+ ) -> str:
36
+ """Mint a credential JWT signed with the issuer's EC P-256 private key (ES256)."""
37
+ now = int(time.time())
38
+ payload = {
39
+ "iss": issuer,
40
+ "sub": subject,
41
+ "iat": now,
42
+ "exp": now + ttl,
43
+ "agent_class": agent_class,
44
+ "paths": allowed_paths or ["/*"],
45
+ }
46
+ if agent_public:
47
+ # Bind the credential to the agent's identity key (key thumbprint).
48
+ payload["cnf"] = {"jkt": agent_id(agent_public), "pub": agent_public}
49
+ return jwt.encode(payload, issuer_private_pem, algorithm=_ALG, headers={"kid": kid})
50
+
51
+
52
+ def validate_credential(token: str, issuer_public_pem: str) -> Optional[dict]:
53
+ """Return decoded claims if the credential is validly signed by the issuer,
54
+ else None. Verifies with the issuer's PUBLIC key — no shared secret."""
55
+ try:
56
+ return jwt.decode(token, issuer_public_pem, algorithms=[_ALG])
57
+ except jwt.PyJWTError:
58
+ return None
ubag/_keys.py ADDED
@@ -0,0 +1,119 @@
1
+ """
2
+ Asymmetric key primitives for UBAG.
3
+
4
+ Two key types, each chosen for a reason:
5
+
6
+ * Agent identity -> Ed25519. Raw 64-byte signatures, no DER/encoding ambiguity,
7
+ so an agent signature produced in Python verifies byte-for-byte in Node and
8
+ vice-versa. The agent's PUBLIC key is its identity.
9
+
10
+ * Issuer (credential signing) -> EC P-256 / ES256 JWT. Chosen because it is the
11
+ one asymmetric JWT algorithm supported by BOTH PyJWT and Node's `jsonwebtoken`,
12
+ so a credential minted by either SDK verifies with the other via the issuer's
13
+ PUBLIC key (JWKS) — the OAuth/OIDC model. No shared secret, works across sites.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import hashlib
19
+
20
+ from cryptography.hazmat.primitives import serialization
21
+ from cryptography.hazmat.primitives.asymmetric import ec, ed25519
22
+
23
+
24
+ def _b64u(b: bytes) -> str:
25
+ return base64.urlsafe_b64encode(b).decode("ascii").rstrip("=")
26
+
27
+
28
+ def _b64u_decode(s: str) -> bytes:
29
+ return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
30
+
31
+
32
+ # ── Agent identity keys (Ed25519) ──────────────────────────────────────────────
33
+
34
+ def generate_agent_keypair() -> tuple[str, str]:
35
+ """Generate an agent identity keypair. Returns (private_b64url, public_b64url)
36
+ of the raw 32-byte Ed25519 keys."""
37
+ sk = ed25519.Ed25519PrivateKey.generate()
38
+ priv = sk.private_bytes(
39
+ serialization.Encoding.Raw,
40
+ serialization.PrivateFormat.Raw,
41
+ serialization.NoEncryption(),
42
+ )
43
+ pub = sk.public_key().public_bytes(
44
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw
45
+ )
46
+ return _b64u(priv), _b64u(pub)
47
+
48
+
49
+ def agent_sign(private_b64: str, message: bytes) -> str:
50
+ """Sign a message with an agent's Ed25519 private key. Returns b64url signature."""
51
+ sk = ed25519.Ed25519PrivateKey.from_private_bytes(_b64u_decode(private_b64))
52
+ return _b64u(sk.sign(message))
53
+
54
+
55
+ def agent_verify(public_b64: str, message: bytes, signature_b64: str) -> bool:
56
+ """Verify an agent's Ed25519 signature over `message`. Never raises."""
57
+ try:
58
+ pk = ed25519.Ed25519PublicKey.from_public_bytes(_b64u_decode(public_b64))
59
+ pk.verify(_b64u_decode(signature_b64), message)
60
+ return True
61
+ except Exception:
62
+ return False
63
+
64
+
65
+ def agent_id(public_b64: str) -> str:
66
+ """Stable agent identity derived from its public key (a key thumbprint).
67
+ This is what `sub` / `cnf` reference — identity is the key, not a claimed name."""
68
+ digest = hashlib.sha256(_b64u_decode(public_b64)).digest()
69
+ return "ubag:" + _b64u(digest)[:43]
70
+
71
+
72
+ # ── Issuer keys (EC P-256, for ES256 credential JWTs) ──────────────────────────
73
+
74
+ def generate_issuer_keypair() -> tuple[str, str]:
75
+ """Generate an issuer keypair for signing credentials.
76
+ Returns (private_pem, public_pem) for an EC P-256 key (ES256)."""
77
+ sk = ec.generate_private_key(ec.SECP256R1())
78
+ priv = sk.private_bytes(
79
+ serialization.Encoding.PEM,
80
+ serialization.PrivateFormat.PKCS8,
81
+ serialization.NoEncryption(),
82
+ ).decode("ascii")
83
+ pub = sk.public_key().public_bytes(
84
+ serialization.Encoding.PEM,
85
+ serialization.PublicFormat.SubjectPublicKeyInfo,
86
+ ).decode("ascii")
87
+ return priv, pub
88
+
89
+
90
+ def issuer_public_from_private(private_pem: str) -> str:
91
+ """Derive the issuer public PEM from its private PEM (so a site configured only
92
+ with the private key can also verify)."""
93
+ sk = serialization.load_pem_private_key(private_pem.encode("ascii"), password=None)
94
+ return sk.public_key().public_bytes(
95
+ serialization.Encoding.PEM,
96
+ serialization.PublicFormat.SubjectPublicKeyInfo,
97
+ ).decode("ascii")
98
+
99
+
100
+ def build_jwks(public_pem: str, kid: str = "ubag-issuer-1") -> dict:
101
+ """Build a JWKS document for the issuer's EC P-256 public key, so resource
102
+ servers can verify credentials by fetching /.well-known/jwks.json."""
103
+ pk = serialization.load_pem_public_key(public_pem.encode("ascii"))
104
+ nums = pk.public_numbers()
105
+ x = nums.x.to_bytes(32, "big")
106
+ y = nums.y.to_bytes(32, "big")
107
+ return {
108
+ "keys": [
109
+ {
110
+ "kty": "EC",
111
+ "crv": "P-256",
112
+ "x": _b64u(x),
113
+ "y": _b64u(y),
114
+ "use": "sig",
115
+ "alg": "ES256",
116
+ "kid": kid,
117
+ }
118
+ ]
119
+ }
ubag/_routing.py ADDED
@@ -0,0 +1,71 @@
1
+ """
2
+ Three-branch routing matrix.
3
+
4
+ Branch A — Human → transparent proxy to origin
5
+ Branch B — Agent → clean JSON-LD structured data
6
+ Branch C — Sandbox → cryptographic challenge
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from enum import Enum
12
+ from typing import Optional
13
+
14
+
15
+ class RoutingBranch(str, Enum):
16
+ HUMAN = "A-HUMAN"
17
+ AGENT = "B-AGENT"
18
+ SANDBOX = "C-SANDBOX"
19
+
20
+
21
+ # HTTP library user-agent signatures — these are never human
22
+ _MACHINE_UA = re.compile(
23
+ r"(python-httpx|python-requests|aiohttp|curl|wget|go-http-client|"
24
+ r"java\/|okhttp|axios|node-fetch|got\/|undici|libwww|mechanize|"
25
+ r"scrapy|selenium|playwright|puppeteer|headlesschrome|"
26
+ r"GPTBot|ClaudeBot|PerplexityBot|anthropic-ai|Googlebot|bingbot|"
27
+ r"DuckDuckBot|Baiduspider|YandexBot|facebookexternalhit|Twitterbot)",
28
+ re.IGNORECASE,
29
+ )
30
+
31
+ _BROWSER_ACCEPT = re.compile(r"text/html", re.IGNORECASE)
32
+ _BROWSER_UA = re.compile(r"Mozilla|Chrome|Safari|Firefox|Edge", re.IGNORECASE)
33
+
34
+
35
+ def _is_machine(user_agent: str, accept: str) -> bool:
36
+ """
37
+ Heuristic: declared machine UA or missing browser Accept header.
38
+ Errs toward HUMAN for ambiguous traffic (fail open).
39
+ """
40
+ if _MACHINE_UA.search(user_agent):
41
+ return True
42
+ # UA claims to be a browser but sends no text/html Accept — likely a lib
43
+ if _BROWSER_UA.search(user_agent) and not _BROWSER_ACCEPT.search(accept):
44
+ return True
45
+ return False
46
+
47
+
48
+ def resolve_branch(
49
+ user_agent: str,
50
+ accept: str,
51
+ credential_token: Optional[str],
52
+ validate_fn,
53
+ ) -> RoutingBranch:
54
+ """
55
+ Resolve which branch handles this request.
56
+
57
+ Args:
58
+ user_agent: Value of User-Agent header
59
+ accept: Value of Accept header
60
+ credential_token: Value of X-UBAG-Credential header (may be None)
61
+ validate_fn: Callable[str] -> Optional[dict] — verifies the token
62
+ """
63
+ if credential_token:
64
+ claims = validate_fn(credential_token)
65
+ if claims:
66
+ return RoutingBranch.AGENT
67
+
68
+ if _is_machine(user_agent, accept):
69
+ return RoutingBranch.SANDBOX
70
+
71
+ return RoutingBranch.HUMAN
ubag/_sux.py ADDED
@@ -0,0 +1,48 @@
1
+ """
2
+ Branch B — Structured UX (S-UX) response builder.
3
+
4
+ Transforms site metadata into a clean JSON-LD payload for authorized agents.
5
+ Agents get structured data in one request instead of scraping 50 pages.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from typing import Any, Optional
11
+
12
+
13
+ def build_jsonld_response(
14
+ host: str,
15
+ path: str,
16
+ site_meta: dict[str, Any],
17
+ agent_claims: dict,
18
+ ) -> dict:
19
+ """
20
+ Build a JSON-LD structured data response for Branch B agents.
21
+
22
+ Args:
23
+ host: The requesting host (e.g. "example.com")
24
+ path: The requested path (e.g. "/products/widget")
25
+ site_meta: Developer-supplied metadata dict (see UBAGMiddleware.site_meta)
26
+ agent_claims: Decoded credential claims
27
+
28
+ Returns a JSON-LD compliant dict ready to serialize.
29
+ """
30
+ now = int(time.time())
31
+
32
+ base = {
33
+ "@context": "https://schema.org",
34
+ "@type": site_meta.get("type", "WebSite"),
35
+ "url": f"https://{host}{path}",
36
+ "name": site_meta.get("name", host),
37
+ "ubag:source": f"https://{host}",
38
+ "ubag:served_at": now,
39
+ "ubag:agent": agent_claims.get("sub", "unknown"),
40
+ "ubag:branch": "B-AGENT",
41
+ }
42
+
43
+ # Merge any developer-supplied schema.org fields
44
+ for key, value in site_meta.items():
45
+ if key not in ("type",):
46
+ base[key] = value
47
+
48
+ return base
ubag/agent.py ADDED
@@ -0,0 +1,90 @@
1
+ """
2
+ AgentCredential — client-side helper for MCP agent developers.
3
+
4
+ An agent's identity IS its Ed25519 keypair. To get into a UBAG-enabled site the
5
+ agent solves the site's nonce challenge by signing it with its private key; the
6
+ site (or central issuer) returns a credential the agent then carries.
7
+
8
+ Usage:
9
+ from ubag import AgentCredential
10
+
11
+ agent = AgentCredential.generate(owner="me@example.com") # once; persist agent.export()
12
+ # ... on a 429 challenge from a site:
13
+ solution = agent.solve_challenge(challenge) # POST to /ubag/verify
14
+ agent.set_credential(resp["credential"]) # store what you get back
15
+ headers = agent.headers() # attach to future requests
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from typing import Optional
20
+
21
+ from ubag._credential import CREDENTIAL_HEADER
22
+ from ubag._keys import agent_id, agent_sign, generate_agent_keypair
23
+
24
+
25
+ class AgentCredential:
26
+ """Holds an agent's identity keypair and (once obtained) its credential token."""
27
+
28
+ def __init__(
29
+ self,
30
+ private_key: str,
31
+ public_key: str,
32
+ owner: str = "",
33
+ agent_class: str = "mcp_agent",
34
+ ) -> None:
35
+ self.private_key = private_key
36
+ self.public_key = public_key
37
+ self.owner = owner
38
+ self.agent_class = agent_class
39
+ self.agent_id = agent_id(public_key)
40
+ self._token: Optional[str] = None
41
+
42
+ @classmethod
43
+ def generate(cls, owner: str = "", agent_class: str = "mcp_agent") -> "AgentCredential":
44
+ """Create a brand-new agent identity (fresh Ed25519 keypair)."""
45
+ priv, pub = generate_agent_keypair()
46
+ return cls(private_key=priv, public_key=pub, owner=owner, agent_class=agent_class)
47
+
48
+ def export(self) -> dict[str, str]:
49
+ """Serialize the identity for persistence. Keep `private_key` secret."""
50
+ return {
51
+ "private_key": self.private_key,
52
+ "public_key": self.public_key,
53
+ "owner": self.owner,
54
+ "agent_class": self.agent_class,
55
+ }
56
+
57
+ @classmethod
58
+ def load(cls, data: dict) -> "AgentCredential":
59
+ return cls(
60
+ private_key=data["private_key"],
61
+ public_key=data["public_key"],
62
+ owner=data.get("owner", ""),
63
+ agent_class=data.get("agent_class", "mcp_agent"),
64
+ )
65
+
66
+ def solve_challenge(self, challenge: dict) -> dict:
67
+ """Sign a site's nonce challenge. Returns the body to POST to /ubag/verify."""
68
+ nonce = challenge["nonce"]
69
+ return {
70
+ "nonce": nonce,
71
+ "timestamp": challenge["timestamp"],
72
+ "stamp": challenge["stamp"],
73
+ "agent_public": self.public_key,
74
+ "signature": agent_sign(self.private_key, nonce.encode()),
75
+ }
76
+
77
+ def set_credential(self, token: str) -> None:
78
+ """Store the credential returned by /ubag/verify."""
79
+ self._token = token
80
+
81
+ def headers(self) -> dict[str, str]:
82
+ """Headers to attach to requests once a credential has been obtained."""
83
+ if not self._token:
84
+ raise RuntimeError(
85
+ "No credential yet — solve a site challenge and call set_credential() first."
86
+ )
87
+ return {CREDENTIAL_HEADER: self._token}
88
+
89
+ def __repr__(self) -> str:
90
+ return f"AgentCredential(agent_id={self.agent_id!r}, agent_class={self.agent_class!r})"
File without changes
@@ -0,0 +1,307 @@
1
+ """
2
+ FastAPI / Starlette ASGI middleware.
3
+
4
+ Usage:
5
+ from fastapi import FastAPI
6
+ from ubag import UBAGMiddleware, generate_issuer_keypair
7
+
8
+ issuer_private, _ = generate_issuer_keypair() # EC P-256 (ES256)
9
+
10
+ app = FastAPI()
11
+ app.add_middleware(
12
+ UBAGMiddleware,
13
+ origin="https://yoursite.com",
14
+ issuer_key=issuer_private, # mints + verifies credentials
15
+ site_meta={
16
+ "name": "My Store",
17
+ "type": "Store",
18
+ "description": "We sell widgets",
19
+ },
20
+ )
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ import os
27
+ from typing import Any, Callable, Optional
28
+
29
+ import httpx
30
+ from starlette.middleware.base import BaseHTTPMiddleware
31
+ from starlette.requests import Request
32
+ from starlette.responses import JSONResponse, Response
33
+
34
+ from ubag._agents_json import build_agents_json
35
+ from ubag._challenge import generate_challenge, verify_challenge
36
+ from ubag._credential import CREDENTIAL_HEADER, issue_credential, validate_credential
37
+ from ubag._keys import build_jwks, issuer_public_from_private
38
+ from ubag._routing import RoutingBranch, resolve_branch
39
+ from ubag._sux import build_jsonld_response
40
+
41
+
42
+ class UBAGMiddleware(BaseHTTPMiddleware):
43
+ """
44
+ UBAG three-branch routing middleware for FastAPI / Starlette.
45
+
46
+ Args:
47
+ app: The ASGI application to wrap.
48
+ origin: The upstream origin URL (Branch A proxy target).
49
+ e.g. "https://yoursite.com" or "https://192.168.1.1"
50
+ secret_key: HMAC/JWT secret. Must match credentials issued to agents.
51
+ Defaults to UBAG_SECRET_KEY env var.
52
+ site_meta: Schema.org metadata served to Branch B agents.
53
+ Keys map directly to JSON-LD fields.
54
+ credential_endpoint: URL where agents obtain credentials.
55
+ audit_fn: Optional callback(branch, request, response) for logging.
56
+ on_verified: Optional callback(claims, request) when a challenge is solved.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ app,
62
+ origin: str = "",
63
+ issuer_key: str = "",
64
+ issuer_public_key: str = "",
65
+ server_secret: str = "",
66
+ site_meta: dict[str, Any] | None = None,
67
+ credential_endpoint: str = "",
68
+ audit_fn: Optional[Callable] = None,
69
+ on_verified: Optional[Callable] = None,
70
+ ) -> None:
71
+ super().__init__(app)
72
+ self.origin = origin.rstrip("/")
73
+ # Issuer private key (EC P-256 PEM) lets this site MINT credentials; the public
74
+ # key (derived from it) is used to VERIFY. A verify-only site can pass
75
+ # issuer_public_key alone — no secret required to validate, the OAuth/JWKS model.
76
+ self.issuer_private = issuer_key or os.getenv("UBAG_ISSUER_KEY", "")
77
+ if self.issuer_private:
78
+ self.issuer_public = issuer_public_from_private(self.issuer_private)
79
+ else:
80
+ self.issuer_public = issuer_public_key or os.getenv("UBAG_ISSUER_PUBLIC", "")
81
+ # HMAC key for stateless nonce stamping — the server signing to itself.
82
+ self.server_secret = (
83
+ server_secret or os.getenv("UBAG_SERVER_SECRET")
84
+ or hashlib.sha256((self.issuer_private or "ubag-stamp").encode()).hexdigest()
85
+ )
86
+ self.site_meta = site_meta or {}
87
+ self.credential_endpoint = credential_endpoint
88
+ self.audit_fn = audit_fn
89
+ self.on_verified = on_verified
90
+
91
+ self._http_client: httpx.AsyncClient | None = None
92
+
93
+ # ------------------------------------------------------------------
94
+ # Middleware entry point
95
+ # ------------------------------------------------------------------
96
+
97
+ async def dispatch(self, request: Request, call_next) -> Response:
98
+ path = request.url.path
99
+
100
+ # UBAG system paths — handled here, never forwarded
101
+ if path in ("/.well-known/ubag.json", "/agents.json"): # /agents.json = legacy alias
102
+ return self._ubag_json_response(request)
103
+ if path == "/.well-known/jwks.json":
104
+ return self._jwks_response()
105
+ if path == "/ubag/verify":
106
+ return await self._handle_verify(request)
107
+
108
+ # Routing
109
+ ua = request.headers.get("user-agent", "")
110
+ accept = request.headers.get("accept", "")
111
+ cred_token = request.headers.get(CREDENTIAL_HEADER.lower()) or \
112
+ request.headers.get(CREDENTIAL_HEADER)
113
+
114
+ branch = resolve_branch(
115
+ user_agent=ua,
116
+ accept=accept,
117
+ credential_token=cred_token,
118
+ validate_fn=lambda t: validate_credential(t, self.issuer_public),
119
+ )
120
+
121
+ if branch == RoutingBranch.AGENT:
122
+ response = self._branch_b(request, cred_token)
123
+ elif branch == RoutingBranch.SANDBOX:
124
+ response = self._branch_c(request)
125
+ else:
126
+ if self.origin:
127
+ response = await self._branch_a(request)
128
+ else:
129
+ response = await call_next(request)
130
+
131
+ if self.audit_fn:
132
+ try:
133
+ self.audit_fn(branch, request, response)
134
+ except Exception:
135
+ pass
136
+
137
+ return response
138
+
139
+ # ------------------------------------------------------------------
140
+ # Branch B — Authorized agent → JSON-LD
141
+ # ------------------------------------------------------------------
142
+
143
+ def _branch_b(self, request: Request, token: str) -> JSONResponse:
144
+ claims = validate_credential(token, self.issuer_public)
145
+ host = request.headers.get("host", "").split(":")[0]
146
+ payload = build_jsonld_response(
147
+ host=host,
148
+ path=request.url.path,
149
+ site_meta=self.site_meta,
150
+ agent_claims=claims or {},
151
+ )
152
+ return JSONResponse(
153
+ content=payload,
154
+ media_type="application/ld+json",
155
+ headers={
156
+ "X-UBAG-Branch": "B-AGENT",
157
+ CREDENTIAL_HEADER: token,
158
+ },
159
+ )
160
+
161
+ # ------------------------------------------------------------------
162
+ # Branch A — Human → transparent proxy to origin
163
+ # ------------------------------------------------------------------
164
+
165
+ async def _branch_a(self, request: Request) -> Response:
166
+ client = self._get_http_client()
167
+ full_path = request.url.path
168
+ url = f"{self.origin}/{full_path.lstrip('/')}"
169
+ if request.url.query:
170
+ url += f"?{request.url.query}"
171
+
172
+ skip = {"connection", "transfer-encoding", "te", "trailer", "upgrade"}
173
+ headers = {k: v for k, v in request.headers.items() if k.lower() not in skip}
174
+ # Preserve original Host so shared hosting serves the right site
175
+ headers["host"] = request.headers.get("host", "").split(":")[0]
176
+ headers["x-forwarded-for"] = request.client.host if request.client else "unknown"
177
+ headers["x-forwarded-proto"] = "https"
178
+
179
+ body = await request.body()
180
+ resp = await client.request(
181
+ method=request.method,
182
+ url=url,
183
+ headers=headers,
184
+ content=body,
185
+ )
186
+
187
+ skip_resp = {"transfer-encoding", "connection", "keep-alive", "upgrade"}
188
+ resp_headers = {k: v for k, v in resp.headers.items() if k.lower() not in skip_resp}
189
+ resp_headers["X-UBAG-Branch"] = "A-HUMAN"
190
+
191
+ return Response(
192
+ content=resp.content,
193
+ status_code=resp.status_code,
194
+ headers=resp_headers,
195
+ media_type=resp.headers.get("content-type"),
196
+ )
197
+
198
+ # ------------------------------------------------------------------
199
+ # Branch C — Unknown agent → sandbox challenge
200
+ # ------------------------------------------------------------------
201
+
202
+ def _branch_c(self, request: Request) -> JSONResponse:
203
+ challenge = generate_challenge(self.server_secret)
204
+ return JSONResponse(
205
+ status_code=429,
206
+ content={
207
+ "status": "challenge_required",
208
+ "ubag_challenge": challenge,
209
+ },
210
+ headers={"X-UBAG-Branch": "C-SANDBOX"},
211
+ )
212
+
213
+ # ------------------------------------------------------------------
214
+ # /ubag/verify — agent submits challenge solution
215
+ # ------------------------------------------------------------------
216
+
217
+ async def _handle_verify(self, request: Request) -> JSONResponse:
218
+ from ubag._challenge import verify_challenge
219
+
220
+ try:
221
+ body = await request.json()
222
+ except Exception:
223
+ return JSONResponse(status_code=400, content={"error": "invalid_json"})
224
+
225
+ ok, reason, aid = verify_challenge(
226
+ server_secret=self.server_secret,
227
+ nonce=body.get("nonce", ""),
228
+ timestamp=int(body.get("timestamp", 0)),
229
+ stamp=body.get("stamp", ""),
230
+ agent_public=body.get("agent_public", ""),
231
+ signature=body.get("signature", ""),
232
+ )
233
+
234
+ if not ok:
235
+ return JSONResponse(status_code=403, content={"status": "failed", "reason": reason})
236
+
237
+ if not self.issuer_private:
238
+ # Identity proven, but this site doesn't mint credentials itself —
239
+ # point the agent at the central issuer.
240
+ return JSONResponse(status_code=200, content={
241
+ "status": "verified",
242
+ "agent_id": aid,
243
+ "credential_endpoint": self.credential_endpoint,
244
+ "message": "Identity verified. Obtain a credential from credential_endpoint.",
245
+ })
246
+
247
+ token = issue_credential(
248
+ subject=aid,
249
+ issuer_private_pem=self.issuer_private,
250
+ agent_public=body.get("agent_public", ""),
251
+ )
252
+
253
+ if self.on_verified:
254
+ try:
255
+ claims = validate_credential(token, self.issuer_public)
256
+ self.on_verified(claims, request)
257
+ except Exception:
258
+ pass
259
+
260
+ return JSONResponse(
261
+ status_code=200,
262
+ content={
263
+ "status": "authorized",
264
+ "credential": token,
265
+ "header": CREDENTIAL_HEADER,
266
+ "instructions": f"Include '{CREDENTIAL_HEADER}: {token}' in all future requests.",
267
+ },
268
+ )
269
+
270
+ # ------------------------------------------------------------------
271
+ # /.well-known/ubag.json (alias: /agents.json) — discovery document
272
+ # ------------------------------------------------------------------
273
+
274
+ def _ubag_json_response(self, request: Request) -> JSONResponse:
275
+ host = request.headers.get("host", "").split(":")[0]
276
+ doc = build_agents_json(
277
+ host=host,
278
+ credential_endpoint=self.credential_endpoint,
279
+ )
280
+ return JSONResponse(content=doc, headers={"X-UBAG-Branch": "META"})
281
+
282
+ # ------------------------------------------------------------------
283
+ # /.well-known/jwks.json — issuer public key, so any site can verify
284
+ # this issuer's credentials without holding a secret (OAuth/OIDC model)
285
+ # ------------------------------------------------------------------
286
+
287
+ def _jwks_response(self) -> JSONResponse:
288
+ if not self.issuer_public:
289
+ return JSONResponse(status_code=404, content={"error": "no_issuer_key"})
290
+ return JSONResponse(
291
+ content=build_jwks(self.issuer_public),
292
+ headers={"X-UBAG-Branch": "META", "Cache-Control": "public, max-age=3600"},
293
+ )
294
+
295
+ # ------------------------------------------------------------------
296
+ # Shared HTTP client for Branch A
297
+ # ------------------------------------------------------------------
298
+
299
+ def _get_http_client(self) -> httpx.AsyncClient:
300
+ if self._http_client is None or self._http_client.is_closed:
301
+ self._http_client = httpx.AsyncClient(
302
+ follow_redirects=True,
303
+ timeout=30,
304
+ verify=False,
305
+ limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
306
+ )
307
+ return self._http_client
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: ubag
3
+ Version: 0.2.0
4
+ Summary: Agent identity and routing middleware for MCP-compatible agents
5
+ Author: Mohamed Ben Hadj Hmida
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://ubagprotocol.com
8
+ Project-URL: Repository, https://github.com/mohameduk/Ubag_protocol
9
+ Keywords: mcp,agents,ai,middleware,routing,bot-detection
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
17
+ Classifier: Topic :: Security
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: PyJWT[crypto]>=2.8.0
22
+ Requires-Dist: cryptography>=42.0
23
+ Requires-Dist: httpx>=0.27.0
24
+ Provides-Extra: fastapi
25
+ Requires-Dist: fastapi>=0.111.0; extra == "fastapi"
26
+ Requires-Dist: starlette>=0.37.0; extra == "fastapi"
27
+ Provides-Extra: django
28
+ Requires-Dist: django>=4.2; extra == "django"
29
+ Provides-Extra: flask
30
+ Requires-Dist: flask>=3.0; extra == "flask"
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest>=8.0; extra == "dev"
33
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
34
+ Requires-Dist: httpx>=0.27.0; extra == "dev"
35
+ Requires-Dist: fastapi>=0.111.0; extra == "dev"
36
+ Requires-Dist: starlette>=0.37.0; extra == "dev"
37
+ Dynamic: license-file
38
+
39
+ # ubag (Python)
40
+
41
+ **UBAG Web Layer — agent identity and routing at the web edge.** FastAPI / Starlette middleware.
42
+
43
+ When an autonomous agent visits a website, UBAG verifies *who it is* and routes
44
+ accordingly: humans to your normal site, credentialed agents to clean JSON-LD,
45
+ unknown bots to a cryptographic challenge. Asymmetric by design — agent identity
46
+ is an Ed25519 keypair; credentials are ES256 JWTs verifiable via JWKS, no shared
47
+ secrets.
48
+
49
+ > Early but real, pre-1.0, unaudited. See the
50
+ > [full README](https://github.com/mohameduk/Ubag_protocol#readme) and
51
+ > [SECURITY.md](https://github.com/mohameduk/Ubag_protocol/blob/main/SECURITY.md).
52
+
53
+ ## Install
54
+
55
+ ```bash
56
+ pip install "ubag[fastapi]"
57
+ ```
58
+
59
+ ## Quick start
60
+
61
+ ```python
62
+ from fastapi import FastAPI
63
+ from ubag import UBAGMiddleware, generate_issuer_keypair
64
+
65
+ issuer_private, _ = generate_issuer_keypair() # EC P-256 (ES256)
66
+
67
+ app = FastAPI()
68
+ app.add_middleware(
69
+ UBAGMiddleware,
70
+ origin="https://yoursite.com",
71
+ issuer_key=issuer_private, # mints + verifies credentials
72
+ site_meta={"name": "My Store", "type": "Store", "description": "We sell widgets"},
73
+ )
74
+ ```
75
+
76
+ Your site now serves clean JSON-LD to credentialed agents, proxies humans to your
77
+ origin, challenges unknown bots, exposes `/.well-known/ubag.json` for discovery,
78
+ and serves its issuer key as JWKS at `/.well-known/jwks.json`.
79
+
80
+ A Node/Express SDK with an identical, cross-verifiable wire format is in the same
81
+ repo. Full docs, a runnable demo, and the protocol details:
82
+ **https://github.com/mohameduk/Ubag_protocol**
83
+
84
+ MIT licensed.
@@ -0,0 +1,15 @@
1
+ ubag/__init__.py,sha256=rD-for6Vz864AUTzgeV0gV3JLQJhcGnZ0bsPblVjWOg,1293
2
+ ubag/_agents_json.py,sha256=eaq0RsKUQx_ArllBisbGOEloPyHh6ziUQ_QBcpjvo-8,2319
3
+ ubag/_challenge.py,sha256=GEXb5ni33UkKZMdtvbZrifM7oTVll5K76mU4dy3V78o,3513
4
+ ubag/_credential.py,sha256=8T_tPreL6wx910rJ1orMXg0MdA-gnDeUN8ryiaH58z4,1970
5
+ ubag/_keys.py,sha256=Opw5rlSYw-7HR2y1a4i4K_MD7YP5-0ywp0Xx4sgWJP4,4477
6
+ ubag/_routing.py,sha256=3GpCuGMHv05AXY8j26V_NsGLWoa81be1WqYjpEcFMoc,2160
7
+ ubag/_sux.py,sha256=PBudq9_7xf1lkcUsW4CE6m52jCqi-wqzRHVLS9JvV4I,1393
8
+ ubag/agent.py,sha256=73u_F9LgWju8Osp4T1rqrJb3J76DJe26Cp3CugfxS8I,3413
9
+ ubag/middleware/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ ubag/middleware/fastapi.py,sha256=0RXu8YTgrtqZ2BljHbz7C3fYQm0RBfDSLzOJ4O_kRck,12210
11
+ ubag-0.2.0.dist-info/licenses/LICENSE,sha256=1-GTGJfWAuz01QvGmwkJSmGdw_-0gW4G51PaL35j-go,1079
12
+ ubag-0.2.0.dist-info/METADATA,sha256=VcsJmmqile-VSitJ4N-go6lJvnnj3Rns-YLg2b8YXAw,3117
13
+ ubag-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
14
+ ubag-0.2.0.dist-info/top_level.txt,sha256=rmnswtqQiKrkm0JMFDK8aA8BT1C3L3Ofi6V5-hIlC0k,5
15
+ ubag-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mohamed Ben Hadj Hmida
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ ubag