From 5aab635c2b4e49ee33e4ad042b1d1bdde7c5830d Mon Sep 17 00:00:00 2001 From: AmirF194 Date: Fri, 3 Jul 2026 19:37:46 -0600 Subject: [PATCH 1/4] fix(http): send a real User-Agent instead of urllib's blocked default wingfoot's HTTP client went out as "Python-urllib/x.y", which Cloudflare and Akamai block outright as an unidentified bot. That made `wingfoot doctor` (and directory fetches) fail with 403 against real CDN-fronted sites before the tool could even prove its identity. Send "wingfoot/ (+repo)" by default, overridable via the headers argument. Covered by tests/test_http.py. --- src/wingfoot/http.py | 13 ++++++++++++- tests/test_http.py | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 tests/test_http.py diff --git a/src/wingfoot/http.py b/src/wingfoot/http.py index bd77bd7..42233b0 100644 --- a/src/wingfoot/http.py +++ b/src/wingfoot/http.py @@ -6,6 +6,14 @@ import urllib.request from dataclasses import dataclass +from . import __version__ + +# urllib's default User-Agent ("Python-urllib/x.y") is blocked outright by many +# CDNs (Cloudflare, Akamai) as an unidentified bot — which would make a +# verified-bot tool fail before it even gets to prove its identity. Send a +# descriptive UA instead; callers can override it via the `headers` argument. +DEFAULT_USER_AGENT = f"wingfoot/{__version__} (+https://github.com/AmirF194/wingfoot)" + @dataclass class Response: @@ -27,7 +35,10 @@ def text(self) -> str: def request(url: str, *, method: str = "GET", headers: dict | None = None, timeout: float = 10.0) -> Response: """Send a request, returning a Response even for 4xx/5xx (no exception).""" - req = urllib.request.Request(url, method=method, headers=headers or {}) + hdrs = dict(headers or {}) + if not any(k.lower() == "user-agent" for k in hdrs): + hdrs["User-Agent"] = DEFAULT_USER_AGENT + req = urllib.request.Request(url, method=method, headers=hdrs) try: with urllib.request.urlopen(req, timeout=timeout) as resp: return Response(resp.status, dict(resp.headers), resp.read()) diff --git a/tests/test_http.py b/tests/test_http.py new file mode 100644 index 0000000..244ad29 --- /dev/null +++ b/tests/test_http.py @@ -0,0 +1,41 @@ +"""http.request sends a descriptive User-Agent, not urllib's CDN-blocked default.""" +import contextlib + +from wingfoot import http as _http + + +class _FakeResp: + status = 200 + headers: dict = {} + + def read(self) -> bytes: + return b"{}" + + +def _capture_request(monkeypatch) -> dict: + """Intercept urlopen so we can inspect the outgoing Request without a network.""" + seen: dict = {} + + @contextlib.contextmanager + def fake_urlopen(req, timeout=None): + seen["req"] = req + yield _FakeResp() + + monkeypatch.setattr("wingfoot.http.urllib.request.urlopen", fake_urlopen) + return seen + + +def test_default_user_agent_is_identifiable(monkeypatch): + seen = _capture_request(monkeypatch) + _http.request("http://example.test/") + ua = seen["req"].get_header("User-agent") + # A verified-bot tool must not go out as the blocked "Python-urllib/x.y". + assert ua is not None + assert ua.startswith("wingfoot/") + assert "Python-urllib" not in ua + + +def test_caller_can_override_user_agent(monkeypatch): + seen = _capture_request(monkeypatch) + _http.request("http://example.test/", headers={"User-Agent": "custom/9.9"}) + assert seen["req"].get_header("User-agent") == "custom/9.9" From fe60935482a01a66ac6c9f497096d700090641ce Mon Sep 17 00:00:00 2001 From: AmirF194 Date: Fri, 3 Jul 2026 19:54:45 -0600 Subject: [PATCH 2/4] feat: sign the key directory response (Web Bot Auth registration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloudflare's verified-bot submission (Request Signature method) requires the key directory RESPONSE to carry its own signature so nobody can mirror the directory and register on your behalf (RFC 9421 directory draft §5.2). Add: - rfc9421.sign_directory / verify_directory: signature over ("@authority";req) with tag="http-message-signatures-directory", alg="ed25519", label binding0. Body-independent, so it can be pre-signed OFFLINE with a long expiry and served as static headers, keeping the private key off the directory host. - `wingfoot directory --sign`: emit the Signature-Input / Signature headers to host. - doctor: new check that the served directory carries a valid signature. Verified byte-exact against Cloudflare's live reference directory: a regression test in test_directory_signing.py checks wingfoot verifies Cloudflare's own signed directory with their published key. --- src/wingfoot/__init__.py | 5 ++ src/wingfoot/cli.py | 20 +++++- src/wingfoot/doctor.py | 18 +++++- src/wingfoot/rfc9421.py | 111 +++++++++++++++++++++++++++++++- tests/test_directory_signing.py | 84 ++++++++++++++++++++++++ 5 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 tests/test_directory_signing.py diff --git a/src/wingfoot/__init__.py b/src/wingfoot/__init__.py index 90b604a..3c185b7 100644 --- a/src/wingfoot/__init__.py +++ b/src/wingfoot/__init__.py @@ -9,6 +9,11 @@ DIRECTORY_PATH = "/.well-known/http-message-signatures-directory" WEB_BOT_AUTH_TAG = "web-bot-auth" +# Tag for the signature ON the key directory response itself (RFC 9421 directory +# draft §5.2), distinct from the per-request WEB_BOT_AUTH_TAG. Verifiers such as +# Cloudflare require the directory to carry its own signature so no one can mirror +# it and register on your behalf. +DIRECTORY_TAG = "http-message-signatures-directory" # Drop-in signing for requests / httpx. Imported last so the constants above are # already defined (integrations -> rfc9421 -> `from . import WEB_BOT_AUTH_TAG`). diff --git a/src/wingfoot/cli.py b/src/wingfoot/cli.py index 59aae27..88b5612 100644 --- a/src/wingfoot/cli.py +++ b/src/wingfoot/cli.py @@ -10,7 +10,7 @@ from .directory import directory_json from .doctor import doctor from .keys import Identity, ephemeral_identity, generate_private_key, load_identity, save_identity -from .rfc9421 import sign_request +from .rfc9421 import sign_directory, sign_request from .verifier import _Colors, demo, start_verifier @@ -40,8 +40,22 @@ def cmd_init(args) -> int: def cmd_directory(args) -> int: - identity = _require_identity(_Colors()) + C = _Colors() + identity = _require_identity(C) print(directory_json([identity.jwk])) + if args.sign: + if not identity.agent_url.startswith("http"): + print(f"\n{C.yellow}Can't sign:{C.reset} no public directory URL. " + f"Re-run `wingfoot init --agent https://your-domain` first.", file=sys.stderr) + return 2 + dir_url = identity.agent_url.rstrip("/") + DIRECTORY_PATH + signed = sign_directory(dir_url, identity.private_key, identity.keyid) + print(f"\n{C.dim}# Serve the JSON above with these response headers and " + f"Content-Type: application/http-message-signatures-directory+json{C.reset}") + print(f"{C.dim}# Verifiers (e.g. Cloudflare) require this signature. " + f"Valid until epoch {signed.expires}; re-run before then to refresh.{C.reset}") + for k, v in signed.headers.items(): + print(f"{k}: {v}") return 0 @@ -127,6 +141,8 @@ def build_parser() -> argparse.ArgumentParser: s.set_defaults(func=cmd_init) s = sub.add_parser("directory", help="print the JWKS to host at the well-known path") + s.add_argument("--sign", action="store_true", + help="also print the signed response headers verifiers require") s.set_defaults(func=cmd_directory) s = sub.add_parser("serve", help="serve your key directory (and verify) locally") diff --git a/src/wingfoot/doctor.py b/src/wingfoot/doctor.py index 676e280..ba44870 100644 --- a/src/wingfoot/doctor.py +++ b/src/wingfoot/doctor.py @@ -6,7 +6,7 @@ from . import http as _http from .directory import directory_url_for, find_key from .keys import DEFAULT_HOME, ephemeral_identity, load_identity -from .rfc9421 import sign_request, verify_request +from .rfc9421 import sign_request, verify_directory, verify_request from .verifier import _Colors @@ -49,16 +49,28 @@ def doctor(url: str, home=DEFAULT_HOME) -> int: _check(C, self_result.ok, "Signature is well-formed and cryptographically valid", self_result.reason if not self_result.ok else "signing is correct per RFC 9421") - # 3. Directory reachability + # 3. Directory reachability + the directory's own signature dir_ok: Optional[bool] = None if identity.agent_url.startswith("http"): dir_url = directory_url_for(identity.agent_url) try: - jwks = _http.fetch_json(dir_url) + resp = _http.request(dir_url) + if resp.status != 200: + raise OSError(f"returned HTTP {resp.status}") + jwks = resp.json() + if not isinstance(jwks, dict): + raise ValueError("did not return a JWKS document") found = find_key(jwks, identity.keyid) is not None dir_ok = found _check(C, found, "Key directory reachable and publishes this key", dir_url if found else f"reachable, but your keyid is not listed at {dir_url}") + # The directory response must carry its own signature (draft §5.2); + # verifiers such as Cloudflare reject an unsigned directory. + dsig = verify_directory(dir_url, resp.headers, + resolve_key=lambda kid, agent: find_key(jwks, kid)) + _check(C, dsig.ok, "Directory response is signed", + "verifiers can trust it" if dsig.ok + else f"{dsig.reason}. Run `wingfoot directory --sign` and serve those headers.") except Exception as exc: dir_ok = False _check(C, False, "Key directory reachable", diff --git a/src/wingfoot/rfc9421.py b/src/wingfoot/rfc9421.py index 0d3c5c9..c72ff98 100644 --- a/src/wingfoot/rfc9421.py +++ b/src/wingfoot/rfc9421.py @@ -19,11 +19,16 @@ from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey -from . import WEB_BOT_AUTH_TAG +from . import DIRECTORY_TAG, WEB_BOT_AUTH_TAG # The covered components for a Web Bot Auth signature, in signing order. COVERED_COMPONENTS = ("@authority", "signature-agent") DEFAULT_LABEL = "sig1" +# A directory signature (draft §5.2) is a *response* signature that binds the +# *request's* @authority, so the component carries the `;req` parameter. Default +# label is "binding0" (binding, one per key), matching Cloudflare's tooling. +DIRECTORY_LABEL = "binding0" +DIRECTORY_LIFETIME = 365 * 24 * 3600 # long-lived: pre-sign offline, keep the key out of the server _DEFAULT_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443} @@ -95,6 +100,71 @@ def sign_request( return SignedHeaders(headers, base, keyid, created, expires) +# --------------------------------------------------------------------------- +# Directory signing (RFC 9421 HTTP Message Signatures Directory draft §5.2) +# +# The key directory response carries its own signature so a verifier can confirm +# the directory is served by whoever controls the keys (and can't be mirrored). +# The signature covers only the request's `@authority` (with `;req`), so it does +# not depend on the response body and can be pre-computed offline: sign once with +# a long lifetime and serve the fixed Signature-Input / Signature headers, keeping +# the private key off the server that hosts the directory. +# --------------------------------------------------------------------------- + + +def _directory_params_value(created: int, expires: int, keyid: str) -> str: + """The @signature-params value for a directory signature (also Signature-Input body).""" + return ( + f'("@authority";req);created={created};keyid="{keyid}"' + f';alg="ed25519";expires={expires};tag="{DIRECTORY_TAG}"' + ) + + +def build_directory_signature_base(authority: str, params_value: str) -> str: + """The exact bytes signed for a directory response, per RFC 9421 §2.5. + + `@authority` carries `;req` because this is a response signature binding the + authority of the request that fetched the directory. + """ + return f'"@authority";req: {authority}\n"@signature-params": {params_value}' + + +@dataclass +class SignedDirectory: + headers: dict + signature_base: str + keyid: str + created: int + expires: int + + +def sign_directory( + directory_url: str, + private_key, + keyid: str, + *, + created: Optional[int] = None, + lifetime: int = DIRECTORY_LIFETIME, + label: str = DIRECTORY_LABEL, +) -> SignedDirectory: + """Produce the Signature-Input / Signature response headers for a key directory. + + Serve these alongside the JWKS body (with the directory content-type). The + signature is over `@authority` only, so it stays valid as long as the host and + the timestamps hold — pre-sign offline and refresh before ``expires``. + """ + created = int(created if created is not None else time.time()) + expires = created + lifetime + params_value = _directory_params_value(created, expires, keyid) + base = build_directory_signature_base(authority_of(directory_url), params_value) + signature = private_key.sign(base.encode("utf-8")) + headers = { + "Signature-Input": f"{label}={params_value}", + "Signature": f"{label}=:{base64.b64encode(signature).decode('ascii')}:", + } + return SignedDirectory(headers, base, keyid, created, expires) + + # --------------------------------------------------------------------------- # Verification # --------------------------------------------------------------------------- @@ -244,3 +314,42 @@ def add(name, ok, detail=""): add("cryptographic signature valid", True) return VerifyResult(True, "verified", keyid=parsed.keyid, checks=checks) + + +def verify_directory( + directory_url: str, + headers: Mapping[str, str], + resolve_key: KeyResolver, + *, + now: Optional[int] = None, +) -> VerifyResult: + """Verify the signature on a key directory response (draft §5.2). + + ``directory_url`` is the URL the directory was fetched from; its ``@authority`` + is what the signature binds. Handles a single ``binding`` signature (one key). + """ + now = int(now if now is not None else time.time()) + try: + parsed = parse_signature(headers) + except ValueError as exc: + return VerifyResult(False, f"directory is unsigned or malformed: {exc}") + + if parsed.tag != DIRECTORY_TAG: + return VerifyResult(False, f'directory tag must be "{DIRECTORY_TAG}", got {parsed.tag!r}', + keyid=parsed.keyid) + if parsed.expires is not None and now > parsed.expires: + return VerifyResult(False, f"directory signature expired {now - parsed.expires}s ago", + keyid=parsed.keyid) + if not parsed.keyid: + return VerifyResult(False, "no keyid in directory signature") + + public_key = resolve_key(parsed.keyid, directory_url) + if public_key is None: + return VerifyResult(False, "could not resolve the directory's own key", keyid=parsed.keyid) + + base = build_directory_signature_base(authority_of(directory_url), parsed.params_value) + try: + public_key.verify(parsed.signature, base.encode("utf-8")) + except InvalidSignature: + return VerifyResult(False, "directory signature did not verify", keyid=parsed.keyid) + return VerifyResult(True, "directory signature valid", keyid=parsed.keyid) diff --git a/tests/test_directory_signing.py b/tests/test_directory_signing.py new file mode 100644 index 0000000..6c178fb --- /dev/null +++ b/tests/test_directory_signing.py @@ -0,0 +1,84 @@ +"""Signing the key directory response (RFC 9421 directory draft §5.2). + +The headline test verifies Cloudflare's OWN live directory signature with +wingfoot's base construction — if that passes, a directory wingfoot signs the +same way will pass Cloudflare's validator. +""" +from wingfoot import DIRECTORY_TAG +from wingfoot.keys import ephemeral_identity, public_key_from_jwk +from wingfoot.rfc9421 import sign_directory, verify_directory + +DIR_URL = "https://fastinfer.org/.well-known/http-message-signatures-directory" + + +def _resolver(identity): + return lambda keyid, agent: identity.public_key if keyid == identity.keyid else None + + +def test_sign_then_verify_round_trip(): + ident = ephemeral_identity(agent_url="https://fastinfer.org") + signed = sign_directory(DIR_URL, ident.private_key, ident.keyid) + result = verify_directory(DIR_URL, signed.headers, _resolver(ident)) + assert result.ok, result.reason + + +def test_signature_input_matches_cloudflare_profile(): + ident = ephemeral_identity(agent_url="https://fastinfer.org") + signed = sign_directory(DIR_URL, ident.private_key, ident.keyid, created=1000, lifetime=300) + si = signed.headers["Signature-Input"] + # Exact shape Cloudflare's tooling emits and validates. + assert si == ( + f'binding0=("@authority";req);created=1000;keyid="{ident.keyid}"' + f';alg="ed25519";expires=1300;tag="http-message-signatures-directory"' + ) + assert signed.headers["Signature"].startswith("binding0=:") + + +def test_wrong_authority_fails(): + """A mirror served under a different host cannot reuse the signature.""" + ident = ephemeral_identity(agent_url="https://fastinfer.org") + signed = sign_directory(DIR_URL, ident.private_key, ident.keyid) + other = "https://evil.example/.well-known/http-message-signatures-directory" + assert not verify_directory(other, signed.headers, _resolver(ident)).ok + + +def test_expired_signature_fails(): + ident = ephemeral_identity(agent_url="https://fastinfer.org") + signed = sign_directory(DIR_URL, ident.private_key, ident.keyid, created=1000, lifetime=300) + assert not verify_directory(DIR_URL, signed.headers, _resolver(ident), now=2000).ok + + +def test_verifies_cloudflares_live_directory(): + """Byte-exact regression: wingfoot verifies Cloudflare's real signed directory. + + Captured live from Cloudflare's reference server. If the signature base ever + drifts from Cloudflare's, this fails. + """ + authority = "http-message-signatures-example.research.cloudflare.com" + cf_url = f"https://{authority}/.well-known/http-message-signatures-directory" + headers = { + "Signature-Input": ( + 'binding0=("@authority";req);created=1783129585;' + 'keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";' + 'alg="ed25519";expires=1783129885;tag="http-message-signatures-directory"' + ), + "Signature": ( + "binding0=:iZqb53u8rp81QldASkp9mPZei2Syaw6dfGYTmfV0wISvd0cE" + "+rtywzNybtXJ+itYniK3rcCcCxQAZt4lcw4sBA==:" + ), + } + cf_pubkey = public_key_from_jwk({ + "kty": "OKP", "crv": "Ed25519", + "x": "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs", + }) + result = verify_directory( + cf_url, headers, + resolve_key=lambda keyid, agent: cf_pubkey, + now=1783129700, # within Cloudflare's created..expires window + ) + assert result.ok, result.reason + assert result.keyid == "poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U" + + +def test_directory_tag_constant(): + assert DIRECTORY_TAG == "http-message-signatures-directory" From 2a182a4c54c48a92379e86d76083d2f33e98338c Mon Sep 17 00:00:00 2001 From: AmirF194 Date: Tue, 7 Jul 2026 16:21:52 -0600 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20add=20`wingfoot=20register`=20?= =?UTF-8?q?=E2=80=94=20ready-to-paste=20verifier=20registration=20packets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No verifier (Cloudflare, DataDome, ...) offers automated registration yet; each runs its own human-reviewed web form. `wingfoot register` shrinks that step to copy/paste: it preflights the hosted directory the way a reviewer would (reachable, publishes the key, response signed), then prints every answer the forms ask for, per provider. Supports --email, --user-agent, --open (launch the form), --no-check, and an optional provider filter. doctor now points at `wingfoot register` when a valid signature is still rejected because the target hasn't allow-listed the key. --- README.md | 29 ++++++++ src/wingfoot/cli.py | 18 +++++ src/wingfoot/doctor.py | 4 +- src/wingfoot/register.py | 144 +++++++++++++++++++++++++++++++++++++++ tests/test_register.py | 107 +++++++++++++++++++++++++++++ 5 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 src/wingfoot/register.py create mode 100644 tests/test_register.py diff --git a/README.md b/README.md index c9505ea..13765e4 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,9 @@ wingfoot sign https://example.com/ # check why a URL accepts or rejects your signed agent wingfoot doctor https://example.com/ + +# get a ready-to-paste registration packet for Cloudflare, DataDome, ... +wingfoot register --email you@example.com ``` `wingfoot doctor` sends a signed request, reads the response, and prints a checklist: @@ -84,6 +87,31 @@ $ wingfoot doctor http://localhost:8088/whoami When something is wrong it reports which check failed and how to fix it: expired signature, clock skew, unreachable directory, key not listed, missing `tag="web-bot-auth"`, and so on. +## Register with verifiers + +A valid signature only helps once a verifier (Cloudflare, DataDome, ...) knows your key. None of +them offer automated registration yet — each runs its own web form with a manual review behind +it. `wingfoot register` shrinks that step to copy/paste: it first checks your hosted directory +the way a reviewer would, then prints every answer their forms ask for: + +```console +$ wingfoot register --email you@example.com + +Preflight — what a reviewer will check + ok Key directory reachable + ok Directory publishes this key + ok Directory response is signed + +Cloudflare — Verified Bots + form https://dash.cloudflare.com/?to=/:account/configurations + Bot / agent name your-bot.example + User-Agent wingfoot/0.1.0 (+https://github.com/AmirF194/wingfoot) + Key directory URL https://your-bot.example/.well-known/http-message-signatures-directory + ... +``` + +Add `--open` to launch the forms in your browser, or name one provider: `wingfoot register datadome`. + ## Commands | Command | What it does | @@ -94,6 +122,7 @@ skew, unreachable directory, key not listed, missing `tag="web-bot-auth"`, and s | `wingfoot serve` | Serve your key directory locally (and verify requests). | | `wingfoot sign ` | Send a Web-Bot-Auth-signed request. | | `wingfoot doctor ` | Diagnose why a URL blocks or accepts your signed agent. | +| `wingfoot register [provider]` | Preflight your setup, then print what to paste into each verifier's registration form. | | `wingfoot verifier` | Run a reference verifier for others to test against. | ## Use it in your code diff --git a/src/wingfoot/cli.py b/src/wingfoot/cli.py index 88b5612..8d77394 100644 --- a/src/wingfoot/cli.py +++ b/src/wingfoot/cli.py @@ -10,6 +10,7 @@ from .directory import directory_json from .doctor import doctor from .keys import Identity, ephemeral_identity, generate_private_key, load_identity, save_identity +from .register import PROVIDERS, register from .rfc9421 import sign_directory, sign_request from .verifier import _Colors, demo, start_verifier @@ -119,6 +120,13 @@ def cmd_demo(args) -> int: return 0 if demo() else 1 +def cmd_register(args) -> int: + C = _Colors() + identity = _require_identity(C) + return register(identity, args.provider, email=args.email, user_agent=args.user_agent, + open_browser=args.open, skip_checks=args.no_check) + + def _origin(url: str) -> str: p = urlsplit(url) return f"{p.scheme}://{p.netloc}" @@ -163,6 +171,16 @@ def build_parser() -> argparse.ArgumentParser: s.add_argument("url") s.set_defaults(func=cmd_doctor) + s = sub.add_parser("register", + help="print a ready-to-paste registration packet for each verifier program") + s.add_argument("provider", nargs="?", choices=[p.slug for p in PROVIDERS], + help="only this provider (default: all)") + s.add_argument("--email", help="contact email to include in the packet") + s.add_argument("--user-agent", help="your bot's User-Agent, if not wingfoot's default") + s.add_argument("--open", action="store_true", help="also open the form in your browser") + s.add_argument("--no-check", action="store_true", help="skip the live directory preflight") + s.set_defaults(func=cmd_register) + return p diff --git a/src/wingfoot/doctor.py b/src/wingfoot/doctor.py index ba44870..7993b1c 100644 --- a/src/wingfoot/doctor.py +++ b/src/wingfoot/doctor.py @@ -113,8 +113,8 @@ def doctor(url: str, home=DEFAULT_HOME) -> int: print(f"{C.yellow}Your signature is valid and your key is published, but " f"{_origin(url)} still returned {signed_resp.status}.{C.reset}") print(f" {C.dim}Most likely the target does not support Web Bot Auth yet, or has not " - f"allow-listed your key. Next: confirm it supports Web Bot Auth and register your " - f"directory ({identity.agent_url}) with them.{C.reset}") + f"allow-listed your key. Next: run `wingfoot register` for a ready-to-paste " + f"registration packet for each verifier program.{C.reset}") else: print(f"{C.red}Blocked, and your local setup has a problem above. Fix that first.{C.reset}") return 1 diff --git a/src/wingfoot/register.py b/src/wingfoot/register.py new file mode 100644 index 0000000..125fee6 --- /dev/null +++ b/src/wingfoot/register.py @@ -0,0 +1,144 @@ +"""wingfoot register: a ready-to-paste registration packet for verifier programs. + +No verifier offers one-click registration yet: Cloudflare, DataDome, and the +rest each run their own web form with a human review behind it. This command +shrinks that manual step to copy/paste — it re-checks your setup the way a +reviewer would, then prints exactly what to enter into each form. +""" +from __future__ import annotations + +import webbrowser +from dataclasses import dataclass +from typing import Optional + +from . import WEB_BOT_AUTH_TAG +from . import http as _http +from .directory import directory_url_for, find_key +from .doctor import _check +from .keys import Identity +from .rfc9421 import verify_directory +from .verifier import _Colors + + +@dataclass(frozen=True) +class Provider: + slug: str + name: str + form_url: str + how: str # where the form lives and what happens after submitting + + +PROVIDERS = ( + Provider( + slug="cloudflare", + name="Cloudflare — Verified Bots", + form_url="https://dash.cloudflare.com/?to=/:account/configurations", + how='Log in, then Manage Account > Configurations > Bot Submission Form. ' + 'Pick verification method "Request Signature".', + ), + Provider( + slug="datadome", + name="DataDome — Bot & AI Agent Verification", + form_url="https://datadome.co/resources/bot-and-ai-agent-verification/", + how="Public form; their analysts review it and add you to the verified-bot catalog.", + ), +) + + +def get_provider(slug: str) -> Optional[Provider]: + return next((p for p in PROVIDERS if p.slug == slug), None) + + +def registration_fields(identity: Identity, *, email: str | None = None, + user_agent: str | None = None) -> list[tuple[str, str]]: + """The answers every provider form asks for, in copy/paste order.""" + return [ + ("Bot / agent name", _host(identity.agent_url)), + ("User-Agent", user_agent or _http.DEFAULT_USER_AGENT), + ("Signature-Agent (domain)", identity.agent_url), + ("Key directory URL", directory_url_for(identity.agent_url)), + ("Key ID (JWK thumbprint)", identity.keyid), + ("Key type / algorithm", "Ed25519 (EdDSA)"), + ("Request signature tag", WEB_BOT_AUTH_TAG), + ("Contact email", email or ""), + ] + + +def preflight(identity: Identity) -> list[tuple[Optional[bool], str, str]]: + """The checks a reviewer's tooling runs against your directory, as (ok, title, detail).""" + checks: list[tuple[Optional[bool], str, str]] = [] + if not identity.agent_url.startswith("http"): + checks.append((False, "Public directory URL", + "no public origin set — re-run `wingfoot init --agent https://your-domain`")) + return checks + dir_url = directory_url_for(identity.agent_url) + try: + resp = _http.request(dir_url) + except Exception as exc: + checks.append((False, "Key directory reachable", f"{dir_url}: {exc}")) + return checks + if resp.status != 200: + checks.append((False, "Key directory reachable", f"{dir_url} returned HTTP {resp.status}")) + return checks + jwks = resp.json() + if not isinstance(jwks, dict): + checks.append((False, "Key directory serves a JWKS", f"{dir_url} did not return a JSON key set")) + return checks + checks.append((True, "Key directory reachable", dir_url)) + + found = find_key(jwks, identity.keyid) is not None + checks.append((found, "Directory publishes this key", + f"keyid {identity.keyid[:16]}..." if found + else "your keyid is not in the hosted JWKS — re-host `wingfoot directory` output")) + + dsig = verify_directory(dir_url, resp.headers, + resolve_key=lambda kid, agent: find_key(jwks, kid)) + checks.append((dsig.ok, "Directory response is signed", + "reviewers can verify you own it" if dsig.ok + else f"{dsig.reason} — run `wingfoot directory --sign` and serve those headers")) + return checks + + +def register(identity: Identity, provider_slug: str | None = None, *, + email: str | None = None, user_agent: str | None = None, + open_browser: bool = False, skip_checks: bool = False) -> int: + C = _Colors() + providers = [p for p in PROVIDERS if provider_slug in (None, p.slug)] + + print(f"{C.bold}wingfoot register{C.reset}\n") + print(f"{C.dim}No verifier offers automated registration yet: each runs its own form with a " + f"human review behind it. Below is everything the forms ask for, ready to paste.{C.reset}\n") + + ok = True + if not skip_checks: + print(f"{C.bold}Preflight — what a reviewer will check{C.reset}") + for check_ok, title, detail in preflight(identity): + _check(C, check_ok, title, detail) + ok = ok and check_ok is not False + print() + if not ok: + print(f"{C.red}Fix the failed checks before submitting{C.reset} — a reviewer fetching " + f"your directory would hit the same problem.\n") + + fields = registration_fields(identity, email=email, user_agent=user_agent) + width = max(len(k) for k, _ in fields) + for p in providers: + print(f"{C.bold}{p.name}{C.reset}") + print(f" form {p.form_url}") + print(f" {C.dim}{p.how}{C.reset}") + for k, v in fields: + print(f" {k.ljust(width)} {v}") + print() + + if open_browser: + for p in providers: + webbrowser.open(p.form_url) + + print(f"{C.dim}Approval is manual and can take days. Once approved, run " + f"`wingfoot doctor ` again — the 403 should become 200.{C.reset}") + return 0 if ok else 1 + + +def _host(url: str) -> str: + from urllib.parse import urlsplit + return urlsplit(url).netloc or url diff --git a/tests/test_register.py b/tests/test_register.py new file mode 100644 index 0000000..b9a6921 --- /dev/null +++ b/tests/test_register.py @@ -0,0 +1,107 @@ +"""`wingfoot register`: preflight + a ready-to-paste registration packet per provider.""" +import json + +from wingfoot import DIRECTORY_PATH, WEB_BOT_AUTH_TAG +from wingfoot import http as _http +from wingfoot.directory import directory_json +from wingfoot.keys import ephemeral_identity +from wingfoot.register import PROVIDERS, get_provider, preflight, registration_fields +from wingfoot.rfc9421 import sign_directory + +AGENT = "https://fastinfer.org" +DIR_URL = AGENT + DIRECTORY_PATH + + +def _identity(): + return ephemeral_identity(agent_url=AGENT) + + +def _serve_directory(monkeypatch, identity, *, status=200, jwks=None, sign=True): + """Fake the hosted directory: _http.request returns this JWKS, optionally signed.""" + body = (jwks if jwks is not None else directory_json([identity.jwk])).encode() + headers = sign_directory(DIR_URL, identity.private_key, identity.keyid).headers if sign else {} + + def fake_request(url, **kwargs): + assert url == DIR_URL + return _http.Response(status, dict(headers), body) + + monkeypatch.setattr("wingfoot.register._http.request", fake_request) + + +# --- provider registry ------------------------------------------------------ + +def test_registry_covers_known_verifiers(): + slugs = [p.slug for p in PROVIDERS] + assert "cloudflare" in slugs and "datadome" in slugs + assert len(slugs) == len(set(slugs)) + for p in PROVIDERS: + assert p.form_url.startswith("https://") + + +def test_get_provider(): + assert get_provider("datadome").name.startswith("DataDome") + assert get_provider("nope") is None + + +# --- registration packet ---------------------------------------------------- + +def test_fields_contain_what_the_forms_ask_for(): + identity = _identity() + fields = dict(registration_fields(identity)) + assert fields["Key directory URL"] == DIR_URL + assert fields["Signature-Agent (domain)"] == AGENT + assert fields["Key ID (JWK thumbprint)"] == identity.keyid + assert fields["Request signature tag"] == WEB_BOT_AUTH_TAG + assert fields["User-Agent"] == _http.DEFAULT_USER_AGENT + assert "email" in fields["Contact email"] # placeholder until --email is given + + +def test_fields_honor_overrides(): + fields = dict(registration_fields(_identity(), email="dev@fastinfer.org", user_agent="mybot/2.0")) + assert fields["Contact email"] == "dev@fastinfer.org" + assert fields["User-Agent"] == "mybot/2.0" + + +# --- preflight -------------------------------------------------------------- + +def _oks(checks): + return {title: ok for ok, title, _ in checks} + + +def test_preflight_all_green_when_directory_is_hosted_and_signed(monkeypatch): + identity = _identity() + _serve_directory(monkeypatch, identity) + assert all(ok for ok, _, _ in preflight(identity)) + + +def test_preflight_flags_missing_key(monkeypatch): + identity = _identity() + other = ephemeral_identity(agent_url=AGENT) # directory hosts a different key + _serve_directory(monkeypatch, identity, jwks=directory_json([other.jwk])) + assert _oks(preflight(identity))["Directory publishes this key"] is False + + +def test_preflight_flags_unsigned_directory(monkeypatch): + identity = _identity() + _serve_directory(monkeypatch, identity, sign=False) + assert _oks(preflight(identity))["Directory response is signed"] is False + + +def test_preflight_flags_unreachable_directory(monkeypatch): + identity = _identity() + _serve_directory(monkeypatch, identity, status=403) + checks = preflight(identity) + assert checks[-1][0] is False and "403" in checks[-1][2] + + +def test_preflight_requires_public_origin(): + identity = ephemeral_identity(agent_url="local-placeholder") + checks = preflight(identity) + assert checks[0][0] is False and "wingfoot init" in checks[0][2] + + +def test_preflight_rejects_non_jwks_body(monkeypatch): + identity = _identity() + _serve_directory(monkeypatch, identity, jwks=json.dumps(["not", "a", "jwks"])) + checks = preflight(identity) + assert checks[-1][0] is False and "JWKS" in checks[-1][1] From ec5e6ebf1b08d70315a3e1862e1fb44ff98ddfe6 Mon Sep 17 00:00:00 2001 From: AmirF194 Date: Tue, 7 Jul 2026 16:30:32 -0600 Subject: [PATCH 4/4] ci: publish to PyPI on GitHub Release (Trusted Publishing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build sdist+wheel, then upload via OIDC — no API tokens stored in the repo. Requires the one-time pending-publisher setup on pypi.org (project wingfoot, workflow publish.yml, environment pypi). --- .github/workflows/publish.yml | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..d318002 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,40 @@ +name: publish + +# Publishes to PyPI via Trusted Publishing (OIDC, no long-lived tokens). +# One-time setup on pypi.org: add a "pending publisher" for this repo with +# workflow file `publish.yml` and environment `pypi`, then publish a GitHub +# Release (tag `v*`) to trigger it. + +on: + release: + types: [published] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Build sdist and wheel + run: | + pip install build + python -m build + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write # required for PyPI Trusted Publishing + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1