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
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -391,9 +391,25 @@ jobs:
# own CI running AdCP's own canonical runner — tracking latest
# surfaces protocol drift as soon as it ships, which is the
# point of this job.
#
# Vendor missing fixtures into the SDK install:
# ``@adcp/sdk`` does not ship two fixtures its storyboard runner
# needs — ``aao-reference-formats.json`` (AAO format catalog,
# upstream adcontextprotocol/adcp#3307) and
# ``v1-canonical-mapping.json`` (v1→v2 format mapping registry
# at schemas/cache/3.1.0-beta.2/registries/). The SDK's own
# error messages recommend vendoring both at the exact paths
# below. We keep copies under ``tests/fixtures/`` and drop them
# into the SDK's expected locations post-install; idempotent if
# upstream later ships them in the npm tarball.
run: |
npm install -g @adcp/sdk@latest
adcp --version
SDK_ROOT="$(npm root -g)/@adcp/sdk"
mkdir -p "${SDK_ROOT}/test/lib/v2-projection-fixtures"
cp tests/fixtures/aao-reference-formats.json "${SDK_ROOT}/test/lib/v2-projection-fixtures/aao-reference-formats.json"
mkdir -p "${SDK_ROOT}/schemas/cache/3.1.0-beta.2/registries"
cp tests/fixtures/v1-canonical-mapping.json "${SDK_ROOT}/schemas/cache/3.1.0-beta.2/registries/v1-canonical-mapping.json"

- name: Install dependencies
run: |
Expand Down Expand Up @@ -548,9 +564,16 @@ jobs:
pip install "sqlalchemy>=2.0" "asyncpg>=0.29" "respx>=0.20"

- name: Pre-install @adcp/sdk (once, then call binary directly)
# See the comment on the storyboard job's install step for the
# AAO reference-formats fixture rationale (upstream adcp#3307).
run: |
npm install -g @adcp/sdk@latest
adcp --version
SDK_ROOT="$(npm root -g)/@adcp/sdk"
mkdir -p "${SDK_ROOT}/test/lib/v2-projection-fixtures"
cp tests/fixtures/aao-reference-formats.json "${SDK_ROOT}/test/lib/v2-projection-fixtures/aao-reference-formats.json"
mkdir -p "${SDK_ROOT}/schemas/cache/3.1.0-beta.2/registries"
cp tests/fixtures/v1-canonical-mapping.json "${SDK_ROOT}/schemas/cache/3.1.0-beta.2/registries/v1-canonical-mapping.json"

- name: Start JS mock-server upstream
run: |
Expand Down Expand Up @@ -757,9 +780,16 @@ jobs:
${{ runner.os }}-npm-

- name: Pre-install @adcp/sdk
# See the comment on the storyboard job's install step for the
# AAO reference-formats fixture rationale (upstream adcp#3307).
run: |
npm install -g @adcp/sdk@latest
adcp --version
SDK_ROOT="$(npm root -g)/@adcp/sdk"
mkdir -p "${SDK_ROOT}/test/lib/v2-projection-fixtures"
cp tests/fixtures/aao-reference-formats.json "${SDK_ROOT}/test/lib/v2-projection-fixtures/aao-reference-formats.json"
mkdir -p "${SDK_ROOT}/schemas/cache/3.1.0-beta.2/registries"
cp tests/fixtures/v1-canonical-mapping.json "${SDK_ROOT}/schemas/cache/3.1.0-beta.2/registries/v1-canonical-mapping.json"

- name: Install dependencies
run: |
Expand Down Expand Up @@ -858,9 +888,16 @@ jobs:
${{ runner.os }}-npm-

- name: Pre-install @adcp/sdk
# See the comment on the storyboard job's install step for the
# AAO reference-formats fixture rationale (upstream adcp#3307).
run: |
npm install -g @adcp/sdk@latest
adcp --version
SDK_ROOT="$(npm root -g)/@adcp/sdk"
mkdir -p "${SDK_ROOT}/test/lib/v2-projection-fixtures"
cp tests/fixtures/aao-reference-formats.json "${SDK_ROOT}/test/lib/v2-projection-fixtures/aao-reference-formats.json"
mkdir -p "${SDK_ROOT}/schemas/cache/3.1.0-beta.2/registries"
cp tests/fixtures/v1-canonical-mapping.json "${SDK_ROOT}/schemas/cache/3.1.0-beta.2/registries/v1-canonical-mapping.json"

- name: Install dependencies
run: |
Expand Down
20 changes: 20 additions & 0 deletions src/adcp/signing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,14 +162,23 @@
)
from adcp.signing.digest import compute_content_digest_sha256, content_digest_matches
from adcp.signing.errors import (
REQUEST_SIGNATURE_AGENT_NOT_IN_BRAND_JSON,
REQUEST_SIGNATURE_ALG_NOT_ALLOWED,
REQUEST_SIGNATURE_BRAND_JSON_AMBIGUOUS,
REQUEST_SIGNATURE_BRAND_JSON_MALFORMED,
REQUEST_SIGNATURE_BRAND_JSON_UNREACHABLE,
REQUEST_SIGNATURE_BRAND_JSON_URL_MISSING,
REQUEST_SIGNATURE_BRAND_ORIGIN_MISMATCH,
REQUEST_SIGNATURE_CAPABILITIES_UNREACHABLE,
REQUEST_SIGNATURE_COMPONENTS_INCOMPLETE,
REQUEST_SIGNATURE_COMPONENTS_UNEXPECTED,
REQUEST_SIGNATURE_DIGEST_MISMATCH,
REQUEST_SIGNATURE_HEADER_MALFORMED,
REQUEST_SIGNATURE_INVALID,
REQUEST_SIGNATURE_JWKS_UNAVAILABLE,
REQUEST_SIGNATURE_JWKS_UNTRUSTED,
REQUEST_SIGNATURE_KEY_ORIGIN_MISMATCH,
REQUEST_SIGNATURE_KEY_ORIGIN_MISSING,
REQUEST_SIGNATURE_KEY_PURPOSE_INVALID,
REQUEST_SIGNATURE_KEY_REVOKED,
REQUEST_SIGNATURE_KEY_UNKNOWN,
Expand Down Expand Up @@ -219,6 +228,7 @@
verify_detached_jws,
verify_jws_document,
)
from adcp.signing.key_origins import check_key_origin_consistency
from adcp.signing.keygen import generate_signing_keypair, pem_to_adcp_jwk
from adcp.signing.middleware import (
unauthorized_response_headers,
Expand Down Expand Up @@ -336,14 +346,23 @@ def __init__(self, *args: object, **kwargs: object) -> None:
"NEGATIVE_CACHE_TTL_SECONDS",
"NONCE_BYTES",
"PgReplayStore",
"REQUEST_SIGNATURE_AGENT_NOT_IN_BRAND_JSON",
"REQUEST_SIGNATURE_ALG_NOT_ALLOWED",
"REQUEST_SIGNATURE_BRAND_JSON_AMBIGUOUS",
"REQUEST_SIGNATURE_BRAND_JSON_MALFORMED",
"REQUEST_SIGNATURE_BRAND_JSON_UNREACHABLE",
"REQUEST_SIGNATURE_BRAND_JSON_URL_MISSING",
"REQUEST_SIGNATURE_BRAND_ORIGIN_MISMATCH",
"REQUEST_SIGNATURE_CAPABILITIES_UNREACHABLE",
"REQUEST_SIGNATURE_COMPONENTS_INCOMPLETE",
"REQUEST_SIGNATURE_COMPONENTS_UNEXPECTED",
"REQUEST_SIGNATURE_DIGEST_MISMATCH",
"REQUEST_SIGNATURE_HEADER_MALFORMED",
"REQUEST_SIGNATURE_INVALID",
"REQUEST_SIGNATURE_JWKS_UNAVAILABLE",
"REQUEST_SIGNATURE_JWKS_UNTRUSTED",
"REQUEST_SIGNATURE_KEY_ORIGIN_MISMATCH",
"REQUEST_SIGNATURE_KEY_ORIGIN_MISSING",
"REQUEST_SIGNATURE_KEY_PURPOSE_INVALID",
"REQUEST_SIGNATURE_KEY_REVOKED",
"REQUEST_SIGNATURE_KEY_UNKNOWN",
Expand Down Expand Up @@ -395,6 +414,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
"build_signature_base",
"canonicalize_authority",
"canonicalize_target_uri",
"check_key_origin_consistency",
"compute_content_digest_sha256",
"content_digest_matches",
"decode_standard_webhook_secret",
Expand Down
61 changes: 60 additions & 1 deletion src/adcp/signing/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,34 @@

from __future__ import annotations

from collections.abc import Mapping


class SignatureVerificationError(Exception):
"""Raised when a request signature fails any step of the verifier checklist."""
"""Raised when a request signature fails any step of the verifier checklist.

``detail`` carries the spec-mandated structured fields for codes that
require them — e.g. ``request_signature_key_origin_mismatch`` carries
``{purpose, expected_origin, actual_origin}`` per ADCP #3690
security.mdx step 7, and ``request_signature_brand_json_url_missing``
carries ``{agent_url}`` per the same section's rejection-code table.
Middleware adapters surface these as structured fields on the 401
response or in a DLQ payload; ``str(exc)`` continues to render the
free-form message for unstructured logs.
"""

def __init__(
self,
code: str,
*,
step: int | str | None = None,
message: str | None = None,
detail: Mapping[str, str] | None = None,
) -> None:
super().__init__(message or code)
self.code = code
self.step = step
self.detail = dict(detail) if detail is not None else None


REQUEST_SIGNATURE_REQUIRED = "request_signature_required"
Expand All @@ -42,6 +56,28 @@ def __init__(
REQUEST_SIGNATURE_JWKS_UNTRUSTED = "request_signature_jwks_untrusted"
REQUEST_SIGNATURE_RATE_ABUSE = "request_signature_rate_abuse"

# brand.json discovery chain (ADCP #3690). Verifiers bootstrap an agent's
# signing keys via ``identity.brand_json_url`` on the agent's
# ``get_adcp_capabilities`` response → brand.json → ``agents[]`` →
# ``jwks_uri``. Each step has a dedicated rejection code so callers can
# disambiguate retryable transport failures (``*_unreachable``) from
# misconfiguration (``*_missing`` / ``*_malformed`` / ``*_mismatch``).
REQUEST_SIGNATURE_BRAND_JSON_URL_MISSING = "request_signature_brand_json_url_missing"
REQUEST_SIGNATURE_CAPABILITIES_UNREACHABLE = "request_signature_capabilities_unreachable"
REQUEST_SIGNATURE_BRAND_JSON_UNREACHABLE = "request_signature_brand_json_unreachable"
REQUEST_SIGNATURE_BRAND_JSON_MALFORMED = "request_signature_brand_json_malformed"
REQUEST_SIGNATURE_BRAND_ORIGIN_MISMATCH = "request_signature_brand_origin_mismatch"
REQUEST_SIGNATURE_AGENT_NOT_IN_BRAND_JSON = "request_signature_agent_not_in_brand_json"
REQUEST_SIGNATURE_BRAND_JSON_AMBIGUOUS = "request_signature_brand_json_ambiguous"

# identity.key_origins consistency check (ADCP #3690). For every purpose
# declared under capabilities ``identity.key_origins``, the resolved
# ``jwks_uri`` host MUST equal the declared origin (after IDNA-A-label
# canonicalization). Mismatch → ``_key_origin_mismatch``. Missing
# declaration when signing posture is asserted → ``_key_origin_missing``.
REQUEST_SIGNATURE_KEY_ORIGIN_MISMATCH = "request_signature_key_origin_mismatch"
REQUEST_SIGNATURE_KEY_ORIGIN_MISSING = "request_signature_key_origin_missing"

# Webhook-signing error taxonomy — adcp#2423 / webhooks.mdx + security.mdx.
# Distinct strings from the request-signing family so receivers can route the
# 401 response through webhook-specific observability.
Expand All @@ -64,6 +100,20 @@ def __init__(
WEBHOOK_SIGNATURE_JWKS_UNTRUSTED = "webhook_signature_jwks_untrusted"
WEBHOOK_SIGNATURE_RATE_ABUSE = "webhook_signature_rate_abuse"

# brand.json discovery chain mirrors for the webhook profile. The chain
# walks identically (capabilities → brand.json → agents[] → jwks_uri),
# just consulting the ``webhook_signing`` purpose under
# ``identity.key_origins`` instead of ``request_signing``.
WEBHOOK_SIGNATURE_BRAND_JSON_URL_MISSING = "webhook_signature_brand_json_url_missing"
WEBHOOK_SIGNATURE_CAPABILITIES_UNREACHABLE = "webhook_signature_capabilities_unreachable"
WEBHOOK_SIGNATURE_BRAND_JSON_UNREACHABLE = "webhook_signature_brand_json_unreachable"
WEBHOOK_SIGNATURE_BRAND_JSON_MALFORMED = "webhook_signature_brand_json_malformed"
WEBHOOK_SIGNATURE_BRAND_ORIGIN_MISMATCH = "webhook_signature_brand_origin_mismatch"
WEBHOOK_SIGNATURE_AGENT_NOT_IN_BRAND_JSON = "webhook_signature_agent_not_in_brand_json"
WEBHOOK_SIGNATURE_BRAND_JSON_AMBIGUOUS = "webhook_signature_brand_json_ambiguous"
WEBHOOK_SIGNATURE_KEY_ORIGIN_MISMATCH = "webhook_signature_key_origin_mismatch"
WEBHOOK_SIGNATURE_KEY_ORIGIN_MISSING = "webhook_signature_key_origin_missing"

# Code-family translation used by the webhook verifier wrapper. The verifier
# pipeline raises request_signature_* codes; the wrapper retags them into
# webhook_signature_* before exposing to callers. Keeps the 300-line verifier
Expand All @@ -87,4 +137,13 @@ def __init__(
REQUEST_SIGNATURE_JWKS_UNAVAILABLE: WEBHOOK_SIGNATURE_JWKS_UNAVAILABLE,
REQUEST_SIGNATURE_JWKS_UNTRUSTED: WEBHOOK_SIGNATURE_JWKS_UNTRUSTED,
REQUEST_SIGNATURE_RATE_ABUSE: WEBHOOK_SIGNATURE_RATE_ABUSE,
REQUEST_SIGNATURE_BRAND_JSON_URL_MISSING: WEBHOOK_SIGNATURE_BRAND_JSON_URL_MISSING,
REQUEST_SIGNATURE_CAPABILITIES_UNREACHABLE: WEBHOOK_SIGNATURE_CAPABILITIES_UNREACHABLE,
REQUEST_SIGNATURE_BRAND_JSON_UNREACHABLE: WEBHOOK_SIGNATURE_BRAND_JSON_UNREACHABLE,
REQUEST_SIGNATURE_BRAND_JSON_MALFORMED: WEBHOOK_SIGNATURE_BRAND_JSON_MALFORMED,
REQUEST_SIGNATURE_BRAND_ORIGIN_MISMATCH: WEBHOOK_SIGNATURE_BRAND_ORIGIN_MISMATCH,
REQUEST_SIGNATURE_AGENT_NOT_IN_BRAND_JSON: WEBHOOK_SIGNATURE_AGENT_NOT_IN_BRAND_JSON,
REQUEST_SIGNATURE_BRAND_JSON_AMBIGUOUS: WEBHOOK_SIGNATURE_BRAND_JSON_AMBIGUOUS,
REQUEST_SIGNATURE_KEY_ORIGIN_MISMATCH: WEBHOOK_SIGNATURE_KEY_ORIGIN_MISMATCH,
REQUEST_SIGNATURE_KEY_ORIGIN_MISSING: WEBHOOK_SIGNATURE_KEY_ORIGIN_MISSING,
}
Loading
Loading