diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c3c5ce98..d6430d5b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -369,6 +369,73 @@ jobs: python -m pytest -n0 -p no:cacheprovider -v -ra \ tests/conformance/reporting/test_reporting_row_storage_interop_pg.py + wba-interop: + needs: changes + if: needs.changes.outputs.run_tests == 'true' + name: Web Bot Auth interop with the Cloudflare reference + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + # PURPOSE: the SDK's Web Bot Auth signer and verifier must interoperate with standard WBA. + # The AdCP harness (adcp/tests/wba-interop) grades tests/wba_interop/adapter.py against + # Cloudflare's web-bot-auth, pinned exactly in the harness lockfile, in both directions: + # the reference signs and the SDK verifies, and the SDK signs and the reference verifies. + env: + # adcontextprotocol/adcp commit that carries tests/wba-interop and the wba-profile + # vectors: the head of adcontextprotocol/adcp#8298. Pin its merge commit once it + # merges, and a release tag once one carries the harness. + ADCP_REF: "306cdfa6e565725e1a122838eafbcc980707306b" + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Check out the AdCP harness at ADCP_REF + uses: actions/checkout@v6 + with: + repository: adcontextprotocol/adcp + ref: ${{ env.ADCP_REF }} + path: adcp + filter: blob:none + sparse-checkout: | + tests/wba-interop + static/compliance/source/test-vectors/wba-profile + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Set up uv + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + with: + version: "0.12.8" + enable-cache: true + cache-dependency-glob: pyproject.toml + cache-suffix: "3.12" + prune-cache: true + - name: Set up Node 24 + uses: actions/setup-node@v6 + with: + # The harness's package.json requires Node 24. + node-version: "24" + cache: npm + cache-dependency-path: adcp/tests/wba-interop/package-lock.json + - name: Install the harness + run: npm ci --prefix adcp/tests/wba-interop --ignore-scripts + - name: Run the harness against the SDK adapter + run: | + node adcp/tests/wba-interop/run.mjs \ + --adapter "uv run python tests/wba_interop/adapter.py" \ + --vectors adcp/static/compliance/source/test-vectors/wba-profile \ + --junit wba-interop.xml + - name: Preserve the harness report + if: always() + uses: actions/upload-artifact@v7 + with: + name: wba-interop-${{ github.run_attempt }} + retention-days: 7 + path: wba-interop.xml + if-no-files-found: error + pg-conformance-required-gate: name: Postgres conformance tests (Postgres 16) runs-on: ubuntu-latest diff --git a/docs/agent-identity-3-3-migration.md b/docs/agent-identity-3-3-migration.md index 2ec8e2760..fd10e7700 100644 --- a/docs/agent-identity-3-3-migration.md +++ b/docs/agent-identity-3-3-migration.md @@ -1,6 +1,6 @@ # 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 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. +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 `e5929470` (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. @@ -29,7 +29,7 @@ signing = SigningConfig( 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. -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. +Web Bot Auth signs with `ed25519`, the profile's one algorithm, following the current #7894 page (`5c3876073f`). The working group hasn't recorded a decision on that page; if it restores another algorithm, this SDK follows it. `sign_wba_request`, `public_directory_key` and `sign_key_directory` accept Ed25519 keys only. ES256 (P-256) stays on the AdCP RFC 9421 profile: a P-256 or RSA key is refused for Web Bot Auth, and such a key in someone's directory is ignored, with its signature. Every signature names `alg`; a signature without it is refused. 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: @@ -99,18 +99,17 @@ 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. `Signature-Agent` is read as a Structured Fields Dictionary only; the older bare sf-string form is not part of the final profile. +- **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. The member value is the signer's https origin in canonical form, compared as a string: a lowercase host, no default port, no trailing `/`, and no path, query, fragment, or userinfo. Any other spelling is refused with 403 before key discovery, and `sign_wba_request` sends the canonical form. A `Signature-Agent` member whose `type` parameter is not `directory` (a Token or a String) is ignored, so the signature has no member keyed to its label (403). Every `Signature-Input` member must be an inner list and every `Signature` member a Byte Sequence with no parameters, including members with other tags that the verifier doesn't read; otherwise the field is malformed (400). Key directory verification applies the same rule. - **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. ### Key directories `DiscoveredSignerKeys` resolves `(covered origin, thumbprint)`. It never calls capabilities or reads brand.json or trust.json. -- 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. +- A directory must be signed (WBA Appendix B.1); an unsigned one is a discovery failure. The media type must be `application/http-message-signatures-directory+json` (WBA section 5.5.1); any other media type fails the directory. Keys are found by their RFC 7638 thumbprint; a `kid` is optional and does not affect lookup. Keys the profile cannot use (P-256 and RSA included) are ignored, and so are their signatures (RFC 7517 section 5); 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. Every usable key signs (one signature per key, Appendix B.1): a usable key without a verified signature fails the directory. - **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. @@ -236,7 +235,7 @@ Onboarding posture is advisory. Sellers still implement requirements, applicatio 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. +**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 vectors in `tests/conformance/vectors/wba-governance/` 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 diff --git a/docs/request-signing-migration.md b/docs/request-signing-migration.md index 1ae8d366a..144529051 100644 --- a/docs/request-signing-migration.md +++ b/docs/request-signing-migration.md @@ -345,6 +345,21 @@ Two ways across: The same function is the identifier comparer for `adagents.json` `authorized_agents[].url`, `brand.json` `agents[].url`, TMP `seller_agent.agent_url`, `format-id.agent_url` and the governance issuer. There the change is strictly more permissive: `https://agent.example.com` and `https://agent.example.com/` now compare equal, where before a publisher who registered the first did not match a caller who arrived as the second, and the refusal named authorization rather than canonicalization. No registration needs editing, and no pair that matched before stops matching. +## 7. Upgrading to strict JWK and base64url decoding + +These `adcp.signing` functions decode keys more strictly than they did in 9.x: + +| Function | Change | +| --- | --- | +| `b64url_decode` | Accepts only RFC 7515 base64url: the URL-safe alphabet, no padding, and the one spelling `b64url_encode` produces. Padding, `+` or `/`, other characters, and non-zero trailing bits raise `ValueError`. | +| `public_key_from_jwk` | Decodes the members with `joserfc` and reads the public members only. Apart from the strict decoding above, it refuses the same keys, but the `ValueError` text changes, for example from `Ed25519 JWK x has length 3, expected 32` to `unusable OKP/Ed25519 JWK: ...`. | +| `private_key_from_jwk` | Reads `d` alone, so a `d`-only JWK still loads. When `x` (or `y`) is present and isn't the public key of `d`, it raises `ValueError` instead of returning the key that `d` names. | +| `publisher_pins.jwk_thumbprint` | Returns the RFC 7638 thumbprint as its base64url `str`, not the digest `bytes`. | + +A key published with padded or standard-alphabet base64 no longer verifies, so check that your JWKS and `adagents.json` entries use unpadded base64url. Code that matched error text, or compared `jwk_thumbprint` with digest bytes, needs updating. + +Signing and verification use `http-message-signatures` 2.1, which parses the URL per RFC 3986, so a URL with a raw `{`, `}`, or `|` in its query can't be signed: percent-encode it. `httpx` encodes those characters when they come through `params=`, but not when they are written into the URL. + ## Related - [`adcp.signing.install_signing_event_hook`](../src/adcp/signing/client.py) — buyer-side preset for adapters not using `ADCPClient`. diff --git a/pyproject.toml b/pyproject.toml index 80b0f6875..e31aa4fa7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -140,16 +140,15 @@ dependencies = [ "idna>=3.18,<4", # 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``, + # assembly, the ;key and ;req component parameters, prepare/apply for an + # awaited signature, and the ed25519 / ecdsa-p256-sha256 algorithms. 2.1 + # is the floor: it resolves ;key and ;req (pyauth#30) and adds + # prepare/apply (pyauth#31). The diagnostic base builder reaches two + # protected hooks (``_build_signature_base``, # ``_parse_covered_component_ids``); the upper bound and # tests/conformance/signing/test_rfc9421_library_contract.py guard them. - # 2.1.0 (2026-10-19) rewrote the component resolver (it no longer exposes - # ``url``, and it resolves ``;key`` and ``;req`` itself), so this layer is - # capped below it until the SDK moves to 2.1 (#1537 carries that move). # Apache-2.0, depends only on cryptography. - "http-message-signatures>=2.0.1,<2.1", + "http-message-signatures>=2.1.0,<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 diff --git a/scripts/ci/ci_scope.py b/scripts/ci/ci_scope.py index 0e07d8f2f..c77d6764f 100644 --- a/scripts/ci/ci_scope.py +++ b/scripts/ci/ci_scope.py @@ -26,6 +26,7 @@ JOB_GROUPS = { "test": "run_tests", "schema-check": "run_tests", + "wba-interop": "run_tests", "pg-conformance": "run_postgres", "pg-reporting-status": "run_postgres", "pg-reporting-materializer": "run_postgres", diff --git a/src/adcp/governance.py b/src/adcp/governance.py index d57e620de..081a62458 100644 --- a/src/adcp/governance.py +++ b/src/adcp/governance.py @@ -33,12 +33,13 @@ from pydantic import BaseModel from adcp._version import resolve_adcp_version +from adcp.signing._strict_json import parse_strict_json from adcp.signing.brand_jwks import BrandJsonResolverError, _pick_agent from adcp.signing.canonical import canonicalize_target_uri -from adcp.signing.crypto import b64url_decode, b64url_encode +from adcp.signing.crypto import b64url_decode, b64url_encode, import_jwk 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.jws import JwsMalformedError, _AdcpJwsRegistry, parse_compact_jws from adcp.signing.replay import ReplayStore, supports_atomic_claim from adcp.types import CheckGovernanceRequest, ReportPlanOutcomeRequest @@ -65,7 +66,6 @@ ] GovernanceReplayResult = Literal["ok", "conflict", "replayed", "rate_abuse"] -_COMPACT_SEGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+$") _CURRENCY_RE = re.compile(r"^[A-Z]{3}$") @@ -941,12 +941,10 @@ 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 = _compact_segments(token) - if parts is None: - return None try: - claims = _decode_json_segment(parts[1]) - except (TypeError, ValueError): + _header, payload, _signature = parse_compact_jws(token) + claims = _decode_json_segment(payload) + except (JwsMalformedError, TypeError, ValueError): return None issuer = claims.get("iss") if not isinstance(issuer, str): @@ -1135,40 +1133,23 @@ 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 = _compact_segments(token) - if parts is None: + try: + b64_header, b64_payload, _signature = parse_compact_jws(token) + except JwsMalformedError: return reject("governance_token_invalid", "governance_context is not a compact JWS") try: - header = _decode_json_segment(parts[0]) - claims = _decode_json_segment(parts[1]) - signature = b64url_decode(parts[2]) - if b64url_encode(signature) != parts[2]: - raise ValueError("non-canonical signature base64url") + header = _decode_json_segment(b64_header) + claims = _decode_json_segment(b64_payload) except (TypeError, ValueError): return reject("governance_token_invalid", "governance_context contains invalid JSON") alg = header.get("alg") if alg not in {"EdDSA", "ES256"} or header.get("typ") != GOVERNANCE_AUTHORIZATION_TYP: return reject("governance_token_invalid", "unsupported governance JWS alg or typ") - 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) - ): + if "crit" not in header: return reject("governance_token_invalid", "invalid governance JWS crit header") - for name in GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS: - has_claim = name in claims - has_marker = name in critical and header.get(name) is True - if has_claim != has_marker: - return reject( - "governance_token_invalid", f"{name} claim and critical marker must match" - ) - if ("authorized_task" in claims) != ("authorized_payload_hash" in claims): - return reject( - "governance_token_invalid", - "authorized_task and authorized_payload_hash must appear together", - ) + critical_failure = critical_claims_failure(header, claims) + if critical_failure is not None: + return reject("governance_token_invalid", critical_failure) try: expected_issuer = canonicalize_target_uri(expected_issuer) issuer = claims.get("iss") @@ -1201,7 +1182,7 @@ def reject( try: jws.deserialize_compact( token, - _import_jwk(_public_jwk(jwk)), + import_jwk(_public_jwk(jwk)), registry=_governance_jws_registry(alg), ) verified = True @@ -1443,9 +1424,46 @@ def _finite_number(value: object) -> bool: ) +def critical_claims_failure(header: Mapping[str, Any], claims: Mapping[str, Any]) -> str | None: + """Check each action-binding claim against its ``crit`` entry and marker. + + Security.mdx checklist step 4, shared by this module's verifier and the + Web Bot Auth issuer check (``wba.verify_wba_governance_issuer``). + joserfc's ``crit`` check (``JWSRegistry.check_header``) refuses a + ``crit`` that is not a list of registered names, and a marker that is + missing (RFC 7515 section 4.1.11) or not a boolean. The SDK + adds what joserfc leaves open: a repeated entry, each marker, its + ``crit`` entry, and its claim appearing together with the marker + ``true``, and ``authorized_task`` with ``authorized_payload_hash``. + Returns the failure reason, or ``None``. + """ + critical = header.get("crit", []) + markers = { + name: header[name] for name in GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS if name in header + } + try: + _governance_jws_registry("EdDSA").check_header( + {"alg": "EdDSA", "crit": critical, **markers} + ) + except (JoseError, TypeError): + return "invalid governance JWS crit header" + if len(set(critical)) != len(critical): + return "invalid governance JWS crit header" + for name in GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS: + together = (name in claims) == (name in critical) == (name in markers) + if not together or markers.get(name, True) is not True: + return f"{name} claim and critical marker must match" + if ("authorized_task" in claims) != ("authorized_payload_hash" in claims): + return "authorized_task and authorized_payload_hash must appear together" + return None + + def _governance_jws_registry(alg: str) -> JWSRegistry: """The AdCP JWS registry (``jws._AdcpJwsRegistry``) for one governance ``alg``. + Both governance verifiers use it: this module's and the Web Bot Auth + issuer check (``wba.verify_wba_governance_issuer``). + The critical claim markers are registered as boolean protected-header parameters, so joserfc's ``crit`` check accepts exactly those. """ @@ -1462,7 +1480,7 @@ def _governance_jws_registry(alg: str) -> JWSRegistry: 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 + Public keys go through ``crypto.import_jwk``, which also enforces the RFC 7518 P-256 coordinate length joserfc leaves open. """ if alg == "EdDSA": @@ -1477,26 +1495,6 @@ def _public_jwk(jwk: Mapping[str, Any]) -> dict[str, Any]: 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, @@ -1508,26 +1506,8 @@ def _compact_json(value: object) -> bytes: def _decode_json_segment(segment: str) -> dict[str, Any]: - raw = b64url_decode(segment) - if b64url_encode(raw) != segment: - raise ValueError("non-canonical base64url") - - def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: - result: dict[str, Any] = {} - for key, value in pairs: - if key in result: - raise ValueError("duplicate JSON object member") - result[key] = value - return result - - def reject_constant(value: str) -> None: - raise ValueError(f"non-finite JSON number: {value}") - - value = json.loads( - raw.decode("utf-8"), - object_pairs_hook=unique_object, - parse_constant=reject_constant, - ) + """A strict JSON object from a base64url JWS segment (``ValueError`` otherwise).""" + value = parse_strict_json(b64url_decode(segment)) if not isinstance(value, dict): raise ValueError("JWS segment is not a JSON object") return value diff --git a/src/adcp/signing/_rfc9421.py b/src/adcp/signing/_rfc9421.py index 66576e00a..525ffa446 100644 --- a/src/adcp/signing/_rfc9421.py +++ b/src/adcp/signing/_rfc9421.py @@ -4,39 +4,40 @@ * ``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``). + (``ed25519`` and ``ecdsa-p256-sha256``). * ``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`). +API: + +* Every profile resolves components with the library's + ``HTTPSignatureComponentResolver``, which implements the RFC 9421 ``;key`` + (section 2.1.2) and ``;req`` (section 2.4) component parameters. The AdCP + RFC 9421 profile hands the library its canonical target URI + (:func:`adcp.signing.canonical.canonicalize_target_uri`), so ``@target-uri`` + and ``@authority`` are the AdCP values; Web Bot Auth hands it the target + URI as received. * :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. +* :func:`prepare` and :func:`apply` are ``HTTPMessageSigner.prepare`` and + ``apply``, for the async (:class:`~adcp.signing.SigningProvider`) path, + where the signature is awaited. + +:func:`build_base` reaches the library's protected ``_build_signature_base``, +because the library has no public call that builds a base for parameters the +caller supplies in their own order. +``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 import UserString from collections.abc import Mapping, Sequence from dataclasses import dataclass, field from datetime import datetime, timezone @@ -53,11 +54,10 @@ 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_message_signatures.structures import CaseInsensitiveDict, PreparedSignature 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 @@ -67,7 +67,7 @@ #: 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) + algorithm.algorithm_id: algorithm for algorithm in (ED25519, ECDSA_P256_SHA256) } @@ -117,6 +117,21 @@ def serialize(value: Any) -> str: return http_sf.ser(value) +def signature_input_field(label: str, member: str) -> str: + """A ``Signature-Input`` value holding one member, serialized by http-sf. + + ``member`` is that member's own serialization, the ``@signature-params`` + value. Raises ``ValueError`` when ``label`` is not an RFC 8941 key. + """ + try: + parsed = http_sf.parse(member.encode("ascii"), tltype="list") + except (http_sf.StructuredFieldError, ValueError) as exc: + raise ValueError(f"malformed signature parameters: {exc}") from exc + if not isinstance(parsed, list) or len(parsed) != 1: + raise ValueError("signature parameters are not one member") + return serialize({label: parsed[0]}) + + def component_identifier(item: Any) -> str: """The serialized component identifier, e.g. ``"signature-agent";key="sig1"``.""" return ser_item(item) @@ -306,105 +321,6 @@ class Response: def __post_init__(self) -> None: self.headers = _first_wins(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 "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) - 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 @@ -425,29 +341,11 @@ 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 _signer(alg: str, private_key: Any = None) -> HTTPMessageSigner: + return HTTPMessageSigner( + signature_algorithm=ALGORITHMS[alg], + key_resolver=_Key(private_key=private_key) if private_key is not None else None, + ) def build_base( @@ -455,23 +353,39 @@ def build_base( *, 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). + resolve raises ``ValueError``. """ - handler = ProfileSigner( - signature_algorithm=ED25519, key_resolver=_Key(), component_resolver_class=resolver - ) + signer = _signer(ED25519.algorithm_id) try: - return handler.base(message, covered_component_ids=covered_component_ids, params=params)[0] - except ComponentError as exc: - raise ValueError(exc.reason) from exc + base, _, _ = signer._build_signature_base( + message, + covered_component_ids=signer._parse_covered_component_ids(covered_component_ids), + signature_params=dict(params), + ) except HTTPMessageSignaturesException as exc: raise ValueError(str(exc)) from exc + return str(base).encode("utf-8") + + +def _check_signable(message: Request | Response) -> None: + """Refuse a request the library can't resolve, and say how to fix its URL. + + The library parses the URL strictly (RFC 3986). A raw ``{``, ``}``, or + ``|`` in a query is not a URI character, so the caller percent-encodes + it, as ``httpx`` does for ``params=``. + """ + try: + HTTPSignatureComponentResolver(message) + except HTTPMessageSignaturesException as exc: + raise ValueError( + f"cannot sign this request: {exc}. Percent-encode every character RFC 3986 " + "doesn't allow in a URL, such as a raw '{', '}', or '|' in the query " + "(httpx params= encodes them)" + ) from exc def _timestamp(seconds: int) -> datetime: @@ -490,20 +404,17 @@ def sign( nonce: str | None, tag: str, covered_component_ids: Sequence[str], - resolver: type[ComponentResolver], + append: bool = False, ) -> 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. + standard Base64; a legacy profile re-encodes it. With ``append``, a + signature already on ``message`` stays, and this one is added beside it. """ - signer = ProfileSigner( - signature_algorithm=ALGORITHMS[alg], - key_resolver=_Key(private_key=private_key), - component_resolver_class=resolver, - ) + _check_signable(message) try: - signer.sign( + _signer(alg, private_key).sign( message, key_id=key_id, created=_timestamp(created), @@ -512,52 +423,55 @@ def sign( label=label, tag=tag, covered_component_ids=covered_component_ids, + append_if_signature_exists=append, ) - 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( +def prepare( message: Request | Response, *, alg: str, key_id: str, + label: 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. +) -> PreparedSignature: + """``HTTPMessageSigner.prepare``: what :func:`sign` would sign, unsigned. - 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. + The signature over ``signature_base`` goes to :func:`apply`. 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, - ) + _check_signable(message) + try: + return _signer(alg).prepare( + 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 HTTPMessageSignaturesException as exc: + raise ValueError(str(exc)) from exc + + +def apply( + message: Request | Response, prepared: PreparedSignature, signature: bytes, *, alg: str +) -> tuple[str, str]: + """``HTTPMessageSigner.apply``: put ``signature`` on ``message``; return the two fields.""" try: - return handler.base(message, covered_component_ids=covered_component_ids, params=params) - except ComponentError as exc: - raise ValueError(exc.reason) from exc + _signer(alg).apply(message, prepared, signature) except HTTPMessageSignaturesException as exc: raise ValueError(str(exc)) from exc + return message.headers["Signature-Input"], message.headers["Signature"] class ProfileVerifier(HTTPMessageVerifier): @@ -595,7 +509,6 @@ def verify( tag: str, alg: str, public_key: Any, - resolver: type[ComponentResolver], window_checked: bool, parameters: Sequence[str] = (), ) -> Mapping[str, str]: @@ -604,7 +517,7 @@ def verify( ``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:`SignatureBaseError` when the signature base can't be built and :class:`SignatureMismatchError` when the signature does not verify. ``window_checked`` must be true: :class:`ProfileVerifier` does not check @@ -612,46 +525,45 @@ def verify( """ if window_checked is not True: raise ValueError("check the signature window before verifying") + try: + # A key that doesn't fit the algorithm can't verify this signature; + # refusing it here leaves the library's errors to the base and the + # signature. + ALGORITHMS[alg](public_key=public_key) + except HTTPMessageSignaturesException as exc: + raise SignatureMismatchError(str(exc)) from exc 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) + # The profile has already decided which parameters it accepts. + setattr( # a per-verifier value of the library class variable + 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 + from cryptography.exceptions import InvalidSignature as CryptoInvalidSignature + + if cause is not None and not isinstance(cause, CryptoInvalidSignature): + # The library could not build the signature base. + 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", "DuplicateFieldError", - "ProfileSigner", "ProfileVerifier", "Request", "Response", "SignatureBaseError", "SignatureMismatchError", + "apply", "build_base", "component_identifier", "dictionary_has_parameter", @@ -659,10 +571,10 @@ def verify( "legacy_binary_tokens", "mixes_base64_alphabets", "parse_dictionary", + "prepare", "ser_item_or_inner_list", "serialize", "sign", - "signature_base", "translate_legacy_binary", "verify", ] diff --git a/src/adcp/signing/canonical.py b/src/adcp/signing/canonical.py index 0d6a22ecd..9f6c71905 100644 --- a/src/adcp/signing/canonical.py +++ b/src/adcp/signing/canonical.py @@ -10,9 +10,8 @@ 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. +the public entry points onto them, and :func:`signing_message` hands the +library a request whose URL is already the canonical target URI. """ from __future__ import annotations @@ -25,6 +24,7 @@ from adcp.signing._idna_canonicalize import canonicalize_host from adcp.signing._rfc9421 import ( + Request, component_identifier, is_inner_list, parse_dictionary, @@ -128,22 +128,31 @@ 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. The base comes from + ``@signature-params``, over :func:`signing_message`. 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 + from adcp.signing._rfc9421 import build_base base = build_base( - Request(method=method, url=url, headers=dict(headers)), + signing_message(method, url, headers), covered_component_ids=[component_identifier((name, {})) for name in parsed.components], params=parsed.params, - resolver=AdcpComponentResolver, ) return base.decode("utf-8") +def signing_message(method: str, url: str, headers: Mapping[str, str]) -> Request: + """The request the AdCP profile signs: its URL is the canonical target URI. + + ``http-message-signatures`` derives ``@target-uri`` and ``@authority`` + from the URL it is given, so handing it the canonical target URI makes + them the AdCP values. Raises :class:`TargetUriMalformedError` for a URL + the profile rejects. + """ + return Request(method=method, url=canonicalize_target_uri(url), headers=dict(headers)) + + def canonicalize_target_uri(url: str) -> str: """Produce the `@target-uri` derived-component value per AdCP profile.""" parts = _split_or_reject(url) diff --git a/src/adcp/signing/crypto.py b/src/adcp/signing/crypto.py index b1ddd2ebd..cfa3186f9 100644 --- a/src/adcp/signing/crypto.py +++ b/src/adcp/signing/crypto.py @@ -15,13 +15,15 @@ from __future__ import annotations -import base64 -from typing import Any +from typing import Any, cast from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec, ed25519 from http_message_signatures.exceptions import HTTPMessageSignaturesException +from joserfc.errors import JoseError +from joserfc.jwk import ECKey, OKPKey +from joserfc.util import urlsafe_b64decode, urlsafe_b64encode from adcp.signing._rfc9421 import ALGORITHMS, parse_dictionary, serialize, translate_legacy_binary @@ -32,20 +34,28 @@ ALG_ES256 = "ecdsa-p256-sha256" ALLOWED_ALGS: frozenset[str] = frozenset({ALG_ED25519, ALG_ES256}) -# Ed25519 raw public-key and ECDSA-P-256 coordinate lengths (both 32 bytes). -# Defensive byte-length check before the library's on-curve validation. -_RAW_KEY_BYTES = 32 +# RFC 7518 section 6.2.1.2: a P-256 coordinate (and section 6.2.2.1, the +# private scalar) is exactly 32 bytes. joserfc accepts a shorter spelling of +# the same number, so the length is checked here. +_P256_MEMBER_BYTES = 32 + +# RFC 7518 sections 6.2.2, 6.3.2 and 6.4.1: the members only a private or +# symmetric key carries. +_PRIVATE_JWK_MEMBERS = frozenset({"d", "p", "q", "dp", "dq", "qi", "oth", "k"}) def b64url_decode(s: str) -> bytes: - """Decode base64url, tolerating missing padding.""" - pad = "=" * (-len(s) % 4) - return base64.urlsafe_b64decode(s + pad) + """Decode RFC 7515 base64url with ``joserfc``: no padding, one spelling. + + Characters outside the URL-safe alphabet, padding, and non-zero trailing + bits raise ``ValueError``. + """ + return urlsafe_b64decode(s.encode("ascii")) def b64url_encode(b: bytes) -> str: - """Encode bytes as base64url without padding.""" - return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii") + """Encode bytes as base64url without padding (``joserfc``).""" + return urlsafe_b64encode(b).decode("ascii") def load_private_key_pem(pem: bytes, *, password: bytes | None = None) -> PrivateKey: @@ -86,68 +96,76 @@ def load_private_key_pem(pem: bytes, *, password: bytes | None = None) -> Privat ) if isinstance(key, ec.EllipticCurvePrivateKey) and not isinstance(key.curve, ec.SECP256R1): raise ValueError( - f"EC key curve {key.curve.name} is not supported — only " - f"P-256 (SECP256R1) is allowed" + f"EC key curve {key.curve.name} is not supported — only P-256 (SECP256R1) is allowed" ) return key -def public_key_from_jwk(jwk: dict[str, Any]) -> PublicKey: - """Reconstruct a public key from its JWK.""" +def import_jwk(jwk: dict[str, Any], *, private: bool = False) -> OKPKey | ECKey: + """Import an Ed25519 or P-256 JWK with ``joserfc``. + + ``joserfc`` decodes the members strictly and checks the key. An unusable + JWK raises ``ValueError``. Unless ``private``, the private members are + dropped first: ``joserfc`` derives the public key from a ``d`` it is + given, and a ``kid`` or pin names the key by a thumbprint over ``x``. + """ + if not private: + jwk = {name: value for name, value in jwk.items() if name not in _PRIVATE_JWK_MEMBERS} kty = jwk.get("kty") crv = jwk.get("crv") + key_type: type[OKPKey] | type[ECKey] if kty == "OKP" and crv == "Ed25519": - x_raw = b64url_decode(jwk["x"]) - if len(x_raw) != _RAW_KEY_BYTES: - raise ValueError(f"Ed25519 JWK x has length {len(x_raw)}, expected {_RAW_KEY_BYTES}") - return ed25519.Ed25519PublicKey.from_public_bytes(x_raw) - if kty == "EC" and crv == "P-256": - x_raw = b64url_decode(jwk["x"]) - y_raw = b64url_decode(jwk["y"]) - if len(x_raw) != _RAW_KEY_BYTES or len(y_raw) != _RAW_KEY_BYTES: - raise ValueError( - f"P-256 JWK x/y must be {_RAW_KEY_BYTES} bytes each, " - f"got x={len(x_raw)} y={len(y_raw)}" - ) - x = int.from_bytes(x_raw, "big") - y = int.from_bytes(y_raw, "big") - return ec.EllipticCurvePublicNumbers(x, y, ec.SECP256R1()).public_key() - raise ValueError(f"unsupported JWK kty/crv: {kty}/{crv}") + key_type = OKPKey + elif kty == "EC" and crv == "P-256": + key_type = ECKey + else: + raise ValueError(f"unsupported JWK kty/crv: {kty}/{crv}") + try: + if key_type is ECKey: + for name in ("x", "y", "d") if "d" in jwk else ("x", "y"): + member = jwk.get(name) + if not isinstance(member, str) or len(b64url_decode(member)) != _P256_MEMBER_BYTES: + raise ValueError(f"P-256 JWK {name} must be {_P256_MEMBER_BYTES} bytes") + return key_type.import_key(jwk) + except (JoseError, ValueError, TypeError, KeyError) as exc: + raise ValueError(f"unusable {kty}/{crv} JWK: {exc}") from exc + + +def public_key_from_jwk(jwk: dict[str, Any]) -> PublicKey: + """Reconstruct a public key from its JWK (see :func:`import_jwk`).""" + return cast(PublicKey, import_jwk(jwk).public_key) def private_key_from_jwk(jwk: dict[str, Any], *, d_field: str = "d") -> PrivateKey: - """Reconstruct a private key from its JWK. + """Reconstruct an Ed25519 or P-256 private key from its JWK's ``d``. `d_field` lets callers read from a non-standard scalar field — the AdCP test vectors store the private half under `_private_d_for_test_only` to signal that the keys are not production-grade. + + The public members are optional. When ``x`` (or ``y``) is present, it must + be the public key of ``d``, so a JWK that pairs ``d`` with another key's + ``x`` raises ``ValueError`` here rather than failing at the receiver. """ - kty = jwk.get("kty") - crv = jwk.get("crv") + d = jwk.get(d_field) + if not isinstance(d, str): + raise ValueError(f"JWK has no private member {d_field!r}") + kty, crv = jwk.get("kty"), jwk.get("crv") + scalar = b64url_decode(d) + if len(scalar) != 32: + raise ValueError(f"{kty}/{crv} JWK {d_field} must be 32 bytes") + key: PrivateKey if kty == "OKP" and crv == "Ed25519": - d_raw = b64url_decode(jwk[d_field]) - if len(d_raw) != _RAW_KEY_BYTES: - raise ValueError(f"Ed25519 JWK d has length {len(d_raw)}, expected {_RAW_KEY_BYTES}") - return ed25519.Ed25519PrivateKey.from_private_bytes(d_raw) - if kty == "EC" and crv == "P-256": - d_raw = b64url_decode(jwk[d_field]) - x_raw = b64url_decode(jwk["x"]) - y_raw = b64url_decode(jwk["y"]) - if ( - len(d_raw) != _RAW_KEY_BYTES - or len(x_raw) != _RAW_KEY_BYTES - or len(y_raw) != _RAW_KEY_BYTES - ): - raise ValueError( - f"P-256 JWK d/x/y must be {_RAW_KEY_BYTES} bytes each, " - f"got d={len(d_raw)} x={len(x_raw)} y={len(y_raw)}" - ) - d = int.from_bytes(d_raw, "big") - x = int.from_bytes(x_raw, "big") - y = int.from_bytes(y_raw, "big") - pub = ec.EllipticCurvePublicNumbers(x, y, ec.SECP256R1()) - return ec.EllipticCurvePrivateNumbers(d, pub).private_key() - raise ValueError(f"unsupported JWK kty/crv: {kty}/{crv}") + key = ed25519.Ed25519PrivateKey.from_private_bytes(scalar) + public = OKPKey.import_key(key).as_dict(private=False) + elif kty == "EC" and crv == "P-256": + key = ec.derive_private_key(int.from_bytes(scalar, "big"), ec.SECP256R1()) + public = ECKey.import_key(key).as_dict(private=False) + else: + raise ValueError(f"unsupported JWK kty/crv: {kty}/{crv}") + if any(name in jwk and jwk[name] != public[name] for name in ("x", "y") if name in public): + raise ValueError(f"JWK public members are not the public key of {d_field}") + return key def alg_for_jwk(jwk: dict[str, Any]) -> str: diff --git a/src/adcp/signing/jws.py b/src/adcp/signing/jws.py index cf537cf7f..392d00d2e 100644 --- a/src/adcp/signing/jws.py +++ b/src/adcp/signing/jws.py @@ -37,12 +37,12 @@ UnsupportedKeyAlgorithmError, ) from joserfc.jwa import EdDSAAlgorithm, ESAlgorithm, JWSAlgModel -from joserfc.jwk import ECKey, OKPKey +from joserfc.jwk import OKPKey from joserfc.jws import JWSRegistry -from joserfc.util import urlsafe_b64decode, urlsafe_b64encode +from joserfc.util import urlsafe_b64encode from adcp.signing._strict_json import StrictJsonError, parse_strict_json -from adcp.signing.crypto import ALG_ED25519, ALG_ES256 +from adcp.signing.crypto import ALG_ED25519, ALG_ES256, b64url_decode, import_jwk from adcp.signing.jwks import AsyncJwksResolver, JwksResolver # JWS (RFC 7518) alg names mapped to the RFC 9421 names the rest of the SDK @@ -53,9 +53,6 @@ } ALLOWED_JWS_ALGS: frozenset[str] = frozenset(JWS_ALG_TO_INTERNAL.keys()) -# RFC 7518 section 6.2.1.2: a P-256 coordinate is exactly 32 bytes. -_P256_COORDINATE_BYTES = 32 - class JwsError(Exception): """Base class for JWS parse/verify failures.""" @@ -100,6 +97,10 @@ def check_key(self, key: Any) -> None: class _AdcpJwsRegistry(JWSRegistry): """joserfc registry that knows only the AdCP profile algorithms. + ``Ed25519`` is RFC 9864's fully specified name for ``EdDSA`` over + Ed25519, which a Web Bot Auth governance token may carry. Each caller + narrows the registry to its own allow-list (``algorithms=``). + The size caps are lifted. The fetch layer already bounds the document (``max_body_bytes``, 1 MiB by default and configurable). joserfc's smaller default caps (512-byte header, 128 kB payload) would reject @@ -109,6 +110,7 @@ class _AdcpJwsRegistry(JWSRegistry): algorithms: dict[str, JWSAlgModel] = { "EdDSA": _Ed25519EdDSA(), "ES256": ESAlgorithm("ES256", "P-256", 256), + "Ed25519": JWSRegistry.algorithms["Ed25519"], } recommended: list[str] = [] max_header_length = sys.maxsize @@ -116,11 +118,6 @@ class _AdcpJwsRegistry(JWSRegistry): max_signature_length = sys.maxsize -def _b64url_decode(segment: str) -> bytes: - """RFC 7515 base64url: URL-safe alphabet, no padding, canonical tail bits.""" - return urlsafe_b64decode(segment.encode("ascii")) - - def _decode_json_object(raw: bytes, what: str) -> dict[str, Any]: """Parse ``raw`` as a strict JSON object. @@ -170,7 +167,7 @@ def parse_compact_jws(token: str) -> tuple[str, str, bytes]: f"compact JWS protected header is not valid base64url JSON: {exc}" ) from exc try: - signature = _b64url_decode(compact.segments["signature"].decode("ascii")) + 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 ( @@ -198,8 +195,7 @@ def parse_general_json_jws(doc: dict[str, Any]) -> tuple[str, str, bytes]: raise JwsMalformedError("JWS general JSON 'signatures' must be a non-empty array") if len(signatures) > 1: raise JwsMalformedError( - "JWS general JSON 'signatures' with multiple entries is not supported for " - "this profile" + "JWS general JSON 'signatures' with multiple entries is not supported for this profile" ) entry = signatures[0] if not isinstance(entry, dict): @@ -227,7 +223,7 @@ def _validate_header( CPU. """ try: - raw = _b64url_decode(b64_protected) + raw = b64url_decode(b64_protected) except ValueError as exc: raise JwsMalformedError(f"protected header is not valid base64url: {exc}") from exc header = _decode_json_object(raw, "protected header") @@ -253,33 +249,6 @@ def _validate_header( return alg, kid -def _import_jwk(jwk: dict[str, Any]) -> OKPKey | ECKey: - """Import the resolved JWK as an Ed25519 or P-256 public key. - - An unusable JWK raises ``ValueError``, not a :class:`JwsError`. That is the - exception the verifier raised before joserfc, so callers see no change. - """ - kty = jwk.get("kty") - crv = jwk.get("crv") - try: - if kty == "OKP" and crv == "Ed25519": - return OKPKey.import_key(jwk) - if kty == "EC" and crv == "P-256": - # joserfc accepts a P-256 coordinate with leading zero bytes. - # RFC 7518 section 6.2.1.2 fixes the length, so check it here. - for name in ("x", "y"): - coordinate = jwk.get(name) - if ( - not isinstance(coordinate, str) - or len(_b64url_decode(coordinate)) != _P256_COORDINATE_BYTES - ): - raise ValueError(f"P-256 JWK {name} must be {_P256_COORDINATE_BYTES} bytes") - return ECKey.import_key(jwk) - except (JoseError, ValueError, TypeError, KeyError) as exc: - raise ValueError(f"unusable {kty}/{crv} JWK: {exc}") from exc - raise ValueError(f"unsupported JWK kty/crv: {kty}/{crv}") - - def _verify_signature_and_decode_payload( *, b64_protected: str, @@ -295,7 +264,7 @@ def _verify_signature_and_decode_payload( The header and payload segments go to joserfc exactly as received, so the signing input is the wire bytes. """ - key = _import_jwk(jwk) + key = import_jwk(jwk) b64_signature = urlsafe_b64encode(signature).decode("ascii") token = f"{b64_protected}.{b64_payload}.{b64_signature}" registry = _AdcpJwsRegistry(algorithms=allowed_algs, strict_check_header=False) diff --git a/src/adcp/signing/publisher_pins.py b/src/adcp/signing/publisher_pins.py index c5e807199..e8d014548 100644 --- a/src/adcp/signing/publisher_pins.py +++ b/src/adcp/signing/publisher_pins.py @@ -8,36 +8,34 @@ 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")), +_KEY_TYPES: dict[str, type[OKPKey] | type[ECKey] | type[RSAKey]] = { + "OKP": OKPKey, + "EC": ECKey, + "RSA": RSAKey, } -def jwk_thumbprint(key: Mapping[str, Any]) -> bytes | None: - """Return the RFC 7638 digest (``joserfc``); incomplete public keys match nothing. +def jwk_thumbprint(key: Mapping[str, Any]) -> str | None: + """Return the RFC 7638 thumbprint (``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. + Only the members RFC 7638 hashes (the key type's required members in + ``joserfc``) go to ``joserfc``: a pin or directory entry is a public key, + and the thumbprint never depends on anything else. ``joserfc`` refuses a + key missing one of them or carrying a malformed one. """ - 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): + key_type = _KEY_TYPES.get(str(key.get("kty"))) + if key_type is None: return None + public = { + name: key[name] + for name, member in key_type.value_registry.items() + if member.required and name in key + } try: - imported = key_type.import_key({"kty": key["kty"], **{m: key[m] for m in members}}) - return urlsafe_b64decode(imported.thumbprint().encode("ascii")) + return key_type.import_key({"kty": key["kty"], **public}).thumbprint() except (JoseError, ValueError, TypeError): return None diff --git a/src/adcp/signing/revocation_fetcher.py b/src/adcp/signing/revocation_fetcher.py index 2618a4cf1..38c3fd962 100644 --- a/src/adcp/signing/revocation_fetcher.py +++ b/src/adcp/signing/revocation_fetcher.py @@ -56,6 +56,7 @@ JwksResolver, ) from adcp.signing.jws import ( + ALLOWED_JWS_ALGS, JwsError, averify_jws_document, verify_jws_document, @@ -930,6 +931,9 @@ class AsyncCachingRevocationChecker(_CheckerState): verifying tasks that all miss on the first verification fire exactly one fetch. (The sync class is documented single-threaded; async concurrency is the typical case.) + * ``allowed_algs`` narrows the list's JWS ``alg`` below + :data:`adcp.signing.jws.ALLOWED_JWS_ALGS`, for a profile that signs + with fewer algorithms. Wire it into :class:`adcp.signing.VerifyOptions.revocation_checker` only if you have an async verifier pipeline — the sync @@ -948,6 +952,7 @@ def __init__( clock: Callable[[], float] = time.monotonic, wall_clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc), history_admission: Callable[[RevocationList], bool] | None = None, + allowed_algs: frozenset[str] = ALLOWED_JWS_ALGS, ) -> None: if clock is time.time: raise ValueError( @@ -969,6 +974,7 @@ def __init__( self._clock = clock self._wall_clock = wall_clock self._history_admission = history_admission + self._allowed_algs = allowed_algs self._init_state() # Eager lock construction: lazy-init was racy (two tasks both # seeing self._lock is None construct separate Locks and skip @@ -988,6 +994,7 @@ def from_issuer_origin( clock: Callable[[], float] = time.monotonic, wall_clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc), history_admission: Callable[[RevocationList], bool] | None = None, + allowed_algs: frozenset[str] = ALLOWED_JWS_ALGS, ) -> AsyncCachingRevocationChecker: """Async variant of :meth:`CachingRevocationChecker.from_issuer_origin`.""" normalized = _normalize_issuer(origin) @@ -1001,6 +1008,7 @@ def from_issuer_origin( clock=clock, wall_clock=wall_clock, history_admission=history_admission, + allowed_algs=allowed_algs, ) async def aprime(self) -> None: @@ -1125,6 +1133,7 @@ async def _refresh(self, *, conditional: bool, now_wall: datetime, now_mono: flo result.body, jwks_resolver=self._jwks_resolver, expected_typ=REVOCATION_LIST_TYP, + allowed_algs=self._allowed_algs, ) except JwsError as exc: raise RevocationListSignatureError( diff --git a/src/adcp/signing/signer.py b/src/adcp/signing/signer.py index e3b0f09b9..c0e6818e4 100644 --- a/src/adcp/signing/signer.py +++ b/src/adcp/signing/signer.py @@ -28,7 +28,7 @@ from http_message_signatures.exceptions import HTTPMessageSignaturesException from adcp.signing import _rfc9421 -from adcp.signing.canonical import _lookup +from adcp.signing.canonical import _lookup, signing_message from adcp.signing.constants import ( DEFAULT_EXPIRES_IN_SECONDS, DEFAULT_TAG, @@ -142,7 +142,7 @@ def _prepare_request( outgoing_headers["Content-Digest"] = content_digest_value components.append("content-digest") return _PreparedRequest( - message=_rfc9421.Request(method=method, url=url, headers=outgoing_headers), + message=signing_message(method, url, outgoing_headers), covered_component_ids=tuple( _rfc9421.component_identifier((name, {})) for name in components ), @@ -278,7 +278,6 @@ def sign_request( nonce=prepared.nonce, tag=tag, covered_component_ids=prepared.covered_component_ids, - resolver=_rfc9421.AdcpComponentResolver, ) return _signed_headers( prepared, @@ -341,23 +340,24 @@ async def async_sign_request( tag=tag, label=label, ) - # The same base and parameters HTTPMessageSigner.sign produces, so the - # headers match sign_request byte for byte. - base, member = _rfc9421.signature_base( + # HTTPMessageSigner.prepare and apply, the library's two halves of sign, + # so the headers match sign_request byte for byte. + to_sign = _rfc9421.prepare( prepared.message, alg=alg, 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, ) - signature = await provider.sign(base) + signature = await provider.sign(to_sign.signature_base) + signature_input, _ = _rfc9421.apply(prepared.message, to_sign, signature, alg=alg) return _signed_headers( prepared, - signature_input=f"{label}={member}", + signature_input=signature_input, 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 572d13071..8e572fd6d 100644 --- a/src/adcp/signing/verifier.py +++ b/src/adcp/signing/verifier.py @@ -28,6 +28,7 @@ TargetUriMalformedError, _lookup, parse_signature_input_header, + signing_message, ) from adcp.signing.constants import ( ADCP_USE_REQUEST, @@ -462,15 +463,29 @@ def verify_request_signature( # 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: + message = signing_message( + method, + url, + { + **{k: v for k, v in headers.items() if k.lower() not in _SIGNATURE_FIELDS}, + "Signature-Input": _rfc9421.signature_input_field(options.label, parsed.raw_value), + "Signature": format_signature_header(sig_bytes, label=options.label), + }, + ) + except TargetUriMalformedError as exc: + # 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.code, step=6, message=f"signature base construction failed: {exc}" + ) from exc + except ValueError as exc: + raise SignatureVerificationError( + REQUEST_SIGNATURE_HEADER_MALFORMED, + step=6, + message=f"signature base construction failed: {exc}", + ) from exc try: _rfc9421.verify( message, @@ -478,20 +493,10 @@ def verify_request_signature( tag=str(parsed.params["tag"]), alg=alg, public_key=public_key, - resolver=_rfc9421.AdcpComponentResolver, window_checked=True, # step 5 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=6, @@ -888,9 +893,7 @@ def _check_key_purpose( raise SignatureVerificationError( REQUEST_SIGNATURE_KEY_PURPOSE_INVALID, step=8, - message=( - f"JWK.adcp_use {jwk.get('adcp_use')!r} not in " f"{sorted(allowed_adcp_uses)!r}" - ), + message=(f"JWK.adcp_use {jwk.get('adcp_use')!r} not in {sorted(allowed_adcp_uses)!r}"), ) try: jwk_alg = alg_for_jwk(dict(jwk)) diff --git a/src/adcp/signing/wba.py b/src/adcp/signing/wba.py index a0cfd10f3..acb67fbe7 100644 --- a/src/adcp/signing/wba.py +++ b/src/adcp/signing/wba.py @@ -11,7 +11,7 @@ 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 +``ed25519`` keys only, 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. @@ -25,7 +25,6 @@ import asyncio import base64 -import binascii import json import secrets import time @@ -37,15 +36,21 @@ from urllib.parse import urlsplit import httpx -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import ed25519, rsa +from cryptography.hazmat.primitives.asymmetric import ed25519 +from joserfc.jwk import OKPKey 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.canonical import _lookup, canonical_origin -from adcp.signing.crypto import b64url_decode, b64url_encode, public_key_from_jwk +from adcp.signing.crypto import ( + _PRIVATE_JWK_MEMBERS, + b64url_decode, + b64url_encode, + import_jwk, + 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 @@ -58,10 +63,9 @@ 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}) +WBA_ALGORITHMS: frozenset[str] = frozenset({ALG_ED25519}) #: Every signed request carries a nonce of exactly 64 random bytes #: (``wba-profile.mdx``, Profile rules; the architecture draft §4.2.2). @@ -73,17 +77,12 @@ #: (WBA §6.10), counted from the last successful fetch. DEFAULT_DIRECTORY_MAX_STALE_SECONDS = 86400 -_DIRECTORY_MEDIA_TYPES = frozenset( - {DIRECTORY_MEDIA_TYPE, "application/jwk-set+json", "application/json"} -) _DIRECTORY_MAX_BYTES = 256 * 1024 _DIRECTORY_MAX_KEYS = 100 _DIRECTORY_CACHE_SECONDS = 300 _DISCOVERY_NEGATIVE_SECONDS = 60 _DIRECTORY_CREATED_SKEW_SECONDS = 60 _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") @@ -119,20 +118,17 @@ def is_64_byte_nonce(nonce: object) -> bool: """ if type(nonce) is not str or not nonce.isascii(): return False - candidates: list[tuple[bytes, str]] = [] try: - raw = base64.urlsafe_b64decode(nonce + "=" * (-len(nonce) % 4)) - candidates.append((raw, b64url_encode(raw))) - except (binascii.Error, ValueError): + # Unpadded base64url, decoded strictly (one spelling per value). + return len(b64url_decode(nonce)) == WBA_NONCE_BYTES + except ValueError: # Not base64url; the standard-base64 form is tried next. pass try: raw = base64.b64decode(nonce, validate=True) - candidates.append((raw, base64.b64encode(raw).decode("ascii"))) - except (binascii.Error, ValueError): - # Not standard base64 either; only a decoded candidate can match. - pass - return any(len(raw) == WBA_NONCE_BYTES and encoded == nonce for raw, encoded in candidates) + except ValueError: + return False + return len(raw) == WBA_NONCE_BYTES and base64.b64encode(raw).decode("ascii") == nonce def valid_wba_nonce(nonce: object) -> bool: @@ -258,6 +254,19 @@ def _dictionary(value: str | None, *, missing_status: int = 400) -> dict[str, An raise WbaVerificationError(f"malformed signature field: {exc}", status=400) from exc +def _check_signature_members(inputs: Mapping[str, Any], signatures: Mapping[str, Any]) -> None: + """RFC 9421 sections 4.1 and 4.2: the shape of every member, read or not. + + Each ``Signature-Input`` member is an inner list, and each ``Signature`` + member a Byte Sequence with no parameters. A member another party added + is not read, but a malformed one makes the field malformed: 400. + """ + if not all(_rfc9421.is_inner_list(member) for member in inputs.values()) or any( + type(value) is not bytes or params for value, params in signatures.values() + ): + raise WbaVerificationError("malformed signature field member", status=400) + + def uses_web_bot_auth(headers: Mapping[str, str]) -> bool: """Select WBA from either marker; never fall back after a WBA failure.""" if _lookup(headers, "signature-agent") is not None: @@ -290,103 +299,51 @@ def _https_origin(url: str) -> str: def thumbprint_key_id(jwk: Mapping[str, Any]) -> str: """Return the RFC 7638 thumbprint (``joserfc``) for supported public key material.""" - digest = jwk_thumbprint(jwk) - if digest is None: + thumbprint = jwk_thumbprint(jwk) + if thumbprint is None: raise ValueError("incomplete public JWK") - return b64url_encode(digest) + return thumbprint -def _int_member(value: int) -> str: - return b64url_encode(value.to_bytes((value.bit_length() + 7) // 8, "big")) - - -WbaPrivateKey = ed25519.Ed25519PrivateKey | rsa.RSAPrivateKey +WbaPrivateKey = ed25519.Ed25519PrivateKey def public_directory_key(private_key: WbaPrivateKey) -> dict[str, Any]: """Export a WBA public JWK, with a thumbprint kid and no purpose claim. - Ed25519 and RSA (2048 to 8192 bits, used with ``rsa-pss-sha512``) keys - are supported: the profile's two algorithms. + The profile's one algorithm is ``ed25519``, so only Ed25519 keys export. """ - public = private_key.public_key() - jwk: dict[str, Any] - if isinstance(public, ed25519.Ed25519PublicKey): - jwk = { - "kty": "OKP", - "crv": "Ed25519", - "x": b64url_encode( - public.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) - ), - } - 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 and RSA keys") - jwk.update({"kid": thumbprint_key_id(jwk), "use": "sig"}) - return jwk - - -def _canonical_b64url(value: object) -> bool: - if type(value) is not str or not value or not value.isascii(): - return False - try: - return b64url_encode(b64url_decode(value)) == value - except (binascii.Error, ValueError): - return False - - -def _rsa_public_key(jwk: Mapping[str, Any]) -> rsa.RSAPublicKey: - modulus = b64url_decode(str(jwk.get("n", ""))) - exponent = b64url_decode(str(jwk.get("e", ""))) - if not modulus or modulus[0] == 0 or not exponent or len(exponent) > 4 or exponent[0] == 0: - raise ValueError("invalid RSA key members") - n = int.from_bytes(modulus, "big") - e = int.from_bytes(exponent, "big") - if not _RSA_MIN_BITS <= n.bit_length() <= _RSA_MAX_BITS: - raise ValueError("RSA modulus size not allowed") - if e < 3 or e > 2**31 - 1 or e % 2 == 0: - raise ValueError("RSA exponent not allowed") - return rsa.RSAPublicNumbers(e, n).public_key() + if not isinstance(private_key, ed25519.Ed25519PrivateKey): + raise ValueError("Web Bot Auth signs with Ed25519 keys only") + key = OKPKey.import_key(private_key) + return {**key.as_dict(private=False), "kid": key.thumbprint(), "use": "sig"} def _algorithm(jwk: Mapping[str, Any]) -> str: """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. + The profile's one algorithm qualifies: an Ed25519 key signs ``ed25519``. + Any other key (P-256 and RSA included) is not a Web Bot Auth key. """ shape = (str(jwk.get("kty")), str(jwk.get("crv"))) - 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): + if ( + shape != ("OKP", "Ed25519") + 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", "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: - try: - _rsa_public_key(jwk) - except (ValueError, TypeError) as exc: - raise WbaVerificationError("unsupported RSA directory key") from exc - jws_alg = {ALG_ED25519: "EdDSA", ALG_RSA_PSS_SHA512: "PS512"}[alg] - if jwk.get("alg") not in {None, jws_alg}: + # One key, one thumbprint: joserfc refuses a non-canonical x. + try: + import_jwk({name: jwk.get(name) for name in ("kty", "crv", "x")}) + except ValueError as exc: + raise WbaVerificationError("invalid directory key member") from exc + if jwk.get("alg") not in {None, "EdDSA"}: raise WbaVerificationError("directory key algorithm mismatch") if "key_ops" in jwk and ( not isinstance(jwk["key_ops"], list) or "verify" not in jwk["key_ops"] ): raise WbaVerificationError("directory key cannot verify") - return alg - - -def _public_key(alg: str, jwk: Mapping[str, Any]) -> Any: - if alg == ALG_RSA_PSS_SHA512: - return _rsa_public_key(jwk) - return public_key_from_jwk(dict(jwk)) + return ALG_ED25519 def _received_authority(url: str) -> str: @@ -407,30 +364,6 @@ def _received_authority(url: str) -> str: return authority -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 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: @@ -453,7 +386,6 @@ def _signature_base( message, covered_component_ids=[_rfc9421.component_identifier(item) for item in items], params=params, - resolver=_WbaComponentResolver, ) @@ -484,22 +416,20 @@ def _verify_crypto( *, 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`` must name the same one. The caller checked the fields' shape + (:func:`_check_signature_members`). """ alg = _algorithm(jwk) - 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) or signature[1]: - raise WbaVerificationError("signature must be a bare sf-binary", status=400) + # Every signature names its algorithm (Profile rules); none is inferred. + if params.get("alg") != alg: + raise WbaVerificationError("signature algorithm is absent or does not match key") try: - public_key = _public_key(alg, jwk) + public_key = public_key_from_jwk(dict(jwk)) except (ValueError, KeyError, TypeError) as exc: raise WbaVerificationError("invalid directory key") from exc try: @@ -509,7 +439,6 @@ def _verify_crypto( tag=str(params["tag"]), alg=alg, public_key=public_key, - resolver=_WbaComponentResolver, window_checked=True, # every caller ran _window first parameters=tuple(params), ) @@ -594,7 +523,6 @@ def sign_wba_request( nonce=nonce, tag=WBA_TAG, covered_component_ids=covered, - resolver=_WbaComponentResolver, ) outgoing["Signature-Input"] = signature_input outgoing["Signature"] = signature @@ -617,7 +545,8 @@ class _VerifiedDirectory: signatures_expire: float -def _parse_directory_keys(body: bytes) -> dict[str, dict[str, Any]]: +def _parse_directory_keys(body: bytes) -> tuple[dict[str, dict[str, Any]], set[str]]: + """Index the usable keys by thumbprint; also return the unusable keys' thumbprints.""" try: document = parse_strict_json(body) except StrictJsonError as exc: @@ -626,6 +555,7 @@ def _parse_directory_keys(body: bytes) -> dict[str, dict[str, Any]]: if not isinstance(keys, list) or not 1 <= len(keys) <= _DIRECTORY_MAX_KEYS: raise WbaVerificationError("invalid directory key count") index: dict[str, dict[str, Any]] = {} + ignored: set[str] = set() for jwk in keys: if not isinstance(jwk, dict): raise WbaVerificationError("directory entry is not an object") @@ -640,8 +570,13 @@ def _parse_directory_keys(body: bytes) -> dict[str, dict[str, Any]]: _algorithm(jwk) thumbprint = thumbprint_key_id(jwk) except (WbaVerificationError, ValueError): - # A plain JWK Set may hold keys for other uses: skip what the - # profile cannot verify with. + # A directory may hold keys for other uses: skip what the profile + # cannot verify with (RFC 7517 section 5), and remember it so its + # signature is skipped too. + try: + ignored.add(thumbprint_key_id(jwk)) + except ValueError: + pass # No thumbprint, so no signature can name it. continue if thumbprint in index: raise WbaVerificationError("directory repeats a key") @@ -650,7 +585,7 @@ def _parse_directory_keys(body: bytes) -> dict[str, dict[str, Any]]: index[thumbprint] = {**jwk, "kid": thumbprint} if not index: raise WbaVerificationError("directory holds no usable key") - return index + return index, ignored def _verify_directory( @@ -658,9 +593,10 @@ def _verify_directory( ) -> _VerifiedDirectory: origin = _https_origin(origin) media_type = (_lookup(response.headers, "content-type") or "").split(";", 1)[0] + # WBA section 5.5.1: a directory is served as the directory media type. if ( response.status != 200 - or media_type.strip().lower() not in _DIRECTORY_MEDIA_TYPES + or media_type.strip().lower() != DIRECTORY_MEDIA_TYPE or len(response.body) > _DIRECTORY_MAX_BYTES ): raise WbaVerificationError("invalid key directory response") @@ -673,11 +609,12 @@ def _verify_directory( # The digest covers the exact bytes served, a UTF-8 BOM included. if digest is None or not content_digest_matches(digest, response.body): raise WbaVerificationError("directory digest mismatch") - keys = _parse_directory_keys(response.body) + keys, ignored = _parse_directory_keys(response.body) inputs = _dictionary(raw_inputs) signatures = _dictionary(raw_signatures) if set(signatures) != set(inputs): raise WbaVerificationError("directory signature labels differ") + _check_signature_members(inputs, signatures) message = _rfc9421.Response( status_code=response.status, request=_rfc9421.Request(method="GET", url=origin + DIRECTORY_PATH), @@ -686,9 +623,14 @@ def _verify_directory( signed: dict[str, dict[str, Any]] = {} earliest = float("inf") for label, member in inputs.items(): - if not _rfc9421.is_inner_list(member) or member[1].get("tag") != DIRECTORY_TAG: + if not _rfc9421.is_inner_list(member): raise WbaVerificationError("invalid directory signature profile") items, params = member + if params.get("keyid") in ignored: + # The signature of a key the profile cannot use is not read. + continue + if params.get("tag") != DIRECTORY_TAG: + raise WbaVerificationError("invalid directory signature profile") _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 @@ -698,13 +640,12 @@ def _verify_directory( keyid = params["keyid"] if keyid not in keys or keyid in signed: raise WbaVerificationError("directory signature key is absent or repeated") - _verify_crypto( - message, label=label, params=params, signature=signatures.get(label), jwk=keys[keyid] - ) + _verify_crypto(message, label=label, params=params, jwk=keys[keyid]) signed[keyid] = keys[keyid] earliest = min(earliest, float(params["expires"])) - # Only keys carrying a verified possession proof are usable; an unsigned - # key alongside them is ignored. + # One signature per key (Appendix B.1): every usable key proves possession. + if signed.keys() != keys.keys(): + raise WbaVerificationError("a usable directory key did not sign") return _VerifiedDirectory(signed, earliest) @@ -713,11 +654,13 @@ def verify_directory_response( ) -> dict[str, dict[str, Any]]: """Validate a key directory served at ``origin``; return keys by thumbprint. - 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). + The response must be served as :data:`DIRECTORY_MEDIA_TYPE` and signed + per WBA Appendix B.1, as the profile requires: every key the profile can + use signs it, and each of those signatures verifies and covers + ``"@authority";req`` and ``"content-digest"``. A key the profile cannot + use (P-256 or RSA, for example) is ignored, and so is its signature. 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).keys @@ -754,12 +697,15 @@ def sign_key_directory( created, expires = now - DIRECTORY_SKEW_ALLOWANCE_SECONDS, now + expires_in_seconds else: expires = created + expires_in_seconds - request = _rfc9421.Request(method="GET", url=origin + DIRECTORY_PATH) - inputs: list[str] = [] - signatures: list[str] = [] + message = _rfc9421.Response( + status_code=200, + request=_rfc9421.Request(method="GET", url=origin + DIRECTORY_PATH), + headers=dict(headers), + ) for number, (key, jwk) in enumerate(zip(private_keys, keys), 1): + # Each key adds its own member to the same two fields. signature_input, signature = _rfc9421.sign( - _rfc9421.Response(status_code=200, request=request, headers=dict(headers)), + message, alg=_algorithm(jwk), private_key=key, key_id=jwk["kid"], @@ -769,11 +715,9 @@ def sign_key_directory( nonce=None, tag=DIRECTORY_TAG, covered_component_ids=['"@authority";req', '"content-digest"'], - resolver=_WbaComponentResolver, + append=True, ) - inputs.append(signature_input) - signatures.append(signature) - headers.update({"Signature-Input": ", ".join(inputs), "Signature": ", ".join(signatures)}) + headers.update({"Signature-Input": signature_input, "Signature": signature}) return DirectoryResponse(body=body, headers=headers) @@ -801,13 +745,8 @@ async def fetch() -> DirectoryResponse: async with client.stream( "GET", url, - headers={ - "Accept": ( - f"{DIRECTORY_MEDIA_TYPE}, application/jwk-set+json;q=0.9, " - "application/json;q=0.8" - ), - "Accept-Encoding": "identity", - }, + # The only media type _verify_directory accepts (WBA section 5.5.1). + headers={"Accept": DIRECTORY_MEDIA_TYPE, "Accept-Encoding": "identity"}, ) as response: return DirectoryResponse( status=response.status_code, @@ -1081,15 +1020,13 @@ async def verify_wba_request( # parse is a 400. inputs = _dictionary(_lookup(headers, "signature-input"), missing_status=403) signatures = _dictionary(_lookup(headers, "signature"), missing_status=403) + # A malformed member is a malformed field, before any member is selected. + _check_signature_members(inputs, signatures) raw_agent = _lookup(headers, "signature-agent") # 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 _rfc9421.is_inner_list(member) and member[1].get("tag") == WBA_TAG - ] + wba = [(label, member) for label, member in inputs.items() if member[1].get("tag") == WBA_TAG] if not wba: raise WbaVerificationError("Signature-Agent requires the Web Bot Auth profile") if set(signatures) != set(inputs): @@ -1128,6 +1065,10 @@ async def verify_wba_request( origin = _https_origin(agent[0]) except ValueError as exc: raise WbaVerificationError("invalid signer origin", label=label) from exc + # The value is the serialized origin itself, compared as a string: a + # spelling that only normalizes to an origin names no origin. + if agent[0] != origin: + raise WbaVerificationError("signer origin is not in canonical form", label=label) 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}))) @@ -1157,9 +1098,7 @@ async def verify_wba_request( 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 - ) + alg = _verify_crypto(message, label=label, params=params, jwk=key.jwk) except WbaVerificationError as exc: exc.label = label raise @@ -1249,7 +1188,6 @@ async def check_wba_revocation( #: 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"}), } @@ -1266,31 +1204,6 @@ def __init__(self, code: str, reason: str) -> None: self.reason = reason -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): - algorithms = { - "EdDSA": _Ed25519EdDSA(), - "Ed25519": JWSRegistry.algorithms["Ed25519"], - "PS512": JWSRegistry.algorithms["PS512"], - } - - # 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( token: object, *, @@ -1324,8 +1237,14 @@ async def verify_wba_governance_issuer( """ 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 + from joserfc.jwk import OKPKey + + from adcp.governance import ( + _decode_json_segment, + _governance_jws_registry, + critical_claims_failure, + ) + from adcp.signing.jws import JwsMalformedError, parse_compact_jws def fail(code: str, reason: str) -> WbaGovernanceError: return WbaGovernanceError(code, reason) @@ -1333,22 +1252,21 @@ def fail(code: str, reason: str) -> WbaGovernanceError: 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") + # The splitter and segment parser of the AdCP 3.2 governance verifier. 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: + b64_header, b64_payload, _signature = parse_compact_jws(token) + header = _decode_json_segment(b64_header) + claims = _decode_json_segment(b64_payload) + except (JwsMalformedError, 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"}: + if type(alg) is not str or alg not in _GOVERNANCE_JWS_ALGS[ALG_ED25519]: 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) + critical_failure = critical_claims_failure(header, claims) + if critical_failure is not None: + raise fail("governance_token_invalid", critical_failure) try: issuer = _https_origin(str(claims.get("iss", ""))) except ValueError as exc: @@ -1385,10 +1303,8 @@ def fail(code: str, reason: str) -> WbaGovernanceError: 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))) + jose_key = OKPKey.import_key(public) + jose_jws.deserialize_compact(token, jose_key, registry=_governance_jws_registry(str(alg))) except BadSignatureError as exc: raise fail("governance_token_invalid", "governance token signature invalid") from exc except JoseError as exc: @@ -1403,30 +1319,6 @@ def fail(code: str, reason: str) -> WbaGovernanceError: 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.""" @@ -1436,7 +1328,6 @@ async def status(self, origin: str, keyid: str) -> WbaRevocationStatus: __all__ = [ "ALG_ED25519", - "ALG_RSA_PSS_SHA512", "DEFAULT_DIRECTORY_MAX_STALE_SECONDS", "DIRECTORY_MEDIA_TYPE", "DIRECTORY_SKEW_ALLOWANCE_SECONDS", @@ -1682,6 +1573,8 @@ async def resolve(keyid: str) -> dict[str, Any] | None: clock=self._clock, wall_clock=self._wall_clock, history_admission=self._admit_revocations, + # The profile's revocation-list JWS is EdDSA only. + allowed_algs=frozenset({"EdDSA"}), ) # Authenticated revocations are permanent history. A 404 or # transport failure with no signed evidence consumes no slot. diff --git a/tests/conformance/signing/test_crypto.py b/tests/conformance/signing/test_crypto.py index 1f9604817..454f6eefc 100644 --- a/tests/conformance/signing/test_crypto.py +++ b/tests/conformance/signing/test_crypto.py @@ -11,6 +11,7 @@ from __future__ import annotations import json +import string from pathlib import Path import pytest @@ -159,5 +160,100 @@ def test_jwk_with_wrong_length_x_rejected() -> None: "crv": "Ed25519", "x": b64url_encode(b"\x00" * 31), # too short } - with pytest.raises(ValueError, match="length"): + with pytest.raises(ValueError, match="32 bytes"): public_key_from_jwk(bad) + + +_B64URL = string.ascii_uppercase + string.ascii_lowercase + string.digits + "-_" + + +@pytest.mark.parametrize( + "spoil", + [ + lambda x: x[:10] + "!!" + x[10:], # characters outside the alphabet + lambda x: x + "=", # padding + lambda x: x[:-1] + _B64URL[_B64URL.index(x[-1]) ^ 1], # same bytes, non-zero pad bits + ], + ids=["alphabet", "padding", "trailing-bits"], +) +def test_jwk_members_are_strict_base64url(spoil) -> None: + """RFC 7515 section 2 base64url: no padding, no other characters, one spelling. + + Verifier step 8 decodes the signer's JWK with ``public_key_from_jwk``, so a + lenient decoder would accept a key whose members a strict verifier refuses. + """ + from adcp.signing.crypto import b64url_decode + + jwk = {k: v for k, v in KEYS["test-ed25519-2026"].items() if not k.startswith("_")} + assert public_key_from_jwk(jwk) + with pytest.raises(ValueError): + public_key_from_jwk({**jwk, "x": spoil(jwk["x"])}) + with pytest.raises(ValueError): + b64url_decode(spoil(jwk["x"])) + + +@pytest.mark.parametrize("kid", ["test-ed25519-2026", "test-es256-2026"]) +def test_public_key_is_read_from_the_public_members(kid: str) -> None: + """A ``d`` beside the public members never chooses the verification key. + + A ``kid`` or a publisher pin names a key by its RFC 7638 thumbprint, which + covers ``x`` (and ``y``) only. A key taken from ``d`` would verify a + signature that the named key never made. + """ + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ec, ed25519 + + from adcp.signing.crypto import b64url_encode + + jwk = {k: v for k, v in KEYS[kid].items() if not k.startswith("_")} + other: ed25519.Ed25519PrivateKey | ec.EllipticCurvePrivateKey + if jwk["kty"] == "OKP": + other = ed25519.Ed25519PrivateKey.generate() + other_d = other.private_bytes_raw() + else: + other = ec.generate_private_key(ec.SECP256R1()) + other_d = other.private_numbers().private_value.to_bytes(32, "big") + raw = serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo + expected = public_key_from_jwk(jwk).public_bytes(*raw) + + key = public_key_from_jwk({**jwk, "d": b64url_encode(other_d)}) + + assert key.public_bytes(*raw) == expected + + +_KEY_TYPES = [("OKP", "Ed25519"), ("EC", "P-256")] + + +def _generate(kty: str, crv: str): + from joserfc.jwk import ECKey, OKPKey + + return (OKPKey if kty == "OKP" else ECKey).generate_key(crv, private=True) + + +def _public_members(private_key, kty: str) -> dict: + from joserfc.jwk import ECKey, OKPKey + + return (OKPKey if kty == "OKP" else ECKey).import_key(private_key).as_dict(private=False) + + +@pytest.mark.parametrize(("kty", "crv"), _KEY_TYPES) +def test_a_private_jwk_needs_only_d(kty, crv) -> None: + """``private_key_from_jwk`` reads ``d``; the public members are optional.""" + key = _generate(kty, crv) + d_only = {"kty": kty, "crv": crv, "d": key.as_dict(private=True)["d"]} + + assert _public_members(private_key_from_jwk(d_only), kty) == key.as_dict(private=False) + + +@pytest.mark.parametrize(("kty", "crv"), _KEY_TYPES) +def test_a_private_jwk_whose_public_members_name_another_key_is_refused(kty, crv) -> None: + """A JWK's ``x`` (and ``y``) must be the public key of its ``d``. + + Otherwise the sender signs with ``d`` while its JWK names another key, and + the mistake surfaces only at the receiver. + """ + key, other = _generate(kty, crv), _generate(kty, crv) + mixed = {**key.as_dict(private=False), "d": other.as_dict(private=True)["d"]} + + with pytest.raises(ValueError, match="public key"): + private_key_from_jwk(mixed) diff --git a/tests/conformance/signing/test_jws.py b/tests/conformance/signing/test_jws.py index e82012553..bcb0f9e2f 100644 --- a/tests/conformance/signing/test_jws.py +++ b/tests/conformance/signing/test_jws.py @@ -339,6 +339,18 @@ def test_reject_non_json_payload() -> None: verify_jws_document(token, jwks_resolver=_resolver_for(jwk), expected_typ=EXPECTED_TYP) +def test_reject_signature_by_a_private_member_beside_the_key() -> None: + """The resolved key is its public members; a ``d`` naming another key never verifies.""" + jwk, _key = _ed25519_jwk_and_key() + other = ed25519.Ed25519PrivateKey.generate() + header = {"alg": "EdDSA", "kid": jwk["kid"], "typ": EXPECTED_TYP} + token = _sign_compact(header, PAYLOAD_JSON, jws_alg="EdDSA", private_key=other) + carrying = {**jwk, "d": b64url_encode(other.private_bytes_raw())} + + with pytest.raises(JwsSignatureInvalidError): + verify_jws_document(token, jwks_resolver=_resolver_for(carrying), expected_typ=EXPECTED_TYP) + + def test_verify_jws_document_rejects_non_string_non_dict() -> None: with pytest.raises(JwsMalformedError, match="compact string or JSON"): verify_jws_document( diff --git a/tests/conformance/signing/test_rfc9421_library_contract.py b/tests/conformance/signing/test_rfc9421_library_contract.py index 8c82224e9..3e3ac0eaa 100644 --- a/tests/conformance/signing/test_rfc9421_library_contract.py +++ b/tests/conformance/signing/test_rfc9421_library_contract.py @@ -1,12 +1,13 @@ """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. +``http-sf``. It uses the library's resolver as it ships (``;key`` and +``;req`` included), its public extension points +(``validate_created_and_expires``, ``signature_metadata_parameters``, +``prepare`` and ``apply``), and, for the diagnostic base builder, two +protected 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 @@ -19,8 +20,6 @@ from http_message_signatures import ( HTTPMessageSigner, HTTPMessageVerifier, - HTTPSignatureComponentResolver, - InvalidSignature, ) from http_message_signatures.signatures import HTTPSignatureHandler @@ -50,65 +49,27 @@ def test_protected_hooks_keep_their_signatures(): } -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) - +def test_library_resolves_key_and_req(): + """RFC 9421 sections 2.1.2 and 2.4, which the profiles cover, come from the library.""" 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, + message, covered_component_ids=['"example-dict";key="b"'], params={"created": 1} ) - assert seen == [("example-dict", {"key": "b"}), ("@authority", {})] - # RFC 9421 section 2.1.2: the member value with its parameters. + # 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, + response = _rfc9421.Response(200, _rfc9421.Request("GET", "https://Example.com/p")) + base = _rfc9421.build_base( + response, covered_component_ids=['"@authority";req'], params={"created": 1} ) - assert signature_input == f"sig1={member}" - assert _rfc9421.parse_dictionary(signature)["sig1"][0] == key.sign(base) + assert base.decode().splitlines()[0] == '"@authority";req: example.com' + for identifier in ('"@authority";req=?0', '"@authority"'): + with pytest.raises(ValueError): + _rfc9421.build_base(response, covered_component_ids=[identifier], params={}) -def test_verify_chains_the_component_error_and_the_crypto_error(): +def test_verify_tells_a_base_failure_from_a_bad_signature(): key = ed25519.Ed25519PrivateKey.generate() message = _rfc9421.Request("POST", "https://example.com/p", {"x-a": "1"}) signature_input, signature = _rfc9421.sign( @@ -122,39 +83,26 @@ def test_verify_chains_the_component_error_and_the_crypto_error(): 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", + + def verify(headers): + return _rfc9421.verify( + _rfc9421.Request("POST", "https://example.com/p", headers), + label="sig1", + tag="t", + alg="ed25519", + public_key=key.public_key(), + window_checked=True, + parameters=("created", "expires", "keyid", "alg", "nonce", "tag"), ) - from cryptography.exceptions import InvalidSignature as CryptoInvalidSignature - assert isinstance(tampered.value.__cause__, CryptoInvalidSignature) + with pytest.raises(_rfc9421.SignatureBaseError): + verify(fields) + with pytest.raises(_rfc9421.SignatureMismatchError): + verify({**fields, "x-a": "2"}) # 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") + assert verify({**fields, "x-a": "1"})['"x-a"'] == "1" def test_http_sf_reports_every_duplicate(): @@ -189,25 +137,6 @@ def test_http_sf_primitives_the_profile_selection_walk_uses(): ) -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 @@ -225,6 +154,37 @@ def test_verify_requires_the_caller_to_have_checked_the_window(): tag="t", alg="ed25519", public_key=None, - resolver=_rfc9421.ComponentResolver, window_checked=False, ) + + +@pytest.mark.parametrize("query", ["a={b}", "a=b|c"]) +def test_a_url_with_a_raw_non_uri_character_is_refused_with_the_fix(query): + """``http-message-signatures`` parses the URL per RFC 3986 and refuses ``{``, ``}``, ``|``. + + Both signers say to percent-encode the URL, as ``httpx`` does for ``params=``. + """ + from adcp.signing import sign_request, sign_wba_request + + key = ed25519.Ed25519PrivateKey.generate() + url = f"https://seller.example.com/mcp?{query}" + with pytest.raises(ValueError, match="Percent-encode"): + sign_request( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=b"{}", + private_key=key, + key_id="k", + alg="ed25519", + signing_profile_version="3.2", + ) + with pytest.raises(ValueError, match="Percent-encode"): + sign_wba_request( + method="GET", + url=url, + headers={}, + body=b"", + private_key=key, + agent_origin="https://buyer.example.com", + ) diff --git a/tests/conformance/signing/test_verifier_behaviors.py b/tests/conformance/signing/test_verifier_behaviors.py index 8c46fea9f..2fd18054a 100644 --- a/tests/conformance/signing/test_verifier_behaviors.py +++ b/tests/conformance/signing/test_verifier_behaviors.py @@ -77,35 +77,61 @@ def _options( # ---- 1e: downstream ValueError/KeyError wrapping ---- -def test_unsupported_derived_component_wraps_as_header_malformed() -> None: - # Craft Signature-Input declaring @path, which the AdCP profile does not - # support — build_signature_base raises ValueError. The verifier must wrap - # that as REQUEST_SIGNATURE_HEADER_MALFORMED rather than bubbling it up. +def test_an_extra_covered_derived_component_verifies() -> None: + """A signature may cover a derived component beyond the required three. + + The AdCP 3.2 checklist (security.mdx step 6) requires ``@method``, + ``@target-uri``, and ``@authority``, plus the body components, and + refuses nothing extra. ``http-message-signatures`` resolves ``@path``, + so a real signature that covers it verifies. + """ + from datetime import datetime, timezone + + from http_message_signatures import HTTPMessageSigner, HTTPSignatureKeyResolver + from http_message_signatures.algorithms import ED25519 + + from adcp.signing.canonical import signing_message + from adcp.signing.digest import compute_content_digest_sha256 + + class Key(HTTPSignatureKeyResolver): + def resolve_private_key(self, key_id): + return private_key_from_jwk(ED25519_KEY, d_field="_private_d_for_test_only") + url = "https://seller.example.com/adcp/create_media_buy" created = 1776520800 - expires = created + 300 - sig_input = ( - '("@method" "@target-uri" "@authority" "@path");' - f"created={created};expires={expires};" - 'nonce="a";keyid="test-ed25519-2026";alg="ed25519";' - 'tag="adcp/request-signing/v1"' - ) - # No Content-Type header — @path is the only novel component so it reaches - # build_signature_base (step 6) before component-set checks short-circuit. + body = b"{}" headers = { - "Signature-Input": f"sig1={sig_input}", - "Signature": "sig1=:AAAA:", + "Content-Type": "application/json", + "Content-Digest": compute_content_digest_sha256(body), } - with pytest.raises(SignatureVerificationError) as exc: - verify_request_signature( - method="POST", - url=url, - headers=headers, - body=b"{}", - options=_options(), - ) - assert exc.value.code == REQUEST_SIGNATURE_HEADER_MALFORMED - assert exc.value.step == 6 + message = signing_message("POST", url, headers) + HTTPMessageSigner(signature_algorithm=ED25519, key_resolver=Key()).sign( + message, + key_id="test-ed25519-2026", + created=datetime.fromtimestamp(created, timezone.utc), + expires=datetime.fromtimestamp(created + 300, timezone.utc), + nonce="KXYnfEfJ0PBRZXQyVXfVQA", + label="sig1", + tag="adcp/request-signing/v1", + covered_component_ids=( + "@method", + "@target-uri", + "@authority", + "@path", + "content-type", + "content-digest", + ), + ) + assert '"@path"' in message.headers["Signature-Input"] + + signer = verify_request_signature( + method="POST", + url=url, + headers={**headers, **{k: message.headers[k] for k in ("Signature-Input", "Signature")}}, + body=body, + options=_options(now=created), + ) + assert signer.key_id == "test-ed25519-2026" def test_missing_covered_header_wraps_as_header_malformed() -> None: diff --git a/tests/conformance/signing/test_wba_governance.py b/tests/conformance/signing/test_wba_governance.py new file mode 100644 index 000000000..70ab826ba --- /dev/null +++ b/tests/conformance/signing/test_wba_governance.py @@ -0,0 +1,181 @@ +"""The governance-token issuer check that runs after Web Bot Auth verification. + +``verify_wba_governance_issuer`` checks that a governance token's key is in +the ``iss`` origin's key directory and that ``iss`` is the buyer's governance +agent. It is an AdCP-layer check, not part of the WBA profile, so its vectors +live apart from the profile's (``vectors/wba-governance``). +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +from adcp.signing.crypto import private_key_from_jwk +from adcp.signing.wba import WbaGovernanceError, verify_wba_governance_issuer +from tests.conformance.signing.test_wba_profile import _vector_resolver + +ROOT = Path(__file__).parents[1] / "vectors" / "wba-governance" +KEYS = {key["kid"]: key for key in json.loads((ROOT / "keys.json").read_text())["keys"]} +VECTORS = sorted(ROOT.glob("0*.json")) +GOVERNANCE_KID = "1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE" + + +def test_vendored_vectors_match_recorded_provenance(): + upstream = json.loads((ROOT / "UPSTREAM.json").read_text()) + assert upstream["commit"] == "e24eeb090c4365707641099cbbb45f9945c6d6e0" + vendored = {path.name for path in ROOT.iterdir() if path.name != "UPSTREAM.json"} + assert vendored == set(upstream["sha256"]) + for name, digest in upstream["sha256"].items(): + assert hashlib.sha256((ROOT / name).read_bytes()).hexdigest() == digest + + +@pytest.mark.parametrize("path", VECTORS, ids=lambda p: p.name) +async def test_governance_vectors(path): + vector = json.loads(path.read_text()) + + async def verify(): + return await verify_wba_governance_issuer( + vector["jws"], + signer_keys=_vector_resolver(vector, KEYS), + 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 _governance_token(header_extra=None, claims_extra=None, *, iss=None): + from joserfc import jws as jose_jws + from joserfc.jwk import OKPKey + + from adcp.governance import _governance_jws_registry + + identity = iss or KEYS[GOVERNANCE_KID]["identity"] + header = { + "alg": "EdDSA", + "typ": "adcp-gov+jws", + "jku": identity + "/.well-known/http-message-signatures-directory", + "kid": GOVERNANCE_KID, + **(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[GOVERNANCE_KID], d_field="_private_d_for_test_only") + return jose_jws.serialize_compact( + header, + json.dumps(claims).encode(), + OKPKey.import_key(private), + registry=_governance_jws_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).""" + vector = json.loads((ROOT / "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(header, claims) + + async def verify(): + return await verify_wba_governance_issuer( + token, + signer_keys=_vector_resolver(vector, KEYS), + 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(): + token = _governance_token(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" + + +async def test_governance_alg_outside_the_profile_is_refused_before_key_discovery(): + """Only Ed25519 signs under the profile, so a PS512 token never reaches discovery.""" + from adcp.signing.crypto import b64url_encode + + identity = KEYS[GOVERNANCE_KID]["identity"] + header = { + "alg": "PS512", + "typ": "adcp-gov+jws", + "jku": identity + "/.well-known/http-message-signatures-directory", + "kid": GOVERNANCE_KID, + } + claims = {"iss": identity, "aud": "https://seller-7k3q.com/mcp/", "iat": 1000, "exp": 1600} + token = ".".join( + b64url_encode(part) + for part in (json.dumps(header).encode(), json.dumps(claims).encode(), b"\x00" * 256) + ) + + async def resolve(keyid, *, signer_origin=None): + pytest.fail("a token alg the profile doesn't sign with must not trigger key discovery") + + with pytest.raises(WbaGovernanceError) as caught: + await verify_wba_governance_issuer( + token, signer_keys=resolve, governance_agents=[identity], now=1100 + ) + assert caught.value.code == "governance_token_invalid" + assert caught.value.reason == "governance token alg not allowed" + + +@pytest.mark.parametrize("alg", [["EdDSA"], {"EdDSA": True}], ids=["list", "object"]) +async def test_a_non_string_governance_alg_is_an_invalid_token(alg): + """A header ``alg`` that isn't a string raises WbaGovernanceError, not TypeError.""" + from adcp.signing.crypto import b64url_encode + + identity = KEYS[GOVERNANCE_KID]["identity"] + header = { + "alg": alg, + "typ": "adcp-gov+jws", + "jku": identity + "/.well-known/http-message-signatures-directory", + "kid": GOVERNANCE_KID, + } + claims = {"iss": identity, "iat": 1000, "exp": 1600} + token = ".".join( + b64url_encode(part) + for part in (json.dumps(header).encode(), json.dumps(claims).encode(), b"\x00" * 64) + ) + + async def resolve(keyid, *, signer_origin=None): + pytest.fail("a malformed alg must not trigger key discovery") + + with pytest.raises(WbaGovernanceError) as caught: + await verify_wba_governance_issuer( + token, signer_keys=resolve, governance_agents=[identity], now=1100 + ) + assert caught.value.code == "governance_token_invalid" diff --git a/tests/conformance/signing/test_wba_interop.py b/tests/conformance/signing/test_wba_interop.py index a02812968..08d12fbee 100644 --- a/tests/conformance/signing/test_wba_interop.py +++ b/tests/conformance/signing/test_wba_interop.py @@ -3,7 +3,8 @@ The fixtures under ``vectors/wba-interop`` were generated by adcp-client with Cloudflare's ``web-bot-auth@0.2.0`` and ``http-message-sig@0.3.0`` (see the README there). The RSA-PSS vector is draft-ietf-webbotauth-httpsig-protocol -Appendix E.1.1, signed with RFC 9421's ``test-key-rsa-pss``. +Appendix E.1.1, signed with RFC 9421's ``test-key-rsa-pss``; the profile signs +with ed25519 only, so it is refused. """ from __future__ import annotations @@ -15,19 +16,17 @@ import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa +from joserfc.jwk import RSAKey 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, _signature_base, _verify_crypto, is_64_byte_nonce, - public_directory_key, sign_key_directory, sign_wba_request, thumbprint_key_id, @@ -184,23 +183,16 @@ async def fetch(url, *, allow_private=False): ) -def _rsa_jwk(public: rsa.RSAPublicKey) -> dict[str, str]: - from adcp.signing.crypto import b64url_encode +def test_rsa_pss_appendix_e_vector_is_not_a_web_bot_auth_signature(): + """The draft's Appendix E vector: the base matches, and its RSA key is refused. - numbers = public.public_numbers() - return { - "kty": "RSA", - "n": b64url_encode(numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, "big")), - "e": b64url_encode(numbers.e.to_bytes((numbers.e.bit_length() + 7) // 8, "big")), - } - - -def test_rsa_pss_appendix_e_vector(): + The profile signs with ed25519 only, so an RSA-PSS key never verifies a + Web Bot Auth signature. + """ public = serialization.load_pem_public_key(RFC9421_TEST_KEY_RSA_PSS) assert isinstance(public, rsa.RSAPublicKey) - jwk = _rsa_jwk(public) + jwk = RSAKey.import_key(public).as_dict(private=False) 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" headers = { "signature-agent": 'agent2="https://signature-agent.test"', @@ -214,53 +206,39 @@ def test_rsa_pss_appendix_e_vector(): f'"@signature-params": {APPENDIX_E_INPUT.removeprefix("sig2=")}' ) _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(tampered, label="sig2", params=params, signature=signature, jwk=jwk) - - -async def test_rsa_pss_identity_round_trip_and_key_bounds(): - private = rsa.generate_private_key(public_exponent=65537, key_size=2048) - origin = "https://buyer.example.com" - directory = sign_key_directory(agent_origin=origin, private_keys=[private], created=1000) - assert 'alg="rsa-pss-sha512"' in directory.headers["Signature-Input"] - body = b'{"jsonrpc":"2.0","id":1,"method":"tools/list"}' - headers = {"content-type": "application/json"} - headers.update( - sign_wba_request( - method="POST", - url="https://seller.example.com/mcp", - headers=headers, - body=body, - private_key=private, - agent_origin=origin, - created=1000, - ) - ) - - async def fetch(url, *, allow_private=False): - return directory - - (signer,) = await verify_wba_request( - method="POST", - url="https://seller.example.com/mcp", - headers=headers, - body=body, - signer_keys=DiscoveredSignerKeys(fetcher=fetch, clock=lambda: 1001), - replay_store=InMemoryReplayStore(), - now=1001, - ) - assert signer.alg == ALG_RSA_PSS_SHA512 - small = rsa.generate_private_key(public_exponent=65537, key_size=1024) - with pytest.raises(ValueError, match="2048"): - public_directory_key(small) - with pytest.raises(WbaVerificationError): - _algorithm(_rsa_jwk(small.public_key())) - with pytest.raises(WbaVerificationError): - _algorithm({**public_directory_key(private), "e": "AQAA"}) # even exponent + with pytest.raises(WbaVerificationError, match="unsupported directory key"): + _verify_crypto(message, label="sig2", params=params, jwk=jwk) + + +@pytest.mark.parametrize( + ("error", "refusal"), + [ + (WbaVerificationError("refused", status=403), True), + (ValueError("the SDK refuses to sign"), True), + (TypeError("a bug in the SDK"), False), + (KeyError("request"), False), + ], + ids=["verification-refusal", "signing-refusal", "type-error", "key-error"], +) +async def test_the_interop_adapter_reports_only_sdk_refusals(monkeypatch, error, refusal): + """The harness grades a refusal; any other exception crashes the adapter. + + ``WbaVerificationError`` and ``ValueError`` are how the SDK refuses. Any + other exception is a defect, so the adapter lets it propagate instead of + reporting it as ``ok: false``, which the harness would grade as a correct + refusal. + """ + from tests.wba_interop import adapter + + async def operation(op): + raise error + + monkeypatch.setitem(adapter.OPERATIONS, "probe", operation) + if refusal: + result = await adapter.run({"op": "probe"}) + assert result["ok"] is False + assert result["error"] == str(error) + return + with pytest.raises(type(error)): + await adapter.run({"op": "probe"}) diff --git a/tests/conformance/signing/test_wba_profile.py b/tests/conformance/signing/test_wba_profile.py index 0dd686208..61f93d77d 100644 --- a/tests/conformance/signing/test_wba_profile.py +++ b/tests/conformance/signing/test_wba_profile.py @@ -29,6 +29,7 @@ SigningConfig, StaticSignerKeys, WbaVerificationError, + _rfc9421, canonical_origin, public_directory_key, sign_key_directory, @@ -43,24 +44,22 @@ 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, keys=KEYS): + """Resolve a vector's keys from the directories it lists, by thumbprint.""" - -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 + 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 return resolve def test_vendored_vectors_match_recorded_provenance(): upstream = json.loads((ROOT / "UPSTREAM.json").read_text()) - assert upstream["commit"] == "e24eeb090c4365707641099cbbb45f9945c6d6e0" + assert upstream["commit"] == "e59294701d733b0ea2a004a2f0c091c8eccdcaae" vendored = { str(path.relative_to(ROOT)) for path in ROOT.rglob("*") @@ -219,29 +218,6 @@ def test_profile_vector_signature_bases(path): 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 @@ -628,43 +604,55 @@ async def fetch(url, *, allow_private=False): await resolver(jwk["kid"], signer_origin="https://legacy.com") -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 +async def test_signature_without_alg_is_refused(monkeypatch): + """Every signature names ``alg``, and it must match the key (Profile rules).""" + from http_message_signatures import HTTPMessageSigner - from adcp.signing.crypto import b64url_encode + sign = HTTPMessageSigner.sign - ed_key, ec_key = ed25519.Ed25519PrivateKey.generate(), ec.generate_private_key(ec.SECP256R1()) - 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( + def sign_without_alg(self, message, **kwargs): + return sign(self, message, **kwargs, include_alg=False) + + key = ed25519.Ed25519PrivateKey.generate() + signed_directory = sign_key_directory( + agent_origin="https://buyer.com", private_keys=[key], created=1000 + ) + headers = {"Content-Type": "application/json"} + with monkeypatch.context() as patched: + patched.setattr(HTTPMessageSigner, "sign", sign_without_alg) + 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, + ) + ) + directory = sign_key_directory( + agent_origin="https://buyer.com", private_keys=[key], created=1000 + ) + assert "alg=" not in headers["Signature-Input"] + assert "alg=" not in directory.headers["Signature-Input"] + with pytest.raises(WbaVerificationError, match="algorithm"): + verify_directory_response("https://buyer.com", directory, now=1001) + + async def fetch(url, *, allow_private=False): + return signed_directory + + with pytest.raises(WbaVerificationError, match="algorithm") as refused: + await verify_wba_request( method="POST", url="https://seller.com/mcp", - headers={"Content-Type": "application/json"}, + headers=headers, body=b"{}", - private_key=ec_key, - agent_origin="https://buyer.com", - created=1000, + signer_keys=DiscoveredSignerKeys(fetcher=fetch, clock=lambda: 1001), + replay_store=InMemoryReplayStore(), + now=1001, ) - # 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 - ) - 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"]] + assert refused.value.status == 403 async def test_unsigned_directory_is_refused(): @@ -785,17 +773,24 @@ async def verify(): 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.""" +def _sign_vector_request(*, nonce="A" * 86, signature_agent=None): + """The positive/001 request, re-signed by the library with ``nonce``. + + ``signature_agent`` replaces the covered Signature-Agent field before + signing, so a shape the SDK signer never emits still carries a valid + 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"} + headers = { + k: v for k, v in request["headers"].items() if k not in {"Signature-Input", "Signature"} + } + if signature_agent is not None: + headers["Signature-Agent"] = signature_agent signature_input, signature = _rfc9421.sign( _rfc9421.Request(request["method"], request["url"], dict(headers)), alg="ed25519", @@ -804,7 +799,7 @@ async def test_short_received_nonce_is_a_challenge(): label="sig1", created=1790841600, expires=1790841900, - nonce="A" * 22, + nonce=nonce, tag="web-bot-auth", covered_component_ids=[ '"@method"', @@ -814,23 +809,160 @@ async def test_short_received_nonce_is_a_challenge(): '"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( + + async def verify(headers=headers, signer_keys=None): + return await verify_wba_request( method=request["method"], url=request["url"], headers=headers, body=request["body"].encode(), - signer_keys=_vector_resolver(vector), + signer_keys=signer_keys or _vector_resolver(vector), replay_store=InMemoryReplayStore(), now=vector["reference_now"], ) + + return headers, verify + + +async def test_short_received_nonce_is_a_challenge(): + """A nonce shorter than 64 bytes fails the profile: 403 with Accept-Signature.""" + _headers, verify = _sign_vector_request() + (signer,) = await verify() + assert signer.agent_url == "https://buyer-7k3q.com" + _headers, verify = _sign_vector_request(nonce="A" * 22) + with pytest.raises(WbaVerificationError, match="nonce") as caught: + await verify() assert caught.value.status == 403 assert "Accept-Signature" in caught.value.headers +@pytest.mark.parametrize( + "value", + [ + "http://buyer-7k3q.com", + "https://buyer-7k3q.com/agents/a", + "https://buyer-7k3q.com/", + "https://BUYER-7k3q.com", + "https://buyer-7k3q.com:443", + "https://buyer-7k3q.com?x=1", + "https://buyer-7k3q.com#f", + "https://agent@buyer-7k3q.com", + ], +) +async def test_signature_agent_is_exactly_the_canonical_https_origin(value): + """The member value is the serialized https origin, compared as a string. + + A lowercase host, no default port, no trailing ``/``, and no path, query, + fragment, or userinfo. Anything else is 403 before key discovery, even + when the origin it normalizes to serves the key. + """ + headers, verify = _sign_vector_request() + assert headers["Signature-Agent"] == 'sig1="https://buyer-7k3q.com"' + (signer,) = await verify() + assert signer.agent_url == "https://buyer-7k3q.com" + fetched = [] + + async def record(keyid, *, signer_origin=None): + fetched.append(signer_origin) + + _headers, verify = _sign_vector_request(signature_agent=f'sig1="{value}"') + with pytest.raises(WbaVerificationError, match="origin") as caught: + await verify(signer_keys=record) + assert caught.value.status == 403 + assert "Accept-Signature" in caught.value.headers + assert fetched == [] + + +def test_signer_sends_the_canonical_origin(): + key = KEYS["6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I"] + headers = sign_wba_request( + method="GET", + url="https://seller-7k3q.com/mcp/", + headers={}, + body=b"", + private_key=private_key_from_jwk(key, d_field="_private_d_for_test_only"), + agent_origin="https://BUYER-7k3q.com:443/", + created=1790841600, + ) + assert headers["Signature-Agent"] == 'sig1="https://buyer-7k3q.com"' + + +@pytest.mark.parametrize( + ("parameter", "verifies"), + [ + ("type=directory", True), + ('type="directory"', True), + ("type=jwks_uri", False), + ('type="jwks_uri"', False), + ("type=cimd", False), + ], +) +async def test_signature_agent_member_type_must_be_directory(parameter, verifies): + """A ``type`` other than ``directory`` makes the verifier ignore the member. + + ``directory`` is the -00 section 5.2.1 type, as a Token or as a String, + as Cloudflare's parser reads it. An ignored member leaves the signature + without a member keyed to its label: 403 with Accept-Signature. + """ + _headers, verify = _sign_vector_request( + signature_agent=f'sig1="https://buyer-7k3q.com";{parameter}' + ) + if verifies: + (signer,) = await verify() + assert signer.agent_url == "https://buyer-7k3q.com" + return + with pytest.raises(WbaVerificationError, match="Signature-Agent member") as caught: + await verify() + assert caught.value.status == 403 + assert "Accept-Signature" in caught.value.headers + + +@pytest.mark.parametrize( + ("cdn_input", "cdn_signature", "verifies"), + [ + ('("@method" "@authority");tag="cdn-edge"', ":AAAA:", True), + ('("@method" "@authority");tag="cdn-edge"', "edge-token", False), + ('("@method" "@authority");tag="cdn-edge"', '"edge"', False), + ('("@method" "@authority");tag="cdn-edge"', ":AAAA:;x=1", False), + ('("@method" "@authority");tag="cdn-edge"', "(1 2)", False), + ('edge-token;tag="cdn-edge"', ":AAAA:", False), + ("edge-token", ":AAAA:", False), + ], +) +async def test_every_signature_field_member_is_well_formed(cdn_input, cdn_signature, verifies): + """RFC 9421 sections 4.1 and 4.2: the shape of every member, read or not. + + Each ``Signature-Input`` member is an inner list, and each ``Signature`` + member a Byte Sequence with no parameters. A member with another tag is + not read, but the fields still parse: a malformed member is 400. + """ + headers, verify = _sign_vector_request() + appended = { + **headers, + "Signature-Input": f"{headers['Signature-Input']}, cdn={cdn_input}", + "Signature": f"{headers['Signature']}, cdn={cdn_signature}", + } + if verifies: + (signer,) = await verify(appended) + assert signer.agent_url == "https://buyer-7k3q.com" + return + with pytest.raises(WbaVerificationError, match="malformed signature field member") as caught: + await verify(appended) + assert caught.value.status == 400 + + +@pytest.mark.parametrize("field", ["Signature-Input", "Signature"]) +async def test_a_malformed_web_bot_auth_member_is_400(field): + """The signature's own member, malformed, is a malformed field: 400.""" + headers, verify = _sign_vector_request() + malformed = {"Signature-Input": 'sig1=token;tag="web-bot-auth"', "Signature": "sig1=token"} + with pytest.raises(WbaVerificationError, match="malformed signature field member") as caught: + await verify({**headers, field: malformed[field]}) + assert caught.value.status == 400 + + @pytest.mark.parametrize( ("revocation_state", "operation"), [ @@ -945,80 +1077,3 @@ 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" diff --git a/tests/conformance/vectors/wba-profile/governance/001-governance-token.json b/tests/conformance/vectors/wba-governance/001-governance-token.json similarity index 100% rename from tests/conformance/vectors/wba-profile/governance/001-governance-token.json rename to tests/conformance/vectors/wba-governance/001-governance-token.json diff --git a/tests/conformance/vectors/wba-profile/governance/002-governance-token-key-not-in-issuer-directory.json b/tests/conformance/vectors/wba-governance/002-governance-token-key-not-in-issuer-directory.json similarity index 100% rename from tests/conformance/vectors/wba-profile/governance/002-governance-token-key-not-in-issuer-directory.json rename to tests/conformance/vectors/wba-governance/002-governance-token-key-not-in-issuer-directory.json diff --git a/tests/conformance/vectors/wba-profile/governance/003-governance-token-from-non-governance-agent.json b/tests/conformance/vectors/wba-governance/003-governance-token-from-non-governance-agent.json similarity index 100% rename from tests/conformance/vectors/wba-profile/governance/003-governance-token-from-non-governance-agent.json rename to tests/conformance/vectors/wba-governance/003-governance-token-from-non-governance-agent.json diff --git a/tests/conformance/vectors/wba-governance/UPSTREAM.json b/tests/conformance/vectors/wba-governance/UPSTREAM.json new file mode 100644 index 000000000..0ec4fc080 --- /dev/null +++ b/tests/conformance/vectors/wba-governance/UPSTREAM.json @@ -0,0 +1,19 @@ +{ + "repository": "adcontextprotocol/adcp", + "pull_request": 7894, + "commit": "e24eeb090c4365707641099cbbb45f9945c6d6e0", + "path": "static/compliance/source/test-vectors/wba-profile/", + "vendored_on": "2026-10-16", + "renamed": { + "governance/001-governance-token.json": "001-governance-token.json", + "governance/002-governance-token-key-not-in-issuer-directory.json": "002-governance-token-key-not-in-issuer-directory.json", + "governance/003-governance-token-from-non-governance-agent.json": "003-governance-token-from-non-governance-agent.json" + }, + "$comment": "The Web Bot Auth profile vectors at adcp 49b3a315 no longer carry these governance vectors. They grade verify_wba_governance_issuer, an AdCP-layer check after WBA verification, until the R4 erratum publishes its own vectors.", + "sha256": { + "001-governance-token.json": "8e3a53bb9fa52b376d242ba67e0a86246af2fb97b44a9407212f65e506ed213b", + "002-governance-token-key-not-in-issuer-directory.json": "8559d3aab6a1db4f2e8afe6d81f36a6e2e54b262464d6179ee04d6846ec9d557", + "003-governance-token-from-non-governance-agent.json": "cbf328b7b7fe63df938da84ab86a94310f3ea9c232da99323cc32fbcee29e65b", + "keys.json": "08637bc288547315e6ce72e895d18ff5f3ab20a5f24375a15d0ebda43a993b3c" + } +} diff --git a/tests/conformance/vectors/wba-governance/keys.json b/tests/conformance/vectors/wba-governance/keys.json new file mode 100644 index 000000000..f66c718ab --- /dev/null +++ b/tests/conformance/vectors/wba-governance/keys.json @@ -0,0 +1,35 @@ +{ + "$comment": "Public test keys for the Web Bot Auth profile conformance vectors. The private seeds are published so that implementations can reproduce every signature. Each seed is SHA-256 of the ASCII string 'rfc004-test/'. Web Bot Auth section 6.8: test keys MUST NOT be used in production, and verifiers SHOULD reject them when detected. Each kid is the RFC 7638 JWK thumbprint (RFC 8037 Appendix A.3 for Ed25519). Each entry's identity is the origin whose key directory publishes the key.", + "keys": [ + { + "identity": "https://buyer-7k3q.com", + "seed_name": "buyer-request", + "kty": "OKP", + "crv": "Ed25519", + "kid": "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I", + "x": "yIxM3SIY89_Yhr6EA9hik-AJjWSDPraK5chDqQ3Xlf0", + "use": "sig", + "_private_d_for_test_only": "qgZV0LEd-ZDGdPdhM182B1YmHy0OAsNAEZT-feYyfbU" + }, + { + "identity": "https://agent.brand-7k3q.com", + "seed_name": "brand-request", + "kty": "OKP", + "crv": "Ed25519", + "kid": "hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI", + "x": "JB8XPw49dSvDh0kqkCxtoxiNb_RZFhxPdlc-1GedFSc", + "use": "sig", + "_private_d_for_test_only": "mu0nHxQDWktUz0o16gWmACuOZ86HpAwfmO4iP1qApp8" + }, + { + "identity": "https://governance-7k3q.com", + "seed_name": "governance-document", + "kty": "OKP", + "crv": "Ed25519", + "kid": "1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE", + "x": "k-Q_ekTZQ_Hv6lLlIUYfE7jpJSCZ5nsLDfeoLIAkrL4", + "use": "sig", + "_private_d_for_test_only": "nfv0d9-TrrE1ajrAPEK-2yPoG9PHHbex-aqnv4xVnKQ" + } + ] +} diff --git a/tests/conformance/vectors/wba-profile/README.md b/tests/conformance/vectors/wba-profile/README.md index d182feff9..5b412ce90 100644 --- a/tests/conformance/vectors/wba-profile/README.md +++ b/tests/conformance/vectors/wba-profile/README.md @@ -1,12 +1,14 @@ # Web Bot Auth profile vectors Copied without modification from adcontextprotocol/adcp PR #7894, commit -`e24eeb090c4365707641099cbbb45f9945c6d6e0`, -`static/compliance/source/test-vectors/wba-profile/`, on 2026-10-16. The +`e59294701d733b0ea2a004a2f0c091c8eccdcaae`, +`static/compliance/source/test-vectors/wba-profile/`, on 2026-10-19. 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. 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. +test inputs only. `scenarios.json` holds the failure scenarios that the AdCP +interop harness (`tests/wba-interop`) signs at run time; this SDK's CI grades +them through `tests/wba_interop/adapter.py`. diff --git a/tests/conformance/vectors/wba-profile/UPSTREAM-README.md b/tests/conformance/vectors/wba-profile/UPSTREAM-README.md index 6aefc5dd8..7e779338c 100644 --- a/tests/conformance/vectors/wba-profile/UPSTREAM-README.md +++ b/tests/conformance/vectors/wba-profile/UPSTREAM-README.md @@ -4,40 +4,37 @@ Test vectors for the proposed AdCP profile of Web Bot Auth (WBA) for request and 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. +**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`, `scenarios.json`, `positive/*.json`, and `negative/*.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. +**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` or `hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI` 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. +The domains in the vectors (`buyer-7k3q.com`, `agent.brand-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. +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, and the `400`, `403`, and `429` responses. JWS artifacts, such as governance tokens, are outside the profile and have no vectors here. The vectors 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 +├── keys.json two Ed25519 test keys, one per identity, with private seeds +├── scenarios.json failure scenarios as data, signed by a harness at run time ├── 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 +└── negative/ requests a verifier MUST reject, with the expected status + ├── 001-missing-nonce.json → 403 (nonce required on every signed request) + ├── 002-replayed-nonce.json → 429 (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) ``` ## Vector format @@ -98,21 +95,21 @@ Every request vector has this shape: - **`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.accept_signature`**: negative vectors with `403` or `429`. The `Accept-Signature` member that the AdCP layer SHOULD send ([AdCP checks after Web Bot Auth verification](https://adcontextprotocol.org/docs/building/by-layer/L1/security#adcp-checks-after-web-bot-auth-verification); 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 +### Failure scenarios -Each vector under `governance/` carries a compact JWS instead of a request: +`scenarios.json` holds failure scenarios as data instead of signed requests. A harness signs each scenario at run time with the reference library, so no committed signature expires, and grades the verifier on the scenario's expected outcome. The file has the following fields: -- **`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. +- **`signer`**: the identity and `kid` from `keys.json` that sign every scenario. +- **`requests`**, **`components`**, and **`parameters`**: the base requests (`post` and `get`), the components each one covers, and the default signature parameters. `{label}` in a component stands for the signature's label. `created` and `expires` are offsets in seconds from the harness's clock. A `nonce` object asks for that many random bytes in that encoding: `base64url` (unpadded), `base64` (canonical padded), or `base64url-padded`. A `base64url-padded` value contains at least one `-` or `_`; without one, it is also canonical padded base64, which the profile accepts. +- **`request_scenarios`**: each entry names a `base` request and overrides any of `label`, `components`, `parameters` (a `null` value omits the parameter), and `signature_agent` (the header value). `extra_signature` adds a second signature with its own label, key, components, and parameters. `after_signing` changes `headers`, appends `append_to_body` to the body, or appends raw `append_members` to `Signature-Input` and `Signature` after signing. `directories` lists the `kid` values each origin serves; it defaults to the signer's own directory. +- **`directory_scenarios`**: each entry overrides how the signer's directory response is signed: `keys` (the kids that sign), `unsigned_keys` (public keys in the body that do not sign), `authority`, `parameters` (including `tag`), `components`, `content_type`, `status` (the HTTP status the directory is served with, `200` by default), `publish_private_members` (`true` publishes each signing key with its private members), and `after_signing`. +- **`expected`**: `success` and `identity` for a scenario that verifies, or `success: false` and the `status` for a request scenario that a verifier rejects. A directory scenario that a verifier rejects has no status of its own: the request that needed its key gets `403`. +- **`tier`**: `wba` when the reference library alone, with no AdCP rules, gives the expected outcome; `adcp` when the bare library gives the opposite outcome and only the profile's rules give the expected one. A WBA MUST that the library does not enforce, such as an `https` origin (`R16`), is `adcp` until the library enforces it. A harness checks every tier against the bare library on every run. +- **`basis`**: the rule the scenario grades. Informational. ## Conformance expectations @@ -120,11 +117,11 @@ 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`. +3. **Negative vectors** answer `expected_outcome.status`. `Accept-Signature` is an AdCP-layer SHOULD, and conformance does not grade it. +4. **Failure scenarios** answer `expected` for every entry in `scenarios.json`. 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. +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. A verifier that accepts any of them has skipped a profile rule, not failed at cryptography. ## Running vectors against an implementation @@ -134,13 +131,13 @@ Several negative vectors carry cryptographically valid signatures on purpose. `0 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. +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). ## 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. +`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`. 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 @@ -151,4 +148,4 @@ Every added vector MUST: 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. +6. Come from `scripts/generate-wba-profile-vectors.mjs`. A negative vector also needs an entry in `TARGETS` 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 index 141e1afa1..903e895d1 100644 --- a/tests/conformance/vectors/wba-profile/UPSTREAM.json +++ b/tests/conformance/vectors/wba-profile/UPSTREAM.json @@ -1,25 +1,23 @@ { "repository": "adcontextprotocol/adcp", "pull_request": 7894, - "commit": "e24eeb090c4365707641099cbbb45f9945c6d6e0", + "commit": "e59294701d733b0ea2a004a2f0c091c8eccdcaae", "path": "static/compliance/source/test-vectors/wba-profile/", - "vendored_on": "2026-10-16", + "vendored_on": "2026-10-19", "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", + "UPSTREAM-README.md": "1dfacdbe62e9157435e6b2bf4b714c193bd4f3126a4b0433299c370d8c1b292b", + "keys.json": "257850b14fe217f67596808040e2c7b4f62bd36bf2c25d0712d066733008b100", + "negative/001-missing-nonce.json": "79a9de2bc336a3b12b20e8cb026614646dc84dbbf05d897c6c34808682062acf", + "negative/002-replayed-nonce.json": "51d2ce5c81d8a8e60b99d5a7eaef80f18baba2a104de93d1fc8aeac5c7aaa7be", + "negative/003-signature-agent-not-covered.json": "ec58e8615d3f933e5693ceb310ef50f35d2a39356b238c5cdec28377b72548c8", + "negative/004-key-not-in-named-directory.json": "1d830742a0cc8f3e556abb7734b093c66f81582a7138b639fae29f358e44371b", "negative/005-malformed-signature-agent.json": "c0bf6e2d95be5f47d18a26abbcf0c38b2fb8a229a77d4acc4cad76fecad61f59", - "negative/006-two-signatures.json": "6dc88d4db2205ba7f0611fd9d4e2e6d7106ed45ea9c8238c5a801abb660901a1", + "negative/006-two-signatures.json": "87cb93e1f9bc4fba2eadb0df7832d51c9edf2d0add02aab5cee8265b9d7f1661", "positive/001-single-signer-request.json": "213b69f5f029c6da37abbc4dafff0f89e7604c5a6a4acca59b8c9df4a3d88e25", "positive/002-signed-directory-response.json": "b07b0a947d943225c29fcb819008d080ca83f209d56be785a1a7b15f15e82dbe", - "UPSTREAM-README.md": "0dca00383bd0907bbf0fadfe09fb9787fd7cdfeabbcdc23b52c17038e1976bfd" + "scenarios.json": "0327f40cf31de2dc6cf975d9643a999aee68a225817dd0d409b81a6f92a868e2" } } diff --git a/tests/conformance/vectors/wba-profile/keys.json b/tests/conformance/vectors/wba-profile/keys.json index f66c718ab..2a6750ab3 100644 --- a/tests/conformance/vectors/wba-profile/keys.json +++ b/tests/conformance/vectors/wba-profile/keys.json @@ -20,16 +20,6 @@ "x": "JB8XPw49dSvDh0kqkCxtoxiNb_RZFhxPdlc-1GedFSc", "use": "sig", "_private_d_for_test_only": "mu0nHxQDWktUz0o16gWmACuOZ86HpAwfmO4iP1qApp8" - }, - { - "identity": "https://governance-7k3q.com", - "seed_name": "governance-document", - "kty": "OKP", - "crv": "Ed25519", - "kid": "1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE", - "x": "k-Q_ekTZQ_Hv6lLlIUYfE7jpJSCZ5nsLDfeoLIAkrL4", - "use": "sig", - "_private_d_for_test_only": "nfv0d9-TrrE1ajrAPEK-2yPoG9PHHbex-aqnv4xVnKQ" } ] } 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 eaeb79b53..0196f05d2 100644 --- a/tests/conformance/vectors/wba-profile/negative/001-missing-nonce.json +++ b/tests/conformance/vectors/wba-profile/negative/001-missing-nonce.json @@ -43,5 +43,5 @@ }, "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 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." + "$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, and the Accept-Signature hint lists nonce among the required parameters. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). Conformance grades the status code. On a 403 or 429, the AdCP layer SHOULD add Accept-Signature naming the components and parameters the profile requires, so the signer can sign again (security.mdx, AdCP checks after Web Bot Auth verification); the accept_signature object describes that header, 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 c1887bbe1..6c8d4aba2 100644 --- a/tests/conformance/vectors/wba-profile/negative/002-replayed-nonce.json +++ b/tests/conformance/vectors/wba-profile/negative/002-replayed-nonce.json @@ -54,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 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." + "$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 Accept-Signature hint on the 429 carries 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). Conformance grades the status code. On a 403 or 429, the AdCP layer SHOULD add Accept-Signature naming the components and parameters the profile requires, so the signer can sign again (security.mdx, AdCP checks after Web Bot Auth verification); the accept_signature object describes that header, 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 7465d9db7..0e2bf8d2c 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 @@ -43,5 +43,5 @@ }, "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 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." + "$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). Conformance grades the status code. On a 403 or 429, the AdCP layer SHOULD add Accept-Signature naming the components and parameters the profile requires, so the signer can sign again (security.mdx, AdCP checks after Web Bot Auth verification); the accept_signature object describes that header, and the exact serialization is RFC 9421 section 5.1." } diff --git a/tests/conformance/vectors/wba-profile/negative/004-key-not-in-named-directory.json b/tests/conformance/vectors/wba-profile/negative/004-key-not-in-named-directory.json index ec522dfa5..9682be0ca 100644 --- a/tests/conformance/vectors/wba-profile/negative/004-key-not-in-named-directory.json +++ b/tests/conformance/vectors/wba-profile/negative/004-key-not-in-named-directory.json @@ -48,5 +48,5 @@ }, "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 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." + "$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). Conformance grades the status code. On a 403 or 429, the AdCP layer SHOULD add Accept-Signature naming the components and parameters the profile requires, so the signer can sign again (security.mdx, AdCP checks after Web Bot Auth verification); the accept_signature object describes that header, 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 index dd02f37fe..7c694cd02 100644 --- a/tests/conformance/vectors/wba-profile/negative/006-two-signatures.json +++ b/tests/conformance/vectors/wba-profile/negative/006-two-signatures.json @@ -49,5 +49,5 @@ }, "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." + "$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). Conformance grades the status code. On a 403 or 429, the AdCP layer SHOULD add Accept-Signature naming the components and parameters the profile requires, so the signer can sign again (security.mdx, AdCP checks after Web Bot Auth verification); the accept_signature object describes that header, and the exact serialization is RFC 9421 section 5.1." } diff --git a/tests/conformance/vectors/wba-profile/scenarios.json b/tests/conformance/vectors/wba-profile/scenarios.json new file mode 100644 index 000000000..7c41dd83c --- /dev/null +++ b/tests/conformance/vectors/wba-profile/scenarios.json @@ -0,0 +1,805 @@ +{ + "$comment": "Failure scenarios for the Web Bot Auth profile, as data. A harness signs each request and directory scenario at run time with the reference library (http-message-sig signs any component and parameter set), applies after_signing, and grades the verifier on expected. created and expires are offsets in seconds from the harness clock; a null parameter is omitted; a nonce object asks for that many random bytes in that encoding. tier \"wba\": the reference library with no AdCP policy already gives the expected outcome. tier \"adcp\": the bare library gives the opposite outcome and only the profile rules give the expected one; a WBA MUST the library does not enforce stays \"adcp\" until it does. See README.md.", + "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#profile-rules", + "signing_profile": "web-bot-auth", + "signer": { + "identity": "https://buyer-7k3q.com", + "kid": "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I" + }, + "requests": { + "post": { + "method": "POST", + "url": "https://seller-7k3q.com/mcp/", + "headers": { + "content-type": "application/json" + }, + "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\"}}}}" + }, + "get": { + "method": "GET", + "url": "https://seller-7k3q.com/mcp/tasks?id=a%7Eb&x=1", + "headers": {}, + "body": "" + } + }, + "components": { + "post": [ + "@method", + "@target-uri", + "@authority", + "content-type", + "content-digest", + "signature-agent;key={label}" + ], + "get": [ + "@method", + "@target-uri", + "@authority", + "signature-agent;key={label}" + ] + }, + "parameters": { + "created": -5, + "expires": 295, + "nonce": { + "bytes": 64, + "encoding": "base64url" + }, + "tag": "web-bot-auth" + }, + "request_scenarios": [ + { + "id": "R00", + "name": "Baseline", + "base": "post", + "expected": { + "success": true, + "identity": "https://buyer-7k3q.com" + }, + "tier": "wba", + "basis": "Profile rules" + }, + { + "id": "R01", + "name": "Body changed after signing", + "base": "post", + "after_signing": { + "append_to_body": "\n" + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "Content-Digest matches the body: RFC 9530; WBA section 5.2" + }, + { + "id": "R02", + "name": "Covered header changed after signing", + "base": "post", + "after_signing": { + "headers": { + "content-type": "text/plain" + } + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "wba", + "basis": "RFC 9421 section 3.2" + }, + { + "id": "R03", + "name": "Expired", + "base": "post", + "parameters": { + "created": -400, + "expires": -100 + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "wba", + "basis": "RFC 9421 section 3.2.1" + }, + { + "id": "R04", + "name": "created 120 seconds in the future", + "base": "post", + "parameters": { + "created": 120, + "expires": 400 + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "wba", + "basis": "Profile: 60 seconds of clock skew" + }, + { + "id": "R05", + "name": "created 30 seconds in the future", + "base": "post", + "parameters": { + "created": 30, + "expires": 300 + }, + "expected": { + "success": true, + "identity": "https://buyer-7k3q.com" + }, + "tier": "adcp", + "basis": "Profile: 60 seconds of clock skew; the library allows none by default" + }, + { + "id": "R06", + "name": "Lifetime of 3600 seconds", + "base": "post", + "parameters": { + "created": -5, + "expires": 3595 + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "Profile: expires at most 300 seconds after created" + }, + { + "id": "R07", + "name": "Nonce of 32 bytes", + "base": "post", + "parameters": { + "nonce": { + "bytes": 32, + "encoding": "base64url" + } + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "wba", + "basis": "Profile: 64-byte nonce; validateNonce" + }, + { + "id": "R08", + "name": "Nonce in canonical padded base64", + "base": "post", + "parameters": { + "nonce": { + "bytes": 64, + "encoding": "base64" + } + }, + "expected": { + "success": true, + "identity": "https://buyer-7k3q.com" + }, + "tier": "wba", + "basis": "Profile: unpadded base64url or canonical padded base64" + }, + { + "id": "R09", + "name": "Nonce in padded base64url", + "base": "post", + "parameters": { + "nonce": { + "bytes": 64, + "encoding": "base64url-padded" + } + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "wba", + "basis": "Profile: unpadded base64url or canonical padded base64" + }, + { + "id": "R10", + "name": "No alg parameter", + "base": "post", + "parameters": { + "alg": null + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "wba", + "basis": "Profile: alg required" + }, + { + "id": "R11", + "name": "alg other than ed25519", + "base": "post", + "parameters": { + "alg": "ecdsa-p256-sha256" + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "wba", + "basis": "Profile: alg is ed25519 and matches the key; RFC 9421 section 3.2" + }, + { + "id": "R12", + "name": "GET covering only @authority and the Signature-Agent member", + "base": "get", + "components": [ + "@authority", + "signature-agent;key=sig1" + ], + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "Profile: cover @method, @target-uri, and @authority; WBA section 5.2 needs one of @authority or @target-uri" + }, + { + "id": "R13", + "name": "Body without content-digest covered", + "base": "post", + "components": [ + "@method", + "@target-uri", + "@authority", + "content-type", + "signature-agent;key=sig1" + ], + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "Profile: a request with a body covers content-type and content-digest; WBA section 5.2" + }, + { + "id": "R14", + "name": "tag other than web-bot-auth", + "base": "post", + "parameters": { + "tag": "adcp/request-signing/v1" + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "wba", + "basis": "WBA section 5.2: tag MUST be web-bot-auth" + }, + { + "id": "R15", + "name": "Bare-string Signature-Agent", + "base": "post", + "signature_agent": "\"https://buyer-7k3q.com\"", + "components": [ + "@method", + "@target-uri", + "@authority", + "content-type", + "content-digest", + "signature-agent" + ], + "expected": { + "success": false, + "status": 400 + }, + "tier": "adcp", + "basis": "Profile: dictionary form only, so the field does not parse; WBA section 5.2.1 lets a verifier accept the bare string" + }, + { + "id": "R16", + "name": "http:// Signature-Agent", + "base": "post", + "signature_agent": "sig1=\"http://buyer-7k3q.com\"", + "directories": { + "http://buyer-7k3q.com": [ + "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I" + ] + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "WBA section 5.2.1: the origin MUST be https; the library accepts http (cloudflare/web-bot-auth#162)" + }, + { + "id": "R17", + "name": "Signature-Agent with a path", + "base": "post", + "signature_agent": "sig1=\"https://buyer-7k3q.com/agents/a\"", + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "WBA section 5.5: a directory member MUST be an origin; the library does not check it" + }, + { + "id": "R17c", + "name": "Signature-Agent origin with a trailing /", + "base": "post", + "signature_agent": "sig1=\"https://buyer-7k3q.com/\"", + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "Profile: the value is exactly the serialized origin (RFC 6454 section 6.2), compared without normalizing" + }, + { + "id": "R17d", + "name": "Signature-Agent origin with an uppercase host", + "base": "post", + "signature_agent": "sig1=\"https://BUYER-7k3q.com\"", + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "Profile: the value is exactly the serialized origin, with a lowercase host, compared without normalizing" + }, + { + "id": "R17e", + "name": "Signature-Agent origin with an explicit :443", + "base": "post", + "signature_agent": "sig1=\"https://buyer-7k3q.com:443\"", + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "Profile: the value is exactly the serialized origin, with no default port, compared without normalizing" + }, + { + "id": "R18", + "name": "Signature-Agent member with type=jwks_uri", + "base": "post", + "signature_agent": "sig1=\"https://buyer-7k3q.com\";type=jwks_uri", + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "Profile: only type=directory; WBA section 5.2.1 lets a verifier ignore other types" + }, + { + "id": "R18c", + "name": "Signature-Agent member with type=directory as a Token", + "base": "post", + "signature_agent": "sig1=\"https://buyer-7k3q.com\";type=directory", + "expected": { + "success": true, + "identity": "https://buyer-7k3q.com" + }, + "tier": "wba", + "basis": "WBA section 5.2.1: type is a Token, and directory is the default" + }, + { + "id": "R18d", + "name": "Signature-Agent member with type=\"directory\" as a String", + "base": "post", + "signature_agent": "sig1=\"https://buyer-7k3q.com\";type=\"directory\"", + "expected": { + "success": true, + "identity": "https://buyer-7k3q.com" + }, + "tier": "wba", + "basis": "Profile: a verifier accepts type=directory as a Token or a String, as the reference library does; WBA section 5.2.1 defines a Token" + }, + { + "id": "R19", + "name": "Signature-Agent member keyed differently from the label", + "base": "post", + "signature_agent": "agent1=\"https://buyer-7k3q.com\"", + "components": [ + "@method", + "@target-uri", + "@authority", + "content-type", + "content-digest", + "signature-agent;key=agent1" + ], + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "WBA section 5.2.1: the member keyed to the label; the library does not check it" + }, + { + "id": "R19b", + "name": "Whole Signature-Agent field covered before the member", + "base": "post", + "components": [ + "@method", + "@target-uri", + "@authority", + "content-type", + "content-digest", + "signature-agent", + "signature-agent;key=sig1" + ], + "expected": { + "success": true, + "identity": "https://buyer-7k3q.com" + }, + "tier": "wba", + "basis": "RFC 9421 section 2.1: the field and its member are separate components; WBA section 5.2.1 requires only the member keyed to the label" + }, + { + "id": "R20", + "name": "Second signature with tag cdn-edge", + "base": "post", + "extra_signature": { + "label": "cdn", + "kid": "hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI", + "components": [ + "@method", + "@authority" + ], + "parameters": { + "created": -5, + "expires": 295, + "nonce": null, + "tag": "cdn-edge" + } + }, + "expected": { + "success": true, + "identity": "https://buyer-7k3q.com" + }, + "tier": "adcp", + "basis": "Profile: Signature-Input members with other tags are not read; the library requires a label when a request carries two signatures" + }, + { + "id": "R20b", + "name": "Second member with tag cdn-edge whose Signature value is a token", + "base": "post", + "after_signing": { + "append_members": { + "signature-input": "cdn=(\"@method\" \"@authority\");tag=\"cdn-edge\"", + "signature": "cdn=edge-token" + } + }, + "expected": { + "success": false, + "status": 400 + }, + "tier": "wba", + "basis": "RFC 9421 section 4.2: every Signature member value is a Byte Sequence, so the field is malformed whatever the member's tag; members with other tags are not read, but the field is still parsed" + }, + { + "id": "R21", + "name": "Two Signature-Agent members, one signature", + "base": "post", + "signature_agent": "sig1=\"https://buyer-7k3q.com\", sig2=\"https://agent.brand-7k3q.com\"", + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "Profile: exactly one Signature-Agent member" + }, + { + "id": "R21b", + "name": "Two web-bot-auth signatures that cover one Signature-Agent member", + "base": "post", + "extra_signature": { + "label": "sig2", + "kid": "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I", + "components": [ + "@method", + "@target-uri", + "@authority", + "content-type", + "content-digest", + "signature-agent;key=sig1" + ], + "parameters": { + "created": -5, + "expires": 295, + "nonce": { + "bytes": 64, + "encoding": "base64url" + }, + "tag": "web-bot-auth" + } + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "wba", + "basis": "Profile: exactly one web-bot-auth signature; WBA section 5.2.2 lets a request carry more" + }, + { + "id": "R22", + "name": "Label other than sig1", + "base": "post", + "label": "wba", + "expected": { + "success": true, + "identity": "https://buyer-7k3q.com" + }, + "tier": "wba", + "basis": "RFC 9421: the signer chooses the label" + }, + { + "id": "R23", + "name": "Components in another order", + "base": "post", + "components": [ + "signature-agent;key=sig1", + "@authority", + "content-digest", + "@target-uri", + "content-type", + "@method" + ], + "expected": { + "success": true, + "identity": "https://buyer-7k3q.com" + }, + "tier": "wba", + "basis": "RFC 9421: the order is the signer's" + } + ], + "directory_scenarios": [ + { + "id": "D00", + "name": "Baseline", + "expected": { + "success": true + }, + "tier": "wba", + "basis": "WBA Appendix B.1" + }, + { + "id": "D01", + "name": "Signed for another authority", + "authority": "agent.brand-7k3q.com", + "expected": { + "success": false + }, + "tier": "wba", + "basis": "WBA Appendix B.1: covers \"@authority\";req" + }, + { + "id": "D02", + "name": "Body changed after signing", + "after_signing": { + "append_to_body": "\n" + }, + "expected": { + "success": false + }, + "tier": "adcp", + "basis": "WBA Appendix B.1: the verifier MUST validate Content-Digest; the library does not compare it with the body" + }, + { + "id": "D03", + "name": "Unsigned", + "keys": [], + "unsigned_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "kid": "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I", + "x": "yIxM3SIY89_Yhr6EA9hik-AJjWSDPraK5chDqQ3Xlf0", + "use": "sig" + } + ], + "expected": { + "success": false + }, + "tier": "adcp", + "basis": "Profile: every Ed25519 key signs; WBA Appendix B.1 only RECOMMENDS a signature, and a verifier MAY use an unproven key" + }, + { + "id": "D04", + "name": "Signature created in the future", + "parameters": { + "created": 3600, + "expires": 90000 + }, + "expected": { + "success": false + }, + "tier": "wba", + "basis": "WBA Appendix B.1: reject a future created" + }, + { + "id": "D05", + "name": "Covers \"@authority\";req only", + "components": [ + "@authority;req" + ], + "expected": { + "success": false + }, + "tier": "adcp", + "basis": "WBA Appendix B.1: covers content-digest" + }, + { + "id": "D06", + "name": "Expired signature", + "parameters": { + "created": -7200, + "expires": -3600 + }, + "expected": { + "success": false + }, + "tier": "wba", + "basis": "RFC 9421 section 3.2.1" + }, + { + "id": "D07", + "name": "Extra P-256 key that does not sign", + "unsigned_keys": [ + { + "kty": "EC", + "crv": "P-256", + "x": "ayhQePn14MyJGqrKHsp_A06Z0N6IZoE0-7wbn66qLLc", + "y": "k98dk4yymVze93JE_EpboBD6oaZbcII4Lvf_Oqu8kII", + "kid": "4rrLPtiRac4zmWwTBQeJlzgNyb5jai8HSOTJONOIKFY", + "use": "sig" + } + ], + "expected": { + "success": true + }, + "tier": "wba", + "basis": "Profile: ignore keys the profile does not use (RFC 7517 section 5)" + }, + { + "id": "D08", + "name": "Media type application/json", + "content_type": "application/json", + "expected": { + "success": false + }, + "tier": "adcp", + "basis": "WBA section 5.5.1: the well-known directory MUST be served with application/http-message-signatures-directory+json" + }, + { + "id": "D09", + "name": "Directory served with status 203", + "status": 203, + "expected": { + "success": false + }, + "tier": "adcp", + "basis": "WBA section 5.5: a status other than 200 is a discovery failure" + }, + { + "id": "D10", + "name": "Signed with tag web-bot-auth", + "parameters": { + "tag": "web-bot-auth" + }, + "expected": { + "success": false + }, + "tier": "adcp", + "basis": "WBA Appendix B.1: tag=\"http-message-signatures-directory\"" + }, + { + "id": "D11", + "name": "Second Ed25519 key that does not sign", + "unsigned_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "kid": "hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI", + "x": "JB8XPw49dSvDh0kqkCxtoxiNb_RZFhxPdlc-1GedFSc", + "use": "sig" + } + ], + "expected": { + "success": false + }, + "tier": "adcp", + "basis": "Profile: every Ed25519 key in the directory signs" + }, + { + "id": "D12", + "name": "Only a P-256 key, unsigned", + "keys": [], + "unsigned_keys": [ + { + "kty": "EC", + "crv": "P-256", + "x": "ayhQePn14MyJGqrKHsp_A06Z0N6IZoE0-7wbn66qLLc", + "y": "k98dk4yymVze93JE_EpboBD6oaZbcII4Lvf_Oqu8kII", + "kid": "4rrLPtiRac4zmWwTBQeJlzgNyb5jai8HSOTJONOIKFY", + "use": "sig" + } + ], + "expected": { + "success": false + }, + "tier": "adcp", + "basis": "Profile: the directory is signed, one signature per Ed25519 key" + }, + { + "id": "D13", + "name": "Extra RSA-PSS key that does not sign", + "unsigned_keys": [ + { + "kty": "RSA", + "e": "AQAB", + "n": "v-QEO4laYaq_rKgLuz0I8hRW_Ig58qmjv_L9cIX_2dCz6pkvMg953c3P3yNtvjl0eLiZNk1tbaWjfdwiMczGUYqhcFutwjbJpny5rMlSOuTkgJEidDpVpsSfa9OwmkRLgn_hjrAkQzKYljsqSN_U9deB6a_NwWiM03MRFLVtcCHJQMEVNCXGvqRIF0MKRVdb3287cgEQ2lvb0g1a84CYzQtbRdWcHnSjhOwF3Y2IH2A2VvQzeCSWWFy2LUr2H09Ce2pvAc-DJX6BdUF5MijFV9SRt4jbPWG28kcrLUEt1GMDynfvMT419dx03kfaZnHkh4I3xXKhcFAIMK4D1WH9bQ", + "alg": "PS512", + "kid": "ugCZP3qkO8gkwu8s71HuHjbt75oPFuBK0Tgb4WkL37k", + "use": "sig" + } + ], + "expected": { + "success": true + }, + "tier": "wba", + "basis": "Profile: ignore keys the profile does not use (RFC 7517 section 5), including a key the library imports" + }, + { + "id": "D14", + "name": "Extra P-256 key that carries its private d", + "unsigned_keys": [ + { + "kty": "EC", + "crv": "P-256", + "x": "ayhQePn14MyJGqrKHsp_A06Z0N6IZoE0-7wbn66qLLc", + "y": "k98dk4yymVze93JE_EpboBD6oaZbcII4Lvf_Oqu8kII", + "kid": "4rrLPtiRac4zmWwTBQeJlzgNyb5jai8HSOTJONOIKFY", + "use": "sig", + "d": "IyiPVggq91xZTs3CxpETaAbK6Qca0DuuD9-DZx7ueHs" + } + ], + "expected": { + "success": false + }, + "tier": "adcp", + "basis": "Profile: refuse a directory in which any key carries a private member, checked before unused keys are skipped; WBA section 5.5.1: a verifier SHOULD reject malformed entries" + }, + { + "id": "D14b", + "name": "Signing Ed25519 key published with its private d", + "publish_private_members": true, + "expected": { + "success": false + }, + "tier": "adcp", + "basis": "Profile: refuse a directory in which any key carries a private member; WBA section 5.5.1: a verifier SHOULD reject malformed entries" + }, + { + "id": "D14c", + "name": "Extra P-256 key that carries its private scalar as an upper-case D", + "unsigned_keys": [ + { + "kty": "EC", + "crv": "P-256", + "x": "ayhQePn14MyJGqrKHsp_A06Z0N6IZoE0-7wbn66qLLc", + "y": "k98dk4yymVze93JE_EpboBD6oaZbcII4Lvf_Oqu8kII", + "kid": "4rrLPtiRac4zmWwTBQeJlzgNyb5jai8HSOTJONOIKFY", + "use": "sig", + "D": "IyiPVggq91xZTs3CxpETaAbK6Qca0DuuD9-DZx7ueHs" + } + ], + "expected": { + "success": false + }, + "tier": "adcp", + "basis": "Profile: private member names compare without regard to case; WBA section 5.5.1: a verifier SHOULD reject malformed entries" + } + ] +} diff --git a/tests/test_governance_authorization.py b/tests/test_governance_authorization.py index 313a4f6d1..c95d37a1e 100644 --- a/tests/test_governance_authorization.py +++ b/tests/test_governance_authorization.py @@ -618,3 +618,58 @@ def test_stateless_governance_applicability( task: str, payload: dict[str, Any], expected: bool | None ) -> None: assert stateless_governance_applicability(task, payload) is expected + + +@pytest.mark.parametrize( + ("header", "claims", "reason"), + [ + ({"crit": []}, {}, None), + ({"crit": ["authorized_task"], "authorized_task": True}, {}, "claim"), + ({"crit": ["authorized_commitment"]}, {}, "crit header"), + ({"crit": ["authorized_commitment"], "authorized_commitment": "yes"}, {}, "crit header"), + ({"crit": ["authorized_commitment", "authorized_commitment"]}, {}, "crit header"), + ({"crit": ["unregistered"], "unregistered": True}, {}, "crit header"), + ({"crit": "authorized_commitment"}, {}, "crit header"), + ( + {"crit": ["authorized_commitment"] * 2, "authorized_commitment": True}, + {"authorized_commitment": {}}, + "crit header", + ), + ], +) +def test_critical_claims_follow_rfc_7515_crit_rules(header, claims, reason): + """RFC 7515 section 4.1.11: every crit name occurs in the header. + + A crit entry whose marker is absent or not a boolean is refused even when + its claim is absent too. + """ + from adcp.governance import critical_claims_failure + + failure = critical_claims_failure(header, claims) + if reason is None: + assert failure is None + else: + assert failure is not None and reason in failure + + +@pytest.mark.parametrize( + "header", + [ + {"crit": ["authorized_commitment"], "authorized_commitment": False}, + {"crit": [], "authorized_commitment": True}, + {"crit": [], "authorized_commitment": False}, + ], + ids=["crit-entry-and-false-marker", "marker-alone", "false-marker-alone"], +) +def test_critical_marker_crit_entry_and_claim_appear_together(header): + """security.mdx checklist step 4: marker, crit entry, and claim appear together. + + "Verifiers MUST also reject when the critical marker and its corresponding + claim do not appear together" (security.mdx, AdCP 3.2.1 to 3.2.3). Each + header here carries a marker for a claim the payload doesn't have. + """ + from adcp.governance import critical_claims_failure + + failure = critical_claims_failure(header, {}) + + assert failure == "authorized_commitment claim and critical marker must match" diff --git a/tests/test_identity_3_3_alignment.py b/tests/test_identity_3_3_alignment.py index fa0563d57..9d2bf5230 100644 --- a/tests/test_identity_3_3_alignment.py +++ b/tests/test_identity_3_3_alignment.py @@ -11,12 +11,14 @@ import asyncio import json import time +from datetime import datetime, timezone from typing import Any import httpx import pytest from asgi_lifespan import LifespanManager -from cryptography.hazmat.primitives.asymmetric import ed25519 +from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa +from joserfc.jwk import ECKey, OKPKey, RSAKey from starlette.requests import Request from starlette.responses import JSONResponse @@ -67,7 +69,6 @@ OriginRevocationChecker, WbaDiscoveryError, WbaVerificationError, - _WbaComponentResolver, sign_key_directory, verify_directory_response, verify_wba_request, @@ -292,6 +293,39 @@ def _signed_revocations(private, origin, *, start, revoked=()): return FetchResult(f"{protected}.{encoded}.{signature}", etag=None) +@pytest.mark.parametrize("alg", ["ES256", "Ed25519"]) +async def test_a_revocation_list_is_eddsa_only(alg): + """An origin's revocation list under Web Bot Auth is an EdDSA JWS only. + + A list with any other ``alg`` is refused before its key is resolved, so it + never verifies and the origin's state is ``"stale"``. + """ + from datetime import timedelta + + private = ed25519.Ed25519PrivateKey.generate() + jwk = public_directory_key(private) + start = datetime.now(timezone.utc) + resolved = [] + + async def resolve(keyid, *, signer_origin=None): + resolved.append(keyid) + return ResolvedSignerKey(signer_origin, jwk) + + async def fetch(uri, **kwargs): + signed = _signed_revocations(private, ORIGIN, start=start) + _, payload, signature = signed.body.split(".") + protected = b64url_encode( + json.dumps({"alg": alg, "kid": jwk["kid"], "typ": "adcp-gov-revocation+jws"}).encode() + ) + return FetchResult(f"{protected}.{payload}.{signature}", etag=None) + + checker = OriginRevocationChecker( + resolve, fetcher=fetch, clock=lambda: 0.0, wall_clock=lambda: start + timedelta(seconds=1) + ) + assert await checker.status(ORIGIN, jwk["kid"]) == "stale" + assert resolved == [] + + async def test_origin_without_a_revocation_list_is_identity_established_and_unbindable(): from datetime import datetime, timedelta, timezone @@ -672,8 +706,13 @@ def test_unsigned_directories_are_refused_and_keys_are_found_by_thumbprint(): private = ed25519.Ed25519PrivateKey.generate() jwk = public_directory_key(private) without_kid = {k: v for k, v in jwk.items() if k != "kid"} - for media_type in ("application/json", "application/jwk-set+json", DIRECTORY_MEDIA_TYPE): - with pytest.raises(WbaVerificationError, match="not signed"): + with pytest.raises(WbaVerificationError, match="not signed"): + verify_directory_response( + ORIGIN, _unsigned_directory(without_kid, media_type=DIRECTORY_MEDIA_TYPE), now=1000 + ) + # WBA section 5.5.1: no other media type is a directory. + for media_type in ("application/json", "application/jwk-set+json"): + with pytest.raises(WbaVerificationError, match="invalid key directory response"): verify_directory_response( ORIGIN, _unsigned_directory(without_kid, media_type=media_type), now=1000 ) @@ -692,50 +731,209 @@ def test_unsigned_directories_are_refused_and_keys_are_found_by_thumbprint(): ) -def _partly_signed_directory(signed_key, unsigned_key, *, components=None, created=1000): +def _public_jwk(private_key, *, private=False): + key_type = { + ed25519.Ed25519PrivateKey: OKPKey, + ec.EllipticCurvePrivateKey: ECKey, + rsa.RSAPrivateKey: RSAKey, + } + imported = next(kind for base, kind in key_type.items() if isinstance(private_key, base)) + key = imported.import_key(private_key) + return {**key.as_dict(private=private), "kid": key.thumbprint()} + + +def _directory_signed_by( + signers, + *, + unsigned=(), + published_private=(), + media_type=DIRECTORY_MEDIA_TYPE, + components=('"@authority";req', '"content-digest"'), + entries=(), +): + """A directory at ``ORIGIN`` that each of ``signers`` signs (Appendix B.1). + + The library signs it directly, so a key the SDK does not sign with still + signs here. ``unsigned`` keys are listed without a signature, and the + signers in ``published_private`` are listed with their private members. + ``entries`` are listed as given, unsigned. + """ + from http_message_signatures import HTTPMessageSigner + from http_message_signatures.algorithms import ECDSA_P256_SHA256, ED25519, RSA_PSS_SHA512 + 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), - } - 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, + keys = [_public_jwk(key, private=key in published_private) for key in signers] + listed = keys + [_public_jwk(key) for key in unsigned] + list(entries) + body = json.dumps({"keys": listed}).encode() + message = _rfc9421.Response( + status_code=200, + request=_rfc9421.Request("GET", ORIGIN + DIRECTORY_PATH), + headers={"Content-Type": media_type, "Content-Digest": compute_content_digest_sha256(body)}, ) - headers.update({"Signature-Input": signature_input, "Signature": signature}) - return DirectoryResponse(body, headers), jwks + algorithms = {"OKP": ED25519, "EC": ECDSA_P256_SHA256, "RSA": RSA_PSS_SHA512} + for number, (private_key, jwk) in enumerate(zip(signers, keys), 1): + HTTPMessageSigner( + signature_algorithm=algorithms[jwk["kty"]], + key_resolver=_rfc9421._Key(private_key=private_key), + ).sign( + message, + key_id=jwk["kid"], + created=datetime.fromtimestamp(1000, timezone.utc), + expires=datetime.fromtimestamp(4600, timezone.utc), + label=f"key{number}", + tag="http-message-signatures-directory", + covered_component_ids=list(components), + append_if_signature_exists=True, + ) + return DirectoryResponse(body=body, headers=dict(message.headers)) -def test_signed_directory_indexes_only_keys_with_a_verified_signature(): +@pytest.mark.parametrize("kind", ["P-256", "RSA"]) +def test_only_ed25519_signs_web_bot_auth(kind): + """The profile's one algorithm is ed25519 (wba-profile.mdx, Profile rules). + + ES256 stays on the AdCP RFC 9421 profile, and RSA-PSS is not a WBA algorithm. + """ + key = ( + ec.generate_private_key(ec.SECP256R1()) + if kind == "P-256" + else rsa.generate_private_key(public_exponent=65537, key_size=2048) + ) + with pytest.raises(ValueError, match="Ed25519"): + public_directory_key(key) + with pytest.raises(ValueError, match="Ed25519"): + sign_key_directory(agent_origin=ORIGIN, private_keys=[key], created=1000) + with pytest.raises(ValueError, match="Ed25519"): + sign_wba_request( + method="POST", + url="https://seller.com/mcp", + headers={"Content-Type": "application/json"}, + body=b"{}", + private_key=key, + agent_origin=ORIGIN, + created=1000, + ) + # A directory signed only by such a key holds no Web Bot Auth key. + with pytest.raises(WbaVerificationError, match="no usable key"): + verify_directory_response(ORIGIN, _directory_signed_by([key]), now=1001) + + +def test_directory_ignores_unusable_keys_and_their_signatures(): + """An unusable key and the signature it makes are ignored (RFC 7517 section 5). + + A directory shared with other HTTP Message Signatures uses holds keys of + other types, and each of them signs it (Appendix B.1). + """ + ed_key = ed25519.Ed25519PrivateKey.generate() + others = [ + ec.generate_private_key(ec.SECP256R1()), + rsa.generate_private_key(public_exponent=65537, key_size=2048), + ] + for response in ( + _directory_signed_by([ed_key, *others]), + _directory_signed_by([ed_key], unsigned=others), + ): + keys = verify_directory_response(ORIGIN, response, now=1001) + assert list(keys) == [public_directory_key(ed_key)["kid"]] + + +@pytest.mark.parametrize("kind", ["P-256", "RSA", "Ed25519"]) +def test_a_directory_publishing_private_key_material_is_refused(kind): + """A key published with ``d`` fails the directory, whether or not the profile can use it. + + The scan runs before unusable keys are skipped. Each directory here also + holds a usable Ed25519 key that signs, so skipping the key with ``d`` + instead would verify the directory. + """ + usable = ed25519.Ed25519PrivateKey.generate() + exposed = { + "P-256": lambda: ec.generate_private_key(ec.SECP256R1()), + "RSA": lambda: rsa.generate_private_key(public_exponent=65537, key_size=2048), + "Ed25519": ed25519.Ed25519PrivateKey.generate, + }[kind]() + directory = _directory_signed_by([usable, exposed], published_private=[exposed]) + assert "d" in json.loads(directory.body)["keys"][1] + + with pytest.raises(WbaVerificationError, match="private key material"): + verify_directory_response(ORIGIN, directory, now=1001) + + +@pytest.mark.parametrize("name", ["D", "P", "Q"]) +def test_a_private_member_refuses_the_directory_whatever_its_case(name): + """The private-member scan ignores case (the #8298 D14c case spells ``D``). + + JWK member names are case-sensitive (RFC 7517 section 4), so ``D`` is not + ``d``. A directory that publishes a private scalar under any spelling is + refused all the same, before its entries are read. + """ + usable = ed25519.Ed25519PrivateKey.generate() + other = ec.generate_private_key(ec.SECP256R1()) + entry = {**_public_jwk(other), name: _public_jwk(other, private=True)["d"]} + + with pytest.raises(WbaVerificationError, match="private key material"): + verify_directory_response(ORIGIN, _directory_signed_by([usable], entries=[entry]), now=1001) + + +def test_every_usable_directory_key_signs(): + """A usable key that does not sign refuses the directory: one signature per key.""" signed, unsigned = (ed25519.Ed25519PrivateKey.generate() for _ in range(2)) - response, jwks = _partly_signed_directory(signed, unsigned) - keys = verify_directory_response(ORIGIN, response, now=1001) - assert list(keys) == [jwks[0]["kid"]] + response = _directory_signed_by([signed], unsigned=[unsigned]) + with pytest.raises(WbaVerificationError, match="did not sign"): + verify_directory_response(ORIGIN, response, now=1001) + + +@pytest.mark.parametrize("media_type", ["application/json", "application/jwk-set+json"]) +def test_directory_media_type_is_the_wba_one(media_type): + """WBA section 5.5.1: a directory is served as the directory media type.""" + key = ed25519.Ed25519PrivateKey.generate() + accepted = _directory_signed_by([key], media_type=f"{DIRECTORY_MEDIA_TYPE}; charset=utf-8") + assert verify_directory_response(ORIGIN, accepted, now=1001) + with pytest.raises(WbaVerificationError, match="invalid key directory response"): + verify_directory_response( + ORIGIN, _directory_signed_by([key], media_type=media_type), now=1001 + ) + + +def test_every_directory_signature_member_is_a_byte_sequence(): + """RFC 9421 section 4.2 holds for a member the verifier ignores too.""" + from dataclasses import replace + + ed_key = ed25519.Ed25519PrivateKey.generate() + rsa_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + response = _directory_signed_by([ed_key, rsa_key]) + assert verify_directory_response(ORIGIN, response, now=1001) + # key2 is the RSA key's member: its signature is not read. + signature = response.headers["Signature"].split(", key2=")[0] + ", key2=token" + with pytest.raises(WbaVerificationError, match="malformed signature field member"): + verify_directory_response( + ORIGIN, + replace(response, headers={**response.headers, "Signature": signature}), + now=1001, + ) def test_directory_signatures_require_the_exact_component_identifiers(): - signed, unsigned = (ed25519.Ed25519PrivateKey.generate() for _ in range(2)) + from dataclasses import replace + + signed = ed25519.Ed25519PrivateKey.generate() + directory = _directory_signed_by([signed]) + assert verify_directory_response(ORIGIN, directory, now=1001) + covered = '("@authority";req "content-digest")' + assert covered in directory.headers["Signature-Input"] + # A bare "@authority" in a response signature is not RFC 9421 (section + # 2.4), so no library signs it; the refusal comes before verification, + # so the Signature-Input alone is changed. for components in ( - ['"@authority"', '"content-digest"'], - ['"@authority"'], - ['"@authority";req', '"content-digest";key="sha-256"'], + '("@authority" "content-digest")', + '("@authority")', + '("@authority";req "content-digest";key="sha-256")', ): - with pytest.raises(WbaVerificationError): + changed = directory.headers["Signature-Input"].replace(covered, components) + with pytest.raises(WbaVerificationError, match="bind authority and digest"): verify_directory_response( ORIGIN, - _partly_signed_directory(signed, unsigned, components=components)[0], + replace(directory, headers={**directory.headers, "Signature-Input": changed}), now=1001, ) diff --git a/tests/test_wba_outage_security.py b/tests/test_wba_outage_security.py index a988aa82f..96e287121 100644 --- a/tests/test_wba_outage_security.py +++ b/tests/test_wba_outage_security.py @@ -20,6 +20,7 @@ ) from adcp.signing.signer_keys import ResolvedSignerKey from adcp.signing.wba import ( + DIRECTORY_MEDIA_TYPE, DirectoryResponse, DiscoveredSignerKeys, OriginRevocationChecker, @@ -33,6 +34,28 @@ ORIGIN = "https://buyer.platform.com" +async def test_directory_fetch_asks_for_the_directory_media_type_only(monkeypatch): + """WBA section 5.5.1: the verifier accepts only the directory media type. + + Offering any other type lets a server that negotiates content choose a + response the verifier then refuses. + """ + requests = [] + + def serve(request): + requests.append(request) + return httpx.Response(200, content=b"{}") + + transport = httpx.MockTransport(serve) + monkeypatch.setattr( + "adcp.signing.ip_pinned_transport.build_async_ip_pinned_transport", + lambda *args, **kwargs: transport, + ) + await fetch_key_directory(ORIGIN + "/.well-known/http-message-signatures-directory") + (request,) = requests + assert request.headers["Accept"] == DIRECTORY_MEDIA_TYPE + + @pytest.mark.parametrize("status", [302, 401, 500, 404, 410]) async def test_directory_fetch_preserves_error_status_without_reading_body(monkeypatch, status): reads = [] diff --git a/tests/wba_interop/adapter.py b/tests/wba_interop/adapter.py new file mode 100644 index 000000000..3b9f4f3a2 --- /dev/null +++ b/tests/wba_interop/adapter.py @@ -0,0 +1,157 @@ +"""Web Bot Auth interop adapter for the AdCP harness (``adcp/tests/wba-interop``). + +The harness grades this SDK against Cloudflare's ``web-bot-auth`` reference. It +runs this script once per operation: one JSON operation on standard input, one +JSON result on standard output. Every operation goes through the SDK's public +``adcp.signing`` API, so the harness grades what an adopter ships. + +Operations (``PROTOCOL.md`` in the harness): + +``sign_request`` + ``key`` (private JWK), ``origin``, ``request`` (``method``, ``url``, + ``headers``, ``body``), ``now``. Returns the signed request. +``sign_directory`` + ``keys`` (private JWKs), ``origin``, ``now``. Returns ``status``, + ``headers``, and ``body``. +``verify_request`` + ``request``, ``directories`` (origin to the signed directory response its + well-known path serves), ``now``, and ``prior`` (requests to verify first, + against the same replay store). Returns ``ok``, ``identity``, and + ``keyid``, or a failure. An origin absent from ``directories`` serves + nothing, and nothing is fetched from the network. +``verify_directory`` + ``origin``, ``response``, ``now``. Returns ``ok`` and ``keyids``, or a + failure. + +A failure is ``{"ok": false, "status": ..., "rule": ..., "error": ...}``. The +status is the one the SDK answers with; an error that carries no HTTP status +(the SDK refusing to sign, for example) reports ``0``. Only the SDK's refusals +are failures: ``WbaVerificationError``, and the ``ValueError`` it raises when +it refuses to sign. Any other exception is a defect, and it crashes the +adapter rather than reading as a refusal. + +Run it as ``uv run python tests/wba_interop/adapter.py``. +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from collections.abc import Awaitable, Callable, Mapping +from typing import Any + +from joserfc.jwk import JWKRegistry + +from adcp.signing import ( + DirectoryResponse, + DiscoveredSignerKeys, + InMemoryReplayStore, + WbaVerificationError, + sign_key_directory, + sign_wba_request, + verify_directory_response, + verify_wba_request, +) + +DIRECTORY_PATH = "/.well-known/http-message-signatures-directory" + +Result = dict[str, Any] + + +def _private_key(jwk: Mapping[str, Any]) -> Any: + return JWKRegistry.import_key(dict(jwk)).private_key + + +def _failure(exc: ValueError) -> Result: + return { + "ok": False, + "status": getattr(exc, "status", 0), + "rule": getattr(exc, "reason", type(exc).__name__), + "error": str(exc), + } + + +def _directory(response: Mapping[str, Any]) -> DirectoryResponse: + return DirectoryResponse( + body=response["body"].encode(), + headers=dict(response["headers"]), + status=response.get("status", 200), + ) + + +async def sign_request(op: Mapping[str, Any]) -> Result: + request = op["request"] + headers = sign_wba_request( + method=request["method"], + url=request["url"], + headers=request["headers"], + body=request.get("body", "").encode(), + private_key=_private_key(op["key"]), + agent_origin=op["origin"], + created=op["now"], + ) + return {**request, "headers": {**request["headers"], **headers}} + + +async def sign_directory(op: Mapping[str, Any]) -> Result: + directory = sign_key_directory( + agent_origin=op["origin"], + private_keys=[_private_key(key) for key in op["keys"]], + created=op["now"] - 60, + ) + return { + "url": op["origin"].rstrip("/") + DIRECTORY_PATH, + "status": directory.status, + "headers": dict(directory.headers), + "body": directory.body.decode(), + } + + +async def verify_request(op: Mapping[str, Any]) -> Result: + served = {origin + DIRECTORY_PATH: response for origin, response in op["directories"].items()} + + async def fetch(url: str, *, allow_private: bool = False) -> DirectoryResponse: + if url not in served: + raise WbaVerificationError(f"no directory at {url}") + return _directory(served[url]) + + now = op["now"] + signer_keys = DiscoveredSignerKeys(fetcher=fetch, clock=lambda: now) + replay_store = InMemoryReplayStore() + for message in [*op.get("prior", []), op["request"]]: + (signer,) = await verify_wba_request( + method=message["method"], + url=message["url"], + headers=message["headers"], + body=message.get("body", "").encode(), + signer_keys=signer_keys, + replay_store=replay_store, + now=now, + ) + return {"ok": True, "identity": signer.agent_url, "keyid": signer.key_id} + + +async def verify_directory(op: Mapping[str, Any]) -> Result: + keys = verify_directory_response(op["origin"], _directory(op["response"]), now=op["now"]) + return {"ok": True, "keyids": sorted(keys)} + + +OPERATIONS: dict[str, Callable[[Mapping[str, Any]], Awaitable[Result]]] = { + "sign_request": sign_request, + "sign_directory": sign_directory, + "verify_request": verify_request, + "verify_directory": verify_directory, +} + + +async def run(op: Mapping[str, Any]) -> Result: + try: + return await OPERATIONS[op["op"]](op) + except ValueError as exc: # WbaVerificationError is a ValueError + # The harness grades the refusal itself, so a refusal is a result. + return _failure(exc) + + +if __name__ == "__main__": + sys.stdout.write(json.dumps(asyncio.run(run(json.loads(sys.stdin.read())))))