regent-httpsig 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- regent_httpsig/__init__.py +31 -0
- regent_httpsig/cli.py +55 -0
- regent_httpsig/config.py +32 -0
- regent_httpsig/fastapi.py +94 -0
- regent_httpsig/jwk.py +46 -0
- regent_httpsig/netguard.py +62 -0
- regent_httpsig/sfv.py +132 -0
- regent_httpsig/sign.py +121 -0
- regent_httpsig/verify.py +342 -0
- regent_httpsig-0.1.0.dist-info/METADATA +176 -0
- regent_httpsig-0.1.0.dist-info/RECORD +14 -0
- regent_httpsig-0.1.0.dist-info/WHEEL +4 -0
- regent_httpsig-0.1.0.dist-info/entry_points.txt +2 -0
- regent_httpsig-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""regent-httpsig — verify and sign AI agent HTTP traffic (RFC 9421).
|
|
2
|
+
|
|
3
|
+
Web Bot Auth (what OpenAI ships, what Cloudflare/AWS/Google verify) and AAuth
|
|
4
|
+
(draft-hardt), in plain Python. See https://github.com/regent-protocol/regent-httpsig
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from regent_httpsig.config import HttpsigConfig
|
|
8
|
+
from regent_httpsig.jwk import b64url, jwk_thumbprint, load_ed25519_jwk
|
|
9
|
+
from regent_httpsig.netguard import NotPublicURL, assert_public_url
|
|
10
|
+
from regent_httpsig.sfv import parse_signature_agent
|
|
11
|
+
from regent_httpsig.sign import DIRECTORY_MEDIA_TYPE, EgressSigner, generate_seed
|
|
12
|
+
from regent_httpsig.verify import WBA_TAG, HttpsigVerifier, VerifiedSignature
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"DIRECTORY_MEDIA_TYPE",
|
|
18
|
+
"EgressSigner",
|
|
19
|
+
"HttpsigConfig",
|
|
20
|
+
"HttpsigVerifier",
|
|
21
|
+
"NotPublicURL",
|
|
22
|
+
"VerifiedSignature",
|
|
23
|
+
"WBA_TAG",
|
|
24
|
+
"__version__",
|
|
25
|
+
"assert_public_url",
|
|
26
|
+
"b64url",
|
|
27
|
+
"generate_seed",
|
|
28
|
+
"jwk_thumbprint",
|
|
29
|
+
"load_ed25519_jwk",
|
|
30
|
+
"parse_signature_agent",
|
|
31
|
+
]
|
regent_httpsig/cli.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""``regent-httpsig keygen`` — generate an agent key + ready-to-publish directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from regent_httpsig.sign import EgressSigner, generate_seed
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: list[str] | None = None) -> int:
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="regent-httpsig",
|
|
16
|
+
description="Utilities for RFC 9421 agent signatures (Web Bot Auth / AAuth).",
|
|
17
|
+
)
|
|
18
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
19
|
+
keygen = sub.add_parser(
|
|
20
|
+
"keygen",
|
|
21
|
+
help="Generate an Ed25519 agent key and the well-known directory files.",
|
|
22
|
+
)
|
|
23
|
+
keygen.add_argument(
|
|
24
|
+
"--agent", default="https://myagent.example",
|
|
25
|
+
help="Your agent's origin (where you will host the key directory).",
|
|
26
|
+
)
|
|
27
|
+
keygen.add_argument(
|
|
28
|
+
"--out", type=Path, default=None,
|
|
29
|
+
help="Directory to write http-message-signatures-directory + jwks.json into.",
|
|
30
|
+
)
|
|
31
|
+
args = parser.parse_args(argv)
|
|
32
|
+
|
|
33
|
+
if args.command == "keygen":
|
|
34
|
+
seed = generate_seed()
|
|
35
|
+
signer = EgressSigner(seed=seed, signature_agent=args.agent)
|
|
36
|
+
directory = json.dumps(signer.directory(), indent=2)
|
|
37
|
+
print("# Keep this secret — it IS your agent's identity:", file=sys.stderr)
|
|
38
|
+
print(f"AGENT_KEY_SEED={seed}")
|
|
39
|
+
print(f"# keyid (RFC 7638 thumbprint): {signer.keyid}", file=sys.stderr)
|
|
40
|
+
if args.out:
|
|
41
|
+
args.out.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
(args.out / "http-message-signatures-directory").write_text(directory)
|
|
43
|
+
(args.out / "jwks.json").write_text(directory)
|
|
44
|
+
print(
|
|
45
|
+
f"# Wrote {args.out}/http-message-signatures-directory and jwks.json\n"
|
|
46
|
+
f"# Serve them at {args.agent}/.well-known/",
|
|
47
|
+
file=sys.stderr,
|
|
48
|
+
)
|
|
49
|
+
else:
|
|
50
|
+
print(directory, file=sys.stderr)
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__": # pragma: no cover
|
|
55
|
+
raise SystemExit(main())
|
regent_httpsig/config.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Verifier configuration — a plain frozen dataclass, no framework coupling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
__all__ = ["HttpsigConfig"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class HttpsigConfig:
|
|
12
|
+
"""Configuration for :class:`regent_httpsig.HttpsigVerifier`.
|
|
13
|
+
|
|
14
|
+
Defaults are production-safe: https-only directory fetching over public IPs,
|
|
15
|
+
bounded cache, 25-hour max signature age (Web Bot Auth signatures carry
|
|
16
|
+
``created``/``expires``; 25h tolerates a day of clock drift)."""
|
|
17
|
+
|
|
18
|
+
# Origins/issuers you additionally mark as trusted (``VerifiedSignature.trusted``).
|
|
19
|
+
# Verification itself never depends on this — it only annotates the result.
|
|
20
|
+
trusted_agents: frozenset[str] = field(default_factory=frozenset)
|
|
21
|
+
# Reject signatures created earlier than this many hours ago.
|
|
22
|
+
max_age_hours: int = 25
|
|
23
|
+
# Key-directory cache TTL (seconds); failures are cached for negative_cache_ttl.
|
|
24
|
+
cache_ttl: float = 600.0
|
|
25
|
+
negative_cache_ttl: float = 120.0
|
|
26
|
+
cache_max_entries: int = 256
|
|
27
|
+
# Directory fetching: timeout, response size cap, redirects are never followed.
|
|
28
|
+
fetch_timeout: float = 5.0
|
|
29
|
+
max_directory_bytes: int = 64 * 1024
|
|
30
|
+
# Hosts exempt from the https-only + public-IP SSRF guard (local dev only —
|
|
31
|
+
# e.g. frozenset({"localhost"})). Leave empty in production.
|
|
32
|
+
insecure_hosts: frozenset[str] = field(default_factory=frozenset)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""FastAPI integration — the 5-line path (requires the ``[fastapi]`` extra).
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from regent_httpsig import HttpsigVerifier
|
|
6
|
+
from regent_httpsig.fastapi import attach, SignatureDep, VerifiedSignature
|
|
7
|
+
|
|
8
|
+
app = FastAPI()
|
|
9
|
+
attach(app, HttpsigVerifier())
|
|
10
|
+
|
|
11
|
+
@app.post("/v1/orders")
|
|
12
|
+
async def create_order(sig: VerifiedSignature | None = SignatureDep):
|
|
13
|
+
if sig:
|
|
14
|
+
... # sig.agent == "https://chatgpt.com", sig.keyid, sig.trusted
|
|
15
|
+
|
|
16
|
+
``SignatureDep`` is enrichment: ``None`` when absent/invalid, never raises.
|
|
17
|
+
``RequiredSignatureDep`` is authentication: a coded 401 tells the agent exactly
|
|
18
|
+
how to sign.
|
|
19
|
+
|
|
20
|
+
Proxy note: the signer signed the PUBLIC url. Behind a reverse proxy this
|
|
21
|
+
dependency rebuilds it from ``X-Forwarded-Proto`` + ``Host`` — make sure your
|
|
22
|
+
proxy sets ``X-Forwarded-Proto`` (nginx: ``proxy_set_header X-Forwarded-Proto
|
|
23
|
+
$scheme;``), or verification will fail on the scheme mismatch.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from fastapi import Depends, FastAPI, HTTPException, Request
|
|
29
|
+
|
|
30
|
+
from regent_httpsig.verify import HttpsigVerifier, VerifiedSignature
|
|
31
|
+
|
|
32
|
+
__all__ = ["RequiredSignatureDep", "SignatureDep", "VerifiedSignature", "attach"]
|
|
33
|
+
|
|
34
|
+
_STATE_ATTR = "regent_httpsig_verifier"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def attach(app: FastAPI, verifier: HttpsigVerifier) -> None:
|
|
38
|
+
"""Register the verifier on the app; the dependencies below read it back."""
|
|
39
|
+
setattr(app.state, _STATE_ATTR, verifier)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _public_url(request: Request) -> str:
|
|
43
|
+
"""Rebuild the URL the signer signed: scheme from the proxy, authority from
|
|
44
|
+
Host — an ASGI server behind a proxy would otherwise see http://<container>."""
|
|
45
|
+
host = request.headers.get("host") or request.url.netloc
|
|
46
|
+
scheme = request.headers.get("x-forwarded-proto") or request.url.scheme
|
|
47
|
+
path = request.url.path
|
|
48
|
+
query = f"?{request.url.query}" if request.url.query else ""
|
|
49
|
+
return f"{scheme}://{host}{path}{query}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def get_signature(request: Request) -> VerifiedSignature | None:
|
|
53
|
+
"""Optional verification: zero-cost without a Signature header, never raises."""
|
|
54
|
+
verifier: HttpsigVerifier | None = getattr(request.app.state, _STATE_ATTR, None)
|
|
55
|
+
if verifier is None:
|
|
56
|
+
raise RuntimeError(
|
|
57
|
+
"regent-httpsig verifier not attached — call "
|
|
58
|
+
"regent_httpsig.fastapi.attach(app, HttpsigVerifier()) at startup"
|
|
59
|
+
)
|
|
60
|
+
if "signature" not in request.headers:
|
|
61
|
+
return None
|
|
62
|
+
cached = getattr(request.state, "regent_httpsig_result", "unset")
|
|
63
|
+
if cached != "unset":
|
|
64
|
+
return cached # type: ignore[return-value]
|
|
65
|
+
result = await verifier.verify(
|
|
66
|
+
request.method, _public_url(request), dict(request.headers)
|
|
67
|
+
)
|
|
68
|
+
request.state.regent_httpsig_result = result
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def require_signature(request: Request) -> VerifiedSignature:
|
|
73
|
+
"""Hard requirement: a fully verified signature, or a 401 that tells the
|
|
74
|
+
agent exactly how to sign."""
|
|
75
|
+
sig = await get_signature(request)
|
|
76
|
+
if sig is None:
|
|
77
|
+
raise HTTPException(
|
|
78
|
+
status_code=401,
|
|
79
|
+
detail={
|
|
80
|
+
"code": "SIGNATURE_REQUIRED",
|
|
81
|
+
"message": (
|
|
82
|
+
"Sign this request with RFC 9421 HTTP Message Signatures: either "
|
|
83
|
+
'Web Bot Auth (tag="web-bot-auth", Ed25519 key published at '
|
|
84
|
+
"{your-origin}/.well-known/http-message-signatures-directory, "
|
|
85
|
+
"Signature-Agent header naming that origin) or AAuth (agent_token "
|
|
86
|
+
"in the Signature-Key header, signature bound to its cnf.jwk)."
|
|
87
|
+
),
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
return sig
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
SignatureDep = Depends(get_signature)
|
|
94
|
+
RequiredSignatureDep = Depends(require_signature)
|
regent_httpsig/jwk.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""JWK helpers — RFC 7638 thumbprints (RFC 8037 A.3 for Ed25519) and key loading."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
11
|
+
|
|
12
|
+
__all__ = ["b64url", "b64url_decode", "jwk_thumbprint", "load_ed25519_jwk"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def b64url(data: bytes) -> str:
|
|
16
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def b64url_decode(data: str) -> bytes:
|
|
20
|
+
return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def jwk_thumbprint(jwk: dict[str, Any]) -> str:
|
|
24
|
+
"""RFC 7638 JWK thumbprint (RFC 8037 A.3 for OKP): sha256 over the canonical
|
|
25
|
+
JSON of the required members, base64url without padding.
|
|
26
|
+
|
|
27
|
+
This is the keyid form Web Bot Auth uses — e.g. the RFC test key's thumbprint
|
|
28
|
+
is ``poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U``."""
|
|
29
|
+
required_by_kty = {
|
|
30
|
+
"OKP": ("crv", "kty", "x"),
|
|
31
|
+
"EC": ("crv", "kty", "x", "y"),
|
|
32
|
+
"RSA": ("e", "kty", "n"),
|
|
33
|
+
}
|
|
34
|
+
members = required_by_kty.get(str(jwk.get("kty", "")))
|
|
35
|
+
if not members:
|
|
36
|
+
raise ValueError(f"unsupported kty {jwk.get('kty')!r}")
|
|
37
|
+
canonical = json.dumps(
|
|
38
|
+
{m: jwk[m] for m in sorted(members)}, separators=(",", ":"), sort_keys=True
|
|
39
|
+
)
|
|
40
|
+
return b64url(hashlib.sha256(canonical.encode()).digest())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_ed25519_jwk(jwk: dict[str, Any]) -> Ed25519PublicKey:
|
|
44
|
+
if jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519" or "x" not in jwk:
|
|
45
|
+
raise ValueError("only OKP/Ed25519 JWKs are supported")
|
|
46
|
+
return Ed25519PublicKey.from_public_bytes(b64url_decode(str(jwk["x"])))
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""SSRF guard for identity-document fetching.
|
|
2
|
+
|
|
3
|
+
The verifier fetches key directories from attacker-nameable origins (whoever
|
|
4
|
+
signs a request chooses its ``Signature-Agent`` / ``iss``). Without a guard,
|
|
5
|
+
a malicious signer could point the verifier at internal services
|
|
6
|
+
(``http://redis:6379``, the cloud metadata IP ``169.254.169.254``, loopback,
|
|
7
|
+
other containers) and use your API as a proxy into your private network.
|
|
8
|
+
|
|
9
|
+
Every resolved address must be public; the check resolves (not just parses),
|
|
10
|
+
which also catches DNS names that map to private IPs."""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import ipaddress
|
|
16
|
+
import socket
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
|
|
19
|
+
__all__ = ["NotPublicURL", "assert_public_url"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NotPublicURL(ValueError):
|
|
23
|
+
"""The URL does not resolve to an exclusively-public address."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _is_public(ip: str) -> bool:
|
|
27
|
+
try:
|
|
28
|
+
addr = ipaddress.ip_address(ip)
|
|
29
|
+
except ValueError:
|
|
30
|
+
return False
|
|
31
|
+
return not (
|
|
32
|
+
addr.is_private
|
|
33
|
+
or addr.is_loopback
|
|
34
|
+
or addr.is_link_local
|
|
35
|
+
or addr.is_reserved
|
|
36
|
+
or addr.is_multicast
|
|
37
|
+
or addr.is_unspecified
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def assert_public_url(url: str, allow_hosts: frozenset[str] = frozenset()) -> None:
|
|
42
|
+
"""Raise :class:`NotPublicURL` unless ``url`` is http(s) to an allow-listed
|
|
43
|
+
host or a host whose EVERY resolved address is public."""
|
|
44
|
+
parsed = urlparse(url)
|
|
45
|
+
if parsed.scheme not in ("http", "https"):
|
|
46
|
+
raise NotPublicURL(f"unsupported scheme {parsed.scheme!r}")
|
|
47
|
+
host = parsed.hostname or ""
|
|
48
|
+
if not host:
|
|
49
|
+
raise NotPublicURL("URL has no host")
|
|
50
|
+
if host in allow_hosts:
|
|
51
|
+
return
|
|
52
|
+
try:
|
|
53
|
+
infos = await asyncio.to_thread(
|
|
54
|
+
socket.getaddrinfo, host, parsed.port or None, 0, socket.SOCK_STREAM
|
|
55
|
+
)
|
|
56
|
+
except socket.gaierror as exc:
|
|
57
|
+
raise NotPublicURL(f"cannot resolve host {host!r}") from exc
|
|
58
|
+
ips = {str(info[4][0]) for info in infos}
|
|
59
|
+
if not ips or any(not _is_public(ip) for ip in ips):
|
|
60
|
+
raise NotPublicURL(
|
|
61
|
+
f"host {host!r} resolves to a private/loopback/link-local address"
|
|
62
|
+
)
|
regent_httpsig/sfv.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""RFC 9421 plumbing over the ``http-message-signatures`` library.
|
|
2
|
+
|
|
3
|
+
Contains the pieces the upstream library is missing for the AI-agent profiles:
|
|
4
|
+
|
|
5
|
+
- :class:`DictKeyComponentResolver` — RFC 9421 §2.1.2 ``;key=`` dictionary-member
|
|
6
|
+
selection (needed for the current Web Bot Auth ``"signature-agent";key="agent2"``
|
|
7
|
+
covered component; upstream resolves whole header values only).
|
|
8
|
+
- :func:`parse_signature_agent` — both wire forms of ``Signature-Agent``:
|
|
9
|
+
the draft -05 sf-dictionary AND the legacy bare sf-string OpenAI ships today.
|
|
10
|
+
- :class:`Message` / :class:`StaticKeyResolver` — the minimal request shape and
|
|
11
|
+
key resolution the verifier needs.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
19
|
+
from http_message_signatures import ( # type: ignore[attr-defined]
|
|
20
|
+
HTTPSignatureComponentResolver,
|
|
21
|
+
HTTPSignatureKeyResolver,
|
|
22
|
+
algorithms,
|
|
23
|
+
http_sfv,
|
|
24
|
+
)
|
|
25
|
+
from http_message_signatures.structures import CaseInsensitiveDict
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"ED25519",
|
|
29
|
+
"CaseInsensitiveDict",
|
|
30
|
+
"DictKeyComponentResolver",
|
|
31
|
+
"Message",
|
|
32
|
+
"SFDictionary",
|
|
33
|
+
"SFItem",
|
|
34
|
+
"StaticKeyResolver",
|
|
35
|
+
"parse_signature_agent",
|
|
36
|
+
"parse_signature_key_header",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# The library ships no explicit re-exports (strict mypy: attr-defined) — alias once.
|
|
40
|
+
SFDictionary = http_sfv.Dictionary # type: ignore[attr-defined]
|
|
41
|
+
SFItem = http_sfv.Item # type: ignore[attr-defined]
|
|
42
|
+
ED25519 = algorithms.ED25519 # type: ignore[attr-defined]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Message:
|
|
46
|
+
"""The minimal request shape http-message-signatures needs (.method/.url/.headers).
|
|
47
|
+
|
|
48
|
+
``headers`` is wrapped in the library's own case-insensitive mapping — ASGI
|
|
49
|
+
frameworks lowercase header names, and the upstream verifier looks up
|
|
50
|
+
``Signature-Input`` case-sensitively."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, method: str, url: str, headers: dict[str, str]):
|
|
53
|
+
self.method = method
|
|
54
|
+
self.url = url
|
|
55
|
+
self.headers = CaseInsensitiveDict(headers) # type: ignore[no-untyped-call]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class DictKeyComponentResolver(HTTPSignatureComponentResolver):
|
|
59
|
+
"""Adds RFC 9421 §2.1.2 ``;key=`` dictionary-member selection for header
|
|
60
|
+
components (the upstream resolver returns the whole header value only).
|
|
61
|
+
Needed for the current Web Bot Auth form: ``"signature-agent";key="agent2"``
|
|
62
|
+
must resolve to the serialized member value (e.g. ``"https://…"``)."""
|
|
63
|
+
|
|
64
|
+
def resolve(self, component_node: Any) -> Any: # http_sfv Item (untyped lib)
|
|
65
|
+
component_id = str(component_node.value)
|
|
66
|
+
key = component_node.params.get("key")
|
|
67
|
+
if key is not None and not component_id.startswith("@"):
|
|
68
|
+
if component_id not in self.headers:
|
|
69
|
+
raise ValueError(f'covered header "{component_id}" not in message')
|
|
70
|
+
node = SFDictionary()
|
|
71
|
+
node.parse(self.headers[component_id].encode())
|
|
72
|
+
if key not in node:
|
|
73
|
+
raise ValueError(f'member "{key}" not in dictionary header "{component_id}"')
|
|
74
|
+
return str(node[key])
|
|
75
|
+
return super().resolve(component_node)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class StaticKeyResolver(HTTPSignatureKeyResolver):
|
|
79
|
+
"""Resolves keyids from a prefetched map; ``default`` (AAuth cnf.jwk) wins
|
|
80
|
+
when the map has no entry — the possession key comes from the token, not
|
|
81
|
+
from the wire keyid."""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
keys: dict[str, Ed25519PublicKey],
|
|
86
|
+
default: Ed25519PublicKey | None = None,
|
|
87
|
+
):
|
|
88
|
+
self._keys = keys
|
|
89
|
+
self._default = default
|
|
90
|
+
|
|
91
|
+
def resolve_public_key(self, key_id: str) -> Ed25519PublicKey:
|
|
92
|
+
key = self._keys.get(key_id, self._default)
|
|
93
|
+
if key is None:
|
|
94
|
+
raise ValueError(f"unknown keyid {key_id!r}")
|
|
95
|
+
return key
|
|
96
|
+
|
|
97
|
+
def resolve_private_key(self, key_id: str) -> Any:
|
|
98
|
+
raise NotImplementedError
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_signature_agent(value: str) -> str | None:
|
|
102
|
+
"""Extract the directory origin from ``Signature-Agent`` — sf-dictionary
|
|
103
|
+
(draft -05) or bare sf-string (legacy, what OpenAI sends)."""
|
|
104
|
+
value = value.strip()
|
|
105
|
+
if not value:
|
|
106
|
+
return None
|
|
107
|
+
try:
|
|
108
|
+
if value.startswith('"'):
|
|
109
|
+
item = SFItem()
|
|
110
|
+
item.parse(value.encode())
|
|
111
|
+
return str(item.value)
|
|
112
|
+
node = SFDictionary()
|
|
113
|
+
node.parse(value.encode())
|
|
114
|
+
for member in node.values():
|
|
115
|
+
return str(member.value)
|
|
116
|
+
except Exception: # noqa: BLE001 — malformed header = no directory
|
|
117
|
+
return None
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def parse_signature_key_header(value: str) -> tuple[str, str] | None:
|
|
122
|
+
"""``Signature-Key: sig=jwt;jwt="eyJ…"`` → (label, jwt) — the AAuth carrier."""
|
|
123
|
+
try:
|
|
124
|
+
node = SFDictionary()
|
|
125
|
+
node.parse(value.encode())
|
|
126
|
+
for label, member in node.items():
|
|
127
|
+
jwt_param = member.params.get("jwt")
|
|
128
|
+
if jwt_param:
|
|
129
|
+
return str(label), str(jwt_param)
|
|
130
|
+
except Exception: # noqa: BLE001
|
|
131
|
+
return None
|
|
132
|
+
return None
|
regent_httpsig/sign.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Outbound RFC 9421 signing (Web Bot Auth) — give your agent a verifiable identity.
|
|
2
|
+
|
|
3
|
+
Sign every request your agent makes with an Ed25519 key whose public half you
|
|
4
|
+
publish at ``{signature_agent}/.well-known/http-message-signatures-directory``.
|
|
5
|
+
Verifiers that speak Web Bot Auth (Cloudflare, AWS WAF, Vercel, this library…)
|
|
6
|
+
then see a *signed agent* instead of an anonymous bot.
|
|
7
|
+
|
|
8
|
+
The ``Signature-Agent`` header is emitted in the LEGACY sf-string form
|
|
9
|
+
(``"https://…"``) — the form OpenAI ships in production, accepted by every
|
|
10
|
+
deployed verifier today; the draft -05 sf-dictionary form still has patchy
|
|
11
|
+
support.
|
|
12
|
+
|
|
13
|
+
Unlike a service-level integration, this library FAILS LOUD: a bad seed raises
|
|
14
|
+
at construction and a signing error raises from :meth:`EgressSigner.sign`.
|
|
15
|
+
Wrap in try/except yourself if your egress path must never break on signing.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import secrets
|
|
21
|
+
from datetime import datetime, timedelta
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
25
|
+
from http_message_signatures import ( # type: ignore[attr-defined]
|
|
26
|
+
HTTPMessageSigner,
|
|
27
|
+
HTTPSignatureKeyResolver,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from regent_httpsig.jwk import b64url, b64url_decode, jwk_thumbprint
|
|
31
|
+
from regent_httpsig.sfv import ED25519, Message
|
|
32
|
+
|
|
33
|
+
__all__ = ["DIRECTORY_MEDIA_TYPE", "EgressSigner", "generate_seed"]
|
|
34
|
+
|
|
35
|
+
DIRECTORY_MEDIA_TYPE = "application/http-message-signatures-directory+json"
|
|
36
|
+
_DEFAULT_COVERED = ("@method", "@authority", "@path", "signature-agent")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def generate_seed() -> str:
|
|
40
|
+
"""A fresh Ed25519 seed, base64url-encoded — store it like any secret."""
|
|
41
|
+
return b64url(secrets.token_bytes(32))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _Resolver(HTTPSignatureKeyResolver):
|
|
45
|
+
def __init__(self, key: Ed25519PrivateKey):
|
|
46
|
+
self._key = key
|
|
47
|
+
|
|
48
|
+
def resolve_private_key(self, key_id: str) -> Ed25519PrivateKey:
|
|
49
|
+
return self._key
|
|
50
|
+
|
|
51
|
+
def resolve_public_key(self, key_id: str) -> Any:
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class EgressSigner:
|
|
56
|
+
"""Sign outbound requests as a Web Bot Auth agent.
|
|
57
|
+
|
|
58
|
+
Usage::
|
|
59
|
+
|
|
60
|
+
signer = EgressSigner(seed=os.environ["AGENT_KEY_SEED"],
|
|
61
|
+
signature_agent="https://myagent.example")
|
|
62
|
+
headers = signer.sign("POST", url, {"content-type": "application/json"})
|
|
63
|
+
httpx.post(url, json=body, headers=headers)
|
|
64
|
+
|
|
65
|
+
Publish ``signer.directory()`` as JSON at
|
|
66
|
+
``https://myagent.example/.well-known/http-message-signatures-directory``
|
|
67
|
+
(or run ``regent-httpsig keygen`` to generate both the seed and the files).
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, *, seed: str, signature_agent: str, ttl_minutes: int = 5):
|
|
71
|
+
raw = b64url_decode(seed)
|
|
72
|
+
if len(raw) != 32:
|
|
73
|
+
raise ValueError("seed must be 32 bytes (base64url-encoded)")
|
|
74
|
+
self._key = Ed25519PrivateKey.from_private_bytes(raw)
|
|
75
|
+
self.signature_agent = signature_agent
|
|
76
|
+
self._ttl = ttl_minutes
|
|
77
|
+
self._jwk = {
|
|
78
|
+
"kty": "OKP",
|
|
79
|
+
"crv": "Ed25519",
|
|
80
|
+
"x": b64url(self._key.public_key().public_bytes_raw()),
|
|
81
|
+
}
|
|
82
|
+
self.keyid = jwk_thumbprint(self._jwk)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def public_jwk(self) -> dict[str, Any]:
|
|
86
|
+
return dict(self._jwk)
|
|
87
|
+
|
|
88
|
+
def directory(self) -> dict[str, Any]:
|
|
89
|
+
"""The JWKS document to serve at
|
|
90
|
+
``/.well-known/http-message-signatures-directory``."""
|
|
91
|
+
return {
|
|
92
|
+
"keys": [{**self._jwk, "kid": self.keyid, "use": "sig", "alg": "EdDSA"}]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
def sign(
|
|
96
|
+
self,
|
|
97
|
+
method: str,
|
|
98
|
+
url: str,
|
|
99
|
+
headers: dict[str, str] | None = None,
|
|
100
|
+
*,
|
|
101
|
+
covered: tuple[str, ...] = _DEFAULT_COVERED,
|
|
102
|
+
label: str = "sig1",
|
|
103
|
+
) -> dict[str, str]:
|
|
104
|
+
"""Return ``headers`` + ``Signature-Agent``/``Signature-Input``/``Signature``."""
|
|
105
|
+
out = dict(headers or {})
|
|
106
|
+
out["Signature-Agent"] = f'"{self.signature_agent}"' # legacy sf-string form
|
|
107
|
+
message = Message(method.upper(), url, out)
|
|
108
|
+
signer = HTTPMessageSigner(
|
|
109
|
+
signature_algorithm=ED25519, key_resolver=_Resolver(self._key)
|
|
110
|
+
)
|
|
111
|
+
now = datetime.now()
|
|
112
|
+
signer.sign(
|
|
113
|
+
message,
|
|
114
|
+
key_id=self.keyid,
|
|
115
|
+
label=label,
|
|
116
|
+
tag="web-bot-auth",
|
|
117
|
+
created=now,
|
|
118
|
+
expires=now + timedelta(minutes=self._ttl),
|
|
119
|
+
covered_component_ids=covered,
|
|
120
|
+
)
|
|
121
|
+
return dict(message.headers)
|
regent_httpsig/verify.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Inbound agent-identity verification — RFC 9421 HTTP Message Signatures.
|
|
2
|
+
|
|
3
|
+
Two schemes are accepted side by side (both ride the same ``Signature`` /
|
|
4
|
+
``Signature-Input`` headers; the difference is where the public key comes from):
|
|
5
|
+
|
|
6
|
+
- **Web Bot Auth** (draft-meunier-web-bot-auth-architecture): the agent's
|
|
7
|
+
operator publishes an Ed25519 JWKS at
|
|
8
|
+
``{Signature-Agent}/.well-known/http-message-signatures-directory`` and signs
|
|
9
|
+
every request with ``tag="web-bot-auth"``. OpenAI's agents sign all their
|
|
10
|
+
traffic this way today. Both wire forms of ``Signature-Agent`` are accepted:
|
|
11
|
+
the draft -05 sf-dictionary (covered with ``;key=``) and the legacy bare
|
|
12
|
+
sf-string OpenAI ships.
|
|
13
|
+
- **AAuth** (draft-hardt-oauth-aauth-protocol, identity-based mode): the agent
|
|
14
|
+
carries a JWT ``agent_token`` (``typ: aa-agent+jwt``) in the ``Signature-Key``
|
|
15
|
+
header; the token's issuer JWKS (``{iss}/.well-known/aauth-agent.json`` →
|
|
16
|
+
``jwks_uri``) verifies the token, and the token's ``cnf.jwk`` verifies the
|
|
17
|
+
request signature (proof of possession). Requires the ``[aauth]`` extra.
|
|
18
|
+
|
|
19
|
+
Directory fetches are SSRF-guarded (https-only, public-IP-only, size-capped,
|
|
20
|
+
no redirects) and cached per verifier instance. A bad or missing signature
|
|
21
|
+
yields ``None`` — verification failure is a result, not an exception.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import logging
|
|
28
|
+
import time
|
|
29
|
+
from collections.abc import Mapping
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from datetime import timedelta
|
|
32
|
+
from typing import Any
|
|
33
|
+
from urllib.parse import urlsplit
|
|
34
|
+
|
|
35
|
+
import httpx
|
|
36
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
37
|
+
from http_message_signatures import HTTPMessageVerifier # type: ignore[attr-defined]
|
|
38
|
+
|
|
39
|
+
from regent_httpsig.config import HttpsigConfig
|
|
40
|
+
from regent_httpsig.jwk import jwk_thumbprint, load_ed25519_jwk
|
|
41
|
+
from regent_httpsig.netguard import NotPublicURL, assert_public_url
|
|
42
|
+
from regent_httpsig.sfv import (
|
|
43
|
+
ED25519,
|
|
44
|
+
DictKeyComponentResolver,
|
|
45
|
+
Message,
|
|
46
|
+
StaticKeyResolver,
|
|
47
|
+
parse_signature_agent,
|
|
48
|
+
parse_signature_key_header,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
__all__ = ["HttpsigVerifier", "VerifiedSignature", "WBA_TAG"]
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger("regent_httpsig")
|
|
54
|
+
|
|
55
|
+
WBA_TAG = "web-bot-auth"
|
|
56
|
+
WBA_DIRECTORY_PATH = "/.well-known/http-message-signatures-directory"
|
|
57
|
+
AAUTH_METADATA_PATH = "/.well-known/aauth-agent.json"
|
|
58
|
+
AAUTH_JWT_TYP = "aa-agent+jwt"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class VerifiedSignature:
|
|
63
|
+
"""A successfully verified inbound agent signature."""
|
|
64
|
+
|
|
65
|
+
scheme: str # "web-bot-auth" | "aauth"
|
|
66
|
+
agent: str # WBA: Signature-Agent origin; AAuth: the token issuer
|
|
67
|
+
keyid: str # RFC 7638 / RFC 8037 A.3 JWK thumbprint
|
|
68
|
+
trusted: bool # agent/issuer is on the configured trust list
|
|
69
|
+
sub: str | None = None # AAuth agent id (token `sub`)
|
|
70
|
+
label: str = ""
|
|
71
|
+
claims: dict[str, Any] = field(default_factory=dict) # AAuth token claims (redacted)
|
|
72
|
+
|
|
73
|
+
def context(self) -> dict[str, Any]:
|
|
74
|
+
"""A flat dict suitable for logging / policy engines / audit trails."""
|
|
75
|
+
out: dict[str, Any] = {
|
|
76
|
+
"signed_agent": True,
|
|
77
|
+
"signature_scheme": self.scheme,
|
|
78
|
+
"signature_agent": self.agent,
|
|
79
|
+
"signature_keyid": self.keyid,
|
|
80
|
+
"signature_trusted": self.trusted,
|
|
81
|
+
}
|
|
82
|
+
if self.sub:
|
|
83
|
+
out["signature_sub"] = self.sub
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _keys_from_jwks(doc: dict[str, Any]) -> dict[str, Ed25519PublicKey]:
|
|
88
|
+
keys: dict[str, Ed25519PublicKey] = {}
|
|
89
|
+
for jwk in list(doc.get("keys") or [])[:10]:
|
|
90
|
+
try:
|
|
91
|
+
keys[jwk_thumbprint(jwk)] = load_ed25519_jwk(jwk)
|
|
92
|
+
except Exception: # noqa: BLE001 — skip non-Ed25519 / malformed keys
|
|
93
|
+
continue
|
|
94
|
+
return keys
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class HttpsigVerifier:
|
|
98
|
+
"""Verify RFC 9421-signed agent requests (Web Bot Auth + AAuth).
|
|
99
|
+
|
|
100
|
+
Instances are cheap and hold their own directory cache; create one per
|
|
101
|
+
application and reuse it. ``http_client`` is optional — pass your app's
|
|
102
|
+
shared :class:`httpx.AsyncClient` to reuse its pool.
|
|
103
|
+
|
|
104
|
+
Usage::
|
|
105
|
+
|
|
106
|
+
verifier = HttpsigVerifier()
|
|
107
|
+
sig = await verifier.verify("POST", "https://api.example/v1/orders", headers)
|
|
108
|
+
if sig:
|
|
109
|
+
print(sig.agent) # e.g. "https://chatgpt.com"
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
config: HttpsigConfig | None = None,
|
|
115
|
+
*,
|
|
116
|
+
http_client: httpx.AsyncClient | None = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
self.config = config or HttpsigConfig()
|
|
119
|
+
self._http = http_client
|
|
120
|
+
self._owns_client = http_client is None
|
|
121
|
+
# url -> (expires_monotonic, parsed JSON | None for negative entries)
|
|
122
|
+
self._cache: dict[str, tuple[float, dict[str, Any] | None]] = {}
|
|
123
|
+
|
|
124
|
+
async def verify(
|
|
125
|
+
self, method: str, url: str, headers: Mapping[str, str]
|
|
126
|
+
) -> VerifiedSignature | None:
|
|
127
|
+
"""Verify the request's agent signature. Returns ``None`` when there is
|
|
128
|
+
no ``Signature`` header, the signature is invalid, or the signer's keys
|
|
129
|
+
cannot be (safely) fetched — never raises on untrusted input."""
|
|
130
|
+
hdrs = {str(k): str(v) for k, v in headers.items()}
|
|
131
|
+
if not any(k.lower() == "signature" for k in hdrs):
|
|
132
|
+
return None
|
|
133
|
+
result: VerifiedSignature | None = None
|
|
134
|
+
try:
|
|
135
|
+
if any(k.lower() == "signature-key" for k in hdrs):
|
|
136
|
+
result = await self._verify_aauth(method, url, hdrs)
|
|
137
|
+
if result is None:
|
|
138
|
+
result = await self._verify_web_bot_auth(method, url, hdrs)
|
|
139
|
+
except Exception as exc: # noqa: BLE001 — belt and braces
|
|
140
|
+
logger.warning("httpsig verify error: %s", str(exc)[:200])
|
|
141
|
+
return None
|
|
142
|
+
if result is not None:
|
|
143
|
+
logger.info(
|
|
144
|
+
"httpsig verified scheme=%s agent=%s keyid=%s trusted=%s",
|
|
145
|
+
result.scheme, result.agent, result.keyid[:16], result.trusted,
|
|
146
|
+
)
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
async def aclose(self) -> None:
|
|
150
|
+
if self._owns_client and self._http is not None:
|
|
151
|
+
await self._http.aclose()
|
|
152
|
+
self._http = None
|
|
153
|
+
|
|
154
|
+
# ── directory fetching (SSRF-guarded, cached) ────────────────────────────
|
|
155
|
+
|
|
156
|
+
def _cache_get(self, url: str) -> tuple[bool, dict[str, Any] | None]:
|
|
157
|
+
entry = self._cache.get(url)
|
|
158
|
+
if entry and entry[0] > time.monotonic():
|
|
159
|
+
return True, entry[1]
|
|
160
|
+
return False, None
|
|
161
|
+
|
|
162
|
+
def _cache_put(self, url: str, doc: dict[str, Any] | None, ttl: float) -> None:
|
|
163
|
+
if len(self._cache) >= self.config.cache_max_entries:
|
|
164
|
+
# Evict the soonest-to-expire entry; bounds memory against keyid spam.
|
|
165
|
+
self._cache.pop(min(self._cache, key=lambda k: self._cache[k][0]), None)
|
|
166
|
+
self._cache[url] = (time.monotonic() + ttl, doc)
|
|
167
|
+
|
|
168
|
+
async def _fetch_json(self, url: str) -> dict[str, Any] | None:
|
|
169
|
+
"""Fetch an attacker-nameable identity document safely: https-only
|
|
170
|
+
(except allow-listed dev hosts), public-IP-only, size-capped, cached."""
|
|
171
|
+
cfg = self.config
|
|
172
|
+
hit, doc = self._cache_get(url)
|
|
173
|
+
if hit:
|
|
174
|
+
return doc
|
|
175
|
+
try:
|
|
176
|
+
parsed = urlsplit(url)
|
|
177
|
+
if parsed.scheme != "https" and parsed.hostname not in cfg.insecure_hosts:
|
|
178
|
+
raise NotPublicURL("identity directories must be https")
|
|
179
|
+
await assert_public_url(url, cfg.insecure_hosts)
|
|
180
|
+
if self._http is None:
|
|
181
|
+
self._http = httpx.AsyncClient()
|
|
182
|
+
resp = await self._http.get(
|
|
183
|
+
url,
|
|
184
|
+
timeout=cfg.fetch_timeout,
|
|
185
|
+
follow_redirects=False,
|
|
186
|
+
headers={"accept": "application/json"},
|
|
187
|
+
)
|
|
188
|
+
resp.raise_for_status()
|
|
189
|
+
if len(resp.content) > cfg.max_directory_bytes:
|
|
190
|
+
raise ValueError("directory too large")
|
|
191
|
+
doc = resp.json()
|
|
192
|
+
if not isinstance(doc, dict):
|
|
193
|
+
raise ValueError("directory is not a JSON object")
|
|
194
|
+
except Exception as exc: # noqa: BLE001 — any failure = unverifiable, not fatal
|
|
195
|
+
logger.info("directory fetch failed url=%s: %s", url, str(exc)[:200])
|
|
196
|
+
self._cache_put(url, None, cfg.negative_cache_ttl)
|
|
197
|
+
return None
|
|
198
|
+
self._cache_put(url, doc, cfg.cache_ttl)
|
|
199
|
+
return doc
|
|
200
|
+
|
|
201
|
+
# ── Web Bot Auth ─────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
async def _verify_web_bot_auth(
|
|
204
|
+
self, method: str, url: str, headers: dict[str, str]
|
|
205
|
+
) -> VerifiedSignature | None:
|
|
206
|
+
message = Message(method, url, headers)
|
|
207
|
+
agent_header = message.headers.get("signature-agent")
|
|
208
|
+
if not agent_header:
|
|
209
|
+
return None # no directory to verify against — can't establish identity
|
|
210
|
+
origin = parse_signature_agent(agent_header)
|
|
211
|
+
if not origin or not origin.startswith(("https://", "http://")):
|
|
212
|
+
return None
|
|
213
|
+
directory = await self._fetch_json(origin.rstrip("/") + WBA_DIRECTORY_PATH)
|
|
214
|
+
if not directory:
|
|
215
|
+
return None
|
|
216
|
+
keys = _keys_from_jwks(directory)
|
|
217
|
+
if not keys:
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
verifier = HTTPMessageVerifier(
|
|
221
|
+
signature_algorithm=ED25519,
|
|
222
|
+
key_resolver=StaticKeyResolver(keys),
|
|
223
|
+
component_resolver_class=DictKeyComponentResolver,
|
|
224
|
+
)
|
|
225
|
+
try:
|
|
226
|
+
results = await asyncio.to_thread(
|
|
227
|
+
verifier.verify,
|
|
228
|
+
message,
|
|
229
|
+
max_age=timedelta(hours=self.config.max_age_hours),
|
|
230
|
+
expect_tag=WBA_TAG,
|
|
231
|
+
)
|
|
232
|
+
except Exception as exc: # noqa: BLE001 — invalid signature = unverified
|
|
233
|
+
logger.info("web-bot-auth invalid agent=%s: %s", origin, str(exc)[:200])
|
|
234
|
+
return None
|
|
235
|
+
if not results:
|
|
236
|
+
return None
|
|
237
|
+
res = results[0]
|
|
238
|
+
return VerifiedSignature(
|
|
239
|
+
scheme=WBA_TAG,
|
|
240
|
+
agent=origin,
|
|
241
|
+
keyid=str(res.parameters.get("keyid", "")),
|
|
242
|
+
trusted=origin in self.config.trusted_agents,
|
|
243
|
+
label=str(res.label),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# ── AAuth (identity-based mode) ──────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
async def _verify_aauth(
|
|
249
|
+
self, method: str, url: str, headers: dict[str, str]
|
|
250
|
+
) -> VerifiedSignature | None:
|
|
251
|
+
try:
|
|
252
|
+
import jwt as pyjwt # the [aauth] extra
|
|
253
|
+
except ImportError:
|
|
254
|
+
logger.info("Signature-Key present but pyjwt is not installed "
|
|
255
|
+
"(pip install 'regent-httpsig[aauth]')")
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
message = Message(method, url, headers)
|
|
259
|
+
parsed = parse_signature_key_header(message.headers.get("signature-key", ""))
|
|
260
|
+
if not parsed:
|
|
261
|
+
return None
|
|
262
|
+
label, token = parsed
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
header = pyjwt.get_unverified_header(token)
|
|
266
|
+
unverified = pyjwt.decode(token, options={"verify_signature": False})
|
|
267
|
+
except Exception: # noqa: BLE001
|
|
268
|
+
return None
|
|
269
|
+
if header.get("typ") != AAUTH_JWT_TYP or header.get("alg") in (None, "none"):
|
|
270
|
+
return None
|
|
271
|
+
iss = str(unverified.get("iss", ""))
|
|
272
|
+
bad_iss = unverified.get("dwk") != "aauth-agent.json" or not iss.startswith("https://")
|
|
273
|
+
if bad_iss and not (
|
|
274
|
+
iss and urlsplit(iss).hostname in self.config.insecure_hosts # dev escape
|
|
275
|
+
):
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
# 1) Verify the agent_token against the issuer's published JWKS.
|
|
279
|
+
metadata = await self._fetch_json(iss.rstrip("/") + AAUTH_METADATA_PATH)
|
|
280
|
+
if not metadata or not metadata.get("jwks_uri"):
|
|
281
|
+
return None
|
|
282
|
+
jwks = await self._fetch_json(str(metadata["jwks_uri"]))
|
|
283
|
+
if not jwks:
|
|
284
|
+
return None
|
|
285
|
+
issuer_key = None
|
|
286
|
+
for k in list(jwks.get("keys") or [])[:10]:
|
|
287
|
+
if k.get("kid") == header.get("kid") or len(jwks.get("keys") or []) == 1:
|
|
288
|
+
try:
|
|
289
|
+
issuer_key = pyjwt.PyJWK(k).key
|
|
290
|
+
break
|
|
291
|
+
except Exception: # noqa: BLE001
|
|
292
|
+
continue
|
|
293
|
+
if issuer_key is None:
|
|
294
|
+
return None
|
|
295
|
+
try:
|
|
296
|
+
claims = pyjwt.decode(
|
|
297
|
+
token,
|
|
298
|
+
key=issuer_key,
|
|
299
|
+
algorithms=["EdDSA", "ES256", "RS256"],
|
|
300
|
+
options={"require": ["iss", "sub", "exp", "iat"]},
|
|
301
|
+
)
|
|
302
|
+
except Exception as exc: # noqa: BLE001
|
|
303
|
+
logger.info("aauth token invalid iss=%s: %s", iss, str(exc)[:200])
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
# 2) Proof of possession: the request signature must verify against cnf.jwk.
|
|
307
|
+
cnf_jwk = (claims.get("cnf") or {}).get("jwk")
|
|
308
|
+
if not isinstance(cnf_jwk, dict):
|
|
309
|
+
return None
|
|
310
|
+
try:
|
|
311
|
+
pop_key = load_ed25519_jwk(cnf_jwk)
|
|
312
|
+
except ValueError:
|
|
313
|
+
return None
|
|
314
|
+
verifier = HTTPMessageVerifier(
|
|
315
|
+
signature_algorithm=ED25519,
|
|
316
|
+
key_resolver=StaticKeyResolver({}, default=pop_key),
|
|
317
|
+
component_resolver_class=DictKeyComponentResolver,
|
|
318
|
+
)
|
|
319
|
+
try:
|
|
320
|
+
# No expect_label: upstream requires expect_tag alongside it, and the
|
|
321
|
+
# AAuth drafts' tag is still moving — we verify all signatures against
|
|
322
|
+
# the possession key and match the Signature-Key label ourselves.
|
|
323
|
+
results = await asyncio.to_thread(
|
|
324
|
+
verifier.verify,
|
|
325
|
+
message,
|
|
326
|
+
max_age=timedelta(hours=self.config.max_age_hours),
|
|
327
|
+
)
|
|
328
|
+
except Exception as exc: # noqa: BLE001
|
|
329
|
+
logger.info("aauth PoP invalid iss=%s: %s", iss, str(exc)[:200])
|
|
330
|
+
return None
|
|
331
|
+
if not any(str(r.label) == label for r in results):
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
return VerifiedSignature(
|
|
335
|
+
scheme="aauth",
|
|
336
|
+
agent=iss,
|
|
337
|
+
keyid=jwk_thumbprint(cnf_jwk),
|
|
338
|
+
trusted=iss in self.config.trusted_agents,
|
|
339
|
+
sub=str(claims.get("sub", "")),
|
|
340
|
+
label=label,
|
|
341
|
+
claims={k: claims[k] for k in ("iss", "sub", "exp", "ps") if k in claims},
|
|
342
|
+
)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: regent-httpsig
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Verify and sign AI agent HTTP traffic in Python — RFC 9421 HTTP Message Signatures: Web Bot Auth (what OpenAI ships) and AAuth.
|
|
5
|
+
Project-URL: Homepage, https://github.com/regent-protocol/regent-httpsig
|
|
6
|
+
Project-URL: Repository, https://github.com/regent-protocol/regent-httpsig
|
|
7
|
+
Project-URL: Issues, https://github.com/regent-protocol/regent-httpsig/issues
|
|
8
|
+
Author-email: Regent Protocol <info@regentprotocol.org>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: aauth,agent-identity,ai-agents,bot-detection,ed25519,http-message-signatures,rfc9421,web-bot-auth
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
18
|
+
Classifier: Topic :: Security :: Cryptography
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: cryptography>=42
|
|
22
|
+
Requires-Dist: http-message-signatures>=2.0.1
|
|
23
|
+
Requires-Dist: httpx>=0.25
|
|
24
|
+
Requires-Dist: typing-extensions>=4.10
|
|
25
|
+
Provides-Extra: aauth
|
|
26
|
+
Requires-Dist: pyjwt>=2.8.0; extra == 'aauth'
|
|
27
|
+
Provides-Extra: fastapi
|
|
28
|
+
Requires-Dist: fastapi>=0.100; extra == 'fastapi'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# regent-httpsig
|
|
32
|
+
|
|
33
|
+
**Verify and sign AI agent HTTP traffic in Python — the way OpenAI signs and Cloudflare verifies.**
|
|
34
|
+
RFC 9421 · Web Bot Auth · AAuth
|
|
35
|
+
|
|
36
|
+
OpenAI's agents cryptographically sign every HTTP request they make. Cloudflare, AWS WAF and
|
|
37
|
+
Google verify those signatures. This library brings both sides of that handshake to Python:
|
|
38
|
+
**verify** signed agents hitting your API, and **sign** your own agent's traffic so bot walls
|
|
39
|
+
recognize it.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install regent-httpsig
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Verify: know which AI agent is calling — in 5 lines
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from fastapi import FastAPI
|
|
49
|
+
from regent_httpsig import HttpsigVerifier
|
|
50
|
+
from regent_httpsig.fastapi import attach, SignatureDep, VerifiedSignature
|
|
51
|
+
|
|
52
|
+
app = FastAPI()
|
|
53
|
+
attach(app, HttpsigVerifier())
|
|
54
|
+
|
|
55
|
+
@app.post("/v1/orders")
|
|
56
|
+
async def create_order(sig: VerifiedSignature | None = SignatureDep):
|
|
57
|
+
if sig:
|
|
58
|
+
print(sig.agent) # "https://chatgpt.com"
|
|
59
|
+
print(sig.keyid) # RFC 7638 key thumbprint
|
|
60
|
+
...
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
No FastAPI? The core has no framework dependencies:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
verifier = HttpsigVerifier()
|
|
67
|
+
sig = await verifier.verify(method, url, headers) # VerifiedSignature | None
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Verification is **enrichment by default**: no `Signature` header costs nothing, a bad
|
|
71
|
+
signature yields `None`, and nothing ever raises on untrusted input. Use
|
|
72
|
+
`regent_httpsig.fastapi.RequiredSignatureDep` when a signature must be present — the 401
|
|
73
|
+
tells the agent exactly how to sign.
|
|
74
|
+
|
|
75
|
+
## Sign: get your agent past bot walls
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from regent_httpsig import EgressSigner
|
|
79
|
+
|
|
80
|
+
signer = EgressSigner(seed=os.environ["AGENT_KEY_SEED"],
|
|
81
|
+
signature_agent="https://myagent.example")
|
|
82
|
+
headers = signer.sign("POST", url, {"content-type": "application/json"})
|
|
83
|
+
resp = httpx.post(url, json=body, headers=headers)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Generate a key and the ready-to-publish `/.well-known/` files in one command:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
regent-httpsig keygen --agent https://myagent.example --out ./well-known/
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Publish the directory at `https://myagent.example/.well-known/http-message-signatures-directory`
|
|
93
|
+
and every Web Bot Auth verifier on the internet can now identify your agent.
|
|
94
|
+
|
|
95
|
+
## What exactly is verified
|
|
96
|
+
|
|
97
|
+
| Check | Status |
|
|
98
|
+
|---|---|
|
|
99
|
+
| RFC 9421 Appendix B.2.6 Ed25519 vector (byte-exact) | ✅ in CI |
|
|
100
|
+
| Web Bot Auth draft -05 A.2.2 — sf-dictionary `Signature-Agent` covered with `;key=` | ✅ in CI¹ |
|
|
101
|
+
| Web Bot Auth A.2.3 — legacy sf-string form (**what OpenAI ships in production**) | ✅ in CI |
|
|
102
|
+
| Sign → verify roundtrip (fresh keys, full pipeline) | ✅ in CI |
|
|
103
|
+
| AAuth identity-mode roundtrip (`aa-agent+jwt` + `cnf.jwk` proof of possession) | ✅ in CI |
|
|
104
|
+
| Tampered request / expired signature / wrong directory key rejected | ✅ in CI |
|
|
105
|
+
|
|
106
|
+
¹ The signature bytes printed in the draft's own A.2.2 example do **not** verify over the
|
|
107
|
+
draft's own signature base (the legacy A.2.3 vector and RFC 9421 B.2.6 both do, so the defect
|
|
108
|
+
is in the example, not the canonicalization). Ed25519 is deterministic, so our test pins the
|
|
109
|
+
vector re-signed with the same RFC test key over the same byte-exact base — reported upstream.
|
|
110
|
+
|
|
111
|
+
## Both dialects, one verifier
|
|
112
|
+
|
|
113
|
+
- **Web Bot Auth** (`draft-meunier-web-bot-auth-architecture`): key discovery via
|
|
114
|
+
`{Signature-Agent}/.well-known/http-message-signatures-directory`. Both wire forms of
|
|
115
|
+
`Signature-Agent` are accepted — the current sf-dictionary and the legacy bare sf-string
|
|
116
|
+
OpenAI actually sends.
|
|
117
|
+
- **AAuth** (`draft-hardt-oauth-aauth-protocol`, identity-based mode): the agent carries a
|
|
118
|
+
JWT `agent_token` in `Signature-Key`; the issuer's JWKS verifies the token, the token's
|
|
119
|
+
`cnf.jwk` verifies the request signature. Install with `pip install 'regent-httpsig[aauth]'`.
|
|
120
|
+
For a full-protocol AAuth implementation (both roles, all token types) see
|
|
121
|
+
[christian-posta/aauth-python-library](https://github.com/christian-posta/aauth-python-library) —
|
|
122
|
+
this library is the thin relying-party verifier that handles both dialects.
|
|
123
|
+
|
|
124
|
+
## Security model (what a naive implementation gets wrong)
|
|
125
|
+
|
|
126
|
+
The verifier fetches key directories from **attacker-nameable origins** — whoever signs a
|
|
127
|
+
request chooses its `Signature-Agent`. regent-httpsig ships with the guard rails on:
|
|
128
|
+
|
|
129
|
+
- **SSRF protection by default**: https-only, every resolved IP must be public (catches
|
|
130
|
+
`169.254.169.254`, loopback, private ranges, DNS names mapping to internal services),
|
|
131
|
+
redirects never followed, responses size-capped.
|
|
132
|
+
- **Bounded caching**: per-instance TTL cache with eviction — a keyid-spam attack can't
|
|
133
|
+
grow memory; failures are negative-cached so a dead origin can't be used to slow you down.
|
|
134
|
+
- **A valid signature proves key possession — not trustworthiness.** `VerifiedSignature.trusted`
|
|
135
|
+
reflects only your configured allow-list; deciding *whether to trust* a key is your policy
|
|
136
|
+
layer's job.
|
|
137
|
+
|
|
138
|
+
Known sharp edges of the underlying ecosystem, already handled: the upstream
|
|
139
|
+
`http-message-signatures` library cannot resolve RFC 9421 `;key=` dictionary members (we
|
|
140
|
+
provide the component resolver), it looks up header names case-sensitively while ASGI
|
|
141
|
+
frameworks lowercase them (we wrap), and it forgets to declare `typing_extensions` (we
|
|
142
|
+
declare it).
|
|
143
|
+
|
|
144
|
+
## Configuration
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
from regent_httpsig import HttpsigConfig, HttpsigVerifier
|
|
148
|
+
|
|
149
|
+
verifier = HttpsigVerifier(HttpsigConfig(
|
|
150
|
+
trusted_agents=frozenset({"https://chatgpt.com", "https://operator.openai.com"}),
|
|
151
|
+
max_age_hours=25, # reject signatures created earlier than this
|
|
152
|
+
cache_ttl=600, # key-directory cache seconds
|
|
153
|
+
))
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Pass your app's shared client to reuse its pool: `HttpsigVerifier(http_client=my_async_client)`.
|
|
157
|
+
|
|
158
|
+
## Honest limitations
|
|
159
|
+
|
|
160
|
+
- Web Bot Auth and AAuth are **IETF drafts** (RFC 9421 itself is a final standard). We track
|
|
161
|
+
the drafts; breaking draft changes land as minor releases while we're 0.x.
|
|
162
|
+
- **Ed25519 only** for now — it's what the agent ecosystem ships.
|
|
163
|
+
- Body coverage (`content-digest`) is verified when covered by the signature, but this
|
|
164
|
+
library does not require it; decide per-route whether you need it.
|
|
165
|
+
|
|
166
|
+
## Related projects
|
|
167
|
+
|
|
168
|
+
[cloudflare/web-bot-auth](https://github.com/cloudflare/web-bot-auth) (TypeScript/Rust) ·
|
|
169
|
+
[christian-posta/aauth-python-library](https://github.com/christian-posta/aauth-python-library)
|
|
170
|
+
(full AAuth protocol) · [pyauth/http-message-signatures](https://github.com/pyauth/http-message-signatures)
|
|
171
|
+
(the RFC 9421 primitive this builds on)
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
Built and battle-tested in production by [Regent Protocol](https://regentprotocol.org) —
|
|
176
|
+
runtime control and identity for AI agents. Apache-2.0.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
regent_httpsig/__init__.py,sha256=UZ-RmE0KLmqjD7JV7LnSOnPTc5FPO-5OtQzvsYq9PAs,987
|
|
2
|
+
regent_httpsig/cli.py,sha256=WUSLQ2WuEX4E85L2C2EgoUfu5cWpj6MRSG1UVNHpzcw,2005
|
|
3
|
+
regent_httpsig/config.py,sha256=xkILlWugaD8Nn4huAgL6RP-C916KxCYRn0YpqCkMOGc,1422
|
|
4
|
+
regent_httpsig/fastapi.py,sha256=YmfrKdeMft2vpuiRlivIEHYC4BIs3ioR7T3t1ZEzcJ4,3707
|
|
5
|
+
regent_httpsig/jwk.py,sha256=h4vgnIgpnyKeWdWpm7g_hEyXZOPVREWn-bD4JN07tT0,1637
|
|
6
|
+
regent_httpsig/netguard.py,sha256=Sqg08mdC94RWvnQLJaH4weZW0nN2VlTJF5wVF8VYGHY,2121
|
|
7
|
+
regent_httpsig/sfv.py,sha256=kcQsBLw9h4h_6D3z2rv0-3_gLZQYC47B9VZi-oS_Vgk,4975
|
|
8
|
+
regent_httpsig/sign.py,sha256=B5ChxFxuuKL0i10bGrgFImzyh8GMjWIqNDECXehBWTA,4397
|
|
9
|
+
regent_httpsig/verify.py,sha256=YVcYWqxUBmqSdWrW8nBCSceT7YApAUjO5J-FIMKBRVM,14256
|
|
10
|
+
regent_httpsig-0.1.0.dist-info/METADATA,sha256=4v_RgOudkaT3ui9921kZhM8R5fGfJbLl5e3e1pDBTh0,7871
|
|
11
|
+
regent_httpsig-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
12
|
+
regent_httpsig-0.1.0.dist-info/entry_points.txt,sha256=SgZdmc27V14IAmwOjbBoCYLsbrALaqdTUHNHLbil2dU,59
|
|
13
|
+
regent_httpsig-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
14
|
+
regent_httpsig-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|