Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 |
Expand All @@ -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 <url>` | Send a Web-Bot-Auth-signed request. |
| `wingfoot doctor <url>` | 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
Expand Down
5 changes: 5 additions & 0 deletions src/wingfoot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
38 changes: 36 additions & 2 deletions src/wingfoot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
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 .register import PROVIDERS, register
from .rfc9421 import sign_directory, sign_request
from .verifier import _Colors, demo, start_verifier


Expand Down Expand Up @@ -40,8 +41,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


Expand Down Expand Up @@ -105,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}"
Expand All @@ -127,6 +149,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")
Expand All @@ -147,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


Expand Down
22 changes: 17 additions & 5 deletions src/wingfoot/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -101,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
Expand Down
13 changes: 12 additions & 1 deletion src/wingfoot/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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())
Expand Down
144 changes: 144 additions & 0 deletions src/wingfoot/register.py
Original file line number Diff line number Diff line change
@@ -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 "<your contact email>"),
]


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 <blocked-url>` 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
Loading
Loading