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
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,48 @@ jobs:
run: |
pytest tests/ -v --cov=src/adcp --cov-report=term-missing

pg-replay-store:
name: PgReplayStore tests (Postgres 16)
runs-on: ubuntu-latest
services:
postgres:
# CI-local ephemeral database. POSTGRES_HOST_AUTH_METHOD=trust
# avoids shipping any password literal (real or placeholder) in
# this workflow — GitHub's default CI network is already the
# trust boundary for this throwaway service.
image: postgres:16
env:
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: adcp_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10

steps:
- uses: actions/checkout@v4

- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install dependencies (with [pg] extra)
run: |
python -m pip install --upgrade pip
pip install -e ".[dev,pg]"

- name: Run PgReplayStore tests (unit + full-wire e2e)
env:
ADCP_PG_TEST_URL: postgresql://postgres@localhost:5432/adcp_test
run: |
pytest tests/conformance/signing/test_pg_replay_store.py \
tests/conformance/signing/test_pg_replay_store_e2e.py \
-v

conventional-commits:
name: Validate conventional commit format
runs-on: ubuntu-latest
Expand Down
15 changes: 14 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ dev = [
docs = [
"pdoc3>=0.10.0",
]
pg = [
# PostgreSQL-backed PgReplayStore (and future PgIdempotencyBackend).
# psycopg3 gives both sync + async client interfaces so the same dep
# serves the sync replay store today and an async one later.
"psycopg[binary]>=3.1.0",
"psycopg-pool>=3.2.0",
]

[project.urls]
Homepage = "https://github.com/adcontextprotocol/adcp-client-python"
Expand All @@ -70,7 +77,7 @@ Issues = "https://github.com/adcontextprotocol/adcp-client-python/issues"
where = ["src"]

[tool.setuptools.package-data]
adcp = ["py.typed", "ADCP_VERSION"]
adcp = ["py.typed", "ADCP_VERSION", "signing/pg/*.sql"]

[tool.black]
line-length = 100
Expand Down Expand Up @@ -114,6 +121,12 @@ disable_error_code = ["valid-type"]
module = "tests.integration.*"
ignore_errors = true

# psycopg is an optional dep behind the [pg] extra; type stubs aren't
# guaranteed to be present when the base SDK is installed.
[[tool.mypy.overrides]]
module = ["psycopg", "psycopg.*", "psycopg_pool", "psycopg_pool.*"]
ignore_missing_imports = true

[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
Expand Down
70 changes: 68 additions & 2 deletions src/adcp/signing/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,45 @@
"""AdCP RFC 9421 request-signing profile.

Implements the transport-layer signed-request profile from the AdCP specification.
See: https://adcontextprotocol.org/docs/building/implementation/security#signed-requests-transport-layer
Implements the transport-layer signed-request profile from the AdCP
specification. See:
https://adcontextprotocol.org/docs/building/implementation/security#signed-requests-transport-layer

Quickstart
==========

The core names you'll reach for (everything else is for advanced use):

**Buyers** (signing outgoing requests):

* :func:`sign_request` — produce ``Signature`` / ``Signature-Input``
headers for one request
* :func:`load_private_key_pem` — rehydrate the PEM ``adcp-keygen`` wrote
* :class:`SigningConfig` — bundle key material for auto-signing via
``ADCPClient(signing=...)``

**Sellers** (verifying incoming requests):

* :func:`verify_starlette_request` / :func:`verify_flask_request` —
framework-shaped wrappers around :func:`verify_request_signature`
* :class:`VerifyOptions` — the knobs (capability, jwks_resolver,
replay_store, revocation_checker)
* :class:`VerifierCapability` — what the seller advertises (e.g.
``required_for={"create_media_buy"}``)
* :class:`StaticJwksResolver` — for testing; use
:class:`CachingJwksResolver` against a live ``jwks_uri``
* :class:`SignatureVerificationError` — raised on rejection; ``.code``
is the spec error string
* :func:`unauthorized_response_headers` — builds the 401
``WWW-Authenticate: Signature error="..."`` header
* :class:`InMemoryReplayStore` for single-process deployments;
:class:`PgReplayStore` (behind ``[pg]`` extra) for multi-worker

**Governance agents**:

* :class:`CachingRevocationChecker` — fetches + caches a signed
revocation list from ``{issuer}/.well-known/governance-revocations.json``
* Async variants: :class:`AsyncCachingJwksResolver`,
:class:`AsyncCachingRevocationChecker`
"""

from __future__ import annotations
Expand Down Expand Up @@ -35,6 +73,7 @@
b64url_encode,
extract_signature_bytes,
format_signature_header,
load_private_key_pem,
private_key_from_jwk,
public_key_from_jwk,
sign_signature_base,
Expand Down Expand Up @@ -117,6 +156,31 @@
verify_request_signature,
)

# Conditional import: PgReplayStore needs the [pg] extra. Always expose
# the name — if psycopg isn't installed we fall through to a stub class
# whose constructor raises ImportError with the install hint. Exposing
# None would give callers a confusing ``TypeError: 'NoneType' object is
# not callable`` on instantiation; the stub turns that into a
# self-explanatory error at the right moment.
try:
from adcp.signing.pg import PgReplayStore # noqa: F401
except ImportError: # pragma: no cover — exercised by the [pg] extra tests

class PgReplayStore: # type: ignore[no-redef]
"""Stub raised when ``adcp[pg]`` isn't installed.

Attempting to instantiate raises :class:`ImportError` with the
install-hint text from :mod:`adcp.signing.pg.replay_store`.
"""

def __init__(self, *args: object, **kwargs: object) -> None:
raise ImportError(
"PgReplayStore requires psycopg3 and psycopg-pool. Install the "
"'pg' extra: `pip install 'adcp[pg]'` (Poetry: "
"`poetry add 'adcp[pg]'`)."
)


__all__ = [
"ALG_ED25519",
"ALG_ES256",
Expand All @@ -141,6 +205,7 @@
"JwsUnknownKeyError",
"MAX_WINDOW_SECONDS",
"NONCE_BYTES",
"PgReplayStore",
"REQUEST_SIGNATURE_ALG_NOT_ALLOWED",
"REQUEST_SIGNATURE_COMPONENTS_INCOMPLETE",
"REQUEST_SIGNATURE_COMPONENTS_UNEXPECTED",
Expand Down Expand Up @@ -195,6 +260,7 @@
"default_revocation_list_fetcher",
"extract_signature_bytes",
"format_signature_header",
"load_private_key_pem",
"operation_needs_signing",
"parse_signature_input_header",
"private_key_from_jwk",
Expand Down
46 changes: 45 additions & 1 deletion src/adcp/signing/crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from typing import Any

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, ed25519
from cryptography.hazmat.primitives.asymmetric.utils import (
decode_dss_signature,
Expand Down Expand Up @@ -50,6 +50,50 @@ def b64url_encode(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")


def load_private_key_pem(pem: bytes, *, password: bytes | None = None) -> PrivateKey:
"""Load an Ed25519 or P-256 private key from PKCS8 PEM bytes.

Closes the loop between ``adcp-keygen`` (which writes a PEM) and
:func:`sign_request` (which takes a ``PrivateKey`` object), so
integrators don't need a direct ``cryptography`` import just to
rehydrate the key.

Parameters
----------
pem:
PEM-encoded PKCS8 private key as bytes. Read via
``pathlib.Path(...).read_bytes()``.
password:
Passphrase if the PEM is encrypted (``adcp-keygen --encrypt``).
Passed through to the cryptography loader as bytes.

Returns
-------
PrivateKey
An :class:`Ed25519PrivateKey` or
:class:`EllipticCurvePrivateKey` ready to pass into
:func:`sign_request`.

Raises
------
ValueError
The PEM is not Ed25519 or ES256 (P-256). These are the only
algorithms the AdCP request-signing profile allows.
"""
key = serialization.load_pem_private_key(pem, password=password)
if not isinstance(key, (ed25519.Ed25519PrivateKey, ec.EllipticCurvePrivateKey)):
raise ValueError(
f"unsupported private key type {type(key).__name__} — "
f"AdCP signing accepts Ed25519 or ECDSA-P-256 only"
)
if isinstance(key, ec.EllipticCurvePrivateKey) and not isinstance(key.curve, ec.SECP256R1):
raise ValueError(
f"EC key curve {key.curve.name} is not supported — only "
f"P-256 (SECP256R1) is allowed"
)
return key


def public_key_from_jwk(jwk: dict[str, Any]) -> PublicKey:
"""Reconstruct a public key from its JWK."""
kty = jwk.get("kty")
Expand Down
24 changes: 21 additions & 3 deletions src/adcp/signing/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,28 @@ def verify_flask_request(request: Any, *, options: VerifyOptions) -> VerifiedSig


async def verify_starlette_request(request: Any, *, options: VerifyOptions) -> VerifiedSigner:
"""Verify a Starlette / FastAPI `Request` object against the AdCP profile.
"""Verify a Starlette / FastAPI ``Request`` object against the AdCP profile.

Consumes `await request.body()` — if downstream code also needs the body,
it must read `request.state` or the returned `VerifiedSigner`-side context.
Consumes ``await request.body()`` once — Starlette caches the result
internally, so downstream handlers calling ``request.body()`` or
``request.json()`` again will get the same bytes. If your handler
needs the parsed body AFTER this verifier succeeds, call
``await request.body()`` yourself downstream; there's no hidden
side channel on the returned :class:`VerifiedSigner`.

Returns
-------
VerifiedSigner
On success — carries the verified ``key_id`` and metadata.

Raises
------
SignatureVerificationError
On any failure of the AdCP verifier checklist. The ``.code``
attribute holds the spec's error code string (e.g.
``request_signature_replayed``) and ``.step`` points at the
failed checklist step. Frameworks typically map this to a 401
with :func:`unauthorized_response_headers`.
"""
body = await request.body()
return verify_request_signature(
Expand Down
22 changes: 22 additions & 0 deletions src/adcp/signing/pg/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""PostgreSQL-backed implementations for the signing module.

This sub-package ships backends that require PostgreSQL via psycopg3.
They live here (and behind the ``[pg]`` optional extra) so the base
``adcp.signing`` import path stays free of SQL dependencies for users
who only need the pure-Python primitives.

Available when ``adcp[pg]`` is installed:

* :class:`PgReplayStore` — multi-instance-safe replay store for the
RFC 9421 verifier pipeline.

The schema DDL ships alongside the Python code at
``adcp/signing/pg/replay_store.sql`` so integrators can run it through
whatever migration tool they use (Alembic, Flyway, psql, ...).
"""

from __future__ import annotations

from adcp.signing.pg.replay_store import PgReplayStore

__all__ = ["PgReplayStore"]
Loading
Loading