ubag 0.2.0__tar.gz

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-0.2.0/LICENSE ADDED
@@ -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.
ubag-0.2.0/PKG-INFO ADDED
@@ -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.
ubag-0.2.0/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # ubag (Python)
2
+
3
+ **UBAG Web Layer — agent identity and routing at the web edge.** FastAPI / Starlette middleware.
4
+
5
+ When an autonomous agent visits a website, UBAG verifies *who it is* and routes
6
+ accordingly: humans to your normal site, credentialed agents to clean JSON-LD,
7
+ unknown bots to a cryptographic challenge. Asymmetric by design — agent identity
8
+ is an Ed25519 keypair; credentials are ES256 JWTs verifiable via JWKS, no shared
9
+ secrets.
10
+
11
+ > Early but real, pre-1.0, unaudited. See the
12
+ > [full README](https://github.com/mohameduk/Ubag_protocol#readme) and
13
+ > [SECURITY.md](https://github.com/mohameduk/Ubag_protocol/blob/main/SECURITY.md).
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install "ubag[fastapi]"
19
+ ```
20
+
21
+ ## Quick start
22
+
23
+ ```python
24
+ from fastapi import FastAPI
25
+ from ubag import UBAGMiddleware, generate_issuer_keypair
26
+
27
+ issuer_private, _ = generate_issuer_keypair() # EC P-256 (ES256)
28
+
29
+ app = FastAPI()
30
+ app.add_middleware(
31
+ UBAGMiddleware,
32
+ origin="https://yoursite.com",
33
+ issuer_key=issuer_private, # mints + verifies credentials
34
+ site_meta={"name": "My Store", "type": "Store", "description": "We sell widgets"},
35
+ )
36
+ ```
37
+
38
+ Your site now serves clean JSON-LD to credentialed agents, proxies humans to your
39
+ origin, challenges unknown bots, exposes `/.well-known/ubag.json` for discovery,
40
+ and serves its issuer key as JWKS at `/.well-known/jwks.json`.
41
+
42
+ A Node/Express SDK with an identical, cross-verifiable wire format is in the same
43
+ repo. Full docs, a runnable demo, and the protocol details:
44
+ **https://github.com/mohameduk/Ubag_protocol**
45
+
46
+ MIT licensed.
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ubag"
7
+ version = "0.2.0"
8
+ description = "Agent identity and routing middleware for MCP-compatible agents"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Mohamed Ben Hadj Hmida" }]
13
+ keywords = ["mcp", "agents", "ai", "middleware", "routing", "bot-detection"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
22
+ "Topic :: Security",
23
+ ]
24
+ dependencies = [
25
+ "PyJWT[crypto]>=2.8.0",
26
+ "cryptography>=42.0",
27
+ "httpx>=0.27.0",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ fastapi = ["fastapi>=0.111.0", "starlette>=0.37.0"]
32
+ django = ["django>=4.2"]
33
+ flask = ["flask>=3.0"]
34
+ dev = [
35
+ "pytest>=8.0",
36
+ "pytest-asyncio>=0.23",
37
+ "httpx>=0.27.0",
38
+ "fastapi>=0.111.0",
39
+ "starlette>=0.37.0",
40
+ ]
41
+
42
+ [project.urls]
43
+ Homepage = "https://ubagprotocol.com"
44
+ Repository = "https://github.com/mohameduk/Ubag_protocol"
45
+
46
+ [tool.setuptools.packages.find]
47
+ where = ["src"]
48
+
49
+ [tool.pytest.ini_options]
50
+ asyncio_mode = "auto"
51
+ testpaths = ["tests"]
ubag-0.2.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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}")
@@ -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
@@ -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)
@@ -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
@@ -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
+ }
@@ -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