From 3bcfb963bfa53a3c2fa598019dc8b052025620de Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 8 Nov 2026 16:16:56 +0100 Subject: [PATCH] Add a Web Bot Auth module for agent-to-agent HTTP signing Adds iab_agentic_primitives.web_bot_auth behind a [wba] extra. It signs and verifies agent requests and responses with HTTP Message Signatures (RFC 9421) under a Web Bot Auth profile: Ed25519 keys named by thumbprint, origin identity, key directories signed by every key they list, response binding to the request, directory discovery and caching, replay protection, an httpx client transport and ASGI middleware. It ships conformance vectors and an interop harness against Cloudflare's web-bot-auth library. Raises requires-python to 3.12. --- .../component-literals-only-in-rules.yml | 30 + ...rary-verify-checks-components-and-body.yml | 25 + .ast-grep/rules/no-absolute-self-import.yml | 21 + .../rules/no-cross-module-private-import.yml | 44 + .ast-grep/rules/no-host-derived-authority.yml | 17 + .../rules/refusal-minted-in-one-place.yml | 28 + .../rules/signature-fields-parsed-once.yml | 30 + .../verified-types-constructed-by-owner.yml | 31 + .github/workflows/ci.yml | 33 +- .gitignore | 4 + .importlinter | 160 +++ CHANGELOG.md | 22 + Makefile | 83 ++ README.md | 12 +- WEB_BOT_AUTH.md | 1074 +++++++++++++++ pyproject.toml | 71 +- ruff-wba-boundary.toml | 29 + ruff-wba.toml | 24 + sgconfig.yml | 7 + .../conformance/gap_report_render.py | 17 +- .../conformance/report.py | 14 +- .../conformance/runner.py | 23 +- .../conformance/vectors.py | 59 +- .../harness/assertions.py | 18 +- .../harness/reference_agents.py | 81 +- src/iab_agentic_primitives/harness/roles.py | 4 +- .../harness/scenario.py | 4 +- .../primitives/_util.py | 4 +- .../primitives/creative.py | 4 +- .../primitives/identity.py | 4 +- .../primitives/inventory.py | 4 +- .../primitives/lifecycle.py | 20 +- .../primitives/pricing.py | 12 +- .../primitives/supply_chain.py | 8 +- src/iab_agentic_primitives/protocol/a2a.py | 8 +- src/iab_agentic_primitives/protocol/avails.py | 66 +- .../protocol/catalog.py | 8 +- .../protocol/negotiation.py | 8 +- src/iab_agentic_primitives/py.typed | 0 src/iab_agentic_primitives/registry_client.py | 4 +- .../sandbox_registry/real_double.py | 29 +- .../web_bot_auth/__init__.py | 179 +++ .../web_bot_auth/_sig.py | 310 +++++ .../web_bot_auth/_unchecked.py | 67 + .../web_bot_auth/_wire.py | 402 ++++++ .../web_bot_auth/asgi.py | 641 +++++++++ .../web_bot_auth/conformance/PROTOCOL.md | 177 +++ .../web_bot_auth/conformance/__init__.py | 432 ++++++ .../web_bot_auth/conformance/__main__.py | 102 ++ .../web_bot_auth/conformance/adapter.py | 208 +++ .../web_bot_auth/conformance/fixtures.py | 211 +++ .../web_bot_auth/conformance/generate.py | 866 ++++++++++++ .../web_bot_auth/conformance/models.py | 857 ++++++++++++ .../web_bot_auth/conformance/scenarios.py | 696 ++++++++++ .../web_bot_auth/conformance/signing.py | 440 +++++++ .../web_bot_auth/directory.py | 601 +++++++++ .../web_bot_auth/errors.py | 183 +++ .../web_bot_auth/extra.py | 32 + .../web_bot_auth/fetcher.py | 180 +++ .../web_bot_auth/httpx.py | 422 ++++++ .../web_bot_auth/keyfile.py | 60 + .../web_bot_auth/keys.py | 190 +++ .../web_bot_auth/message.py | 192 +++ .../web_bot_auth/ports.py | 120 ++ .../web_bot_auth/presentation.py | 80 ++ .../web_bot_auth/replay.py | 95 ++ .../web_bot_auth/resolver.py | 436 ++++++ .../web_bot_auth/roles.py | 88 ++ .../web_bot_auth/rules.py | 419 ++++++ .../web_bot_auth/signer.py | 216 +++ .../web_bot_auth/sync.py | 206 +++ .../web_bot_auth/testing.py | 153 +++ .../web_bot_auth/values.py | 567 ++++++++ .../web_bot_auth/vectors/README.md | 181 +++ .../web_bot_auth/vectors/keys.json | 35 + .../vectors/negative/001-missing-nonce.json | 64 + .../vectors/negative/002-replayed-nonce.json | 84 ++ .../003-signature-agent-not-covered.json | 64 + .../004-key-not-in-named-directory.json | 76 ++ .../005-malformed-signature-agent.json | 48 + .../vectors/negative/006-two-signatures.json | 77 ++ .../negative/007-response-not-bound.json | 61 + ...key-not-in-named-directory-warm-cache.json | 89 ++ .../positive/001-single-signer-request.json | 49 + .../002-signed-directory-response.json | 32 + .../vectors/positive/003-signed-response.json | 59 + .../positive/004-origin-form-request.json | 49 + .../005-target-uri-array-brackets.json | 47 + .../vectors/positive/006-target-uri-pipe.json | 47 + .../positive/007-target-uri-braces.json | 47 + .../positive/008-target-uri-caret.json | 47 + .../009-target-uri-escaped-space.json | 47 + .../positive/010-target-uri-default-port.json | 47 + .../positive/011-target-uri-mixed-case.json | 47 + .../positive/012-target-uri-dot-segments.json | 47 + .../web_bot_auth/vectors/refusals.json | 173 +++ .../web_bot_auth/vectors/scenarios.json | 1163 +++++++++++++++++ .../web_bot_auth/verifier.py | 532 ++++++++ tests/web_bot_auth/__init__.py | 1 + tests/web_bot_auth/conftest.py | 41 + tests/web_bot_auth/interop/.gitignore | 1 + tests/web_bot_auth/interop/docs.mjs | 36 + tests/web_bot_auth/interop/judge.mjs | 423 ++++++ tests/web_bot_auth/interop/package-lock.json | 57 + tests/web_bot_auth/interop/package.json | 16 + tests/web_bot_auth/interop/policy.mjs | 333 +++++ tests/web_bot_auth/interop/run.mjs | 325 +++++ tests/web_bot_auth/support/__init__.py | 428 ++++++ tests/web_bot_auth/support/crafting.py | 289 ++++ tests/web_bot_auth/test_wba_asgi.py | 453 +++++++ tests/web_bot_auth/test_wba_client_answers.py | 466 +++++++ tests/web_bot_auth/test_wba_directory.py | 406 ++++++ tests/web_bot_auth/test_wba_docs.py | 193 +++ .../web_bot_auth/test_wba_http_integration.py | 997 ++++++++++++++ tests/web_bot_auth/test_wba_interop.py | 202 +++ tests/web_bot_auth/test_wba_keyfile.py | 98 ++ .../web_bot_auth/test_wba_library_contract.py | 146 +++ tests/web_bot_auth/test_wba_logging.py | 63 + tests/web_bot_auth/test_wba_message.py | 139 ++ .../web_bot_auth/test_wba_request_verifier.py | 858 ++++++++++++ tests/web_bot_auth/test_wba_resolver.py | 1130 ++++++++++++++++ tests/web_bot_auth/test_wba_response.py | 221 ++++ tests/web_bot_auth/test_wba_roles.py | 359 +++++ tests/web_bot_auth/test_wba_rules.py | 234 ++++ tests/web_bot_auth/test_wba_sync.py | 428 ++++++ tests/web_bot_auth/test_wba_target_uri.py | 206 +++ tests/web_bot_auth/test_wba_values.py | 622 +++++++++ tests/web_bot_auth/test_wba_vectors.py | 478 +++++++ tests/web_bot_auth/test_wba_wire.py | 331 +++++ uv.lock | 814 +++++++++++- 130 files changed, 25732 insertions(+), 246 deletions(-) create mode 100644 .ast-grep/rules/component-literals-only-in-rules.yml create mode 100644 .ast-grep/rules/library-verify-checks-components-and-body.yml create mode 100644 .ast-grep/rules/no-absolute-self-import.yml create mode 100644 .ast-grep/rules/no-cross-module-private-import.yml create mode 100644 .ast-grep/rules/no-host-derived-authority.yml create mode 100644 .ast-grep/rules/refusal-minted-in-one-place.yml create mode 100644 .ast-grep/rules/signature-fields-parsed-once.yml create mode 100644 .ast-grep/rules/verified-types-constructed-by-owner.yml create mode 100644 .importlinter create mode 100644 Makefile create mode 100644 WEB_BOT_AUTH.md create mode 100644 ruff-wba-boundary.toml create mode 100644 ruff-wba.toml create mode 100644 sgconfig.yml create mode 100644 src/iab_agentic_primitives/py.typed create mode 100644 src/iab_agentic_primitives/web_bot_auth/__init__.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/_sig.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/_unchecked.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/_wire.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/asgi.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/conformance/PROTOCOL.md create mode 100644 src/iab_agentic_primitives/web_bot_auth/conformance/__init__.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/conformance/__main__.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/conformance/adapter.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/conformance/fixtures.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/conformance/generate.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/conformance/models.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/conformance/scenarios.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/conformance/signing.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/directory.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/errors.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/extra.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/fetcher.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/httpx.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/keyfile.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/keys.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/message.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/ports.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/presentation.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/replay.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/resolver.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/roles.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/rules.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/signer.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/sync.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/testing.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/values.py create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/README.md create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/keys.json create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/negative/001-missing-nonce.json create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/negative/002-replayed-nonce.json create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/negative/003-signature-agent-not-covered.json create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/negative/004-key-not-in-named-directory.json create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/negative/005-malformed-signature-agent.json create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/negative/006-two-signatures.json create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/negative/007-response-not-bound.json create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/negative/008-key-not-in-named-directory-warm-cache.json create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/positive/001-single-signer-request.json create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/positive/002-signed-directory-response.json create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/positive/003-signed-response.json create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/positive/004-origin-form-request.json create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/positive/005-target-uri-array-brackets.json create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/positive/006-target-uri-pipe.json create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/positive/007-target-uri-braces.json create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/positive/008-target-uri-caret.json create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/positive/009-target-uri-escaped-space.json create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/positive/010-target-uri-default-port.json create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/positive/011-target-uri-mixed-case.json create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/positive/012-target-uri-dot-segments.json create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/refusals.json create mode 100644 src/iab_agentic_primitives/web_bot_auth/vectors/scenarios.json create mode 100644 src/iab_agentic_primitives/web_bot_auth/verifier.py create mode 100644 tests/web_bot_auth/__init__.py create mode 100644 tests/web_bot_auth/conftest.py create mode 100644 tests/web_bot_auth/interop/.gitignore create mode 100644 tests/web_bot_auth/interop/docs.mjs create mode 100644 tests/web_bot_auth/interop/judge.mjs create mode 100644 tests/web_bot_auth/interop/package-lock.json create mode 100644 tests/web_bot_auth/interop/package.json create mode 100644 tests/web_bot_auth/interop/policy.mjs create mode 100644 tests/web_bot_auth/interop/run.mjs create mode 100644 tests/web_bot_auth/support/__init__.py create mode 100644 tests/web_bot_auth/support/crafting.py create mode 100644 tests/web_bot_auth/test_wba_asgi.py create mode 100644 tests/web_bot_auth/test_wba_client_answers.py create mode 100644 tests/web_bot_auth/test_wba_directory.py create mode 100644 tests/web_bot_auth/test_wba_docs.py create mode 100644 tests/web_bot_auth/test_wba_http_integration.py create mode 100644 tests/web_bot_auth/test_wba_interop.py create mode 100644 tests/web_bot_auth/test_wba_keyfile.py create mode 100644 tests/web_bot_auth/test_wba_library_contract.py create mode 100644 tests/web_bot_auth/test_wba_logging.py create mode 100644 tests/web_bot_auth/test_wba_message.py create mode 100644 tests/web_bot_auth/test_wba_request_verifier.py create mode 100644 tests/web_bot_auth/test_wba_resolver.py create mode 100644 tests/web_bot_auth/test_wba_response.py create mode 100644 tests/web_bot_auth/test_wba_roles.py create mode 100644 tests/web_bot_auth/test_wba_rules.py create mode 100644 tests/web_bot_auth/test_wba_sync.py create mode 100644 tests/web_bot_auth/test_wba_target_uri.py create mode 100644 tests/web_bot_auth/test_wba_values.py create mode 100644 tests/web_bot_auth/test_wba_vectors.py create mode 100644 tests/web_bot_auth/test_wba_wire.py 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=