From 2edbcc61f424921161aa3e5b7f4ce7ec11a97d6b Mon Sep 17 00:00:00 2001 From: Nas Kavian Date: Sat, 3 Oct 2026 19:17:18 -0700 Subject: [PATCH] feat(tap): add optional Python seller verification --- .github/workflows/release.yml | 7 +- README.md | 99 +++++- conformance/README.md | 8 + conformance/adapter.py | 5 + conformance/inflow-specs.lock.json | 2 +- conformance/tap.py | 108 +++++++ examples/README.md | 43 ++- examples/tap_seller.py | 88 ++++++ pyproject.toml | 1 + scripts/conformance.mjs | 12 +- scripts/verify_distribution.py | 65 +++- src/inflowpay/tap/__init__.py | 1 + src/inflowpay/tap/_keys.py | 124 ++++++++ src/inflowpay/tap/_signature.py | 155 ++++++++++ src/inflowpay/tap/_types.py | 51 +++ src/inflowpay/tap/seller.py | 125 ++++++++ tests/test_release.py | 96 ++++++ tests/test_tap_examples.py | 151 +++++++++ tests/test_tap_keys.py | 256 +++++++++++++++ tests/test_tap_seller.py | 478 +++++++++++++++++++++++++++++ uv.lock | 6 +- 21 files changed, 1864 insertions(+), 17 deletions(-) create mode 100644 conformance/tap.py create mode 100644 examples/tap_seller.py create mode 100644 src/inflowpay/tap/__init__.py create mode 100644 src/inflowpay/tap/_keys.py create mode 100644 src/inflowpay/tap/_signature.py create mode 100644 src/inflowpay/tap/_types.py create mode 100644 src/inflowpay/tap/seller.py create mode 100644 tests/test_tap_examples.py create mode 100644 tests/test_tap_keys.py create mode 100644 tests/test_tap_seller.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e4e1ad..459d881 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,7 +60,7 @@ jobs: exit 1 fi version=$(python scripts/release.py) - if git show-ref --verify --quiet "refs/tags/v$version"; then + if [[ "$GITHUB_EVENT_NAME" != pull_request ]] && git show-ref --verify --quiet "refs/tags/v$version"; then test "$(git rev-parse "v$version^{commit}")" = "$GITHUB_SHA" fi echo "version=$version" >> "$GITHUB_OUTPUT" @@ -106,7 +106,10 @@ jobs: node scripts/interoperability.mjs ../node-sdk "$RUNNER_TEMP/release-reports/interoperability.json" make build uv run --locked python scripts/verify_distribution.py --dist-dir dist - python scripts/release.py --dist-dir dist + # Feature PRs can keep the published version; release candidates must be uploadable. + if [[ "$GITHUB_EVENT_NAME" != pull_request ]]; then + python scripts/release.py --dist-dir dist + fi cp coverage.json "$RUNNER_TEMP/release-reports/coverage.json" (cd dist && sha256sum * > "$RUNNER_TEMP/release-reports/SHA256SUMS") - uses: actions/upload-artifact@v7 diff --git a/README.md b/README.md index 15023de..73c0bea 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,109 @@ pinned InFlow contract fixtures and produce reports for Python 3.11–3.14. Protocol and framework dependencies are optional. The `mpp` and `x402` extras select payment libraries; `evm` and `svm` select x402 external-wallet dependencies; `mcp` selects MCP dependencies for both protocols; `fastapi` selects the optional -web framework. The base package does not install those extras. +web framework. The `tap` extra installs Ed25519 cryptography for agent recognition, +independently of payment libraries. The base package does not install those extras. Runtime dependencies use compatible version ranges; `uv.lock` records the exact versions used in development and CI. MCP dependencies use the 1.x line required by pympp. No framework or blockchain package is imported by `import inflowpay`. +## TAP agent recognition + +Visa Trusted Agent Protocol (TAP) lets a Seller recognize a request signed by a +trusted Agent key. It does **not** identify the customer, authorize access to their +account, approve a purchase, or prove payment. Keep application authentication, +AEP enrollment, and MPP or x402 payment checks separate. + +```sh +pip install 'inflowpay[tap]' +``` + +The import is `inflowpay.tap.seller`. No InFlow account or API key is needed for +verification. The default resolver retrieves public keys from +`https://mcp.visa.com/.well-known/jwks`; a successful request needs a signature +created with a private key registered with that trusted source. + +```python +from inflowpay.tap.seller import TapRequest, TapVerifier + + +async def verify_agent_request(verifier: TapVerifier, method, absolute_url, headers, body): + return await verifier.verify( + TapRequest( + method=method, + url=absolute_url, + headers=headers, + body=body, + ) + ) +``` + +Create the verifier at server startup and close it at shutdown with `aclose()` +or an application-lifetime `async with TapVerifier()` scope. Pass that verifier +to request handlers, as the [runnable TAP example](examples/README.md#tap-agent-recognition) +does. Creating one per request discards its key cache and process-local replay +history. `verify()` returns immutable `TapVerificationFacts`; alternatively, +`await verifier.with_verified(request, async_handler)` invokes your handler only +after signature verification and the replay claim succeed, returning its result. + +Supply the method without changing its case, the absolute external URL with its +original encoded path and query, and the exact body bytes. `body=None` means no +body; `b""` is a supplied empty body and still requires signed `content-digest` +and `content-type` fields. Strings are encoded as UTF-8. Do not parse and +reserialize JSON before verification. Header names are case-insensitive. A mapping +can contain string values or lists of values; `httpx.Headers` is also accepted. +Duplicate values for required fields are rejected, not silently selected. +Obtain the external origin from trusted deployment configuration rather than +unvalidated `Forwarded` or `X-Forwarded-*` headers. + +This implements InFlow's restricted Visa profile: one `sig2` Ed25519 signature +covering method, authority, path and query, plus body digest and content type when +a body is supplied. Signature lifetimes are at most eight minutes. Both +`ed25519` and `Ed25519` are accepted; facts report `ed25519`. Intent is `browse` +or `pay`, reflecting the signer's tag, not proof of payment. Repeated signature +parameters use their last value while retaining their first position, as required +by Structured Fields; duplicate covered components remain invalid. + +### Keys, replay storage and cleanup + +`VisaTapKeyResolver` accepts `url`, `cache_ttl` (3600 seconds), `cache_max_age` +(86400 seconds), `timeout` (3 seconds), `clock` and an optional HTTPX `transport`. +It shares concurrent refreshes, replaces the whole key set on success, and +remembers missing identifiers within that cache generation. A failed retrieval +can use a previously trusted matching key within `cache_max_age`; it cannot +introduce an unknown key or restore one removed by a successful refresh. +Redirects are not followed. The resolver owns its transport and closes it with +`aclose()`; do not share that transport with other clients. + +For another trusted key source, pass `key_resolver` to `TapVerifier`. Implement +the public `TapKeyResolver` protocol's async `resolve(keyid, algorithm)` method, +returning a trusted `cryptography` `Ed25519PublicKey` or `None`. The request's +untrusted key identifier must not choose a network destination. The verifier +still checks the signature. An explicitly supplied resolver remains application-owned; +the verifier closes only the default resolver it creates itself. + +`MemoryTapReplayStore` atomically claims a `(keyid, nonce)` pair until expiration, +but only within one process. For multiple workers or servers, supply a shared +`TapReplayStore` with an async, atomic `claim(keyid, nonce, expires)` operation. +Return `False` for a retained duplicate; let storage failures propagate. Invalid +signatures never consume a claim, and store failures never invoke the handler. +Nonce replay protection is not payment idempotency. + +Python clocks return Unix seconds (`time.time` by default), rather than Node's +milliseconds. Validity is checked when verification starts, not again after +key retrieval or replay storage. Cancelling a caller stops its wait without +cancelling a shared key refresh needed by other requests. Closing the resolver +cancels and drains that refresh. Use a resolver on one event loop, and keep it +open until requests have finished. + +`TapVerificationError.code` distinguishes malformed input, digest mismatch, +invalid lifetime, not-yet-valid or expired signatures, missing or unavailable +keys, invalid signatures, and replayed nonces. Exceptions from a custom resolver, +store, or handler propagate unchanged. Your application chooses the HTTP response; +the example returns a generic 401 for verification failures without exposing key +service details. TAP does not enable or modify any payment route automatically. + ## Client configuration and lifetime `ClientOptions` selects `production` (the default, `https://api.inflowpay.ai`) or diff --git a/conformance/README.md b/conformance/README.md index 67de244..9b4c2dc 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -41,6 +41,14 @@ artifact per Python version. A failed required case fails the job. | MPP | Public codecs, `BuyerMethod.create_credential`, Seller preparation/validation, and pympp's `broadcast_credential` and `pay` | Wire data, approvals, cancellation, Buyer subscriptions, validation before broadcast, idempotency, and route binding. | | x402 | Public identifier helpers, `Seller.offers`/`route`, `Buyer.prepare`, and `Facilitator.verify`/`settle` | Offer construction, sponsorship declarations, approval lifecycle, cancellation, concurrent waits, payment identifiers, and verification/settlement. | +The TAP suite calls `TapVerifier.with_verified`, `VisaTapKeyResolver` and +`MemoryTapReplayStore` through the public `inflowpay.tap.seller` module. Its 92 +cases use real Ed25519 signatures, controlled clocks and loopback key endpoints. +They cover request binding, Structured Field parameters, validity intervals, +replay, cache replacement, outages and application-supplied failures. The adapter +does not parse signatures, construct signature bases or implement verification. +Handler and replay-claim counts come from the actual callback and store boundary. + The runner owns the loopback HTTP servers, expected request sequences and results. The Python process receives inputs, not expected outcomes or response scripts. Polling, retry, cancellation, validation and broadcast remain in the SDK and its diff --git a/conformance/adapter.py b/conformance/adapter.py index 7b84d3d..8e2d7e2 100644 --- a/conformance/adapter.py +++ b/conformance/adapter.py @@ -13,6 +13,7 @@ from starlette.responses import JSONResponse from x402.schemas import PaymentPayload, PaymentRequirements, ResourceInfo +from conformance.tap import tap_execute from inflowpay import ClientOptions, InflowApiError, mpp, x402 from inflowpay.mpp.buyer import ( BuyerMethod, @@ -438,6 +439,10 @@ async def respond(request: Data) -> Data: result = await x402_execute(operation, data) elif operation.startswith("runtime."): result = await runtime_execute(operation, data) + elif operation == "tap.seller.verify": + if data.get("resolver") == "http": + options(data) + result = await tap_execute(data) else: raise RuntimeError("Unknown operation") observation = {"result": result} diff --git a/conformance/inflow-specs.lock.json b/conformance/inflow-specs.lock.json index e425ce7..32ce2df 100644 --- a/conformance/inflow-specs.lock.json +++ b/conformance/inflow-specs.lock.json @@ -1,4 +1,4 @@ { "repository": "inflowpayai/inflow-specs", - "revision": "d79cc3ab3b3acde196e41d15783ed3d119f19379" + "revision": "7737099308106a1cabaab57de1d621d881cce0e0" } diff --git a/conformance/tap.py b/conformance/tap.py new file mode 100644 index 0000000..1785ad0 --- /dev/null +++ b/conformance/tap.py @@ -0,0 +1,108 @@ +import asyncio +import base64 +from contextlib import AsyncExitStack +from copy import deepcopy +from dataclasses import asdict +from typing import Any + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from inflowpay.tap.seller import ( + MemoryTapReplayStore, + TapKeyResolver, + TapRequest, + TapVerificationError, + TapVerificationFacts, + TapVerifier, + VisaTapKeyResolver, +) + + +class InjectedFailure(Exception): + pass + + +async def tap_execute(data: dict[str, Any]) -> dict[str, Any]: + now = data["steps"][0]["now_ms"] / 1000 + handler_calls = 0 + claim_calls = 0 + memory = MemoryTapReplayStore(lambda: now) + + class Store: + async def claim(self, keyid: str, nonce: str, expires: int) -> bool: + nonlocal claim_calls + claim_calls += 1 + if data.get("store_failure"): + raise InjectedFailure("CUSTOM_STORE_FAILED") + return await memory.claim(keyid, nonce, expires) + + class Resolver: + async def resolve(self, keyid: str, algorithm: str) -> Ed25519PublicKey | None: + nonlocal now + if data.get("resolver_failure"): + raise InjectedFailure("CUSTOM_RESOLVER_FAILED") + if "resolver_completion_ms" in data: + now = data["resolver_completion_ms"] / 1000 + if keyid != data["key"]["kid"] or algorithm != "ed25519": + return None + return Ed25519PublicKey.from_public_bytes( + base64.urlsafe_b64decode(data["key"]["x"] + "=") + ) + + async with AsyncExitStack() as stack: + resolver: TapKeyResolver = Resolver() + if data.get("resolver") == "http": + resolver = await stack.enter_async_context( + VisaTapKeyResolver( + url=data["base_url"] + "/keys", + clock=lambda: now, + cache_ttl=data.get("cache_ttl_ms", 3600000) / 1000, + cache_max_age=data.get("cache_max_age_ms", 86400000) / 1000, + ) + ) + verifier = await stack.enter_async_context( + TapVerifier( + key_resolver=resolver, + replay_store=Store(), + clock=lambda: now, + ) + ) + steps = [] + + async def handler(facts: TapVerificationFacts) -> None: + nonlocal handler_calls + handler_calls += 1 + value = asdict(facts) + value["coveredComponents"] = list(value.pop("covered_components")) + accepted.append(value) + + async def verify(request: TapRequest) -> None: + before = deepcopy(request) + try: + await verifier.with_verified(request, handler) + except TapVerificationError as error: + rejected.append(error.code) + except InjectedFailure as error: + rejected.append(str(error)) + if request != before: + raise RuntimeError("TAP request was mutated") + + for step in data["steps"]: + now = step["now_ms"] / 1000 + accepted: list[dict[str, Any]] = [] + rejected: list[str] = [] + + requests = [ + TapRequest( + method=item["method"], + url=item["url"], + headers=item["headers"], + body=base64.b64decode(item["body_base64"], validate=True) + if "body_base64" in item + else None, + ) + for item in step["requests"] + ] + await asyncio.gather(*(verify(request) for request in requests)) + steps.append({"accepted": accepted, "rejected": sorted(rejected)}) + return {"steps": steps, "handler_calls": handler_calls, "claim_calls": claim_calls} diff --git a/examples/README.md b/examples/README.md index 46f7053..dcfc3f9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,11 +1,14 @@ -# Run a Sandbox payment +# Run the examples -These four programs connect to **InFlow Sandbox**. The Sellers run on your computer; +The four payment programs connect to **InFlow Sandbox**. The Sellers run on your computer; configuration, approvals, and payments use Sandbox accounts. They are not simulated payments. Run the commands from the repository root with Python 3.11 or newer. ## Set up your accounts +These accounts are for payment examples. The separate [TAP example](#tap-agent-recognition) +verifies signed requests without an InFlow account or payment. + 1. Register at [InFlow Sandbox](https://sandbox.inflowpay.ai). Accepting payments requires a **Seller** account and an API key from its dashboard. A Developer key cannot be used as a Seller key. @@ -133,6 +136,42 @@ verify settlement. These programs print neither payment credentials nor signatur The Sellers bind only to loopback and have no application login. Add your own application authentication when required; paying is not a substitute for logging in. +## TAP agent recognition + +The [TAP Seller](tap_seller.py) recognizes signed Agent requests independently of +payments. It needs no InFlow account, API key, or balance. From this checkout: + +```sh +make sync +export PUBLIC_ORIGIN='http://127.0.0.1:3002' +uv run --locked python -m examples.tap_seller +curl -i http://127.0.0.1:3002/api/catalog +``` + +The unsigned request returns HTTP 401 with `{"error":"TAP verification failed"}`. +For HTTP 200, send a request signed by an Agent whose public key is available from +Visa's trusted key endpoint. That signature must cover the method, external +authority, encoded path and query, and, for POST bodies, the exact bytes' digest +and content type. The response contains verified Agent facts and a small catalog; +it does not grant access to a customer's account or charge them. + +`PUBLIC_ORIGIN` is the origin the Agent signs. Behind a proxy, set it to the public +HTTPS origin, not the internal listening address. The example deliberately ignores +client-supplied forwarding headers. A proxy must preserve the signed path, query, +method, content type and body bytes. The example binds only to loopback, accepts +GET and POST, limits bodies to one mebibyte, and uses a process-local replay store. +Production multi-worker applications need a shared atomic replay store. + +For a separate application, install `inflowpay[tap,fastapi]` and `uvicorn`. The TAP +SDK itself has no FastAPI requirement. Keep one `TapVerifier` open for the server's +lifetime, as `run()` does, then add your own account authorization and payment +checks inside the protected handler if needed. + +The automated example tests create real Ed25519 signatures with synthetic keys +and exercise both the ASGI application and a loopback HTTP server. They require +neither a live Visa registration nor a payment. They do not establish that a +production proxy or registered Agent is configured correctly. + ## Adapt the examples - [Manual MPP waiting and cancellation](../README.md#waiting-errors-and-shutdown) diff --git a/examples/tap_seller.py b/examples/tap_seller.py new file mode 100644 index 0000000..2e5122b --- /dev/null +++ b/examples/tap_seller.py @@ -0,0 +1,88 @@ +import asyncio +import os +import sys +from dataclasses import asdict + +import httpx +import uvicorn +from fastapi import FastAPI, Request +from starlette.responses import JSONResponse + +from inflowpay.tap.seller import TapRequest, TapVerificationError, TapVerificationFacts, TapVerifier + + +def create_app(verifier: TapVerifier, public_origin: str) -> FastAPI: + origin = httpx.URL(public_origin) + if ( + origin.scheme not in ("http", "https") + or not origin.host + or origin.userinfo + or origin.raw_path != b"/" + or origin.fragment + ): + raise ValueError( + "PUBLIC_ORIGIN must be an absolute HTTP origin without a path or credentials" + ) + app = FastAPI() + + @app.api_route("/api/catalog", methods=["GET", "POST"]) + async def catalog(request: Request) -> JSONResponse: + # Preserve bytes, including an explicitly supplied empty body. Do not reserialize JSON. + body = bytearray() + async for chunk in request.stream(): + body.extend(chunk) + if len(body) > 1024 * 1024: + return JSONResponse({"error": "Request body too large"}, status_code=413) + has_body = ( + "content-length" in request.headers + or "transfer-encoding" in request.headers + or bool(body) + ) + # PUBLIC_ORIGIN is deployment configuration, never a client-supplied Forwarded header. + path = request.scope["raw_path"].split(b"?", 1)[0].decode("ascii") + query = request.scope["query_string"].decode("ascii") + url = str(origin).rstrip("/") + path + ("?" + query if query else "") + snapshot = TapRequest( + method=request.method, + url=url, + headers=httpx.Headers(request.headers.raw), + body=bytes(body) if has_body else None, + ) + + async def recognized(facts: TapVerificationFacts) -> JSONResponse: + # Recognition is not customer authentication or payment. Add those checks separately. + return JSONResponse({"agent": asdict(facts), "catalog": ["search", "contents"]}) + + try: + return await verifier.with_verified(snapshot, recognized) + except TapVerificationError: + return JSONResponse({"error": "TAP verification failed"}, status_code=401) + + return app + + +async def run() -> None: + origin = os.environ.get("PUBLIC_ORIGIN") + if not origin: + raise ValueError("Set PUBLIC_ORIGIN to the externally visible origin of this server.") + async with TapVerifier() as verifier: + app = create_app(verifier, origin) + print("TAP: http://127.0.0.1:3002/api/catalog requires a signed agent request.", flush=True) + await uvicorn.Server( + uvicorn.Config(app, host="127.0.0.1", port=3002, proxy_headers=False) + ).serve() + + +def main() -> int: + try: + asyncio.run(run()) + except KeyboardInterrupt: + return 130 + except Exception as error: + print(f"TAP Seller failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml index c734570..6e46935 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ fastapi = ["fastapi>=0.142.2,<1"] mcp = ["mcp>=1.30.0,<2", "pympp[mcp]>=0.11.0,<0.12", "x402[mcp]>=2.25.0,<3"] mpp = ["pympp>=0.11.0,<0.12", "rfc8785>=0.1.4,<0.2"] svm = ["x402[svm]>=2.25.0,<3"] +tap = ["cryptography>=50.0.2,<51"] x402 = ["x402>=2.25.0,<3"] [dependency-groups] diff --git a/scripts/conformance.mjs b/scripts/conformance.mjs index 8d90eac..4587845 100644 --- a/scripts/conformance.mjs +++ b/scripts/conformance.mjs @@ -31,6 +31,8 @@ async function main() { if (!values["contract-root"] || !values["output-dir"]) throw new Error("Use --contract-root PATH --output-dir EXISTING_DIRECTORY"); const contractRoot = resolve(values["contract-root"]); + const outputDirectory = resolve(values["output-dir"]); + process.chdir(root); const pin = JSON.parse( await readFile( new URL("../conformance/inflow-specs.lock.json", import.meta.url), @@ -59,13 +61,13 @@ async function main() { process.once("SIGINT", abort); process.once("SIGTERM", abort); try { - for (const suite of ["runtime", "mpp", "x402"]) { + for (const suite of ["runtime", "mpp", "x402", "tap"]) { if (controller.signal.aborted) throw new Error("Conformance interrupted"); const fixtures = await import( pathToFileURL(join(contractRoot, `fixtures/${suite}.mjs`)) ); const output = await open( - join(resolve(values["output-dir"]), `${suite}.json`), + join(outputDirectory, `${suite}.json`), "wx", 0o600, ); @@ -79,7 +81,9 @@ async function main() { suites: suite === "runtime" ? ["runtime"] - : [`${suite}-core`, `${suite}-buyer`, `${suite}-seller`], + : suite === "tap" + ? ["tap-seller"] + : [`${suite}-core`, `${suite}-buyer`, `${suite}-seller`], supported_features: [], unsupported_features: suite === "mpp" @@ -93,7 +97,7 @@ async function main() { : [], }, implementation, - command: [python, join(root, "conformance/adapter.py")], + command: [python, "-m", "conformance.adapter"], contractRoot, sdkRoot: root, signal: controller.signal, diff --git a/scripts/verify_distribution.py b/scripts/verify_distribution.py index 35e587e..1c11d09 100644 --- a/scripts/verify_distribution.py +++ b/scripts/verify_distribution.py @@ -15,20 +15,56 @@ import inflowpay assert inflowpay.__version__ == metadata.version('inflowpay') assert files('inflowpay').joinpath('py.typed').is_file() -for name in ('mpp', 'x402', 'fastapi', 'mcp', 'web3', 'solana'): +for name in ('mpp', 'x402', 'fastapi', 'mcp', 'web3', 'solana', 'cryptography'): assert util.find_spec(name) is None, name """ OPTIONAL_CONSUMER = """ import sys import inflowpay -assert not any(name in sys.modules for name in ('mpp', 'x402', 'fastapi', 'mcp', 'web3')) +assert not any(name in sys.modules for name in + ('mpp', 'x402', 'fastapi', 'mcp', 'web3', 'cryptography')) from mpp.extensions.mcp import McpClient from x402.http.middleware.fastapi import payment_middleware from x402.mechanisms.evm.exact import ExactEvmClientScheme from x402.mechanisms.svm.exact import ExactSvmClientScheme from inflowpay.x402.eip7702 import SponsorshipExtension, SponsorshipSigner import x402.mcp +from inflowpay.tap.seller import TapVerifier +""" + +TAP_CONSUMER = """ +import asyncio, base64, sys +from importlib import util +import inflowpay +assert "cryptography" not in sys.modules +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from inflowpay.tap.seller import TapRequest, TapVerifier, TapVerificationError +key = Ed25519PrivateKey.from_private_bytes(b"\\x11" * 32) +parameters = ('("@method" "@authority" "@path" "@query");created=1800000000;' + 'expires=1800000300;keyid="test";alg="ed25519";nonce="one";tag="agent-browser-auth"') +base = ('"@method": GET\\n"@authority": merchant.example\\n"@path": /catalog\\n' + '"@query": ?\\n"@signature-params": ' + parameters) +signature = base64.b64encode(key.sign(base.encode())).decode() +request = TapRequest(method="GET", url="https://merchant.example/catalog", headers={ + "signature-input": "sig2=" + parameters, "signature": "sig2=:" + signature + ":"}) +class Resolver: + async def resolve(self, keyid, algorithm): + assert keyid == "test" and algorithm == "ed25519" + return key.public_key() +async def check(): + async with TapVerifier(key_resolver=Resolver(), clock=lambda: 1800000000) as verifier: + facts = await verifier.verify(request) + assert facts.verified and facts.intent == "browse" + try: + await verifier.verify(request) + except TapVerificationError as error: + assert error.code == "NONCE_REPLAYED" + else: + raise AssertionError("Replay accepted") +asyncio.run(check()) +for name in ("mpp", "x402", "mcp", "web3", "solana", "fastapi", "rfc8785"): + assert util.find_spec(name) is None, name """ MPP_CONSUMER = """ @@ -135,6 +171,15 @@ def main() -> None: ["uv", "pip", "install", "--python", str(python), str(wheels[0])], check=True ) subprocess.run([str(python), "-I", "-c", CONSUMER], cwd=temporary, check=True) + tap_environment = temporary / "tap-venv" + subprocess.run(["uv", "venv", "--python", sys.executable, str(tap_environment)], check=True) + tap_python = tap_environment / ( + "Scripts/python.exe" if sys.platform == "win32" else "bin/python" + ) + subprocess.run( + ["uv", "pip", "install", "--python", str(tap_python), f"{wheels[0]}[tap]"], check=True + ) + subprocess.run([str(tap_python), "-I", "-c", TAP_CONSUMER], cwd=temporary, check=True) x402_environment = temporary / "x402-venv" subprocess.run( ["uv", "venv", "--python", sys.executable, str(x402_environment)], check=True @@ -158,7 +203,7 @@ def main() -> None: "install", "--python", str(python), - f"{wheels[0]}[evm,fastapi,mcp,mpp,svm,x402]", + f"{wheels[0]}[evm,fastapi,mcp,mpp,svm,tap,x402]", ], check=True, ) @@ -172,9 +217,15 @@ def main() -> None: ignore=shutil.ignore_patterns("__pycache__"), ) settings = dict(os.environ) - for key in ("INFLOW_API_KEY", "MPP_SECRET_KEY", "INFLOW_BASE_URL", "TARGET_URL"): + for key in ( + "INFLOW_API_KEY", + "MPP_SECRET_KEY", + "INFLOW_BASE_URL", + "TARGET_URL", + "PUBLIC_ORIGIN", + ): settings.pop(key, None) - for name in ("mpp_buyer", "mpp_seller", "x402_buyer", "x402_seller"): + for name in ("mpp_buyer", "mpp_seller", "x402_buyer", "x402_seller", "tap_seller"): result = subprocess.run( [str(python), "-I", str(temporary / "examples" / f"{name}.py")], cwd=temporary, @@ -184,7 +235,9 @@ def main() -> None: timeout=30, ) assert result.returncode == 1, result - assert "INFLOW_API_KEY" in result.stderr, result.stderr + assert ( + "PUBLIC_ORIGIN" if name == "tap_seller" else "INFLOW_API_KEY" + ) in result.stderr, result.stderr assert "Traceback" not in result.stderr, result.stderr diff --git a/src/inflowpay/tap/__init__.py b/src/inflowpay/tap/__init__.py new file mode 100644 index 0000000..e1bcfff --- /dev/null +++ b/src/inflowpay/tap/__init__.py @@ -0,0 +1 @@ +"""Optional Trusted Agent Protocol verification.""" diff --git a/src/inflowpay/tap/_keys.py b/src/inflowpay/tap/_keys.py new file mode 100644 index 0000000..078e06a --- /dev/null +++ b/src/inflowpay/tap/_keys.py @@ -0,0 +1,124 @@ +import asyncio +import base64 +import re +import time +from collections.abc import Callable +from types import TracebackType +from typing import Self + +import httpx +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from ._types import TapVerificationError + + +class VisaTapKeyResolver: + def __init__( + self, + *, + url: str = "https://mcp.visa.com/.well-known/jwks", + cache_ttl: float = 3600, + cache_max_age: float = 86400, + timeout: float = 3, + clock: Callable[[], float] = time.time, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + self._url = url + self._ttl, self._max_age, self._clock = cache_ttl, cache_max_age, clock + self._timeout = timeout + self._client = httpx.AsyncClient(transport=transport, follow_redirects=False, timeout=None) + self._cache: dict[str, Ed25519PublicKey] = {} + self._missing: set[str] = set() + self._updated: float | None = None + self._task: asyncio.Task[None] | None = None + self._closed = False + + async def __aenter__(self) -> Self: + self._check_open() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self.aclose() + + async def aclose(self) -> None: + self._closed = True + if self._task is not None: + self._task.cancel() + await asyncio.gather(self._task, return_exceptions=True) + await self._client.aclose() + + def _check_open(self) -> None: + if self._closed: + raise RuntimeError("TAP key resolver is closed") + + async def resolve(self, keyid: str, algorithm: str) -> Ed25519PublicKey | None: + self._check_open() + if algorithm != "ed25519": + return None + if ( + self._updated is not None + and self._clock() - self._updated <= self._ttl + and (keyid in self._cache or keyid in self._missing) + ): + return self._cache.get(keyid) + try: + if self._task is None: + self._task = asyncio.create_task(self._load()) + self._task.add_done_callback(self._finish) + # wait() preserves shared retrieval when a caller cancels. Unlike Python 3.14's + # shield(), it does not log late failures that _finish already observes. + task = self._task + await asyncio.wait((task,)) + task.result() + except Exception as error: + if self._updated is not None and self._clock() - self._updated <= self._max_age: + cached = self._cache.get(keyid) + if cached is not None: + return cached + raise TapVerificationError( + "KEY_RETRIEVAL_FAILED", "The TAP verification key could not be retrieved." + ) from error + resolved = self._cache.get(keyid) + if resolved is None: + self._missing.add(keyid) + return resolved + + def _finish(self, task: asyncio.Task[None]) -> None: + if not task.cancelled(): + task.exception() + self._task = None + + async def _load(self) -> None: + async with asyncio.timeout(self._timeout): + response = await self._client.get(self._url, headers={"accept": "application/json"}) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, dict) or not isinstance(payload.get("keys", []), list): + raise ValueError("Key service returned an invalid key set") + replacement: dict[str, Ed25519PublicKey] = {} + for key in payload.get("keys", []): + if not ( + isinstance(key, dict) + and isinstance(key.get("kid"), str) + and key.get("alg") in ("ed25519", "Ed25519") + and key.get("kty") == "OKP" + and key.get("crv") == "Ed25519" + and key.get("use", "sig") == "sig" + ): + continue + if key["kid"] in replacement: + raise ValueError("Key service returned a duplicate key identifier") + encoded = key.get("x") + if not isinstance(encoded, str) or re.fullmatch(r"[A-Za-z0-9_-]{43}", encoded) is None: + raise ValueError("Key service returned an invalid Ed25519 key") + replacement[key["kid"]] = Ed25519PublicKey.from_public_bytes( + base64.urlsafe_b64decode(encoded + "=") + ) + self._cache = replacement + self._missing.clear() + self._updated = self._clock() diff --git a/src/inflowpay/tap/_signature.py b/src/inflowpay/tap/_signature.py new file mode 100644 index 0000000..8b7eb03 --- /dev/null +++ b/src/inflowpay/tap/_signature.py @@ -0,0 +1,155 @@ +import base64 +import hashlib +import math +import re +from collections.abc import Mapping, Sequence +from dataclasses import dataclass + +import httpx + +from ._types import TapRequest, TapVerificationError + +_INPUT = re.compile( + r' *sig2=\( *(?P"[a-z@-]+"(?: +"[a-z@-]+")*) *\)(?P[^\r\n]*)' +) +_BASE64 = r"(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}(?:==)?|[A-Za-z0-9+/]{3}=?)?" +_PARAMETER = re.compile( + r"; *(created|expires|keyid|alg|nonce|tag)(?:=(" + r'"(?:[\x20-\x21\x23-\x5b\x5d-\x7e]|\\["\\])*"' + r"|-?\d{1,12}\.\d{1,3}|-?\d{1,15}|\?[01]|:" + _BASE64 + r":" + r"|[A-Za-z*][A-Za-z0-9!#$%&'*+.^_`|~:/-]*))?(?=;|[ \t]*$)", + re.ASCII, +) +_SIGNATURE = re.compile(r" *sig2=:(" + _BASE64 + r"):[ \t]*") +_COMPONENTS = ("@method", "@authority", "@path", "@query") + + +def invalid(message: str) -> TapVerificationError: + return TapVerificationError("SIGNATURE_INPUT_INVALID", message) + + +@dataclass(frozen=True) +class Parsed: + components: tuple[str, ...] + created: int + expires: int + keyid: str + nonce: str + tag: str + parameters: str + + +def parse_input(value: str) -> Parsed: + match = _INPUT.fullmatch(value) + if match is None: + raise invalid("The TAP Signature-Input field is invalid.") + components = tuple(item[1:-1] for item in re.split(" +", match["components"])) + if len(set(components)) != len(components): + raise invalid("The TAP covered components are invalid.") + parameters: dict[str, str | int | None] = {} + remaining = match["parameters"].rstrip(" \t") + while remaining: + parameter = _PARAMETER.match(remaining) + if parameter is None: + raise invalid("The TAP Signature-Input field is invalid.") + name, encoded = parameter.groups() + decoded: str | int | None = None + if encoded is not None: + if encoded.startswith('"'): + decoded = re.sub(r'\\(["\\])', r"\1", encoded[1:-1]) + elif re.fullmatch(r"-?[0-9]+", encoded): + decoded = int(encoded) + # Structured Field parameters retain their first position and last value, including type. + parameters[name] = decoded + remaining = remaining[parameter.end() :] + created, expires = parameters.get("created"), parameters.get("expires") + keyid, algorithm = parameters.get("keyid"), parameters.get("alg") + nonce, tag = parameters.get("nonce"), parameters.get("tag") + if not ( + isinstance(created, int) + and isinstance(expires, int) + and isinstance(keyid, str) + and keyid + and algorithm in ("ed25519", "Ed25519") + and isinstance(nonce, str) + and nonce + and isinstance(tag, str) + and tag in ("agent-browser-auth", "agent-payer-auth") + ): + raise invalid("The TAP signature parameters are invalid.") + serialized = "(" + " ".join('"' + field + '"' for field in components) + ")" + for name, item in parameters.items(): + serialized += ";" + name + "=" + serialized += ( + '"' + item.replace("\\", "\\\\").replace('"', '\\"') + '"' + if isinstance(item, str) + else str(item) + ) + return Parsed(components, created, expires, keyid, nonce, tag, serialized) + + +def header(headers: Mapping[str, str | Sequence[str]], name: str) -> str | None: + entries = headers.multi_items() if isinstance(headers, httpx.Headers) else headers.items() + found = [value for key, value in entries if key.lower() == name] + if len(found) != 1: + return None + value = found[0] + if isinstance(value, str): + return value + return value[0] if len(value) == 1 else None + + +def required_header(headers: Mapping[str, str | Sequence[str]], name: str) -> str: + value = header(headers, name) + if value is None: + raise invalid(f"The TAP {name} field is missing or ambiguous.") + return value + + +def signature_bytes(value: str) -> bytes: + match = _SIGNATURE.fullmatch(value) + if match is None: + raise invalid("The TAP Signature field is invalid.") + encoded = match[1] + return base64.b64decode(encoded + "=" * (-len(encoded) % 4), validate=True) + + +def signature_base(request: TapRequest, parsed: Parsed, clock: float) -> bytes: + required = _COMPONENTS + ( + ("content-digest", "content-type") if request.body is not None else () + ) + if set(parsed.components) != set(required): + raise invalid("The TAP covered components are invalid.") + if not 0 < parsed.expires - parsed.created <= 480: + raise TapVerificationError( + "SIGNATURE_LIFETIME_INVALID", "The TAP signature lifetime is invalid." + ) + now = math.floor(clock) + if now < parsed.created: + raise TapVerificationError("SIGNATURE_NOT_YET_VALID", "The TAP signature is not yet valid.") + if now >= parsed.expires: + raise TapVerificationError("SIGNATURE_EXPIRED", "The TAP signature has expired.") + try: + url = httpx.URL(request.url) + if url.scheme not in ("http", "https") or not url.host or url.userinfo or url.fragment: + raise ValueError("Expected an absolute HTTP request URL") + except (httpx.InvalidURL, ValueError) as error: + raise invalid("The TAP request URL is invalid.") from error + values = { + "@method": request.method, + "@authority": url.netloc.decode("ascii"), + "@path": url.raw_path.split(b"?", 1)[0].decode("ascii"), + "@query": "?" + url.query.decode("ascii"), + } + if request.body is not None: + content_type = required_header(request.headers, "content-type") + body = request.body.encode("utf-8") if isinstance(request.body, str) else request.body + digest = "sha-256=:" + base64.b64encode(hashlib.sha256(body).digest()).decode("ascii") + ":" + if header(request.headers, "content-digest") != digest: + raise TapVerificationError( + "CONTENT_DIGEST_INVALID", "The TAP content digest is invalid." + ) + values.update({"content-digest": digest, "content-type": content_type}) + lines = [f'"{field}": {values[field]}' for field in parsed.components] + lines.append(f'"@signature-params": {parsed.parameters}') + return "\n".join(lines).encode("utf-8") diff --git a/src/inflowpay/tap/_types.py b/src/inflowpay/tap/_types.py new file mode 100644 index 0000000..4cebfdf --- /dev/null +++ b/src/inflowpay/tap/_types.py @@ -0,0 +1,51 @@ +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Literal, Protocol + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +ErrorCode = Literal[ + "CONTENT_DIGEST_INVALID", + "KEY_NOT_FOUND", + "KEY_RETRIEVAL_FAILED", + "NONCE_REPLAYED", + "SIGNATURE_EXPIRED", + "SIGNATURE_INPUT_INVALID", + "SIGNATURE_INVALID", + "SIGNATURE_LIFETIME_INVALID", + "SIGNATURE_NOT_YET_VALID", +] + + +class TapVerificationError(Exception): + def __init__(self, code: ErrorCode, message: str) -> None: + super().__init__(message) + self.code = code + + +@dataclass(frozen=True, kw_only=True) +class TapRequest: + method: str + url: str + headers: Mapping[str, str | Sequence[str]] + body: bytes | str | None = None + + +@dataclass(frozen=True, kw_only=True) +class TapVerificationFacts: + keyid: str + intent: Literal["browse", "pay"] + nonce: str + created: int + expires: int + covered_components: tuple[str, ...] + verified: Literal[True] = True + algorithm: Literal["ed25519"] = "ed25519" + + +class TapKeyResolver(Protocol): + async def resolve(self, keyid: str, algorithm: str) -> Ed25519PublicKey | None: ... + + +class TapReplayStore(Protocol): + async def claim(self, keyid: str, nonce: str, expires: int) -> bool: ... diff --git a/src/inflowpay/tap/seller.py b/src/inflowpay/tap/seller.py new file mode 100644 index 0000000..afeba2d --- /dev/null +++ b/src/inflowpay/tap/seller.py @@ -0,0 +1,125 @@ +import time +from collections.abc import Awaitable, Callable +from threading import Lock +from types import TracebackType +from typing import Self, TypeVar + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from ._keys import VisaTapKeyResolver +from ._signature import parse_input, required_header, signature_base, signature_bytes +from ._types import ( + TapKeyResolver, + TapReplayStore, + TapRequest, + TapVerificationError, + TapVerificationFacts, +) + +__all__ = [ + "MemoryTapReplayStore", + "TapKeyResolver", + "TapReplayStore", + "TapRequest", + "TapVerificationError", + "TapVerificationFacts", + "TapVerifier", + "VisaTapKeyResolver", +] +_T = TypeVar("_T") + + +class MemoryTapReplayStore: + def __init__(self, clock: Callable[[], float] = time.time) -> None: + self._clock = clock + self._claims: dict[tuple[str, str], int] = {} + self._lock = Lock() + + async def claim(self, keyid: str, nonce: str, expires: int) -> bool: + with self._lock: + now = self._clock() + self._claims = {pair: end for pair, end in self._claims.items() if end > now} + pair = (keyid, nonce) + if pair in self._claims: + return False + self._claims[pair] = expires + return True + + +class TapVerifier: + def __init__( + self, + *, + key_resolver: TapKeyResolver | None = None, + replay_store: TapReplayStore | None = None, + clock: Callable[[], float] = time.time, + ) -> None: + self._owned_resolver: VisaTapKeyResolver | None = None + if key_resolver is None: + self._owned_resolver = VisaTapKeyResolver(clock=clock) + key_resolver = self._owned_resolver + self._resolver = key_resolver + self._store = replay_store if replay_store is not None else MemoryTapReplayStore(clock) + self._clock = clock + self._closed = False + + async def __aenter__(self) -> Self: + self._check_open() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self.aclose() + + async def aclose(self) -> None: + self._closed = True + if self._owned_resolver is not None: + await self._owned_resolver.aclose() + + def _check_open(self) -> None: + if self._closed: + raise RuntimeError("TAP verifier is closed") + + async def verify(self, request: TapRequest) -> TapVerificationFacts: + self._check_open() + value = required_header(request.headers, "signature-input") + signature = required_header(request.headers, "signature") + parsed = parse_input(value) + # Freeze signed bytes before awaiting caller-supplied code. Expiration is checked here, + # not after key retrieval or replay storage; a request may expire during those operations. + base = signature_base(request, parsed, self._clock()) + key = await self._resolver.resolve(parsed.keyid, "ed25519") + if key is None: + raise TapVerificationError("KEY_NOT_FOUND", "The TAP verification key was not found.") + if not isinstance(key, Ed25519PublicKey): + raise TapVerificationError( + "SIGNATURE_INVALID", "The TAP verification key is not Ed25519." + ) + try: + key.verify(signature_bytes(signature), base) + except InvalidSignature as error: + raise TapVerificationError( + "SIGNATURE_INVALID", "The TAP signature is invalid." + ) from error + if not await self._store.claim(parsed.keyid, parsed.nonce, parsed.expires): + raise TapVerificationError("NONCE_REPLAYED", "The TAP nonce has already been used.") + return TapVerificationFacts( + keyid=parsed.keyid, + intent="pay" if parsed.tag == "agent-payer-auth" else "browse", + nonce=parsed.nonce, + created=parsed.created, + expires=parsed.expires, + covered_components=parsed.components, + ) + + async def with_verified( + self, + request: TapRequest, + handler: Callable[[TapVerificationFacts], Awaitable[_T]], + ) -> _T: + return await handler(await self.verify(request)) diff --git a/tests/test_release.py b/tests/test_release.py index 3985a0a..d190786 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -1,6 +1,12 @@ import hashlib import io import json +import os +import shlex +import shutil +import subprocess +import sys +import textwrap from email.message import Message from pathlib import Path from urllib.error import HTTPError @@ -76,3 +82,93 @@ def test_wrong_project(tmp_path: Path) -> None: project.write_text('[project]\nname="another-project"\nversion="0.1.0"\n') with pytest.raises(ValueError): project_version(project) + + +@pytest.mark.parametrize( + "event,publish,repository,ref,same_commit,success", + [ + ("pull_request", "false", "inflowpayai/inflow-python", "refs/pull/17/merge", False, True), + ( + "workflow_dispatch", + "false", + "inflowpayai/inflow-python", + "refs/heads/main", + False, + False, + ), + ("workflow_dispatch", "true", "inflowpayai/inflow-python", "refs/heads/main", False, False), + ("workflow_dispatch", "false", "inflowpayai/inflow-python", "refs/heads/main", True, True), + ("workflow_dispatch", "true", "inflowpayai/inflow-python", "refs/heads/main", True, True), + ("workflow_dispatch", "true", "nkavian/inflow-python", "refs/heads/main", True, False), + ( + "workflow_dispatch", + "true", + "inflowpayai/inflow-python", + "refs/heads/feature", + True, + False, + ), + ], +) +def test_workflow_release_tag_guard( + tmp_path: Path, + event: str, + publish: str, + repository: str, + ref: str, + same_commit: bool, + success: bool, +) -> None: + root = Path(__file__).resolve().parents[1] + workflow = (root / ".github/workflows/release.yml").read_text() + step = workflow.split(" - name: Validate release\n", 1)[1].split(" - name:", 1)[0] + script = textwrap.dedent(step.split(" run: |\n", 1)[1]).replace( + "python scripts/release.py", shlex.quote(sys.executable) + " scripts/release.py" + ) + (tmp_path / "scripts").mkdir() + shutil.copy(root / "scripts/release.py", tmp_path / "scripts/release.py") + (tmp_path / "pyproject.toml").write_text('[project]\nname="inflowpay"\nversion="0.1.0"\n') + + def git(*arguments: str) -> str: + return subprocess.check_output( + [ + "git", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "-c", + f"core.hooksPath={os.devnull}", + *arguments, + ], + cwd=tmp_path, + text=True, + stderr=subprocess.PIPE, + ).strip() + + git("init") + git("add", ".") + git("commit", "-m", "Initial version") + git("tag", "v0.1.0") + if not same_commit: + git("commit", "--allow-empty", "-m", "Feature") + result = subprocess.run( + ["bash", "-e", "-c", script], + cwd=tmp_path, + capture_output=True, + text=True, + env={ + **os.environ, + "PUBLISH": publish, + "GITHUB_EVENT_NAME": event, + "GITHUB_REPOSITORY": repository, + "GITHUB_REF": ref, + "GITHUB_SHA": git("rev-parse", "HEAD"), + "GITHUB_OUTPUT": str(tmp_path / "output"), + }, + ) + assert (result.returncode == 0) is success, result.stderr + if success: + assert (tmp_path / "output").read_text() == "version=0.1.0\n" diff --git a/tests/test_tap_examples.py b/tests/test_tap_examples.py new file mode 100644 index 0000000..117b9b7 --- /dev/null +++ b/tests/test_tap_examples.py @@ -0,0 +1,151 @@ +import asyncio +import runpy +import socket +from dataclasses import replace +from typing import cast + +import httpx +import pytest +import uvicorn +from examples import tap_seller +from fastapi import FastAPI + +from inflowpay.tap.seller import TapVerifier +from test_tap_seller import NOW, Resolver, signed + + +@pytest.mark.parametrize( + "origin", + [ + "/relative", + "ftp://host/", + "https://host/path", + "https://host/?x=1", + "https://user@host", + "https://host/#x", + ], +) +def test_example_requires_origin(origin: str) -> None: + verifier = TapVerifier(key_resolver=Resolver()) + with pytest.raises(ValueError, match="PUBLIC_ORIGIN"): + tap_seller.create_app(verifier, origin) + + +@pytest.mark.parametrize("body", [None, b"", b"{}"]) +async def test_http_example_accepts_real_signed_request_then_rejects_replay( + body: bytes | None, +) -> None: + async with TapVerifier(key_resolver=Resolver(), clock=lambda: NOW) as verifier: + app = tap_seller.create_app(verifier, "https://public.example") + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app), base_url="http://private" + ) as client: + request = signed( + method="GET" if body is None else "POST", + body=body, + url="https://public.example/api/catalog?x=1&x=2", + ) + for status in (200, 401): + response = await client.request( + request.method, + "/api/catalog?x=1&x=2", + content=body, + headers={ + **cast(dict[str, str], request.headers), + "Forwarded": "host=evil.example", + }, + ) + assert response.status_code == status + assert response.json() == {"error": "TAP verification failed"} + assert (await client.get("/api/catalog")).status_code == 401 + assert ( + await client.post("/api/catalog", content=b"x" * (1024 * 1024 + 1)) + ).status_code == 413 + + +async def test_example_over_real_loopback_http() -> None: + async with TapVerifier(key_resolver=Resolver(), clock=lambda: NOW) as verifier: + app = tap_seller.create_app(verifier, "https://public.example") + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + config = uvicorn.Config(app, log_config=None, log_level="critical", proxy_headers=False) + server = uvicorn.Server(config) + running = asyncio.create_task(server.serve(sockets=[sock])) + try: + async with asyncio.timeout(5): + while not server.started: + if running.done(): + await running + pytest.fail("Example server stopped before startup") + await asyncio.sleep(0.001) + request = signed( + body=b"{}", method="POST", url="https://public.example/api/catalog" + ) + async with httpx.AsyncClient( + base_url=f"http://127.0.0.1:{sock.getsockname()[1]}" + ) as client: + response = await client.post( + "/api/catalog", + content=request.body, + headers=cast(dict[str, str], request.headers), + ) + assert response.status_code == 200 + assert response.json()["agent"]["keyid"] == "test-key" + assert (await client.get("/api/catalog")).status_code == 401 + finally: + server.should_exit = True + await asyncio.wait_for(running, 5) + + +async def test_chunked_empty_body_and_query_tamper() -> None: + async with TapVerifier(key_resolver=Resolver(), clock=lambda: NOW) as verifier: + app = tap_seller.create_app(verifier, "https://public.example") + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app), base_url="http://private" + ) as client: + request = signed(body=b"", method="POST", url="https://public.example/api/catalog") + outbound = client.build_request( + "POST", "/api/catalog", content=b"", headers=cast(dict[str, str], request.headers) + ) + del outbound.headers["content-length"] + outbound.headers["transfer-encoding"] = "chunked" + assert (await client.send(outbound)).status_code == 200 + request = replace(signed(url="https://public.example/api/catalog?x=1"), method="GET") + assert ( + await client.get("/api/catalog?x=2", headers=cast(dict[str, str], request.headers)) + ).status_code == 401 + + +def test_entry_requires_configuration( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.delenv("PUBLIC_ORIGIN", raising=False) + with pytest.raises(SystemExit) as error: + runpy.run_path(tap_seller.__file__, run_name="__main__") + assert error.value.code == 1 + assert "PUBLIC_ORIGIN" in capsys.readouterr().err + + +@pytest.mark.parametrize("interrupt", [False, True]) +def test_entry_outcomes(interrupt: bool, monkeypatch: pytest.MonkeyPatch) -> None: + async def run() -> None: + if interrupt: + raise KeyboardInterrupt + + monkeypatch.setattr(tap_seller, "run", run) + assert tap_seller.main() == (130 if interrupt else 0) + + +async def test_run_serves_example(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PUBLIC_ORIGIN", "https://public.example") + + async def serve(server: uvicorn.Server, sockets: object = None) -> None: + assert server.config.port == 3002 and not server.config.proxy_headers + assert isinstance(server.config.app, FastAPI) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(server.config.app), base_url="http://private" + ) as client: + assert (await client.get("/api/catalog")).status_code == 401 + + monkeypatch.setattr(uvicorn.Server, "serve", serve) + await tap_seller.run() diff --git a/tests/test_tap_keys.py b/tests/test_tap_keys.py new file mode 100644 index 0000000..e898163 --- /dev/null +++ b/tests/test_tap_keys.py @@ -0,0 +1,256 @@ +import asyncio +import base64 +from typing import Any + +import httpx +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from inflowpay.tap.seller import TapVerificationError, TapVerifier, VisaTapKeyResolver +from test_runtime_http import Exchange, server +from test_tap_seller import KEY, NOW, signed + +JWK = { + "kid": "test-key", + "alg": "Ed25519", + "kty": "OKP", + "crv": "Ed25519", + "use": "sig", + "x": base64.urlsafe_b64encode(KEY.public_key().public_bytes_raw()).decode().rstrip("="), +} + + +class Keys(httpx.AsyncBaseTransport): + def __init__(self) -> None: + self.calls = 0 + self.closed = False + self.payload: object = {"keys": [JWK]} + self.error: Exception | None = None + self.status = 200 + self.started = asyncio.Event() + self.release = asyncio.Event() + self.release.set() + self.cancelled = asyncio.Event() + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + self.calls += 1 + assert request.url == "https://mcp.visa.com/.well-known/jwks" + assert request.headers["accept"] == "application/json" + assert "authorization" not in request.headers and "x-api-key" not in request.headers + self.started.set() + try: + await self.release.wait() + except asyncio.CancelledError: + self.cancelled.set() + raise + if self.error: + raise self.error + return httpx.Response(self.status, json=self.payload) + + async def aclose(self) -> None: + self.closed = True + + +async def test_cache_replacement_and_negative_cache() -> None: + clock = NOW + transport = Keys() + async with VisaTapKeyResolver( + transport=transport, clock=lambda: clock, cache_ttl=10 + ) as resolver: + assert await resolver.resolve("test-key", "rsa") is None + key = await resolver.resolve("test-key", "ed25519") + assert isinstance(key, Ed25519PublicKey) + assert await resolver.resolve("test-key", "ed25519") is key + assert transport.calls == 1 + assert await resolver.resolve("missing", "ed25519") is None + assert await resolver.resolve("missing", "ed25519") is None + assert transport.calls == 2 + transport.payload = {"keys": [{**JWK, "kid": "missing"}]} + clock += 11 + assert await resolver.resolve("test-key", "ed25519") is None + assert await resolver.resolve("missing", "ed25519") is not None + assert transport.calls == 3 + assert transport.closed + with pytest.raises(RuntimeError, match="closed"): + await resolver.resolve("test-key", "ed25519") + with pytest.raises(RuntimeError, match="closed"): + await resolver.__aenter__() + await resolver.aclose() + + +@pytest.mark.parametrize( + "payload", + [ + None, + [], + {"keys": None}, + {"keys": {}}, + {"keys": [JWK, JWK]}, + {"keys": [{**JWK, "x": None}]}, + {"keys": [{**JWK, "x": "bad"}]}, + {"keys": [{**JWK, "x": "!" * 43}]}, + {"keys": [{**JWK, "x": JWK["x"] + "="}]}, + ], +) +async def test_invalid_keysets_do_not_establish_trust(payload: object) -> None: + transport = Keys() + transport.payload = payload + async with VisaTapKeyResolver(transport=transport) as resolver: + with pytest.raises(TapVerificationError) as error: + await resolver.resolve("test-key", "ed25519") + assert error.value.code == "KEY_RETRIEVAL_FAILED" + assert isinstance(error.value.__cause__, ValueError) + + +@pytest.mark.parametrize( + "key", + [ + None, + "invalid", + {}, + {**JWK, "kid": None}, + {**JWK, "alg": "rsa"}, + {**JWK, "kty": "RSA"}, + {**JWK, "crv": "P-256"}, + {**JWK, "use": "enc"}, + ], +) +async def test_unrelated_keys_ignored(key: object) -> None: + transport = Keys() + transport.payload = { + "keys": [key, {name: value for name, value in JWK.items() if name != "use"}] + } + async with VisaTapKeyResolver(transport=transport) as resolver: + assert await resolver.resolve("test-key", "ed25519") is not None + + +async def test_absent_keys_is_empty_keyset() -> None: + transport = Keys() + transport.payload = {} + async with VisaTapKeyResolver(transport=transport) as resolver: + assert await resolver.resolve("test-key", "ed25519") is None + + +async def test_failed_refresh_is_atomic_and_fallback_has_age_bound() -> None: + now = NOW + transport = Keys() + async with VisaTapKeyResolver( + transport=transport, clock=lambda: now, cache_ttl=1, cache_max_age=10 + ) as resolver: + old = await resolver.resolve("test-key", "ed25519") + now += 2 + transport.payload = {"keys": [{**JWK, "kid": "new"}, {**JWK, "x": "bad"}]} + assert await resolver.resolve("test-key", "ed25519") is old + with pytest.raises(TapVerificationError): + await resolver.resolve("new", "ed25519") + now += 9 + with pytest.raises(TapVerificationError): + await resolver.resolve("test-key", "ed25519") + transport.payload = {"keys": []} + assert await resolver.resolve("test-key", "ed25519") is None + now += 2 + transport.error = httpx.ConnectError("offline") + with pytest.raises(TapVerificationError): + await resolver.resolve("test-key", "ed25519") + + +async def test_fallback_limit_does_not_expire_fresh_cache() -> None: + now = NOW + transport = Keys() + async with VisaTapKeyResolver( + transport=transport, clock=lambda: now, cache_ttl=100, cache_max_age=10 + ) as resolver: + old = await resolver.resolve("test-key", "ed25519") + now += 11 + transport.error = httpx.ConnectError("offline") + assert await resolver.resolve("test-key", "ed25519") is old + assert transport.calls == 1 + + +async def test_single_refresh_and_waiter_cancellation() -> None: + transport = Keys() + transport.release.clear() + async with VisaTapKeyResolver(transport=transport) as resolver: + first = asyncio.create_task(resolver.resolve("test-key", "ed25519")) + await transport.started.wait() + second = asyncio.create_task(resolver.resolve("test-key", "ed25519")) + await asyncio.sleep(0) + first.cancel() + with pytest.raises(asyncio.CancelledError): + await first + assert not transport.cancelled.is_set() + transport.release.set() + assert await second is not None + assert transport.calls == 1 + + +async def test_close_drains_refresh_and_waiters() -> None: + transport = Keys() + transport.release.clear() + resolver = VisaTapKeyResolver(transport=transport) + task = asyncio.create_task(resolver.resolve("test-key", "ed25519")) + await transport.started.wait() + await resolver.aclose() + with pytest.raises(asyncio.CancelledError): + await task + assert transport.cancelled.is_set() and transport.closed + + +async def test_refresh_exception_is_observed_after_all_waiters_leave() -> None: + transport = Keys() + transport.release.clear() + transport.error = httpx.ConnectError("offline") + observed: list[dict[str, Any]] = [] + loop = asyncio.get_running_loop() + previous = loop.get_exception_handler() + loop.set_exception_handler(lambda _, event: observed.append(event)) + try: + async with VisaTapKeyResolver(transport=transport) as resolver: + task = asyncio.create_task(resolver.resolve("test-key", "ed25519")) + await transport.started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + transport.release.set() + await asyncio.sleep(0) + await asyncio.sleep(0) + assert not observed + finally: + loop.set_exception_handler(previous) + + +async def test_total_timeout_cancels_retrieval() -> None: + transport = Keys() + transport.release.clear() + async with VisaTapKeyResolver(transport=transport, timeout=0.01) as resolver: + with pytest.raises(TapVerificationError) as error: + await resolver.resolve("test-key", "ed25519") + assert isinstance(error.value.__cause__, TimeoutError) + assert transport.cancelled.is_set() + + +async def test_real_http_resolver_and_verifier() -> None: + exchanges: list[Exchange] = [ + { + "request": {"method": "GET", "path": "/keys", "headers": {}}, + "response": {"status": 200, "json": {"keys": [JWK]}}, + } + ] + async with server(exchanges) as url, VisaTapKeyResolver(url=url + "/keys") as resolver: + async with TapVerifier(key_resolver=resolver, clock=lambda: NOW) as verifier: + assert (await verifier.verify(signed())).verified + # The application owns an explicitly supplied resolver, not the verifier. + assert await resolver.resolve("test-key", "ed25519") is not None + + +async def test_real_http_redirect_is_not_followed() -> None: + exchanges: list[Exchange] = [ + { + "request": {"method": "GET", "path": "/keys", "headers": {}}, + "response": {"status": 302, "headers": {"Location": "/different"}}, + } + ] + async with server(exchanges) as url, VisaTapKeyResolver(url=url + "/keys") as resolver: + with pytest.raises(TapVerificationError) as error: + await resolver.resolve("test-key", "ed25519") + assert isinstance(error.value.__cause__, httpx.HTTPStatusError) diff --git a/tests/test_tap_seller.py b/tests/test_tap_seller.py new file mode 100644 index 0000000..e2dd179 --- /dev/null +++ b/tests/test_tap_seller.py @@ -0,0 +1,478 @@ +import asyncio +import base64 +import hashlib +import json +from collections.abc import Mapping, Sequence +from copy import deepcopy +from dataclasses import replace +from typing import cast +from urllib.parse import urlsplit + +import httpx +import pytest +from cryptography.hazmat.primitives.asymmetric import ed25519, rsa + +from inflowpay.tap.seller import ( + MemoryTapReplayStore, + TapRequest, + TapVerificationError, + TapVerificationFacts, + TapVerifier, +) + +NOW = 1800000000 +KEY = ed25519.Ed25519PrivateKey.from_private_bytes(b"\x11" * 32) +FIELDS = ("@method", "@authority", "@path", "@query") + + +def signed( + *, + body: bytes | str | None = None, + method: str = "GET", + url: str = "https://merchant.example:8443/catalog%2Fitems?q=red%20shoes&kind=a&kind=b", + fields: tuple[str, ...] | None = None, + params: dict[str, str | int] | None = None, + wire: str | None = None, +) -> TapRequest: + parameters = ( + params + if params is not None + else { + "created": NOW, + "expires": NOW + 300, + "keyid": "test-key", + "alg": "ed25519", + "nonce": "nonce", + "tag": "agent-browser-auth", + } + ) + fields = ( + fields + if fields is not None + else FIELDS + (("content-digest", "content-type") if body is not None else ()) + ) + headers = {} + if body is not None: + raw = body.encode() if isinstance(body, str) else body + headers = { + "content-type": "application/json", + "content-digest": "sha-256=:" + + base64.b64encode(hashlib.sha256(raw).digest()).decode() + + ":", + } + canonical = ( + "(" + + " ".join(json.dumps(field) for field in fields) + + ")" + + "".join(";" + name + "=" + json.dumps(value) for name, value in parameters.items()) + ) + parsed = urlsplit(url) + values = { + "@method": method, + "@authority": parsed.netloc, + "@path": parsed.path or "/", + "@query": "?" + parsed.query, + **headers, + } + base = "\n".join( + [ + *(f'"{field}": {values.get(field, "")}' for field in fields), + f'"@signature-params": {canonical}', + ] + ) + headers["signature-input"] = "sig2=" + (wire if wire is not None else canonical) + headers["signature"] = "sig2=:" + base64.b64encode(KEY.sign(base.encode())).decode() + ":" + return TapRequest(method=method, url=url, headers=headers, body=body) + + +class Resolver: + calls = 0 + + async def resolve(self, keyid: str, algorithm: str) -> ed25519.Ed25519PublicKey | None: + self.calls += 1 + assert algorithm == "ed25519" + return KEY.public_key() if keyid == "test-key" else None + + +class Store(MemoryTapReplayStore): + calls = 0 + + async def claim(self, keyid: str, nonce: str, expires: int) -> bool: + self.calls += 1 + return await super().claim(keyid, nonce, expires) + + +async def check_error(request: TapRequest, code: str, *, now: float = NOW) -> None: + store = Store(lambda: now) + calls = [] + + async def handler(facts: TapVerificationFacts) -> None: + calls.append(facts) + + before = deepcopy(request) + async with TapVerifier( + key_resolver=Resolver(), replay_store=store, clock=lambda: now + ) as verifier: + with pytest.raises(TapVerificationError) as error: + await verifier.with_verified(request, handler) + assert error.value.code == code + assert not calls and store.calls == 0 + assert request == before + + +@pytest.mark.parametrize("body", [None, b"", b"{}", '{"name":"café"}']) +@pytest.mark.parametrize("tag", ["agent-browser-auth", "agent-payer-auth"]) +@pytest.mark.parametrize("algorithm", ["ed25519", "Ed25519"]) +async def test_real_signatures_and_replay( + body: bytes | str | None, tag: str, algorithm: str +) -> None: + params: dict[str, str | int] = { + "tag": tag, + "nonce": 'quote"slash\\', + "expires": NOW + 300, + "created": NOW, + "alg": algorithm, + "keyid": "test-key", + } + request = signed(body=body, params=params) + before = deepcopy(request) + async with TapVerifier(key_resolver=Resolver(), clock=lambda: NOW) as verifier: + facts = await verifier.verify(request) + assert facts.verified and facts.algorithm == "ed25519" + assert facts.intent == ("pay" if tag == "agent-payer-auth" else "browse") + assert facts.nonce == 'quote"slash\\' + with pytest.raises(TapVerificationError, match="nonce"): + await verifier.verify(request) + assert request == before + + +@pytest.mark.parametrize("headers", ["httpx", "list", "mixed-case"]) +async def test_supported_header_containers(headers: str) -> None: + request = signed() + values: Mapping[str, str | Sequence[str]] + if headers == "httpx": + values = httpx.Headers(cast(dict[str, str], request.headers)) + elif headers == "list": + values = {key: [cast(str, value)] for key, value in request.headers.items()} + else: + values = {key.upper(): value for key, value in request.headers.items()} + async with TapVerifier(key_resolver=Resolver(), clock=lambda: NOW) as verifier: + assert (await verifier.verify(replace(request, headers=values))).verified + + +@pytest.mark.parametrize( + "value", + [ + "", + "sig1=()", + "sig2=()", + 'sig2=("@method" "@method")', + 'sig2=("@method");created', + 'sig2=("@method");created=?1', + 'sig2=("@method");created=1.0', + 'sig2=("@method");created=invalid', + 'sig2=("@method");created=:YWJj:', + 'sig2=("@method");unknown=1', + 'sig2=("@method");created=\u0661', + 'sig2=("@method");nonce="bad\\n"', + 'sig2=("@method");nonce="bad\n"', + ], +) +async def test_malformed_input(value: str) -> None: + request = signed() + await check_error( + replace(request, headers={**request.headers, "signature-input": value}), + "SIGNATURE_INPUT_INVALID", + ) + + +@pytest.mark.parametrize("wire", ["boolean", "integer", "decimal", "bytes", "token", "string"]) +async def test_duplicate_parameter_last_value_first_position(wire: str) -> None: + request = signed() + canonical = cast(str, request.headers["signature-input"])[5:] + old = { + "boolean": "", + "integer": "=0", + "decimal": "=1.5", + "bytes": "=:YWJj:", + "token": "=test", + "string": '="bad"', + }[wire] + value = canonical.replace(f";created={NOW}", f";created{old};created={NOW}") + value = value.replace(f";expires={NOW + 300}", f"; expires=0{NOW + 300}") + " \t" + request = replace(request, headers={**request.headers, "signature-input": " sig2=" + value}) + async with TapVerifier(key_resolver=Resolver(), clock=lambda: NOW) as verifier: + assert (await verifier.verify(request)).verified + + +@pytest.mark.parametrize( + "field,value", + [ + ("created", "1"), + ("expires", "1"), + ("keyid", ""), + ("alg", "rsa"), + ("nonce", ""), + ("tag", "unknown"), + ("keyid", 1), + ("nonce", 1), + ("tag", 1), + ], +) +async def test_effective_parameter_types(field: str, value: str | int) -> None: + request = signed() + wire = cast(str, request.headers["signature-input"]) + ";" + field + "=" + json.dumps(value) + await check_error( + replace(request, headers={**request.headers, "signature-input": wire}), + "SIGNATURE_INPUT_INVALID", + ) + + +@pytest.mark.parametrize( + "field,value,code", + [ + ("created", NOW + 300, "SIGNATURE_LIFETIME_INVALID"), + ("expires", NOW + 481, "SIGNATURE_LIFETIME_INVALID"), + ("created", NOW + 1, "SIGNATURE_NOT_YET_VALID"), + ("expires", NOW, "SIGNATURE_LIFETIME_INVALID"), + ], +) +async def test_time_intervals(field: str, value: int, code: str) -> None: + request = signed() + wire = cast(str, request.headers["signature-input"]) + f";{field}={value}" + await check_error(replace(request, headers={**request.headers, "signature-input": wire}), code) + + +async def test_exact_expiration_and_precreation() -> None: + await check_error(signed(), "SIGNATURE_EXPIRED", now=NOW + 300) + await check_error(signed(), "SIGNATURE_NOT_YET_VALID", now=NOW - 0.1) + + +@pytest.mark.parametrize("field", ["@method", "@path", "@query", "@authority"]) +async def test_tamper_bound_values(field: str) -> None: + request = signed() + modified = { + "@method": replace(request, method="get"), + "@path": replace(request, url=request.url.replace("catalog%2Fitems", "catalog/items")), + "@query": replace(request, url=request.url.replace("kind=a&kind=b", "kind=b&kind=a")), + "@authority": replace(request, url=request.url.replace(":8443", ":8444")), + }[field] + await check_error(modified, "SIGNATURE_INVALID") + + +@pytest.mark.parametrize("fields", [FIELDS[:-1], (*FIELDS, "other"), (*FIELDS, "content-type")]) +async def test_exact_components(fields: tuple[str, ...]) -> None: + await check_error(signed(fields=fields), "SIGNATURE_INPUT_INVALID") + + +@pytest.mark.parametrize("value", ["sig1=:YWJj:", "sig2=:!:", "sig2=:YWJj:", "sig2=::"]) +async def test_signature_encoding(value: str) -> None: + request = signed() + code = ( + "SIGNATURE_INPUT_INVALID" if value in ("sig1=:YWJj:", "sig2=:!:") else "SIGNATURE_INVALID" + ) + await check_error(replace(request, headers={**request.headers, "signature": value}), code) + + +@pytest.mark.parametrize( + "url", + [ + "/relative", + "ftp://host/path", + "https:///", + "https://user@host/", + "https://host/#fragment", + "https://host:bad/", + "https://host/\n", + ], +) +async def test_invalid_request_url(url: str) -> None: + await check_error(replace(signed(), url=url), "SIGNATURE_INPUT_INVALID") + + +@pytest.mark.parametrize( + "field", ["signature-input", "signature", "content-type", "content-digest"] +) +@pytest.mark.parametrize("kind", ["missing", "duplicate-case", "multiple", "empty-list", "httpx"]) +async def test_ambiguous_and_missing_headers(field: str, kind: str) -> None: + request = signed(body=b"{}") + headers: dict[str, str | Sequence[str]] = dict(request.headers) + original = cast(str, headers[field]) + if kind == "missing": + del headers[field] + elif kind == "duplicate-case": + headers[field.upper()] = original + elif kind in ("multiple", "empty-list"): + headers[field] = [original, original] if kind == "multiple" else [] + else: + headers = cast( + dict[str, str | Sequence[str]], + httpx.Headers( + [ + *((key, cast(str, value)) for key, value in request.headers.items()), + (field, original), + ] + ), + ) + await check_error( + replace(request, headers=headers), + "CONTENT_DIGEST_INVALID" if field == "content-digest" else "SIGNATURE_INPUT_INVALID", + ) + + +async def test_body_and_signature_failures_do_not_consume_nonce() -> None: + request = signed(body=b"{}") + await check_error(replace(request, body=b"[]"), "CONTENT_DIGEST_INVALID") + async with TapVerifier(key_resolver=Resolver(), clock=lambda: NOW) as verifier: + with pytest.raises(TapVerificationError): + await verifier.verify(replace(request, method="POST")) + assert (await verifier.verify(request)).verified + + +async def test_unknown_and_wrong_type_keys() -> None: + request = signed() + wire = cast(str, request.headers["signature-input"]).replace( + 'keyid="test-key"', 'keyid="unknown"' + ) + await check_error( + replace(request, headers={**request.headers, "signature-input": wire}), "KEY_NOT_FOUND" + ) + + class WrongKey: + async def resolve(self, keyid: str, algorithm: str) -> ed25519.Ed25519PublicKey: + # Deliberately violate the custom implementation's declared return type. + return cast( + ed25519.Ed25519PublicKey, rsa.generate_private_key(65537, 2048).public_key() + ) + + async with TapVerifier(key_resolver=WrongKey(), clock=lambda: NOW) as verifier: + with pytest.raises(TapVerificationError) as error: + await verifier.verify(request) + assert error.value.code == "SIGNATURE_INVALID" + + +async def test_custom_failures_and_cancellation_do_not_invoke_handler() -> None: + class FailedResolver: + async def resolve(self, keyid: str, algorithm: str) -> ed25519.Ed25519PublicKey: + raise LookupError("application key failure") + + class FailedStore: + async def claim(self, keyid: str, nonce: str, expires: int) -> bool: + raise LookupError("application store failure") + + async def handler(facts: TapVerificationFacts) -> None: + pytest.fail("Unverified request reached handler") + + async with TapVerifier(key_resolver=FailedResolver(), clock=lambda: NOW) as verifier: + with pytest.raises(LookupError, match="key failure"): + await verifier.with_verified(signed(), handler) + async with TapVerifier( + key_resolver=Resolver(), replay_store=FailedStore(), clock=lambda: NOW + ) as verifier: + with pytest.raises(LookupError, match="store failure"): + await verifier.with_verified(signed(), handler) + + +async def test_late_resolution_and_concurrent_replay() -> None: + now: float = NOW + + class SlowResolver(Resolver): + async def resolve(self, keyid: str, algorithm: str) -> ed25519.Ed25519PublicKey | None: + nonlocal now + await asyncio.sleep(0) + now = NOW + 300 + return await super().resolve(keyid, algorithm) + + async with TapVerifier(key_resolver=SlowResolver(), clock=lambda: now) as verifier: + assert (await verifier.verify(signed())).verified + async with TapVerifier(key_resolver=Resolver(), clock=lambda: NOW) as verifier: + outcomes = await asyncio.gather( + *(verifier.verify(signed()) for _ in range(20)), return_exceptions=True + ) + assert sum(isinstance(item, TapVerificationFacts) for item in outcomes) == 1 + assert all( + isinstance(item, TapVerificationFacts) + or (isinstance(item, TapVerificationError) and item.code == "NONCE_REPLAYED") + for item in outcomes + ) + + +async def test_store_expiry_and_tuple_keys() -> None: + now = NOW + store = MemoryTapReplayStore(lambda: now) + assert await store.claim("a\x00b", "c", NOW + 1) + assert await store.claim("a", "b\x00c", NOW + 1) + assert not await store.claim("a", "b\x00c", NOW + 1) + now += 1 + assert await store.claim("a", "b\x00c", NOW + 2) + + +async def test_default_lifecycle_and_closed_use() -> None: + async with TapVerifier() as verifier: + with pytest.raises(TapVerificationError): + await verifier.verify(TapRequest(method="GET", url="https://host/", headers={})) + await verifier.aclose() + with pytest.raises(RuntimeError, match="closed"): + await verifier.verify(signed()) + with pytest.raises(RuntimeError, match="closed"): + await verifier.__aenter__() + + +async def test_missing_signature_does_not_resolve_keys() -> None: + resolver = Resolver() + request = signed() + headers = dict(request.headers) + del headers["signature"] + async with TapVerifier(key_resolver=resolver, clock=lambda: NOW) as verifier: + with pytest.raises(TapVerificationError): + await verifier.verify(replace(request, headers=headers)) + assert resolver.calls == 0 + + +@pytest.mark.parametrize("boundary", ["resolver", "store"]) +async def test_cancellation_propagates_without_handler(boundary: str) -> None: + started = asyncio.Event() + calls = [] + + class WaitingResolver(Resolver): + async def resolve(self, keyid: str, algorithm: str) -> ed25519.Ed25519PublicKey | None: + if boundary == "resolver": + started.set() + await asyncio.Event().wait() + return await super().resolve(keyid, algorithm) + + class WaitingStore: + async def claim(self, keyid: str, nonce: str, expires: int) -> bool: + started.set() + await asyncio.Event().wait() + return True + + async def handler(facts: TapVerificationFacts) -> None: + calls.append(facts) + + async with TapVerifier( + key_resolver=WaitingResolver(), replay_store=WaitingStore(), clock=lambda: NOW + ) as verifier: + task = asyncio.create_task(verifier.with_verified(signed(), handler)) + await asyncio.wait_for(started.wait(), 1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert not calls + + +async def test_handler_result_and_error_propagation() -> None: + async def handler(facts: TapVerificationFacts) -> str: + return facts.keyid + + async def fail(facts: TapVerificationFacts) -> None: + raise LookupError("handler failure") + + async with TapVerifier(key_resolver=Resolver(), clock=lambda: NOW) as verifier: + assert ( + await verifier.with_verified(signed(url="https://merchant.example/"), handler) + == "test-key" + ) + async with TapVerifier(key_resolver=Resolver(), clock=lambda: NOW) as verifier: + with pytest.raises(LookupError, match="handler failure"): + await verifier.with_verified(signed(), fail) diff --git a/uv.lock b/uv.lock index 890ed29..4377d03 100644 --- a/uv.lock +++ b/uv.lock @@ -1472,6 +1472,9 @@ mpp = [ svm = [ { name = "x402", extra = ["svm"] }, ] +tap = [ + { name = "cryptography" }, +] x402 = [ { name = "x402" }, ] @@ -1490,6 +1493,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "cryptography", marker = "extra == 'tap'", specifier = ">=50.0.2,<51" }, { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.142.2,<1" }, { name = "httpx", specifier = ">=0.28.1,<1" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.30.0,<2" }, @@ -1501,7 +1505,7 @@ requires-dist = [ { name = "x402", extras = ["mcp"], marker = "extra == 'mcp'", specifier = ">=2.25.0,<3" }, { name = "x402", extras = ["svm"], marker = "extra == 'svm'", specifier = ">=2.25.0,<3" }, ] -provides-extras = ["evm", "fastapi", "mcp", "mpp", "svm", "x402"] +provides-extras = ["evm", "fastapi", "mcp", "mpp", "svm", "tap", "x402"] [package.metadata.requires-dev] dev = [