diff --git a/.ast-grep/rules/component-literals-only-in-rules.yml b/.ast-grep/rules/component-literals-only-in-rules.yml new file mode 100644 index 0000000..8c00072 --- /dev/null +++ b/.ast-grep/rules/component-literals-only-in-rules.yml @@ -0,0 +1,30 @@ +# Invariant 5: signer and verifier agree on every rule. The profile's +# component lists are stated once, in rules.py, and both roles read them. +# A component identifier written anywhere else is a second copy of a rule. +id: component-literals-only-in-rules +language: Python +severity: error +message: Component identifiers are stated once, in rules.py; signer, verifier, and directory read them from there. +files: + - "src/iab_agentic_primitives/web_bot_auth/**/*.py" +ignores: + - "src/iab_agentic_primitives/web_bot_auth/rules.py" + # Fixture data: the scenarios list deliberately wrong components, as scenarios.json spells them. + - "src/iab_agentic_primitives/web_bot_auth/conformance/scenarios.py" +rule: + kind: string + any: + # The first argument of component_id("content-digest"), _key("@method"), + # and the like. + - all: + - nthChild: 1 + - inside: + kind: argument_list + inside: + kind: call + has: + field: function + regex: '^(component_id|component_key|_key)$' + # A bare derived-component name such as "@target-uri" or '"@method";req'. + # A docstring opens with three quotes, so the regex never matches one. + - regex: '^["'']"?@[a-z-]+' diff --git a/.ast-grep/rules/library-verify-checks-components-and-body.yml b/.ast-grep/rules/library-verify-checks-components-and-body.yml new file mode 100644 index 0000000..a686f82 --- /dev/null +++ b/.ast-grep/rules/library-verify-checks-components-and-body.yml @@ -0,0 +1,25 @@ +# The RFC 9421 seam hands the library the components the profile requires and +# the covered body, so the library checks them as well as the profile does +# (invariant 1). The profile's own checks run first, so no behavior test can +# tell the library's checks apart; this rule keeps them in place. +id: library-verify-checks-components-and-body +language: Python +severity: error +message: The seam's call to the library verifier passes require_components= and body=. +files: + - "src/iab_agentic_primitives/web_bot_auth/_sig.py" +rule: + pattern: $V.verify($$$ARGS) + any: + - not: + has: + kind: argument_list + has: + kind: keyword_argument + regex: "^require_components=" + - not: + has: + kind: argument_list + has: + kind: keyword_argument + regex: "^body=" diff --git a/.ast-grep/rules/no-absolute-self-import.yml b/.ast-grep/rules/no-absolute-self-import.yml new file mode 100644 index 0000000..4f991b9 --- /dev/null +++ b/.ast-grep/rules/no-absolute-self-import.yml @@ -0,0 +1,21 @@ +# The module is self-contained: it reaches its siblings through relative +# imports only, so it can be vendored or moved without edits. +id: no-absolute-self-import +language: Python +severity: error +message: web_bot_auth is self-contained and uses relative imports only. +files: + - "src/iab_agentic_primitives/web_bot_auth/**/*.py" +rule: + any: + # import iab_agentic_primitives, import iab_agentic_primitives.x as y + - kind: import_statement + has: + stopBy: end + kind: dotted_name + regex: '^iab_agentic_primitives(\.|$)' + # from iab_agentic_primitives.x import y + - kind: import_from_statement + has: + field: module_name + regex: '^iab_agentic_primitives(\.|$)' diff --git a/.ast-grep/rules/no-cross-module-private-import.yml b/.ast-grep/rules/no-cross-module-private-import.yml new file mode 100644 index 0000000..465efc9 --- /dev/null +++ b/.ast-grep/rules/no-cross-module-private-import.yml @@ -0,0 +1,44 @@ +# A private name belongs to its module. Another module reaches it only +# through a public seam, so the layer order cannot be bypassed by an import, +# and a test reaches the module only through its public surface. +id: no-cross-module-private-import +language: Python +severity: error +message: A private name belongs to its module; another module imports it only through a public seam. +files: + - "src/iab_agentic_primitives/web_bot_auth/**/*.py" +rule: + kind: dotted_name + regex: '^_[a-z]' + inside: + kind: import_from_statement + any: + # from .directory import _checked_max_age + - has: + kind: relative_import + regex: '^\.+[a-z]' + # from iab_agentic_primitives.web_bot_auth.verifier import _select + - has: + field: module_name + regex: '^iab_agentic_primitives\.web_bot_auth(\.[a-z][a-z_]*)+$' +--- +id: no-cross-module-private-import-in-tests +language: Python +severity: error +message: A test reaches a private name; test the behavior through the public API instead. +files: + - "tests/web_bot_auth/**/*.py" +rule: + kind: dotted_name + regex: '^_[a-z]' + inside: + kind: import_from_statement + any: + # from .directory import _checked_max_age + - has: + kind: relative_import + regex: '^\.+[a-z]' + # from iab_agentic_primitives.web_bot_auth.verifier import _select + - has: + field: module_name + regex: '^iab_agentic_primitives\.web_bot_auth(\.[a-z][a-z_]*)+$' diff --git a/.ast-grep/rules/no-host-derived-authority.yml b/.ast-grep/rules/no-host-derived-authority.yml new file mode 100644 index 0000000..8881e8b --- /dev/null +++ b/.ast-grep/rules/no-host-derived-authority.yml @@ -0,0 +1,17 @@ +# Invariant 3: the server's own authority comes from configuration, never +# from the request. Reading Host, Forwarded, or the ASGI server tuple to +# decide what the server is would let a client choose the authority a +# signature is checked against. +id: no-host-derived-authority +language: Python +severity: error +message: The server's authority comes from configuration (base_url / authorities), never from Host, Forwarded, or scope["server"]. +files: + - "src/iab_agentic_primitives/web_bot_auth/**/*.py" +rule: + any: + - pattern: $S["server"] + - pattern: $S.get("server") + - all: + - kind: string + - regex: '^b?["''](host|x-forwarded-host|forwarded)["'']$' diff --git a/.ast-grep/rules/refusal-minted-in-one-place.yml b/.ast-grep/rules/refusal-minted-in-one-place.yml new file mode 100644 index 0000000..3032f30 --- /dev/null +++ b/.ast-grep/rules/refusal-minted-in-one-place.yml @@ -0,0 +1,28 @@ +# Invariant 4: every refusal is a typed WebBotAuthError with a status from +# the profile's table. A refusal is minted from that table in one place, so +# no call site can pick its own status or reason. +id: refusal-minted-in-one-place +language: Python +severity: error +message: A refusal is minted in errors.py from a Refusal member; elsewhere, raise refuse(Refusal.X, detail). +files: + - "src/iab_agentic_primitives/web_bot_auth/**/*.py" +ignores: + - "src/iab_agentic_primitives/web_bot_auth/errors.py" +rule: + any: + - pattern: WebBotAuthError($$$A) + - pattern: $M.WebBotAuthError($$$A) +--- +# A test asserts a refusal by its Refusal member, through the one helper that +# checks the member and its status together. +id: refusal-asserted-through-expect-refusal +language: Python +severity: error +message: Assert a refusal with expect_refusal(call, Refusal.X), not pytest.raises(WebBotAuthError). +files: + - "tests/web_bot_auth/**/*.py" +rule: + any: + - pattern: pytest.raises(WebBotAuthError $$$A) + - pattern: raises(WebBotAuthError $$$A) diff --git a/.ast-grep/rules/signature-fields-parsed-once.yml b/.ast-grep/rules/signature-fields-parsed-once.yml new file mode 100644 index 0000000..95e4470 --- /dev/null +++ b/.ast-grep/rules/signature-fields-parsed-once.yml @@ -0,0 +1,30 @@ +# Signature and Signature-Input are parsed by one wire-seam function. Any +# other module that reads them by name is a second parser whose rules can +# drift from the first. +id: signature-fields-parsed-once +language: Python +severity: error +message: Signature / Signature-Input are parsed by one L2 function; nothing else reads them by name. +files: + - "src/iab_agentic_primitives/web_bot_auth/**/*.py" +ignores: + - "src/iab_agentic_primitives/web_bot_auth/_wire.py" + # Fixture data: a scenario appends raw members to these fields after signing. + - "src/iab_agentic_primitives/web_bot_auth/conformance/scenarios.py" +rule: + all: + - kind: string + - regex: '^b?["'']signature(-input)?["'']$' + - inside: + kind: call + stopBy: end + # component_id("signature", {"req": True, ...}) names the covered + # component, not the field; component-literals-only-in-rules owns it. + - not: + inside: + kind: argument_list + inside: + kind: call + has: + field: function + regex: '^component_id$' diff --git a/.ast-grep/rules/verified-types-constructed-by-owner.yml b/.ast-grep/rules/verified-types-constructed-by-owner.yml new file mode 100644 index 0000000..eb6b1ea --- /dev/null +++ b/.ast-grep/rules/verified-types-constructed-by-owner.yml @@ -0,0 +1,31 @@ +# A verified credential is constructed only by the code that verified it, so +# holding one means the check ran. Tests get one by verifying a real message. +id: verified-credentials-constructed-by-owner +language: Python +severity: error +message: VerifiedRequest and VerifiedResponse are constructed only in verifier.py. +files: + - "src/iab_agentic_primitives/web_bot_auth/**/*.py" + - "tests/web_bot_auth/**/*.py" +ignores: + - "src/iab_agentic_primitives/web_bot_auth/verifier.py" +rule: + any: + - pattern: VerifiedRequest($$$A) + - pattern: $M.VerifiedRequest($$$A) + - pattern: VerifiedResponse($$$A) + - pattern: $M.VerifiedResponse($$$A) +--- +id: verified-directory-constructed-by-owner +language: Python +severity: error +message: VerifiedDirectory is constructed only in directory.py; tests verify a real signed directory. +files: + - "src/iab_agentic_primitives/web_bot_auth/**/*.py" + - "tests/web_bot_auth/**/*.py" +ignores: + - "src/iab_agentic_primitives/web_bot_auth/directory.py" +rule: + any: + - pattern: VerifiedDirectory($$$A) + - pattern: $M.VerifiedDirectory($$$A) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce89032..fa477d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,8 +12,39 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v5 with: - python-version: "3.11" + python-version: "3.12" - name: Run tests run: uv run pytest - name: Lint run: uv run ruff check . + + # The Web Bot Auth gates, in order: the ruff format check, the ruff lint, + # the seam boundary, the structural rules, the import contracts, mypy + # strict, and the behavior suite with its coverage floor. + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.12" + - name: Run the Web Bot Auth quality gates + run: make quality + + # The interop tests, which pytest deselects by default: Cloudflare's + # web-bot-auth signs and verifies against this library through Node. + web-bot-auth-interop: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.12" + - name: Install Node + uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Run the interop harness against the module's adapter + run: make interop diff --git a/.gitignore b/.gitignore index 74b5151..bfc77a0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ build/ *.egg-info/ .pytest_cache/ .ruff_cache/ +# Web Bot Auth quality gates (make quality) +.mypy_cache/ +.import_linter_cache/ +.coverage # Secrets hygiene .env diff --git a/.importlinter b/.importlinter new file mode 100644 index 0000000..c04660b --- /dev/null +++ b/.importlinter @@ -0,0 +1,160 @@ +# Import contracts for the Web Bot Auth module (`make imports`). Every one blocks. +[importlinter] +root_package = iab_agentic_primitives +include_external_packages = True + +[importlinter:contract:wba-roles-no-network] +name = values, seams, rules, directory, ports, and roles import no network client (only the fetcher, the edges, and testing do) +type = forbidden +source_modules = + iab_agentic_primitives.web_bot_auth.presentation + iab_agentic_primitives.web_bot_auth.roles + iab_agentic_primitives.web_bot_auth.signer + iab_agentic_primitives.web_bot_auth.verifier + iab_agentic_primitives.web_bot_auth.ports + iab_agentic_primitives.web_bot_auth.replay + iab_agentic_primitives.web_bot_auth.directory + iab_agentic_primitives.web_bot_auth.keys + iab_agentic_primitives.web_bot_auth.values + iab_agentic_primitives.web_bot_auth.errors + iab_agentic_primitives.web_bot_auth.message + iab_agentic_primitives.web_bot_auth._wire + iab_agentic_primitives.web_bot_auth._sig + iab_agentic_primitives.web_bot_auth.rules +forbidden_modules = + httpx +allow_indirect_imports = True + +[importlinter:contract:wba-l0-no-io] +name = the value, error, key, message, wire, rules, directory, presentation, and ports modules do no I/O (no network, filesystem, event loop, threads) +type = forbidden +source_modules = + iab_agentic_primitives.web_bot_auth.keys + iab_agentic_primitives.web_bot_auth.presentation + iab_agentic_primitives.web_bot_auth.ports + iab_agentic_primitives.web_bot_auth.directory + iab_agentic_primitives.web_bot_auth.rules + iab_agentic_primitives.web_bot_auth.values + iab_agentic_primitives.web_bot_auth.errors + iab_agentic_primitives.web_bot_auth.message + iab_agentic_primitives.web_bot_auth._wire + iab_agentic_primitives.web_bot_auth._sig +forbidden_modules = + httpx + asyncio + os + pathlib + threading + subprocess +allow_indirect_imports = True + +[importlinter:contract:wba-seam-libs] +name = only the L2 seams import the RFC 9421 and Structured Fields libraries +type = forbidden +source_modules = + iab_agentic_primitives.web_bot_auth.keyfile + iab_agentic_primitives.web_bot_auth.presentation + iab_agentic_primitives.web_bot_auth.roles + iab_agentic_primitives.web_bot_auth.signer + iab_agentic_primitives.web_bot_auth.verifier + iab_agentic_primitives.web_bot_auth.directory + iab_agentic_primitives.web_bot_auth.rules + iab_agentic_primitives.web_bot_auth.asgi + iab_agentic_primitives.web_bot_auth.resolver + iab_agentic_primitives.web_bot_auth.ports + iab_agentic_primitives.web_bot_auth.fetcher + iab_agentic_primitives.web_bot_auth.testing + iab_agentic_primitives.web_bot_auth.httpx + iab_agentic_primitives.web_bot_auth.replay + iab_agentic_primitives.web_bot_auth.keys + iab_agentic_primitives.web_bot_auth.values + iab_agentic_primitives.web_bot_auth.errors + iab_agentic_primitives.web_bot_auth.message + iab_agentic_primitives.web_bot_auth.conformance + iab_agentic_primitives.web_bot_auth._unchecked +forbidden_modules = + http_message_signatures + http_sf +allow_indirect_imports = True + +[importlinter:contract:wba-roles-no-loop] +name = the roles run no event loop or thread; the sync facade owns the one loop +type = forbidden +source_modules = + iab_agentic_primitives.web_bot_auth.roles + iab_agentic_primitives.web_bot_auth.signer + iab_agentic_primitives.web_bot_auth.verifier +forbidden_modules = + asyncio + concurrent + threading +allow_indirect_imports = True + +[importlinter:contract:wba-conformance-public-only] +name = conformance reaches the seams only through the public signers and verifiers +type = forbidden +source_modules = + iab_agentic_primitives.web_bot_auth.conformance +forbidden_modules = + iab_agentic_primitives.web_bot_auth._sig + iab_agentic_primitives.web_bot_auth._wire +allow_indirect_imports = True + +[importlinter:contract:wba-unchecked-only-in-conformance] +name = only conformance signs raw, through _unchecked; no production module can +type = forbidden +source_modules = + iab_agentic_primitives.web_bot_auth.asgi + iab_agentic_primitives.web_bot_auth.httpx + iab_agentic_primitives.web_bot_auth.testing + iab_agentic_primitives.web_bot_auth.keyfile + iab_agentic_primitives.web_bot_auth.sync + iab_agentic_primitives.web_bot_auth.roles + iab_agentic_primitives.web_bot_auth.verifier + iab_agentic_primitives.web_bot_auth.signer + iab_agentic_primitives.web_bot_auth.resolver + iab_agentic_primitives.web_bot_auth.fetcher + iab_agentic_primitives.web_bot_auth.ports + iab_agentic_primitives.web_bot_auth.replay + iab_agentic_primitives.web_bot_auth.directory + iab_agentic_primitives.web_bot_auth.presentation + iab_agentic_primitives.web_bot_auth.rules + iab_agentic_primitives.web_bot_auth._sig + iab_agentic_primitives.web_bot_auth._wire + iab_agentic_primitives.web_bot_auth.message + iab_agentic_primitives.web_bot_auth.keys + iab_agentic_primitives.web_bot_auth.values + iab_agentic_primitives.web_bot_auth.errors + iab_agentic_primitives.web_bot_auth.extra +forbidden_modules = + iab_agentic_primitives.web_bot_auth._unchecked +allow_indirect_imports = False + +# The layer order, top to bottom. Every module has a place (exhaustive), so a new +# module must take one. sync sits below the edges because the httpx edge and the +# façades run on its loop; roles holds the ports the edges take. resolver composes +# the ports and builds the default fetcher, so the fetcher sits below it; the +# verifiers see only the KeyResolver port. _sig sits above _wire: it verifies the +# SignatureMember that _wire parses. +[importlinter:contract:wba-layers] +name = web_bot_auth layer order (edges > roles > resolution > directory > seams > values) +type = layers +containers = iab_agentic_primitives.web_bot_auth +exhaustive = True +layers = + conformance + asgi | httpx | testing | keyfile | _unchecked + sync + roles + verifier | signer + resolver + fetcher + ports | replay + directory + presentation + rules + _sig + _wire + message | keys + values + errors | extra diff --git a/CHANGELOG.md b/CHANGELOG.md index cbff7f4..1f626a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ major version bump. ## [Unreleased] +### Changed + +- The package requires Python 3.12 or later, and CI runs on 3.12. Before + 3.12, pydantic refuses the `typing.TypedDict` types that the Web Bot Auth + conformance documents validate, so the 3.11 floor didn't hold. + +### Added + +- `iab_agentic_primitives.web_bot_auth`, the OpenProposal 3.0 § 2.8 Web Bot + Auth profile, behind the new `wba` extra: `RequestSigner` and + `ResponseSigner`, `RequestVerifier` and `ResponseVerifier` with typed + credentials, the signed key directory and its resolver, ASGI and httpx + edges, a sync façade, and the `Refusal` vocabulary. Its conformance vectors + ship as package data, with a runner, a typed adapter protocol + (`conformance/PROTOCOL.md`), and a Node harness that grades it against + Cloudflare's `web-bot-auth` library rule by rule. `make quality` runs its + gates; WEB_BOT_AUTH.md states the rules and the API. +- The package ships a `py.typed` marker (PEP 561), so a type checker reads + its inline annotations instead of reporting `import-untyped`. The marker + covers the whole package, the existing modules as well as + `iab_agentic_primitives.web_bot_auth`. + ## [0.5.1] - 2026-08-10 First tagged release published on GitHub. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e1efb38 --- /dev/null +++ b/Makefile @@ -0,0 +1,83 @@ +# Quality gates for the Web Bot Auth module. +# +# `make quality` runs every gate in order and stops at the first failure: +# the ruff format check, the ruff lint (ruff-wba.toml, mccabe 10), the seam +# boundary, the structural rules, the import contracts, mypy strict, and the +# behavior suite with its branch-coverage floor. The upstream +# commands in README.md (`uv run pytest`, `uv run ruff check .`) are +# unchanged; these gates are scoped to the web_bot_auth paths so they never +# fail on upstream modules. +# +# The interop tests drive Cloudflare's library through Node. pytest deselects +# them by default (pyproject.toml), and `make interop` runs them. + +WBA_SRC := src/iab_agentic_primitives/web_bot_auth +WBA_TESTS := tests/web_bot_auth +WBA_PATHS := $(WBA_SRC) $(WBA_TESTS) +WBA_INTEROP := $(WBA_TESTS)/interop + +# The branch-coverage floor for the module, measured over the WBA suite. +WBA_COV_FLOOR := 94 + +.PHONY: quality lint format-check lint-wba boundary guards imports typecheck +.PHONY: test test-wba interop interop-deps + +# The stages run in sequence even under `make -j`. +quality: + $(MAKE) format-check + $(MAKE) lint-wba + $(MAKE) boundary + $(MAKE) guards + $(MAKE) imports + $(MAKE) typecheck + $(MAKE) test-wba + +lint: + $(MAKE) format-check + $(MAKE) lint-wba + $(MAKE) boundary + +format-check: + uv run ruff format --check $(WBA_PATHS) + +lint-wba: + uv run ruff check --config ruff-wba.toml $(WBA_PATHS) + +# Only the wire seams import the RFC 9421 and Structured Fields libraries, and +# only the fetcher, the httpx edge, and testing import httpx. +boundary: + uv run ruff check --config ruff-wba-boundary.toml $(WBA_SRC) + +# Every structural rule is `severity: error`, so any finding fails the scan. +guards: + uv run ast-grep scan --config sgconfig.yml --report-style short + +# The layer order and the I/O and seam contracts. +imports: + uv run lint-imports --config .importlinter + +# mypy strict on the whole module and the whole suite, with the settings in +# pyproject.toml. Imported modules are analyzed but not reported. +typecheck: + uv run mypy --follow-imports=silent $(WBA_PATHS) + +# The WBA behavior suite with the branch-coverage floor. +test-wba: + uv run pytest $(WBA_TESTS) -q \ + --cov=iab_agentic_primitives.web_bot_auth --cov-branch \ + --cov-fail-under=$(WBA_COV_FLOOR) + +# The whole repo, upstream tests included, as CI's test job runs it. +test: + uv run pytest + +# The harness's pinned Cloudflare libraries. npm ci reruns only when the lock +# file is newer than the installed tree. +interop-deps: $(WBA_INTEROP)/node_modules/.package-lock.json + +$(WBA_INTEROP)/node_modules/.package-lock.json: $(WBA_INTEROP)/package-lock.json + npm ci --prefix $(WBA_INTEROP) + +# Interop with Cloudflare's web-bot-auth, the tests pytest deselects by default. +interop: interop-deps + uv run pytest -m interop $(WBA_TESTS)/test_wba_interop.py -v diff --git a/README.md b/README.md index cfad164..c235941 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,14 @@ implementation.** spec fixtures in CI (continuous integration) like any other implementation. +The Web Bot Auth vectors are the one exception: they live in the package, in +`src/iab_agentic_primitives/web_bot_auth/vectors/`, beside the adapter +protocol they are graded with (`conformance/PROTOCOL.md`). The module is +self-contained so it can be lifted into another repository, and an installed +wheel carries everything its conformance CLI reads. Regenerate them with +`python -m iab_agentic_primitives.web_bot_auth.conformance --regenerate`, the +same flag as the package's own conformance CLI. + ## Package layout | Module | Role | @@ -37,6 +45,7 @@ implementation.** | `iab_agentic_primitives.state` | Canonical lifecycle state machines (one state vocabulary per exchanged primitive) | | `iab_agentic_primitives.events` | Shared event types crossing the buyer/seller boundary | | `iab_agentic_primitives.conformance` | Schema validators, golden vectors, standards checklist | +| `iab_agentic_primitives.web_bot_auth` | The OpenProposal § 2.8 Web Bot Auth profile: signed requests and responses, the signed key directory, verification, conformance vectors, and an interop harness. Needs the `wba` extra; see [WEB_BOT_AUTH.md](WEB_BOT_AUTH.md) | The subpackages are scaffolded but intentionally empty of domain models: primitive and protocol definitions land in subsequent work items. @@ -56,11 +65,12 @@ This library follows semantic versioning (semver): ## Development -Requires Python >= 3.11 and [uv](https://docs.astral.sh/uv/). +Requires Python >= 3.12 and [uv](https://docs.astral.sh/uv/). ```bash uv run pytest # tests uv run ruff check . # lint +make quality # Web Bot Auth gates ``` The only runtime dependency is `pydantic>=2`. diff --git a/WEB_BOT_AUTH.md b/WEB_BOT_AUTH.md new file mode 100644 index 0000000..099a0f3 --- /dev/null +++ b/WEB_BOT_AUTH.md @@ -0,0 +1,1074 @@ +# Web Bot Auth profile + +`iab_agentic_primitives.web_bot_auth` implements the Web Bot Auth (WBA) profile +that OpenProposal 3.0 § 2.8 defines for agent-to-agent HTTP traffic. With it, a +buyer agent signs each request it sends, a seller agent verifies the signature +and learns which origin sent the request, and the seller signs each response and +binds it to the request it answers. Neither party needs the other's key in +advance: each one publishes its keys in a signed key directory at its own +origin, and the verifier fetches that directory. + +The module confirms a signature and returns the verified origin. It doesn't +decide who the caller is in your system. Your identity boundary maps +`VerifiedRequest.origin` to the principal in your own records, the same way it +maps a bearer token. + +The module is self-contained. It imports nothing else from +`iab_agentic_primitives`, and its dependencies are the `wba` extra: + +```bash +pip install 'iab-agentic-primitives[wba]' +``` + +The module uses existing libraries for everything except the profile's rules: + +| Library | Use | +| :--- | :--- | +| `http-message-signatures==3.0.0` | RFC 9421 signature bases, Ed25519 signing and verification, the `;key` and `;req` component parameters, and RFC 9530 `Content-Digest` | +| `http-sf` | RFC 8941 Structured Fields for `Signature`, `Signature-Input`, and `Signature-Agent`, with duplicate keys refused | +| `joserfc` | JWK import and export, and RFC 7638 thumbprints for Ed25519 (RFC 8037) | +| `idna` | IDNA-2008 host canonicalization for the origin rule | +| `cryptography` | Ed25519 keys | +| `httpx` | Directory fetches, through a client you can inject | + +The module overrides one hook of `http-message-signatures`, the verifier's +time-window check, so the profile can check the window against an injected +clock. `tests/web_bot_auth/test_wba_library_contract.py` pins the library +behavior the module relies on, through the module's own signers and +verifiers. The ASGI helpers are plain ASGI, so they need no web framework. + +## Profile rules + +The profile selects options that WBA and RFC 9421 already define. It adds no +header, tag, key field, or error code. + +| Rule | Profile choice | +| :--- | :--- | +| Identity | An origin `https://`: a canonical host with no port, and no path, query, fragment, or userinfo. A canonical host is lowercase IDNA-2008 A-labels whose last label isn't a number, or an IP literal in its compressed form (IPv6 in brackets, with no zone). One agent per origin. | +| `Signature-Agent` value | A signer sends its key directory URL, `https:///.well-known/http-message-signatures-directory`, with no member parameters. A verifier accepts exactly that URL or exactly the bare origin `https://` (the draft -00 form), with the same host rules, compares without normalizing, and refuses any other value with `403`. Both forms name the origin `https://`. A member that carries parameters is ignored. | +| Keys | The origin's key directory, `GET https:///.well-known/http-message-signatures-directory`, a JWK Set served as `application/http-message-signatures-directory+json`. Signing keys are Ed25519 (`kty` `OKP`, `crv` `Ed25519`), and each `kid` is the key's RFC 7638 thumbprint. The verifier looks a key up by the pair (origin, `keyid`), never by `keyid` alone. | +| Directory signatures | The directory response is signed once per Ed25519 key, covering `"@authority";req` and `content-digest`, with `created`, `expires`, `keyid`, `tag="http-message-signatures-directory"`, and, when present, `alg="ed25519"`. Signers send `alg`; a verifier accepts a signature without it, as draft -01 Appendix E.2.3 shows. The same 60 s clock skew as for requests applies to `created`, so a `created` up to 60 s in the future is accepted. It doesn't apply to `expires`: a directory signature verifies until its `expires` and not one second after. Every Ed25519 key signs. Keys of other types, and their signatures, are ignored. The server signs for its own configured authority, never for the `Host` a client sends. | +| Fetches | The verifier fetches exactly `https:///.well-known/http-message-signatures-directory` with `Accept-Encoding: identity`. `https` only, no redirects (any `3xx` is refused), status `200` only, the directory media type, no `Content-Encoding` other than `identity`, a 256 KiB cap on the raw body read as a stream, a `Content-Digest` with `sha-256`, a UTF-8 JSON body, at most 16 keys, and 5 s to connect and 10 s in total. | +| Cache | A fetched directory is fresh for 300 s and valid while the current time is at or before the earliest `expires` among its signatures, the same rule the directory check applies. While it's fresh, it answers every `keyid` without a fetch, so an unknown `keyid` never triggers one. A stale directory is fetched again, and a directory that resolves replaces the entry, with or without the key. A failed fetch doesn't evict a valid entry, and the origin isn't fetched again for 60 s. Both caches are bounded. | +| One signature | Exactly one `Signature-Input` member with `tag="web-bot-auth"` and exactly one `Signature-Agent` member, keyed to that member's label. A repeat of that member's label is refused with `403`. The verifier doesn't read members with other tags, nor their repeats, but every member must be well formed. | +| Request components | `@method`, `@target-uri`, `@authority`, and `"signature-agent";key=