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 +21 -0
- ubag-0.2.0/PKG-INFO +84 -0
- ubag-0.2.0/README.md +46 -0
- ubag-0.2.0/pyproject.toml +51 -0
- ubag-0.2.0/setup.cfg +4 -0
- ubag-0.2.0/src/ubag/__init__.py +47 -0
- ubag-0.2.0/src/ubag/_agents_json.py +60 -0
- ubag-0.2.0/src/ubag/_challenge.py +111 -0
- ubag-0.2.0/src/ubag/_credential.py +58 -0
- ubag-0.2.0/src/ubag/_keys.py +119 -0
- ubag-0.2.0/src/ubag/_routing.py +71 -0
- ubag-0.2.0/src/ubag/_sux.py +48 -0
- ubag-0.2.0/src/ubag/agent.py +90 -0
- ubag-0.2.0/src/ubag/middleware/__init__.py +0 -0
- ubag-0.2.0/src/ubag/middleware/fastapi.py +307 -0
- ubag-0.2.0/src/ubag.egg-info/PKG-INFO +84 -0
- ubag-0.2.0/src/ubag.egg-info/SOURCES.txt +22 -0
- ubag-0.2.0/src/ubag.egg-info/dependency_links.txt +1 -0
- ubag-0.2.0/src/ubag.egg-info/requires.txt +20 -0
- ubag-0.2.0/src/ubag.egg-info/top_level.txt +1 -0
- ubag-0.2.0/tests/test_challenge.py +80 -0
- ubag-0.2.0/tests/test_credential.py +73 -0
- ubag-0.2.0/tests/test_middleware_fastapi.py +116 -0
- ubag-0.2.0/tests/test_routing.py +69 -0
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,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
|