From 9aad42f704831afff767ca144540024c9fa29608 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 17 Oct 2026 00:29:41 +0000 Subject: [PATCH 1/2] refactor(signing): RFC 9421 on http-message-signatures; align Web Bot Auth with adcp#7894 Moves every RFC 9421 layer onto http-message-signatures 2.0.1 and RFC 8941 onto http-sf, replacing the archived http-sfv: - adcp.signing._rfc9421 holds the component resolver that supplies the ;key and ;req parameters (pyauth/http-message-signatures#29) and the AdCP @target-uri/@authority canonicalization, plus thin sign/verify wrappers over HTTPMessageSigner and HTTPMessageVerifier. - The 3.2 request and webhook profiles verify at step 10 through HTTPMessageVerifier and sign through HTTPMessageSigner.sign; the async SigningProvider path takes the library's own base. Error codes and step order are unchanged; every conformance vector passes. - crypto.py dispatch uses the library algorithm classes; Signature, Signature-Input and Content-Digest parse with http-sf, which refuses duplicate keys and parameters through on_duplicate_key. - Governance JWS signs and verifies with joserfc; publisher-pin thumbprints use joserfc; JWS splitting uses joserfc extract_compact. Aligns the experimental Web Bot Auth profile with the final adcontextprotocol/adcp#7894 at e24eeb09: one signature and one Signature-Agent member per request, signed directories only, ed25519 and rsa-pss-sha512 only, a 64-byte nonce, and 429 with Accept-Signature carrying a fresh nonce for a replay. Re-vendors the wba-profile vectors, governance vectors included, with provenance. Refs #1511 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 29 + docs/agent-identity-3-3-migration.md | 41 +- pyproject.toml | 26 +- src/adcp/governance.py | 110 ++- src/adcp/server/signed_requests.py | 11 +- src/adcp/signing/_header_precheck.py | 74 +- src/adcp/signing/_rfc9421.py | 653 ++++++++++++ src/adcp/signing/_structured_fields.py | 177 ---- src/adcp/signing/autosign.py | 6 + src/adcp/signing/canonical.py | 209 ++-- src/adcp/signing/crypto.py | 129 +-- src/adcp/signing/digest.py | 60 +- src/adcp/signing/jws.py | 50 +- src/adcp/signing/publisher_pins.py | 40 +- src/adcp/signing/signer.py | 231 ++--- src/adcp/signing/verifier.py | 73 +- src/adcp/signing/wba.py | 931 ++++++++++-------- src/adcp/signing/webhook_verifier.py | 24 +- tests/conformance/signing/test_crypto.py | 2 +- tests/conformance/signing/test_jws.py | 3 +- .../signing/test_parse_edge_cases.py | 7 +- .../test_revocation_checker_boundary.py | 11 +- .../signing/test_rfc9421_library_contract.py | 167 ++++ .../signing/test_signing_provider.py | 10 +- .../test_structured_field_duplicates.py | 2 +- tests/conformance/signing/test_wba_interop.py | 51 +- tests/conformance/signing/test_wba_profile.py | 354 +++++-- .../signing/test_webhook_rc4_vectors.py | 2 +- .../conformance/vectors/wba-profile/README.md | 15 +- .../vectors/wba-profile/UPSTREAM-README.md | 154 +++ .../vectors/wba-profile/UPSTREAM.json | 25 + .../governance/001-governance-token.json | 46 + ...nce-token-key-not-in-issuer-directory.json | 47 + ...nance-token-from-non-governance-agent.json | 47 + .../conformance/vectors/wba-profile/keys.json | 10 - .../negative/001-missing-nonce.json | 3 +- .../negative/002-replayed-nonce.json | 5 +- .../003-signature-agent-not-covered.json | 3 +- ...on => 004-key-not-in-named-directory.json} | 3 +- .../004-relay-omits-inner-signature.json | 52 - ...son => 005-malformed-signature-agent.json} | 0 .../005-relay-inner-signature-altered.json | 52 - .../negative/006-two-signatures.json | 53 + .../002-relay-request-two-signatures.json | 42 - ...son => 002-signed-directory-response.json} | 0 tests/test_identity_3_3_alignment.py | 157 ++- 46 files changed, 2693 insertions(+), 1504 deletions(-) create mode 100644 src/adcp/signing/_rfc9421.py delete mode 100644 src/adcp/signing/_structured_fields.py create mode 100644 tests/conformance/signing/test_rfc9421_library_contract.py create mode 100644 tests/conformance/vectors/wba-profile/UPSTREAM-README.md create mode 100644 tests/conformance/vectors/wba-profile/UPSTREAM.json create mode 100644 tests/conformance/vectors/wba-profile/governance/001-governance-token.json create mode 100644 tests/conformance/vectors/wba-profile/governance/002-governance-token-key-not-in-issuer-directory.json create mode 100644 tests/conformance/vectors/wba-profile/governance/003-governance-token-from-non-governance-agent.json rename tests/conformance/vectors/wba-profile/negative/{006-key-not-in-named-directory.json => 004-key-not-in-named-directory.json} (91%) delete mode 100644 tests/conformance/vectors/wba-profile/negative/004-relay-omits-inner-signature.json rename tests/conformance/vectors/wba-profile/negative/{007-malformed-signature-agent.json => 005-malformed-signature-agent.json} (100%) delete mode 100644 tests/conformance/vectors/wba-profile/negative/005-relay-inner-signature-altered.json create mode 100644 tests/conformance/vectors/wba-profile/negative/006-two-signatures.json delete mode 100644 tests/conformance/vectors/wba-profile/positive/002-relay-request-two-signatures.json rename tests/conformance/vectors/wba-profile/positive/{003-signed-directory-response.json => 002-signed-directory-response.json} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17a8bf90d..fc5b6d2e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,35 @@ streaming budget, so this is its only response-size bound. Refs #1503, adcontextprotocol/adcp#8190, adcontextprotocol/adcp#8200. +* **signing:** RFC 9421 comes from maintained libraries for every profile. + `http-message-signatures` builds the signature base, assembles + `Signature-Input`, and signs and verifies for the AdCP RFC 9421 request and + webhook profiles and for Web Bot Auth, through an SDK component resolver + that adds the `;key` and `;req` parameters and the AdCP `@target-uri` / + `@authority` canonicalization. `http-sf` parses Structured Fields and + refuses duplicate keys and parameters; governance tokens and publisher-pin + thumbprints use `joserfc`. **Dependencies:** the archived `http-sfv` is + replaced by `http-message-signatures>=2.0.1,<3` and `http-sf>=1.4.0,<2`. + Every request-signing and webhook conformance vector passes with the same + error codes. Wire notes: the signer now emits signature parameters in the + library's order (`created`, `keyid`, `alg`, `expires`, `nonce`, `tag`), + which verifiers read from the header; `@signature-params` is the RFC 8941 + serialization of the received member; `Signature-Input`, `Signature` and + `Content-Digest` must parse as a whole; and `parse_compact_jws` requires a + JSON protected header with `alg`. Refs #1511. + +* **identity:** The experimental Web Bot Auth profile follows the final + adcontextprotocol/adcp#7894 (`e24eeb09`): one `web-bot-auth` signature + and one `Signature-Agent` member per request (relays are refused), signed + key directories only, `ed25519` and `rsa-pss-sha512` only (ES256 stays on + the RFC 9421 profile), a 64-byte nonce on every request, and a reused nonce + answered 429 with `Accept-Signature` carrying a fresh nonce. + `verify_wba_governance_issuer` runs the profile's governance-token key and + issuer checks. The removed options and other changes to this experimental + surface are listed in + [the migration guide](docs/agent-identity-3-3-migration.md#breaking-changes-to-the-experimental-api). + Refs #1511. + * **auth:** `BearerTokenAuth.resolve_principal` supports sync/async non-bearer principal resolution on MCP and A2A using the same metadata-only `AuthRequest`. Supplied credential headers always select bearer auth; invalid credentials diff --git a/docs/agent-identity-3-3-migration.md b/docs/agent-identity-3-3-migration.md index e85c64d5c..b92c1a4c3 100644 --- a/docs/agent-identity-3-3-migration.md +++ b/docs/agent-identity-3-3-migration.md @@ -1,9 +1,11 @@ # Experimental AdCP 3.3 agent identity -This SDK supports an opt-in preview of issue [#1500](https://github.com/adcontextprotocol/adcp-client-python/issues/1500). The Web Bot Auth wire profile and identity schemas are pinned to the draft commits recorded in `tests/conformance/vectors/wba-profile/README.md` and `src/adcp/signing/_identity_schemas/UPSTREAM.json`. They may change before the protocol release. The stable wire pin and `COMPATIBLE_ADCP_VERSIONS` do not advertise 3.3 yet. +This SDK supports an opt-in preview of issue [#1500](https://github.com/adcontextprotocol/adcp-client-python/issues/1500). The Web Bot Auth wire profile follows `wba-profile.mdx` in adcontextprotocol/adcp#7894 at `e24eeb09` (vectors in `tests/conformance/vectors/wba-profile/`, provenance in its `UPSTREAM.json`); the identity schemas are pinned to the commits in `src/adcp/signing/_identity_schemas/UPSTREAM.json`. Both may change before the protocol release. The stable wire pin and `COMPATIBLE_ADCP_VERSIONS` do not advertise 3.3 yet. The behavior below follows the final spec decisions (adcontextprotocol/adcp#8162, #8168, #8169, #8170) and matches the Go (`adcp-go`) and TypeScript (`@adcp/sdk/identity-3.3`) SDKs, except where [Differences from the other SDKs](#differences-from-the-other-sdks) says otherwise. +RFC 9421 comes from `http-message-signatures` (signature bases, `Signature-Input`, signing and verification, for this profile and for the AdCP RFC 9421 profile alike), Structured Fields from `http-sf`, and JOSE from `joserfc`. The SDK keeps the profile rules on top. + ## Buyer signing and publishing Give each agent its own HTTPS origin. Generate a key using the existing keygen helpers, then derive its WBA public JWK and thumbprint: @@ -23,13 +25,13 @@ signing = SigningConfig( # Pass signing=signing to ADCPClient or install_signing_event_hook. ``` -`agent_origin` explicitly selects Web Bot Auth, sets and covers the structured `Signature-Agent` member, uses `tag="web-bot-auth"`, and uses standard Base64 signatures regardless of the legacy wire encoding. The signer derives the algorithm from the public key, generates a 64-byte random nonce (unpadded base64url; RECOMMENDED by the architecture draft §4.2.2 and required by Cloudflare's `web-bot-auth` verifier). A caller-supplied `nonce` may be any 22 to 256 characters, the range verifiers accept, and binds every nonempty body with its digest. `@target-uri` covers the URI the request is sent to, unchanged: percent-encoding such as `/a%7Eb/mcp/` is signed as sent (RFC 9421 §2.2.2). The canonical origin is used only for the `Signature-Agent` identity. A request that already carries signature fields is refused rather than stripped; the signer never drops another party's signature. +`agent_origin` explicitly selects Web Bot Auth, sets and covers the structured `Signature-Agent` member, uses `tag="web-bot-auth"`, and uses standard Base64 signatures regardless of the legacy wire encoding. The signer derives the algorithm from the public key, generates a 64-byte random nonce (unpadded base64url), as the profile requires on every signed request, and binds every nonempty body with its digest. A caller-supplied `nonce` must also encode exactly 64 bytes. `@target-uri` covers the URI the request is sent to, unchanged: percent-encoding such as `/a%7Eb/mcp/` is signed as sent (RFC 9421 §2.2.2). The canonical origin is used only for the `Signature-Agent` identity. A request that already carries signature fields is refused rather than stripped; the signer never drops another party's signature. Automatic signing also requires `web-bot-auth` in the seller's `request_signing.profiles`; omission advertises legacy support only. An unsupported configured profile raises before sending and never silently downgrades. Keep header emission opt-in until all counterparties tolerate the additional covered component. -Ed25519 and P-256 keys work everywhere. `sign_wba_request`, `public_directory_key` and `sign_key_directory` also accept RSA keys of 2048 to 8192 bits, which sign with `rsa-pss-sha512` as in the draft's Appendix E vectors. +Web Bot Auth signs with `ed25519` or `rsa-pss-sha512`, the profile's two algorithms. `sign_wba_request`, `public_directory_key` and `sign_key_directory` accept Ed25519 keys and RSA keys of 2048 to 8192 bits (signed with `rsa-pss-sha512`, as in the draft's Appendix E vectors). ES256 (P-256) stays on the AdCP RFC 9421 profile: a P-256 key is refused for Web Bot Auth, and a P-256 key in someone's directory is skipped. -Serve the key directory at `/.well-known/http-message-signatures-directory` on that origin. Signing it is RECOMMENDED (WBA Appendix B.1): +Serve the key directory at `/.well-known/http-message-signatures-directory` on that origin. The profile requires it signed (WBA Appendix B.1); verifiers refuse an unsigned directory: ```python response = sign_key_directory( @@ -81,7 +83,7 @@ Verification and authorization are separate layers, as in the other SDKs. ### Errors are split by layer -- **The signature path** answers only with Web Bot Auth statuses and headers, never 503 (DR-0023): 400 when a signature field does not parse, 403 with `Accept-Signature` for a missing or failed signature or an unresolvable key directory, 403 without `Accept-Signature` for a revoked key, and 429 for a reused nonce and for replay-store or discovery capacity. `RequestSignatureVerification(wba_replay_status=403)` (or `verify_wba_request(replay_status=403)`) only reproduces #7894's older replay vector. Any body is the one generic authentication error, the same for every cause: `{"errors":[{"code":"AUTH_INVALID","message":"Request authentication failed","recovery":"correctable"}]}`. The specific reason is logged, never sent. +- **The signature path** answers only with Web Bot Auth statuses and headers, never 503 (DR-0023): 400 when a signature field does not parse; 403 with `Accept-Signature` for a missing or failed signature, a second signature or `Signature-Agent` member, a nonce that is not 64 bytes, or an unresolvable key directory; 403 without `Accept-Signature` for a revoked key; 429 with `Accept-Signature` carrying a fresh nonce for a reused nonce; and 429 without it for replay-store or discovery capacity. `Accept-Signature` names the profile's components and the parameters `created`, `expires`, `nonce`, `keyid`, `alg` and `tag="web-bot-auth"`. Any body is the one generic authentication error, the same for every cause: `{"errors":[{"code":"AUTH_INVALID","message":"Request authentication failed","recovery":"correctable"}]}`. The specific reason is logged, never sent. - **Authorization outcomes** after verification use the standard AdCP error envelope (the MCP and A2A error bindings for skills; otherwise JSON with HTTP 403 or 503 and `Retry-After`) with existing codes: - an operation outside what an unbound caller may do gets what an anonymous caller would get; - an operation in `required_for` from an unbound or delisted origin, or a failed binding, gets `PERMISSION_DENIED` with no `details`; @@ -89,7 +91,7 @@ Verification and authorization are separate layers, as in the other SDKs. - a trust-record or revocation-list outage on a spend-committing call gets the retryable `SERVICE_UNAVAILABLE`. - **Infrastructure outages** (the replay store or revocation store failing) are application-layer `SERVICE_UNAVAILABLE`, never a signature outcome. -A correctly signed request whose key the verifier can resolve never gets 403 with `Accept-Signature` (an unresolvable key directory leaves the request unverified under the default policy). Clients act on the status and `Accept-Signature`, not the generic body: sign again on 403 with `Accept-Signature`, stop using the key on 403 without it, back off on 429 or 503. +A correctly signed request whose key the verifier can resolve never gets 403 with `Accept-Signature` (an unresolvable key directory leaves the request unverified under the default policy). Clients act on the status and `Accept-Signature`, not the generic body: sign again on 403 with `Accept-Signature`, sign again with the offered nonce on 429 with `Accept-Signature`, stop using the key on 403 without it, back off on 429 without it or 503. **Unsigned requests.** With Web Bot Auth enabled, an unsigned request to an operation in `required_for` is answered as the RFC 9421 profile does (401 with `WWW-Authenticate: Signature error="request_signature_required"`) plus an `Accept-Signature` header for Web Bot Auth, so a client of either profile learns how to sign. Set `rfc9421_profile=False` when the endpoint advertises only `web-bot-auth`: such a request, and any RFC 9421 signature, then gets 403 with `Accept-Signature`. `RequestSignatureVerification.from_capability` sets both from `request_signing.profiles` when it lists `web-bot-auth`. With `allow_bearer_fallback`, an unsigned request is passed to bearer authentication as before. @@ -97,7 +99,8 @@ Duplicate structured-field parameters, dictionary labels, and `Signature-Agent` ### Interop -- The verifier accepts any nonce of 22 to 256 printable characters (at least 128 bits in base64): 64 bytes is RECOMMENDED for signers, and httpsig-02 adds no nonce requirement, so a compliant signer may send fewer. This SDK's signer always sends 64 bytes, which Cloudflare's verifier requires. +- **One signature.** A request carries exactly one `web-bot-auth` signature and exactly one `Signature-Agent` member, keyed to that signature's label. Relays are gone: an intermediary that signs is the signer, and whether it may act for another party is an authorization question, not a second signature. `verify_wba_request` still returns a tuple, now always of one signer. +- **Nonce.** The nonce encodes exactly 64 bytes. The profile spells it as unpadded base64url; padded standard base64 of 64 bytes is also accepted, because Cloudflare's `web-bot-auth` signer and the draft's Appendix E vectors send it. The page has no shorter-nonce exception for `required_for` operations or reads. - `@target-uri` is the target URI as received, built from the request line (`raw_path` and query string), so percent-encoding is preserved. `@authority` is that URI's authority, lowercased with any default port removed (RFC 9421 §2.2.3). - RSA keys verify with `rsa-pss-sha512` (2048 to 8192 bits, odd exponent). - `tests/conformance/vectors/wba-interop/` vendors adcp-client's Cloudflare interop fixtures with provenance; the SDK verifies both the Cloudflare-signed and the adcp-client-signed requests and directories. @@ -106,8 +109,8 @@ Duplicate structured-field parameters, dictionary labels, and `Signature-Agent` `DiscoveredSignerKeys` resolves `(covered origin, thumbprint)`. It never calls capabilities or reads brand.json or trust.json. -- A plain, unsigned JWK Set is accepted by default (`application/http-message-signatures-directory+json`, `application/jwk-set+json` or `application/json`). Keys are found by their RFC 7638 thumbprint; a `kid` is optional and does not affect lookup. Keys the profile cannot use are skipped; private key material fails the directory. `DiscoveredSignerKeys(require_signed_directories=True)` refuses unsigned directories. -- When a directory carries signatures, every one must verify, cover at least the exact identifiers `"@authority";req` and `"content-digest"` (a keyed member or a bare `"@authority"` does not count), and fall inside its window (60 seconds of skew on `created`, none on `expires`). The digest covers the exact bytes served, a UTF-8 BOM included. Only keys with a verified signature are usable; an unsigned key beside them is ignored. +- A directory must be signed (WBA Appendix B.1); an unsigned one is a discovery failure. The media type may be `application/http-message-signatures-directory+json`, `application/jwk-set+json` or `application/json`. Keys are found by their RFC 7638 thumbprint; a `kid` is optional and does not affect lookup. Keys the profile cannot use (P-256 included) are skipped; private key material fails the directory. +- Every directory signature must verify, cover at least the exact identifiers `"@authority";req` and `"content-digest"` (a keyed member or a bare `"@authority"` does not count), and fall inside its window (60 seconds of skew on `created`, none on `expires`). The digest covers the exact bytes served, a UTF-8 BOM included. Only keys with a verified signature are usable; an unsigned key beside them is ignored. - **Discovery failures** (WBA §6.10) cover a directory that does not resolve: a network or TLS error, a timeout, any status other than 200 (404, 410, 429 and 5xx included), or an invalid directory. A discovery failure never evicts a cached directory: its keys keep verifying for up to `max_stale_seconds` (24 hours by default) after the last successful fetch, never past the directory signatures' expiry. A key missing from such a stale copy is a discovery failure too. Failures are retried after a 60-second cooldown. - With no usable cached copy, the request is unverified, and what happens is local policy: `RequestSignatureVerification(wba_discovery_failure=...)`. The default `"reject"` answers 403 with `Accept-Signature`. `"anonymous"` serves the request exactly as an unsigned one, except operations in `required_for` or `protocol_methods_required_for`. - A directory that resolves without the key is evidence that the key was removed: a signature failure. @@ -231,11 +234,13 @@ Use `ADCPClient(..., experimental_agent_identity=True)` or the equivalent multi- Onboarding posture is advisory. Sellers still implement requirements, applications and billing policy. Evaluate the verified origin's trust record and required role with `records.check_listing(...)` when applying an `open` policy, before provisioning access. -AdCP 3.3 deprecates the RFC 9421 profile; it keeps working through 3.x. `SigningConfig` and `RequestSignatureVerification` emit a `DeprecationWarning` for it only once the SDK advertises 3.3 (`COMPATIBLE_ADCP_VERSIONS`), so legacy constructions stay quiet while 3.3 is not served. `async_resolve_agent`, `verify_from_agent_url`, BrandJsonJwksResolver and governance/rights/document JWS verification retain their existing chain until AdCP 3.4. Roll back opt-in signing only for origins that have not been bound; rolling a bound origin back to RFC 9421 requires explicit seller migration. +AdCP 3.3 deprecates the RFC 9421 profile; it keeps working through 3.x. `SigningConfig` and `RequestSignatureVerification` emit a `DeprecationWarning` for it only once the SDK advertises 3.3 (`COMPATIBLE_ADCP_VERSIONS`), so legacy constructions stay quiet while 3.3 is not served. `async_resolve_agent`, `verify_from_agent_url`, BrandJsonJwksResolver and governance/rights/document JWS verification retain their existing chain until AdCP 3.4. + +**Governance tokens under Web Bot Auth.** `adcp.signing.wba.verify_wba_governance_issuer(token, signer_keys=..., governance_agents=...)` runs the profile's two governance checks (One agent per purpose): the token's `kid` is in the key directory of the `iss` origin and `jku` is that directory's URL (`governance_key_unknown`), and `iss` is the buyer's governance agent (`governance_issuer_not_authorized`), with the JWS, signature and time checks around them. It returns the issuer origin. The rest of the governance checklist (`aud`, `phase`, `sub`, the action binding, revocation, replay) stays with the caller. The `governance/` vectors grade it. Roll back opt-in signing only for origins that have not been bound; rolling a bound origin back to RFC 9421 requires explicit seller migration. ## Differences from the other SDKs -- **Nonce acceptance.** The verifier accepts 22 to 256 characters (a 128-bit floor). TypeScript keeps a 16-byte floor; Go currently requires exactly 64 bytes and is being changed to accept a range. +- **Nonce encodings.** Exactly 64 bytes, as #7894 requires; Python also accepts the padded standard-base64 spelling Cloudflare's signer emits. Go and TypeScript are aligning to #7894 at the same commit. - **Signed directories with an unsigned key.** Only keys with a verified signature are usable (draft directory-05). Go accepts every key in a signed directory; TypeScript refuses the directory. - **Discovery capacity.** A local discovery-limit refusal is a 429, as in TypeScript. Go treats it as a discovery failure. - **Directory created skew.** Directory signatures tolerate 60 seconds of `created` skew, as in TypeScript; Go allows none. All SDKs backdate `created` when publishing. @@ -246,7 +251,17 @@ AdCP 3.3 deprecates the RFC 9421 profile; it keeps working through 3.x. `Signing ## Breaking changes to the experimental API -The experimental identity surface changed incompatibly with the previous preview: +The experimental identity surface changed incompatibly with the previous preview. Aligning with the final #7894 profile: + +- `verify_wba_request(replay_status=)` and `RequestSignatureVerification(wba_replay_status=)` are gone: a reused nonce is always 429 with `Accept-Signature` carrying a fresh nonce. +- `DiscoveredSignerKeys(require_signed_directories=)` and `verify_directory_response(require_signed=)` are gone: directories must always be signed. +- Relay signatures are refused (403); a request carries one `web-bot-auth` signature and one `Signature-Agent` member. +- The verifier requires a 64-byte nonce (it accepted 22 to 256 characters), and `sign_wba_request(nonce=)` refuses anything else. +- P-256 keys are refused for Web Bot Auth (`public_directory_key`, `sign_wba_request`, `sign_key_directory`, and `SigningConfig`/`sign_request` with `agent_origin`); `ecdsa-p256-sha256` stays on the RFC 9421 profile. +- `accept_signature_value()` names `alg` and, on a 403, a bare `nonce` parameter; a replay 429 passes a fresh `nonce`. `WbaVerificationError.headers` is computed from its status and label, and the `fresh_nonce=` argument marks a replay answer. +- `adcp.signing.wba` adds `verify_wba_governance_issuer`, `WbaGovernanceError`, `WBA_ALGORITHMS` and `WBA_GOVERNANCE_TYP`. + +From the earlier alignment: - `WbaUnavailableError` is an application-layer infrastructure error (`RuntimeError`), no longer a `WbaVerificationError` with `status`/`headers`. Discovery failures are `WbaDiscoveryError`; a revoked key is `WbaRevokedKeyError` (`WbaAuthorizationError` remains as an alias). - `verify_wba_request(revocation_checker=...)` is optional; `OriginRevocationChecker.status()` replaces the boolean call, which now raises for undecidable states. @@ -262,6 +277,6 @@ The experimental identity surface changed incompatibly with the previous preview ## Not covered yet - The final 3.3 schema bundle and the P7 trust-record and grant-chain conformance vectors are upstream release dependencies; re-pin and rerun conformance when they land. -- Konstantin Mirin's follow-ups in #1511 that remain open: building the profile on the `http-message-signatures` library (and moving from `http-sfv` to `http-sf`), which would also retire the WBA signature-base builder and the strict structured-field pre-scan, and accepting the legacy sf-string `Signature-Agent`. +- Accepting the legacy sf-string `Signature-Agent` (a #1511 follow-up). - Durable stores: bindings and replay state are pluggable; authority history and revocation history are in memory with no pluggable store yet (Go and TypeScript have one). Public deployments need durable versions of all four. - The directory cache is evictable by unauthenticated traffic naming many origins (as in Go); eviction only forces a refetch, but a signer whose directory is down at that moment loses its stale-copy continuation. diff --git a/pyproject.toml b/pyproject.toml index c230e2eca..18eb304bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -138,7 +138,20 @@ dependencies = [ # httpx2 2.5.0 requires idna>=3.18. Upper bound at <4 because IDNA # semantics across a major are not contracted. "idna>=3.18,<4", - "http-sfv>=0.9.9,<1.0", + # RFC 9421 HTTP Message Signatures for every signing profile + # (``adcp.signing._rfc9421``): signature bases, ``Signature-Input`` + # assembly, and the ed25519 / ecdsa-p256-sha256 / rsa-pss-sha512 + # algorithms. The SDK supplies a ``component_resolver_class`` and reaches + # two protected subclass hooks (``_build_signature_base``, + # ``_parse_covered_component_ids``); the upper bound and + # tests/conformance/signing/test_rfc9421_library_contract.py guard them. + # Apache-2.0, depends only on cryptography. + "http-message-signatures>=2.0.1,<3", + # RFC 8941 / RFC 9651 Structured Fields (successor to the archived + # http-sfv). 1.4.0 is the tested floor; ``on_duplicate_key`` lets the SDK + # refuse a duplicate dictionary key or parameter instead of keeping the + # last. MIT. + "http-sf>=1.4.0,<2", ] [project.scripts] @@ -377,6 +390,17 @@ ignore_errors = true module = ["psycopg", "psycopg.*", "psycopg_pool", "psycopg_pool.*"] ignore_missing_imports = true +# http-message-signatures ships py.typed but re-exports its public classes +# implicitly and leaves some helpers unannotated. adcp.signing._rfc9421 is the +# one module that calls it. +[[tool.mypy.overrides]] +module = ["http_message_signatures", "http_message_signatures.*"] +implicit_reexport = true + +[[tool.mypy.overrides]] +module = "adcp.signing._rfc9421" +disallow_untyped_calls = false + # jsonschema doesn't ship PEP 561 stubs in the default package; the # ``types-jsonschema`` stub package is optional and we use # :class:`Any`-typed returns across the validator boundary anyway. diff --git a/src/adcp/governance.py b/src/adcp/governance.py index ba38f9b56..1cb417c6a 100644 --- a/src/adcp/governance.py +++ b/src/adcp/governance.py @@ -25,22 +25,20 @@ from urllib.parse import urlsplit import rfc8785 +from joserfc import jws +from joserfc.errors import JoseError +from joserfc.jwk import ECKey, OKPKey +from joserfc.jws import JWSRegistry +from joserfc.registry import HeaderParameter, is_bool from pydantic import BaseModel from adcp._version import resolve_adcp_version from adcp.signing.brand_jwks import BrandJsonResolverError, _pick_agent from adcp.signing.canonical import canonicalize_target_uri -from adcp.signing.crypto import ( - ALG_ED25519, - ALG_ES256, - b64url_decode, - b64url_encode, - public_key_from_jwk, - sign_signature_base, - verify_signature, -) +from adcp.signing.crypto import b64url_decode, b64url_encode from adcp.signing.etld import host_from from adcp.signing.jwks import AsyncCachingJwksResolver, AsyncJwksResolver, JwksResolver +from adcp.signing.jws import _AdcpJwsRegistry, _import_jwk from adcp.signing.replay import ReplayStore, supports_atomic_claim from adcp.types import CheckGovernanceRequest, ReportPlanOutcomeRequest @@ -943,8 +941,8 @@ def read_governance_authorization_issuer(token: object) -> str | None: """ if not isinstance(token, str) or not token or len(token) > GOVERNANCE_MAX_TOKEN_BYTES: return None - parts = token.split(".") - if len(parts) != 3 or any(_COMPACT_SEGMENT_RE.fullmatch(part) is None for part in parts): + parts = _compact_segments(token) + if parts is None: return None try: claims = _decode_json_segment(parts[1]) @@ -1088,14 +1086,18 @@ def issue_governance_authorization( "crit": list(GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS), **{name: True for name in GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS}, } - encoded_header = b64url_encode(_compact_json(header)) - encoded_claims = b64url_encode(_compact_json(claims)) - signing_input = f"{encoded_header}.{encoded_claims}".encode("ascii") - internal_alg = ALG_ED25519 if alg == "EdDSA" else ALG_ES256 - signature = sign_signature_base( - alg=internal_alg, private_key=private_key, signature_base=signing_input - ) - return f"{encoded_header}.{encoded_claims}.{b64url_encode(signature)}" + try: + key = _jose_key(alg, private_key) + # Sorted header members and the canonical claims bytes keep the token + # byte-identical to the profile's compact JSON; joserfc signs it. + return jws.serialize_compact( + dict(sorted(header.items())), + _compact_json(claims), + key, + registry=_governance_jws_registry(alg), + ) + except JoseError as exc: + raise ValueError(f"governance signing key cannot sign {alg}: {exc}") from exc async def verify_governance_authorization( @@ -1133,8 +1135,8 @@ def reject( return reject("governance_token_invalid", str(exc)) if not isinstance(token, str) or not token or len(token) > GOVERNANCE_MAX_TOKEN_BYTES: return reject("governance_token_invalid", "governance_context is missing or too large") - parts = token.split(".") - if len(parts) != 3 or any(_COMPACT_SEGMENT_RE.fullmatch(part) is None for part in parts): + parts = _compact_segments(token) + if parts is None: return reject("governance_token_invalid", "governance_context is not a compact JWS") try: header = _decode_json_segment(parts[0]) @@ -1197,13 +1199,13 @@ def reject( ): return reject("governance_key_unknown", "governance signing key is not authorized") try: - verified = verify_signature( - alg=ALG_ED25519 if alg == "EdDSA" else ALG_ES256, - public_key=public_key_from_jwk(dict(jwk)), - signature_base=f"{parts[0]}.{parts[1]}".encode("ascii"), - signature=signature, + jws.deserialize_compact( + token, + _import_jwk(_public_jwk(jwk)), + registry=_governance_jws_registry(alg), ) - except (TypeError, ValueError): + verified = True + except (JoseError, TypeError, ValueError): verified = False if not verified: return reject("governance_token_invalid", "governance token signature verification failed") @@ -1441,6 +1443,60 @@ def _finite_number(value: object) -> bool: ) +def _governance_jws_registry(alg: str) -> JWSRegistry: + """The AdCP JWS registry (``jws._AdcpJwsRegistry``) for one governance ``alg``. + + The critical claim markers are registered as boolean protected-header + parameters, so joserfc's ``crit`` check accepts exactly those. + """ + return _AdcpJwsRegistry( + header_registry={ + name: HeaderParameter(f"AdCP critical claim marker: {name}", is_bool) + for name in GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS + }, + algorithms=[alg], + strict_check_header=False, + ) + + +def _jose_key(alg: str, key: Any) -> OKPKey | ECKey: + """Import a cryptography private key object for ``alg`` into joserfc. + + Public keys go through ``jws._import_jwk``, which also enforces the + RFC 7518 P-256 coordinate length joserfc leaves open. + """ + if alg == "EdDSA": + return OKPKey.import_key(key) + if alg == "ES256": + return ECKey.import_key(key) + raise ValueError(f"unsupported governance alg {alg!r}") + + +def _public_jwk(jwk: Mapping[str, Any]) -> dict[str, Any]: + """The key members of a resolved JWK; its policy members were checked already.""" + return {name: jwk[name] for name in ("kty", "crv", "x", "y") if name in jwk} + + +def _compact_segments(token: str) -> tuple[str, str, str] | None: + """Split a compact JWS with ``joserfc.jws.extract_compact``, or ``None``.""" + if token.count(".") != 2 or any( + _COMPACT_SEGMENT_RE.fullmatch(part) is None for part in token.split(".") + ): + return None + try: + compact = jws.extract_compact( + token.encode("ascii"), registry=_AdcpJwsRegistry(strict_check_header=False) + ) + except (JoseError, ValueError): + return None + segments = compact.segments + return ( + segments["header"].decode("ascii"), + segments["payload"].decode("ascii"), + segments["signature"].decode("ascii"), + ) + + def _compact_json(value: object) -> bytes: return json.dumps( value, diff --git a/src/adcp/server/signed_requests.py b/src/adcp/server/signed_requests.py index cfb5c11e8..260037ade 100644 --- a/src/adcp/server/signed_requests.py +++ b/src/adcp/server/signed_requests.py @@ -321,8 +321,6 @@ class RequestSignatureVerification: with ``Accept-Signature``; ``"anonymous"`` serves it exactly as an unsigned request, except operations in ``required_for`` / ``protocol_methods_required_for``, which are still refused. - :param wba_replay_status: Status for a reused Web Bot Auth nonce: 429, as - the spec requires; 403 only reproduces #7894's older replay vector. :param authorities: The ``host[:port]`` values this endpoint serves. A Web Bot Auth request whose ``@authority`` is not among them is refused before key discovery, so a signature captured at another seller @@ -359,15 +357,12 @@ class RequestSignatureVerification: default=None, init=False, repr=False, compare=False ) wba_discovery_failure: Literal["reject", "anonymous"] = "reject" - wba_replay_status: Literal[403, 429] = 429 authorities: frozenset[str] | None = None rfc9421_profile: bool = True def __post_init__(self) -> None: if self.wba_discovery_failure not in {"reject", "anonymous"}: raise ValueError("wba_discovery_failure must be 'reject' or 'anonymous'") - if self.wba_replay_status not in {403, 429}: - raise ValueError("wba_replay_status must be 403 or 429") if not self.rfc9421_profile and not self.enable_web_bot_auth: raise ValueError("a verifier needs at least one signing profile") if self.enable_web_bot_auth and self.replay_store is None: @@ -827,7 +822,6 @@ async def _serve_wba( signer_keys=cast(WbaSignerKeyResolver, config.signer_keys), replay_store=config.replay_store, raw_headers=scope.get("headers"), - replay_status=config.wba_replay_status, authorities=config.authorities, ) except WbaUnavailableError as exc: @@ -873,9 +867,8 @@ async def _serve_verified_wba( except WbaRevokedKeyError as exc: await self._reject_wba(scope, receive, send, exc) return - # The originating signer owns the AdCP caller; relays are verified - # independently and do not acquire the originator's account access. - signer = signers[0] + # The profile verifies exactly one signer per request. + (signer,) = signers principal, authorization_error = await self._authorize_wba_binding(signer, operation) if authorization_error is not None: await self._call_with_signer( diff --git a/src/adcp/signing/_header_precheck.py b/src/adcp/signing/_header_precheck.py index 819b4d677..ac45227ca 100644 --- a/src/adcp/signing/_header_precheck.py +++ b/src/adcp/signing/_header_precheck.py @@ -34,13 +34,12 @@ from collections.abc import Mapping, Sequence -from adcp.signing._structured_fields import reject_dictionary_duplicates +from adcp.signing._rfc9421 import parse_dictionary, translate_legacy_binary from adcp.signing.canonical import ( TargetUriMalformedError, _split_or_reject, host_has_raw_non_ascii, malformed_authority_reason, - split_structured_field, ) #: Headers that must arrive exactly once. Each is either a singleton by RFC @@ -100,20 +99,16 @@ def strict_header_precheck( seen.add(name) for name, value in pairs: - if name in _SINGLE_VALUE_HEADERS and len(split_structured_field(value, ",")) > 1: + if name in _SINGLE_VALUE_HEADERS and _has_top_level_comma(value): return ( f"{name!r} carries more than one value; it is a singleton field " "and a comma-joined value means two were folded together" ) header_name = _DICTIONARY_HEADERS.get(name) if header_name is not None: - reason = _duplicate_dictionary_key_reason(value, header_name) + reason = _dictionary_reason(value, header_name) if reason is not None: return reason - try: - reject_dictionary_duplicates(value) - except ValueError as exc: - return f"{header_name} is malformed: {exc}" return _authority_reason(pairs=pairs, url=url) @@ -141,7 +136,27 @@ def _header_pairs( return pairs -def _duplicate_dictionary_key_reason(value: str, header_name: str) -> str | None: +def _has_top_level_comma(value: str) -> bool: + """Whether a media type carries a comma outside an RFC 9110 quoted-string. + + ``Content-Type`` is not a Structured Field, so this is HTTP field syntax, + not RFC 8941: a ``boundary="a,b"`` parameter is one value, a bare comma is + two values folded together. + """ + quoted = escaped = False + for character in value: + if escaped: + escaped = False + elif quoted and character == "\\": + escaped = True + elif character == '"': + quoted = not quoted + elif character == "," and not quoted: + return True + return False + + +def _dictionary_reason(value: str, header_name: str) -> str | None: """RFC 8941 §3.2: a duplicate dictionary key MUST be rejected, not resolved. "Retaining only the last value" is the other option the RFC allows, and it @@ -149,30 +164,31 @@ def _duplicate_dictionary_key_reason(value: str, header_name: str) -> str | None is the smuggling vector: a proxy appends a second entry with the same label and a weaker covered-component set, the parser keeps the last, and the signature verifies over fewer components than the producer signed. + ``http-sf`` reports every duplicate key and parameter through its + ``on_duplicate_key`` hook, which :func:`parse_dictionary` turns into an + error. - Keys are compared case-folded. RFC 8941 §3.2's `key` production admits - lowercase only, so two keys differing only in case are never two distinct - keys -- one of them is a key spelled illegally. Folding is what makes the - rule hold for `Content-Digest`, whose RFC 9530 §2 algorithm names are - lowercase tokens: comparing `SHA-256` and `sha-256` as written lets the - same algorithm appear twice and the parser-differential this rejects stays - open behind a change of case. + Keys differing only in case (``SHA-256`` beside ``sha-256``) never reach + the duplicate check: RFC 8941 §3.2's ``key`` production is lowercase only, + so the uppercase spelling fails the parse and the field is malformed. """ - keys: set[str] = set() - for entry in split_structured_field(value, ","): - entry = entry.strip() - if not entry: - continue - key = entry.split("=", 1)[0].strip().lower() - if not key: - continue - if key in keys: + try: + try: + parse_dictionary(value) + except ValueError as exc: + if "duplicate" in str(exc): + raise + # The AdCP 3.0/3.1 Base64URL byte-sequence spelling is the + # decoder's call (3.2 refuses it there, still at step 1), not an + # ambiguity: judge the field with those tokens translated. + parse_dictionary(translate_legacy_binary(value)) + except ValueError as exc: + if "duplicate" in str(exc): return ( - f"{header_name} carries duplicate dictionary key {key!r}; RFC 8941 §3.2 " - "requires rejecting rather than resolving, since resolving lets a " - "repeated key downgrade what was signed" + f"{header_name} carries a {exc}; RFC 8941 §3.2 requires rejecting rather " + "than resolving, since resolving lets a repeated key downgrade what was signed" ) - keys.add(key) + return f"{header_name} is malformed: {exc}" return None diff --git a/src/adcp/signing/_rfc9421.py b/src/adcp/signing/_rfc9421.py new file mode 100644 index 000000000..9368c4ecb --- /dev/null +++ b/src/adcp/signing/_rfc9421.py @@ -0,0 +1,653 @@ +"""RFC 9421 and RFC 8941 plumbing shared by every signing profile in the SDK. + +The SDK writes no RFC 9421 or RFC 8941 grammar of its own: + +* ``http-message-signatures`` builds every signature base, assembles + ``Signature-Input``, and signs and verifies with its algorithm classes + (``ed25519``, ``ecdsa-p256-sha256`` and ``rsa-pss-sha512``). +* ``http-sf`` parses and serializes Structured Fields, and reports a + duplicate dictionary key or parameter through ``on_duplicate_key`` so the + SDK can refuse it instead of keeping the last value. + +What stays here is profile policy, expressed through the library's public +extension points: + +* :class:`ComponentResolver` is the ``component_resolver_class`` the signer + and verifier take. It supplies the RFC 9421 ``;key`` (section 2.1.2) and + ``;req`` (section 2.4) component parameters, which + ``http-message-signatures`` 2.0.1 does not implement yet + (pyauth/http-message-signatures#29; the fix is pyauth#30). Once a release + with that fix is out, the ``key``/``req`` branches here can go. +* Its subclasses decide what ``@target-uri`` and ``@authority`` mean: the AdCP + RFC 9421 profile canonicalizes the URL + (:class:`AdcpComponentResolver`), while Web Bot Auth covers the target URI + as received (:class:`ReceivedComponentResolver`). +* :class:`ProfileVerifier` turns off the library's own wall-clock window + check, because every profile checks its window against an injected clock + with its own error codes. + +The signer subclass reaches ``_build_signature_base`` only for the async +(:class:`~adcp.signing.SigningProvider`) path, where the base must exist +before an awaited signature. ``tests/conformance/signing/test_rfc9421_library_contract.py`` +pins the library shapes this module relies on. +""" + +from __future__ import annotations + +import base64 +import binascii +from collections import OrderedDict, UserString +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + +import http_sf +from http_message_signatures import ( + HTTPMessageSigner, + HTTPMessageVerifier, + HTTPSignatureComponentResolver, + HTTPSignatureKeyResolver, + InvalidSignature, +) +from http_message_signatures.algorithms import ( + ECDSA_P256_SHA256, + ED25519, + RSA_PSS_SHA512, + HTTPSignatureAlgorithm, +) +from http_message_signatures.exceptions import HTTPMessageSignaturesException +from http_message_signatures.structures import CaseInsensitiveDict +from http_sf.bare_item import parse_bare_item +from http_sf.innerlist import ser_item_or_inner_list +from http_sf.item import parse_item, ser_item +from http_sf.state import ParserState +from http_sf.util import discard_http_ows, discard_ows, parse_key + +#: RFC 9421 algorithm names mapped to the library's algorithm classes. Each +#: profile narrows this to its own allow-list before anything reaches it. +ALGORITHMS: dict[str, type[HTTPSignatureAlgorithm]] = { + algorithm.algorithm_id: algorithm for algorithm in (ED25519, ECDSA_P256_SHA256, RSA_PSS_SHA512) +} + + +# --------------------------------------------------------------------------- +# Structured Fields (RFC 8941 / RFC 9651) via http-sf +# --------------------------------------------------------------------------- + + +_EQUALS, _SEMICOLON, _COMMA = ord("="), ord(";"), ord(",") +_PAREN_OPEN, _PAREN_CLOSE = ord("("), ord(")") + + +def _refuse_duplicate(key: str, context: str) -> None: + where = "dictionary member" if context == "dictionary" else "parameter" + raise ValueError(f"duplicate Structured Field {where} {key!r}") + + +def parse_dictionary(value: str) -> dict[str, Any]: + """Parse an RFC 8941 Dictionary, refusing duplicate keys and parameters. + + RFC 8941 section 4.2.2 lets a parser keep the last of two duplicate + keys. That is how a proxy-appended member downgrades what was signed, so + every duplicate is an error here. Members come back in http-sf's shape: + ``(value, params)`` for an item and ``([items], params)`` for an inner + list. Raises ``ValueError`` for anything that does not parse, including + an empty field. + """ + if not value.isascii(): + raise ValueError("Structured Fields must be ASCII") + try: + parsed = http_sf.parse( + value.encode("ascii"), tltype="dictionary", on_duplicate_key=_refuse_duplicate + ) + except http_sf.StructuredFieldError as exc: + raise ValueError(f"malformed Structured Field: {exc}") from exc + if not isinstance(parsed, dict) or not parsed: + raise ValueError("empty Structured Field dictionary") + return parsed + + +def serialize(value: Any) -> str: + """Serialize a Structured Field value (an http-sf structure).""" + return http_sf.ser(value) + + +def component_identifier(item: Any) -> str: + """The serialized component identifier, e.g. ``"signature-agent";key="sig1"``.""" + return ser_item(item) + + +def is_inner_list(member: Any) -> bool: + return isinstance(member, tuple) and len(member) == 2 and isinstance(member[0], list) + + +def dictionary_has_parameter(value: str, name: str, expected: str) -> bool: + """Whether any Dictionary member carries ``name=expected`` as a member parameter. + + Profile selection must see an explicit marker even where the strict + parse would discard it: in a member a later duplicate overwrites, in a + duplicated parameter, or before a syntax error. Every RFC 8941 parser + keeps the last duplicate or stops at the first error, so this walks the + Dictionary member by member with http-sf's own key, item and bare-item + parsers and looks at each member parameter as it is read. It never + verifies anything; a field it reads still has to pass + :func:`parse_dictionary`. Component parameters inside an inner list and + text inside strings are not member parameters and never match. + """ + # Non-ASCII cannot be RFC 8941; "?" stands in for it so the walk stops there. + state = ParserState(value.encode("ascii", "replace")) + try: + discard_ows(state) + while state.has_data(): + parse_key(state) + if state.has_data() and state.data[state.cursor] == _EQUALS: + state.cursor += 1 + if state.has_data() and state.data[state.cursor] == _PAREN_OPEN: + state.cursor += 1 + while True: + discard_ows(state) + if state.data[state.cursor] == _PAREN_CLOSE: + state.cursor += 1 + break + parse_item(state) + else: + parse_bare_item(state) + while state.has_data() and state.data[state.cursor] == _SEMICOLON: + state.cursor += 1 + discard_ows(state) + key = parse_key(state) + parameter: Any = True + if state.has_data() and state.data[state.cursor] == _EQUALS: + state.cursor += 1 + parameter = parse_bare_item(state) + if key == name and isinstance(parameter, (str, UserString)): + if parameter == expected: + return True + discard_http_ows(state) + if not state.has_data() or state.data[state.cursor] != _COMMA: + break + state.cursor += 1 + discard_http_ows(state) + except (http_sf.StructuredFieldError, IndexError): + # A syntax error ends the walk; a marker already seen was returned. + pass + return False + + +# --------------------------------------------------------------------------- +# AdCP 3.0/3.1 legacy Base64URL sf-binary +# --------------------------------------------------------------------------- + +_BASE64URL_ONLY = frozenset("-_") +_BASE64_ONLY = frozenset("+/") +_LEGACY_ALPHABET = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=-_") + + +def legacy_binary_tokens(value: str) -> list[tuple[str | None, str]]: + """Every ``:...:`` byte-sequence token outside sf-strings, with its member key. + + AdCP 3.0 and 3.1 emitted ``Signature`` and ``Content-Digest`` values in + unpadded Base64URL. No RFC 8941 parser accepts that alphabet (section + 3.3.5 is standard Base64), so the legacy profiles translate those tokens + before parsing. This walker only finds the tokens; the translated field + is then parsed strictly by http-sf. The key is the top-level dictionary + key the token is the value of, or ``None`` for a token in a parameter. + """ + tokens: list[tuple[str | None, str]] = [] + index, length = 0, len(value) + key_start = 0 + while index < length: + char = value[index] + if char == '"': + index += 1 + while index < length and value[index] != '"': + index += 2 if value[index] == "\\" else 1 + index += 1 + continue + if char == ":": + end = value.find(":", index + 1) + if end < 0: + break + owner = None + if index > 0 and value[index - 1] == "=": + candidate = value[key_start : index - 1].strip() + owner = candidate if candidate and ";" not in candidate else None + tokens.append((owner, value[index + 1 : end])) + index = end + 1 + continue + if char == ",": + key_start = index + 1 + index += 1 + return tokens + + +def translate_legacy_binary(value: str) -> str: + """Rewrite Base64URL ``:...:`` tokens as RFC 8941 standard Base64. + + A token in standard Base64 is left alone, so a field already in RFC 8941 + form parses unchanged. Only the legacy profiles call this. + """ + out: list[str] = [] + index, length = 0, len(value) + while index < length: + char = value[index] + if char == '"': + start = index + index += 1 + while index < length and value[index] != '"': + index += 2 if value[index] == "\\" else 1 + index += 1 + out.append(value[start:index]) + continue + if char == ":": + end = value.find(":", index + 1) + if end < 0: + out.append(value[index:]) + break + token = value[index + 1 : end] + if set(token) <= _LEGACY_ALPHABET and (_BASE64URL_ONLY & set(token) or len(token) % 4): + standard = token.replace("-", "+").replace("_", "/").rstrip("=") + try: + raw = base64.b64decode(standard + "=" * (-len(standard) % 4), validate=True) + token = base64.b64encode(raw).decode("ascii") + except (binascii.Error, ValueError): + # Not Base64 in either alphabet: the strict parse refuses it. + pass + out.append(f":{token}:") + index = end + 1 + continue + out.append(char) + index += 1 + return "".join(out) + + +def mixes_base64_alphabets(token: str) -> bool: + """Whether a byte-sequence token uses characters only one alphabet has from each.""" + characters = set(token) + return bool(characters & _BASE64_ONLY) and bool(characters & _BASE64URL_ONLY) + + +# --------------------------------------------------------------------------- +# Messages and component resolution +# --------------------------------------------------------------------------- + + +@dataclass +class Request: + """The request shape ``http-message-signatures`` reads: method, URL, headers. + + Header names are case-insensitive (the library's ``CaseInsensitiveDict``), + so the library finds ``Signature-Input`` however the framework spelled it. + """ + + method: str + url: str + headers: Mapping[str, str] = field(default_factory=dict) + + def __post_init__(self) -> None: + self.headers = CaseInsensitiveDict(dict(self.headers)) + + +@dataclass +class Response: + """A response with the request it answers, for ``;req`` components.""" + + status_code: int + request: Request + headers: Mapping[str, str] = field(default_factory=dict) + + def __post_init__(self) -> None: + self.headers = CaseInsensitiveDict(dict(self.headers)) + + @property + def url(self) -> str: + return self.request.url + + +class ComponentError(HTTPMessageSignaturesException): + """A covered component this profile cannot resolve. + + Raised by the resolvers so a verifier can tell a component that does not + resolve (a malformed request) from a signature that does not verify. + ``status`` is the Web Bot Auth status for the failure. + """ + + def __init__(self, reason: str, *, status: int = 403) -> None: + super().__init__(reason) + self.reason = reason + self.status = status + + +class ComponentResolver(HTTPSignatureComponentResolver): + """The ``component_resolver_class`` every SDK signer and verifier uses. + + Adds what the profiles need on top of the library resolver: + + * ``;key=""`` on a covered Dictionary field resolves to that + member's serialization (RFC 9421 section 2.1.2); + * ``;req`` on a response resolves the component from the request it + answers (section 2.4); + * a covered field's value is trimmed of surrounding whitespace (section + 2.1) and refused when it carries a line break or NUL; + * only the derived components in :attr:`derived_component_names` resolve. + + pyauth/http-message-signatures#30 moves ``key`` and ``req`` into the + library; drop those branches once a release carries it. + """ + + derived_component_names = {"@method", "@target-uri", "@authority"} + + def resolve(self, component_node: Any) -> str: + name = component_node.value + params = dict(component_node.params) + if type(name) is not str: + raise ComponentError("a covered component name must be an sf-string", status=400) + if name != name.lower(): + raise ComponentError("covered component names must be lowercase") + if params.pop("req", False) is True: + if not isinstance(self.message, Response): + raise ComponentError("the req parameter only applies to a response") + request_resolver = type(self)(self.message.request) + return request_resolver._resolve(name, params) + return self._resolve(name, params) + + def _resolve(self, name: str, params: dict[str, Any]) -> str: + if name.startswith("@"): + if params: + raise ComponentError("unsupported derived component parameters") + if name not in self.derived_component_names: + raise ComponentError(f"unsupported derived component {name}") + value: str = getattr(self, "get_" + name[1:].replace("-", "_"))() + return value + raw = self.headers.get(name) + if raw is None: + raise ComponentError(f"covered field {name!r} is missing") + if any(c in raw for c in "\r\n\x00"): + raise ComponentError("a covered field carries a control character", status=400) + if not params: + return str(raw).strip(" \t") + member_key = params.pop("key", None) + if params or type(member_key) is not str: + raise ComponentError("unsupported field component parameters") + try: + dictionary = parse_dictionary(raw) + except ValueError as exc: + raise ComponentError(f"covered field {name!r} is malformed", status=400) from exc + if member_key not in dictionary: + raise ComponentError(f"covered dictionary member {member_key!r} is missing") + return ser_item_or_inner_list(dictionary[member_key]) + + +class AdcpComponentResolver(ComponentResolver): + """The AdCP RFC 9421 profile: ``@target-uri`` and ``@authority`` canonicalized. + + The canonicalization is the AdCP URL algorithm in + :mod:`adcp.signing.canonical`. Its ``TargetUriMalformedError`` propagates + so the verifier reports ``request_target_uri_malformed``. + """ + + def get_target_uri(self) -> str: + from adcp.signing.canonical import canonicalize_target_uri + + return canonicalize_target_uri(self.url) + + def get_authority(self) -> str: + from adcp.signing.canonical import canonicalize_authority + + return canonicalize_authority(self.url) + + +# --------------------------------------------------------------------------- +# Signing and verification on the library +# --------------------------------------------------------------------------- + + +class _Key(HTTPSignatureKeyResolver): + """The one key a profile already selected under its own key policy.""" + + def __init__(self, *, private_key: Any = None, public_key: Any = None) -> None: + self._private_key = private_key + self._public_key = public_key + + def resolve_private_key(self, key_id: str) -> Any: + return self._private_key + + def resolve_public_key(self, key_id: str) -> Any: + return self._public_key + + +class ProfileSigner(HTTPMessageSigner): + """``HTTPMessageSigner`` that can also hand out a signature base. + + ``HTTPMessageSigner.sign`` signs in one step. The async + (:class:`~adcp.signing.SigningProvider`) path awaits the signature, so it + needs the base first, and the public ``build_signature_base`` helper + returns one. Both reuse the library's own base builder through its + protected subclass hooks; the contract test pins them. + """ + + def base( + self, + message: Request | Response, + *, + covered_component_ids: Sequence[str], + params: Mapping[str, Any], + ) -> tuple[bytes, str]: + base, node, _ = self._build_signature_base( + message, + covered_component_ids=self._parse_covered_component_ids(covered_component_ids), + signature_params=dict(params), + ) + return base.encode("utf-8"), str(node) + + +def build_base( + message: Request | Response, + *, + covered_component_ids: Sequence[str], + params: Mapping[str, Any], + resolver: type[ComponentResolver], +) -> bytes: + """The RFC 9421 signature base for these components and parameters. + + ``params`` are serialized in the order given. A component that does not + resolve raises ``ValueError`` (an AdCP canonicalization failure keeps its + own ``ValueError`` subclass). + """ + handler = ProfileSigner( + signature_algorithm=ED25519, key_resolver=_Key(), component_resolver_class=resolver + ) + try: + return handler.base(message, covered_component_ids=covered_component_ids, params=params)[0] + except ComponentError as exc: + raise ValueError(exc.reason) from exc + except HTTPMessageSignaturesException as exc: + raise ValueError(str(exc)) from exc + + +def _timestamp(seconds: int) -> datetime: + return datetime.fromtimestamp(seconds, tz=timezone.utc) + + +def sign( + message: Request | Response, + *, + alg: str, + private_key: Any, + key_id: str, + label: str, + created: int, + expires: int | None, + nonce: str | None, + tag: str, + covered_component_ids: Sequence[str], + resolver: type[ComponentResolver], +) -> tuple[str, str]: + """Sign ``message`` with ``HTTPMessageSigner.sign``; return the two fields. + + Returns ``(Signature-Input, Signature)``. ``Signature`` is RFC 8941 + standard Base64; a legacy profile re-encodes it. + """ + signer = ProfileSigner( + signature_algorithm=ALGORITHMS[alg], + key_resolver=_Key(private_key=private_key), + component_resolver_class=resolver, + ) + try: + signer.sign( + message, + key_id=key_id, + created=_timestamp(created), + expires=_timestamp(expires) if expires is not None else None, + nonce=nonce, + label=label, + tag=tag, + covered_component_ids=covered_component_ids, + ) + except ComponentError as exc: + raise ValueError(exc.reason) from exc + except HTTPMessageSignaturesException as exc: + raise ValueError(str(exc)) from exc + return message.headers["Signature-Input"], message.headers["Signature"] + + +def signature_base( + message: Request | Response, + *, + alg: str, + key_id: str, + created: int, + expires: int | None, + nonce: str | None, + tag: str, + covered_component_ids: Sequence[str], + resolver: type[ComponentResolver], +) -> tuple[bytes, str]: + """The base :func:`sign` would sign, and the serialized parameters. + + Parameters are added in the order ``HTTPMessageSigner.sign`` adds them, + so a signature over this base is byte-identical to one :func:`sign` + produces. Used where the signature itself is awaited. + """ + params: dict[str, Any] = OrderedDict() + params["created"] = created + params["keyid"] = key_id + params["alg"] = ALGORITHMS[alg].algorithm_id + if expires is not None: + params["expires"] = expires + if nonce: + params["nonce"] = nonce + params["tag"] = tag + handler = ProfileSigner( + signature_algorithm=ALGORITHMS[alg], + key_resolver=_Key(), + component_resolver_class=resolver, + ) + try: + return handler.base(message, covered_component_ids=covered_component_ids, params=params) + except ComponentError as exc: + raise ValueError(exc.reason) from exc + except HTTPMessageSignaturesException as exc: + raise ValueError(str(exc)) from exc + + +class ProfileVerifier(HTTPMessageVerifier): + """``HTTPMessageVerifier`` with the window left to the profile. + + The library compares ``created`` and ``expires`` with the real wall + clock and a 5-second skew. Each profile has already checked its window + against the verifier's injected clock, with its own skew and error codes, + before this runs. Unknown signature parameters are the profile's call + too, so the verifier is told which ones the signature carries. + """ + + def validate_created_and_expires(self, sig_input: Any, max_age: Any = None) -> None: + return None + + +class SignatureBaseError(ValueError): + """The signature base could not be built: a component does not resolve.""" + + def __init__(self, reason: str, *, status: int = 403, cause: BaseException | None = None): + super().__init__(reason) + self.reason = reason + self.status = status + self.cause = cause + + +class SignatureMismatchError(ValueError): + """The signature does not verify over the base.""" + + +def verify( + message: Request | Response, + *, + label: str, + tag: str, + alg: str, + public_key: Any, + resolver: type[ComponentResolver], + parameters: Sequence[str] = (), +) -> Mapping[str, str]: + """Verify ``label`` with ``HTTPMessageVerifier``; return the covered components. + + ``message.headers`` must carry the ``Signature-Input`` and ``Signature`` + fields to verify. ``alg`` and ``public_key`` are the profile's choice; + the library confirms the signature names the same ``alg``. Raises + :class:`SignatureBaseError` when a component does not resolve and + :class:`SignatureMismatchError` when the signature does not verify. + """ + verifier = ProfileVerifier( + signature_algorithm=ALGORITHMS[alg], + key_resolver=_Key(public_key=public_key), + component_resolver_class=resolver, + ) + verifier.signature_metadata_parameters = ( + HTTPMessageVerifier.signature_metadata_parameters | set(parameters) + ) + try: + (result,) = verifier.verify(message, expect_tag=tag, expect_label=label) + except InvalidSignature as exc: + cause = exc.__cause__ + if isinstance(cause, ComponentError): + raise SignatureBaseError(cause.reason, status=cause.status, cause=cause) from exc + if cause is not None and not isinstance(cause, HTTPMessageSignaturesException): + # A crypto failure is cryptography's InvalidSignature. Anything + # else (an AdCP canonicalization error, say) came from the base. + from cryptography.exceptions import InvalidSignature as CryptoInvalidSignature + + if not isinstance(cause, CryptoInvalidSignature): + raise SignatureBaseError(str(cause), cause=cause) from exc + raise SignatureMismatchError("signature did not verify") from exc + except HTTPMessageSignaturesException as exc: + # The key does not fit the algorithm: it cannot verify this signature. + raise SignatureMismatchError(str(exc)) from exc + return dict(result.covered_components) + + +__all__ = [ + "ALGORITHMS", + "AdcpComponentResolver", + "ComponentError", + "ComponentResolver", + "ProfileSigner", + "ProfileVerifier", + "Request", + "Response", + "SignatureBaseError", + "SignatureMismatchError", + "build_base", + "component_identifier", + "dictionary_has_parameter", + "is_inner_list", + "legacy_binary_tokens", + "mixes_base64_alphabets", + "parse_dictionary", + "ser_item_or_inner_list", + "serialize", + "sign", + "signature_base", + "translate_legacy_binary", + "verify", +] diff --git a/src/adcp/signing/_structured_fields.py b/src/adcp/signing/_structured_fields.py deleted file mode 100644 index f43d6a352..000000000 --- a/src/adcp/signing/_structured_fields.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Reject Structured Fields duplicates before a parser overwrites their evidence. - -This grammar-aware preflight preserves item and inner-list parameter scopes. -It does not decode values or replace the profile's existing value validation; -in particular, legacy Base64URL compatibility remains the decoder's choice. -""" - -from __future__ import annotations - - -def reject_dictionary_duplicates(value: str) -> None: - """Raise ValueError for duplicate labels/parameters or ambiguous syntax.""" - if not value.isascii(): - raise ValueError("Structured Fields must be ASCII") - _DictionaryScanner(value).scan() - - -def dictionary_has_parameter(value: str, name: str, expected: str) -> bool: - """Observe member parameters without resolving duplicates for profile choice. - - This is never a verification parser. An explicit marker observed before a - syntax failure remains a marker; quoted text and component parameters do - not become dictionary-member parameters. - """ - scanner = _DictionaryScanner(value, reject_duplicates=False) - try: - scanner.scan() - except ValueError: - # A syntax error must not erase an explicit marker already observed. - return _parameters_contain_marker(scanner.member_parameters, name, expected) - return _parameters_contain_marker(scanner.member_parameters, name, expected) - - -def _parameters_contain_marker(parameters: list[tuple[str, str]], name: str, expected: str) -> bool: - from http_sfv.item import Item - - for key, raw in parameters: - if key != name: - continue - try: - item = Item() - item.parse(raw.encode("ascii")) - except (ValueError, UnicodeError): - continue - if item.value == expected: - return True - return False - - -class _DictionaryScanner: - def __init__(self, value: str, *, reject_duplicates: bool = True) -> None: - self.value = value - self.index = 0 - self.reject_duplicates = reject_duplicates - self.member_parameters: list[tuple[str, str]] = [] - - def _ows(self, *, inner: bool = False) -> None: - whitespace = " " if inner else " \t" - while self.index < len(self.value) and self.value[self.index] in whitespace: - self.index += 1 - - def _key(self) -> str: - start = self.index - if start == len(self.value) or self.value[start] not in "abcdefghijklmnopqrstuvwxyz*": - raise ValueError("invalid Structured Field key") - self.index += 1 - while self.index < len(self.value) and self.value[self.index] in ( - "abcdefghijklmnopqrstuvwxyz0123456789_.*-" - ): - self.index += 1 - return self.value[start : self.index] - - def _bare_item(self) -> None: - if self.index == len(self.value): - raise ValueError("missing Structured Field value") - start = self.value[self.index] - if start == "%" and self.value[self.index : self.index + 2] == '%"': - # RFC 9651 Display Strings share quoted delimiter boundaries. - # Their percent encoding is validated by the value parser. - self.index += 1 - start = '"' - if start == '"': - self.index += 1 - while self.index < len(self.value): - character = self.value[self.index] - self.index += 1 - if character == '"': - return - if character == "\\": - if self.index == len(self.value) or self.value[self.index] not in '\\"': - raise ValueError("invalid Structured Field string escape") - self.index += 1 - elif not 0x20 <= ord(character) <= 0x7E: - raise ValueError("invalid Structured Field string") - raise ValueError("unterminated Structured Field string") - if start == ":": - self.index += 1 - while self.index < len(self.value): - character = self.value[self.index] - self.index += 1 - if character == ":": - return - if character not in ( - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=_-" - ): - raise ValueError("invalid Structured Field binary syntax") - raise ValueError("unterminated Structured Field binary") - initial = self.index - while self.index < len(self.value) and self.value[self.index] not in " \t;,()": - character = self.value[self.index] - if character in '\\"' or not 0x21 <= ord(character) <= 0x7E: - raise ValueError("invalid Structured Field value") - self.index += 1 - if self.index == initial: - raise ValueError("missing Structured Field item") - - def _parameters(self, *, member: bool = False) -> None: - seen: set[str] = set() - while self.index < len(self.value) and self.value[self.index] == ";": - self.index += 1 - self._ows(inner=True) - key = self._key() - if key in seen and self.reject_duplicates: - raise ValueError(f"duplicate Structured Field parameter {key!r}") - seen.add(key) - if self.index < len(self.value) and self.value[self.index] == "=": - self.index += 1 - start = self.index - self._bare_item() - if member: - self.member_parameters.append((key, self.value[start : self.index])) - - def _item(self, *, member: bool = False) -> None: - self._bare_item() - self._parameters(member=member) - - def _member(self) -> None: - if self.index < len(self.value) and self.value[self.index] == "(": - self.index += 1 - while True: - self._ows(inner=True) - if self.index == len(self.value): - raise ValueError("unterminated Structured Field inner list") - if self.value[self.index] == ")": - self.index += 1 - break - self._item() - if self.index == len(self.value) or self.value[self.index] not in " )": - raise ValueError("invalid Structured Field inner-list delimiter") - self._parameters(member=True) - else: - self._item(member=True) - - def scan(self) -> None: - seen: set[str] = set() - self._ows() - if self.index == len(self.value): - raise ValueError("empty Structured Field dictionary") - while self.index < len(self.value): - key = self._key() - if key in seen and self.reject_duplicates: - raise ValueError(f"duplicate Structured Field dictionary member {key!r}") - seen.add(key) - if self.index < len(self.value) and self.value[self.index] == "=": - self.index += 1 - self._member() - else: - self._parameters(member=True) - self._ows() - if self.index == len(self.value): - return - if self.value[self.index] != ",": - raise ValueError("invalid Structured Field dictionary delimiter") - self.index += 1 - self._ows() - if self.index == len(self.value): - raise ValueError("trailing Structured Field dictionary comma") diff --git a/src/adcp/signing/autosign.py b/src/adcp/signing/autosign.py index a36bca9aa..890038869 100644 --- a/src/adcp/signing/autosign.py +++ b/src/adcp/signing/autosign.py @@ -18,6 +18,7 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Literal +from cryptography.hazmat.primitives.asymmetric import ed25519 from pydantic import RootModel from adcp.signing.constants import DEFAULT_TAG @@ -135,6 +136,11 @@ def __post_init__(self) -> None: from adcp.signing.wba import WBA_TAG, _https_origin, public_directory_key object.__setattr__(self, "agent_origin", _https_origin(self.agent_origin)) + if not isinstance(self.private_key, ed25519.Ed25519PrivateKey): + raise ValueError( + "Web Bot Auth signs with ed25519 or rsa-pss-sha512; ES256 is an AdCP " + "RFC 9421 profile algorithm only" + ) if self.key_id != public_directory_key(self.private_key)["kid"]: raise ValueError("Web Bot Auth key_id must be the public key's thumbprint") object.__setattr__(self, "tag", WBA_TAG) diff --git a/src/adcp/signing/canonical.py b/src/adcp/signing/canonical.py index 202f8400a..b8aad5b09 100644 --- a/src/adcp/signing/canonical.py +++ b/src/adcp/signing/canonical.py @@ -1,11 +1,18 @@ -"""RFC 9421 canonicalization for the AdCP request-signing profile. - -Implements the canonical signature base per RFC 9421 §2.5 with the covered -components mandated by the AdCP profile (@method, @target-uri, @authority, -content-type, content-digest). URI canonicalization follows RFC 3986 §6.2 -scheme-based normalization plus AdCP-specific rules: query preserved -byte-for-byte, percent-encoding hex uppercased, unreserved chars decoded, -default ports stripped, dot-segments collapsed. +"""AdCP URL canonicalization for the RFC 9421 request-signing profile. + +This module is the AdCP-specific part of the profile: the ``@target-uri`` +and ``@authority`` values the spec defines. URI canonicalization follows +RFC 3986 §6.2 scheme-based normalization plus AdCP-specific rules: query +preserved byte-for-byte, percent-encoding hex uppercased, unreserved chars +decoded, default ports stripped, dot-segments collapsed, hosts converted to +A-labels, and the malformed-authority rejections. + +RFC 9421 itself (the ``Signature-Input`` grammar and the signature base) +comes from ``http-message-signatures`` and ``http-sf``; +:func:`parse_signature_input_header` and :func:`build_signature_base` are +the public entry points onto them, and +:class:`adcp.signing._rfc9421.AdcpComponentResolver` plugs the +canonicalization below into the library. """ from __future__ import annotations @@ -17,7 +24,12 @@ import idna from adcp.signing._idna_canonicalize import canonicalize_host -from adcp.signing._structured_fields import reject_dictionary_duplicates +from adcp.signing._rfc9421 import ( + component_identifier, + is_inner_list, + parse_dictionary, + ser_item_or_inner_list, +) #: Spec code for an authority the AdCP profile requires be rejected rather than #: canonicalized. Declared here rather than imported from ``errors`` so this @@ -56,7 +68,11 @@ def host_has_raw_non_ascii(host: str) -> bool: @dataclass(frozen=True) class SignatureInputLabel: - """A single label in a Signature-Input header (e.g. the `sig1=...` entry).""" + """A single label in a Signature-Input header (e.g. the `sig1=...` entry). + + ``raw_value`` is the member's RFC 8941 serialization, which is the + ``@signature-params`` value the signature base carries. + """ label: str components: tuple[str, ...] @@ -67,33 +83,37 @@ class SignatureInputLabel: def parse_signature_input_header(header_value: str) -> dict[str, SignatureInputLabel]: """Parse a Signature-Input header value into a dict keyed by label. - A Signature-Input header may contain multiple labels separated by commas: - `sig1=(...);..., sig2=(...);...`. The AdCP profile mandates that verifiers - process exactly one label (conventionally `sig1`) and ignore others. + The field is an RFC 8941 Dictionary, parsed by ``http-sf`` with duplicate + keys and parameters refused. The AdCP profile then narrows its shape: + every member is an inner list of component names (sf-strings without + component parameters), and every signature parameter is an sf-string or + an integer. A Signature-Input header may contain several labels; the + profile verifies exactly one (conventionally ``sig1``) and ignores others. """ - reject_dictionary_duplicates(header_value) labels: dict[str, SignatureInputLabel] = {} - for entry in split_structured_field(header_value, ","): - entry = entry.strip() - if not entry: - continue - eq_paren = entry.find("=(") - if eq_paren < 0: - raise ValueError(f"malformed Signature-Input entry: {entry!r}") - label = entry[:eq_paren].strip() - remainder = entry[eq_paren + 1 :] - close = remainder.find(")") - if close < 0: - raise ValueError(f"unterminated component list in label {label!r}") - components_str = remainder[1:close] - params_str = remainder[close + 1 :] - components = tuple(_unquote_component(tok) for tok in components_str.split()) - params = _parse_params(params_str) + for label, member in parse_dictionary(header_value).items(): + if not is_inner_list(member): + raise ValueError(f"malformed Signature-Input entry: {label!r} is not an inner list") + items, member_params = member + components: list[str] = [] + for name, component_params in items: + if type(name) is not str: + raise ValueError(f"component name not quoted: {name!r}") + if component_params: + raise ValueError(f"component {name!r} carries parameters the profile does not use") + components.append(name) + params: dict[str, str | int] = {} + for key, value in member_params.items(): + if type(value) is not str and type(value) is not int: + raise ValueError( + f"signature param {key!r} must be a quoted string or integer, got {value!r}" + ) + params[key] = value labels[label] = SignatureInputLabel( label=label, - components=components, + components=tuple(components), params=params, - raw_value=remainder, + raw_value=ser_item_or_inner_list(member), ) return labels @@ -106,16 +126,20 @@ def build_signature_base( ) -> str: """Build the RFC 9421 signature base string for the AdCP profile. - Lines are joined with a single `\\n` (LF, not CRLF). No trailing newline. - Components appear in the exact order listed in `Signature-Input`, followed - by `@signature-params` as the last line. + ``http-message-signatures`` builds the base: lines joined with a single + LF, no trailing newline, components in ``Signature-Input`` order, then + ``@signature-params``. :class:`AdcpComponentResolver` supplies the AdCP + ``@target-uri`` and ``@authority`` canonicalization. """ - lines: list[str] = [] - for comp in parsed.components: - value = _resolve_component(comp, method, url, headers) - lines.append(f'"{comp}": {value}') - lines.append(f'"@signature-params": {parsed.raw_value}') - return "\n".join(lines) + from adcp.signing._rfc9421 import AdcpComponentResolver, Request, build_base + + base = build_base( + Request(method=method, url=url, headers=dict(headers)), + covered_component_ids=[component_identifier((name, {})) for name in parsed.components], + params=parsed.params, + resolver=AdcpComponentResolver, + ) + return base.decode("utf-8") def canonicalize_target_uri(url: str) -> str: @@ -186,21 +210,6 @@ def _split_or_reject(url: str) -> SplitResult: _UNRESERVED = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") -def _resolve_component(comp: str, method: str, url: str, headers: Mapping[str, str]) -> str: - if comp == "@method": - return method.upper() - if comp == "@target-uri": - return canonicalize_target_uri(url) - if comp == "@authority": - return canonicalize_authority(url) - if comp.startswith("@"): - raise ValueError(f"unsupported derived component for AdCP profile: {comp}") - value = _lookup(headers, comp.lower()) - if value is None: - raise ValueError(f"missing header for covered component: {comp}") - return value.strip() - - def _lookup(headers: Mapping[str, str], name_lower: str) -> str | None: for k, v in headers.items(): if k.lower() == name_lower: @@ -417,91 +426,3 @@ def _normalize_pct(s: str) -> str: out.append(c) i += 1 return "".join(out) - - -def split_structured_field(s: str, sep: str) -> list[str]: - """Split on `sep` occurrences that are outside RFC 8941 sf-string quotes and parens. - - sf-string escapes per RFC 8941 §3.3.3 are `\\\\` and `\\"` only; the state - machine tracks an `esc` flag so that `\\\\"` closes the quoted span (the - backslash escapes itself, the following quote is unescaped). - """ - out: list[str] = [] - depth = 0 - in_q = False - esc = False - start = 0 - for i, c in enumerate(s): - if in_q: - if esc: - esc = False - elif c == "\\": - esc = True - elif c == '"': - in_q = False - elif c == '"': - in_q = True - elif c == "(": - depth += 1 - elif c == ")": - depth -= 1 - elif c == sep and depth == 0: - out.append(s[start:i]) - start = i + 1 - out.append(s[start:]) - return out - - -def _unquote_component(token: str) -> str: - t = token.strip() - if len(t) >= 2 and t[0] == '"' and t[-1] == '"': - return t[1:-1] - raise ValueError(f"component name not quoted: {token!r}") - - -def _parse_params(params_str: str) -> dict[str, str | int]: - out: dict[str, str | int] = {} - s = params_str.strip() - if not s: - return out - if s.startswith(";"): - s = s[1:] - for part in split_structured_field(s, ";"): - part = part.strip() - if not part: - continue - k, eq, v = part.partition("=") - k = k.strip() - v = v.strip() - if not k: - raise ValueError(f"signature param with empty name: {part!r}") - if not eq or not v: - raise ValueError(f"signature param {k!r} has empty value") - if v.startswith('"'): - if not (v.endswith('"') and len(v) >= 2): - raise ValueError(f"signature param {k!r} has unterminated quoted value") - out[k] = _unescape_sf_string(v[1:-1]) - else: - try: - out[k] = int(v) - except ValueError as exc: - raise ValueError( - f"signature param {k!r} must be a quoted string or integer, got {v!r}" - ) from exc - return out - - -def _unescape_sf_string(s: str) -> str: - """Unescape RFC 8941 §3.3.3 sf-string contents (only `\\\\` and `\\"`).""" - out: list[str] = [] - i = 0 - n = len(s) - while i < n: - c = s[i] - if c == "\\" and i + 1 < n and s[i + 1] in ("\\", '"'): - out.append(s[i + 1]) - i += 2 - else: - out.append(c) - i += 1 - return "".join(out) diff --git a/src/adcp/signing/crypto.py b/src/adcp/signing/crypto.py index 075bec789..e08d0669e 100644 --- a/src/adcp/signing/crypto.py +++ b/src/adcp/signing/crypto.py @@ -1,12 +1,14 @@ -"""Crypto primitives for the AdCP request-signing profile. +"""Key handling and signature primitives for the AdCP request-signing profile. Supported algorithms per RFC 9421 profile: - `ed25519` (EdDSA over Curve25519) - `ecdsa-p256-sha256` (ES256) -ES256 signatures use IEEE P1363 (r||s, 64 bytes) per RFC 9421 §3.3.2. The -`cryptography` library emits/accepts DER by default, so sign() and verify() -convert at the boundary. +Signing and verification are ``http-message-signatures``' algorithm classes, +which also convert ES256 to and from the IEEE P1363 ``r || s`` form RFC 9421 +§3.3.2 requires. The ``Signature`` field is parsed and serialized by +``http-sf``. What stays here is AdCP key policy: the JWK shapes the profile +accepts and the PEM loader. Encrypted PEM support (passphrase-protected private keys) is tracked in #191. """ @@ -14,19 +16,14 @@ from __future__ import annotations import base64 -import binascii from typing import Any from cryptography.exceptions import InvalidSignature -from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec, ed25519 -from cryptography.hazmat.primitives.asymmetric.utils import ( - decode_dss_signature, - encode_dss_signature, -) +from http_message_signatures.exceptions import HTTPMessageSignaturesException -from adcp.signing._structured_fields import reject_dictionary_duplicates -from adcp.signing.canonical import split_structured_field +from adcp.signing._rfc9421 import ALGORITHMS, parse_dictionary, serialize, translate_legacy_binary PublicKey = ed25519.Ed25519PublicKey | ec.EllipticCurvePublicKey PrivateKey = ed25519.Ed25519PrivateKey | ec.EllipticCurvePrivateKey @@ -171,28 +168,19 @@ def verify_signature( signature_base: bytes, signature: bytes, ) -> bool: - """Return True iff `signature` is valid over `signature_base` for `alg`.""" + """Return True iff `signature` is valid over `signature_base` for `alg`. + + The check is ``http-message-signatures``' algorithm class for ``alg`` + (ES256 takes the RFC 9421 §3.3.2 ``r || s`` form). A key of the wrong + type for ``alg`` cannot verify, so it returns False. + """ if alg not in ALLOWED_ALGS: raise ValueError(f"unsupported alg: {alg}") try: - if alg == ALG_ED25519: - if not isinstance(public_key, ed25519.Ed25519PublicKey): - return False - public_key.verify(signature, signature_base) - return True - if alg == ALG_ES256: - if not isinstance(public_key, ec.EllipticCurvePublicKey): - return False - if len(signature) != 64: - return False - r = int.from_bytes(signature[:32], "big") - s = int.from_bytes(signature[32:], "big") - der = encode_dss_signature(r, s) - public_key.verify(der, signature_base, ec.ECDSA(hashes.SHA256())) - return True - except InvalidSignature: + ALGORITHMS[alg](public_key=public_key).verify(signature=signature, message=signature_base) + except (InvalidSignature, HTTPMessageSignaturesException): return False - return False + return True def sign_signature_base( @@ -202,17 +190,15 @@ def sign_signature_base( signature_base: bytes, ) -> bytes: """Produce a signature over the base. ES256 returns r||s (64 bytes).""" - if alg == ALG_ED25519: - if not isinstance(private_key, ed25519.Ed25519PrivateKey): - raise ValueError("alg/private_key mismatch: expected Ed25519 key") - return private_key.sign(signature_base) - if alg == ALG_ES256: - if not isinstance(private_key, ec.EllipticCurvePrivateKey): - raise ValueError("alg/private_key mismatch: expected EC P-256 key") - der = private_key.sign(signature_base, ec.ECDSA(hashes.SHA256())) - r, s = decode_dss_signature(der) - return r.to_bytes(32, "big") + s.to_bytes(32, "big") - raise ValueError(f"unsupported alg: {alg}") + if alg not in ALLOWED_ALGS: + raise ValueError(f"unsupported alg: {alg}") + try: + signer = ALGORITHMS[alg](private_key=private_key) + except HTTPMessageSignaturesException as exc: + expected = "Ed25519 key" if alg == ALG_ED25519 else "EC P-256 key" + raise ValueError(f"alg/private_key mismatch: expected {expected}") from exc + signature: bytes = signer.sign(signature_base) + return signature def extract_signature_bytes( @@ -223,35 +209,24 @@ def extract_signature_bytes( ) -> bytes: """Parse a Signature header value and return the raw signature bytes for `label`. - The value is an sf-dictionary: `sig1=::[, sig2=::]`. - RFC 8941 specifies standard padded Base64. AdCP 3.0/3.1 compatibility - permits the historical Base64URL spelling; 3.2 callers disable that - fallback so malformed Structured Fields fail at verifier step 1. + The value is an RFC 8941 Dictionary parsed by ``http-sf``, with duplicate + labels and parameters refused. RFC 8941 byte sequences are standard + padded Base64. AdCP 3.0/3.1 compatibility permits the historical + Base64URL spelling, which is translated before the strict parse; 3.2 + callers disable that so a malformed field fails at verifier step 1. """ - reject_dictionary_duplicates(signature_header) - for entry in split_structured_field(signature_header, ","): - entry = entry.strip() - if not entry: - continue - eq = entry.find("=") - if eq < 0: - continue - name = entry[:eq].strip() - value = entry[eq + 1 :].strip() - if name != label: - continue - if not (value.startswith(":") and value.endswith(":")): - raise ValueError(f"Signature value for {label!r} is not an sf-binary: {value!r}") - inner = value[1:-1] - try: - return base64.b64decode(inner, validate=True) - except (ValueError, binascii.Error) as exc: - if not allow_legacy_base64url: - raise ValueError( - f"Signature value for {label!r} is not RFC 8941 standard Base64" - ) from exc - return b64url_decode(inner) - raise KeyError(f"label not found in Signature header: {label}") + try: + members = parse_dictionary(signature_header) + except ValueError: + if not allow_legacy_base64url: + raise + members = parse_dictionary(translate_legacy_binary(signature_header)) + if label not in members: + raise KeyError(f"label not found in Signature header: {label}") + value, _params = members[label] + if not isinstance(value, bytes): + raise ValueError(f"Signature value for {label!r} is not an sf-binary") + return value def format_signature_header( @@ -260,10 +235,12 @@ def format_signature_header( *, use_legacy_base64url: bool = False, ) -> str: - """Produce a Signature header value for a single-label signature.""" - encoded = ( - b64url_encode(signature) - if use_legacy_base64url - else base64.b64encode(signature).decode("ascii") - ) - return f"{label}=:{encoded}:" + """Produce a Signature header value for a single-label signature. + + RFC 8941 serialization comes from ``http-sf``. The AdCP 3.0/3.1 legacy + spelling (unpadded Base64URL) is not RFC 8941 and is formatted here. + """ + if use_legacy_base64url: + serialize({label: (True, {})}) # validates the label as an RFC 8941 key + return f"{label}=:{b64url_encode(signature)}:" + return serialize({label: (signature, {})}) diff --git a/src/adcp/signing/digest.py b/src/adcp/signing/digest.py index e1b2ee958..84fc99a00 100644 --- a/src/adcp/signing/digest.py +++ b/src/adcp/signing/digest.py @@ -1,55 +1,39 @@ """Content-Digest (RFC 9530) support for the AdCP request-signing profile. -Only `sha-256` is required. The header value is an RFC 8941 sf-dictionary: -`sha-256=::[, sha-512=::]`. A digest matches when the sha-256 -entry's decoded bytes equal `sha256(body)`. +Only `sha-256` is required. The header value is an RFC 8941 Dictionary +(`sha-256=::[, sha-512=::]`), parsed by ``http-sf`` with +duplicate keys refused. A digest matches when the sha-256 entry's decoded +bytes equal `sha256(body)`. """ from __future__ import annotations -import base64 -import binascii import hashlib +import hmac -from adcp.signing.canonical import split_structured_field +from adcp.signing._rfc9421 import parse_dictionary, serialize, translate_legacy_binary def compute_content_digest_sha256(body: bytes) -> str: """Return the Content-Digest header value for a body (sha-256 only).""" - h = hashlib.sha256(body).digest() - return f"sha-256=:{base64.b64encode(h).decode('ascii')}:" + return serialize({"sha-256": (hashlib.sha256(body).digest(), {})}) def content_digest_matches(header_value: str, body: bytes) -> bool: - """Return True iff the sha-256 entry in the header matches the body.""" - expected = hashlib.sha256(body).digest() - for entry in split_structured_field(header_value, ","): - entry = entry.strip() - eq = entry.find("=") - if eq < 0: - continue - algo = entry[:eq].strip().lower() - if algo != "sha-256": - continue - val = entry[eq + 1 :].strip() - if not (val.startswith(":") and val.endswith(":")): - return False - b64 = val[1:-1] - received = _decode_sf_binary(b64) - if received is None: - return False - return received == expected - return False - + """Return True iff the sha-256 entry in the header matches the body. -def _decode_sf_binary(b64: str) -> bytes | None: - """Decode an sf-binary payload. Standard base64 per RFC 8941; also tolerate base64url.""" + RFC 8941 byte sequences are standard Base64. The Base64URL spelling some + AdCP 3.0/3.1 senders emitted is still accepted: a field that does not + parse as written is translated and parsed strictly once more. + """ try: - return base64.b64decode(b64, validate=True) - except (ValueError, binascii.Error): - pass - try: - pad = "=" * (-len(b64) % 4) - return base64.urlsafe_b64decode(b64 + pad) - except (ValueError, binascii.Error): - return None + members = parse_dictionary(header_value) + except ValueError: + try: + members = parse_dictionary(translate_legacy_binary(header_value)) + except ValueError: + return False + entry = members.get("sha-256") + if entry is None or not isinstance(entry[0], bytes): + return False + return hmac.compare_digest(entry[0], hashlib.sha256(body).digest()) diff --git a/src/adcp/signing/jws.py b/src/adcp/signing/jws.py index 649ce00ee..cf537cf7f 100644 --- a/src/adcp/signing/jws.py +++ b/src/adcp/signing/jws.py @@ -141,12 +141,18 @@ def _decode_json_object(raw: bytes, what: str) -> dict[str, Any]: return value +def _split_registry() -> _AdcpJwsRegistry: + # Splitting decodes the protected header and checks segment sizes only; + # the profile's alg allow-list is applied by _validate_header afterwards. + return _AdcpJwsRegistry(strict_check_header=False) + + def parse_compact_jws(token: str) -> tuple[str, str, bytes]: """Split a compact JWS into ``(b64_header, b64_payload, signature bytes)``. - Returns the header and payload as the ORIGINAL base64url substrings, not - decoded and re-encoded, so the verifier checks the exact bytes the signer - signed. + ``joserfc.jws.extract_compact`` does the split. The header and payload + come back as the ORIGINAL base64url substrings, not decoded and + re-encoded, so the verifier checks the exact bytes the signer signed. """ if not isinstance(token, str): raise JwsMalformedError("compact JWS must be a string") @@ -155,21 +161,31 @@ def parse_compact_jws(token: str) -> tuple[str, str, bytes]: raise JwsMalformedError( f"compact JWS must have exactly 3 dot-separated segments, got {len(parts)}" ) - b64_header, b64_payload, b64_signature = parts - if not b64_header or not b64_payload or not b64_signature: + if "" in parts: raise JwsMalformedError("compact JWS has an empty segment") try: - signature = _b64url_decode(b64_signature) + compact = jws.extract_compact(token.encode("ascii"), registry=_split_registry()) + except (JoseError, UnicodeError, ValueError) as exc: + raise JwsMalformedError( + f"compact JWS protected header is not valid base64url JSON: {exc}" + ) from exc + try: + signature = _b64url_decode(compact.segments["signature"].decode("ascii")) except ValueError as exc: raise JwsMalformedError(f"compact JWS signature is not valid base64url: {exc}") from exc - return b64_header, b64_payload, signature + return ( + compact.segments["header"].decode("ascii"), + compact.segments["payload"].decode("ascii"), + signature, + ) def parse_general_json_jws(doc: dict[str, Any]) -> tuple[str, str, bytes]: """Extract the single signature from a JWS general JSON serialization. - Returns ``(b64_header, b64_payload, signature_bytes)``. A single operator - key signs an AdCP revocation list, so this profile treats a list with more + ``joserfc.jws.extract_compact`` does the parse (see below). Returns + ``(b64_header, b64_payload, signature_bytes)``. A single operator key + signs an AdCP revocation list, so this profile treats a list with more than one signature as malformed. An unprotected ``header`` member is ignored: every parameter the profile reads must be integrity-protected. """ @@ -188,18 +204,14 @@ def parse_general_json_jws(doc: dict[str, Any]) -> tuple[str, str, bytes]: entry = signatures[0] if not isinstance(entry, dict): raise JwsMalformedError("JWS signature entry must be an object") - b64_header = entry.get("protected") - b64_signature = entry.get("signature") - b64_payload = doc["payload"] - if not isinstance(b64_header, str) or not isinstance(b64_signature, str): + if not isinstance(entry.get("protected"), str) or not isinstance(entry.get("signature"), str): raise JwsMalformedError("JWS signature entry missing 'protected' or 'signature'") - if not isinstance(b64_payload, str): + if not isinstance(doc["payload"], str): raise JwsMalformedError("JWS 'payload' must be a base64url string") - try: - signature = _b64url_decode(b64_signature) - except ValueError as exc: - raise JwsMalformedError(f"JWS signature is not valid base64url: {exc}") from exc - return b64_header, b64_payload, signature + # One signature with a protected header is the compact serialization in + # another shape (RFC 7515 section 7.2.1), so joserfc's compact extractor + # splits it with the same checks. + return parse_compact_jws(f"{entry['protected']}.{doc['payload']}.{entry['signature']}") def _validate_header( diff --git a/src/adcp/signing/publisher_pins.py b/src/adcp/signing/publisher_pins.py index 79772e362..c5e807199 100644 --- a/src/adcp/signing/publisher_pins.py +++ b/src/adcp/signing/publisher_pins.py @@ -2,26 +2,44 @@ from __future__ import annotations -import hashlib -import json from collections.abc import Mapping, Sequence from datetime import datetime from typing import Any +from joserfc.errors import JoseError +from joserfc.jwk import ECKey, OKPKey, RSAKey +from joserfc.util import urlsafe_b64decode + PublisherPins = Mapping[str, Sequence[Mapping[str, Any]] | None] +#: The public members RFC 7638 hashes, per key type. A key missing one (or +#: carrying a non-string one) has no thumbprint and matches nothing. +_THUMBPRINT_MEMBERS: dict[ + str, tuple[type[OKPKey] | type[ECKey] | type[RSAKey], tuple[str, ...]] +] = { + "OKP": (OKPKey, ("crv", "x")), + "EC": (ECKey, ("crv", "x", "y")), + "RSA": (RSAKey, ("e", "n")), +} + def jwk_thumbprint(key: Mapping[str, Any]) -> bytes | None: - """Return the RFC 7638 digest; incomplete public keys match nothing.""" - members = { - "OKP": ("crv", "kty", "x"), - "EC": ("crv", "kty", "x", "y"), - "RSA": ("e", "kty", "n"), - }.get(str(key.get("kty"))) - if members is None or any(not isinstance(key.get(m), str) or not key[m] for m in members): + """Return the RFC 7638 digest (``joserfc``); incomplete public keys match nothing. + + Only the public members go to ``joserfc``: a pin or directory entry is a + public key, and the thumbprint never depends on anything else. + """ + shape = _THUMBPRINT_MEMBERS.get(str(key.get("kty"))) + if shape is None: + return None + key_type, members = shape + if any(not isinstance(key.get(m), str) or not key[m] for m in members): + return None + try: + imported = key_type.import_key({"kty": key["kty"], **{m: key[m] for m in members}}) + return urlsafe_b64decode(imported.thumbprint().encode("ascii")) + except (JoseError, ValueError, TypeError): return None - canonical = json.dumps({m: key[m] for m in members}, sort_keys=True, separators=(",", ":")) - return hashlib.sha256(canonical.encode("utf-8")).digest() def matches_publisher_pins(key: Mapping[str, Any], pins: PublisherPins, *, now: float) -> bool: diff --git a/src/adcp/signing/signer.py b/src/adcp/signing/signer.py index 63478ee2e..47c6954cc 100644 --- a/src/adcp/signing/signer.py +++ b/src/adcp/signing/signer.py @@ -2,6 +2,9 @@ Produces the `Signature-Input`, `Signature`, and (optionally) `Content-Digest` headers for a request, per the verifier checklist the other side will run. +``http-message-signatures`` builds the signature base and serializes +``Signature-Input``; this module applies the profile's choices (covered +components, window, nonce, tag, and the 3.0/3.1 Base64URL ``Signature``). Two entry points share the same canonicalization spine: @@ -21,11 +24,11 @@ from dataclasses import dataclass from typing import Literal -from adcp.signing.canonical import ( - SignatureInputLabel, - _lookup, - build_signature_base, -) +from cryptography.hazmat.primitives.asymmetric import ed25519 +from http_message_signatures.exceptions import HTTPMessageSignaturesException + +from adcp.signing import _rfc9421 +from adcp.signing.canonical import _lookup from adcp.signing.constants import ( DEFAULT_EXPIRES_IN_SECONDS, DEFAULT_TAG, @@ -39,8 +42,8 @@ ALLOWED_ALGS, PrivateKey, b64url_encode, + extract_signature_bytes, format_signature_header, - sign_signature_base, ) from adcp.signing.digest import compute_content_digest_sha256 from adcp.signing.provider import SigningProvider @@ -67,72 +70,19 @@ def as_dict(self) -> dict[str, str]: return headers -_SF_STRING_ALLOWED: frozenset[str] = frozenset(chr(c) for c in range(0x20, 0x7F)) - -# RFC 8941 §3.1.2 key grammar: ( lcalpha / "*" ) *( lcalpha / DIGIT / "_" / "-" / "." / "*" ) -_SF_KEY_HEAD_ALLOWED: frozenset[str] = frozenset("abcdefghijklmnopqrstuvwxyz*") -_SF_KEY_TAIL_ALLOWED: frozenset[str] = frozenset("abcdefghijklmnopqrstuvwxyz0123456789_-.*") - - -def _validate_sf_key(value: str, *, field: str) -> str: - """Validate ``value`` as an RFC 8941 §3.1.2 sf-key (token). - - Sig-labels are emitted unquoted in both ``Signature-Input`` and - ``Signature`` headers, so they cannot use sf-string escaping — - they must be valid tokens. Anything outside the §3.1.2 grammar - would either inject extra header bytes (CRLF) or produce a - label conformant verifiers parse differently. - """ - if not value: - raise ValueError(f"{field} must be a non-empty RFC 8941 sf-key") - if value[0] not in _SF_KEY_HEAD_ALLOWED: - raise ValueError( - f"{field}={value!r} is not a valid RFC 8941 sf-key — must start with " - "a lowercase letter or '*'" - ) - bad = next((c for c in value[1:] if c not in _SF_KEY_TAIL_ALLOWED), None) - if bad is not None: - raise ValueError( - f"{field}={value!r} is not a valid RFC 8941 sf-key — character " - f"{bad!r} (codepoint {ord(bad):#06x}) is not allowed; " - "only [a-z0-9_-.*] permitted after the first character" - ) - return value - - -def _escape_sf_string(value: str, *, field: str) -> str: - """Escape ``value`` for embedding in an RFC 8941 §3.3.3 sf-string. - - The §3.3.3 grammar permits only printable ASCII 0x20-0x7E; ``"`` - and ``\\`` require escaping (and are the only escapes). Control - bytes and non-ASCII have no representation at all and are rejected - here — passing one through would emit a header line that conformant - verifiers parse differently from this serializer (a parser-divergence - bug class) and, in the CRLF case, can also turn into HTTP header - injection at non-httpx integrators that don't sanitize embedded - line terminators. - """ - bad = next((c for c in value if c not in _SF_STRING_ALLOWED), None) - if bad is not None: - raise ValueError( - f"{field} contains character {bad!r} (codepoint {ord(bad):#06x}) " - "not allowed in RFC 8941 sf-string — only printable ASCII 0x20-0x7E " - "may appear in keyid, nonce, or tag values" - ) - return value.replace("\\", "\\\\").replace('"', '\\"') - - @dataclass(frozen=True) -class _PreparedSignature: - """Result of canonicalizing a request — everything except the raw signature.""" - - base: bytes - raw_value: str - label: str +class _PreparedRequest: + """Everything about a request to sign except the signature itself.""" + + message: _rfc9421.Request + covered_component_ids: tuple[str, ...] + created: int + expires: int + nonce: str content_digest_value: str | None -def _prepare_signature( +def _prepare_request( *, method: str, url: str, @@ -146,13 +96,15 @@ def _prepare_signature( nonce: str | None, tag: str, label: str, -) -> _PreparedSignature: - """Canonicalize a request into a signable RFC 9421 base. +) -> _PreparedRequest: + """Apply the AdCP profile's signer rules; the library does the RFC 9421 work. Pure: takes only the request shape and the key metadata that lands in - ``Signature-Input``. The actual key material (private key OR - SigningProvider) is applied separately so the sync and async signers - produce byte-identical bases. + ``Signature-Input``. The key material (private key or SigningProvider) is + applied separately, and the sync and async signers produce byte-identical + headers. Values that RFC 8941 cannot carry (a control character in + ``key_id``, a label outside the key grammar) are refused by the + ``http-sf`` and ``http-message-signatures`` serializers. """ if alg not in ALLOWED_ALGS: raise ValueError(f"alg must be one of {sorted(ALLOWED_ALGS)}, got {alg!r}") @@ -160,13 +112,20 @@ def _prepare_signature( raise ValueError( f"expires_in_seconds must be in (0, {MAX_WINDOW_SECONDS}], got {expires_in_seconds}" ) - if not key_id: - raise ValueError("key_id must be a non-empty string") - _validate_sf_key(label, field="label") + for name, value in (("key_id", key_id), ("label", label), ("tag", tag)): + if not value: + raise ValueError(f"{name} must be a non-empty string") + if nonce is not None and not nonce: + raise ValueError("nonce must be a non-empty string") + try: + # A label is an RFC 8941 key in both fields; refuse a bad one before + # anything is signed. + _rfc9421.serialize({label: (True, {})}) + except ValueError as exc: + raise ValueError(f"label {label!r} is not a valid RFC 8941 key: {exc}") from exc if created is None: created = int(time.time()) - expires = created + expires_in_seconds if nonce is None: nonce = b64url_encode(secrets.token_bytes(NONCE_BYTES)) @@ -177,54 +136,36 @@ def _prepare_signature( components.append("content-type") if cover_content_digest: content_digest_value = compute_content_digest_sha256(body) + outgoing_headers = { + k: v for k, v in outgoing_headers.items() if k.lower() != "content-digest" + } outgoing_headers["Content-Digest"] = content_digest_value components.append("content-digest") - - comp_serialized = "(" + " ".join(f'"{c}"' for c in components) + ")" - nonce_escaped = _escape_sf_string(nonce, field="nonce") - key_id_escaped = _escape_sf_string(key_id, field="key_id") - tag_escaped = _escape_sf_string(tag, field="tag") - params_serialized = ( - f';created={created};expires={expires};nonce="{nonce_escaped}"' - f';keyid="{key_id_escaped}";alg="{alg}";tag="{tag_escaped}"' - ) - raw_value = comp_serialized + params_serialized - - parsed = SignatureInputLabel( - label=label, - components=tuple(components), - params={ - "created": created, - "expires": expires, - "nonce": nonce, - "keyid": key_id, - "alg": alg, - "tag": tag, - }, - raw_value=raw_value, - ) - base = build_signature_base( - method=method, url=url, headers=outgoing_headers, parsed=parsed - ).encode("utf-8") - return _PreparedSignature( - base=base, - raw_value=raw_value, - label=label, + return _PreparedRequest( + message=_rfc9421.Request(method=method, url=url, headers=outgoing_headers), + covered_component_ids=tuple( + _rfc9421.component_identifier((name, {})) for name in components + ), + created=created, + expires=created + expires_in_seconds, + nonce=nonce, content_digest_value=content_digest_value, ) -def _assemble_headers( - prepared: _PreparedSignature, - sig_bytes: bytes, +def _signed_headers( + prepared: _PreparedRequest, *, + signature_input: str, + signature: bytes, + label: str, signing_profile_version: Literal["3.0", "3.1", "3.2"], ) -> SignedHeaders: return SignedHeaders( - signature_input=f"{prepared.label}={prepared.raw_value}", + signature_input=signature_input, signature=format_signature_header( - sig_bytes, - label=prepared.label, + signature, + label=label, use_legacy_base64url=signing_profile_version != "3.2", ), content_digest=prepared.content_digest_value, @@ -276,6 +217,11 @@ def sign_request( if agent_origin is not None: from adcp.signing.wba import public_directory_key, sign_wba_request + if not isinstance(private_key, ed25519.Ed25519PrivateKey): + raise ValueError( + "Web Bot Auth signs with ed25519 or rsa-pss-sha512; ES256 is an AdCP " + "RFC 9421 profile algorithm only" + ) if key_id != public_directory_key(private_key)["kid"]: raise ValueError("Web Bot Auth key_id must be the public key's RFC 7638 thumbprint") result = sign_wba_request( @@ -301,7 +247,7 @@ def sign_request( body=body, cover_content_digest=cover_content_digest, ) - prepared = _prepare_signature( + prepared = _prepare_request( method=method, url=url, headers=headers, @@ -315,10 +261,30 @@ def sign_request( tag=tag, label=label, ) - sig_bytes = sign_signature_base(alg=alg, private_key=private_key, signature_base=prepared.base) - return _assemble_headers( + try: + # Fails before signing when the key does not fit ``alg``. + _rfc9421.ALGORITHMS[alg](private_key=private_key) + except HTTPMessageSignaturesException as exc: + expected = "Ed25519 key" if alg == ALG_ED25519 else "EC P-256 key" + raise ValueError(f"alg/private_key mismatch: expected {expected}") from exc + signature_input, signature = _rfc9421.sign( + prepared.message, + alg=alg, + private_key=private_key, + key_id=key_id, + label=label, + created=prepared.created, + expires=prepared.expires, + nonce=prepared.nonce, + tag=tag, + covered_component_ids=prepared.covered_component_ids, + resolver=_rfc9421.AdcpComponentResolver, + ) + return _signed_headers( prepared, - sig_bytes, + signature_input=signature_input, + signature=extract_signature_bytes(signature, label, allow_legacy_base64url=False), + label=label, signing_profile_version=signing_profile_version, ) @@ -359,13 +325,15 @@ async def async_sign_request( body=body, cover_content_digest=cover_content_digest, ) - prepared = _prepare_signature( + key_id = provider.key_id() + alg = provider.algorithm() + prepared = _prepare_request( method=method, url=url, headers=headers, body=body, - key_id=provider.key_id(), - alg=provider.algorithm(), + key_id=key_id, + alg=alg, cover_content_digest=resolved_cover_content_digest, created=created, expires_in_seconds=expires_in_seconds, @@ -373,10 +341,25 @@ async def async_sign_request( tag=tag, label=label, ) - sig_bytes = await provider.sign(prepared.base) - return _assemble_headers( + # The same base and parameters HTTPMessageSigner.sign produces, so the + # headers match sign_request byte for byte. + base, member = _rfc9421.signature_base( + prepared.message, + alg=alg, + key_id=key_id, + created=prepared.created, + expires=prepared.expires, + nonce=prepared.nonce, + tag=tag, + covered_component_ids=prepared.covered_component_ids, + resolver=_rfc9421.AdcpComponentResolver, + ) + signature = await provider.sign(base) + return _signed_headers( prepared, - sig_bytes, + signature_input=f"{label}={member}", + signature=signature, + label=label, signing_profile_version=signing_profile_version, ) diff --git a/src/adcp/signing/verifier.py b/src/adcp/signing/verifier.py index 84ac75f53..272ad254d 100644 --- a/src/adcp/signing/verifier.py +++ b/src/adcp/signing/verifier.py @@ -4,6 +4,11 @@ defined in `security.mdx`. Each step either passes or raises `SignatureVerificationError` with the exact code from the transport error taxonomy — conformance requires byte-for-byte match on the code string. + +RFC 9421 comes from libraries: ``http-sf`` parses the fields at step 1, and +``http-message-signatures`` builds the signature base and verifies it at +step 10. The checklist order, the profile checks between those steps, and +the mapping of every failure onto an AdCP code stay here. """ from __future__ import annotations @@ -15,12 +20,12 @@ from datetime import datetime, timezone from typing import Any, Literal +from adcp.signing import _rfc9421 from adcp.signing._header_precheck import strict_header_precheck from adcp.signing._strict_json import StrictJsonError, body_is_empty, parse_strict_json from adcp.signing.canonical import ( TargetUriMalformedError, _lookup, - build_signature_base, parse_signature_input_header, ) from adcp.signing.constants import ( @@ -36,8 +41,8 @@ ALLOWED_ALGS, alg_for_jwk, extract_signature_bytes, + format_signature_header, public_key_from_jwk, - verify_signature, ) from adcp.signing.digest import content_digest_matches from adcp.signing.errors import ( @@ -82,6 +87,7 @@ REQUIRED_COMPONENTS = ("@method", "@target-uri", "@authority") REQUIRED_PARAMS = ("created", "expires", "nonce", "keyid", "alg", "tag") _INT_PARAMS = frozenset({"created", "expires"}) +_SIGNATURE_FIELDS = frozenset({"signature", "signature-input"}) _STR_PARAMS = frozenset({"nonce", "keyid", "alg", "tag"}) # Defensive upper bound against log/dict-key poisoning. RFC 7517 has no hard kid @@ -432,42 +438,57 @@ def verify_request_signature( ) try: - base = build_signature_base(method=method, url=url, headers=headers, parsed=parsed).encode( - "utf-8" - ) - except TargetUriMalformedError as exc: - # MUST precede the (ValueError, KeyError) clause below: TargetUriMalformedError - # IS a ValueError, so reordering these two silently downgrades every - # malformed-authority rejection to request_signature_header_malformed - # with the suite still green. test_target_uri_malformed_boundary.py - # guards the ordering. - raise SignatureVerificationError( - exc.code, - step=6, - message=f"signature base construction failed: {exc}", - ) from exc + public_key = public_key_from_jwk(jwk) except (ValueError, KeyError) as exc: raise SignatureVerificationError( REQUEST_SIGNATURE_HEADER_MALFORMED, - step=6, - message=f"signature base construction failed: {exc}", + step=8, + message=f"JWK decode failed: {exc}", ) from exc + # Step 10: http-message-signatures builds the base and verifies. The + # selected label is handed over alone, re-serialized, with its signature + # in RFC 8941 form, so the other labels the profile ignores (and the + # 3.0/3.1 Base64URL spelling) never reach the library. + message = _rfc9421.Request( + method=method, + url=url, + headers={ + **{k: v for k, v in headers.items() if k.lower() not in _SIGNATURE_FIELDS}, + "Signature-Input": f"{options.label}={parsed.raw_value}", + "Signature": format_signature_header(sig_bytes, label=options.label), + }, + ) try: - public_key = public_key_from_jwk(jwk) - except (ValueError, KeyError) as exc: + _rfc9421.verify( + message, + label=options.label, + tag=str(parsed.params["tag"]), + alg=alg, + public_key=public_key, + resolver=_rfc9421.AdcpComponentResolver, + parameters=tuple(parsed.params), + ) + except _rfc9421.SignatureBaseError as exc: + if isinstance(exc.cause, TargetUriMalformedError): + # A malformed authority keeps its own code (step 6 of the + # canonicalization rules), not request_signature_header_malformed. + # test_target_uri_malformed_boundary.py guards the distinction. + raise SignatureVerificationError( + exc.cause.code, + step=6, + message=f"signature base construction failed: {exc.cause}", + ) from exc raise SignatureVerificationError( REQUEST_SIGNATURE_HEADER_MALFORMED, - step=8, - message=f"JWK decode failed: {exc}", + step=6, + message=f"signature base construction failed: {exc}", ) from exc - if not verify_signature( - alg=alg, public_key=public_key, signature_base=base, signature=sig_bytes - ): + except _rfc9421.SignatureMismatchError as exc: raise SignatureVerificationError( REQUEST_SIGNATURE_INVALID, step=10, message="signature did not verify over the computed base", - ) + ) from exc body_authenticated = False if "content-digest" in parsed.components: diff --git a/src/adcp/signing/wba.py b/src/adcp/signing/wba.py index 3d8535822..205adf1f0 100644 --- a/src/adcp/signing/wba.py +++ b/src/adcp/signing/wba.py @@ -9,7 +9,16 @@ Web Bot Auth statuses and headers (DR-0023): 400, 403 with or without ``Accept-Signature``, and 429. -Governance JWS verification continues to use the RFC 9421 discovery chain. +The profile is adcontextprotocol/adcp#7894 (``wba-profile.mdx``): one +``web-bot-auth`` signature and one ``Signature-Agent`` member per request, +``ed25519`` or ``rsa-pss-sha512``, a 64-byte nonce on every request, and key +directories signed per WBA Appendix B.1. RFC 9421 itself comes from +``http-message-signatures`` (signature bases, signing, verification) and +``http-sf`` (Structured Fields); this module keeps the profile rules. + +Governance tokens under this profile are checked by +:func:`verify_wba_governance_issuer` (key in the ``iss`` origin's directory, +``iss`` is the buyer's governance agent). """ from __future__ import annotations @@ -28,24 +37,15 @@ from urllib.parse import urlsplit import httpx -from cryptography.exceptions import InvalidSignature -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import ec, ed25519, padding, rsa -from http_sfv.dictionary import Dictionary -from http_sfv.item import InnerList, Item +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519, rsa +from adcp.signing import _rfc9421 from adcp.signing._bounded_http import async_read_limited_bytes from adcp.signing._header_precheck import strict_header_precheck from adcp.signing._strict_json import StrictJsonError, parse_strict_json -from adcp.signing._structured_fields import dictionary_has_parameter, reject_dictionary_duplicates from adcp.signing.canonical import _lookup, canonical_origin -from adcp.signing.crypto import ( - b64url_decode, - b64url_encode, - public_key_from_jwk, - sign_signature_base, - verify_signature, -) +from adcp.signing.crypto import b64url_decode, b64url_encode, public_key_from_jwk from adcp.signing.digest import compute_content_digest_sha256, content_digest_matches from adcp.signing.jwks import SSRFValidationError, validate_uri_static from adcp.signing.publisher_pins import jwk_thumbprint @@ -57,11 +57,14 @@ DIRECTORY_TAG = "http-message-signatures-directory" DIRECTORY_PATH = "/.well-known/http-message-signatures-directory" DIRECTORY_MEDIA_TYPE = "application/http-message-signatures-directory+json" +ALG_ED25519 = "ed25519" ALG_RSA_PSS_SHA512 = "rsa-pss-sha512" +#: The algorithms the profile allows (``wba-profile.mdx``, Profile rules). +#: ES256 is an AdCP RFC 9421 profile algorithm only. +WBA_ALGORITHMS: frozenset[str] = frozenset({ALG_ED25519, ALG_RSA_PSS_SHA512}) -#: The signer's nonce is 64 random bytes (RECOMMENDED by the architecture -#: draft §4.2.2, and required by Cloudflare's ``web-bot-auth`` verifier). The -#: verifier accepts any 22-256 character nonce (see :func:`valid_wba_nonce`). +#: Every signed request carries a nonce of exactly 64 random bytes +#: (``wba-profile.mdx``, Profile rules; the architecture draft §4.2.2). WBA_NONCE_BYTES = 64 #: Seconds :func:`sign_key_directory` backdates ``created`` by, so a #: verifier whose clock is slightly behind still accepts a fresh directory. @@ -81,6 +84,8 @@ _REQUEST_SKEW_SECONDS = 60 _RSA_MIN_BITS, _RSA_MAX_BITS = 2048, 8192 _PRIVATE_JWK_MEMBERS = frozenset({"d", "p", "q", "dp", "dq", "qi", "k", "oth"}) +_REQUEST_COMPONENTS = ("@method", "@target-uri", "@authority") +_BODY_COMPONENTS = ("content-type", "content-digest") #: The one body every signature-path failure carries, whatever the cause, so #: the response is no oracle. The specific reason is for server logs only. @@ -107,9 +112,10 @@ def _new_nonce() -> str: def is_64_byte_nonce(nonce: object) -> bool: """Whether ``nonce`` encodes exactly 64 bytes, canonically. - The architecture draft (§4.2.2) RECOMMENDS 64 random bytes, as unpadded - base64url or padded standard base64, and Cloudflare's ``web-bot-auth`` - verifier requires it. This SDK's signer always sends one. + The profile's spelling is unpadded base64url, which this SDK's signer + sends. Padded standard base64 of 64 bytes is accepted too: Cloudflare's + ``web-bot-auth`` signer and the protocol draft's Appendix E vectors emit + it, and the bytes are what make the nonce. """ if type(nonce) is not str or not nonce.isascii(): return False @@ -130,18 +136,13 @@ def is_64_byte_nonce(nonce: object) -> bool: def valid_wba_nonce(nonce: object) -> bool: - """Whether a received nonce is acceptable: 22 to 256 printable characters. + """Whether a received nonce is acceptable: exactly 64 bytes (see :func:`is_64_byte_nonce`). - 64 bytes is RECOMMENDED for signers (architecture draft §4.2.2), and - httpsig-02 adds no nonce requirement, so a compliant signer may send a - shorter nonce. The 22-character floor keeps at least 128 bits of entropy - in a base64 encoding. + The profile requires a 64-byte nonce on every signed request, reads + included (``wba-profile.mdx``, Profile rules and Replay protection). A + shorter nonce is a profile failure: 403 with ``Accept-Signature``. """ - return ( - type(nonce) is str - and 22 <= len(nonce) <= 256 - and all(0x21 <= ord(c) <= 0x7E for c in nonce) - ) + return is_64_byte_nonce(nonce) class WbaVerificationError(ValueError): @@ -149,7 +150,8 @@ class WbaVerificationError(ValueError): ``status`` is 400 (a field does not parse), 403 (a signature, profile check, or key discovery fails; with ``Accept-Signature`` unless the key is - revoked) or 429 (capacity). Answer it with + revoked) or 429 (a reused nonce, with ``Accept-Signature`` carrying a + fresh nonce; or local capacity, without it). Answer it with :data:`GENERIC_AUTHENTICATION_ERROR`; ``reason`` is for logs only and must never be sent to the caller. """ @@ -164,41 +166,43 @@ def __init__( status: int = 403, label: str = "sig1", challenge: bool = True, + fresh_nonce: bool = False, ) -> None: super().__init__(reason) self.reason = reason self.status = status self.label = label - self.headers: dict[str, str] = {} - if status == 403 and challenge: - self.headers["Accept-Signature"] = accept_signature_value(label) + self.challenge = challenge and status in {403, 429} + self._nonce = _new_nonce() if fresh_nonce else None + @property + def headers(self) -> dict[str, str]: + """``Accept-Signature`` for a 403 or a replay 429, else no headers.""" + if not self.challenge: + return {} + return {"Accept-Signature": accept_signature_value(self.label, nonce=self._nonce)} -def accept_signature_value(label: str = "sig1") -> str: + +def accept_signature_value(label: str = "sig1", *, nonce: str | None = None) -> str: """``Accept-Signature`` naming what the profile requires (RFC 9421 §5.1). - Parameters indicate requirements, not values to sign; the nonce is a - fresh 64-byte value the signer may use on its next attempt. + The components and parameters of ``wba-profile.mdx``, Error responses: + parameters name requirements, not values to sign. A replay answer passes + ``nonce``, the fresh value the signer uses on its next attempt. """ - member = InnerList( - [ - Item(c) - for c in ("@method", "@target-uri", "@authority", "content-type", "content-digest") - ] - ) - agent = Item("signature-agent") - agent.params["key"] = label - member.append(agent) - member.params.update( - { - "created": True, - "expires": True, - "keyid": True, - "nonce": _new_nonce(), - "tag": WBA_TAG, - } - ) - return str(Dictionary({label: member})) + components: list[tuple[str, dict[str, Any]]] = [ + (name, {}) for name in (*_REQUEST_COMPONENTS, *_BODY_COMPONENTS) + ] + components.append(("signature-agent", {"key": label})) + params: dict[str, Any] = { + "created": True, + "expires": True, + "nonce": nonce if nonce is not None else True, + "keyid": True, + "alg": True, + "tag": WBA_TAG, + } + return _rfc9421.serialize({label: (components, params)}) class WbaDiscoveryError(WbaVerificationError): @@ -242,19 +246,15 @@ class WbaUnavailableError(RuntimeError): """ -def _dictionary(value: str | None, *, missing_status: int = 400) -> Dictionary: +def _dictionary(value: str | None, *, missing_status: int = 400) -> dict[str, Any]: + """Parse a signature field with http-sf; a field that does not parse is 400.""" if value is None: raise WbaVerificationError("missing signature field", status=missing_status) if len(value) > 65536: raise WbaVerificationError("oversized signature field", status=400) try: - reject_dictionary_duplicates(value) - result = Dictionary() - result.parse(value.encode("ascii")) - if not result: - raise ValueError("empty dictionary") - return result - except (ValueError, UnicodeError) as exc: + return _rfc9421.parse_dictionary(value) + except ValueError as exc: raise WbaVerificationError(f"malformed signature field: {exc}", status=400) from exc @@ -267,7 +267,7 @@ def uses_web_bot_auth(headers: Mapping[str, str]) -> bool: return False # Observe every explicit tag, including one an ambiguous duplicate would # overwrite. Text inside nonce/component strings cannot select a profile. - return dictionary_has_parameter(raw, "tag", WBA_TAG) + return _rfc9421.dictionary_has_parameter(raw, "tag", WBA_TAG) def _https_origin(url: str) -> str: @@ -289,7 +289,7 @@ def _https_origin(url: str) -> str: def thumbprint_key_id(jwk: Mapping[str, Any]) -> str: - """Return an RFC 7638 thumbprint for supported public key material.""" + """Return the RFC 7638 thumbprint (``joserfc``) for supported public key material.""" digest = jwk_thumbprint(jwk) if digest is None: raise ValueError("incomplete public JWK") @@ -300,14 +300,14 @@ def _int_member(value: int) -> str: return b64url_encode(value.to_bytes((value.bit_length() + 7) // 8, "big")) -WbaPrivateKey = ed25519.Ed25519PrivateKey | ec.EllipticCurvePrivateKey | rsa.RSAPrivateKey +WbaPrivateKey = ed25519.Ed25519PrivateKey | rsa.RSAPrivateKey def public_directory_key(private_key: WbaPrivateKey) -> dict[str, Any]: """Export a WBA public JWK, with a thumbprint kid and no purpose claim. - Ed25519, P-256 and RSA (2048 to 8192 bits, used with ``rsa-pss-sha512``) - keys are supported. + Ed25519 and RSA (2048 to 8192 bits, used with ``rsa-pss-sha512``) keys + are supported: the profile's two algorithms. """ public = private_key.public_key() jwk: dict[str, Any] @@ -319,21 +319,13 @@ def public_directory_key(private_key: WbaPrivateKey) -> dict[str, Any]: public.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) ), } - elif isinstance(public, ec.EllipticCurvePublicKey) and isinstance(public.curve, ec.SECP256R1): - numbers = public.public_numbers() - jwk = { - "kty": "EC", - "crv": "P-256", - "x": b64url_encode(numbers.x.to_bytes(32, "big")), - "y": b64url_encode(numbers.y.to_bytes(32, "big")), - } elif isinstance(public, rsa.RSAPublicKey): rsa_numbers = public.public_numbers() if not _RSA_MIN_BITS <= public.key_size <= _RSA_MAX_BITS: raise ValueError("Web Bot Auth RSA keys must be 2048 to 8192 bits") jwk = {"kty": "RSA", "n": _int_member(rsa_numbers.n), "e": _int_member(rsa_numbers.e)} else: - raise ValueError("Web Bot Auth supports Ed25519, P-256 and RSA keys") + raise ValueError("Web Bot Auth supports Ed25519 and RSA keys") jwk.update({"kid": thumbprint_key_id(jwk), "use": "sig"}) return jwk @@ -362,17 +354,18 @@ def _rsa_public_key(jwk: Mapping[str, Any]) -> rsa.RSAPublicKey: def _algorithm(jwk: Mapping[str, Any]) -> str: - """Derive the signature algorithm and check the key may verify.""" + """Derive the signature algorithm and check the key may verify. + + Only the profile's algorithms qualify: an Ed25519 key signs ``ed25519`` + and an RSA key ``rsa-pss-sha512``. Any other key (P-256 included) is not + a Web Bot Auth key. + """ shape = (str(jwk.get("kty")), str(jwk.get("crv"))) - alg = { - ("OKP", "Ed25519"): "ed25519", - ("EC", "P-256"): "ecdsa-p256-sha256", - ("RSA", "None"): ALG_RSA_PSS_SHA512, - }.get(shape) + alg = {("OKP", "Ed25519"): ALG_ED25519, ("RSA", "None"): ALG_RSA_PSS_SHA512}.get(shape) if alg is None or jwk.get("use", "sig") != "sig" or _PRIVATE_JWK_MEMBERS & set(jwk): raise WbaVerificationError("unsupported directory key") # One key, one thumbprint: key members must be canonical base64url. - for member in ("x", "y", "n", "e"): + for member in ("x", "n", "e"): if member in jwk and not _canonical_b64url(jwk[member]): raise WbaVerificationError("directory key member is not canonical base64url") if alg == ALG_RSA_PSS_SHA512: @@ -380,7 +373,7 @@ def _algorithm(jwk: Mapping[str, Any]) -> str: _rsa_public_key(jwk) except (ValueError, TypeError) as exc: raise WbaVerificationError("unsupported RSA directory key") from exc - jws_alg = {"ed25519": "EdDSA", "ecdsa-p256-sha256": "ES256", ALG_RSA_PSS_SHA512: "PS512"}[alg] + jws_alg = {ALG_ED25519: "EdDSA", ALG_RSA_PSS_SHA512: "PS512"}[alg] if jwk.get("alg") not in {None, jws_alg}: raise WbaVerificationError("directory key algorithm mismatch") if "key_ops" in jwk and ( @@ -390,38 +383,10 @@ def _algorithm(jwk: Mapping[str, Any]) -> str: return alg -def _sign(alg: str, private_key: WbaPrivateKey, base: bytes) -> bytes: - if alg == ALG_RSA_PSS_SHA512: - if not isinstance(private_key, rsa.RSAPrivateKey): - raise ValueError("alg/private_key mismatch: expected an RSA key") - return private_key.sign( - base, - padding.PSS(mgf=padding.MGF1(hashes.SHA512()), salt_length=64), - hashes.SHA512(), - ) - if isinstance(private_key, rsa.RSAPrivateKey): - raise ValueError("alg/private_key mismatch: RSA keys sign with rsa-pss-sha512") - return sign_signature_base(alg=alg, private_key=private_key, signature_base=base) - - -def _verify(alg: str, jwk: Mapping[str, Any], base: bytes, signature: bytes) -> bool: +def _public_key(alg: str, jwk: Mapping[str, Any]) -> Any: if alg == ALG_RSA_PSS_SHA512: - try: - _rsa_public_key(jwk).verify( - signature, - base, - padding.PSS(mgf=padding.MGF1(hashes.SHA512()), salt_length=64), - hashes.SHA512(), - ) - return True - except InvalidSignature: - return False - return verify_signature( - alg=alg, - public_key=public_key_from_jwk(dict(jwk)), - signature_base=base, - signature=signature, - ) + return _rsa_public_key(jwk) + return public_key_from_jwk(dict(jwk)) def _received_authority(url: str) -> str: @@ -442,72 +407,59 @@ def _received_authority(url: str) -> str: return authority -def _target_uri(url: str) -> str: - """``@target-uri`` per RFC 9421 §2.2.2: the target URI as received, unchanged.""" - if any(ord(c) <= 0x20 or ord(c) == 0x7F for c in url) or not url.isascii(): - raise WbaVerificationError("request URL is not a valid target URI", status=400) - parts = urlsplit(url) - if parts.scheme.lower() not in {"http", "https"} or not parts.netloc or parts.fragment: - raise WbaVerificationError("request URL is not an absolute target URI") - return url +class _WbaComponentResolver(_rfc9421.ComponentResolver): + """Web Bot Auth components: the target URI as received, not canonicalized. + + ``@target-uri`` is the URI exactly as sent or received (RFC 9421 + §2.2.2), percent-encoding included; ``@authority`` is its normalized + authority. The AdCP origin canonicalization identifies the signer only. + """ + def get_target_uri(self) -> str: + url = self.url + if any(ord(c) <= 0x20 or ord(c) == 0x7F for c in url) or not url.isascii(): + raise _rfc9421.ComponentError("request URL is not a valid target URI", status=400) + parts = urlsplit(url) + if parts.scheme.lower() not in {"http", "https"} or not parts.netloc or parts.fragment: + raise _rfc9421.ComponentError("request URL is not an absolute target URI") + return url -def _base( - method: str, - url: str, - headers: Mapping[str, str], - member: InnerList, - *, - directory_response: bool = False, + def get_authority(self) -> str: + try: + return _received_authority(self.url) + except WbaVerificationError as exc: + raise _rfc9421.ComponentError(exc.reason) from exc + + +def _signature_base( + label: str, method: str, url: str, headers: Mapping[str, str], *, response: bool = False ) -> bytes: - """Build the RFC 9421 §2.5 signature base for ``member``. + """The signature base for ``label`` in ``headers["Signature-Input"]``. - ``url`` is the target URI as sent or received. ``@target-uri`` is that - string unchanged; ``@authority`` is its normalized authority. The - canonical origin applies only to the ``Signature-Agent`` identity. + For diagnostics and conformance tests: verification builds the same base + inside ``http-message-signatures``. """ - lines: list[str] = [] - for component in member: - name = component.value - if type(name) is not str: - raise WbaVerificationError("component must be a string", status=400) - params = component.params - if name.startswith("@"): - expected = {"req": True} if directory_response and name == "@authority" else {} - if dict(params) != expected: - raise WbaVerificationError("unsupported derived component parameters") - if name == "@method": - value = method.upper() - elif name == "@target-uri": - value = _target_uri(url) - elif name == "@authority": - value = _received_authority(url) - else: - raise WbaVerificationError("unsupported derived component") - else: - if name != name.lower(): - raise WbaVerificationError("covered field names must be lowercase") - raw = _lookup(headers, name) - if raw is None: - raise WbaVerificationError("missing covered field") - if any(c in raw for c in "\r\n\x00"): - raise WbaVerificationError("covered field carries a control character", status=400) - if params: - if set(params) != {"key"} or type(params["key"]) is not str: - raise WbaVerificationError("unsupported field component parameters") - dictionary = _dictionary(raw) - if params["key"] not in dictionary: - raise WbaVerificationError("missing covered dictionary member") - value = str(dictionary[params["key"]]) - else: - value = raw.strip(" \t") - lines.append(f"{component}: {value}") - lines.append(f'"@signature-params": {member}') - return "\n".join(lines).encode("utf-8") + member = _dictionary(_lookup(headers, "signature-input"))[label] + items, params = member + request = _rfc9421.Request(method=method, url=url, headers=dict(headers)) + message: _rfc9421.Request | _rfc9421.Response = ( + _rfc9421.Response( + status_code=200, request=_rfc9421.Request("GET", url), headers=dict(headers) + ) + if response + else request + ) + return _rfc9421.build_base( + message, + covered_component_ids=[_rfc9421.component_identifier(item) for item in items], + params=params, + resolver=_WbaComponentResolver, + ) -def _window(member: InnerList, *, now: float, directory: bool = False, label: str = "sig1") -> None: - params = member.params +def _window( + params: Mapping[str, Any], *, now: float, directory: bool = False, label: str = "sig1" +) -> None: if type(params.get("tag")) is not str: raise WbaVerificationError("signature tag must be an sf-string", label=label) for name in ("created", "expires"): @@ -527,19 +479,43 @@ def _window(member: InnerList, *, now: float, directory: bool = False, label: st raise WbaVerificationError("missing thumbprint keyid", label=label) -def _verify_crypto(member: InnerList, signature: Any, jwk: Mapping[str, Any], base: bytes) -> str: +def _verify_crypto( + message: _rfc9421.Request | _rfc9421.Response, + *, + label: str, + params: Mapping[str, Any], + signature: Any, + jwk: Mapping[str, Any], +) -> str: + """Verify ``label`` with ``http-message-signatures``; return the algorithm. + + The algorithm comes from the key (:func:`_algorithm`), and the signature's + ``alg`` must name the same one. + """ alg = _algorithm(jwk) - declared = member.params.get("alg", alg) + declared = params.get("alg", alg) if type(declared) is not str or declared != alg: raise WbaVerificationError("signature algorithm does not match key") - if not isinstance(signature, Item) or not isinstance(signature.value, bytes): + if not isinstance(signature, tuple) or not isinstance(signature[0], bytes): raise WbaVerificationError("signature must be sf-binary", status=400) try: - valid = _verify(alg, jwk, base, signature.value) + public_key = _public_key(alg, jwk) except (ValueError, KeyError, TypeError) as exc: raise WbaVerificationError("invalid directory key") from exc - if not valid: - raise WbaVerificationError("signature did not verify") + try: + _rfc9421.verify( + message, + label=label, + tag=str(params["tag"]), + alg=alg, + public_key=public_key, + resolver=_WbaComponentResolver, + parameters=tuple(params), + ) + except _rfc9421.SignatureBaseError as exc: + raise WbaVerificationError(exc.reason, status=exc.status) from exc + except _rfc9421.SignatureMismatchError as exc: + raise WbaVerificationError("signature did not verify") from exc return alg @@ -560,9 +536,11 @@ def sign_wba_request( ``url`` is the target URI exactly as the request will send it: ``@target-uri`` covers it unchanged (RFC 9421 §2.2.2), percent-encoding - included. The nonce defaults to 64 random bytes. A request that already - carries signature fields is refused rather than stripped: another - party's signature is never silently dropped. + included. The nonce defaults to 64 random bytes, and an explicit one must + be 64 bytes too. A request that already carries signature fields is + refused rather than stripped: another party's signature is never + silently dropped. ``http-message-signatures`` builds the base, signs, + and serializes ``Signature-Input`` and ``Signature``. """ origin = _https_origin(agent_origin) if not 0 < expires_in_seconds <= 300: @@ -575,53 +553,50 @@ def sign_wba_request( "request already carries signature fields; refusing to replace another " "party's signature" ) - from adcp.signing.signer import _validate_sf_key - - _validate_sf_key(label, field="label") + if not label: + raise ValueError("label must be a non-empty RFC 8941 key") jwk = public_directory_key(private_key) alg = _algorithm(jwk) created = int(time.time()) if created is None else created nonce = _new_nonce() if nonce is None else nonce if not valid_wba_nonce(nonce): - raise ValueError( - "nonce must carry at least 128 bits of entropy and be at most 256 characters" - ) - outgoing = {"Signature-Agent": str(Dictionary({label: Item(origin)}))} - components = [Item(c) for c in ("@method", "@target-uri", "@authority")] + raise ValueError("nonce must encode exactly 64 bytes") + try: + agent = _rfc9421.serialize({label: (origin, {})}) + except ValueError as exc: + raise ValueError(f"label {label!r} is not a valid RFC 8941 key: {exc}") from exc + outgoing = {"Signature-Agent": agent} + components = list(_REQUEST_COMPONENTS) if body: if _lookup(headers, "content-type") is None: raise ValueError("body-bearing WBA requests require Content-Type") outgoing["Content-Digest"] = compute_content_digest_sha256(body) - components.extend(Item(c) for c in ("content-type", "content-digest")) - agent = Item("signature-agent") - agent.params["key"] = label - components.append(agent) - member = InnerList(list(components)) - member.params.update( - { - "created": created, - "expires": created + expires_in_seconds, - "nonce": nonce, - "keyid": jwk["kid"], - "alg": alg, - "tag": WBA_TAG, - } + components.extend(_BODY_COMPONENTS) + covered = [_rfc9421.component_identifier((name, {})) for name in components] + covered.append(_rfc9421.component_identifier(("signature-agent", {"key": label}))) + message = _rfc9421.Request( + method=method, + url=url, + headers={ + **{k.lower(): v for k, v in headers.items()}, + **{k.lower(): v for k, v in outgoing.items()}, + }, ) - signature = _sign( - alg, - private_key, - _base( - method, - url, - { - **{k.lower(): v for k, v in headers.items()}, - **{k.lower(): v for k, v in outgoing.items()}, - }, - member, - ), + signature_input, signature = _rfc9421.sign( + message, + alg=alg, + private_key=private_key, + key_id=jwk["kid"], + label=label, + created=created, + expires=created + expires_in_seconds, + nonce=nonce, + tag=WBA_TAG, + covered_component_ids=covered, + resolver=_WbaComponentResolver, ) - outgoing["Signature-Input"] = str(Dictionary({label: member})) - outgoing["Signature"] = str(Dictionary({label: Item(signature)})) + outgoing["Signature-Input"] = signature_input + outgoing["Signature"] = signature return outgoing @@ -637,8 +612,8 @@ class DirectoryResponse: @dataclass(frozen=True) class _VerifiedDirectory: keys: dict[str, dict[str, Any]] - #: Earliest signature ``expires`` for a signed directory; ``None`` if unsigned. - signatures_expire: float | None + #: Earliest signature ``expires`` across the directory's signatures. + signatures_expire: float def _parse_directory_keys(body: bytes) -> dict[str, dict[str, Any]]: @@ -678,7 +653,7 @@ def _parse_directory_keys(body: bytes) -> dict[str, dict[str, Any]]: def _verify_directory( - origin: str, response: DirectoryResponse, *, now: float, require_signed: bool + origin: str, response: DirectoryResponse, *, now: float ) -> _VerifiedDirectory: origin = _https_origin(origin) media_type = (_lookup(response.headers, "content-type") or "").split(";", 1)[0] @@ -690,11 +665,9 @@ def _verify_directory( raise WbaVerificationError("invalid key directory response") raw_inputs = _lookup(response.headers, "signature-input") raw_signatures = _lookup(response.headers, "signature") - if raw_inputs is None and raw_signatures is None: - # Appendix B.1 signing is RECOMMENDED, not required. - if require_signed: - raise WbaVerificationError("key directory is not signed") - return _VerifiedDirectory(_parse_directory_keys(response.body), None) + if raw_inputs is None or raw_signatures is None: + # The profile requires signed directory responses (Appendix B.1). + raise WbaVerificationError("key directory is not signed") digest = _lookup(response.headers, "content-digest") # The digest covers the exact bytes served, a UTF-8 BOM included. if digest is None or not content_digest_matches(digest, response.body): @@ -704,47 +677,48 @@ def _verify_directory( signatures = _dictionary(raw_signatures) if set(signatures) != set(inputs): raise WbaVerificationError("directory signature labels differ") + message = _rfc9421.Response( + status_code=response.status, + request=_rfc9421.Request(method="GET", url=origin + DIRECTORY_PATH), + headers=dict(response.headers), + ) signed: dict[str, dict[str, Any]] = {} earliest = float("inf") for label, member in inputs.items(): - if not isinstance(member, InnerList) or member.params.get("tag") != DIRECTORY_TAG: + if not _rfc9421.is_inner_list(member) or member[1].get("tag") != DIRECTORY_TAG: raise WbaVerificationError("invalid directory signature profile") - _window(member, now=now, directory=True, label=label) - covered = {str(c) for c in member} + items, params = member + _window(params, now=now, directory=True, label=label) + covered = {_rfc9421.component_identifier(item) for item in items} # Exact identifiers: a "content-digest" member or a bare @authority # does not bind the response's digest and request authority. if not {'"@authority";req', '"content-digest"'} <= covered: raise WbaVerificationError("directory signature does not bind authority and digest") - keyid = member.params["keyid"] + keyid = params["keyid"] if keyid not in keys or keyid in signed: raise WbaVerificationError("directory signature key is absent or repeated") _verify_crypto( - member, - signatures.get(label), - keys[keyid], - _base( - "GET", origin + DIRECTORY_PATH, response.headers, member, directory_response=True - ), + message, label=label, params=params, signature=signatures.get(label), jwk=keys[keyid] ) signed[keyid] = keys[keyid] - earliest = min(earliest, float(member.params["expires"])) - # In a signed directory only keys carrying a verified possession proof - # are usable; an unsigned key alongside them is ignored. + earliest = min(earliest, float(params["expires"])) + # Only keys carrying a verified possession proof are usable; an unsigned + # key alongside them is ignored. return _VerifiedDirectory(signed, earliest) def verify_directory_response( - origin: str, response: DirectoryResponse, *, now: float, require_signed: bool = False + origin: str, response: DirectoryResponse, *, now: float ) -> dict[str, dict[str, Any]]: """Validate a key directory served at ``origin``; return keys by thumbprint. - A plain, unsigned JWK Set is accepted unless ``require_signed``; every key - is indexed by its RFC 7638 thumbprint (a ``kid`` is optional). When the - response carries signatures (WBA Appendix B.1), every one must verify, - cover exactly ``"@authority";req`` and ``"content-digest"``, and only the - keys they prove are returned. + The response must be signed per WBA Appendix B.1, as the profile + requires: every signature must verify with a key the directory holds, + cover ``"@authority";req`` and ``"content-digest"``, and only the keys + those signatures prove are returned. An unsigned directory is refused. + Every key is indexed by its RFC 7638 thumbprint (a ``kid`` is optional). """ - return _verify_directory(origin, response, now=now, require_signed=require_signed).keys + return _verify_directory(origin, response, now=now).keys def sign_key_directory( @@ -756,10 +730,11 @@ def sign_key_directory( ) -> DirectoryResponse: """Generate a signed directory response; republish before its expires time. - With no explicit ``created``, the signatures are backdated by - :data:`DIRECTORY_SKEW_ALLOWANCE_SECONDS` (verifiers allow little clock - skew on directory signatures) and still expire ``expires_in_seconds`` - from now. + One signature per key (WBA Appendix B.1), each from + ``HTTPMessageSigner.sign``. With no explicit ``created``, the signatures + are backdated by :data:`DIRECTORY_SKEW_ALLOWANCE_SECONDS` (verifiers + allow little clock skew on directory signatures) and still expire + ``expires_in_seconds`` from now. """ origin = _https_origin(agent_origin) if not 1 <= len(private_keys) <= _DIRECTORY_MAX_KEYS or expires_in_seconds <= 0: @@ -778,31 +753,26 @@ def sign_key_directory( created, expires = now - DIRECTORY_SKEW_ALLOWANCE_SECONDS, now + expires_in_seconds else: expires = created + expires_in_seconds - inputs, signatures = Dictionary(), Dictionary() + request = _rfc9421.Request(method="GET", url=origin + DIRECTORY_PATH) + inputs: list[str] = [] + signatures: list[str] = [] for number, (key, jwk) in enumerate(zip(private_keys, keys), 1): - authority = Item("@authority") - authority.params["req"] = True - member = InnerList([authority, Item("content-digest")]) - alg = _algorithm(jwk) - member.params.update( - { - "created": created, - "expires": expires, - "keyid": jwk["kid"], - "alg": alg, - "tag": DIRECTORY_TAG, - } + signature_input, signature = _rfc9421.sign( + _rfc9421.Response(status_code=200, request=request, headers=dict(headers)), + alg=_algorithm(jwk), + private_key=key, + key_id=jwk["kid"], + label=f"key{number}", + created=created, + expires=expires, + nonce=None, + tag=DIRECTORY_TAG, + covered_component_ids=['"@authority";req', '"content-digest"'], + resolver=_WbaComponentResolver, ) - label = f"key{number}" - inputs[label] = member - signatures[label] = Item( - _sign( - alg, - key, - _base("GET", origin + DIRECTORY_PATH, headers, member, directory_response=True), - ) - ) - headers.update({"Signature-Input": str(inputs), "Signature": str(signatures)}) + inputs.append(signature_input) + signatures.append(signature) + headers.update({"Signature-Input": ", ".join(inputs), "Signature": ", ".join(signatures)}) return DirectoryResponse(body=body, headers=headers) @@ -901,7 +871,6 @@ def __init__( allow_private: bool = False, max_origins: int = 256, max_origins_per_operator: int = 8, - require_signed_directories: bool = False, max_stale_seconds: float = DEFAULT_DIRECTORY_MAX_STALE_SECONDS, clock: Callable[[], float] = time.time, legacy_resolver: SignerKeyResolver | None = None, @@ -923,7 +892,6 @@ def __init__( clock, ) self._max_origins_per_operator = max_origins_per_operator - self._require_signed = require_signed_directories self._max_stale = float(max_stale_seconds) self._cache: OrderedDict[str, _DirectoryEntry] = OrderedDict() self._failures: OrderedDict[str, float] = OrderedDict() @@ -994,7 +962,7 @@ async def __call__( entry.expires, entry.refresh_at + _DIRECTORY_CACHE_SECONDS ): return self._lookup_key(origin, entry, keyid, stale=True) - raise WbaVerificationError(refused, status=429) + raise WbaVerificationError(refused, status=429, challenge=False) task = asyncio.create_task(self._refresh(origin)) # A cancelled HTTP request must not cancel discovery for other # waiters or leave an unobserved exception in a background task. @@ -1028,9 +996,7 @@ async def _refresh(self, origin: str) -> _DirectoryEntry: if not isinstance(response, DirectoryResponse) or response.status != 200: # Any status other than 200 is a discovery failure (§5.5). raise WbaVerificationError("key directory fetch did not return 200") - verified = _verify_directory( - origin, response, now=self._clock(), require_signed=self._require_signed - ) + verified = _verify_directory(origin, response, now=self._clock()) except asyncio.CancelledError: raise except Exception as exc: @@ -1046,9 +1012,7 @@ async def _refresh(self, origin: str) -> _DirectoryEntry: self._failures.popitem(last=False) raise WbaDiscoveryError("key directory could not be resolved") from exc now = self._clock() - expires = now + self._max_stale - if verified.signatures_expire is not None: - expires = min(expires, verified.signatures_expire) + expires = min(now + self._max_stale, verified.signatures_expire) entry = _DirectoryEntry( response, verified.keys, min(now + _DIRECTORY_CACHE_SECONDS, expires), expires ) @@ -1074,31 +1038,34 @@ async def verify_wba_request( revocation_checker: Any = None, now: float | None = None, raw_headers: Sequence[tuple[bytes, bytes]] | None = None, - replay_status: Literal[403, 429] = 429, authorities: Collection[str] | None = None, ) -> tuple[VerifiedSigner, ...]: - """Verify every WBA signature and relay coverage; return established origins. + """Verify the request's one WBA signature; return the established origin. + + The verification procedure of ``wba-profile.mdx``: the request carries + exactly one ``web-bot-auth`` signature and exactly one ``Signature-Agent`` + member, keyed to that signature's label. The result is a one-element + tuple holding that signer. ``url`` is the target URI as received (RFC 9421 §2.2.2): build it from the request line, not a decoded path. Verification needs only the key directory (``signer_keys``) and the replay store, reads no trust record - and grants no authority. The first signer is the originating signer and - the AdCP caller; later ones are relays that covered it. + and grants no authority. Failures raise :class:`WbaVerificationError` (or its :class:`WbaDiscoveryError` subclass for an unresolvable directory), whose - status and headers are the whole answer. A replay-store failure raises - :class:`WbaUnavailableError`, an application-layer outage. - - A replayed nonce is 429 by default; ``replay_status=403`` is a - compatibility switch for #7894's older replay vector. ``authorities`` - lists the ``host[:port]`` values this endpoint serves; a request whose - ``@authority`` is not among them is refused before key discovery. + status and headers are the whole answer: 400 when a field does not parse, + 403 with ``Accept-Signature`` for any other signature failure, and 429 + with ``Accept-Signature`` carrying a fresh nonce for a reused nonce. A + replay-store failure raises :class:`WbaUnavailableError`, an + application-layer outage. ``authorities`` lists the ``host[:port]`` + values this endpoint serves; a request whose ``@authority`` is not among + them is refused before key discovery. ``revocation_checker`` is optional and belongs to the authorization layer. When given, :func:`check_wba_revocation` runs after verification: - a revoked key raises :class:`WbaRevokedKeyError`, and every signer - records its revocation state. + a revoked key raises :class:`WbaRevokedKeyError`, and the signer records + its revocation state. """ if replay_store is None: raise ValueError("Web Bot Auth requires a replay store") @@ -1114,138 +1081,122 @@ async def verify_wba_request( inputs = _dictionary(_lookup(headers, "signature-input"), missing_status=403) signatures = _dictionary(_lookup(headers, "signature"), missing_status=403) raw_agent = _lookup(headers, "signature-agent") - # Selection by tag alone leaves every Signature-Agent member absent, which - # fails each signature with 403 below. - agents = _dictionary(raw_agent) if raw_agent is not None else Dictionary() + # Selection by tag alone leaves the Signature-Agent member absent, which + # fails the signature with 403 below. + agents = _dictionary(raw_agent) if raw_agent is not None else {} wba = [ (label, member) for label, member in inputs.items() - if isinstance(member, InnerList) and member.params.get("tag") == WBA_TAG + if _rfc9421.is_inner_list(member) and member[1].get("tag") == WBA_TAG ] if not wba: raise WbaVerificationError("Signature-Agent requires the Web Bot Auth profile") if set(signatures) != set(inputs): raise WbaVerificationError("signature labels do not match", status=400) + label, (items, params) = wba[0] + if len(wba) > 1 or len(agents) > 1: + # One signature, one Signature-Agent member (Profile rules, "One + # signature"). An intermediary that signs is the signer; acting for + # another party is an authorization question, not a second signature. + raise WbaVerificationError( + "the profile takes one web-bot-auth signature and one Signature-Agent member", + label=label, + ) if authorities is not None: # A signature captured at another seller cannot be replayed to this # endpoint under that seller's Host. allowed = {_received_authority("https://" + a) for a in authorities} if _received_authority(url) not in allowed: raise WbaVerificationError( - "request authority is not served by this verifier", label=wba[0][0] - ) - verified: list[tuple[VerifiedSigner, str, float]] = [] - earlier: list[str] = [] - for label, member in wba: - _window(member, now=now, label=label) - nonce = member.params.get("nonce") - if not valid_wba_nonce(nonce): - raise WbaVerificationError("missing or invalid nonce", label=label) - nonce = cast(str, nonce) - agent = agents.get(label) - if ( - not isinstance(agent, Item) - or type(agent.value) is not str - or agent.params.get("type", "directory") != "directory" - ): - raise WbaVerificationError("missing supported Signature-Agent member", label=label) - try: - origin = _https_origin(agent.value) - except ValueError as exc: - raise WbaVerificationError("invalid signer origin", label=label) from exc - covered = {str(c) for c in member} - required = { - '"@method"', - '"@target-uri"', - '"@authority"', - f'"signature-agent";key="{label}"', - } - if body: - required.update({'"content-type"', '"content-digest"'}) - for previous in earlier: - required.update( - f'"{field}";key="{previous}"' - for field in ("signature-agent", "signature-input", "signature") - ) - # RFC 9421 §2.2.8 also requires all components of the signature - # being covered, including application-specific header fields. - required.update(str(component) for component in inputs[previous]) - if not required <= covered or len(covered) != len(member): - raise WbaVerificationError("missing or repeated covered component", label=label) - keyid = member.params["keyid"] - try: - key = await signer_keys(keyid, signer_origin=origin) - except WbaVerificationError as exc: - exc.label = label - if exc.status == 403 and "Accept-Signature" in exc.headers: - exc.headers["Accept-Signature"] = accept_signature_value(label) - raise - except Exception as exc: - # A resolver that cannot answer leaves the request unverified. - raise WbaDiscoveryError("signer key resolution failed", label=label) from exc - try: - matches = ( - key is not None - and canonical_origin(key.agent_url) == origin - and thumbprint_key_id(key.jwk) == keyid - ) - except (ValueError, TypeError, AttributeError): - matches = False - if not matches: - raise WbaVerificationError("key absent from the named origin", label=label) - assert key is not None - try: - alg = _verify_crypto( - member, signatures.get(label), key.jwk, _base(method, url, headers, member) - ) - except WbaVerificationError as exc: - exc.label = label - raise - if body: - digest = _lookup(headers, "content-digest") - if digest is None or not content_digest_matches(digest, body): - raise WbaVerificationError("request digest mismatch", label=label) - verified.append( - ( - VerifiedSigner( - key_id=keyid, - alg=alg, - label=label, - verified_at=now, - agent_url=origin, - body_authenticated=bool(body), - profile=WBA_TAG, - ), - nonce, - # One extra second keeps the nonce through the last second of - # the accepted window, including a replay at expires + 60. - max(member.params["expires"] - now + _REQUEST_SKEW_SECONDS + 1, 1), + "request authority is not served by this verifier", label=label ) + _window(params, now=now, label=label) + nonce = params.get("nonce") + if not valid_wba_nonce(nonce): + raise WbaVerificationError("missing or invalid nonce", label=label) + nonce = cast(str, nonce) + agent = agents.get(label) + if ( + agent is None + or _rfc9421.is_inner_list(agent) + or type(agent[0]) is not str + or agent[1].get("type", "directory") != "directory" + ): + raise WbaVerificationError("missing supported Signature-Agent member", label=label) + try: + origin = _https_origin(agent[0]) + except ValueError as exc: + raise WbaVerificationError("invalid signer origin", label=label) from exc + covered = [_rfc9421.component_identifier(item) for item in items] + required = {_rfc9421.component_identifier((name, {})) for name in _REQUEST_COMPONENTS} + required.add(_rfc9421.component_identifier(("signature-agent", {"key": label}))) + if body: + required.update(_rfc9421.component_identifier((name, {})) for name in _BODY_COMPONENTS) + if not required <= set(covered) or len(set(covered)) != len(covered): + raise WbaVerificationError("missing or repeated covered component", label=label) + keyid = params["keyid"] + try: + key = await signer_keys(keyid, signer_origin=origin) + except WbaVerificationError as exc: + exc.label = label + raise + except Exception as exc: + # A resolver that cannot answer leaves the request unverified. + raise WbaDiscoveryError("signer key resolution failed", label=label) from exc + try: + matches = ( + key is not None + and canonical_origin(key.agent_url) == origin + and thumbprint_key_id(key.jwk) == keyid + ) + except (ValueError, TypeError, AttributeError): + matches = False + if not matches: + raise WbaVerificationError("key absent from the named origin", label=label) + assert key is not None + message = _rfc9421.Request(method=method, url=url, headers=dict(headers)) + try: + alg = _verify_crypto( + message, label=label, params=params, signature=signatures.get(label), jwk=key.jwk + ) + except WbaVerificationError as exc: + exc.label = label + raise + if body: + digest = _lookup(headers, "content-digest") + if digest is None or not content_digest_matches(digest, body): + raise WbaVerificationError("request digest mismatch", label=label) + # One extra second keeps the nonce through the last second of the + # accepted window, including a replay at expires + 60. + ttl = max(params["expires"] - now + _REQUEST_SKEW_SECONDS + 1, 1) + try: + claim = await asyncio.to_thread(_claim_replay_nonce, replay_store, origin, nonce, ttl) + except Exception as exc: + raise WbaUnavailableError("replay store unavailable") from exc + if claim == "replayed": + # A reused nonce is 429 with Accept-Signature carrying a fresh nonce + # (Error responses; WBA section 5.3). + raise WbaVerificationError("replayed nonce", status=429, label=label, fresh_nonce=True) + if claim != "claimed": + raise WbaVerificationError( + "replay store capacity exceeded", status=429, label=label, challenge=False ) - earlier.append(label) - # Claim relays before the originator, so a relay replay does not burn - # the originator's nonce. No nonce is claimed until every signature - # verified. - for signer, nonce, ttl in reversed(verified): - try: - claim = await asyncio.to_thread( - _claim_replay_nonce, replay_store, signer.agent_url or "", nonce, ttl - ) - except Exception as exc: - raise WbaUnavailableError("replay store unavailable") from exc - if claim == "replayed": - # A reused nonce is 429 (security.mdx, Web Bot Auth identities). - # replay_status=403 reproduces #7894's older negative/002 vector. - raise WbaVerificationError("replayed nonce", status=replay_status, label=signer.label) - if claim != "claimed": - raise WbaVerificationError( - "replay store capacity exceeded", status=429, label=signer.label - ) try: parsed = parse_strict_json(body) if body.strip() else None except StrictJsonError as exc: raise WbaVerificationError("malformed signed JSON body", status=400) from exc - signers = tuple(replace(signer, parsed_body=parsed) for signer, _, _ in verified) + signers: tuple[VerifiedSigner, ...] = ( + VerifiedSigner( + key_id=keyid, + alg=alg, + label=label, + verified_at=now, + agent_url=origin, + body_authenticated=bool(body), + profile=WBA_TAG, + parsed_body=parsed, + ), + ) if revocation_checker is not None: signers = await check_wba_revocation(revocation_checker, signers) return signers @@ -1277,10 +1228,10 @@ async def check_wba_revocation( ) -> tuple[VerifiedSigner, ...]: """Record each verified signer's revocation state; refuse a revoked key. - Runs in the authorization layer, after verification. A revoked key, the - originator's or a relay's, raises :class:`WbaRevokedKeyError` (403, no - ``Accept-Signature``). Every other state is recorded in - :attr:`VerifiedSigner.revocation` for the binding and grant gates. + Runs in the authorization layer, after verification. A revoked key + raises :class:`WbaRevokedKeyError` (403, no ``Accept-Signature``). Every + other state is recorded in :attr:`VerifiedSigner.revocation` for the + binding and grant gates. """ checked: list[VerifiedSigner] = [] for signer in signers: @@ -1291,6 +1242,153 @@ async def check_wba_revocation( return tuple(checked) +WBA_GOVERNANCE_TYP = "adcp-gov+jws" +#: JWS ``alg`` values a governance token may carry under the profile, by the +#: Web Bot Auth algorithm of the issuer's key. ``Ed25519`` is RFC 9864's +#: fully specified name for ``EdDSA`` over Ed25519. +_GOVERNANCE_JWS_ALGS: dict[str, frozenset[str]] = { + ALG_ED25519: frozenset({"EdDSA", "Ed25519"}), + ALG_RSA_PSS_SHA512: frozenset({"PS512"}), +} + + +class WbaGovernanceError(ValueError): + """A governance token failed a Web Bot Auth governance check. + + ``code`` is the governance error code from ``security.mdx`` (Verification + error taxonomy); ``reason`` is for logs only. + """ + + def __init__(self, code: str, reason: str) -> None: + super().__init__(f"{code}: {reason}") + self.code = code + self.reason = reason + + +def _governance_registry(alg: str) -> Any: + from joserfc.jws import JWSRegistry + + from adcp.signing.jws import _AdcpJwsRegistry, _Ed25519EdDSA + + class _Registry(_AdcpJwsRegistry): + algorithms = { + "EdDSA": _Ed25519EdDSA(), + "Ed25519": JWSRegistry.algorithms["Ed25519"], + "PS512": JWSRegistry.algorithms["PS512"], + } + + return _Registry(algorithms=[alg], strict_check_header=False) + + +async def verify_wba_governance_issuer( + token: object, + *, + signer_keys: WbaSignerKeyResolver, + governance_agents: Collection[str], + now: float | None = None, + clock_skew_seconds: float = 60, +) -> str: + """Check a governance token's key and issuer; return the issuer origin. + + The two checks of ``wba-profile.mdx``, One agent per purpose: + + 1. the token's ``kid`` is in the key directory of the ``iss`` origin + (resolved through ``signer_keys``, keyed on origin and key), and + ``jku`` is that directory's URL (``governance_key_unknown``); + 2. the ``iss`` origin is the buyer's governance agent: one of + ``governance_agents``, the governance-typed ``agents[]`` entry in the + buyer's brand.json or the ``adcp:governance`` origin in its trust.json + (``governance_issuer_not_authorized``). + + In checklist order around them: the compact JWS and its ``alg`` and + ``typ`` (``governance_token_invalid``), the signature, verified by + ``joserfc`` (``governance_token_invalid``), and ``exp`` and ``iat`` with + ``clock_skew_seconds`` of skew (``governance_token_expired`` / + ``governance_token_not_yet_valid``). An unresolvable directory is + ``governance_jwks_unavailable``. Failures raise + :class:`WbaGovernanceError`. + + The rest of the governance checklist (``aud``, ``phase``, ``sub``, the + action binding, revocation, replay) is the caller's. + """ + from joserfc import jws as jose_jws + from joserfc.errors import BadSignatureError, JoseError + from joserfc.jwk import OKPKey, RSAKey + from joserfc.util import urlsafe_b64decode + + def fail(code: str, reason: str) -> WbaGovernanceError: + return WbaGovernanceError(code, reason) + + now = time.time() if now is None else now + if not isinstance(token, str) or not token or len(token) > 16384: + raise fail("governance_token_invalid", "governance token is missing or too large") + try: + compact = jose_jws.extract_compact( + token.encode("ascii"), registry=_governance_registry("EdDSA") + ) + header = parse_strict_json(urlsafe_b64decode(compact.segments["header"])) + claims = parse_strict_json(compact.payload) + except (JoseError, StrictJsonError, UnicodeError, ValueError) as exc: + raise fail("governance_token_invalid", "governance token is not a compact JWS") from exc + if not isinstance(header, dict) or not isinstance(claims, dict): + raise fail("governance_token_invalid", "governance token header or claims not an object") + alg = header.get("alg") + if alg not in {"EdDSA", "Ed25519", "PS512"}: + raise fail("governance_token_invalid", "governance token alg not allowed") + if header.get("typ") != WBA_GOVERNANCE_TYP: + raise fail("governance_token_invalid", "governance token typ mismatch") + try: + issuer = _https_origin(str(claims.get("iss", ""))) + except ValueError as exc: + raise fail("governance_token_invalid", "governance token iss is not an origin") from exc + if issuer != claims.get("iss") or header.get("jku") != issuer + DIRECTORY_PATH: + raise fail("governance_key_unknown", "jku is not the iss origin's key directory") + kid = header.get("kid") + if not isinstance(kid, str) or not kid: + raise fail("governance_key_unknown", "governance token kid is missing") + try: + key = await signer_keys(kid, signer_origin=issuer) + except WbaVerificationError as exc: + raise fail("governance_jwks_unavailable", "issuer key directory unavailable") from exc + try: + matches = ( + key is not None + and canonical_origin(key.agent_url) == issuer + and thumbprint_key_id(key.jwk) == kid + ) + key_alg = _algorithm(key.jwk) if key is not None and matches else None + except (ValueError, TypeError, AttributeError, WbaVerificationError): + matches, key_alg = False, None + if not matches or key is None or key_alg is None: + raise fail("governance_key_unknown", "kid is not in the iss origin's key directory") + if alg not in _GOVERNANCE_JWS_ALGS[key_alg]: + raise fail("governance_token_invalid", "governance token alg does not match the key") + public = {name: value for name, value in key.jwk.items() if name not in {"kid", "alg"}} + try: + jose_key = ( + OKPKey.import_key(public) if key_alg == ALG_ED25519 else RSAKey.import_key(public) + ) + jose_jws.deserialize_compact(token, jose_key, registry=_governance_registry(str(alg))) + except BadSignatureError as exc: + raise fail("governance_token_invalid", "governance token signature invalid") from exc + except JoseError as exc: + raise fail("governance_token_invalid", f"governance token rejected: {exc}") from exc + expires, issued = claims.get("exp"), claims.get("iat") + if type(expires) is not int or type(issued) is not int: + raise fail("governance_token_invalid", "governance token exp and iat must be integers") + if expires <= now - clock_skew_seconds: + raise fail("governance_token_expired", "governance token expired") + if issued > now + clock_skew_seconds: + raise fail("governance_token_not_yet_valid", "governance token issued in the future") + try: + authorized = {_https_origin(origin) for origin in governance_agents} + except ValueError as exc: + raise ValueError("governance_agents must be HTTPS origins") from exc + if issuer not in authorized: + raise fail("governance_issuer_not_authorized", "iss is not the buyer's governance agent") + return issuer + + class WbaRevocationStatusChecker(Protocol): """Report a WBA key's state in its origin's signed revocation list.""" @@ -1299,6 +1397,7 @@ async def status(self, origin: str, keyid: str) -> WbaRevocationStatus: __all__ = [ + "ALG_ED25519", "ALG_RSA_PSS_SHA512", "DEFAULT_DIRECTORY_MAX_STALE_SECONDS", "DIRECTORY_MEDIA_TYPE", @@ -1307,9 +1406,12 @@ async def status(self, origin: str, keyid: str) -> WbaRevocationStatus: "DiscoveredSignerKeys", "GENERIC_AUTHENTICATION_ERROR", "OriginRevocationChecker", + "WBA_ALGORITHMS", + "WBA_GOVERNANCE_TYP", "WBA_NONCE_BYTES", "WbaAuthorizationError", "WbaDiscoveryError", + "WbaGovernanceError", "WbaRevocationStatus", "WbaRevocationStatusChecker", "WbaRevokedKeyError", @@ -1325,6 +1427,7 @@ async def status(self, origin: str, keyid: str) -> WbaRevocationStatus: "is_64_byte_nonce", "valid_wba_nonce", "verify_directory_response", + "verify_wba_governance_issuer", "verify_wba_request", "wba_revocation_status", ] diff --git a/src/adcp/signing/webhook_verifier.py b/src/adcp/signing/webhook_verifier.py index 4478dfd0e..03d542070 100644 --- a/src/adcp/signing/webhook_verifier.py +++ b/src/adcp/signing/webhook_verifier.py @@ -27,7 +27,8 @@ import httpx -from adcp.signing.canonical import _lookup, parse_signature_input_header, split_structured_field +from adcp.signing._rfc9421 import legacy_binary_tokens, mixes_base64_alphabets +from adcp.signing.canonical import _lookup, parse_signature_input_header from adcp.signing.constants import ( DEFAULT_SKEW_SECONDS, MAX_WINDOW_SECONDS, @@ -232,20 +233,13 @@ def _precheck_webhook_signature_alphabet(headers: Mapping[str, str], label: str) raw = _lookup(headers, "signature") if raw is None: return - for entry in split_structured_field(raw, ","): - name, separator, value = entry.strip().partition("=") - if not separator or name.strip() != label: - continue - value = value.strip() - if value.startswith(":") and value.endswith(":"): - token = value[1:-1] - if any(char in token for char in "+/") and any(char in token for char in "-_"): - raise SignatureVerificationError( - WEBHOOK_SIGNATURE_HEADER_MALFORMED, - step=1, - message="webhook Signature must not mix Base64 alphabets", - ) - return + for owner, token in legacy_binary_tokens(raw): + if owner == label and mixes_base64_alphabets(token): + raise SignatureVerificationError( + WEBHOOK_SIGNATURE_HEADER_MALFORMED, + step=1, + message="webhook Signature must not mix Base64 alphabets", + ) def _precheck_webhook_has_required_components(headers: Mapping[str, str]) -> None: diff --git a/tests/conformance/signing/test_crypto.py b/tests/conformance/signing/test_crypto.py index bc0da8c04..1f9604817 100644 --- a/tests/conformance/signing/test_crypto.py +++ b/tests/conformance/signing/test_crypto.py @@ -134,7 +134,7 @@ def test_extract_signature_rejects_base64url_for_3_2_profile() -> None: ) header = vector["request"]["headers"]["Signature"] - with pytest.raises(ValueError, match="RFC 8941 standard Base64"): + with pytest.raises(ValueError, match="malformed Structured Field"): extract_signature_bytes(header, allow_legacy_base64url=False) diff --git a/tests/conformance/signing/test_jws.py b/tests/conformance/signing/test_jws.py index a16804cda..e82012553 100644 --- a/tests/conformance/signing/test_jws.py +++ b/tests/conformance/signing/test_jws.py @@ -120,7 +120,8 @@ def resolve(keyid: str) -> dict[str, object] | None: def test_parse_compact_jws_splits_segments() -> None: # Build a fake compact-shaped string inline so the test literal isn't # a plausible-looking JWT (high-entropy-secret scanners flag otherwise). - header_b64 = b64url_encode(b"FAKE-HEADER") + # joserfc's extract_compact decodes the header, so it must be JSON with alg. + header_b64 = b64url_encode(b'{"alg":"FAKE"}') payload_b64 = b64url_encode(b"FAKE-PAYLOAD") sig_b64 = b64url_encode(b"FAKE-SIG") token = f"{header_b64}.{payload_b64}.{sig_b64}" diff --git a/tests/conformance/signing/test_parse_edge_cases.py b/tests/conformance/signing/test_parse_edge_cases.py index 9e4c87929..09b81c737 100644 --- a/tests/conformance/signing/test_parse_edge_cases.py +++ b/tests/conformance/signing/test_parse_edge_cases.py @@ -11,7 +11,6 @@ from adcp.signing.canonical import ( canonicalize_target_uri, parse_signature_input_header, - split_structured_field, ) @@ -27,10 +26,10 @@ def test_sfv_splitter_handles_escaped_backslash_before_closing_quote() -> None: assert labels["sig1"].params["keyid"] == "kid1" -def test_sfv_splitter_escape_preserved_quote_inside_string() -> None: +def test_escaped_quote_inside_string_does_not_terminate_the_value() -> None: # Escaped double-quote inside a quoted value must not terminate the span. - out = split_structured_field('a="quoted \\"inner\\" text";b=1', ";") - assert out == ['a="quoted \\"inner\\" text"', "b=1"] + labels = parse_signature_input_header('sig1=("@method");nonce="quoted \\"inner\\" text";b=1') + assert labels["sig1"].params == {"nonce": 'quoted "inner" text', "b": 1} def test_parse_rejects_unterminated_quoted_string() -> None: diff --git a/tests/conformance/signing/test_revocation_checker_boundary.py b/tests/conformance/signing/test_revocation_checker_boundary.py index 4bc2e01db..643c334bf 100644 --- a/tests/conformance/signing/test_revocation_checker_boundary.py +++ b/tests/conformance/signing/test_revocation_checker_boundary.py @@ -109,15 +109,16 @@ def run(): def _observe_crypto_and_replay(monkeypatch, replay, events): - from adcp.signing import verifier + from adcp.signing import _rfc9421 - original = verifier.verify_signature + original = _rfc9421.verify - def verify(**kwargs): + def verify(*args, **kwargs): events.append("callback_crypto") - return original(**kwargs) + return original(*args, **kwargs) - monkeypatch.setattr(verifier, "verify_signature", verify) + # The verifier's step 10 is http-message-signatures, reached through here. + monkeypatch.setattr(_rfc9421, "verify", verify) for name in ("seen", "remember", "claim", "at_capacity"): method = getattr(replay, name) diff --git a/tests/conformance/signing/test_rfc9421_library_contract.py b/tests/conformance/signing/test_rfc9421_library_contract.py new file mode 100644 index 000000000..cf9425cd3 --- /dev/null +++ b/tests/conformance/signing/test_rfc9421_library_contract.py @@ -0,0 +1,167 @@ +"""The http-message-signatures and http-sf shapes adcp.signing._rfc9421 relies on. + +The SDK takes RFC 9421 from ``http-message-signatures`` and RFC 8941 from +``http-sf``. It plugs in through public extension points (the +``component_resolver_class``, ``validate_created_and_expires``, +``signature_metadata_parameters``) and two protected subclass hooks +(``_build_signature_base``, ``_parse_covered_component_ids``). A library +release that moves any of them fails here, before it fails a profile test +with a confusing error code. +""" + +from __future__ import annotations + +import inspect + +import http_sf +import pytest +from cryptography.hazmat.primitives.asymmetric import ed25519 +from http_message_signatures import ( + HTTPMessageSigner, + HTTPMessageVerifier, + HTTPSignatureComponentResolver, + InvalidSignature, +) +from http_message_signatures.signatures import HTTPSignatureHandler + +from adcp.signing import _rfc9421 + + +def test_protected_hooks_keep_their_signatures(): + build = inspect.signature(HTTPSignatureHandler._build_signature_base) + assert list(build.parameters) == [ + "self", + "message", + "covered_component_ids", + "signature_params", + ] + parse = inspect.signature(HTTPMessageSigner._parse_covered_component_ids) + assert list(parse.parameters) == ["self", "covered_component_ids"] + init = inspect.signature(HTTPSignatureHandler.__init__) + assert "component_resolver_class" in init.parameters + assert callable(HTTPMessageVerifier.validate_created_and_expires) + assert HTTPSignatureHandler.signature_metadata_parameters == { + "alg", + "created", + "expires", + "keyid", + "nonce", + "tag", + } + + +def test_resolver_receives_nodes_with_value_and_params(): + seen = [] + + class Recording(_rfc9421.ComponentResolver): + def resolve(self, component_node): + seen.append((component_node.value, dict(component_node.params))) + return super().resolve(component_node) + + message = _rfc9421.Request( + "GET", "https://example.com/", {"Example-Dict": "a=1, b=2;x=1;y=2, c=(a b c)"} + ) + base = _rfc9421.build_base( + message, + covered_component_ids=['"example-dict";key="b"', '"@authority"'], + params={"created": 1}, + resolver=Recording, + ) + assert seen == [("example-dict", {"key": "b"}), ("@authority", {})] + # RFC 9421 section 2.1.2: the member value with its parameters. + assert base.decode().splitlines()[0] == '"example-dict";key="b": 2;x=1;y=2' + + +def test_sign_emits_the_parameter_order_signature_base_reproduces(): + key = ed25519.Ed25519PrivateKey.generate() + covered = ['"@method"', '"@authority"'] + signed = _rfc9421.Request("POST", "https://example.com/p") + signature_input, signature = _rfc9421.sign( + signed, + alg="ed25519", + private_key=key, + key_id="k", + label="sig1", + created=10, + expires=20, + nonce="n", + tag="t", + covered_component_ids=covered, + resolver=_rfc9421.ComponentResolver, + ) + assert signature_input == ( + 'sig1=("@method" "@authority");created=10;keyid="k";alg="ed25519";' + 'expires=20;nonce="n";tag="t"' + ) + base, member = _rfc9421.signature_base( + _rfc9421.Request("POST", "https://example.com/p"), + alg="ed25519", + key_id="k", + created=10, + expires=20, + nonce="n", + tag="t", + covered_component_ids=covered, + resolver=_rfc9421.ComponentResolver, + ) + assert signature_input == f"sig1={member}" + assert _rfc9421.parse_dictionary(signature)["sig1"][0] == key.sign(base) + + +def test_verify_chains_the_component_error_and_the_crypto_error(): + key = ed25519.Ed25519PrivateKey.generate() + message = _rfc9421.Request("POST", "https://example.com/p", {"x-a": "1"}) + signature_input, signature = _rfc9421.sign( + message, + alg="ed25519", + private_key=key, + key_id="k", + label="sig1", + created=10, + expires=20, + nonce="n", + tag="t", + covered_component_ids=['"x-a"'], + resolver=_rfc9421.ComponentResolver, + ) + fields = {"Signature-Input": signature_input, "Signature": signature} + verifier = _rfc9421.ProfileVerifier( + signature_algorithm=_rfc9421.ALGORITHMS["ed25519"], + key_resolver=_rfc9421._Key(public_key=key.public_key()), + component_resolver_class=_rfc9421.ComponentResolver, + ) + with pytest.raises(InvalidSignature) as missing: + verifier.verify(_rfc9421.Request("POST", "https://example.com/p", fields), expect_tag="t") + assert isinstance(missing.value.__cause__, _rfc9421.ComponentError) + with pytest.raises(InvalidSignature) as tampered: + verifier.verify( + _rfc9421.Request("POST", "https://example.com/p", {**fields, "x-a": "2"}), + expect_tag="t", + ) + from cryptography.exceptions import InvalidSignature as CryptoInvalidSignature + + assert isinstance(tampered.value.__cause__, CryptoInvalidSignature) + # The library's own window check is off: the profile checks its window. + verifier.verify( + _rfc9421.Request("POST", "https://example.com/p", {**fields, "x-a": "1"}), expect_tag="t" + ) + + +def test_library_resolver_still_lacks_key_and_req(): + """pyauth/http-message-signatures#29: drop the key/req branches once fixed.""" + resolver = HTTPSignatureComponentResolver( + _rfc9421.Request("GET", "https://example.com/", {"d": "a=1, b=2"}) + ) + node = HTTPMessageSigner._parse_covered_component_ids(None, ['"d";key="a"'])[0] + if resolver.resolve(node) == "1": # pragma: no cover - the upstream fix landed + pytest.fail("the library now resolves ;key; remove the ComponentResolver branch") + + +def test_http_sf_reports_every_duplicate(): + calls = [] + http_sf.parse( + b'a=1, a=2, b=("x";p=1;p=2);q=1;q=2', + tltype="dictionary", + on_duplicate_key=lambda key, context: calls.append((key, context)), + ) + assert calls == [("a", "dictionary"), ("p", "parameter"), ("q", "parameter")] diff --git a/tests/conformance/signing/test_signing_provider.py b/tests/conformance/signing/test_signing_provider.py index 63e9d057d..ff709a855 100644 --- a/tests/conformance/signing/test_signing_provider.py +++ b/tests/conformance/signing/test_signing_provider.py @@ -368,7 +368,7 @@ def test_sign_request_rejects_key_id_with_control_characters() -> None: key_id would otherwise produce a Signature-Input header with a literal line break — a header-injection vector at non-httpx integrators.""" private_key = private_key_from_jwk(ED25519_KEY, d_field="_private_d_for_test_only") - with pytest.raises(ValueError, match="key_id contains character"): + with pytest.raises(ValueError, match="disallowed characters"): sign_request( method="POST", url="https://seller.example.com/x", @@ -383,7 +383,7 @@ def test_sign_request_rejects_key_id_with_control_characters() -> None: def test_sign_request_rejects_key_id_with_non_ascii() -> None: private_key = private_key_from_jwk(ED25519_KEY, d_field="_private_d_for_test_only") - with pytest.raises(ValueError, match="key_id contains character"): + with pytest.raises(ValueError, match="disallowed characters"): sign_request( method="POST", url="https://seller.example.com/x", @@ -401,7 +401,7 @@ def test_sign_request_rejects_label_with_crlf() -> None: headers; a CRLF here would inject extra header bytes. RFC 8941 §3.1.2 sf-keys are restricted to a token grammar.""" private_key = private_key_from_jwk(ED25519_KEY, d_field="_private_d_for_test_only") - with pytest.raises(ValueError, match="not a valid RFC 8941 sf-key"): + with pytest.raises(ValueError, match="not a valid RFC 8941 key"): sign_request( method="POST", url="https://seller.example.com/x", @@ -418,7 +418,7 @@ def test_sign_request_rejects_label_with_crlf() -> None: def test_sign_request_rejects_label_starting_with_uppercase() -> None: """RFC 8941 §3.1.2 sf-keys must start with lowercase letter or '*'.""" private_key = private_key_from_jwk(ED25519_KEY, d_field="_private_d_for_test_only") - with pytest.raises(ValueError, match="must start with a lowercase letter"): + with pytest.raises(ValueError, match="not a valid RFC 8941 key"): sign_request( method="POST", url="https://seller.example.com/x", @@ -462,7 +462,7 @@ def test_pem_to_adcp_jwk_rejects_rsa_public_pem() -> None: def test_sign_request_rejects_tag_with_control_characters() -> None: private_key = private_key_from_jwk(ED25519_KEY, d_field="_private_d_for_test_only") - with pytest.raises(ValueError, match="tag contains character"): + with pytest.raises(ValueError, match="disallowed characters"): sign_request( method="POST", url="https://seller.example.com/x", diff --git a/tests/conformance/signing/test_structured_field_duplicates.py b/tests/conformance/signing/test_structured_field_duplicates.py index 0661ec1fe..0b7b40e5d 100644 --- a/tests/conformance/signing/test_structured_field_duplicates.py +++ b/tests/conformance/signing/test_structured_field_duplicates.py @@ -19,7 +19,7 @@ verify_request_signature, verify_wba_request, ) -from adcp.signing._structured_fields import reject_dictionary_duplicates +from adcp.signing._rfc9421 import parse_dictionary as reject_dictionary_duplicates from adcp.signing.canonical import parse_signature_input_header from adcp.signing.crypto import extract_signature_bytes from adcp.signing.wba import WbaVerificationError, _dictionary, uses_web_bot_auth diff --git a/tests/conformance/signing/test_wba_interop.py b/tests/conformance/signing/test_wba_interop.py index 8169c7e8f..a02812968 100644 --- a/tests/conformance/signing/test_wba_interop.py +++ b/tests/conformance/signing/test_wba_interop.py @@ -15,16 +15,16 @@ import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa -from http_sfv.dictionary import Dictionary from adcp.signing import InMemoryReplayStore +from adcp.signing._rfc9421 import Request, parse_dictionary from adcp.signing.wba import ( ALG_RSA_PSS_SHA512, DirectoryResponse, DiscoveredSignerKeys, WbaVerificationError, _algorithm, - _base, + _signature_base, _verify_crypto, is_64_byte_nonce, public_directory_key, @@ -80,32 +80,23 @@ async def fetch(url, *, allow_private=False): assert signer.agent_url == expected["identity"] assert signer.key_id == expected["keyid"] # @target-uri is the URI as received: %7E stays encoded. - label = next(iter(_parse_dictionary(request["headers"]["signature-input"]))) - member = _parse_dictionary(request["headers"]["signature-input"])[label] - base = _base(request["method"], request["url"], request["headers"], member).decode() + label = next(iter(parse_dictionary(request["headers"]["signature-input"]))) + base = _signature_base(label, request["method"], request["url"], request["headers"]).decode() assert base == expected["signature_base"] -def _parse_dictionary(value: str) -> Dictionary: - parsed = Dictionary() - parsed.parse(value.encode("ascii")) - return parsed - - @pytest.mark.parametrize("path", FIXTURES, ids=lambda path: path.stem) def test_foreign_signed_directories_verify(path): fixture = json.loads(path.read_text()) origin = fixture["directory"]["url"].split("/.well-known/")[0] - keys = verify_directory_response( - origin, _directory(fixture), now=fixture["reference_now"], require_signed=True - ) + keys = verify_directory_response(origin, _directory(fixture), now=fixture["reference_now"]) assert fixture["expected"]["keyid"] in keys def test_cloudflare_padded_base64_nonce_is_64_bytes(): fixture = json.loads((ROOT / "cloudflare-signed-body-request.json").read_text()) - member = _parse_dictionary(fixture["request"]["headers"]["signature-input"])["sig1"] - nonce = member.params["nonce"] + _components, params = parse_dictionary(fixture["request"]["headers"]["signature-input"])["sig1"] + nonce = params["nonce"] assert nonce.endswith("==") and is_64_byte_nonce(nonce) and valid_wba_nonce(nonce) @@ -131,10 +122,10 @@ async def test_python_signed_requests_verify_with_the_target_uri_as_sent(path): created=1790841610, ) ) - member = _parse_dictionary(headers["Signature-Input"])["sig1"] + _components, params = parse_dictionary(headers["Signature-Input"])["sig1"] # Default nonce: 64 random bytes, unpadded base64url. - assert len(member.params["nonce"]) == 86 and is_64_byte_nonce(member.params["nonce"]) - base = _base("POST", url, {k.lower(): v for k, v in headers.items()}, member).decode() + assert len(params["nonce"]) == 86 and is_64_byte_nonce(params["nonce"]) + base = _signature_base("sig1", "POST", url, headers).decode() assert f'"@target-uri": {url}\n' in base assert '"@authority": seller.example.com\n' in base directory = sign_key_directory(agent_origin=origin, private_keys=[private], created=1790841550) @@ -211,19 +202,27 @@ def test_rsa_pss_appendix_e_vector(): assert thumbprint_key_id(jwk) == "oD0HwocPBSfpNy5W3bpJeyFGY_IQ_YpqxSjQ3Yd-CLA" assert _algorithm(jwk) == ALG_RSA_PSS_SHA512 assert is_64_byte_nonce(APPENDIX_E_NONCE), "padded standard base64 of 64 bytes" - member = _parse_dictionary(APPENDIX_E_INPUT)["sig2"] - signature = _parse_dictionary(APPENDIX_E_SIGNATURE)["sig2"] - headers = {"signature-agent": 'agent2="https://signature-agent.test"'} - base = _base("GET", "https://example.com/", headers, member) + headers = { + "signature-agent": 'agent2="https://signature-agent.test"', + "signature-input": APPENDIX_E_INPUT, + "signature": APPENDIX_E_SIGNATURE, + } + base = _signature_base("sig2", "GET", "https://example.com/", headers) assert base.decode() == ( '"@authority": example.com\n' '"signature-agent";key="agent2": "https://signature-agent.test"\n' f'"@signature-params": {APPENDIX_E_INPUT.removeprefix("sig2=")}' ) - assert _verify_crypto(member, signature, jwk, base) == ALG_RSA_PSS_SHA512 - tampered = base.replace(b"example.com", b"example.net", 1) + _components, params = parse_dictionary(APPENDIX_E_INPUT)["sig2"] + signature = parse_dictionary(APPENDIX_E_SIGNATURE)["sig2"] + message = Request("GET", "https://example.com/", headers) + assert ( + _verify_crypto(message, label="sig2", params=params, signature=signature, jwk=jwk) + == ALG_RSA_PSS_SHA512 + ) + tampered = Request("GET", "https://example.net/", headers) with pytest.raises(WbaVerificationError, match="did not verify"): - _verify_crypto(member, signature, jwk, tampered) + _verify_crypto(tampered, label="sig2", params=params, signature=signature, jwk=jwk) async def test_rsa_pss_identity_round_trip_and_key_bounds(): diff --git a/tests/conformance/signing/test_wba_profile.py b/tests/conformance/signing/test_wba_profile.py index 57f4abaf6..2b0a5eaae 100644 --- a/tests/conformance/signing/test_wba_profile.py +++ b/tests/conformance/signing/test_wba_profile.py @@ -1,7 +1,8 @@ -"""Draft WBA vectors and the security boundaries of the experimental profile.""" +"""WBA profile vectors (adcp#7894) and the security boundaries of the experimental profile.""" from __future__ import annotations +import hashlib import json from dataclasses import replace from pathlib import Path @@ -42,6 +43,32 @@ ROOT = Path(__file__).parents[1] / "vectors" / "wba-profile" KEYS = {key["kid"]: key for key in json.loads((ROOT / "keys.json").read_text())["keys"]} VECTORS = sorted((ROOT / "positive").glob("*.json")) + sorted((ROOT / "negative").glob("*.json")) +GOVERNANCE_VECTORS = sorted((ROOT / "governance").glob("*.json")) + + +def _public(kid): + return {k: v for k, v in KEYS[kid].items() if not k.startswith("_")} + + +def _vector_resolver(vector): + async def resolve(keyid, *, signer_origin=None): + ids = vector["directories"].get(signer_origin, {}).get("keys_ref", []) + return ResolvedSignerKey(signer_origin, _public(keyid)) if keyid in ids else None + + return resolve + + +def test_vendored_vectors_match_recorded_provenance(): + upstream = json.loads((ROOT / "UPSTREAM.json").read_text()) + assert upstream["commit"] == "e24eeb090c4365707641099cbbb45f9945c6d6e0" + vendored = { + str(path.relative_to(ROOT)) + for path in ROOT.rglob("*") + if path.is_file() and path.name not in {"README.md", "UPSTREAM.json"} + } + assert vendored == set(upstream["sha256"]) + for name, digest in upstream["sha256"].items(): + assert hashlib.sha256((ROOT / name).read_bytes()).hexdigest() == digest async def _not_revoked(origin, keyid): @@ -111,24 +138,21 @@ async def fetch(url, *, allow_private=False): @pytest.mark.parametrize("path", VECTORS, ids=lambda p: f"{p.parent.name}/{p.name}") -async def test_draft_vectors(path): +async def test_profile_vectors(path): vector = json.loads(path.read_text()) request = vector["request"] if "response" in vector: response = vector["response"] + origin = request["url"].split("/.well-known/")[0] result = verify_directory_response( - request["url"], + origin, DirectoryResponse(response["body"].encode(), response["headers"]), now=vector["reference_now"], ) assert set(result) <= set(KEYS) + assert set(vector["expected_outcome"]["identities"].values()) == {origin} return - async def resolve(keyid, *, signer_origin=None): - ids = vector["directories"].get(signer_origin, {}).get("keys_ref", []) - public = {k: v for k, v in KEYS[keyid].items() if not k.startswith("_")} - return ResolvedSignerKey(signer_origin, public) if keyid in ids else None - replay = InMemoryReplayStore() for entry in vector.get("test_harness_state", {}).get("replay_cache_entries", []): replay.claim(entry["identity"], entry["nonce"], entry["ttl_seconds"]) @@ -139,42 +163,118 @@ async def verify(): url=request["url"], headers=request["headers"], body=request["body"].encode(), - signer_keys=resolve, + signer_keys=_vector_resolver(vector), replay_store=replay, revocation_checker=_not_revoked, now=vector["reference_now"], - # The spec answers a reused nonce 429; #7894's older replay vector - # predates that and expects 403, reproduced by the compat switch. - replay_status=403 if "replayed-nonce" in path.name else 429, ) expected = vector["expected_outcome"] if expected["success"]: signers = await verify() assert {s.label: s.agent_url for s in signers} == expected["identities"] + return + with pytest.raises(WbaVerificationError) as caught: + await verify() + assert caught.value.status == expected["status"] + challenge = expected.get("accept_signature") + if challenge is None: + assert "Accept-Signature" not in caught.value.headers + return + # The Accept-Signature member names the label, components and parameters. + from adcp.signing.wba import _dictionary + + member = _dictionary(caught.value.headers["Accept-Signature"])[challenge["label"]] + components, params = member + assert "(" + " ".join(_identifier(item) for item in components) + ")" == challenge["components"] + assert list(params) == challenge["parameters"] + assert params["tag"] == challenge["tag"] + if challenge.get("nonce") == "verifier-chosen": + assert isinstance(params["nonce"], str) and len(params["nonce"]) == 86 else: - with pytest.raises(WbaVerificationError) as caught: - await verify() - assert caught.value.status == expected["status"] - if expected["status"] == 403: - assert "Accept-Signature" in caught.value.headers + assert params["nonce"] is True + + +def _identifier(item): + from adcp.signing._rfc9421 import component_identifier + + return component_identifier(item) + + +@pytest.mark.parametrize("path", VECTORS, ids=lambda p: f"{p.parent.name}/{p.name}") +def test_profile_vector_signature_bases(path): + """Our signature base matches every vector's, built by http-message-signatures.""" + from adcp.signing.wba import _signature_base + + vector = json.loads(path.read_text()) + message = vector.get("response", vector["request"]) + for label, expected in vector.get("expected_signature_base", {}).items(): + base = _signature_base( + label, + vector["request"]["method"], + vector["request"]["url"], + message["headers"], + response="response" in vector, + ) + assert base.decode() == expected + + +@pytest.mark.parametrize("path", GOVERNANCE_VECTORS, ids=lambda p: p.name) +async def test_governance_vectors(path): + from adcp.signing.wba import WbaGovernanceError, verify_wba_governance_issuer + + vector = json.loads(path.read_text()) + + async def verify(): + return await verify_wba_governance_issuer( + vector["jws"], + signer_keys=_vector_resolver(vector), + governance_agents=vector["role_listing"]["adcp:governance"], + now=vector["reference_now"], + ) + + expected = vector["expected_outcome"] + if expected["success"]: + assert await verify() == expected["issuer"] + return + with pytest.raises(WbaGovernanceError) as caught: + await verify() + assert caught.value.code == expected["error_code"] def test_signer_reproduces_independent_vector(): + """Signing the committed base with the committed seed gives the committed signature.""" + from adcp.signing._rfc9421 import parse_dictionary + from adcp.signing.crypto import sign_signature_base + vector = json.loads((ROOT / "positive/001-single-signer-request.json").read_text()) request = vector["request"] - key = next(iter(KEYS.values())) + key = KEYS["6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I"] + private = private_key_from_jwk(key, d_field="_private_d_for_test_only") + signature = sign_signature_base( + alg="ed25519", + private_key=private, + signature_base=vector["expected_signature_base"]["sig1"].encode(), + ) + assert parse_dictionary(request["headers"]["Signature"])["sig1"][0] == signature + # sign_wba_request produces the same covered components, Signature-Agent + # and Content-Digest; the library orders the parameters its own way, so + # its base differs from the vector's only in @signature-params. result = sign_wba_request( method=request["method"], url=request["url"], headers={"Content-Type": "application/json"}, body=request["body"].encode(), - private_key=private_key_from_jwk(key, d_field="_private_d_for_test_only"), + private_key=private, agent_origin=key["identity"], created=1790841600, nonce="Mn-ZM8SnGESx0-rmyYMuTkJFerfbwEii2C0qNkDP8ugpcuv0pcrt6Da2ifr-6MTmfVeZhnFymLeAofeXJbFaxA", ) - assert all(request["headers"][name] == value for name, value in result.items()) + for name in ("Signature-Agent", "Content-Digest"): + assert result[name] == request["headers"][name] + ours = parse_dictionary(result["Signature-Input"])["sig1"] + theirs = parse_dictionary(request["headers"]["Signature-Input"])["sig1"] + assert ours[0] == theirs[0] and ours[1] == theirs[1] @pytest.mark.parametrize( @@ -314,9 +414,10 @@ async def resolve(keyid, *, signer_origin=None): revocation_checker=_not_revoked, now=1001, ) - # A reused nonce is 429 with no re-sign challenge. + # A reused nonce is 429 with Accept-Signature carrying a fresh + # nonce (wba-profile.mdx, Error responses). assert replayed.value.status == 429 - assert "Accept-Signature" not in replayed.value.headers + assert 'nonce="' in replayed.value.headers["Accept-Signature"] async def test_framework_requires_explicit_binding_before_dispatch(): @@ -527,53 +628,65 @@ async def fetch(url, *, allow_private=False): await resolver(jwk["kid"], signer_origin="https://legacy.com") -async def test_p256_wba_derives_optional_algorithm_and_signs_multikey_directory(): +async def test_es256_is_not_a_web_bot_auth_algorithm(): + """The profile allows ed25519 and rsa-pss-sha512; ES256 stays on RFC 9421.""" from cryptography.hazmat.primitives.asymmetric import ec - from adcp.signing.crypto import format_signature_header, sign_signature_base - from adcp.signing.wba import _base, _dictionary + from adcp.signing.crypto import b64url_encode ed_key, ec_key = ed25519.Ed25519PrivateKey.generate(), ec.generate_private_key(ec.SECP256R1()) - response = sign_key_directory( - agent_origin="https://buyer.com", private_keys=[ed_key, ec_key], created=1000 - ) - keys = verify_directory_response("https://buyer.com", response, now=1001) - assert len(keys) == 2 - headers = {"Content-Type": "application/json"} - headers.update( + with pytest.raises(ValueError, match="Ed25519 and RSA"): + sign_key_directory(agent_origin="https://buyer.com", private_keys=[ec_key], created=1000) + with pytest.raises(ValueError, match="Ed25519 and RSA"): sign_wba_request( method="POST", url="https://seller.com/mcp", - headers=headers, + headers={"Content-Type": "application/json"}, body=b"{}", private_key=ec_key, agent_origin="https://buyer.com", created=1000, ) + # A directory may list a P-256 key for another use; it is skipped, and the + # signed Ed25519 key is the only one returned. + response = sign_key_directory( + agent_origin="https://buyer.com", private_keys=[ed_key], created=1000 ) - inputs = _dictionary(headers["Signature-Input"]) - member = inputs["sig1"] - del member.params["alg"] - headers["Signature-Input"] = str(inputs) - signature = sign_signature_base( - alg="ecdsa-p256-sha256", - private_key=ec_key, - signature_base=_base("POST", "https://seller.com/mcp", headers, member), - ) - headers["Signature"] = format_signature_header( - signature, label="sig1", use_legacy_base64url=False - ) - result = await verify_wba_request( - method="POST", - url="https://seller.com/mcp", - headers=headers, - body=b"{}", - signer_keys=StaticSignerKeys({"https://buyer.com": {"keys": list(keys.values())}}), - replay_store=InMemoryReplayStore(), - revocation_checker=_not_revoked, - now=1001, + numbers = ec_key.public_key().public_numbers() + p256 = { + "kty": "EC", + "crv": "P-256", + "x": b64url_encode(numbers.x.to_bytes(32, "big")), + "y": b64url_encode(numbers.y.to_bytes(32, "big")), + } + with pytest.raises(WbaVerificationError, match="unsupported directory key"): + from adcp.signing.wba import _algorithm + + _algorithm(p256) + keys = verify_directory_response("https://buyer.com", response, now=1001) + assert list(keys) == [public_directory_key(ed_key)["kid"]] + + +async def test_unsigned_directory_is_refused(): + """Signed directory responses are required (WBA Appendix B.1).""" + key = ed25519.Ed25519PrivateKey.generate() + signed = sign_key_directory(agent_origin="https://buyer.com", private_keys=[key], created=1000) + unsigned = DirectoryResponse( + signed.body, + {k: v for k, v in signed.headers.items() if k not in {"Signature", "Signature-Input"}}, ) - assert result[0].alg == "ecdsa-p256-sha256" + with pytest.raises(WbaVerificationError, match="not signed"): + verify_directory_response("https://buyer.com", unsigned, now=1001) + + async def fetch(url, *, allow_private=False): + return unsigned + + with pytest.raises(WbaVerificationError): + await DiscoveredSignerKeys(fetcher=fetch, clock=lambda: 1001)( + public_directory_key(key)["kid"], signer_origin="https://buyer.com" + ) + with pytest.raises(TypeError): + DiscoveredSignerKeys(require_signed_directories=True) # type: ignore[call-arg] @pytest.mark.parametrize( @@ -607,56 +720,115 @@ def test_legacy_verifier_never_attributes_or_emits_legacy_codes_for_wba(headers) resolver.assert_not_called() -@pytest.mark.parametrize("cover_extra", [False, True]) -async def test_relay_covers_every_component_of_earlier_signature(cover_extra): - from http_sfv.item import Item - - from adcp.signing.crypto import sign_signature_base - from adcp.signing.wba import _base, _dictionary +@pytest.mark.parametrize("change", ["second_agent_member", "short_nonce", "padded_nonce"]) +async def test_one_signer_and_64_byte_nonce(change): + """One Signature-Agent member per request, and a nonce of exactly 64 bytes.""" + import base64 - vector = json.loads((ROOT / "positive/002-relay-request-two-signatures.json").read_text()) + vector = json.loads((ROOT / "positive/001-single-signer-request.json").read_text()) request = vector["request"] - headers = request["headers"] - headers["X-Buyer-Reference"] = "campaign-123" - inputs = _dictionary(headers["Signature-Input"]) - signatures = _dictionary(headers["Signature"]) - labels = list(inputs) - inputs[labels[0]].append(Item("x-buyer-reference")) - if cover_extra: - inputs[labels[1]].append(Item("x-buyer-reference")) - headers["Signature-Input"] = str(inputs) - for label, member in inputs.items(): - key = KEYS[member.params["keyid"]] - signatures[label] = Item( - sign_signature_base( - alg="ed25519", - private_key=private_key_from_jwk(key, d_field="_private_d_for_test_only"), - signature_base=_base(request["method"], request["url"], headers, member), + key = KEYS["6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I"] + private = private_key_from_jwk(key, d_field="_private_d_for_test_only") + if change == "short_nonce": + with pytest.raises(ValueError, match="64 bytes"): + sign_wba_request( + method="POST", + url=request["url"], + headers={"Content-Type": "application/json"}, + body=request["body"].encode(), + private_key=private, + agent_origin=key["identity"], + created=1790841600, + nonce="A" * 22, ) + return + nonce = ( + base64.b64encode(bytes(range(64))).decode() + if change == "padded_nonce" + else base64.urlsafe_b64encode(b"\x07" * 64).decode().rstrip("=") + ) + headers = {"Content-Type": "application/json"} + headers.update( + sign_wba_request( + method="POST", + url=request["url"], + headers=headers, + body=request["body"].encode(), + private_key=private, + agent_origin=key["identity"], + created=1790841600, + nonce=nonce, ) - headers["Signature"] = str(signatures) - - async def resolve(keyid, *, signer_origin=None): - key = KEYS[keyid] - return ResolvedSignerKey(signer_origin, key) if key["identity"] == signer_origin else None + ) + if change == "second_agent_member": + headers["Signature-Agent"] += ', sig2="https://agent.brand-7k3q.com"' async def verify(): return await verify_wba_request( - method=request["method"], + method="POST", url=request["url"], headers=headers, body=request["body"].encode(), - signer_keys=resolve, + signer_keys=_vector_resolver(vector), replay_store=InMemoryReplayStore(), - revocation_checker=_not_revoked, now=vector["reference_now"], ) - if cover_extra: - assert len(await verify()) == 2 - else: - with pytest.raises(WbaVerificationError, match="covered component"): - await verify() + if change == "padded_nonce": + # Cloudflare's signer sends padded standard base64 of 64 bytes. + (signer,) = await verify() + assert signer.agent_url == key["identity"] + return + with pytest.raises(WbaVerificationError, match="one web-bot-auth signature") as caught: + await verify() + assert caught.value.status == 403 + assert "Accept-Signature" in caught.value.headers + + +async def test_short_received_nonce_is_a_challenge(): + """A nonce shorter than 64 bytes fails the profile: 403 with Accept-Signature.""" + from adcp.signing import _rfc9421 + from adcp.signing.wba import _WbaComponentResolver + + key = KEYS["6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I"] + private = private_key_from_jwk(key, d_field="_private_d_for_test_only") + vector = json.loads((ROOT / "positive/001-single-signer-request.json").read_text()) + request = vector["request"] + headers = {k: v for k, v in request["headers"].items() if not k.startswith("Signature-I")} + headers = {k: v for k, v in headers.items() if k != "Signature"} + signature_input, signature = _rfc9421.sign( + _rfc9421.Request(request["method"], request["url"], dict(headers)), + alg="ed25519", + private_key=private, + key_id=key["kid"], + label="sig1", + created=1790841600, + expires=1790841900, + nonce="A" * 22, + tag="web-bot-auth", + covered_component_ids=[ + '"@method"', + '"@target-uri"', + '"@authority"', + '"content-type"', + '"content-digest"', + '"signature-agent";key="sig1"', + ], + resolver=_WbaComponentResolver, + ) + headers.update({"Signature-Input": signature_input, "Signature": signature}) + with pytest.raises(WbaVerificationError, match="nonce") as caught: + await verify_wba_request( + method=request["method"], + url=request["url"], + headers=headers, + body=request["body"].encode(), + signer_keys=_vector_resolver(vector), + replay_store=InMemoryReplayStore(), + now=vector["reference_now"], + ) + assert caught.value.status == 403 + assert "Accept-Signature" in caught.value.headers @pytest.mark.parametrize( diff --git a/tests/conformance/signing/test_webhook_rc4_vectors.py b/tests/conformance/signing/test_webhook_rc4_vectors.py index 362feda50..6571de0c5 100644 --- a/tests/conformance/signing/test_webhook_rc4_vectors.py +++ b/tests/conformance/signing/test_webhook_rc4_vectors.py @@ -123,7 +123,7 @@ def test_mixed_alphabet_stops_before_lookup_crypto_or_replay(signature, monkeypa def forbidden(*args, **kwargs): pytest.fail("malformed signature reached key lookup, crypto or replay state") - monkeypatch.setattr("adcp.signing.verifier.verify_signature", forbidden) + monkeypatch.setattr("adcp.signing._rfc9421.verify", forbidden) for method in ("seen", "remember", "claim", "at_capacity"): monkeypatch.setattr(options.replay_store, method, forbidden) options = replace(options, jwks_resolver=forbidden) diff --git a/tests/conformance/vectors/wba-profile/README.md b/tests/conformance/vectors/wba-profile/README.md index 55045441e..d182feff9 100644 --- a/tests/conformance/vectors/wba-profile/README.md +++ b/tests/conformance/vectors/wba-profile/README.md @@ -1,9 +1,12 @@ -# Experimental Web Bot Auth vectors +# Web Bot Auth profile vectors Copied without modification from adcontextprotocol/adcp PR #7894, commit -`656bd080f01cc2fca4a1e623af44e96cfd827ba5`, -`static/compliance/source/test-vectors/wba-profile/` on 2026-10-13. +`e24eeb090c4365707641099cbbb45f9945c6d6e0`, +`static/compliance/source/test-vectors/wba-profile/`, on 2026-10-16. The +upstream `README.md` is vendored as `UPSTREAM-README.md`. `UPSTREAM.json` +records each file's SHA-256, and +`tests/conformance/signing/test_wba_profile.py` checks them. -These are draft transport vectors, not released AdCP 3.3 compliance inputs. -Governance JWS remains on the RFC 9421 chain and is outside this preview. -The public private seeds in keys.json are test inputs only. +The specification is `docs/building/by-layer/L1/wba-profile.mdx` at the same +commit (proposed for AdCP 3.3). The public private seeds in `keys.json` are +test inputs only. diff --git a/tests/conformance/vectors/wba-profile/UPSTREAM-README.md b/tests/conformance/vectors/wba-profile/UPSTREAM-README.md new file mode 100644 index 000000000..6aefc5dd8 --- /dev/null +++ b/tests/conformance/vectors/wba-profile/UPSTREAM-README.md @@ -0,0 +1,154 @@ +# Web Bot Auth profile conformance vectors + +Test vectors for the proposed AdCP profile of Web Bot Auth (WBA) for request and webhook signing. The profile is proposed for AdCP 3.3 in [RFC #7878](https://github.com/adcontextprotocol/adcp/issues/7878) and is published alongside the AdCP RFC 9421 request-signing profile. These fixtures let a signer written with one WBA or RFC 9421 library and a verifier written with another agree on the wire format and on what a verifier rejects. + +Specification: [Web Bot Auth request-signing profile](https://adcontextprotocol.org/docs/building/by-layer/L1/wba-profile) in `docs/building/by-layer/L1/wba-profile.mdx`. Section numbers in vector comments refer to [draft-ietf-webbotauth-httpsig-protocol-00](https://datatracker.ietf.org/doc/draft-ietf-webbotauth-httpsig-protocol/00/) unless they name RFC 9421. + +**Canonical URLs.** These vectors are served at `https://adcontextprotocol.org/compliance/{version}/test-vectors/wba-profile/`, with `{version}` either an immutable release or `latest`. Tree preserved: `keys.json`, `positive/*.json`, `negative/*.json`, and `governance/*.json` are all resolvable. + +## Test keys are public + +`keys.json` publishes the private seed of every test key in `_private_d_for_test_only`, so implementations can reproduce every signature. Each seed is SHA-256 of the ASCII string `rfc004-test/`, used as the Ed25519 private key. Each `kid` is the key's JWK thumbprint (RFC 7638; RFC 8037 Appendix A.3 for Ed25519), and each entry's `identity` is the origin whose key directory publishes the key. + +**These keys are valid only for grading against this suite.** WBA section 6.8: test keys "MUST NOT be used in production", and verifiers SHOULD reject them when detected. A production verifier that accepts a directory containing `6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I`, `hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI`, or `1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE` is exploitable. + +The domains in the vectors (`buyer-7k3q.com`, `agent.brand-7k3q.com`, `governance-7k3q.com`, `seller-7k3q.com`) are placeholders that resolve to nothing. A harness serves each directory from the vector's `directories` object instead of fetching it. + +## Scope + +The vectors exercise the profile rules: the covered `Signature-Agent` member, key selection by thumbprint from the directory the member names, the required `nonce`, replay rejection, one signature per request, the signed directory response, the `400`, `403`, and `429` responses, and the two checks on a governance token: its key is in the issuer's directory, and its issuer is the buyer's governance agent. They do not exercise live directory fetches, revocation-list polling, or onboarding state, which need live endpoints and belong in integration suites. + +## File layout + +``` +test-vectors/wba-profile/ +├── README.md this file +├── keys.json three Ed25519 test keys, one per identity, with private seeds +├── positive/ requests and responses that MUST verify +│ ├── 001-single-signer-request.json buyer agent signs create_media_buy +│ └── 002-signed-directory-response.json directory response signed per key (WBA Appendix B.1) +├── negative/ requests a verifier MUST reject, with the expected status +│ ├── 001-missing-nonce.json → 403 with Accept-Signature (nonce required on every signed request) +│ ├── 002-replayed-nonce.json → 429 with Accept-Signature carrying a fresh nonce (needs test_harness_state) +│ ├── 003-signature-agent-not-covered.json → 403 (Signature-Agent present but not a covered component) +│ ├── 004-key-not-in-named-directory.json → 403 (key is published by an origin other than the one named) +│ ├── 005-malformed-signature-agent.json → 400 (Signature-Agent does not parse) +│ └── 006-two-signatures.json → 403 (two web-bot-auth signatures on one request, each valid on its own) +└── governance/ governance tokens: one agent per purpose + ├── 001-governance-token.json governance agent, its own key → verifies + ├── 002-governance-token-key-not-in-issuer-directory.json → governance_key_unknown + └── 003-governance-token-from-non-governance-agent.json → governance_issuer_not_authorized +``` + +## Vector format + +Every request vector has this shape: + +```json +{ + "name": "human-readable description", + "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#anchor", + "signing_profile": "web-bot-auth", + "reference_now": 1790841610, + "request": { + "method": "POST", + "url": "https://seller-7k3q.com/mcp/", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:...:", + "Signature-Agent": "sig1=\"https://buyer-7k3q.com\"", + "Signature-Input": "sig1=(...);created=...;expires=...;nonce=\"...\";keyid=\"...\";alg=\"ed25519\";tag=\"web-bot-auth\"", + "Signature": "sig1=:...:" + }, + "body": "{...}" + }, + "directories": { + "https://buyer-7k3q.com": { "keys_ref": ["6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I"] } + }, + "test_harness_state": { + "replay_cache_entries": [{ "identity": "https://buyer-7k3q.com", "nonce": "...", "ttl_seconds": 360 }] + }, + "expected_signature_base": { "sig1": "\"@method\": POST\n..." }, + "expected_outcome": { + "success": false, + "status": 403, + "accept_signature": { + "label": "sig1", + "components": "(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\")", + "parameters": ["created", "expires", "nonce", "keyid", "alg", "tag"], + "tag": "web-bot-auth" + }, + "reason": "..." + }, + "$comment": "free-form notes" +} +``` + +### Fields + +- **`name`**: one-line description. +- **`spec_reference`**: the section of `wba-profile.mdx` the vector grades. +- **`signing_profile`**: always `web-bot-auth`. A verifier selects these vectors by this value and never grades them as AdCP RFC 9421 profile cases. +- **`reference_now`**: Unix seconds. Inject this as the verifier's wall clock instead of using the real time. +- **`request`**: the raw HTTP request the verifier receives. Header names are case-insensitive; `body` is the exact byte string on the wire. Binary header values use RFC 8941 `sf-binary` (standard Base64 with padding). +- **`response`**: present on the directory-response vector only. The response the directory server sends to `request`, with `status`, `headers`, and the exact `body`. +- **`directories`**: the key directory each origin serves, as a list of `kid` values from `keys.json`. The harness answers a fetch of `/.well-known/http-message-signatures-directory` with a JWK Set holding exactly those keys. An origin absent from the object serves no directory. Several vectors list more than one origin so that a harness can show the trap the vector guards against (see `004-key-not-in-named-directory`). +- **`test_harness_state`**: optional. Preloads verifier state before verification. `replay_cache_entries` lists `{ identity, nonce, ttl_seconds }` pairs the verifier has already accepted. Reset verifier state before every vector. +- **`expected_signature_base`**: keyed by the signature's label, the signature base per RFC 9421 section 2.5. Lines are joined with a single `\n`, there is no trailing newline, and components appear in the order listed in `Signature-Input`, followed by `@signature-params`. The base is the one a verifier computes from the request as sent. `006-two-signatures` carries one base per label, so a harness can show that each signature verifies on its own. Diff your computed base against this field before looking at signatures. +- **`expected_outcome.success`**: `true` for positive vectors, `false` for negative. +- **`expected_outcome.identities`**: positive vectors only. Keyed by the signature's label, the origin the verifier attributes the signature to. +- **`expected_outcome.status`**: negative vectors only. The HTTP status the verifier answers: `400` when the signature headers do not parse, `403` when a signature or the profile's checks fail, and `429` when the signature or its nonce was used before. Conformance requires this status. +- **`expected_outcome.accept_signature`**: negative vectors with `403` or `429`. The `Accept-Signature` member the verifier sends (RFC 9421 section 5.1): the label, the covered components, and the parameters the profile requires. `"nonce": "verifier-chosen"` marks the replay case, where the verifier supplies a fresh nonce value that this vector does not pin. The exact serialization is RFC 9421 section 5.1; conformance grades the status, and a harness MAY check that `Accept-Signature` names at least these components and parameters. +- **`expected_outcome.reason`**: the profile rule the request breaks. Informational. +- **`$comment`**: free-form notes, including which non-conformant verifier behavior the vector catches. + +### Governance vectors + +Each vector under `governance/` carries a compact JWS instead of a request: + +- **`document_type`**: `governance_context`. +- **`jws`**: the token as a verifier receives it. `decoded` repeats its header and payload for reading. +- **`directories`**: as for request vectors. The verifier resolves the key from the directory of the `iss` origin, which `jku` names. +- **`role_listing`**: the origins listed in each role by a record the verifier already trusts. In deployment, that record is the governance-typed `agents[]` entry in the buyer's `brand.json`, or the `adcp:governance` role in `trust.json` once #7809 lands. +- **`expected_outcome`**: `issuer` for the positive vector; for a negative vector, the `error_code` from the governance error table in `security.mdx`. + +The vectors grade the two governance checks. The two negative vectors each fail one check: `002` the key lookup in the `iss` origin's directory, and `003` the check that the issuer is the buyer's governance agent. The positive vector passes the remaining checks a verifier runs on the way (`alg`, `typ`, `jku` equal to the `iss` origin's directory, the signature, `exp` and `iat`), but no vector here fails them. The other checklist steps (`aud`, `phase`, `sub`, action binding, revocation, replay) are out of scope. + +## Conformance expectations + +An implementation is conformant when, for every vector: + +1. **Positive request vectors** verify the `web-bot-auth` signature and attribute it to the origin in `expected_outcome.identities`. +2. **The directory-response vector** verifies the response signature with the key the directory body provides and matches `Content-Digest` against the body. +3. **Negative vectors** answer `expected_outcome.status`. A `403` carries `Accept-Signature`. +4. **Governance vectors** verify `001` and attribute it to `expected_outcome.issuer`, and reject `002` and `003` with `expected_outcome.error_code`. +5. **Signature bytes on positive vectors** match the committed `Signature` values byte for byte when the implementation signs the committed `expected_signature_base` with the corresponding private seed. Ed25519 is deterministic. + +Several negative vectors carry cryptographically valid signatures on purpose. `001-missing-nonce`, `003-signature-agent-not-covered`, and `006-two-signatures` verify once the targeted check is removed, and `004-key-not-in-named-directory` verifies under a verifier that looks keys up by thumbprint alone. Both governance negatives carry valid signatures too: `002` verifies under a thumbprint-only lookup, and `003` verifies when the governance-agent check is skipped. A verifier that accepts any of them has skipped a profile rule, not failed at cryptography. + +## Running vectors against an implementation + +1. Parse each vector. +2. Build the verifier's directory store from `directories`, keyed by origin, selecting entries from `keys.json`. +3. Preload `test_harness_state` into the verifier, then reset it before the next vector. +4. Build the request from `request`, and invoke verification with `reference_now` as the wall clock. +5. Assert on `expected_outcome`: identities for positive vectors, status for negative vectors. + +Run the positive vectors first. If `positive/001` fails, the signature base, key loading, or thumbprint computation is wrong; the `expected_signature_base` field isolates the first of those. Then run `005` (parse), `001`, `003`, and `006` (profile rules without state), `004` (key lookup), and `002` (replay state). Run the governance vectors last; `003` is the one that shows a token is accepted for its issuer, not for its key. + +## Generating the vectors + +`scripts/generate-wba-profile-vectors.mjs` writes every file in this directory except this README. It derives the keys from their seeds, builds each signature base component by component, and signs it with Ed25519. Run `node scripts/generate-wba-profile-vectors.mjs --check` to confirm the committed files match the generator. The positive values are the ones in RFC #7878's illustrations. Do not hand-edit the vector files; change the generator and run it again. + +`tests/wba-profile-vectors.test.cjs` verifies the vectors without sharing code with the generator. It parses the headers, rebuilds each signature base from the request, and checks signatures and digests with `node:crypto`. It verifies the governance tokens with a minimal compact-JWS verifier, one named check per stage. For each negative vector it asserts the expected status and the check that fails. For each negative vector with a valid signature, it asserts that the request verifies once that one check is skipped. `npm run test:wba-profile-vectors` runs both. The positive vectors were also verified with a generic RFC 9421 library ([`http-message-sig`](https://www.npmjs.com/package/http-message-sig)) and, for the single-signer request, with Cloudflare's [`web-bot-auth`](https://www.npmjs.com/package/web-bot-auth) library. + +## Adding vectors + +Every added vector MUST: + +1. Cite the section of `wba-profile.mdx` it grades in `spec_reference`. +2. Use only keys from `keys.json`, and list every directory the verifier may consult in `directories`. +3. State `expected_outcome.status` for a negative vector in the profile's status-code vocabulary, and `accept_signature` for a `403`. +4. Include `expected_signature_base` for every label when the signature headers parse, computed from the request as sent. +5. Include `test_harness_state` when the vector needs preloaded verifier state. +6. Come from `scripts/generate-wba-profile-vectors.mjs`. A negative vector also needs an entry in `TARGETS` (or `GOVERNANCE_TARGETS` for a governance token) in `tests/wba-profile-vectors.test.cjs`, naming the check it targets. diff --git a/tests/conformance/vectors/wba-profile/UPSTREAM.json b/tests/conformance/vectors/wba-profile/UPSTREAM.json new file mode 100644 index 000000000..141e1afa1 --- /dev/null +++ b/tests/conformance/vectors/wba-profile/UPSTREAM.json @@ -0,0 +1,25 @@ +{ + "repository": "adcontextprotocol/adcp", + "pull_request": 7894, + "commit": "e24eeb090c4365707641099cbbb45f9945c6d6e0", + "path": "static/compliance/source/test-vectors/wba-profile/", + "vendored_on": "2026-10-16", + "renamed": { + "README.md": "UPSTREAM-README.md" + }, + "sha256": { + "governance/001-governance-token.json": "8e3a53bb9fa52b376d242ba67e0a86246af2fb97b44a9407212f65e506ed213b", + "governance/002-governance-token-key-not-in-issuer-directory.json": "8559d3aab6a1db4f2e8afe6d81f36a6e2e54b262464d6179ee04d6846ec9d557", + "governance/003-governance-token-from-non-governance-agent.json": "cbf328b7b7fe63df938da84ab86a94310f3ea9c232da99323cc32fbcee29e65b", + "keys.json": "08637bc288547315e6ce72e895d18ff5f3ab20a5f24375a15d0ebda43a993b3c", + "negative/001-missing-nonce.json": "12a60d6067c347976f2ec1769c2078c07f049f490a17c3da602898b4fdc8c3ad", + "negative/002-replayed-nonce.json": "7471d84d3aca75a196439b80c9a605230601c5495464daf6da6d7f9baeed1971", + "negative/003-signature-agent-not-covered.json": "fd2203fe8e445344c4fa27331d119216f6fd8383e8cfd0cb0c85b1dcd29019fc", + "negative/004-key-not-in-named-directory.json": "366094bc9af941dd20127402c8ebf03ab6eff1094dc54363fabec759b75ceb67", + "negative/005-malformed-signature-agent.json": "c0bf6e2d95be5f47d18a26abbcf0c38b2fb8a229a77d4acc4cad76fecad61f59", + "negative/006-two-signatures.json": "6dc88d4db2205ba7f0611fd9d4e2e6d7106ed45ea9c8238c5a801abb660901a1", + "positive/001-single-signer-request.json": "213b69f5f029c6da37abbc4dafff0f89e7604c5a6a4acca59b8c9df4a3d88e25", + "positive/002-signed-directory-response.json": "b07b0a947d943225c29fcb819008d080ca83f209d56be785a1a7b15f15e82dbe", + "UPSTREAM-README.md": "0dca00383bd0907bbf0fadfe09fb9787fd7cdfeabbcdc23b52c17038e1976bfd" + } +} diff --git a/tests/conformance/vectors/wba-profile/governance/001-governance-token.json b/tests/conformance/vectors/wba-profile/governance/001-governance-token.json new file mode 100644 index 000000000..a189b5eee --- /dev/null +++ b/tests/conformance/vectors/wba-profile/governance/001-governance-token.json @@ -0,0 +1,46 @@ +{ + "name": "Governance token issued by the governance identity https://governance-7k3q.com with its own key", + "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#one-agent-per-purpose", + "signing_profile": "web-bot-auth", + "document_type": "governance_context", + "reference_now": 1790841610, + "jws": "eyJhbGciOiJFZDI1NTE5IiwidHlwIjoiYWRjcC1nb3YrandzIiwiamt1IjoiaHR0cHM6Ly9nb3Zlcm5hbmNlLTdrM3EuY29tLy53ZWxsLWtub3duL2h0dHAtbWVzc2FnZS1zaWduYXR1cmVzLWRpcmVjdG9yeSIsImtpZCI6IjFYbEozRUl0ZTdiUE9FcUtRdEtyS05UTnk1aHl3VHlyd0ZYV3JNYlhLcUUifQ.eyJpc3MiOiJodHRwczovL2dvdmVybmFuY2UtN2szcS5jb20iLCJhdWQiOiJodHRwczovL3NlbGxlci03azNxLmNvbS9tY3AvIiwicGhhc2UiOiJpbnRlbnQiLCJqdGkiOiIwMUo5WlE0TThUNlczUjVZMks3TjFQMEI5QyIsImlhdCI6MTc5MDg0MTUwMCwiZXhwIjoxNzkwODQyNDAwfQ.p5jSxfBPrQ5Yj7PAGM8sWvABhdHON-6hKAeoaKmZnGdOslxlGrWmWOmjmk1PCCWinRiZAtJEIkaSoWOTZAs6AA", + "decoded": { + "header": { + "alg": "Ed25519", + "typ": "adcp-gov+jws", + "jku": "https://governance-7k3q.com/.well-known/http-message-signatures-directory", + "kid": "1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE" + }, + "payload": { + "iss": "https://governance-7k3q.com", + "aud": "https://seller-7k3q.com/mcp/", + "phase": "intent", + "jti": "01J9ZQ4M8T6W3R5Y2K7N1P0B9C", + "iat": 1790841500, + "exp": 1790842400 + } + }, + "directories": { + "https://governance-7k3q.com": { + "keys_ref": [ + "1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE" + ] + }, + "https://buyer-7k3q.com": { + "keys_ref": [ + "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I" + ] + } + }, + "role_listing": { + "adcp:governance": [ + "https://governance-7k3q.com" + ] + }, + "expected_outcome": { + "success": true, + "issuer": "https://governance-7k3q.com" + }, + "$comment": "The token in RFC #7878's illustrations. jku is the WBA directory of the iss origin, the kid is in that directory, the signature verifies, and the iss origin is listed in the governance role. role_listing stands in for the record the verifier already trusts: in deployment, the governance-typed agents[] entry in the buyer's brand.json, or the adcp:governance role in trust.json once #7809 lands. The token's other claims (aud, phase, sub, action binding, revocation, replay) follow the existing governance checklist and are not graded here." +} diff --git a/tests/conformance/vectors/wba-profile/governance/002-governance-token-key-not-in-issuer-directory.json b/tests/conformance/vectors/wba-profile/governance/002-governance-token-key-not-in-issuer-directory.json new file mode 100644 index 000000000..b10e2e90a --- /dev/null +++ b/tests/conformance/vectors/wba-profile/governance/002-governance-token-key-not-in-issuer-directory.json @@ -0,0 +1,47 @@ +{ + "name": "Governance token naming the governance agent as issuer, signed with a key that only the buyer agent publishes", + "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#one-agent-per-purpose", + "signing_profile": "web-bot-auth", + "document_type": "governance_context", + "reference_now": 1790841610, + "jws": "eyJhbGciOiJFZDI1NTE5IiwidHlwIjoiYWRjcC1nb3YrandzIiwiamt1IjoiaHR0cHM6Ly9nb3Zlcm5hbmNlLTdrM3EuY29tLy53ZWxsLWtub3duL2h0dHAtbWVzc2FnZS1zaWduYXR1cmVzLWRpcmVjdG9yeSIsImtpZCI6IjZXc0tNQ09iYmE0OVYzdU4wdklFTm5rMUJLQWs3Yzl6akJKY1dLeVE5X0kifQ.eyJpc3MiOiJodHRwczovL2dvdmVybmFuY2UtN2szcS5jb20iLCJhdWQiOiJodHRwczovL3NlbGxlci03azNxLmNvbS9tY3AvIiwicGhhc2UiOiJpbnRlbnQiLCJqdGkiOiIwMUo5WlE0TThUNlczUjVZMks3TjFQMEI5RCIsImlhdCI6MTc5MDg0MTUwMCwiZXhwIjoxNzkwODQyNDAwfQ.c4KwhdVq5e1Avl0r2GvuO0SJVQb2WOa7rxzCP_vpnoBWUe0XqIqgd_wdTLnsK4CKPkBITcSDzj-AlKcFiKNXBA", + "decoded": { + "header": { + "alg": "Ed25519", + "typ": "adcp-gov+jws", + "jku": "https://governance-7k3q.com/.well-known/http-message-signatures-directory", + "kid": "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I" + }, + "payload": { + "iss": "https://governance-7k3q.com", + "aud": "https://seller-7k3q.com/mcp/", + "phase": "intent", + "jti": "01J9ZQ4M8T6W3R5Y2K7N1P0B9D", + "iat": 1790841500, + "exp": 1790842400 + } + }, + "directories": { + "https://governance-7k3q.com": { + "keys_ref": [ + "1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE" + ] + }, + "https://buyer-7k3q.com": { + "keys_ref": [ + "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I" + ] + } + }, + "role_listing": { + "adcp:governance": [ + "https://governance-7k3q.com" + ] + }, + "expected_outcome": { + "success": false, + "error_code": "governance_key_unknown", + "reason": "The kid is not in the key directory of the iss origin. Key lookup is keyed on the pair of origin and key, so a key another agent publishes cannot sign as the governance agent." + }, + "$comment": "The signature is valid under the buyer agent's key, which the directories object lists under the buyer agent's origin. A verifier that looks keys up by thumbprint alone finds the key and accepts the token. role_listing stands in for the record the verifier already trusts: in deployment, the governance-typed agents[] entry in the buyer's brand.json, or the adcp:governance role in trust.json once #7809 lands. The token's other claims (aud, phase, sub, action binding, revocation, replay) follow the existing governance checklist and are not graded here." +} diff --git a/tests/conformance/vectors/wba-profile/governance/003-governance-token-from-non-governance-agent.json b/tests/conformance/vectors/wba-profile/governance/003-governance-token-from-non-governance-agent.json new file mode 100644 index 000000000..23e93626c --- /dev/null +++ b/tests/conformance/vectors/wba-profile/governance/003-governance-token-from-non-governance-agent.json @@ -0,0 +1,47 @@ +{ + "name": "Governance token issued by the buyer agent https://buyer-7k3q.com, which is not the buyer's governance agent, with its own key and directory", + "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#one-agent-per-purpose", + "signing_profile": "web-bot-auth", + "document_type": "governance_context", + "reference_now": 1790841610, + "jws": "eyJhbGciOiJFZDI1NTE5IiwidHlwIjoiYWRjcC1nb3YrandzIiwiamt1IjoiaHR0cHM6Ly9idXllci03azNxLmNvbS8ud2VsbC1rbm93bi9odHRwLW1lc3NhZ2Utc2lnbmF0dXJlcy1kaXJlY3RvcnkiLCJraWQiOiI2V3NLTUNPYmJhNDlWM3VOMHZJRU5uazFCS0FrN2M5empCSmNXS3lROV9JIn0.eyJpc3MiOiJodHRwczovL2J1eWVyLTdrM3EuY29tIiwiYXVkIjoiaHR0cHM6Ly9zZWxsZXItN2szcS5jb20vbWNwLyIsInBoYXNlIjoiaW50ZW50IiwianRpIjoiMDFKOVpRNE04VDZXM1I1WTJLN04xUDBCOUUiLCJpYXQiOjE3OTA4NDE1MDAsImV4cCI6MTc5MDg0MjQwMH0.yrat0BpIuHUsbg-ErWFDHtOpqPYvUcOYgpnbGXPIl5EfsFJMk9FHV2BbY6pdc48lYoLqOtKNyNYgHdvVgbDcCA", + "decoded": { + "header": { + "alg": "Ed25519", + "typ": "adcp-gov+jws", + "jku": "https://buyer-7k3q.com/.well-known/http-message-signatures-directory", + "kid": "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I" + }, + "payload": { + "iss": "https://buyer-7k3q.com", + "aud": "https://seller-7k3q.com/mcp/", + "phase": "intent", + "jti": "01J9ZQ4M8T6W3R5Y2K7N1P0B9E", + "iat": 1790841500, + "exp": 1790842400 + } + }, + "directories": { + "https://governance-7k3q.com": { + "keys_ref": [ + "1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE" + ] + }, + "https://buyer-7k3q.com": { + "keys_ref": [ + "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I" + ] + } + }, + "role_listing": { + "adcp:governance": [ + "https://governance-7k3q.com" + ] + }, + "expected_outcome": { + "success": false, + "error_code": "governance_issuer_not_authorized", + "reason": "The iss origin is not the buyer's governance agent. The key, jku, and signature are all consistent, so this is the check that rejects the token." + }, + "$comment": "Every check up to the governance-agent check passes: jku is the iss origin's directory, the kid is in it, and the signature verifies. A token is accepted for who issued it, not for which key signed it. A verifier that checks only that the key sits in the iss directory accepts this token. role_listing stands in for the record the verifier already trusts: in deployment, the governance-typed agents[] entry in the buyer's brand.json, or the adcp:governance role in trust.json once #7809 lands. The token's other claims (aud, phase, sub, action binding, revocation, replay) follow the existing governance checklist and are not graded here." +} diff --git a/tests/conformance/vectors/wba-profile/keys.json b/tests/conformance/vectors/wba-profile/keys.json index 3174c7048..f66c718ab 100644 --- a/tests/conformance/vectors/wba-profile/keys.json +++ b/tests/conformance/vectors/wba-profile/keys.json @@ -21,16 +21,6 @@ "use": "sig", "_private_d_for_test_only": "mu0nHxQDWktUz0o16gWmACuOZ86HpAwfmO4iP1qApp8" }, - { - "identity": "https://relay.agency-7k3q.com", - "seed_name": "relay-request", - "kty": "OKP", - "crv": "Ed25519", - "kid": "ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ", - "x": "8nrvWz557SHSlmuXE2dMstsU_WmRQNze3VIIeNhN-VE", - "use": "sig", - "_private_d_for_test_only": "9HMKspy6Bjt8vLzngwm2L1pzrbxOn6A7nTDBRhmRYOE" - }, { "identity": "https://governance-7k3q.com", "seed_name": "governance-document", diff --git a/tests/conformance/vectors/wba-profile/negative/001-missing-nonce.json b/tests/conformance/vectors/wba-profile/negative/001-missing-nonce.json index 9a86106fc..eaeb79b53 100644 --- a/tests/conformance/vectors/wba-profile/negative/001-missing-nonce.json +++ b/tests/conformance/vectors/wba-profile/negative/001-missing-nonce.json @@ -36,11 +36,12 @@ "expires", "nonce", "keyid", + "alg", "tag" ], "tag": "web-bot-auth" }, "reason": "The profile requires an RFC 9421 nonce on every signed request. Without one the verifier cannot detect replay." }, - "$comment": "The signature is cryptographically valid over its covered components, so a verifier that does not enforce the nonce requirement accepts it. The verifier answers 403 with Accept-Signature listing nonce among the required parameters. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." + "$comment": "The signature is cryptographically valid over its covered components, so a verifier that does not enforce the nonce requirement accepts it. The verifier answers 403 with Accept-Signature listing nonce among the required parameters. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 or 429 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." } diff --git a/tests/conformance/vectors/wba-profile/negative/002-replayed-nonce.json b/tests/conformance/vectors/wba-profile/negative/002-replayed-nonce.json index f22908699..c1887bbe1 100644 --- a/tests/conformance/vectors/wba-profile/negative/002-replayed-nonce.json +++ b/tests/conformance/vectors/wba-profile/negative/002-replayed-nonce.json @@ -37,7 +37,7 @@ }, "expected_outcome": { "success": false, - "status": 403, + "status": 429, "accept_signature": { "label": "sig1", "components": "(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\")", @@ -46,6 +46,7 @@ "expires", "nonce", "keyid", + "alg", "tag" ], "tag": "web-bot-auth", @@ -53,5 +54,5 @@ }, "reason": "The verifier MUST reject a nonce it has already seen for the same identity within the signature's lifetime." }, - "$comment": "The request is byte-identical to positive/001-single-signer-request.json. The verifier checks the nonce per signature and per identity, whether or not an edge verifier ran first. The 403 carries Accept-Signature with a verifier-chosen nonce value (RFC 9421 sections 5.1 and 7.2.2), which the signer uses on its next attempt; the value is not pinned by this vector. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." + "$comment": "The request is byte-identical to positive/001-single-signer-request.json. The verifier checks the nonce per signature and per identity, whether or not an edge verifier ran first. The 429 carries Accept-Signature with a verifier-chosen nonce value (Web Bot Auth section 5.3; RFC 9421 sections 5.1 and 7.2.2), which the signer uses on its next attempt; the value is not pinned by this vector. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 or 429 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." } diff --git a/tests/conformance/vectors/wba-profile/negative/003-signature-agent-not-covered.json b/tests/conformance/vectors/wba-profile/negative/003-signature-agent-not-covered.json index 3417218b6..7465d9db7 100644 --- a/tests/conformance/vectors/wba-profile/negative/003-signature-agent-not-covered.json +++ b/tests/conformance/vectors/wba-profile/negative/003-signature-agent-not-covered.json @@ -36,11 +36,12 @@ "expires", "nonce", "keyid", + "alg", "tag" ], "tag": "web-bot-auth" }, "reason": "Web Bot Auth section 5.2.1: the Signature-Agent member keyed to the signature label MUST be signed as a component. An uncovered member is an unverified claim and MUST NOT be attributed (section 4.1)." }, - "$comment": "The signature verifies over the components it does cover, so a verifier that reads the identity from the uncovered header and skips the coverage check accepts it and attributes the request to an origin nobody signed for. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." + "$comment": "The signature verifies over the components it does cover, so a verifier that reads the identity from the uncovered header and skips the coverage check accepts it and attributes the request to an origin nobody signed for. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 or 429 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." } diff --git a/tests/conformance/vectors/wba-profile/negative/006-key-not-in-named-directory.json b/tests/conformance/vectors/wba-profile/negative/004-key-not-in-named-directory.json similarity index 91% rename from tests/conformance/vectors/wba-profile/negative/006-key-not-in-named-directory.json rename to tests/conformance/vectors/wba-profile/negative/004-key-not-in-named-directory.json index 62d51df5d..ec522dfa5 100644 --- a/tests/conformance/vectors/wba-profile/negative/006-key-not-in-named-directory.json +++ b/tests/conformance/vectors/wba-profile/negative/004-key-not-in-named-directory.json @@ -41,11 +41,12 @@ "expires", "nonce", "keyid", + "alg", "tag" ], "tag": "web-bot-auth" }, "reason": "Key lookup is keyed on the (origin, keyid) pair, never on keyid alone (Web Bot Auth section 5.4). The directory at the named origin holds no key with this thumbprint, so the request is unverified and MUST NOT be attributed to that origin (section 6.10)." }, - "$comment": "The signature is valid under the buyer agent's key, and the directories object lists that key under its own origin so the harness can show the trap: a verifier that indexes keys by thumbprint alone finds the key, verifies, and attributes the request to the brand agent, an origin that never published the key. A conformant verifier resolves only https://agent.brand-7k3q.com/.well-known/http-message-signatures-directory and rejects. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." + "$comment": "The signature is valid under the buyer agent's key, and the directories object lists that key under its own origin so the harness can show the trap: a verifier that indexes keys by thumbprint alone finds the key, verifies, and attributes the request to the brand agent, an origin that never published the key. A conformant verifier resolves only https://agent.brand-7k3q.com/.well-known/http-message-signatures-directory and rejects. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 or 429 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." } diff --git a/tests/conformance/vectors/wba-profile/negative/004-relay-omits-inner-signature.json b/tests/conformance/vectors/wba-profile/negative/004-relay-omits-inner-signature.json deleted file mode 100644 index eebbaa208..000000000 --- a/tests/conformance/vectors/wba-profile/negative/004-relay-omits-inner-signature.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "name": "Relay adds a second signature that does not cover the brand agent's signature members", - "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#relays", - "signing_profile": "web-bot-auth", - "reference_now": 1790841610, - "request": { - "method": "POST", - "url": "https://seller-7k3q.com/mcp/", - "headers": { - "Content-Type": "application/json", - "Content-Digest": "sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:", - "Signature-Agent": "brand=\"https://agent.brand-7k3q.com\", relay=\"https://relay.agency-7k3q.com\"", - "Signature-Input": "brand=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\", relay=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\");created=1790841601;expires=1790841901;nonce=\"pFCSjj_rt5LZuG-V4YvQAcOlDBrfb8Y35GQsMHTcpO99SLTcz-osapOLlF7767B6CyiDfZCertLo87_dfioQHg\";keyid=\"ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "Signature": "brand=:7QHcZuQ1NGje/glPbC1DTSmnKDFPf8FIJohfZEOs6/INcF1I5I6xcNsWz3+Czj9mtGQWRG9wmDOWTAmnPQcoAA==:, relay=:2yPlznBMeRW3OuZL0nTEiAre21tcqZH04+dfLsykHH+5FOQzdACO+Bf7i1CYQE24TWrz5NallQc2+yr+ehX1CA==:" - }, - "body": "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"create_media_buy\",\"arguments\":{\"idempotency_key\":\"4f6c1a0e-8d2b-4b7e-9c3a-2e5d7f1b0a96\",\"account\":{\"account_id\":\"acc_42\"},\"proposal_id\":\"prop_7\",\"total_budget\":{\"amount\":25000,\"currency\":\"USD\"}}}}" - }, - "directories": { - "https://agent.brand-7k3q.com": { - "keys_ref": [ - "hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI" - ] - }, - "https://relay.agency-7k3q.com": { - "keys_ref": [ - "ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ" - ] - } - }, - "expected_signature_base": { - "brand": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"brand\": \"https://agent.brand-7k3q.com\"\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "relay": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"relay\": \"https://relay.agency-7k3q.com\"\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\");created=1790841601;expires=1790841901;nonce=\"pFCSjj_rt5LZuG-V4YvQAcOlDBrfb8Y35GQsMHTcpO99SLTcz-osapOLlF7767B6CyiDfZCertLo87_dfioQHg\";keyid=\"ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ\";alg=\"ed25519\";tag=\"web-bot-auth\"" - }, - "expected_outcome": { - "success": false, - "status": 403, - "accept_signature": { - "label": "relay", - "components": "(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\" \"signature-agent\";key=\"brand\" \"signature-input\";key=\"brand\" \"signature\";key=\"brand\")", - "parameters": [ - "created", - "expires", - "nonce", - "keyid", - "tag" - ], - "tag": "web-bot-auth" - }, - "reason": "The profile requires each later signer to cover the signature-agent, signature-input, and signature members of the signature before it. The relay label covers none of the brand label's members." - }, - "$comment": "Both signatures verify on their own, so a verifier that checks only cryptographic validity accepts the request. The relay's signature then records nothing about the brand agent's signature having been present, which is the evidence the profile requires of a relay (Web Bot Auth section 5.2.2 and Appendix D.2). The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." -} diff --git a/tests/conformance/vectors/wba-profile/negative/007-malformed-signature-agent.json b/tests/conformance/vectors/wba-profile/negative/005-malformed-signature-agent.json similarity index 100% rename from tests/conformance/vectors/wba-profile/negative/007-malformed-signature-agent.json rename to tests/conformance/vectors/wba-profile/negative/005-malformed-signature-agent.json diff --git a/tests/conformance/vectors/wba-profile/negative/005-relay-inner-signature-altered.json b/tests/conformance/vectors/wba-profile/negative/005-relay-inner-signature-altered.json deleted file mode 100644 index 7fa9df29b..000000000 --- a/tests/conformance/vectors/wba-profile/negative/005-relay-inner-signature-altered.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "name": "Brand agent's signature altered after the relay signed over it", - "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#relays", - "signing_profile": "web-bot-auth", - "reference_now": 1790841610, - "request": { - "method": "POST", - "url": "https://seller-7k3q.com/mcp/", - "headers": { - "Content-Type": "application/json", - "Content-Digest": "sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:", - "Signature-Agent": "brand=\"https://agent.brand-7k3q.com\", relay=\"https://relay.agency-7k3q.com\"", - "Signature-Input": "brand=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\", relay=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\" \"signature-agent\";key=\"brand\" \"signature-input\";key=\"brand\" \"signature\";key=\"brand\");created=1790841601;expires=1790841901;nonce=\"-GaxnHg03kcsnLVNnfSCtmu1a_zGjiVj6mJkFsZhxKyixpqR8yb_rkbLaG1ZA2sbwwoJjDQuifAeZj1-hBohVw\";keyid=\"ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "Signature": "brand=:AQHcZuQ1NGje/glPbC1DTSmnKDFPf8FIJohfZEOs6/INcF1I5I6xcNsWz3+Czj9mtGQWRG9wmDOWTAmnPQcoAA==:, relay=:ra29HdXx7Hl46Tb9bx+8hhZQ8sL7xeeIC0kaWoUTS7qZyjSd9IyZa7jFcdbjkDFMfnmhHtu6RD+WFZ84Tz78CA==:" - }, - "body": "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"create_media_buy\",\"arguments\":{\"idempotency_key\":\"4f6c1a0e-8d2b-4b7e-9c3a-2e5d7f1b0a96\",\"account\":{\"account_id\":\"acc_42\"},\"proposal_id\":\"prop_7\",\"total_budget\":{\"amount\":25000,\"currency\":\"USD\"}}}}" - }, - "directories": { - "https://agent.brand-7k3q.com": { - "keys_ref": [ - "hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI" - ] - }, - "https://relay.agency-7k3q.com": { - "keys_ref": [ - "ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ" - ] - } - }, - "expected_signature_base": { - "brand": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"brand\": \"https://agent.brand-7k3q.com\"\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "relay": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"relay\": \"https://relay.agency-7k3q.com\"\n\"signature-agent\";key=\"brand\": \"https://agent.brand-7k3q.com\"\n\"signature-input\";key=\"brand\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\"\n\"signature\";key=\"brand\": :AQHcZuQ1NGje/glPbC1DTSmnKDFPf8FIJohfZEOs6/INcF1I5I6xcNsWz3+Czj9mtGQWRG9wmDOWTAmnPQcoAA==:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\" \"signature-agent\";key=\"brand\" \"signature-input\";key=\"brand\" \"signature\";key=\"brand\");created=1790841601;expires=1790841901;nonce=\"-GaxnHg03kcsnLVNnfSCtmu1a_zGjiVj6mJkFsZhxKyixpqR8yb_rkbLaG1ZA2sbwwoJjDQuifAeZj1-hBohVw\";keyid=\"ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ\";alg=\"ed25519\";tag=\"web-bot-auth\"" - }, - "expected_outcome": { - "success": false, - "status": 403, - "accept_signature": { - "label": "relay", - "components": "(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\" \"signature-agent\";key=\"brand\" \"signature-input\";key=\"brand\" \"signature\";key=\"brand\")", - "parameters": [ - "created", - "expires", - "nonce", - "keyid", - "tag" - ], - "tag": "web-bot-auth" - }, - "reason": "The relay label claims to cover \"signature\";key=\"brand\", but the value on the wire is not the one the relay signed, so the relay's signature fails. The brand label's signature fails as well." - }, - "$comment": "Derived from positive/002-relay-request-two-signatures.json by changing the first character of the brand signature. Every web-bot-auth signature on a request must verify, so one failure rejects the request. The vector shows that the relay's signature binds the brand agent's signature bytes: changing or removing them breaks the relay's signature too. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." -} diff --git a/tests/conformance/vectors/wba-profile/negative/006-two-signatures.json b/tests/conformance/vectors/wba-profile/negative/006-two-signatures.json new file mode 100644 index 000000000..dd02f37fe --- /dev/null +++ b/tests/conformance/vectors/wba-profile/negative/006-two-signatures.json @@ -0,0 +1,53 @@ +{ + "name": "Two web-bot-auth signatures on one request: positive/001 plus a second valid signature from https://agent.brand-7k3q.com", + "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#verification", + "signing_profile": "web-bot-auth", + "reference_now": 1790841610, + "request": { + "method": "POST", + "url": "https://seller-7k3q.com/mcp/", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:", + "Signature-Agent": "sig1=\"https://buyer-7k3q.com\", sig2=\"https://agent.brand-7k3q.com\"", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\");created=1790841600;expires=1790841900;nonce=\"Mn-ZM8SnGESx0-rmyYMuTkJFerfbwEii2C0qNkDP8ugpcuv0pcrt6Da2ifr-6MTmfVeZhnFymLeAofeXJbFaxA\";keyid=\"6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I\";alg=\"ed25519\";tag=\"web-bot-auth\", sig2=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig2\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\"", + "Signature": "sig1=:ihPGBC7XQPM6WkIYhYQvFHgwTpbssvuQzkb0FfwkuebREyF32sXhiRxXbXWRZMp0O//C4I2g1a2Kui56ffEfBQ==:, sig2=:UdBx3V5QrRm84M54KvhTG/DxP+i2cNRdPmKgbUVJRvy2omQm6buIJRnOB8Rray4p8mSEy/JuaopoFISc1a2SDQ==:" + }, + "body": "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"create_media_buy\",\"arguments\":{\"idempotency_key\":\"4f6c1a0e-8d2b-4b7e-9c3a-2e5d7f1b0a96\",\"account\":{\"account_id\":\"acc_42\"},\"proposal_id\":\"prop_7\",\"total_budget\":{\"amount\":25000,\"currency\":\"USD\"}}}}" + }, + "directories": { + "https://buyer-7k3q.com": { + "keys_ref": [ + "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I" + ] + }, + "https://agent.brand-7k3q.com": { + "keys_ref": [ + "hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI" + ] + } + }, + "expected_signature_base": { + "sig1": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"sig1\": \"https://buyer-7k3q.com\"\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\");created=1790841600;expires=1790841900;nonce=\"Mn-ZM8SnGESx0-rmyYMuTkJFerfbwEii2C0qNkDP8ugpcuv0pcrt6Da2ifr-6MTmfVeZhnFymLeAofeXJbFaxA\";keyid=\"6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I\";alg=\"ed25519\";tag=\"web-bot-auth\"", + "sig2": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"sig2\": \"https://agent.brand-7k3q.com\"\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig2\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\"" + }, + "expected_outcome": { + "success": false, + "status": 403, + "accept_signature": { + "label": "sig1", + "components": "(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\")", + "parameters": [ + "created", + "expires", + "nonce", + "keyid", + "alg", + "tag" + ], + "tag": "web-bot-auth" + }, + "reason": "The profile takes exactly one web-bot-auth signature and one Signature-Agent member per request (RFC 9421 section 3.2.1). Web Bot Auth section 5.2.2 permits more than one signature; the profile does not accept one." + }, + "$comment": "The sig1 label is byte-identical to positive/001-single-signer-request.json, and the sig2 label is a second valid signature over the same request from another identity. Each verifies on its own against its own directory, so a verifier that loops over every label and accepts when each verifies has skipped the count check. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 or 429 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." +} diff --git a/tests/conformance/vectors/wba-profile/positive/002-relay-request-two-signatures.json b/tests/conformance/vectors/wba-profile/positive/002-relay-request-two-signatures.json deleted file mode 100644 index 9ceeb670b..000000000 --- a/tests/conformance/vectors/wba-profile/positive/002-relay-request-two-signatures.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "Relay: brand agent signs, agency relay forwards unchanged and adds a signature covering the brand agent's members", - "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#relays", - "signing_profile": "web-bot-auth", - "reference_now": 1790841610, - "request": { - "method": "POST", - "url": "https://seller-7k3q.com/mcp/", - "headers": { - "Content-Type": "application/json", - "Content-Digest": "sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:", - "Signature-Agent": "brand=\"https://agent.brand-7k3q.com\", relay=\"https://relay.agency-7k3q.com\"", - "Signature-Input": "brand=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\", relay=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\" \"signature-agent\";key=\"brand\" \"signature-input\";key=\"brand\" \"signature\";key=\"brand\");created=1790841601;expires=1790841901;nonce=\"-GaxnHg03kcsnLVNnfSCtmu1a_zGjiVj6mJkFsZhxKyixpqR8yb_rkbLaG1ZA2sbwwoJjDQuifAeZj1-hBohVw\";keyid=\"ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "Signature": "brand=:7QHcZuQ1NGje/glPbC1DTSmnKDFPf8FIJohfZEOs6/INcF1I5I6xcNsWz3+Czj9mtGQWRG9wmDOWTAmnPQcoAA==:, relay=:ra29HdXx7Hl46Tb9bx+8hhZQ8sL7xeeIC0kaWoUTS7qZyjSd9IyZa7jFcdbjkDFMfnmhHtu6RD+WFZ84Tz78CA==:" - }, - "body": "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"create_media_buy\",\"arguments\":{\"idempotency_key\":\"4f6c1a0e-8d2b-4b7e-9c3a-2e5d7f1b0a96\",\"account\":{\"account_id\":\"acc_42\"},\"proposal_id\":\"prop_7\",\"total_budget\":{\"amount\":25000,\"currency\":\"USD\"}}}}" - }, - "directories": { - "https://agent.brand-7k3q.com": { - "keys_ref": [ - "hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI" - ] - }, - "https://relay.agency-7k3q.com": { - "keys_ref": [ - "ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ" - ] - } - }, - "expected_signature_base": { - "brand": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"brand\": \"https://agent.brand-7k3q.com\"\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "relay": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"relay\": \"https://relay.agency-7k3q.com\"\n\"signature-agent\";key=\"brand\": \"https://agent.brand-7k3q.com\"\n\"signature-input\";key=\"brand\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\"\n\"signature\";key=\"brand\": :7QHcZuQ1NGje/glPbC1DTSmnKDFPf8FIJohfZEOs6/INcF1I5I6xcNsWz3+Czj9mtGQWRG9wmDOWTAmnPQcoAA==:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\" \"signature-agent\";key=\"brand\" \"signature-input\";key=\"brand\" \"signature\";key=\"brand\");created=1790841601;expires=1790841901;nonce=\"-GaxnHg03kcsnLVNnfSCtmu1a_zGjiVj6mJkFsZhxKyixpqR8yb_rkbLaG1ZA2sbwwoJjDQuifAeZj1-hBohVw\";keyid=\"ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ\";alg=\"ed25519\";tag=\"web-bot-auth\"" - }, - "expected_outcome": { - "success": true, - "identities": { - "brand": "https://agent.brand-7k3q.com", - "relay": "https://relay.agency-7k3q.com" - } - }, - "$comment": "Both web-bot-auth signatures must verify, each against its own covered components and its own identity's directory (Web Bot Auth section 5.2.2). The relay label covers the brand label's signature-agent, signature-input, and signature members, as section 5.2.2 requires of a signer that covers another signature and as Appendix D.2 illustrates. Whether the relay may act for the brand is an authorization question answered by the brand's brand.json, outside this profile." -} diff --git a/tests/conformance/vectors/wba-profile/positive/003-signed-directory-response.json b/tests/conformance/vectors/wba-profile/positive/002-signed-directory-response.json similarity index 100% rename from tests/conformance/vectors/wba-profile/positive/003-signed-directory-response.json rename to tests/conformance/vectors/wba-profile/positive/002-signed-directory-response.json diff --git a/tests/test_identity_3_3_alignment.py b/tests/test_identity_3_3_alignment.py index 389b5aa9e..fa0563d57 100644 --- a/tests/test_identity_3_3_alignment.py +++ b/tests/test_identity_3_3_alignment.py @@ -17,8 +17,6 @@ import pytest from asgi_lifespan import LifespanManager from cryptography.hazmat.primitives.asymmetric import ed25519 -from http_sfv.dictionary import Dictionary -from http_sfv.item import InnerList, Item from starlette.requests import Request from starlette.responses import JSONResponse @@ -69,8 +67,7 @@ OriginRevocationChecker, WbaDiscoveryError, WbaVerificationError, - _base, - _sign, + _WbaComponentResolver, sign_key_directory, verify_directory_response, verify_wba_request, @@ -111,10 +108,10 @@ def _signed(private: Any, body: bytes, *, origin: str = ORIGIN, url: str = TARGE return headers -def _member(value: str, label: str = "sig1") -> Any: - parsed = Dictionary() - parsed.parse(value.encode("ascii")) - return parsed[label] +def _params(value: str, label: str = "sig1") -> dict[str, Any]: + from adcp.signing._rfc9421 import parse_dictionary + + return parse_dictionary(value)[label][1] def _recording_app(config: RequestSignatureVerification, *, transport: str = "mcp"): @@ -388,28 +385,23 @@ async def listed(url, *, allow_private=False): assert signer.principal_id == (None if revocation == "no_list" else "principal-1") -async def test_revoked_relay_key_is_refused_like_the_originator(): +async def test_revoked_key_is_refused_without_a_challenge(): from adcp.signing.wba import WbaRevokedKeyError, check_wba_revocation - originator, relay = ( - VerifiedSigner( - kid * 43, "ed25519", label, time.time(), agent_url=origin, profile="web-bot-auth" - ) - for kid, label, origin in ( - ("o", "sig1", ORIGIN), - ("r", "sig2", "https://relay.example.com"), - ) - ) + signer = VerifiedSigner("o" * 43, "ed25519", "sig1", time.time(), agent_url=ORIGIN) class Checker: + def __init__(self, status): + self._status = status + async def status(self, origin, keyid): - return "revoked" if keyid == relay.key_id else "current" + return self._status - assert (await check_wba_revocation(Checker(), (originator,)))[0].revocation == "current" + assert (await check_wba_revocation(Checker("current"), (signer,)))[0].revocation == "current" with pytest.raises(WbaRevokedKeyError) as revoked: - await check_wba_revocation(Checker(), (originator, relay)) + await check_wba_revocation(Checker("revoked"), (signer,)) assert revoked.value.status == 403 - assert revoked.value.label == "sig2" + assert revoked.value.label == "sig1" assert not revoked.value.headers @@ -594,24 +586,24 @@ async def test_grant_domains_compare_as_registrable_domains(): @pytest.mark.parametrize( "nonce,accepted", [ - (b64url_encode(b"\x07" * 15), False), # 20 characters - (b64url_encode(b"\x07" * 16), True), # 22 characters, the 128-bit floor - (b64url_encode(b"\x07" * 32), True), - (b64url_encode(b"\x07" * 64), True), - ("A" * 256, True), - ("A" * 257, False), - ("A" * 21, False), + (b64url_encode(b"\x07" * 16), False), # 22 characters, 128 bits: too short + (b64url_encode(b"\x07" * 32), False), + (b64url_encode(b"\x07" * 63), False), + (b64url_encode(b"\x07" * 65), False), + (b64url_encode(b"\x07" * 64) + "=", False), # padded base64url is not canonical + (b64url_encode(b"\x07" * 64), True), # the profile's spelling + (__import__("base64").b64encode(b"\xfb" * 64).decode(), True), # Cloudflare's ], ) -async def test_verifier_accepts_22_to_256_character_nonces(nonce, accepted): - """64 bytes is RECOMMENDED for signers; a compliant signer may send fewer.""" +async def test_verifier_requires_a_64_byte_nonce(nonce, accepted): + """The profile requires a 64-byte nonce on every signed request (adcp#7894).""" private = ed25519.Ed25519PrivateKey.generate() jwk = public_directory_key(private) body = _tool("get_products") if not accepted: - with pytest.raises(ValueError, match="128 bits"): + with pytest.raises(ValueError, match="64 bytes"): _signed(private, body, created=1000, nonce=nonce) - valid = "B" * 86 + valid = b64url_encode(b"\x05" * 64) headers = _signed(private, body, created=1000, nonce=valid) headers["Signature-Input"] = headers["Signature-Input"].replace(valid, nonce) else: @@ -629,15 +621,17 @@ async def test_verifier_accepts_22_to_256_character_nonces(nonce, accepted): (signer,) = await verify_wba_request(**options) assert signer.agent_url == ORIGIN else: - with pytest.raises(WbaVerificationError, match="nonce"): + with pytest.raises(WbaVerificationError, match="nonce") as caught: await verify_wba_request(**options) + assert caught.value.status == 403 + assert "Accept-Signature" in caught.value.headers def test_signer_defaults_to_a_64_byte_nonce(): from adcp.signing.wba import is_64_byte_nonce headers = _signed(ed25519.Ed25519PrivateKey.generate(), _tool("get_products")) - assert is_64_byte_nonce(_member(headers["Signature-Input"]).params["nonce"]) + assert is_64_byte_nonce(_params(headers["Signature-Input"])["nonce"]) def test_received_target_uri_keeps_percent_encoding_and_query(): @@ -673,49 +667,54 @@ def _unsigned_directory(*jwks: dict[str, Any], media_type: str = "application/js return DirectoryResponse(body, {"Content-Type": media_type}) -def test_plain_jwks_directory_is_accepted_by_thumbprint_unless_signatures_are_required(): +def test_unsigned_directories_are_refused_and_keys_are_found_by_thumbprint(): + """Signed directories are required (adcp#7894, WBA Appendix B.1).""" private = ed25519.Ed25519PrivateKey.generate() jwk = public_directory_key(private) without_kid = {k: v for k, v in jwk.items() if k != "kid"} - other_use = {"kty": "oct", "k": "c2VjcmV0"} # not a key the profile can use - keys = verify_directory_response( - ORIGIN, _unsigned_directory(without_kid, {**jwk, "use": "enc"}), now=1000 - ) - assert list(keys) == [jwk["kid"]] - for media_type in ("application/jwk-set+json", DIRECTORY_MEDIA_TYPE): - assert verify_directory_response( - ORIGIN, _unsigned_directory(without_kid, media_type=media_type), now=1000 - ) + for media_type in ("application/json", "application/jwk-set+json", DIRECTORY_MEDIA_TYPE): + with pytest.raises(WbaVerificationError, match="not signed"): + verify_directory_response( + ORIGIN, _unsigned_directory(without_kid, media_type=media_type), now=1000 + ) + # A signed directory finds the key by its thumbprint; a kid is optional. + signed = sign_key_directory(agent_origin=ORIGIN, private_keys=[private], created=1000) + body = json.loads(signed.body) + body["keys"] = [without_kid, {"kty": "oct", "k": "c2VjcmV0"}] + raw = json.dumps(body).encode() with pytest.raises(WbaVerificationError, match="private key material"): - verify_directory_response(ORIGIN, _unsigned_directory(without_kid, other_use), now=1000) - with pytest.raises(WbaVerificationError, match="not signed"): verify_directory_response( - ORIGIN, _unsigned_directory(without_kid), now=1000, require_signed=True + ORIGIN, + DirectoryResponse( + raw, {**signed.headers, "Content-Digest": compute_content_digest_sha256(raw)} + ), + now=1001, ) def _partly_signed_directory(signed_key, unsigned_key, *, components=None, created=1000): + from adcp.signing import _rfc9421 + jwks = [public_directory_key(signed_key), public_directory_key(unsigned_key)] body = json.dumps({"keys": jwks}).encode() headers = { "Content-Type": DIRECTORY_MEDIA_TYPE, "Content-Digest": compute_content_digest_sha256(body), } - authority = Item("@authority") - authority.params["req"] = True - member = InnerList(components or [authority, Item("content-digest")]) - member.params.update( - { - "created": created, - "expires": created + 3600, - "keyid": jwks[0]["kid"], - "alg": "ed25519", - "tag": "http-message-signatures-directory", - } + signature_input, signature = _rfc9421.sign( + _rfc9421.Response(200, _rfc9421.Request("GET", ORIGIN + DIRECTORY_PATH), dict(headers)), + alg="ed25519", + private_key=signed_key, + key_id=jwks[0]["kid"], + label="key1", + created=created, + expires=created + 3600, + nonce=None, + tag="http-message-signatures-directory", + covered_component_ids=components or ['"@authority";req', '"content-digest"'], + resolver=_WbaComponentResolver, ) - base = _base("GET", ORIGIN + DIRECTORY_PATH, headers, member, directory_response=True) - headers["Signature-Input"] = str(Dictionary({"key1": member})) - headers["Signature"] = str(Dictionary({"key1": Item(_sign("ed25519", signed_key, base))})) + headers.update({"Signature-Input": signature_input, "Signature": signature}) return DirectoryResponse(body, headers), jwks @@ -728,24 +727,17 @@ def test_signed_directory_indexes_only_keys_with_a_verified_signature(): def test_directory_signatures_require_the_exact_component_identifiers(): signed, unsigned = (ed25519.Ed25519PrivateKey.generate() for _ in range(2)) - keyed_digest = Item("content-digest") - keyed_digest.params["key"] = "sha-256" - bare_authority = Item("@authority") - for components in ([Item("@authority"), Item("content-digest")], [bare_authority]): + for components in ( + ['"@authority"', '"content-digest"'], + ['"@authority"'], + ['"@authority";req', '"content-digest";key="sha-256"'], + ): with pytest.raises(WbaVerificationError): verify_directory_response( ORIGIN, _partly_signed_directory(signed, unsigned, components=components)[0], now=1001, ) - authority = Item("@authority") - authority.params["req"] = True - with pytest.raises(WbaVerificationError): - verify_directory_response( - ORIGIN, - _partly_signed_directory(signed, unsigned, components=[authority, keyed_digest])[0], - now=1001, - ) def test_directory_digest_covers_the_exact_bytes_including_a_bom(): @@ -765,9 +757,9 @@ def test_directory_created_is_backdated_and_tolerates_a_verifier_behind(): response = sign_key_directory( agent_origin=ORIGIN, private_keys=[ed25519.Ed25519PrivateKey.generate()] ) - member = _member(response.headers["Signature-Input"], "key1") - assert member.params["created"] <= before - DIRECTORY_SKEW_ALLOWANCE_SECONDS + 1 - assert member.params["expires"] >= before + 86400 + params = _params(response.headers["Signature-Input"], "key1") + assert params["created"] <= before - DIRECTORY_SKEW_ALLOWANCE_SECONDS + 1 + assert params["expires"] >= before + 86400 assert verify_directory_response(ORIGIN, response, now=time.time() - 30) @@ -1435,7 +1427,10 @@ def test_wba_without_authorities_warns_at_boot(caplog): assert "authorities" in caplog.text -async def test_replayed_wba_nonce_is_429_without_a_challenge(): +async def test_replayed_wba_nonce_is_429_with_a_fresh_nonce(): + """A reused nonce is 429 with Accept-Signature carrying a fresh nonce (adcp#7894).""" + from adcp.signing.wba import is_64_byte_nonce + private = ed25519.Ed25519PrivateKey.generate() jwk = public_directory_key(private) body = _tool("get_products") @@ -1453,7 +1448,11 @@ async def test_replayed_wba_nonce_is_429_without_a_challenge(): with pytest.raises(WbaVerificationError, match="replayed") as replayed: await verify_wba_request(**options) assert replayed.value.status == 429 - assert not replayed.value.headers + challenge = _params(replayed.value.headers["Accept-Signature"]) + sent = _params(headers["Signature-Input"])["nonce"] + assert is_64_byte_nonce(challenge["nonce"]) and challenge["nonce"] != sent + # The fresh nonce is stable for this answer. + assert replayed.value.headers == replayed.value.headers def test_rfc9421_deprecation_warns_only_once_33_is_advertised(monkeypatch): From e81d80090799ebde187551883712fd1eb094dde5 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 17 Oct 2026 00:55:23 +0000 Subject: [PATCH 2/2] fix(signing): address review of the RFC 9421 library migration - Read one header spelling everywhere: the library request takes the first spelling of a name, as the profile lookups do, and the step-1 precheck refuses a signed field that a mapping carries under two spellings. - Refuse parameters on a Signature member and a req parameter that is not a true flag. - verify_wba_governance_issuer registers the action-binding crit markers, checks marker/claim pairing, and checks the issuer before key discovery. - Typed duplicate-field errors, an explicit window guard on the shared verify helper, a tab-tolerant profile-selection walk, and contract tests for the http-sf primitives it uses. Refs #1511 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/agent-identity-3-3-migration.md | 3 +- src/adcp/governance.py | 4 +- src/adcp/signing/_header_precheck.py | 20 ++- src/adcp/signing/_rfc9421.py | 129 ++++++++++-------- src/adcp/signing/autosign.py | 4 +- src/adcp/signing/canonical.py | 4 +- src/adcp/signing/crypto.py | 6 +- src/adcp/signing/signer.py | 4 +- src/adcp/signing/verifier.py | 1 + src/adcp/signing/wba.py | 56 ++++++-- .../signing/test_rfc9421_library_contract.py | 63 +++++++++ .../test_structured_field_duplicates.py | 114 ++++++++++++++++ tests/conformance/signing/test_wba_profile.py | 77 +++++++++++ 13 files changed, 404 insertions(+), 81 deletions(-) diff --git a/docs/agent-identity-3-3-migration.md b/docs/agent-identity-3-3-migration.md index b92c1a4c3..2ec8e2760 100644 --- a/docs/agent-identity-3-3-migration.md +++ b/docs/agent-identity-3-3-migration.md @@ -99,7 +99,7 @@ Duplicate structured-field parameters, dictionary labels, and `Signature-Agent` ### Interop -- **One signature.** A request carries exactly one `web-bot-auth` signature and exactly one `Signature-Agent` member, keyed to that signature's label. Relays are gone: an intermediary that signs is the signer, and whether it may act for another party is an authorization question, not a second signature. `verify_wba_request` still returns a tuple, now always of one signer. +- **One signature.** A request carries exactly one `web-bot-auth` signature and exactly one `Signature-Agent` member, keyed to that signature's label. Relays are gone: an intermediary that signs is the signer, and whether it may act for another party is an authorization question, not a second signature. `verify_wba_request` still returns a tuple, now always of one signer. `Signature-Agent` is read as a Structured Fields Dictionary only; the older bare sf-string form is not part of the final profile. - **Nonce.** The nonce encodes exactly 64 bytes. The profile spells it as unpadded base64url; padded standard base64 of 64 bytes is also accepted, because Cloudflare's `web-bot-auth` signer and the draft's Appendix E vectors send it. The page has no shorter-nonce exception for `required_for` operations or reads. - `@target-uri` is the target URI as received, built from the request line (`raw_path` and query string), so percent-encoding is preserved. `@authority` is that URI's authority, lowercased with any default port removed (RFC 9421 §2.2.3). - RSA keys verify with `rsa-pss-sha512` (2048 to 8192 bits, odd exponent). @@ -277,6 +277,5 @@ From the earlier alignment: ## Not covered yet - The final 3.3 schema bundle and the P7 trust-record and grant-chain conformance vectors are upstream release dependencies; re-pin and rerun conformance when they land. -- Accepting the legacy sf-string `Signature-Agent` (a #1511 follow-up). - Durable stores: bindings and replay state are pluggable; authority history and revocation history are in memory with no pluggable store yet (Go and TypeScript have one). Public deployments need durable versions of all four. - The directory cache is evictable by unauthenticated traffic naming many origins (as in Go); eviction only forces a refetch, but a signer whose directory is down at that moment loses its stale-copy continuation. diff --git a/src/adcp/governance.py b/src/adcp/governance.py index 1cb417c6a..d57e620de 100644 --- a/src/adcp/governance.py +++ b/src/adcp/governance.py @@ -1089,14 +1089,14 @@ def issue_governance_authorization( try: key = _jose_key(alg, private_key) # Sorted header members and the canonical claims bytes keep the token - # byte-identical to the profile's compact JSON; joserfc signs it. + # as before for an ASCII kid (joserfc escapes non-ASCII in the header). return jws.serialize_compact( dict(sorted(header.items())), _compact_json(claims), key, registry=_governance_jws_registry(alg), ) - except JoseError as exc: + except (JoseError, TypeError, ValueError) as exc: raise ValueError(f"governance signing key cannot sign {alg}: {exc}") from exc diff --git a/src/adcp/signing/_header_precheck.py b/src/adcp/signing/_header_precheck.py index ac45227ca..66a98ef7a 100644 --- a/src/adcp/signing/_header_precheck.py +++ b/src/adcp/signing/_header_precheck.py @@ -34,7 +34,7 @@ from collections.abc import Mapping, Sequence -from adcp.signing._rfc9421 import parse_dictionary, translate_legacy_binary +from adcp.signing._rfc9421 import DuplicateFieldError, parse_dictionary, translate_legacy_binary from adcp.signing.canonical import ( TargetUriMalformedError, _split_or_reject, @@ -88,6 +88,16 @@ def strict_header_precheck( """ pairs = _header_pairs(headers=headers, raw_headers=raw_headers) + names = [name for name, _ in _header_pairs(headers=headers, raw_headers=None)] + for name in _SINGLE_LINE_SIGNED_HEADERS | {"signature-agent"}: + if names.count(name) > 1: + # A mapping can hold two spellings of one name (``Content-Digest`` + # and ``content-digest``); which one a reader takes is arbitrary. + return ( + f"{name!r} appears under more than one spelling; a repeated signed " + "field is ambiguous and MUST be rejected rather than folded" + ) + if raw_headers is not None: seen: set[str] = set() for name, _ in pairs: @@ -175,15 +185,15 @@ def _dictionary_reason(value: str, header_name: str) -> str | None: try: try: parse_dictionary(value) - except ValueError as exc: - if "duplicate" in str(exc): - raise + except DuplicateFieldError: + raise + except ValueError: # The AdCP 3.0/3.1 Base64URL byte-sequence spelling is the # decoder's call (3.2 refuses it there, still at step 1), not an # ambiguity: judge the field with those tokens translated. parse_dictionary(translate_legacy_binary(value)) except ValueError as exc: - if "duplicate" in str(exc): + if isinstance(exc, DuplicateFieldError): return ( f"{header_name} carries a {exc}; RFC 8941 §3.2 requires rejecting rather " "than resolving, since resolving lets a repeated key downgrade what was signed" diff --git a/src/adcp/signing/_rfc9421.py b/src/adcp/signing/_rfc9421.py index 9368c4ecb..66576e00a 100644 --- a/src/adcp/signing/_rfc9421.py +++ b/src/adcp/signing/_rfc9421.py @@ -80,9 +80,13 @@ _PAREN_OPEN, _PAREN_CLOSE = ord("("), ord(")") +class DuplicateFieldError(ValueError): + """A Structured Field repeats a dictionary key or a parameter.""" + + def _refuse_duplicate(key: str, context: str) -> None: where = "dictionary member" if context == "dictionary" else "parameter" - raise ValueError(f"duplicate Structured Field {where} {key!r}") + raise DuplicateFieldError(f"duplicate Structured Field {where} {key!r}") def parse_dictionary(value: str) -> dict[str, Any]: @@ -138,7 +142,7 @@ def dictionary_has_parameter(value: str, name: str, expected: str) -> bool: # Non-ASCII cannot be RFC 8941; "?" stands in for it so the walk stops there. state = ParserState(value.encode("ascii", "replace")) try: - discard_ows(state) + discard_http_ows(state) while state.has_data(): parse_key(state) if state.has_data() and state.data[state.cursor] == _EQUALS: @@ -184,28 +188,22 @@ def dictionary_has_parameter(value: str, name: str, expected: str) -> bool: _LEGACY_ALPHABET = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=-_") -def legacy_binary_tokens(value: str) -> list[tuple[str | None, str]]: - """Every ``:...:`` byte-sequence token outside sf-strings, with its member key. +def _binary_spans(value: str) -> list[tuple[int, int, str | None]]: + """``(start, end, owner)`` of every ``:...:`` token outside sf-strings. - AdCP 3.0 and 3.1 emitted ``Signature`` and ``Content-Digest`` values in - unpadded Base64URL. No RFC 8941 parser accepts that alphabet (section - 3.3.5 is standard Base64), so the legacy profiles translate those tokens - before parsing. This walker only finds the tokens; the translated field - is then parsed strictly by http-sf. The key is the top-level dictionary - key the token is the value of, or ``None`` for a token in a parameter. + ``value[start:end]`` is the token between its colons. ``owner`` is the + top-level dictionary key the token is the value of, or ``None`` for a + token in a parameter. """ - tokens: list[tuple[str | None, str]] = [] - index, length = 0, len(value) - key_start = 0 + spans: list[tuple[int, int, str | None]] = [] + index, length, key_start = 0, len(value), 0 while index < length: char = value[index] if char == '"': index += 1 while index < length and value[index] != '"': index += 2 if value[index] == "\\" else 1 - index += 1 - continue - if char == ":": + elif char == ":": end = value.find(":", index + 1) if end < 0: break @@ -213,52 +211,47 @@ def legacy_binary_tokens(value: str) -> list[tuple[str | None, str]]: if index > 0 and value[index - 1] == "=": candidate = value[key_start : index - 1].strip() owner = candidate if candidate and ";" not in candidate else None - tokens.append((owner, value[index + 1 : end])) - index = end + 1 - continue - if char == ",": + spans.append((index + 1, end, owner)) + index = end + elif char == ",": key_start = index + 1 index += 1 - return tokens + return spans + + +def legacy_binary_tokens(value: str) -> list[tuple[str | None, str]]: + """Every byte-sequence token outside sf-strings, with the member key it belongs to. + + AdCP 3.0 and 3.1 emitted ``Signature`` and ``Content-Digest`` values in + unpadded Base64URL. No RFC 8941 parser accepts that alphabet (section + 3.3.5 is standard Base64), so the legacy profiles look at, and translate, + those tokens before the strict http-sf parse. + """ + return [(owner, value[start:end]) for start, end, owner in _binary_spans(value)] def translate_legacy_binary(value: str) -> str: """Rewrite Base64URL ``:...:`` tokens as RFC 8941 standard Base64. - A token in standard Base64 is left alone, so a field already in RFC 8941 - form parses unchanged. Only the legacy profiles call this. + Only a token made of Base64 characters that decodes is rewritten; a token + already in standard Base64 is left alone, so a field in RFC 8941 form + parses unchanged, and anything else is left for the strict parse to + refuse. Only the legacy profiles call this. """ - out: list[str] = [] - index, length = 0, len(value) - while index < length: - char = value[index] - if char == '"': - start = index - index += 1 - while index < length and value[index] != '"': - index += 2 if value[index] == "\\" else 1 - index += 1 - out.append(value[start:index]) - continue - if char == ":": - end = value.find(":", index + 1) - if end < 0: - out.append(value[index:]) - break - token = value[index + 1 : end] - if set(token) <= _LEGACY_ALPHABET and (_BASE64URL_ONLY & set(token) or len(token) % 4): - standard = token.replace("-", "+").replace("_", "/").rstrip("=") - try: - raw = base64.b64decode(standard + "=" * (-len(standard) % 4), validate=True) - token = base64.b64encode(raw).decode("ascii") - except (binascii.Error, ValueError): - # Not Base64 in either alphabet: the strict parse refuses it. - pass - out.append(f":{token}:") - index = end + 1 - continue - out.append(char) - index += 1 + out, cursor = [], 0 + for start, end, _owner in _binary_spans(value): + token = value[start:end] + if set(token) <= _LEGACY_ALPHABET and (_BASE64URL_ONLY & set(token) or len(token) % 4): + standard = token.replace("-", "+").replace("_", "/").rstrip("=") + try: + raw = base64.b64decode(standard + "=" * (-len(standard) % 4), validate=True) + token = base64.b64encode(raw).decode("ascii") + except (binascii.Error, ValueError): + # Not Base64 in either alphabet: the strict parse refuses it. + pass + out.append(value[cursor:start] + token) + cursor = end + out.append(value[cursor:]) return "".join(out) @@ -286,7 +279,20 @@ class Request: headers: Mapping[str, str] = field(default_factory=dict) def __post_init__(self) -> None: - self.headers = CaseInsensitiveDict(dict(self.headers)) + self.headers = _first_wins(self.headers) + + +def _first_wins(headers: Mapping[str, str]) -> Any: + """Case-insensitive headers where the first spelling of a name wins. + + The same rule as ``canonical._lookup``, which the profiles use for their + own checks, so the library and the profile never read two different + values for one name (``Content-Digest`` beside ``content-digest``). + """ + folded: dict[str, str] = {} + for name, value in headers.items(): + folded.setdefault(name.lower(), value) + return CaseInsensitiveDict(folded) @dataclass @@ -298,7 +304,7 @@ class Response: headers: Mapping[str, str] = field(default_factory=dict) def __post_init__(self) -> None: - self.headers = CaseInsensitiveDict(dict(self.headers)) + self.headers = _first_wins(self.headers) @property def url(self) -> str: @@ -345,7 +351,9 @@ def resolve(self, component_node: Any) -> str: raise ComponentError("a covered component name must be an sf-string", status=400) if name != name.lower(): raise ComponentError("covered component names must be lowercase") - if params.pop("req", False) is True: + if "req" in params: + if params.pop("req") is not True: + raise ComponentError("the req parameter is a boolean flag") if not isinstance(self.message, Response): raise ComponentError("the req parameter only applies to a response") request_resolver = type(self)(self.message.request) @@ -588,6 +596,7 @@ def verify( alg: str, public_key: Any, resolver: type[ComponentResolver], + window_checked: bool, parameters: Sequence[str] = (), ) -> Mapping[str, str]: """Verify ``label`` with ``HTTPMessageVerifier``; return the covered components. @@ -597,7 +606,12 @@ def verify( the library confirms the signature names the same ``alg``. Raises :class:`SignatureBaseError` when a component does not resolve and :class:`SignatureMismatchError` when the signature does not verify. + + ``window_checked`` must be true: :class:`ProfileVerifier` does not check + ``created``/``expires``, so the caller states it already has. """ + if window_checked is not True: + raise ValueError("check the signature window before verifying") verifier = ProfileVerifier( signature_algorithm=ALGORITHMS[alg], key_resolver=_Key(public_key=public_key), @@ -631,6 +645,7 @@ def verify( "AdcpComponentResolver", "ComponentError", "ComponentResolver", + "DuplicateFieldError", "ProfileSigner", "ProfileVerifier", "Request", diff --git a/src/adcp/signing/autosign.py b/src/adcp/signing/autosign.py index 890038869..57a611694 100644 --- a/src/adcp/signing/autosign.py +++ b/src/adcp/signing/autosign.py @@ -138,8 +138,8 @@ def __post_init__(self) -> None: object.__setattr__(self, "agent_origin", _https_origin(self.agent_origin)) if not isinstance(self.private_key, ed25519.Ed25519PrivateKey): raise ValueError( - "Web Bot Auth signs with ed25519 or rsa-pss-sha512; ES256 is an AdCP " - "RFC 9421 profile algorithm only" + "Web Bot Auth here takes an Ed25519 key (sign RSA keys with " + "sign_wba_request); ES256 is an AdCP RFC 9421 profile algorithm only" ) if self.key_id != public_directory_key(self.private_key)["kid"]: raise ValueError("Web Bot Auth key_id must be the public key's thumbprint") diff --git a/src/adcp/signing/canonical.py b/src/adcp/signing/canonical.py index b8aad5b09..0d6a22ecd 100644 --- a/src/adcp/signing/canonical.py +++ b/src/adcp/signing/canonical.py @@ -129,7 +129,9 @@ def build_signature_base( ``http-message-signatures`` builds the base: lines joined with a single LF, no trailing newline, components in ``Signature-Input`` order, then ``@signature-params``. :class:`AdcpComponentResolver` supplies the AdCP - ``@target-uri`` and ``@authority`` canonicalization. + ``@target-uri`` and ``@authority`` canonicalization. The base comes from + ``parsed.components`` and ``parsed.params`` (in order); ``raw_value`` is + not consulted, so ``@signature-params`` is the RFC 8941 serialization. """ from adcp.signing._rfc9421 import AdcpComponentResolver, Request, build_base diff --git a/src/adcp/signing/crypto.py b/src/adcp/signing/crypto.py index e08d0669e..b1ddd2ebd 100644 --- a/src/adcp/signing/crypto.py +++ b/src/adcp/signing/crypto.py @@ -223,9 +223,13 @@ def extract_signature_bytes( members = parse_dictionary(translate_legacy_binary(signature_header)) if label not in members: raise KeyError(f"label not found in Signature header: {label}") - value, _params = members[label] + value, params = members[label] if not isinstance(value, bytes): raise ValueError(f"Signature value for {label!r} is not an sf-binary") + if params: + # RFC 9421 §4.2 defines no Signature member parameters; unsigned + # extras are refused rather than ignored. + raise ValueError(f"Signature value for {label!r} carries parameters") return value diff --git a/src/adcp/signing/signer.py b/src/adcp/signing/signer.py index 47c6954cc..e3b0f09b9 100644 --- a/src/adcp/signing/signer.py +++ b/src/adcp/signing/signer.py @@ -219,8 +219,8 @@ def sign_request( if not isinstance(private_key, ed25519.Ed25519PrivateKey): raise ValueError( - "Web Bot Auth signs with ed25519 or rsa-pss-sha512; ES256 is an AdCP " - "RFC 9421 profile algorithm only" + "Web Bot Auth here takes an Ed25519 key (sign RSA keys with " + "sign_wba_request); ES256 is an AdCP RFC 9421 profile algorithm only" ) if key_id != public_directory_key(private_key)["kid"]: raise ValueError("Web Bot Auth key_id must be the public key's RFC 7638 thumbprint") diff --git a/src/adcp/signing/verifier.py b/src/adcp/signing/verifier.py index 272ad254d..ab4089d11 100644 --- a/src/adcp/signing/verifier.py +++ b/src/adcp/signing/verifier.py @@ -466,6 +466,7 @@ def verify_request_signature( alg=alg, public_key=public_key, resolver=_rfc9421.AdcpComponentResolver, + window_checked=True, # step 5 parameters=tuple(parsed.params), ) except _rfc9421.SignatureBaseError as exc: diff --git a/src/adcp/signing/wba.py b/src/adcp/signing/wba.py index 205adf1f0..a0cfd10f3 100644 --- a/src/adcp/signing/wba.py +++ b/src/adcp/signing/wba.py @@ -496,8 +496,8 @@ def _verify_crypto( declared = params.get("alg", alg) if type(declared) is not str or declared != alg: raise WbaVerificationError("signature algorithm does not match key") - if not isinstance(signature, tuple) or not isinstance(signature[0], bytes): - raise WbaVerificationError("signature must be sf-binary", status=400) + if not isinstance(signature, tuple) or not isinstance(signature[0], bytes) or signature[1]: + raise WbaVerificationError("signature must be a bare sf-binary", status=400) try: public_key = _public_key(alg, jwk) except (ValueError, KeyError, TypeError) as exc: @@ -510,6 +510,7 @@ def _verify_crypto( alg=alg, public_key=public_key, resolver=_WbaComponentResolver, + window_checked=True, # every caller ran _window first parameters=tuple(params), ) except _rfc9421.SignatureBaseError as exc: @@ -1267,7 +1268,9 @@ def __init__(self, code: str, reason: str) -> None: def _governance_registry(alg: str) -> Any: from joserfc.jws import JWSRegistry + from joserfc.registry import HeaderParameter, is_bool + from adcp.governance import GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS from adcp.signing.jws import _AdcpJwsRegistry, _Ed25519EdDSA class _Registry(_AdcpJwsRegistry): @@ -1277,7 +1280,15 @@ class _Registry(_AdcpJwsRegistry): "PS512": JWSRegistry.algorithms["PS512"], } - return _Registry(algorithms=[alg], strict_check_header=False) + # The action-binding claim markers are the only crit extensions. + return _Registry( + header_registry={ + name: HeaderParameter(f"AdCP critical claim marker: {name}", is_bool) + for name in GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS + }, + algorithms=[alg], + strict_check_header=False, + ) async def verify_wba_governance_issuer( @@ -1337,12 +1348,21 @@ def fail(code: str, reason: str) -> WbaGovernanceError: raise fail("governance_token_invalid", "governance token alg not allowed") if header.get("typ") != WBA_GOVERNANCE_TYP: raise fail("governance_token_invalid", "governance token typ mismatch") + _check_critical_markers(header, claims, fail) try: issuer = _https_origin(str(claims.get("iss", ""))) except ValueError as exc: raise fail("governance_token_invalid", "governance token iss is not an origin") from exc if issuer != claims.get("iss") or header.get("jku") != issuer + DIRECTORY_PATH: raise fail("governance_key_unknown", "jku is not the iss origin's key directory") + try: + authorized = {_https_origin(origin) for origin in governance_agents} + except ValueError as exc: + raise ValueError("governance_agents must be HTTPS origins") from exc + # Checked before key discovery, so a token cannot make the verifier fetch + # a directory from an origin that is not the buyer's governance agent. + if issuer not in authorized: + raise fail("governance_issuer_not_authorized", "iss is not the buyer's governance agent") kid = header.get("kid") if not isinstance(kid, str) or not kid: raise fail("governance_key_unknown", "governance token kid is missing") @@ -1380,15 +1400,33 @@ def fail(code: str, reason: str) -> WbaGovernanceError: raise fail("governance_token_expired", "governance token expired") if issued > now + clock_skew_seconds: raise fail("governance_token_not_yet_valid", "governance token issued in the future") - try: - authorized = {_https_origin(origin) for origin in governance_agents} - except ValueError as exc: - raise ValueError("governance_agents must be HTTPS origins") from exc - if issuer not in authorized: - raise fail("governance_issuer_not_authorized", "iss is not the buyer's governance agent") return issuer +def _check_critical_markers( + header: Mapping[str, Any], claims: Mapping[str, Any], fail: Callable[[str, str], Exception] +) -> None: + """Security.mdx checklist step 4: each action-binding marker, crit entry and claim together.""" + from adcp.governance import GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS + + critical = header.get("crit", []) + if ( + not isinstance(critical, list) + or any(not isinstance(name, str) for name in critical) + or len(set(critical)) != len(critical) + or any(name not in GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS for name in critical) + ): + raise fail("governance_token_invalid", "invalid governance JWS crit header") + for name in GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS: + if (name in claims) != (name in critical and header.get(name) is True): + raise fail("governance_token_invalid", f"{name} claim and critical marker must match") + if ("authorized_task" in claims) != ("authorized_payload_hash" in claims): + raise fail( + "governance_token_invalid", + "authorized_task and authorized_payload_hash must appear together", + ) + + class WbaRevocationStatusChecker(Protocol): """Report a WBA key's state in its origin's signed revocation list.""" diff --git a/tests/conformance/signing/test_rfc9421_library_contract.py b/tests/conformance/signing/test_rfc9421_library_contract.py index cf9425cd3..8c82224e9 100644 --- a/tests/conformance/signing/test_rfc9421_library_contract.py +++ b/tests/conformance/signing/test_rfc9421_library_contract.py @@ -165,3 +165,66 @@ def test_http_sf_reports_every_duplicate(): on_duplicate_key=lambda key, context: calls.append((key, context)), ) assert calls == [("a", "dictionary"), ("p", "parameter"), ("q", "parameter")] + + +def test_http_sf_primitives_the_profile_selection_walk_uses(): + """``dictionary_has_parameter`` walks members with these http-sf functions.""" + from http_sf.bare_item import parse_bare_item + from http_sf.item import parse_item + from http_sf.state import ParserState + from http_sf.util import discard_http_ows, discard_ows, parse_key + + state = ParserState(b' sig1=("a";k=1);tag="x"') + discard_ows(state) + discard_http_ows(state) + assert parse_key(state) == "sig1" + state.cursor += 2 # "=(" + assert parse_item(state) == ("a", {"k": 1}) + state.cursor += 2 # ");" + assert parse_key(state) == "tag" + state.cursor += 1 + assert parse_bare_item(state) == "x" + assert _rfc9421.dictionary_has_parameter( + '\tsig1=("a");tag="web-bot-auth";nonce="x', "tag", "web-bot-auth" + ) + + +def test_req_must_be_a_true_flag(): + response = _rfc9421.Response(200, _rfc9421.Request("GET", "https://example.com/")) + for identifier in ('"@authority";req=?0', '"@authority";req=1'): + with pytest.raises(ValueError, match="boolean flag"): + _rfc9421.build_base( + response, + covered_component_ids=[identifier], + params={}, + resolver=_rfc9421.ComponentResolver, + ) + with pytest.raises(ValueError, match="only applies to a response"): + _rfc9421.build_base( + _rfc9421.Request("GET", "https://example.com/"), + covered_component_ids=['"@authority";req'], + params={}, + resolver=_rfc9421.ComponentResolver, + ) + + +def test_first_spelling_of_a_header_wins_as_in_lookup(): + from adcp.signing.canonical import _lookup + + headers = {"Content-Digest": "first", "content-digest": "second"} + assert _rfc9421.Request("GET", "https://example.com/", headers).headers[ + "content-digest" + ] == _lookup(headers, "content-digest") + + +def test_verify_requires_the_caller_to_have_checked_the_window(): + with pytest.raises(ValueError, match="window"): + _rfc9421.verify( + _rfc9421.Request("GET", "https://example.com/"), + label="sig1", + tag="t", + alg="ed25519", + public_key=None, + resolver=_rfc9421.ComponentResolver, + window_checked=False, + ) diff --git a/tests/conformance/signing/test_structured_field_duplicates.py b/tests/conformance/signing/test_structured_field_duplicates.py index 0b7b40e5d..f6a67887c 100644 --- a/tests/conformance/signing/test_structured_field_duplicates.py +++ b/tests/conformance/signing/test_structured_field_duplicates.py @@ -318,3 +318,117 @@ async def dispatch(scope, receive, send): if not wba: assert response.json()["error"] == REQUEST_SIGNATURE_HEADER_MALFORMED assert not calls and not dispatched and not replay.claims + + +def _signed_32(body=b"{}"): + key = Ed25519PrivateKey.generate() + headers = {"Content-Type": "application/json"} + headers.update( + sign_request( + method="POST", + url="https://seller.com/mcp", + headers=headers, + body=body, + private_key=key, + key_id="kid", + alg="ed25519", + created=1000, + signing_profile_version="3.2", + ).as_dict() + ) + return key, headers + + +def _options_32(key, replay=None): + from cryptography.hazmat.primitives import serialization + + from adcp.signing.crypto import b64url_encode + + raw = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + jwk = { + "kty": "OKP", + "crv": "Ed25519", + "x": b64url_encode(raw), + "kid": "kid", + "use": "sig", + "key_ops": ["verify"], + "adcp_use": "request-signing", + } + return VerifyOptions( + now=1000, + jwks_resolver=lambda keyid: jwk, + capability=VerifierCapability(covers_content_digest="required"), + operation="create_media_buy", + replay_store=replay or InMemoryReplayStore(), + signing_profile_version="3.2", + ) + + +def test_two_spellings_of_a_signed_header_are_malformed_not_split(): + """A mapping with Content-Digest and content-digest must not let the digest + check and the signature base read different values.""" + import hashlib + + key, headers = _signed_32() + swapped = b'{"amount":999999}' + forged = ( + "sha-256=:" + + __import__("base64").b64encode(hashlib.sha256(swapped).digest()).decode() + + ":" + ) + attack_headers = {"content-digest": forged, **headers} + with pytest.raises(SignatureVerificationError) as rejected: + verify_request_signature( + method="POST", + url="https://seller.com/mcp", + headers=attack_headers, + body=swapped, + options=_options_32(key), + ) + assert rejected.value.code == REQUEST_SIGNATURE_HEADER_MALFORMED + + +async def test_two_spellings_of_a_signed_header_are_400_for_wba(): + key = Ed25519PrivateKey.generate() + headers = {"Content-Type": "application/json"} + headers.update( + sign_wba_request( + method="POST", + url="https://seller.com/mcp", + headers=headers, + body=b"{}", + private_key=key, + agent_origin="https://buyer.com", + created=1000, + ) + ) + + async def resolve(keyid, *, signer_origin): + pytest.fail("Ambiguous headers must not reach discovery") + + with pytest.raises(WbaVerificationError) as rejected: + await verify_wba_request( + method="POST", + url="https://seller.com/mcp", + headers={"content-digest": "sha-256=:AAAA:", **headers}, + body=b"{}", + signer_keys=resolve, + replay_store=InMemoryReplayStore(), + now=1000, + ) + assert rejected.value.status == 400 + + +def test_signature_member_parameters_are_malformed(): + key, headers = _signed_32() + headers["Signature"] += ";foo=1" + with pytest.raises(SignatureVerificationError) as rejected: + verify_request_signature( + method="POST", + url="https://seller.com/mcp", + headers=headers, + body=b"{}", + options=_options_32(key), + ) + assert rejected.value.code == REQUEST_SIGNATURE_HEADER_MALFORMED + assert rejected.value.step == 1 diff --git a/tests/conformance/signing/test_wba_profile.py b/tests/conformance/signing/test_wba_profile.py index 2b0a5eaae..0dd686208 100644 --- a/tests/conformance/signing/test_wba_profile.py +++ b/tests/conformance/signing/test_wba_profile.py @@ -945,3 +945,80 @@ async def dispatch(scope, receive, send): # A current list, or a stale one for a non-spend call, proceeds. assert response.status_code == 200, response.text assert dispatched == [True] + + +def _governance_token(key, header_extra=None, claims_extra=None, *, iss=None): + from joserfc import jws as jose_jws + from joserfc.jwk import OKPKey + + from adcp.signing.wba import _governance_registry + + identity = iss or KEYS["1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE"]["identity"] + header = { + "alg": "EdDSA", + "typ": "adcp-gov+jws", + "jku": identity + "/.well-known/http-message-signatures-directory", + "kid": "1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE", + **(header_extra or {}), + } + claims = {"iss": identity, "aud": "https://seller-7k3q.com/mcp/", "iat": 1000, "exp": 1600} + claims.update(claims_extra or {}) + private = private_key_from_jwk( + KEYS["1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE"], d_field="_private_d_for_test_only" + ) + return jose_jws.serialize_compact( + header, + json.dumps(claims).encode(), + OKPKey.import_key(private), + registry=_governance_registry("EdDSA"), + ) + + +@pytest.mark.parametrize("markers", ["matched", "claim_without_marker", "task_without_hash"]) +async def test_governance_action_binding_markers(markers): + """Action-authorizing tokens carry crit markers (security.mdx checklist step 4).""" + from adcp.signing.wba import WbaGovernanceError, verify_wba_governance_issuer + + vector = json.loads((ROOT / "governance/001-governance-token.json").read_text()) + binding = {"authorized_task": "create_media_buy", "authorized_payload_hash": "h"} + header = {"crit": list(binding), **{name: True for name in binding}} + if markers == "claim_without_marker": + header = {} + claims = dict(binding) + if markers == "task_without_hash": + header = {"crit": ["authorized_task"], "authorized_task": True} + claims = {"authorized_task": "create_media_buy"} + token = _governance_token(None, header, claims) + + async def verify(): + return await verify_wba_governance_issuer( + token, + signer_keys=_vector_resolver(vector), + governance_agents=vector["role_listing"]["adcp:governance"], + now=1100, + ) + + if markers == "matched": + assert await verify() == "https://governance-7k3q.com" + return + with pytest.raises(WbaGovernanceError) as caught: + await verify() + assert caught.value.code == "governance_token_invalid" + + +async def test_governance_issuer_is_checked_before_key_discovery(): + from adcp.signing.wba import WbaGovernanceError, verify_wba_governance_issuer + + token = _governance_token(None, iss="https://buyer-7k3q.com") + + async def resolve(keyid, *, signer_origin=None): + pytest.fail("an unlisted issuer must not trigger key discovery") + + with pytest.raises(WbaGovernanceError) as caught: + await verify_wba_governance_issuer( + token, + signer_keys=resolve, + governance_agents=["https://governance-7k3q.com"], + now=1100, + ) + assert caught.value.code == "governance_issuer_not_authorized"