diff --git a/tests/conformance/signing/test_canonicalization.py b/tests/conformance/signing/test_canonicalization.py index 55284992..077479a1 100644 --- a/tests/conformance/signing/test_canonicalization.py +++ b/tests/conformance/signing/test_canonicalization.py @@ -10,26 +10,67 @@ import json from pathlib import Path +from typing import Any import pytest 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": "#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": "#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": "#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", + # 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" + ), +} 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[Any]: + 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 +98,40 @@ 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"): + # 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 ( + 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..274a70f0 100644 --- a/tests/conformance/signing/test_verifier_vectors.py +++ b/tests/conformance/signing/test_verifier_vectors.py @@ -9,6 +9,7 @@ import json from pathlib import Path +from typing import Any from urllib.parse import urlsplit import pytest @@ -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": ( + "#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": ( + "#976: multi-valued Content-Type on a covered non-list field is not rejected at parse" + ), + "023-multi-valued-content-digest.json": ( + "#976: multi-valued Content-Digest (duplicate RFC 9530 algorithm) is not rejected at parse" + ), + "026-non-ascii-host.json": ( + "#977: 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[Any]: + """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..ade79781 --- /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.8", + "source": "adcontextprotocol/adcp @ v3.1.8 -- dist/compliance/3.1.8/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..e1bae089 --- /dev/null +++ b/tests/conformance/signing/vectors.py @@ -0,0 +1,116 @@ +"""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: + # 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") + 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." +}