Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 83 additions & 8 deletions tests/conformance/signing/test_canonicalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
107 changes: 107 additions & 0 deletions tests/conformance/signing/test_vector_completeness.py
Original file line number Diff line number Diff line change
@@ -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"
48 changes: 40 additions & 8 deletions tests/conformance/signing/test_verifier_vectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import json
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit

import pytest
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
50 changes: 50 additions & 0 deletions tests/conformance/signing/vector_manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading