From 5b2fbdb0ca57cb20c2553d138e88f7997c467d18 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Tue, 28 Jul 2026 22:32:05 +0200 Subject: [PATCH 1/6] test(signing): vendor the complete 3.1.1 request-signing vector set and pin it The vendored request-signing conformance vectors were an incomplete copy of the spec set, and the loaders could not tell. Both graders globbed the vector directory and asserted only that the result was non-empty (test_verifier_vectors.py, test_canonicalization.py), so any non-empty subset passed and a missing vector was indistinguishable from a complete set. Against AdCP 3.1.1 -- the version src/adcp/ADCP_VERSION already targets -- the vendored copy was missing: - 4 of 12 positive vectors (009/010 percent-encoding edges, 011/012 IPv6 authority) - 8 of 28 negative vectors (021-028) - canonicalization.json entirely, despite the vendored README documenting it and devoting a section to it Three further vectors (016, 017, 020) had silently drifted from their upstream contents. 017 is the notable one: it had been vendored at a revision that revoked test-ed25519-2026, the primary signing key, rather than the dedicated test-revoked-2026 keypair the spec now uses -- and keys.json had never been refreshed to carry that key. Changes: - Vendor all 12 missing files plus canonicalization.json, and re-sync the three drifted vectors, keys.json and README.md. The tree is now byte-identical to dist/compliance/3.1.1/test-vectors/request-signing/. - Add tests/conformance/signing/vectors.py: one shared loader whose parametrization is gated on a completeness pin, replacing the per-module glob-and-assert-non-empty helpers. - Add vector_manifest.json, pinning the SHA-256 of every vendored file. It fails on a missing file, an unexpected extra file, and on content drift. A filename-only manifest would not have caught what happened to 016/017/020, so the pin is over content, not names. - Add test_vector_completeness.py, which also ties the pin to get_adcp_spec_version(), so bumping the targeted spec version without re-vendoring fails loudly instead of leaving the SDK graded against a stale contract. - Wire canonicalization.json into test_canonicalization.py. It earns its own grader: it exercises canonicalize_target_uri/canonicalize_authority over 31 cases including 6 malformed-authority rejections that no signed vector covers at all. The newly graded vectors surface 12 real conformance failures, marked strict-xfail with the gap each one tracks so a fix XPASSes and forces the entry to be retired. They fall into two groups: - The verifier has no step-1 strict-parse stage, so malformed or ambiguous input flows on to a later check and is rejected under the wrong error code (negative 021, 022, 023, 026). Conformance grades the code byte-for-byte, so "rejected anyway" is not a pass. - @target-uri canonicalization applies no UTS-46 A-label conversion, drops a trailing empty query, and accepts five malformed authority shapes the spec requires be rejected. adcp.signing ships canonicalize_host(), but canonical.py never calls it. Each group is filed separately; this change deliberately adds no production behavior, only the vectors and the guard that keeps them complete. --- .../signing/test_canonicalization.py | 79 +++++- .../signing/test_vector_completeness.py | 107 ++++++++ .../signing/test_verifier_vectors.py | 48 +++- .../conformance/signing/vector_manifest.json | 50 ++++ tests/conformance/signing/vectors.py | 113 ++++++++ .../vectors/request-signing/README.md | 24 +- .../request-signing/canonicalization.json | 241 ++++++++++++++++++ .../vectors/request-signing/keys.json | 14 + .../negative/016-replayed-nonce.json | 4 +- .../negative/017-key-revoked.json | 11 +- .../negative/020-rate-abuse.json | 3 +- .../021-duplicate-signature-input-label.json | 31 +++ .../022-multi-valued-content-type.json | 31 +++ .../023-multi-valued-content-digest.json | 32 +++ .../negative/024-unquoted-string-param.json | 31 +++ .../negative/025-jwk-alg-crv-mismatch.json | 43 ++++ .../negative/026-non-ascii-host.json | 31 +++ ...-registration-authentication-unsigned.json | 25 ++ ...028-unsigned-protocol-method-required.json | 26 ++ ...09-percent-encoded-unreserved-decoded.json | 30 +++ .../010-percent-encoded-slash-preserved.json | 30 +++ .../positive/011-ipv6-authority.json | 30 +++ ...-ipv6-authority-default-port-stripped.json | 30 +++ 23 files changed, 1035 insertions(+), 29 deletions(-) create mode 100644 tests/conformance/signing/test_vector_completeness.py create mode 100644 tests/conformance/signing/vector_manifest.json create mode 100644 tests/conformance/signing/vectors.py create mode 100644 tests/conformance/vectors/request-signing/canonicalization.json create mode 100644 tests/conformance/vectors/request-signing/negative/021-duplicate-signature-input-label.json create mode 100644 tests/conformance/vectors/request-signing/negative/022-multi-valued-content-type.json create mode 100644 tests/conformance/vectors/request-signing/negative/023-multi-valued-content-digest.json create mode 100644 tests/conformance/vectors/request-signing/negative/024-unquoted-string-param.json create mode 100644 tests/conformance/vectors/request-signing/negative/025-jwk-alg-crv-mismatch.json create mode 100644 tests/conformance/vectors/request-signing/negative/026-non-ascii-host.json create mode 100644 tests/conformance/vectors/request-signing/negative/027-webhook-registration-authentication-unsigned.json create mode 100644 tests/conformance/vectors/request-signing/negative/028-unsigned-protocol-method-required.json create mode 100644 tests/conformance/vectors/request-signing/positive/009-percent-encoded-unreserved-decoded.json create mode 100644 tests/conformance/vectors/request-signing/positive/010-percent-encoded-slash-preserved.json create mode 100644 tests/conformance/vectors/request-signing/positive/011-ipv6-authority.json create mode 100644 tests/conformance/vectors/request-signing/positive/012-ipv6-authority-default-port-stripped.json diff --git a/tests/conformance/signing/test_canonicalization.py b/tests/conformance/signing/test_canonicalization.py index 55284992..ac99e899 100644 --- a/tests/conformance/signing/test_canonicalization.py +++ b/tests/conformance/signing/test_canonicalization.py @@ -10,26 +10,62 @@ import json from pathlib import Path +from typing import Any import pytest +from _pytest.mark import ParameterSet from adcp.signing.canonical import ( build_signature_base, + canonicalize_authority, + canonicalize_target_uri, parse_signature_input_header, ) +from tests.conformance.signing.vectors import ( + VECTORS_DIR, + load_canonicalization_cases, + load_vector_set, +) -VECTORS_DIR = Path(__file__).parent.parent / "vectors" / "request-signing" +# Cases from canonicalization.json that the SDK does not yet satisfy, mapped to +# the issue tracking each gap. Marked strict so a fix XPASSes and forces the +# entry to be retired instead of lingering. +KNOWN_CANONICALIZATION_GAPS: dict[str, str] = { + # No UTS-46 A-label conversion on the signing path. adcp.signing has a + # canonicalize_host() helper, but canonical.py does not call it, so an IDN + # authority signs and verifies under a non-ASCII host. + "idn-to-punycode": "IDN host is not converted to a Punycode A-label", + "idn-mixed-case-to-punycode": "IDN host is not converted to a Punycode A-label", + # urlunsplit() cannot distinguish "no query" from "empty query", so the + # trailing '?' is dropped and signer/verifier can disagree on the base. + "trailing-empty-query-preserved": "trailing '?' with an empty query is dropped", + # Malformed authorities are canonicalized rather than rejected; the spec's + # request_target_uri_malformed code is not implemented at all. + "malformed-port-without-host": "authority with a port but no host is accepted", + "malformed-userinfo-without-host": "authority with userinfo but no host is accepted", + "malformed-empty-authority": "empty authority is accepted", + "malformed-bare-ipv6": "unbracketed IPv6 literal is accepted", + "malformed-ipv6-zone-identifier": "RFC 6874 zone identifier is accepted", +} def _vectors_with_expected_base() -> list[tuple[str, Path]]: - all_vectors = sorted((VECTORS_DIR / "positive").glob("*.json")) + sorted( - (VECTORS_DIR / "negative").glob("*.json") - ) - result = [ - (p.name, p) for p in all_vectors if "expected_signature_base" in json.loads(p.read_text()) + all_vectors = load_vector_set("positive") + load_vector_set("negative") + return [ + (name, path) + for name, path in all_vectors + if "expected_signature_base" in json.loads(path.read_text()) ] - assert result, f"no vectors with expected_signature_base under {VECTORS_DIR}" - return result + + +def _canonicalization_params() -> list[ParameterSet]: + params = [] + for name, case in load_canonicalization_cases(): + marks = [] + if name in KNOWN_CANONICALIZATION_GAPS: + marks.append(pytest.mark.xfail(strict=True, reason=KNOWN_CANONICALIZATION_GAPS[name])) + params.append(pytest.param(name, case, marks=marks)) + return params @pytest.mark.parametrize( @@ -57,6 +93,33 @@ def test_signature_base_matches_expected(name: str, path: Path) -> None: ) +@pytest.mark.parametrize(("name", "case"), _canonicalization_params()) +def test_canonicalization_case(name: str, case: dict[str, Any]) -> None: + """Grade `canonicalization.json` -- pure URL canonicalization, no crypto. + + The per-vector `expected_signature_base` path only exercises the URL shapes + that happen to appear in a signed vector. This fixture is the exhaustive + set: IDN, IPv6, userinfo, empty-query and the six malformed-authority + rejections, several of which no signed vector covers at all. + """ + url = case["input_url"] + + if case.get("reject"): + # The spec's expected_error_code here is request_target_uri_malformed, + # which the SDK does not define. Assert the refusal, which is the + # behavioral obligation; the code mapping follows once it exists. + with pytest.raises(ValueError): + canonicalize_target_uri(url) + return + + assert ( + canonicalize_target_uri(url) == case["expected_target_uri"] + ), f"{name}: @target-uri mismatch for {url!r} ({case['rule']})" + assert ( + canonicalize_authority(url) == case["expected_authority"] + ), f"{name}: @authority mismatch for {url!r} ({case['rule']})" + + def test_multi_label_signature_input_selects_sig1() -> None: """Per spec, verifiers process exactly sig1 and ignore additional labels.""" vector = json.loads( diff --git a/tests/conformance/signing/test_vector_completeness.py b/tests/conformance/signing/test_vector_completeness.py new file mode 100644 index 00000000..120adc59 --- /dev/null +++ b/tests/conformance/signing/test_vector_completeness.py @@ -0,0 +1,107 @@ +"""Guard the vendored request-signing vector set against silent shrinkage. + +``test_verifier_vectors`` and ``test_canonicalization`` grade whatever vectors +happen to be on disk. That makes them blind in one specific direction: a vector +the spec defines but the SDK never vendored is a rule nobody grades, and no +amount of green in those modules can tell you it is absent. + +These tests supply the missing direction by pinning the vendored tree to a +manifest -- filenames *and* content hashes -- for the AdCP release named in +``VECTOR_SET_SPEC_VERSION``. +""" + +from __future__ import annotations + +import json + +import pytest + +from adcp import get_adcp_spec_version +from tests.conformance.signing.vectors import ( + VECTOR_MANIFEST, + VECTOR_SET_SPEC_VERSION, + VECTORS_DIR, + load_canonicalization_cases, + manifest_ids, + on_disk_files, +) + + +def test_vendored_tree_matches_manifest_exactly() -> None: + """Every vendored file is present, unmodified, and nothing extra is present.""" + on_disk = on_disk_files() + + missing = sorted(set(VECTOR_MANIFEST) - set(on_disk)) + unexpected = sorted(set(on_disk) - set(VECTOR_MANIFEST)) + drifted = sorted( + name + for name, digest in VECTOR_MANIFEST.items() + if name in on_disk and on_disk[name] != digest + ) + + assert not missing, ( + f"vendored vectors missing from disk: {missing}\n" + f"Re-vendor from the AdCP {VECTOR_SET_SPEC_VERSION} spec repo " + f"(dist/compliance/{VECTOR_SET_SPEC_VERSION}/test-vectors/request-signing/)." + ) + assert not unexpected, ( + f"files present under {VECTORS_DIR} that the manifest does not pin: {unexpected}\n" + "Vectors are vendored verbatim from the spec; add the file upstream first, " + "then re-vendor and regenerate the manifest." + ) + assert not drifted, ( + f"vendored vectors differ from their pinned content: {drifted}\n" + "Either the local copy was edited (revert it -- vectors are vendored verbatim) " + "or the spec changed and the SDK must be re-graded before re-pinning." + ) + + +def test_manifest_pins_the_spec_version_the_sdk_targets() -> None: + """A spec bump must re-vendor the vectors, not silently keep the old set.""" + assert get_adcp_spec_version() == VECTOR_SET_SPEC_VERSION, ( + f"SDK targets AdCP {get_adcp_spec_version()} but the vendored request-signing " + f"vectors are pinned at {VECTOR_SET_SPEC_VERSION}. Re-vendor the vector tree " + f"from the new spec release and regenerate the pin with " + f"`python -m tests.conformance.signing.vectors --write`." + ) + + +@pytest.mark.parametrize(("subdir", "expected_count"), [("positive", 12), ("negative", 28)]) +def test_vector_counts_match_spec(subdir: str, expected_count: int) -> None: + """Counts are stated literally so a shrunken set is obvious in the diff.""" + ids = manifest_ids(subdir) + assert len(ids) == expected_count, ( + f"AdCP {VECTOR_SET_SPEC_VERSION} defines {expected_count} {subdir} vectors, " + f"manifest pins {len(ids)}" + ) + + +@pytest.mark.parametrize("subdir", ["positive", "negative"]) +def test_vector_numbering_is_contiguous(subdir: str) -> None: + """Vectors are numbered 001..NNN; a gap means one was dropped.""" + numbers = sorted(int(name.split("-", 1)[0]) for name in manifest_ids(subdir)) + assert numbers == list( + range(1, len(numbers) + 1) + ), f"{subdir} vector numbering has a gap: {numbers}" + + +def test_canonicalization_fixture_is_vendored_and_populated() -> None: + """The standalone canonicalization fixture ships and carries both case kinds.""" + cases = load_canonicalization_cases() + assert len(cases) == 31, ( + f"AdCP {VECTOR_SET_SPEC_VERSION} canonicalization.json defines 31 cases, " + f"found {len(cases)}" + ) + assert sum(1 for _, case in cases if case.get("reject")) == 6 + assert sum(1 for _, case in cases if not case.get("reject")) == 25 + + +def test_every_key_referenced_by_a_vector_exists_in_keys_json() -> None: + """``jwks_ref`` resolution is silent on a missing kid; catch it here instead.""" + available = {k["kid"] for k in json.loads((VECTORS_DIR / "keys.json").read_text())["keys"]} + for name in VECTOR_MANIFEST: + if not name.startswith(("positive/", "negative/")): + continue + vector = json.loads((VECTORS_DIR / name).read_text()) + for kid in vector.get("jwks_ref", []): + assert kid in available, f"{name} references kid {kid!r} absent from keys.json" diff --git a/tests/conformance/signing/test_verifier_vectors.py b/tests/conformance/signing/test_verifier_vectors.py index 1948ae1b..f37de429 100644 --- a/tests/conformance/signing/test_verifier_vectors.py +++ b/tests/conformance/signing/test_verifier_vectors.py @@ -12,6 +12,7 @@ from urllib.parse import urlsplit import pytest +from _pytest.mark import ParameterSet from adcp.signing import ( InMemoryReplayStore, @@ -21,10 +22,36 @@ VerifyOptions, verify_request_signature, ) +from tests.conformance.signing.vectors import VECTORS_DIR, load_vector_set -VECTORS_DIR = Path(__file__).parent.parent / "vectors" / "request-signing" KEYS_BY_KID = {k["kid"]: k for k in json.loads((VECTORS_DIR / "keys.json").read_text())["keys"]} +# Negative vectors the verifier does not yet satisfy, each mapped to the gap it +# tracks. Every one is the same shape: the verifier has no step-1 strict-parse +# stage, so malformed or ambiguous input flows on to a later check and is +# rejected under the wrong error code. Conformance grades the code +# byte-for-byte, so "rejected anyway" is not a pass. +# +# Marked strict: when the verifier is fixed the vector XPASSes and this module +# goes red, which is the signal to delete the entry rather than leave a stale +# exemption behind. +KNOWN_VERIFIER_GAPS: dict[str, str] = { + "021-duplicate-signature-input-label.json": ( + "duplicate Signature-Input dictionary key is silently resolved instead of " + "rejected (RFC 8941 §3.2 / covered-component smuggling)" + ), + "022-multi-valued-content-type.json": ( + "multi-valued Content-Type on a covered non-list field is not rejected at parse" + ), + "023-multi-valued-content-digest.json": ( + "multi-valued Content-Digest (duplicate RFC 9530 algorithm) is not rejected at parse" + ), + "026-non-ascii-host.json": ( + "raw IDN U-label in the authority is not rejected; @target-uri canonicalization " + "applies no UTS-46 A-label conversion" + ), +} + def _operation_from_url(url: str) -> str: path = urlsplit(url).path @@ -73,16 +100,21 @@ def _build_options(vector: dict) -> tuple[VerifyOptions, InMemoryReplayStore]: return options, replay_store -def _load(subdir: str) -> list[tuple[str, Path]]: - result = [(p.name, p) for p in sorted((VECTORS_DIR / subdir).glob("*.json"))] - assert result, f"no vectors under {VECTORS_DIR / subdir}" - return result +_vector_id = lambda v: v if isinstance(v, str) else v.name # noqa: E731 -_vector_id = lambda v: v if isinstance(v, str) else v.name # noqa: E731 +def _negative_params() -> list[ParameterSet]: + """Negative vectors, with known verifier gaps marked strict-xfail.""" + params = [] + for name, path in load_vector_set("negative"): + marks = [] + if name in KNOWN_VERIFIER_GAPS: + marks.append(pytest.mark.xfail(strict=True, reason=KNOWN_VERIFIER_GAPS[name])) + params.append(pytest.param(name, path, marks=marks)) + return params -@pytest.mark.parametrize(("name", "path"), _load("positive"), ids=_vector_id) +@pytest.mark.parametrize(("name", "path"), load_vector_set("positive"), ids=_vector_id) def test_positive_vector(name: str, path: Path) -> None: vector = json.loads(path.read_text()) options, _ = _build_options(vector) @@ -97,7 +129,7 @@ def test_positive_vector(name: str, path: Path) -> None: assert signer.label == vector["expected_outcome"].get("verified_label", "sig1") -@pytest.mark.parametrize(("name", "path"), _load("negative"), ids=_vector_id) +@pytest.mark.parametrize(("name", "path"), _negative_params(), ids=_vector_id) def test_negative_vector(name: str, path: Path) -> None: vector = json.loads(path.read_text()) options, _ = _build_options(vector) diff --git a/tests/conformance/signing/vector_manifest.json b/tests/conformance/signing/vector_manifest.json new file mode 100644 index 00000000..cbd30838 --- /dev/null +++ b/tests/conformance/signing/vector_manifest.json @@ -0,0 +1,50 @@ +{ + "$comment": "Completeness pin for the vendored AdCP request-signing conformance vectors. Generated by `python -m tests.conformance.signing.vectors --write`; do not hand-edit. Graded by tests/conformance/signing/test_vector_completeness.py.", + "spec_version": "3.1.1", + "source": "adcontextprotocol/adcp @ v3.1.1 -- dist/compliance/3.1.1/test-vectors/request-signing/", + "files": { + "README.md": "74977dcb44eef69aee2b45e78e9fc111625e101d7e8646ed9f44b521aee396e8", + "canonicalization.json": "83a15584bebb0ff006ffd99a4080f5a922ea56e9e590d516ee87becaaf15ad1b", + "keys.json": "67b4f32f72ad5aa0463b8f4d407f18fe3c22c0c52655ba47a5f2de89ae974edf", + "negative/001-no-signature-header.json": "94e148f6855026304a04cc34fcd6639ce1e874b7190422f1acbd79545d1ba60d", + "negative/002-wrong-tag.json": "55640f52000dd5d9f9c158ed9417c20802489991ccd42d59b652ccb30fc706b7", + "negative/003-expired-signature.json": "457332ba1626d23a77cac47eb1d59c319eb64af0b048edc9de87224418910ce0", + "negative/004-window-too-long.json": "ebc849fc39789fb71fbf16ff73928f53eff351a5351769fdc3bd7496c0eb5f2f", + "negative/005-alg-not-allowed.json": "3c635f2c3300c6d9856ee6ea6231147cdc6489353b891a5cce16d9bcfc02bfec", + "negative/006-missing-covered-component.json": "5bc102d6518f1def3cc3a43599edefc391eb922dc517e209a56979eb73560a78", + "negative/007-missing-content-digest.json": "7898dc9599722af3cd487af8461c15fafc00165ae425b949b159251cbb4b310e", + "negative/008-unknown-keyid.json": "0694be78535561e2028ddee44813a5a48f77c9b2341900023aff63070eabaded", + "negative/009-key-ops-missing-verify.json": "9eceb605d6c8be1dfffedbb9a0233c3551a2fb7d2034fcbac30b9316dead26ea", + "negative/010-content-digest-mismatch.json": "3bbaefd9a894f6769417705c6daa48c125c1385a65c8ba0610f15555a3b8c396", + "negative/011-malformed-header.json": "0ca8c66e02bd1eb011212de32db4c92af5885d585c18221bf8592cfb7b58bc78", + "negative/012-missing-expires-param.json": "573407764dc539aaaeac0d0b0df1392c1ddb7c9ef315633e47955b617f505c15", + "negative/013-expires-le-created.json": "7cf14332588f4b5d677265087aadeebf953bda5592131224adf72d38e9dba0a6", + "negative/014-missing-nonce-param.json": "19bfcbd02be903a27515ec4af9a1fffd66e8f100ddd6624e2e4500368d8acd3d", + "negative/015-signature-invalid.json": "c835f3452dbab92b055a446e2ca38ce9faf2defaf90eeaecbbf6878dd33f231b", + "negative/016-replayed-nonce.json": "c490b4e2f121d07cbdfc06942b7586328bc5576db3ba78572cece651d67dc717", + "negative/017-key-revoked.json": "c8c535a02b71becf73388894ca0aebd687974e347ab93817b88a030920a202f0", + "negative/018-digest-covered-when-forbidden.json": "45e14505f97e09f1435a1ff892ea6c70583397ee5b495c67fbc2336efb48a548", + "negative/019-signature-without-signature-input.json": "dfc90dcca95d84124f24d7e26abf5f7fe1c8aaebb22e126e135f65260c8efe72", + "negative/020-rate-abuse.json": "2db09f91eb20e0c0443f98266c4cba8c06a419fd8908ff5521f48985bc65f213", + "negative/021-duplicate-signature-input-label.json": "bb57d930f888c6e87cd7dc4865accffde923115289fe1e96f0577de20c2899ac", + "negative/022-multi-valued-content-type.json": "0b09cf0fccd80a288819583f4cd8b27bc124a42c19e893d9ecbcb272194f3529", + "negative/023-multi-valued-content-digest.json": "ed3d211ec5f089a8e024fd4a401cb8a287c549e35156006c4110024651690e71", + "negative/024-unquoted-string-param.json": "a59f698851905fecc163bf7ddae4e1ad3e15477610af4ea0812d9db95a96f9db", + "negative/025-jwk-alg-crv-mismatch.json": "10aca9a60485c61930e2aedd25444653612c14ea5eed908b40b9402e95c220a8", + "negative/026-non-ascii-host.json": "2fb7b813edd4d0dbe102063e611a2147cf8dceb0c3ff62135dc1a9afd7444661", + "negative/027-webhook-registration-authentication-unsigned.json": "8d9a7276fa6ad16ee59343bbdce9e5c081318cdb73aaacfe70a65eef304db5c5", + "negative/028-unsigned-protocol-method-required.json": "d3f5dbe447bef275a9eb88bebd30eed0f8ddfb85addffb83b827db046573c5ac", + "positive/001-basic-post.json": "a592b78de5877f03d852443921760191c2f2c1fa9d8ce23aaacbb1e2c1157158", + "positive/002-post-with-content-digest.json": "7dad1aecf1ea91b5e8fc434989ad2b1b14058e406e91e9aa3a9d05be31942f2d", + "positive/003-es256-post.json": "24972773bacc4445998f5ab1347caaaf2c13f924f5eac89ae8c775e5467aa4c1", + "positive/004-multiple-signature-labels.json": "c9894a9ee26724c46534d7265696282a566e84ff203156d83e28d40eadebb1cc", + "positive/005-default-port-stripped.json": "33f8c9c25b274dccb8a88f23cf08dc11903a001201cdec94ad0644c804287763", + "positive/006-dot-segment-path.json": "153ff35e8b7c89f09099bbe3cdf7cbc48d6db677fbb7c731cd40d1b462ca3383", + "positive/007-query-byte-preserved.json": "83649a92c3488742a49d47e2dabcd8570b3e94caab8951708f934c3b043dc270", + "positive/008-percent-encoded-path.json": "98a4a8ce2b6f4a40564422737c7104a80a85675b85e99ef709e8285aaa5029d0", + "positive/009-percent-encoded-unreserved-decoded.json": "674b99f0e53536e10b7cd8fb046df07219fc79f2a799731cf37a183c0ec57262", + "positive/010-percent-encoded-slash-preserved.json": "669fa85495e494bbcfa9f361925e109beba76667d5d4b37022d98867882bc93b", + "positive/011-ipv6-authority.json": "39a0fd4aba2b81ce9d9687c375b7d25e7727b87282832c41151b96b6c5675308", + "positive/012-ipv6-authority-default-port-stripped.json": "fab9dcef74c2b849b47a0c4a0c0669205300aa9b77d6bced5bbf54e9a40551ba" + } +} diff --git a/tests/conformance/signing/vectors.py b/tests/conformance/signing/vectors.py new file mode 100644 index 00000000..c350c5fc --- /dev/null +++ b/tests/conformance/signing/vectors.py @@ -0,0 +1,113 @@ +"""Shared loader and completeness pin for the AdCP request-signing vectors. + +The vectors under ``tests/conformance/vectors/request-signing/`` are vendored +verbatim from the AdCP spec repo. They are the graded conformance contract, so +the property that matters most about the vendored copy is that it is +*complete* -- a vector that never lands on disk is a rule nobody grades. + +Globbing the directory and asserting the result is non-empty cannot express +that: any non-empty subset passes, so a missing vector is indistinguishable +from a complete set. That is not hypothetical. Before this module existed the +vendored copy was missing 12 of the spec's 40 vectors plus +``canonicalization.json`` entirely, and three further vectors had drifted from +their upstream contents -- all while the suite reported green. + +``vector_manifest.json`` closes that hole by pinning the SHA-256 of every +vendored file. It fails on a missing file, an unexpected extra file, and on +silent content drift -- the last being what happened to the three stale +vectors, and what a filename-only manifest would have missed. + +Re-pinning after a spec bump +---------------------------- +Copy the new vector tree over ``tests/conformance/vectors/request-signing/``, +update ``src/adcp/ADCP_VERSION``, then regenerate the pin:: + + python -m tests.conformance.signing.vectors --write + +Review the resulting diff: every changed hash is a spec change the SDK must be +re-graded against, not a rubber stamp. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +VECTORS_DIR = Path(__file__).parent.parent / "vectors" / "request-signing" +MANIFEST_PATH = Path(__file__).parent / "vector_manifest.json" + +_MANIFEST = json.loads(MANIFEST_PATH.read_text()) + +#: AdCP spec release these vectors were vendored from. Kept equal to the +#: SDK-wide pin in ``src/adcp/ADCP_VERSION`` so that bumping the targeted spec +#: version without re-vendoring the vectors fails loudly rather than leaving +#: the SDK graded against a stale contract. +VECTOR_SET_SPEC_VERSION: str = _MANIFEST["spec_version"] + +#: SHA-256 of every vendored file, relative to :data:`VECTORS_DIR`. +VECTOR_MANIFEST: dict[str, str] = _MANIFEST["files"] + + +def manifest_ids(subdir: str) -> tuple[str, ...]: + """Filenames the manifest expects under ``subdir`` ("positive"/"negative").""" + prefix = f"{subdir}/" + return tuple(sorted(name[len(prefix) :] for name in VECTOR_MANIFEST if name.startswith(prefix))) + + +def on_disk_files() -> dict[str, str]: + """SHA-256 of every file actually present under :data:`VECTORS_DIR`.""" + return { + path.relative_to(VECTORS_DIR).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() + for path in VECTORS_DIR.rglob("*") + if path.is_file() + } + + +def load_vector_set(subdir: str) -> list[tuple[str, Path]]: + """Parametrization list for ``subdir``, gated on manifest completeness. + + Raises if the directory does not hold exactly the manifest's filenames, so + a vector that goes missing takes the suite red at collection time instead + of silently shrinking the graded set. + """ + expected = set(manifest_ids(subdir)) + directory = VECTORS_DIR / subdir + present = {path.name for path in directory.glob("*.json")} + + missing = sorted(expected - present) + unexpected = sorted(present - expected) + if missing or unexpected: + raise AssertionError( + f"{subdir} vector set does not match the manifest for AdCP " + f"{VECTOR_SET_SPEC_VERSION}\n" + f" missing: {missing or 'none'}\n" + f" unexpected: {unexpected or 'none'}\n" + "Re-vendor from the spec repo, then regenerate the pin with " + "`python -m tests.conformance.signing.vectors --write`." + ) + + return [(name, directory / name) for name in sorted(expected)] + + +def load_canonicalization_cases() -> list[tuple[str, dict[str, Any]]]: + """Cases from ``canonicalization.json`` (pure URL canonicalization, no crypto).""" + document = json.loads((VECTORS_DIR / "canonicalization.json").read_text()) + return [(case["name"], case) for case in document["cases"]] + + +def _write_manifest() -> None: + document = dict(_MANIFEST) + document["files"] = dict(sorted(on_disk_files().items())) + MANIFEST_PATH.write_text(json.dumps(document, indent=2) + "\n") + print(f"wrote {MANIFEST_PATH} with {len(document['files'])} entries") + + +if __name__ == "__main__": + import sys + + if "--write" in sys.argv: + _write_manifest() + else: + print(json.dumps(dict(sorted(on_disk_files().items())), indent=2)) diff --git a/tests/conformance/vectors/request-signing/README.md b/tests/conformance/vectors/request-signing/README.md index 7f67cb05..19fa658c 100644 --- a/tests/conformance/vectors/request-signing/README.md +++ b/tests/conformance/vectors/request-signing/README.md @@ -2,13 +2,13 @@ Test vectors for the AdCP RFC 9421 request-signing profile. These fixtures drive cross-implementation conformance testing so a signer written in one SDK and a verifier written in another agree on the wire format. -Specification: [Signed Requests (Transport Layer)](https://adcontextprotocol.org/docs/building/implementation/security#signed-requests-transport-layer) in `docs/building/implementation/security.mdx`. +Specification: [Signed Requests (Transport Layer)](https://adcontextprotocol.org/docs/building/by-layer/L1/security#signed-requests-transport-layer) in `docs/building/by-layer/L1/security.mdx`. -**Canonical URLs.** These vectors are served at `https://adcontextprotocol.org/test-vectors/request-signing/` (tree preserved — `keys.json`, `negative/*.json`, `positive/*.json` all resolvable). SDKs SHOULD fetch from the CDN path rather than requiring a checkout of the spec repo. Example: `https://adcontextprotocol.org/test-vectors/request-signing/positive/001-basic-post.json`. +**Canonical URLs.** These vectors are served at `https://adcontextprotocol.org/compliance/{version}/test-vectors/request-signing/`, with `{version}` being either a specific release (e.g. `3.0.0`) or `latest` (tracks the most recent GA). Tree preserved — `keys.json`, `negative/*.json`, `positive/*.json` all resolvable. SDKs SHOULD fetch from the versioned CDN path and record the version under test rather than requiring a checkout of the spec repo. Example: `https://adcontextprotocol.org/compliance/latest/test-vectors/request-signing/positive/001-basic-post.json`. ## Scope -These vectors exercise the [verifier checklist](https://adcontextprotocol.org/docs/building/implementation/security#verifier-checklist-requests) and the RFC 9421 profile constraints: covered components, signature parameters, tag namespace, alg allowlist, `adcp_use` key-purpose discriminator, replay dedup, revocation, and content-digest semantics. They do not exercise live JWKS fetch, brand.json discovery, or revocation-list polling — those require live endpoints and belong in integration suites. +These vectors exercise the [verifier checklist](https://adcontextprotocol.org/docs/building/by-layer/L1/security#verifier-checklist-requests) and the RFC 9421 profile constraints: covered components, signature parameters, tag namespace, alg allowlist, `adcp_use` key-purpose discriminator, replay dedup, revocation, and content-digest semantics. They do not exercise live JWKS fetch, brand.json discovery, or revocation-list polling — those require live endpoints and belong in integration suites. ## File layout @@ -37,7 +37,15 @@ test-vectors/request-signing/ │ ├── 017-key-revoked.json → request_signature_key_revoked (step 9; requires test_harness_state preload) │ ├── 018-digest-covered-when-forbidden.json → request_signature_components_unexpected (step 6; policy 'forbidden') │ ├── 019-signature-without-signature-input.json → request_signature_header_malformed (pre-check; downgrade loophole) -│ └── 020-rate-abuse.json → request_signature_rate_abuse (step 9a cap; abuse signal) +│ ├── 020-rate-abuse.json → request_signature_rate_abuse (step 9a cap; abuse signal) +│ ├── 021-duplicate-signature-input-label.json → request_signature_header_malformed (step 1; RFC 8941 dict duplicate-key) +│ ├── 022-multi-valued-content-type.json → request_signature_header_malformed (step 1; covered non-list field must be single-valued) +│ ├── 023-multi-valued-content-digest.json → request_signature_header_malformed (step 1; RFC 9530 dict duplicate algorithm) +│ ├── 024-unquoted-string-param.json → request_signature_header_malformed (step 1; RFC 8941 §3.3 string values must be quoted) +│ ├── 025-jwk-alg-crv-mismatch.json → request_signature_key_purpose_invalid (step 8; alg=EdDSA with crv=P-256 is impossible per RFC 8037) +│ ├── 026-non-ascii-host.json → request_signature_header_malformed (step 1; raw IDN U-label on wire; MUST be A-label) +│ ├── 027-webhook-registration-authentication-unsigned.json → request_signature_required (webhook-reg with push_notification_config.authentication over bearer on a seller supporting signing; operation NOT in required_for) +│ └── 028-unsigned-protocol-method-required.json → request_signature_required (unsigned `tasks/cancel` JSON-RPC POST; method is in `protocol_methods_required_for`) └── positive/ vectors that MUST verify successfully ├── 001-basic-post.json Ed25519, no content-digest ├── 002-post-with-content-digest.json Ed25519, content-digest covered @@ -46,7 +54,11 @@ test-vectors/request-signing/ ├── 005-default-port-stripped.json URL has :443; canonical strips it ├── 006-dot-segment-path.json Path has /./; canonical collapses it ├── 007-query-byte-preserved.json Query b=2&a=1&c=3 — preserved, not alphabetized - └── 008-percent-encoded-path.json Path has lowercase %xx; canonical uppercases + ├── 008-percent-encoded-path.json Path has lowercase %xx; canonical uppercases + ├── 009-percent-encoded-unreserved-decoded.json Path has %7E/%2D/%5F/%2E; canonical decodes unreserved per RFC 3986 §6.2.2.2 + ├── 010-percent-encoded-slash-preserved.json Path has %2F (reserved); stays percent-encoded, not treated as segment separator + ├── 011-ipv6-authority.json IPv6 literal host; brackets preserved in @target-uri and @authority + └── 012-ipv6-authority-default-port-stripped.json IPv6 literal with :443; port stripped, brackets preserved ``` ## Canonicalization vectors (`canonicalization.json`) @@ -141,7 +153,7 @@ Every vector is a single JSON file with this shape: - `revocation_list` — full signed-revocation-list object to preload as the current freshness snapshot. Used by `017-key-revoked.json` to assert the revocation check at step 9. - **`expected_signature_base`** — present on positive vectors and on `015-signature-invalid.json`. The canonical signature base string per RFC 9421 §2.5. Shape specifics that implementers get wrong: **lines are joined with a single `\n`** (LF, not CRLF); **there is no trailing newline** after the final `@signature-params` line; **components appear in the exact order listed in `Signature-Input`**, followed by `@signature-params` as the last line. The JSON string uses `\n` escapes which parse to real newline bytes at load time. Implementers can diff their computed base against this field BEFORE worrying about signatures — canonicalization disagreements are the #1 source of 9421 interop bugs, and checking the base is how you catch them. - **`expected_outcome.success`** — `true` for positive vectors, `false` for negative. -- **`expected_outcome.error_code`** — stable code from the [transport error taxonomy](https://adcontextprotocol.org/docs/building/implementation/security#transport-error-taxonomy). Conformance requires **byte-for-byte match** on this code. Negative vectors only. +- **`expected_outcome.error_code`** — stable code from the [transport error taxonomy](https://adcontextprotocol.org/docs/building/by-layer/L1/security#transport-error-taxonomy). Conformance requires **byte-for-byte match** on this code. Negative vectors only. - **`expected_outcome.failed_step`** — which step of the verifier checklist the rejection occurs at. Integer for numbered steps (`1`–`13`), or a string for lettered sub-steps (e.g. `"9a"` for the per-keyid cap check). Informational only — an implementation that rejects with the correct error code is conformant even if its internal step numbering differs. An implementation that rejects with a DIFFERENT error code is non-conformant (see [Conformance expectations](#conformance-expectations)). Negative vectors only. - **`$comment`** — free-form clarifying notes. Some vectors use `$comment` to describe test-harness setup or conformance edge cases. diff --git a/tests/conformance/vectors/request-signing/canonicalization.json b/tests/conformance/vectors/request-signing/canonicalization.json new file mode 100644 index 00000000..24570ae2 --- /dev/null +++ b/tests/conformance/vectors/request-signing/canonicalization.json @@ -0,0 +1,241 @@ +{ + "$comment": "URL canonicalization conformance vectors for the AdCP RFC 9421 request-signing profile. Each case asserts the canonical @target-uri and @authority values a signer MUST compute from the as-received URL before emitting the signature base, and that a verifier MUST recompute before verifying. Independent of cryptographic signing — an SDK can run this set without keys, crypto, or a full verifier harness. Cited by docs/building/implementation/security.mdx §AdCP RFC 9421 profile, `@target-uri` canonicalization.", + "version": "3.0", + "spec_reference": "#adcp-rfc-9421-profile", + "cases": [ + { + "name": "scheme-lowercase", + "rule": "step 1: lowercase the scheme", + "input_url": "HTTPS://seller.example.com/adcp/create_media_buy", + "expected_target_uri": "https://seller.example.com/adcp/create_media_buy", + "expected_authority": "seller.example.com" + }, + { + "name": "host-lowercase", + "rule": "step 2: lowercase the host", + "input_url": "https://Seller.Example.COM/p", + "expected_target_uri": "https://seller.example.com/p", + "expected_authority": "seller.example.com" + }, + { + "name": "idn-to-punycode", + "rule": "step 2: UTS-46 Nontransitional ToASCII → Punycode A-label", + "input_url": "https://bücher.example/p", + "expected_target_uri": "https://xn--bcher-kva.example/p", + "expected_authority": "xn--bcher-kva.example", + "$comment": "Punycode of 'bücher' is 'bcher-kva' → ACE label 'xn--bcher-kva'. Verifiable against any UTS-46-conformant IDN tool." + }, + { + "name": "idn-mixed-case-to-punycode", + "rule": "step 2: UTS-46 case-folds non-ASCII before Punycode; naive ASCII-lowercase + Punycode diverges", + "input_url": "https://BÜCHER.Example/p", + "expected_target_uri": "https://xn--bcher-kva.example/p", + "expected_authority": "xn--bcher-kva.example", + "$comment": "Pins UTS-46 Nontransitional behavior. A signer that lowercases to 'bücher' via locale-dependent ASCII lowercasing before ToASCII still produces the same A-label here because 'Ü' → 'ü' is a UTS-46 mapping, but implementations that skip UTS-46 entirely (e.g., raw idnaEncodeLabel) can diverge on other scripts — this case is the canary." + }, + { + "name": "ipv6-host-hex-lowercased", + "rule": "step 2: IPv6 brackets preserved; hex digits inside lowercased", + "input_url": "https://[2001:DB8::1]/p", + "expected_target_uri": "https://[2001:db8::1]/p", + "expected_authority": "[2001:db8::1]" + }, + { + "name": "ipv6-host-with-port", + "rule": "step 2 + step 4: IPv6 brackets + non-default port preserved", + "input_url": "https://[::1]:8443/p", + "expected_target_uri": "https://[::1]:8443/p", + "expected_authority": "[::1]:8443" + }, + { + "name": "userinfo-stripped", + "rule": "step 3: strip userinfo", + "input_url": "https://user:pass@seller.example.com/p", + "expected_target_uri": "https://seller.example.com/p", + "expected_authority": "seller.example.com" + }, + { + "name": "default-port-https-stripped", + "rule": "step 4: strip :443 for https", + "input_url": "https://seller.example.com:443/adcp/create_media_buy", + "expected_target_uri": "https://seller.example.com/adcp/create_media_buy", + "expected_authority": "seller.example.com" + }, + { + "name": "default-port-http-stripped", + "rule": "step 4: strip :80 for http", + "input_url": "http://seller.example.com:80/p", + "expected_target_uri": "http://seller.example.com/p", + "expected_authority": "seller.example.com" + }, + { + "name": "non-default-port-preserved", + "rule": "step 4: preserve non-default ports", + "input_url": "https://seller.example.com:8443/p", + "expected_target_uri": "https://seller.example.com:8443/p", + "expected_authority": "seller.example.com:8443" + }, + { + "name": "dot-segment-collapsed", + "rule": "step 5: remove_dot_segments (RFC 3986 §5.2.4)", + "input_url": "https://seller.example.com/adcp/./create_media_buy", + "expected_target_uri": "https://seller.example.com/adcp/create_media_buy", + "expected_authority": "seller.example.com" + }, + { + "name": "double-dot-segment-collapsed", + "rule": "step 5: remove_dot_segments resolves /a/b/../c to /a/c", + "input_url": "https://seller.example.com/a/b/../c", + "expected_target_uri": "https://seller.example.com/a/c", + "expected_authority": "seller.example.com" + }, + { + "name": "consecutive-slashes-preserved", + "rule": "step 5: consecutive slashes preserved byte-for-byte (NOT collapsed)", + "input_url": "https://seller.example.com/a//b", + "expected_target_uri": "https://seller.example.com/a//b", + "expected_authority": "seller.example.com", + "$comment": "RFC 3986 does not mandate collapsing consecutive slashes. Preserving closes a path-confusion attack surface where the server routes /a//b differently from /a/b; deployments MUST disable slash-folding on signed routes (nginx merge_slashes off; Express: do not pre-normalize; Go http.ServeMux 1.22+: use explicit http.Handler)." + }, + { + "name": "dot-segment-with-consecutive-slashes", + "rule": "step 5: remove_dot_segments + preserve consecutive slashes; /a/.//b → /a//b", + "input_url": "https://seller.example.com/a/.//b", + "expected_target_uri": "https://seller.example.com/a//b", + "expected_authority": "seller.example.com", + "$comment": "remove_dot_segments resolves /./ but does not collapse the following //. Pins the interaction explicitly — some parsers eagerly collapse // during dot-segment processing." + }, + { + "name": "double-dot-segment-with-consecutive-slashes", + "rule": "step 5: remove_dot_segments across a consecutive-slash boundary; /a//../b → /a/b", + "input_url": "https://seller.example.com/a//../b", + "expected_target_uri": "https://seller.example.com/a/b", + "expected_authority": "seller.example.com", + "$comment": "remove_dot_segments treats // as 'segment + empty segment'; /../ pops the empty segment, leaving /a/b. Pins this behavior so parsers that treat // as a single boundary don't emit /b." + }, + { + "name": "empty-path-with-authority-becomes-slash", + "rule": "step 5: empty path with authority → /", + "input_url": "https://seller.example.com?x=1", + "expected_target_uri": "https://seller.example.com/?x=1", + "expected_authority": "seller.example.com" + }, + { + "name": "percent-encoded-hex-uppercased", + "rule": "step 6: uppercase %xx hex digits", + "input_url": "https://seller.example.com/path%2fhere", + "expected_target_uri": "https://seller.example.com/path%2Fhere", + "expected_authority": "seller.example.com", + "$comment": "%2F is a reserved gen-delim and remains percent-encoded; only the hex case is normalized." + }, + { + "name": "percent-encoded-reserved-preserved", + "rule": "step 6: reserved characters remain percent-encoded; only hex case normalized", + "input_url": "https://seller.example.com/a%3Ab%3Fc", + "expected_target_uri": "https://seller.example.com/a%3Ab%3Fc", + "expected_authority": "seller.example.com", + "$comment": "%3A (':') and %3F ('?') are reserved sub-delims / gen-delims and MUST stay encoded. Implementers who over-decode produce an invalid path." + }, + { + "name": "percent-encoded-unreserved-tilde-decoded", + "rule": "step 6: decode percent-encoded unreserved '~' (RFC 3986 §2.3)", + "input_url": "https://seller.example.com/%7Efoo", + "expected_target_uri": "https://seller.example.com/~foo", + "expected_authority": "seller.example.com", + "$comment": "%7E is the unreserved character '~'; RFC 3986 §6.2.2.2 requires decoding. Verifiers that leave it encoded fail step 10 on cross-SDK traffic." + }, + { + "name": "percent-encoded-unreserved-alpha-decoded", + "rule": "step 6: decode percent-encoded unreserved ALPHA (general rule, not just ~)", + "input_url": "https://seller.example.com/%41%42C", + "expected_target_uri": "https://seller.example.com/ABC", + "expected_authority": "seller.example.com", + "$comment": "%41 ('A') and %42 ('B') are unreserved ALPHA. Implementers commonly only decode %7E/%2D/%5F/%2E and miss the general rule — this case catches that bug." + }, + { + "name": "query-byte-preserved", + "rule": "step 7: preserve query string byte-for-byte (no reordering, no re-encoding)", + "input_url": "https://seller.example.com/p?b=2&a=1&c=3", + "expected_target_uri": "https://seller.example.com/p?b=2&a=1&c=3", + "expected_authority": "seller.example.com" + }, + { + "name": "query-plus-not-decoded", + "rule": "step 7: '+' is preserved, NOT interpreted as space", + "input_url": "https://seller.example.com/p?x=a+b", + "expected_target_uri": "https://seller.example.com/p?x=a+b", + "expected_authority": "seller.example.com" + }, + { + "name": "trailing-empty-query-preserved", + "rule": "step 7: trailing '?' with empty query preserved (distinct from no '?')", + "input_url": "https://seller.example.com/p?", + "expected_target_uri": "https://seller.example.com/p?", + "expected_authority": "seller.example.com" + }, + { + "name": "no-query-preserved", + "rule": "step 7: URL with no '?' stays with no '?'", + "input_url": "https://seller.example.com/p", + "expected_target_uri": "https://seller.example.com/p", + "expected_authority": "seller.example.com" + }, + { + "name": "fragment-stripped", + "rule": "step 8: strip fragment (RFC 9421 §2.2.2)", + "input_url": "https://seller.example.com/p#frag", + "expected_target_uri": "https://seller.example.com/p", + "expected_authority": "seller.example.com" + }, + { + "name": "malformed-port-without-host", + "rule": "step 3: authority with port but no host is malformed", + "input_url": "https://:443/p", + "reject": true, + "reject_reason": "authority missing host", + "expected_error_code": "request_target_uri_malformed" + }, + { + "name": "malformed-userinfo-without-host", + "rule": "step 3: authority with userinfo but no host is malformed", + "input_url": "https://user@/p", + "reject": true, + "reject_reason": "authority missing host", + "expected_error_code": "request_target_uri_malformed" + }, + { + "name": "malformed-empty-authority", + "rule": "step 3: empty authority is malformed", + "input_url": "https:///p", + "reject": true, + "reject_reason": "empty authority", + "expected_error_code": "request_target_uri_malformed" + }, + { + "name": "malformed-ipv6-missing-closing-bracket", + "rule": "step 2: bracketed IPv6 host missing closing bracket is malformed", + "input_url": "https://[::1/p", + "reject": true, + "reject_reason": "IPv6 literal missing closing bracket", + "expected_error_code": "request_target_uri_malformed" + }, + { + "name": "malformed-bare-ipv6", + "rule": "step 2: IPv6 address outside brackets is malformed (ambiguous with port)", + "input_url": "https://fe80::1/p", + "reject": true, + "reject_reason": "IPv6 literal not bracketed", + "expected_error_code": "request_target_uri_malformed", + "$comment": "Parsers variously treat '::1' as port or as host; rejecting unambiguously removes the interop divergence." + }, + { + "name": "malformed-ipv6-zone-identifier", + "rule": "step 2: IPv6 zone identifier (RFC 6874) is node-local and MUST be rejected in signed URLs", + "input_url": "https://[fe80::1%25eth0]/p", + "reject": true, + "reject_reason": "IPv6 zone identifier in signed URL", + "expected_error_code": "request_target_uri_malformed", + "$comment": "RFC 6874 §1 defines zone-ids as node-local; they have no meaning outside the signing host. A verifier on a different node cannot interpret them. Rejecting at the signer (MUST NOT sign) and verifier (MUST reject) closes the ambiguity." + } + ] +} diff --git a/tests/conformance/vectors/request-signing/keys.json b/tests/conformance/vectors/request-signing/keys.json index 085f0387..b883930a 100644 --- a/tests/conformance/vectors/request-signing/keys.json +++ b/tests/conformance/vectors/request-signing/keys.json @@ -41,6 +41,20 @@ "adcp_use": "governance-signing", "x": "rkUcKP5oMd7YjV4yy5mVS5S8fA3LDXcf5jk1P1_52EA", "_private_d_for_test_only": "bag_KLehHhOb-giX2u8kEfm9Djo6Fldl6_dFoRiC_eE" + }, + { + "$comment": "Revoked request-signing key. Dedicated keypair for negative vector 017-key-revoked under the signed-requests-runner test-kit contract. adcp_use is 'request-signing' so that the verifier's purpose check (step 8) passes and the revocation check (step 9) fires. Agents pre-configure their revocation list to include this keyid before the negative phase runs; see test-kits/signed-requests-runner.yaml.", + "kid": "test-revoked-2026", + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "key_ops": [ + "verify" + ], + "adcp_use": "request-signing", + "x": "r8wqMpVCLKzLSRNBtNmI1g71pPzQcwkATJHcyHK1lXg", + "_private_d_for_test_only": "PHd2372_ljsWBOUrEyhipyplIkv7feOfpeoU9bb0T_U" } ] } diff --git a/tests/conformance/vectors/request-signing/negative/016-replayed-nonce.json b/tests/conformance/vectors/request-signing/negative/016-replayed-nonce.json index a7cc270b..701f1620 100644 --- a/tests/conformance/vectors/request-signing/negative/016-replayed-nonce.json +++ b/tests/conformance/vectors/request-signing/negative/016-replayed-nonce.json @@ -29,5 +29,7 @@ "error_code": "request_signature_replayed", "failed_step": 12 }, - "$comment": "This vector is identical to positive/001-basic-post.json but is submitted after the replay cache has already recorded the (keyid, nonce) pair. The Signature field should be populated with the exact signature from positive/001 before running (test harness responsibility). Verifier MUST reject at step 12 with request_signature_replayed without reaching step 13's cache insert." + "requires_contract": "replay_window", + "side_effects": "mutating", + "$comment": "This vector is identical to positive/001-basic-post.json but is submitted after the replay cache has already recorded the (keyid, nonce) pair. White-box harnesses MAY inject the cache entry directly (see test_harness_state). Black-box runners SHOULD send the request twice in sequence per test-kits/signed-requests-runner.yaml → stateful_vector_contract.replay_window; the first submission is accepted as a real create_media_buy and MUST be sent against a sandbox endpoint only (see endpoint_scope in the test-kit), the second MUST be rejected at step 12 with request_signature_replayed without reaching step 13's cache insert. The side_effects: mutating marker is a belt-and-suspenders signal for consumers that read vectors outside the storyboard runner: the black-box path for this vector has a first-request side effect by design." } diff --git a/tests/conformance/vectors/request-signing/negative/017-key-revoked.json b/tests/conformance/vectors/request-signing/negative/017-key-revoked.json index 05881f2b..f4b1052b 100644 --- a/tests/conformance/vectors/request-signing/negative/017-key-revoked.json +++ b/tests/conformance/vectors/request-signing/negative/017-key-revoked.json @@ -7,7 +7,7 @@ "url": "https://seller.example.com/adcp/create_media_buy", "headers": { "Content-Type": "application/json", - "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-revoked-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", "Signature": "sig1=:U51PJzU9nMJxMAH_u-UDpSecT5SQX1-deSnWE3XpFo-BLT2_2h5FgMltntNCW05chhmFnjZEzkRmaYKeU0UUBw:" }, "body": "{\"plan_id\":\"plan_001\",\"packages\":[{\"package_id\":\"pkg_1\",\"budget\":{\"amount\":1000,\"currency\":\"USD\"}}]}" @@ -17,14 +17,14 @@ "covers_content_digest": "either", "required_for": ["create_media_buy"] }, - "jwks_ref": ["test-ed25519-2026"], + "jwks_ref": ["test-revoked-2026"], "test_harness_state": { - "$comment": "Pre-populate revocation list with test-ed25519-2026 revoked. Harness sets revocation_list = { revoked_kids: ['test-ed25519-2026'], revoked_jtis: [], fresh: true, issuer: 'https://seller.example.com', updated: '2026-04-18T14:00:00Z', next_update: '2026-04-18T14:15:00Z' }.", + "$comment": "Pre-populate revocation list with test-revoked-2026 revoked. Harness sets revocation_list = { revoked_kids: ['test-revoked-2026'], revoked_jtis: [], fresh: true, issuer: 'https://seller.example.com', updated: '2026-04-18T14:00:00Z', next_update: '2026-04-18T14:15:00Z' }. Black-box runners rely on the agent having pre-configured this state per test-kits/signed-requests-runner.yaml → stateful_vector_contract.revocation (pre_revoked_keyid: test-revoked-2026).", "revocation_list": { "issuer": "https://seller.example.com", "updated": "2026-04-18T14:00:00Z", "next_update": "2026-04-18T14:15:00Z", - "revoked_kids": ["test-ed25519-2026"], + "revoked_kids": ["test-revoked-2026"], "revoked_jtis": [] } }, @@ -33,5 +33,6 @@ "error_code": "request_signature_key_revoked", "failed_step": 9 }, - "$comment": "Revocation check runs BEFORE cryptographic verify (step 9, before step 10) — this prevents cheap amplification where an attacker replays a revoked key's valid signature and forces the verifier to do an Ed25519/ECDSA verify for every rejection. Verifier MUST reject at step 9 without computing the signature base or running crypto verification." + "requires_contract": "revocation", + "$comment": "Revocation check runs BEFORE cryptographic verify (step 9, before step 10) — this prevents cheap amplification where an attacker replays a revoked key's valid signature and forces the verifier to do an Ed25519/ECDSA verify for every rejection. Verifier MUST reject at step 9 without computing the signature base or running crypto verification. Implementations that defer the revocation check until after crypto verify will fail this vector because the committed Signature bytes are a placeholder copied from positive/001 and do not verify cryptographically against this vector's own signature base (different keyid in @signature-params); a crypto-first verifier will return request_signature_invalid instead of request_signature_key_revoked, which is a graded mismatch. Graders SHOULD surface this specific mismatch (_invalid where _key_revoked was expected) in operator-facing diagnostics as a step-ordering bug (still a graded FAIL) rather than as a generic vector failure — the vector is deliberately designed as a step-ordering canary and a verifier that runs crypto before revocation is exploitable (cheap amplification on revoked-key replay), which is the very failure mode the step-9-before-step-10 ordering exists to prevent. The keyid test-revoked-2026 is a dedicated revoked keypair in keys.json so this vector does not conflict with the purpose-mismatch vector (009) or the runner signing keys (test-ed25519-2026, test-es256-2026)." } diff --git a/tests/conformance/vectors/request-signing/negative/020-rate-abuse.json b/tests/conformance/vectors/request-signing/negative/020-rate-abuse.json index da21b6ca..82947b65 100644 --- a/tests/conformance/vectors/request-signing/negative/020-rate-abuse.json +++ b/tests/conformance/vectors/request-signing/negative/020-rate-abuse.json @@ -29,5 +29,6 @@ "error_code": "request_signature_rate_abuse", "failed_step": "9a" }, - "$comment": "When the per-keyid replay cache has reached its configured cap (recommended: 1M entries), new signatures from that keyid MUST be rejected with request_signature_rate_abuse — not silently evict, not accept. Silent eviction creates replay windows exactly when under attack. The nonce in this vector is fresh (never seen); the rejection is due to the cap, not replay. Verifiers SHOULD alert operators on this condition as a compromised-key or misconfigured-signer signal. The cap check is step 9a of the verifier checklist and runs BEFORE crypto verify (step 10) to prevent amplified Ed25519/ECDSA work on an abusive signer — same rationale as revocation (step 9). Implementations that defer the cap check until after crypto verify will fail this vector because the committed Signature bytes are a placeholder copied from positive/001 and do not verify cryptographically against this vector's own signature base (different nonce in @signature-params, different body)." + "requires_contract": "rate_abuse", + "$comment": "When the per-keyid replay cache has reached its configured cap (recommended: 1M entries), new signatures from that keyid MUST be rejected with request_signature_rate_abuse — not silently evict, not accept. Silent eviction creates replay windows exactly when under attack. The nonce in this vector is fresh (never seen); the rejection is due to the cap, not replay. Verifiers SHOULD alert operators on this condition as a compromised-key or misconfigured-signer signal. The cap check is step 9a of the verifier checklist and runs BEFORE crypto verify (step 10) to prevent amplified Ed25519/ECDSA work on an abusive signer — same rationale as revocation (step 9). Black-box runners target the cap declared in test-kits/signed-requests-runner.yaml → stateful_vector_contract.rate_abuse (grading_target_per_keyid_cap_requests: 100); agents MAY lower their production cap for the test-kit counterparty only. Implementations that defer the cap check until after crypto verify will fail this vector because the committed Signature bytes are a placeholder copied from positive/001 and do not verify cryptographically against this vector's own signature base (different nonce in @signature-params, different body); a crypto-first verifier will return request_signature_invalid instead of request_signature_rate_abuse. Graders SHOULD surface this specific mismatch (_invalid where _rate_abuse was expected) in operator-facing diagnostics as a step-ordering bug (still a graded FAIL) rather than as a generic vector failure — the vector is deliberately designed as a step-ordering canary and a verifier that runs crypto before the per-keyid cap is exploitable (an abusive signer forces Ed25519/ECDSA verify per request), which is the very failure mode the step-9a-before-step-10 ordering exists to prevent." } diff --git a/tests/conformance/vectors/request-signing/negative/021-duplicate-signature-input-label.json b/tests/conformance/vectors/request-signing/negative/021-duplicate-signature-input-label.json new file mode 100644 index 00000000..bfc65064 --- /dev/null +++ b/tests/conformance/vectors/request-signing/negative/021-duplicate-signature-input-label.json @@ -0,0 +1,31 @@ +{ + "name": "Signature-Input header declares label 'sig1' twice (malformed structured dictionary)", + "spec_reference": "#verifier-checklist-requests step 1 (RFC 9421 §4.1 Signature-Input is a Dictionary; duplicate keys malformed)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\", sig1=(\"@method\" \"@target-uri\");created=1776520800;expires=1776521100;nonce=\"AAAAAAAAAAAAAAAAAAAAAA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_outcome": { + "success": false, + "error_code": "request_signature_header_malformed", + "failed_step": 1 + }, + "$comment": "RFC 8941 §3.2 Dictionaries: 'When parsing, duplicate keys MUST be handled by either rejecting the input or by retaining only the last value.' Per the AdCP profile and downgrade-protection rule at step 1, verifiers MUST reject — silently retaining 'the last value' would let a proxy smuggle a weaker set of covered components past a verifier that read the first. Related cases: 011-malformed-header (unparseable) and 019-signature-without-signature-input (missing pair); this vector is the 'parseable-but-ambiguous' case the two existing vectors don't cover." +} diff --git a/tests/conformance/vectors/request-signing/negative/022-multi-valued-content-type.json b/tests/conformance/vectors/request-signing/negative/022-multi-valued-content-type.json new file mode 100644 index 00000000..1019f5ac --- /dev/null +++ b/tests/conformance/vectors/request-signing/negative/022-multi-valued-content-type.json @@ -0,0 +1,31 @@ +{ + "name": "Content-Type header sent as multiple field values (malformed — field covered by signature must be single-valued)", + "spec_reference": "#verifier-checklist-requests step 1 (covered component fields must be single-valued; RFC 9421 §2.1 derivation rules do not permit concatenation for non-list headers)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json, text/plain", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_outcome": { + "success": false, + "error_code": "request_signature_header_malformed", + "failed_step": 1 + }, + "$comment": "Content-Type is not a List-Structured-Field — it has a single canonical value with optional parameters. RFC 9421 §2.1 says covered field values are the field's canonical in-order concatenation only for fields the HTTP spec treats as list-typed. A proxy inserting a second Content-Type (or a client with a bug emitting two) leaves the field's meaning undefined relative to the signature base. Verifier MUST reject at parse time rather than pick one value and hope for the best." +} diff --git a/tests/conformance/vectors/request-signing/negative/023-multi-valued-content-digest.json b/tests/conformance/vectors/request-signing/negative/023-multi-valued-content-digest.json new file mode 100644 index 00000000..0a07ca34 --- /dev/null +++ b/tests/conformance/vectors/request-signing/negative/023-multi-valued-content-digest.json @@ -0,0 +1,32 @@ +{ + "name": "Content-Digest header sent as multiple field values (malformed)", + "spec_reference": "#verifier-checklist-requests step 1 (Content-Digest covered in signature must be single-valued; RFC 9530)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:, sha-256=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "required", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_outcome": { + "success": false, + "error_code": "request_signature_header_malformed", + "failed_step": 1 + }, + "$comment": "Content-Digest per RFC 9530 §2 is a Dictionary-Structured-Field where each member is a digest algorithm — duplicates of the same algorithm are ambiguous and undefined for signature coverage. Even if the two sha-256 values happened to be identical, permitting multiple on the wire creates a parser-differential attack: signer and verifier might disagree on which value enters the signature base. Verifier MUST reject. Distinct from 010-content-digest-mismatch (where the single declared digest doesn't match the body)." +} diff --git a/tests/conformance/vectors/request-signing/negative/024-unquoted-string-param.json b/tests/conformance/vectors/request-signing/negative/024-unquoted-string-param.json new file mode 100644 index 00000000..1013f9ee --- /dev/null +++ b/tests/conformance/vectors/request-signing/negative/024-unquoted-string-param.json @@ -0,0 +1,31 @@ +{ + "name": "Signature-Input sig-param string value sent unquoted (keyid=foo instead of keyid=\"foo\")", + "spec_reference": "#verifier-checklist-requests step 1 (RFC 9421 §2.3 sig-params; RFC 8941 §3.3 string values MUST be double-quoted)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=test-ed25519-2026;alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_outcome": { + "success": false, + "error_code": "request_signature_header_malformed", + "failed_step": 1 + }, + "$comment": "Per RFC 8941 §3.3.3, Structured-Field string values MUST be wrapped in ASCII double-quotes. RFC 9421 §2.3 defines keyid, nonce, and tag as string-typed sig-params, so 'keyid=foo' (bare token) is not a valid string — it would parse as a token-typed value if the grammar allowed tokens there, which it does not. A verifier that tolerantly accepts bare tokens (common bug in hand-rolled parsers) introduces a parser-differential attack: one implementation sees keyid='foo', another sees keyid=undefined, and they disagree on which JWKS entry to resolve. Verifier MUST reject at parse." +} diff --git a/tests/conformance/vectors/request-signing/negative/025-jwk-alg-crv-mismatch.json b/tests/conformance/vectors/request-signing/negative/025-jwk-alg-crv-mismatch.json new file mode 100644 index 00000000..537e59a1 --- /dev/null +++ b/tests/conformance/vectors/request-signing/negative/025-jwk-alg-crv-mismatch.json @@ -0,0 +1,43 @@ +{ + "name": "JWK declares alg=EdDSA but crv=P-256 (parameter-mismatch on presented key)", + "spec_reference": "#verifier-checklist-requests step 8 (key-purpose + parameter consistency; RFC 8037 binds alg=EdDSA to OKP key types, not EC)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-alg-crv-mismatch-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_override": { + "keys": [ + { + "$comment": "Malformed JWK: alg=EdDSA is valid only for kty=OKP with crv=Ed25519 or crv=Ed448 (RFC 8037 §3.1). This JWK declares alg=EdDSA with kty=EC and crv=P-256, which is an impossible combination. Key material is the real Ed25519 public key bytes from test-ed25519-2026 so the failure MUST land on parameter consistency, not on some unrelated key-material defect. Verifier MUST reject at step 8 with request_signature_key_purpose_invalid — 'key purpose' encompasses both adcp_use scoping and the fundamental alg/kty/crv consistency that makes a JWK usable at all.", + "kid": "test-alg-crv-mismatch-2026", + "kty": "EC", + "crv": "P-256", + "alg": "EdDSA", + "use": "sig", + "key_ops": ["verify"], + "adcp_use": "request-signing", + "x": "gWUqzATUcUco5Q8fZZXn8aWwb7DQbYGBiqUzLiSDDJo" + } + ] + }, + "expected_outcome": { + "success": false, + "error_code": "request_signature_key_purpose_invalid", + "failed_step": 8 + }, + "$comment": "Parameter-consistency check on the resolved JWK. A lenient verifier that picks alg from the sig-params and ignores the JWK's declared alg/crv would proceed to step 10 and reject with request_signature_invalid — a DIFFERENT (and less informative) error code. Per README 'Conformance expectations' §1, error-code mismatch is non-conformant even when the rejection lands at a different step. Uses jwks_override rather than a new keys.json entry because this key shape is deliberately malformed — polluting the canonical keys.json with impossible key parameters would risk other vectors inheriting the malformed shape through a bug." +} diff --git a/tests/conformance/vectors/request-signing/negative/026-non-ascii-host.json b/tests/conformance/vectors/request-signing/negative/026-non-ascii-host.json new file mode 100644 index 00000000..c4ea123e --- /dev/null +++ b/tests/conformance/vectors/request-signing/negative/026-non-ascii-host.json @@ -0,0 +1,31 @@ +{ + "name": "URL authority contains raw IDN U-label (non-ASCII bytes in host)", + "spec_reference": "#adcp-rfc-9421-profile (@target-uri canonicalization: hosts MUST be ASCII A-labels; RFC 5891 §4.4)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://bücher.example.com/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_outcome": { + "success": false, + "error_code": "request_signature_header_malformed", + "failed_step": 1 + }, + "$comment": "Host label 'bücher' is a Unicode U-label; the AdCP @target-uri algorithm requires A-label (Punycode, 'xn--bcher-kva') form. Accepting a raw U-label on the wire risks a canonicalization-differential: UTS-46 Nontransitional processing produces one A-label, naive lowercase + Punycode produces another (see canonicalization.json case 'idn-mixed-case-to-punycode'), and non-ASCII-aware libraries may round-trip the bytes unchanged. Verifier MUST reject at parse rather than guess. Companion canonicalization.json cases 'idn-to-punycode' and 'idn-mixed-case-to-punycode' exercise the SIGNER side (expect A-label output from U-label input after explicit UTS-46 processing); this vector exercises the VERIFIER side (reject U-label bytes received on the wire inside a signed request)." +} diff --git a/tests/conformance/vectors/request-signing/negative/027-webhook-registration-authentication-unsigned.json b/tests/conformance/vectors/request-signing/negative/027-webhook-registration-authentication-unsigned.json new file mode 100644 index 00000000..4862deba --- /dev/null +++ b/tests/conformance/vectors/request-signing/negative/027-webhook-registration-authentication-unsigned.json @@ -0,0 +1,25 @@ +{ + "name": "Webhook registration with push_notification_config.authentication over bearer (unsigned); seller supports request signing but operation is NOT in required_for", + "spec_reference": "#webhook-security Downgrade and injection resistance — MUST require 9421 when authentication is present", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/update_media_buy", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer test-bearer-token" + }, + "body": "{\"media_buy_id\":\"mb_001\",\"push_notification_config\":{\"url\":\"https://buyer.example.com/webhook\",\"authentication\":{\"scheme\":\"HMAC-SHA256\",\"credentials\":\"shared-secret-placeholder\"}}}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [] + }, + "jwks_ref": ["test-ed25519-2026"], + "expected_outcome": { + "success": false, + "error_code": "request_signature_required", + "failed_step": 0 + } +} diff --git a/tests/conformance/vectors/request-signing/negative/028-unsigned-protocol-method-required.json b/tests/conformance/vectors/request-signing/negative/028-unsigned-protocol-method-required.json new file mode 100644 index 00000000..a0488bd8 --- /dev/null +++ b/tests/conformance/vectors/request-signing/negative/028-unsigned-protocol-method-required.json @@ -0,0 +1,26 @@ +{ + "name": "Unsigned tasks/cancel JSON-RPC POST; method is in protocol_methods_required_for", + "spec_reference": "#signed-requests-transport-layer (protocol_methods_* namespace; pre-check 0)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/mcp", + "headers": { + "Content-Type": "application/json" + }, + "body": "{\"jsonrpc\":\"2.0\",\"method\":\"tasks/cancel\",\"params\":{\"taskId\":\"task_conformance_001\"},\"id\":1}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [], + "protocol_methods_required_for": ["tasks/cancel"] + }, + "jwks_ref": ["test-ed25519-2026"], + "expected_outcome": { + "success": false, + "error_code": "request_signature_required", + "failed_step": 0 + }, + "$comment": "Exercises the protocol_methods_required_for namespace introduced in adcp#4326. The verifier resolves the JSON-RPC envelope's `method` field (`tasks/cancel`), matches it against `protocol_methods_required_for`, and rejects the unsigned request with `request_signature_required`. A verifier that only consults `required_for` (the AdCP-tool namespace) would silently accept the unsigned request — which is the regression this vector locks out." +} diff --git a/tests/conformance/vectors/request-signing/positive/009-percent-encoded-unreserved-decoded.json b/tests/conformance/vectors/request-signing/positive/009-percent-encoded-unreserved-decoded.json new file mode 100644 index 00000000..8fa90b29 --- /dev/null +++ b/tests/conformance/vectors/request-signing/positive/009-percent-encoded-unreserved-decoded.json @@ -0,0 +1,30 @@ +{ + "name": "URL path percent-encoded unreserved bytes decoded per RFC 3986 §6.2.2.2", + "spec_reference": "#adcp-rfc-9421-profile (@target-uri canonicalization step 6: decode percent-encoded unreserved characters)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/a%7Eb%2Dc%5Fd%2Ee/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:o2kFcTvxhbtnyteqXSKBvg_WcDeh7Ev-6rmhbl5bqEa5LHEDBBQbzoB8JEgOxtYjsrANKz8qygxfc0JOUQnKBg:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://seller.example.com/adcp/a~b-c_d.e/create_media_buy\n\"@authority\": seller.example.com\n\"content-type\": application/json\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "expected_outcome": { + "success": true + }, + "$comment": "Companion to 008-percent-encoded-path (which exercises the reserved-byte uppercase rule). This vector covers the decode-side of step 6: the four unreserved characters that are percent-encoded on the wire (%7E=~, %2D=-, %5F=_, %2E=.) MUST be decoded in the canonical @target-uri per RFC 3986 §6.2.2.2. A verifier that uppercases but doesn't decode will fail step 10 (signature invalid) because %7E != ~ in the canonical base. Signature generated with the test-ed25519-2026 private key from the expected_signature_base." +} diff --git a/tests/conformance/vectors/request-signing/positive/010-percent-encoded-slash-preserved.json b/tests/conformance/vectors/request-signing/positive/010-percent-encoded-slash-preserved.json new file mode 100644 index 00000000..2363063f --- /dev/null +++ b/tests/conformance/vectors/request-signing/positive/010-percent-encoded-slash-preserved.json @@ -0,0 +1,30 @@ +{ + "name": "URL path with %2F (reserved) preserved literally through remove_dot_segments", + "spec_reference": "#adcp-rfc-9421-profile (@target-uri canonicalization step 5: remove_dot_segments operates on percent-encoded form; step 6: reserved bytes stay percent-encoded)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://seller.example.com/adcp/segment%2Fwith-encoded-slash/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:2MVpRwioVrc-gLepGnleM5Nk0hI8M-V5A9buqMly0oL5Z4p3VfPh92ijitH1fKL2o7i97160AJ0Sf_wP9Q0KCQ:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://seller.example.com/adcp/segment%2Fwith-encoded-slash/create_media_buy\n\"@authority\": seller.example.com\n\"content-type\": application/json\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "expected_outcome": { + "success": true + }, + "$comment": "%2F is the percent-encoding of '/', a reserved character. Two properties MUST hold in canonical form: (a) remove_dot_segments per RFC 3986 §5.2.4 operates on the percent-encoded path and does NOT treat %2F as a segment separator, so 'segment%2Fwith-encoded-slash' stays as one path segment; (b) step 6 keeps reserved bytes percent-encoded (with uppercase hex) — the %2F does NOT decode to '/'. A verifier that decodes %2F before remove_dot_segments can produce a different path entirely (especially when combined with /./ or /../). Signature generated with the test-ed25519-2026 private key from the expected_signature_base." +} diff --git a/tests/conformance/vectors/request-signing/positive/011-ipv6-authority.json b/tests/conformance/vectors/request-signing/positive/011-ipv6-authority.json new file mode 100644 index 00000000..9eae84d0 --- /dev/null +++ b/tests/conformance/vectors/request-signing/positive/011-ipv6-authority.json @@ -0,0 +1,30 @@ +{ + "name": "IPv6 literal authority; brackets preserved in @target-uri and @authority", + "spec_reference": "#adcp-rfc-9421-profile (@target-uri canonicalization step 2: IPv6 brackets preserved; @authority derivation)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://[2001:db8::1]/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:QplWbT2vVnF_TSfY5w5d8zQYkLpD7Bp4rxE9uKHl14UjBmUnF6mwKB8pEgoFTKHF1jVwwL14hx_AM6sFBtT9CA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://[2001:db8::1]/adcp/create_media_buy\n\"@authority\": [2001:db8::1]\n\"content-type\": application/json\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "expected_outcome": { + "success": true + }, + "$comment": "Bare IPv6 literal in authority. RFC 9421 canonicalizes @target-uri and @authority with brackets retained; common host-parsing libraries strip brackets during authority split and must reinstate them in the canonical form. A verifier that emits '2001:db8::1' without brackets (or with an extra ':' splitting ambiguity) will fail step 10. Paired with 012-ipv6-authority-default-port-stripped to cover the '@target-uri must not strip the bracket while stripping the default port' interaction. Signature generated with the test-ed25519-2026 private key from the expected_signature_base." +} diff --git a/tests/conformance/vectors/request-signing/positive/012-ipv6-authority-default-port-stripped.json b/tests/conformance/vectors/request-signing/positive/012-ipv6-authority-default-port-stripped.json new file mode 100644 index 00000000..449c0637 --- /dev/null +++ b/tests/conformance/vectors/request-signing/positive/012-ipv6-authority-default-port-stripped.json @@ -0,0 +1,30 @@ +{ + "name": "IPv6 literal authority with explicit :443; canonicalization strips port but preserves brackets", + "spec_reference": "#adcp-rfc-9421-profile (@target-uri canonicalization step 2 + step 4: IPv6 brackets preserved AND default ports stripped)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://[2001:db8::1]:443/adcp/create_media_buy", + "headers": { + "Content-Type": "application/json", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "Signature": "sig1=:QplWbT2vVnF_TSfY5w5d8zQYkLpD7Bp4rxE9uKHl14UjBmUnF6mwKB8pEgoFTKHF1jVwwL14hx_AM6sFBtT9CA:" + }, + "body": "{\"plan_id\":\"plan_001\"}" + }, + "verifier_capability": { + "supported": true, + "covers_content_digest": "either", + "required_for": [ + "create_media_buy" + ] + }, + "jwks_ref": [ + "test-ed25519-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://[2001:db8::1]/adcp/create_media_buy\n\"@authority\": [2001:db8::1]\n\"content-type\": application/json\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-ed25519-2026\";alg=\"ed25519\";tag=\"adcp/request-signing/v1\"", + "expected_outcome": { + "success": true + }, + "$comment": "Intersection of bracket preservation (step 2) and default-port stripping (step 4). As-received URL has '[2001:db8::1]:443'; canonical form is '[2001:db8::1]' — port stripped, brackets retained. A naive regex that strips ':443$' from the authority string will incorrectly produce '[2001:db8::1' (eating the closing bracket) or leave the port in place. Canonical base is identical to 011-ipv6-authority by construction, so the same signature bytes verify both vectors. Signature generated with the test-ed25519-2026 private key from the expected_signature_base." +} From d72551c0ee0335dee04f1e9ea791a5e0d80f4c23 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Tue, 28 Jul 2026 22:34:31 +0200 Subject: [PATCH 2/6] test(signing): cite tracking issues in the strict-xfail ledgers --- .../conformance/signing/test_canonicalization.py | 16 ++++++++-------- .../conformance/signing/test_verifier_vectors.py | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/conformance/signing/test_canonicalization.py b/tests/conformance/signing/test_canonicalization.py index ac99e899..413e056e 100644 --- a/tests/conformance/signing/test_canonicalization.py +++ b/tests/conformance/signing/test_canonicalization.py @@ -34,18 +34,18 @@ # No UTS-46 A-label conversion on the signing path. adcp.signing has a # canonicalize_host() helper, but canonical.py does not call it, so an IDN # authority signs and verifies under a non-ASCII host. - "idn-to-punycode": "IDN host is not converted to a Punycode A-label", - "idn-mixed-case-to-punycode": "IDN host is not converted to a Punycode A-label", + "idn-to-punycode": "#977: IDN host is not converted to a Punycode A-label", + "idn-mixed-case-to-punycode": "#977: IDN host is not converted to a Punycode A-label", # urlunsplit() cannot distinguish "no query" from "empty query", so the # trailing '?' is dropped and signer/verifier can disagree on the base. - "trailing-empty-query-preserved": "trailing '?' with an empty query is dropped", + "trailing-empty-query-preserved": "#979: trailing '?' with an empty query is dropped", # Malformed authorities are canonicalized rather than rejected; the spec's # request_target_uri_malformed code is not implemented at all. - "malformed-port-without-host": "authority with a port but no host is accepted", - "malformed-userinfo-without-host": "authority with userinfo but no host is accepted", - "malformed-empty-authority": "empty authority is accepted", - "malformed-bare-ipv6": "unbracketed IPv6 literal is accepted", - "malformed-ipv6-zone-identifier": "RFC 6874 zone identifier is accepted", + "malformed-port-without-host": "#978: authority with a port but no host is accepted", + "malformed-userinfo-without-host": "#978: authority with userinfo but no host is accepted", + "malformed-empty-authority": "#978: empty authority is accepted", + "malformed-bare-ipv6": "#978: unbracketed IPv6 literal is accepted", + "malformed-ipv6-zone-identifier": "#978: RFC 6874 zone identifier is accepted", } diff --git a/tests/conformance/signing/test_verifier_vectors.py b/tests/conformance/signing/test_verifier_vectors.py index f37de429..2828c08c 100644 --- a/tests/conformance/signing/test_verifier_vectors.py +++ b/tests/conformance/signing/test_verifier_vectors.py @@ -37,17 +37,17 @@ # exemption behind. KNOWN_VERIFIER_GAPS: dict[str, str] = { "021-duplicate-signature-input-label.json": ( - "duplicate Signature-Input dictionary key is silently resolved instead of " + "#976: duplicate Signature-Input dictionary key is silently resolved instead of " "rejected (RFC 8941 §3.2 / covered-component smuggling)" ), "022-multi-valued-content-type.json": ( - "multi-valued Content-Type on a covered non-list field is not rejected at parse" + "#976: multi-valued Content-Type on a covered non-list field is not rejected at parse" ), "023-multi-valued-content-digest.json": ( - "multi-valued Content-Digest (duplicate RFC 9530 algorithm) is not rejected at parse" + "#976: multi-valued Content-Digest (duplicate RFC 9530 algorithm) is not rejected at parse" ), "026-non-ascii-host.json": ( - "raw IDN U-label in the authority is not rejected; @target-uri canonicalization " + "#977: raw IDN U-label in the authority is not rejected; @target-uri canonicalization " "applies no UTS-46 A-label conversion" ), } From e903425fdda0f37a23a52faa6b46da04bbe52437 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Tue, 28 Jul 2026 22:36:41 +0200 Subject: [PATCH 3/6] test(signing): avoid private _pytest import in vector parametrization --- tests/conformance/signing/test_canonicalization.py | 3 +-- tests/conformance/signing/test_verifier_vectors.py | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/conformance/signing/test_canonicalization.py b/tests/conformance/signing/test_canonicalization.py index 413e056e..0aa70279 100644 --- a/tests/conformance/signing/test_canonicalization.py +++ b/tests/conformance/signing/test_canonicalization.py @@ -13,7 +13,6 @@ from typing import Any import pytest -from _pytest.mark import ParameterSet from adcp.signing.canonical import ( build_signature_base, @@ -58,7 +57,7 @@ def _vectors_with_expected_base() -> list[tuple[str, Path]]: ] -def _canonicalization_params() -> list[ParameterSet]: +def _canonicalization_params() -> list[Any]: params = [] for name, case in load_canonicalization_cases(): marks = [] diff --git a/tests/conformance/signing/test_verifier_vectors.py b/tests/conformance/signing/test_verifier_vectors.py index 2828c08c..274a70f0 100644 --- a/tests/conformance/signing/test_verifier_vectors.py +++ b/tests/conformance/signing/test_verifier_vectors.py @@ -9,10 +9,10 @@ import json from pathlib import Path +from typing import Any from urllib.parse import urlsplit import pytest -from _pytest.mark import ParameterSet from adcp.signing import ( InMemoryReplayStore, @@ -103,7 +103,7 @@ def _build_options(vector: dict) -> tuple[VerifyOptions, InMemoryReplayStore]: _vector_id = lambda v: v if isinstance(v, str) else v.name # noqa: E731 -def _negative_params() -> list[ParameterSet]: +def _negative_params() -> list[Any]: """Negative vectors, with known verifier gaps marked strict-xfail.""" params = [] for name, path in load_vector_set("negative"): From 62521edb0d999dc2b9401e1cb4a8e76cbad4fa85 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Tue, 28 Jul 2026 22:39:00 +0200 Subject: [PATCH 4/6] test(signing): note why _write_manifest replaces files instead of mutating --- tests/conformance/signing/vectors.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/conformance/signing/vectors.py b/tests/conformance/signing/vectors.py index c350c5fc..e1bae089 100644 --- a/tests/conformance/signing/vectors.py +++ b/tests/conformance/signing/vectors.py @@ -98,6 +98,9 @@ def load_canonicalization_cases() -> list[tuple[str, dict[str, Any]]]: def _write_manifest() -> None: + # Shallow copy, then REPLACE "files" rather than mutating it in place -- + # ``_MANIFEST["files"]`` is what ``VECTOR_MANIFEST`` is bound to, and + # mutating it here would rewrite the pin the tests grade against. document = dict(_MANIFEST) document["files"] = dict(sorted(on_disk_files().items())) MANIFEST_PATH.write_text(json.dumps(document, indent=2) + "\n") From 071238919b46dbccd6db523bcf3d451f0219331b Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Wed, 29 Jul 2026 10:28:09 +0200 Subject: [PATCH 5/6] test(signing): assert the reject code, not just the refusal The reject branch asserted a bare ValueError -- but urlsplit raises exactly that for `https://[::1/p`, so `malformed-ipv6-missing-closing-bracket` passed on someone else's exception while five sibling cases sat in the xfail ledger. Six reject cases ship in the data; only five were recorded as gaps. No case asserted `expected_error_code` at all, so the vector set could not detect its own incompleteness -- the defect this PR exists to close. Capture the exception and assert the code the vector ships. All six reject cases now xfail together under #978 and retire together when the typed error lands. --- .../signing/test_canonicalization.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/tests/conformance/signing/test_canonicalization.py b/tests/conformance/signing/test_canonicalization.py index 0aa70279..077479a1 100644 --- a/tests/conformance/signing/test_canonicalization.py +++ b/tests/conformance/signing/test_canonicalization.py @@ -45,6 +45,12 @@ "malformed-empty-authority": "#978: empty authority is accepted", "malformed-bare-ipv6": "#978: unbracketed IPv6 literal is accepted", "malformed-ipv6-zone-identifier": "#978: RFC 6874 zone identifier is accepted", + # This one is refused today, but by urlsplit() rather than by us: a bare + # ValueError carrying no code. It belongs in the ledger with its five + # siblings so all six reject cases retire together under #978. + "malformed-ipv6-missing-closing-bracket": ( + "#978: refused by urlsplit with a bare ValueError that carries no error code" + ), } @@ -104,11 +110,18 @@ def test_canonicalization_case(name: str, case: dict[str, Any]) -> None: url = case["input_url"] if case.get("reject"): - # The spec's expected_error_code here is request_target_uri_malformed, - # which the SDK does not define. Assert the refusal, which is the - # behavioral obligation; the code mapping follows once it exists. - with pytest.raises(ValueError): + # Assert the code the vector ships, not merely that something raised. + # `https://[::1/p` is refused inside urlsplit() with a bare ValueError + # carrying no code, so a bare `pytest.raises(ValueError)` passes for the + # wrong reason and grades nothing -- the refusal has to be ours, and it + # has to name which rule fired. + with pytest.raises(ValueError) as excinfo: canonicalize_target_uri(url) + actual_code = getattr(excinfo.value, "code", None) + assert actual_code == case["expected_error_code"], ( + f"{name}: expected error code {case['expected_error_code']!r}, got " + f"{actual_code!r} from {type(excinfo.value).__name__} ({case['rule']})" + ) return assert ( From d719b5c69d03d30e45bb0beceb7524c61afbcbb1 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Wed, 29 Jul 2026 15:12:34 +0200 Subject: [PATCH 6/6] test(signing): re-pin the vendored vectors at AdCP 3.1.8 Review follow-up. main moved to 3.1.8 (#992) while this branch pinned 3.1.1, so test_manifest_pins_the_spec_version_the_sdk_targets failed once the two met. Only spec_version and source change: the 3.1.1 and 3.1.8 dist/compliance/.../test-vectors/request-signing trees are byte-identical, and regenerating the manifest reproduces all 43 file hashes unchanged. That is the pin doing its job -- it forced an explicit re-vendor decision on a spec bump rather than letting the old set ride silently. Note for the next bump: `python -m tests.conformance.signing.vectors --write` regenerates `files` only and carries spec_version/source forward, so those two fields need editing before the regen, not by it. Refs #975. --- tests/conformance/signing/vector_manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/conformance/signing/vector_manifest.json b/tests/conformance/signing/vector_manifest.json index cbd30838..ade79781 100644 --- a/tests/conformance/signing/vector_manifest.json +++ b/tests/conformance/signing/vector_manifest.json @@ -1,7 +1,7 @@ { "$comment": "Completeness pin for the vendored AdCP request-signing conformance vectors. Generated by `python -m tests.conformance.signing.vectors --write`; do not hand-edit. Graded by tests/conformance/signing/test_vector_completeness.py.", - "spec_version": "3.1.1", - "source": "adcontextprotocol/adcp @ v3.1.1 -- dist/compliance/3.1.1/test-vectors/request-signing/", + "spec_version": "3.1.8", + "source": "adcontextprotocol/adcp @ v3.1.8 -- dist/compliance/3.1.8/test-vectors/request-signing/", "files": { "README.md": "74977dcb44eef69aee2b45e78e9fc111625e101d7e8646ed9f44b521aee396e8", "canonicalization.json": "83a15584bebb0ff006ffd99a4080f5a922ea56e9e590d516ee87becaaf15ad1b",