From 5160c2853fc579ef13e9bb20c2f5e5c20c282f4c Mon Sep 17 00:00:00 2001 From: jabdikadyr Date: Fri, 25 Sep 2026 10:41:41 +0600 Subject: [PATCH] fix(sdk): harden spec conformance across core, agent, service, and platform --- README.md | 86 +- examples/README.md | 38 +- examples/agent_service.py | 46 +- examples/hosted_platform.py | 70 +- scripts/conformance_adapter.py | 2147 +++++++++++++++-- scripts/verify-consumer.sh | 4 + .../adapters/asgi.py | 78 +- .../agent/__init__.py | 7 + src/agent_enrollment_protocol/agent/client.py | 122 +- .../agent/platform_provider.py | 74 +- .../agent/protected_resource.py | 408 ++++ src/agent_enrollment_protocol/agent/stores.py | 3 + .../agent/transport.py | 49 +- src/agent_enrollment_protocol/agent/types.py | 19 +- .../core/__init__.py | 12 +- .../core/assertions.py | 38 +- src/agent_enrollment_protocol/core/did_web.py | 7 +- src/agent_enrollment_protocol/core/http.py | 42 +- src/agent_enrollment_protocol/core/models.py | 137 +- src/agent_enrollment_protocol/core/openapi.py | 64 + .../core/validation.py | 12 +- .../platform/document.py | 15 + .../platform/platform.py | 181 +- .../service/credentials.py | 100 +- .../service/service.py | 48 +- tests/test_agent_boundaries.py | 64 +- tests/test_agent_cache.py | 195 ++ tests/test_agent_integration.py | 593 +++++ tests/test_agent_platform_provider.py | 113 +- tests/test_agent_tls.py | 103 + tests/test_asgi.py | 266 +- tests/test_conformance_falsifiability.py | 276 +++ tests/test_core_assertions.py | 109 + tests/test_core_http.py | 154 +- tests/test_core_models.py | 152 +- tests/test_core_validation.py | 131 + tests/test_examples.py | 99 + tests/test_platform.py | 38 +- tests/test_platform_boundaries.py | 412 +++- tests/test_protected_resource.py | 522 ++++ tests/test_service.py | 36 + tests/test_service_boundaries.py | 65 + tests/test_service_credentials.py | 247 ++ 43 files changed, 6857 insertions(+), 525 deletions(-) create mode 100644 src/agent_enrollment_protocol/agent/protected_resource.py create mode 100644 tests/test_agent_cache.py create mode 100644 tests/test_agent_integration.py create mode 100644 tests/test_agent_tls.py create mode 100644 tests/test_conformance_falsifiability.py create mode 100644 tests/test_examples.py create mode 100644 tests/test_protected_resource.py diff --git a/README.md b/README.md index d95cc45..9b38af8 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,10 @@ uv run python examples/agent_service.py uv run python examples/hosted_platform.py ``` -The first example composes an Agent, Service, every built-in credential profile, protected resource, -and the framework-neutral ASGI adapter. The second demonstrates hosted identity Platform discovery, -Service DID resolution, provisioning, DID publication, delegated signing, and identity listing. See +The first example composes an Agent, Service, JWT authentication, every built-in credential profile, +a protected resource, and the framework-neutral ASGI adapter. The second demonstrates Platform +discovery, Service DID resolution, provisioning, DID publication, verified delegated signing, +identity listing, and suspension that prevents further signing. See [`examples/README.md`](./examples/README.md) for the integration boundaries and production replacements. @@ -63,6 +64,50 @@ Concrete Grant Types and extensions can define additional Grant and Revoke reque those members through `GrantOptions.parameters` or `RevokeOptions.parameters`; the Agent retains control of the standard Grant Type and Revoke selector fields. +## Protected HTTP requests + +After explicitly enrolling an Agent with a Service, use `fetch_protected_resource()` to request a +protected URL. It returns the SDK's transport-independent `HttpResponse`: + +```python +from agent_enrollment_protocol.agent import ( + FetchProtectedResourceOptions, + fetch_protected_resource, +) +from agent_enrollment_protocol.core import HttpRequest + +response = await fetch_protected_resource( + FetchProtectedResourceOptions( + agent=agent, + request=HttpRequest(method="GET", url="https://service.example/private"), + carrier="AEP-Authorization", + ) +) +``` + +The helper uses advertised OpenAPI security when it can resolve a complete supported requirement; +otherwise it waits for a valid `401` AEP challenge. It reuses stored credentials or signs a +resource-bound assertion, and can request a supported Grant for an already-enrolled identity. It +does not enroll automatically. Set `grant_type` to select a credential type, or `credential_id` to +select one stored credential. A missing `credential_id` match fails instead of requesting a +replacement Grant. + +Requests use replayable byte bodies. Redirects are handled manually: each same-origin JWT retry +gets a fresh assertion bound to its target, and an origin change drops caller headers and requires +a new anonymous AEP challenge. The defaults are five redirects and thirty seconds for the entire +operation, including discovery and authentication. Task cancellation propagates to the active +operation. Responses obey the Agent's `maximum_response_bytes` bound. + +Put payment credentials and custom secret headers in `additional_authentication_headers`, not +`request.headers`. They are sent only on authenticated requests to the original origin. Select +`carrier="AEP-Authorization"` when payment authentication occupies `Authorization`. Additional +headers cannot override the selected AEP credential. + +An optional `transport` implements `AsyncHttpTransport`; it must return redirects without following +them. The helper closes its default `HttpxTransport` after the operation but does not close a +caller-provided transport. Existing `ServiceSession.authentication_headers()` remains available +for applications that manage their own HTTP requests. + ## Service ASGI integration `agent_enrollment_protocol.adapters` provides a framework-neutral ASGI integration with no @@ -97,6 +142,10 @@ if principal is None: raise RuntimeError("The route requires AEP authentication") ``` +The application then applies its own account permissions and scope checks. Authentication alone +does not authorize every operation. The complete Service construction, credential issuers and +example DID verifier are in [`examples/agent_service.py`](./examples/agent_service.py). + Use a separate unprotected application branch for public resources. For local development, `allow_insecure_loopback=True` permits an HTTP `localhost` or loopback resource origin; production origins require HTTPS. @@ -140,6 +189,15 @@ opaque Platform context; returning updated context starts the next signing stage idempotency key. Without a resolver, pending signing raises `PlatformSignPendingError` for the application to continue explicitly. +Grant recovers an existing identity through `find_identity_by_service_did` when the identity +provider implements `ExistingIdentityProvider` and the Agent's local identity store is empty. +Recovery does not provision or enroll an identity. The Agent checks Service Status before +requesting credentials when the Service advertises Status. + +The default HTTP transport requires TLS 1.3 or later and verifies certificates using the system +trust configuration. Applications supplying an HTTP client or transport control its TLS settings +and must enforce the same protocol minimum. The SDK does not change a supplied client's settings. + ## Hosted identity Platform `agent_enrollment_protocol.platform` implements discovery, Service-scoped Agent identity @@ -182,6 +240,28 @@ advertised by `DiscoveryOptions`. Authenticate private Platform routes before co `RequestContext`; the Platform also invokes the supplied authorizer for every private operation. Enable hosted verification only with a replay store. +For hosted verification, the authorizer decides whether the authenticated caller may verify the +supplied Agent assertion. A trusted Service can call with its own credentials; it does not need +the identity owner's credentials. Authorize that relationship explicitly rather than permitting +every authenticated caller to verify every identity. Signing and identity management retain their +owner checks. + +## Application-owned storage + +Default memory stores retain state only for the lifetime of one process. To retain an Agent's +identity and session credentials across restarts, supply `AgentOptions.identity_store` and +`credential_store`. A Service supplies durable enrollment, credential, replay and idempotency +stores; a Platform supplies identity, key, idempotency and, when using hosted verification, replay +storage. Inspect and discovery caches can remain transient. + +Custom stores implement the asynchronous interfaces exported by their role module. In particular, +Service `EnrollmentStore.find_or_create()` must atomically reuse or create one enrollment for an +Agent DID. Service `IdempotencyStore.execute()` and Platform `PlatformIdempotencyStore.execute()` +must atomically run or replay an operation and reject conflicting reuse of the key. Retain command +results for at least one hour. Replay-store `consume()` must atomically refuse a second use of an +assertion identifier. Independent reads followed by writes do not provide these guarantees when +requests run concurrently or the application has multiple instances. + ## Development Install the locked development environment and run the complete merge gate: diff --git a/examples/README.md b/examples/README.md index e21e036..4367da3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,8 +6,8 @@ keys in memory. | Example | Demonstrates | | --- | --- | -| [`agent_service.py`](./agent_service.py) | Agent inspection, enrollment, API-key, Basic, and OAuth Bearer grants, authenticated Service access, credential revocation, and rejected reuse through the ASGI adapter | -| [`hosted_platform.py`](./hosted_platform.py) | Hosted identity Platform discovery, Service DID resolution, provisioning, DID document publication, delegated signing, identity listing, and lifecycle management | +| [`agent_service.py`](./agent_service.py) | Inspect, Enroll, Status, a JWT-authenticated request, API-key/Basic/OAuth Bearer grants, authenticated resource access, and rejection after credential revocation through the ASGI adapter | +| [`hosted_platform.py`](./hosted_platform.py) | Platform discovery, Service DID resolution, identity provisioning, DID publication, delegated signing verified with the published public key, listing, suspension, and refused signing after suspension | Run them from the repository root after installing the development environment: @@ -17,15 +17,39 @@ uv run python examples/agent_service.py uv run python examples/hosted_platform.py ``` -Both examples resolve `did:web` documents through HTTP clients using HTTPS URLs. Their in-memory -transports make the examples deterministic without bypassing DID resolution. Production -integrations use a normal HTTP client with their cache and network policy. Replace all memory stores -with durable implementations before deployment. +No external account, API token, running server, or payment is required. Both examples use HTTPS URLs, +but their HTTP transports run in process: `MockTransport` serves the example DID documents and +`ASGITransport` calls the Service application. They exercise DID lookup, key selection and real +signature verification, not a network TLS handshake. The Agent and Service example runs every built-in credential profile. Each profile supplies an issuer callback and the same `ServiceCredentialStore` to its corresponding `stored_*_grant_type()` factory. -The example authenticates with each issued credential, revokes it, and confirms that reuse is rejected. +The example first uses `fetch_protected_resource()` with the dedicated `AEP-Authorization` carrier. +Before any session credentials exist, the Agent uses the advertised JWT method. The credential loop +then uses the lower-level `authentication_headers()` API and an explicitly selected Grant Type. +Each issuer grants only the example's `account:read` scope rather than trusting arbitrary requested +scopes. Each credential is revoked by its identifier, and reuse must return `401`. The Platform example deliberately keeps its private key in process. A production Platform supplies a `KeyStore` backed by its key-management system and maps the Platform methods to authenticated HTTP routes. + +Successful runs print one result per step and exit with status zero. Unexpected enrollment, resource, +listing, signature, lifecycle, or revocation outcomes raise an exception and fail the command. + +## Adapting the examples to an application + +- Publish the Agent DID document where its DID resolves, and connect the Service application to + your HTTP server. Replace the two in-process transports with real network transports. +- The example DID resolvers trust a fixed local URL map. A production resolver needs HTTPS with + TLS 1.3 or later, certificate verification, bounded response sizes and timeouts, and a safe + redirect policy. Reuse validated DID metadata according to your application's caching policy. +- Keep Agent signing keys and Service-issued credentials in protected storage. The sample's + generated key and all memory stores disappear when the process exits. Persist enrollment, + credential, replay and idempotency state where multiple requests or instances can share it. +- Decide which Agents may enroll, which scopes they may receive, and which application operations + they may perform. Successful AEP authentication establishes a principal, not application permission. +- For a Platform, replace `AllowAuthenticatedCaller` with your permission checks and construct + `RequestContext.principal` from validated route authentication, never from caller-supplied JSON. + Use the SDK's default unique identifier generators instead of the sample's fixed identifiers. + Keep lifecycle management on privileged Platform routes, not in the Agent workflow. diff --git a/examples/agent_service.py b/examples/agent_service.py index 0f8bb5c..4c80932 100644 --- a/examples/agent_service.py +++ b/examples/agent_service.py @@ -27,11 +27,13 @@ AssertionSigner, AuthenticationOptions, EnrollOptions, + FetchProtectedResourceOptions, GrantOptions, HttpxTransport, IdentityRequest, RevokeOptions, ServiceIdentity, + fetch_protected_resource, ) from agent_enrollment_protocol.core import ( AEP_AUTHENTICATION_METHOD_JWT, @@ -42,12 +44,14 @@ AEP_GRANT_TYPE_OAUTH_BEARER, AEP_IDENTITY_METHOD_DID_WEB, AepAssertionError, + AgentStatus, ApiKeyGrantResponse, BasicGrantResponse, ClaimValues, ClientAssertionClaims, GrantRequest, GrantTypeConfig, + HttpRequest, InspectClaims, OAuthBearerGrantResponse, SigningAlgorithm, @@ -191,6 +195,8 @@ async def main() -> None: key = Ed25519PrivateKey.generate() credential_store = MemoryServiceCredentialStore() + # The demo grants read-only account access. Requested scopes are not permission to issue + # arbitrary privileges; a real issuer applies its own account and authorization policy. async def issue_api_key(request: GrantRequest, context: GrantContext) -> ApiKeyGrantResponse: return ApiKeyGrantResponse( api_key=secrets.token_urlsafe(24), @@ -199,7 +205,7 @@ async def issue_api_key(request: GrantRequest, context: GrantContext) -> ApiKeyG .isoformat(timespec="seconds") .replace("+00:00", "Z"), header="X-Agent-Key", - scopes=request.requested_scopes, + scopes=("account:read",), ) async def issue_basic(request: GrantRequest, context: GrantContext) -> BasicGrantResponse: @@ -209,7 +215,7 @@ async def issue_basic(request: GrantRequest, context: GrantContext) -> BasicGran .isoformat(timespec="seconds") .replace("+00:00", "Z"), password=secrets.token_urlsafe(24), - scopes=request.requested_scopes, + scopes=("account:read",), username="example-agent", ) @@ -220,7 +226,7 @@ async def issue_oauth(request: GrantRequest, context: GrantContext) -> OAuthBear expires_at=(context.current_time + timedelta(hours=1)) .isoformat(timespec="seconds") .replace("+00:00", "Z"), - scopes=request.requested_scopes, + scopes=("account:read",), token_type="Bearer", ) @@ -305,8 +311,37 @@ def resolve_agent_did(request: httpx.Request) -> httpx.Response: ) ) ) + if enrollment.body.status is not AgentStatus.ACTIVE: + raise RuntimeError("The example enrollment did not become active") print("Enroll:", enrollment.body.status.value) + status = await session.status() + if status.body.status is not AgentStatus.ACTIVE: + raise RuntimeError("The Service did not report an active Agent") + print("Status:", status.body.status.value) + + # With no stored session credential, the advertised JWT method signs this resource + # request. The helper follows the challenge but does not enroll for the caller. + authenticated = await fetch_protected_resource( + FetchProtectedResourceOptions( + agent=agent, + request=HttpRequest(method="GET", url=RESOURCE), + carrier="AEP-Authorization", + transport=transport, + ) + ) + if authenticated.status != 200: + raise RuntimeError("The JWT-authenticated resource request failed") + jwt_resource = json.loads(authenticated.body) + if ( + jwt_resource["agent_did"] != AGENT_DID + or jwt_resource["authentication_method"] != AEP_AUTHENTICATION_METHOD_JWT + ): + raise RuntimeError( + "The resource did not authenticate the expected Agent and method" + ) + print("Resource:", jwt_resource["message"], "(aep-jwt)") + for grant_type in ( AEP_GRANT_TYPE_API_KEY, AEP_GRANT_TYPE_BASIC, @@ -329,6 +364,11 @@ def resolve_agent_did(request: httpx.Request) -> httpx.Response: response = await client.get(RESOURCE, headers=headers) response.raise_for_status() resource: dict[str, Any] = response.json() + if ( + resource["agent_did"] != AGENT_DID + or resource["authentication_method"] != grant_type + ): + raise RuntimeError("The resource did not use the selected credential") print( "Resource:", resource["message"], diff --git a/examples/hosted_platform.py b/examples/hosted_platform.py index 70dcf55..ce3840d 100644 --- a/examples/hosted_platform.py +++ b/examples/hosted_platform.py @@ -6,6 +6,7 @@ from typing import Any import httpx +import jwt from cryptography.hazmat.primitives.asymmetric import ec from agent_enrollment_protocol.core import ( @@ -18,8 +19,11 @@ PlatformSignCompleted, PlatformSignRequest, SigningAlgorithm, + VerifyClientAssertionOptions, did_web_document_url, + select_did_web_public_jwk, sign_client_assertion, + verify_client_assertion, ) from agent_enrollment_protocol.platform import ( AuthorizationRequest, @@ -101,6 +105,8 @@ def _key(self, identity: IdentityRecord) -> ec.EllipticCurvePrivateKey: async def run(client: httpx.AsyncClient) -> None: + # These fixed identifiers and permissive authorizer serve one local caller. Production + # routes authenticate callers and supply their identity and permissions to the Platform. platform = Platform( PlatformOptions( agent_did_id_generator=lambda: "agent-one", @@ -129,18 +135,24 @@ async def run(client: httpx.AsyncClient) -> None: ) discovery = platform.discovery() - print("Discovery:", discovery.body.platform.name if discovery.body else "missing") + if discovery.status != 200 or discovery.body is None: + raise RuntimeError("The Platform did not return discovery metadata") + print("Discovery:", discovery.body.platform.name) provisioned = await platform.provision( PlatformProvisionRequest(service_did=SERVICE_DID), context ) identity = provisioned.body - if not isinstance(identity, PlatformAgentIdentity): + if provisioned.status != 200 or not isinstance(identity, PlatformAgentIdentity): raise RuntimeError("The Platform did not provision an Agent identity") print("Provision:", identity.agent_did) document = await platform.did_document("agent-one") - if not isinstance(document.body, dict): + if ( + document.status != 200 + or not isinstance(document.body, dict) + or document.body.get("id") != identity.agent_did + ): raise RuntimeError("The Platform did not publish a DID document") print("DID document:", document.body["id"]) @@ -157,24 +169,70 @@ async def run(client: httpx.AsyncClient) -> None: current_time=datetime.now(UTC), ), ) - if not isinstance(signed.body, PlatformSignCompleted): + if signed.status != 200 or not isinstance(signed.body, PlatformSignCompleted): raise RuntimeError("The Platform did not return a signed assertion") + # Verify with the published public key, not the key store's private signing key. + public_jwk = select_did_web_public_jwk( + document.body, did=identity.agent_did, key_id=identity.key_id + ) + verified = verify_client_assertion( + signed.body.client_assertion, + key=jwt.PyJWK.from_dict(public_jwk).key, + options=VerifyClientAssertionOptions( + audience=SERVICE_DID, + issuer=identity.agent_did, + subject=identity.agent_did, + operation=AssertionOperation.ENROLL, + ), + ) + if verified.jti != signed.body.jti: + raise RuntimeError("The signed assertion does not match its public metadata") print("Sign:", signed.body.agent_did, signed.body.jti) listed = await platform.list( IdentityListQuery(service_did=SERVICE_DID), RequestContext(principal="developer-one", current_time=datetime.now(UTC)), ) - print("List:", listed.body.count if listed.body else "missing", "identity") + if ( + listed.status != 200 + or listed.body is None + or listed.body.count != "1" + or len(listed.body.data) != 1 + or listed.body.data[0].agent_identity_id != identity.agent_identity_id + ): + raise RuntimeError("The Platform did not list the provisioned identity") + print("List:", listed.body.count, "identity") suspended = await platform.update_identity( identity.agent_identity_id, PlatformLifecycleRequest(status=ManagedAgentStatus.SUSPENDED), RequestContext(principal="developer-one", current_time=datetime.now(UTC)), ) - if not isinstance(suspended.body, PlatformAgentIdentity): + if ( + suspended.status != 200 + or not isinstance(suspended.body, PlatformAgentIdentity) + or suspended.body.status is not ManagedAgentStatus.SUSPENDED + ): raise RuntimeError("The Platform did not update the hosted identity") print("Lifecycle:", suspended.body.status.value) + refused = await platform.sign( + identity.agent_identity_id, + PlatformSignRequest( + jti="assertion-after-suspension", op=AssertionOperation.ENROLL, service_did=SERVICE_DID + ), + RequestContext( + principal="developer-one", + idempotency_key="sign-after-suspension", + current_time=datetime.now(UTC), + ), + ) + if ( + refused.status != 403 + or refused.problem is None + or refused.problem.code != "identity_suspended" + ): + raise RuntimeError("The suspended identity was not refused for signing") + print("Suspended signing:", refused.status, refused.problem.code) async def main() -> None: diff --git a/scripts/conformance_adapter.py b/scripts/conformance_adapter.py index c19721e..bc21e5a 100755 --- a/scripts/conformance_adapter.py +++ b/scripts/conformance_adapter.py @@ -5,32 +5,42 @@ import base64 import json import sys -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import replace from datetime import UTC, datetime, timedelta from inspect import Parameter, signature -from typing import Any +from typing import Any, cast from urllib.parse import parse_qs, urlsplit +import jwt +from cryptography.hazmat.primitives.asymmetric import ec from pydantic import BaseModel from agent_enrollment_protocol.agent import ( - InspectCacheEntry, - MemoryInspectCache, - OperationKey, + Agent, + AgentCommandError, + AgentOptions, + AssertionSigner, + AuthenticationOptions, + CredentialRecord, + EnrollOptions, + FetchProtectedResourceOptions, + GrantOptions, PlatformCommandError, PlatformContextProvider, PlatformIdentityProvider, PlatformIdentityProviderOptions, PlatformPendingSignResolver, PlatformSignPendingError, - RandomIdempotencyKeyProvider, + RevokeOptions, ServiceIdentity, + ServiceSession, + fetch_protected_resource, ) from agent_enrollment_protocol.agent.types import IdentityRequest from agent_enrollment_protocol.core import ( AEP_MEDIA_TYPE, - AepAssertionError, + AEP_PROBLEM_MEDIA_TYPE, AepAuthorizationError, AepValidationError, AgentStatus, @@ -54,21 +64,21 @@ OpenApiAepSecurityScheme, OpenApiTrailingSlash, PlatformAgentIdentity, - PlatformAgentIdentityListResponse, - PlatformDiscoveryDocument, PlatformLifecycleRequest, PlatformProvisionRequest, + PlatformSignCompleted, PlatformSignRequest, PlatformVerificationRequest, - PlatformVerificationResponse, ProblemDetails, ProtectedResourceAuthorization, RevokeRequest, RevokeResponse, SigningAlgorithm, StatusResponse, + VerifyClientAssertionOptions, authorization_header_name, command_path, + decode_jwt_unverified, did_web_document_url, evaluate_claim_support, is_version_compatible, @@ -79,42 +89,44 @@ parse_json_model, render_authorization, require_service_origin_binding, + resolve_openapi_security, resolve_openapi_url, - same_origin, + sign_client_assertion, + verify_client_assertion, ) from agent_enrollment_protocol.platform import ( AuthorizationRequest, DidVerificationMethod, DiscoveryOptions, - IdempotentOperation, IdentityListQuery, IdentityRecord, MemoryIdentityStore, - MemoryPlatformIdempotencyStore, MemoryReplayStore, Platform, - PlatformIdempotencyInput, PlatformOptions, + PlatformResult, RequestContext, - create_service_scoped_agent_did, -) -from agent_enrollment_protocol.platform import ( - StoredResponse as PlatformStoredResponse, ) from agent_enrollment_protocol.service import ( AssertionVerificationContext, CommandOptions, + EnrollmentDecision, EnrollmentRecord, GrantContext, GrantTypeDefinition, IdempotencyInput, MemoryEnrollmentStore, MemoryIdempotencyStore, + MemoryServiceCredentialStore, ProtectedResourceRequest, RevokeContext, Service, ServiceOptions, + StoredCredentialGrantTypeOptions, StoredResponse, + stored_api_key_grant_type, + stored_basic_grant_type, + stored_oauth_bearer_grant_type, ) JsonObject = dict[str, Any] @@ -122,6 +134,7 @@ AGENT_DID = "did:web:agent.example.com:agents:123" SERVICE_DID = "did:web:api.example.com" PLATFORM_ORIGIN = "https://p.example" +ASSERTION_KEY = ec.generate_private_key(ec.SECP256R1()) CLAIM_VALUE_CASES = frozenset( { @@ -306,6 +319,7 @@ def platform_provider( idempotency_keys: list[str] | None = None, pending_resolver: PlatformPendingSignResolver | None = None, platform_context: PlatformContextProvider | None = None, + clock: Callable[[], datetime] = lambda: NOW, ) -> PlatformIdentityProvider: keys = iter(idempotency_keys or []) @@ -314,6 +328,7 @@ async def idempotency_key() -> str: return PlatformIdentityProvider( PlatformIdentityProviderOptions( + clock=clock, idempotency_key=idempotency_key if idempotency_keys is not None else None, pending_sign_resolver=pending_resolver, platform_context=platform_context, @@ -376,7 +391,16 @@ def evaluate_claims(identifier: str, case: JsonObject) -> bool: inspection = InspectClaims.model_validate(source["inspect"]) submitted = ClaimValues.model_validate(source["submitted"]) result = evaluate_claim_support(inspection, tuple(submitted.to_wire())) - return result.can_satisfy_required is expected["enrollment_requirement_satisfied"] + return ( + result.can_satisfy_required is expected["enrollment_requirement_satisfied"] + and (not result.supported_preferred) is expected["omitted_preferred_allowed"] + and expected["unknown_optional_action"] == "ignore" + and not result.supported_optional + and expected["unknown_preferred_action"] == "ignore" + and not result.supported_preferred + and expected["unknown_submitted_default_action"] == "ignore" + and asyncio.run(exercise_claim_negotiation(source)) + ) return False @@ -395,10 +419,7 @@ def evaluate_assertion(identifier: str, case: JsonObject) -> bool: ) return bool(claims.to_wire() == expected) if identifier == "validation-requirements": - claims = parse_json_model( - json.dumps(expected["claims"]), ClientAssertionClaims, "Client assertion" - ) - return claims.op is AssertionOperation.ENROLL and claims.iss == claims.sub + return asyncio.run(exercise_assertion_requirements(source, expected)) return False @@ -472,11 +493,52 @@ def evaluate_inspect(identifier: str, case: JsonObject) -> bool: return False return True if identifier == "transport-requirements": - source = case["input"] - return media_type_essence(source["content_type"]) == AEP_MEDIA_TYPE + return asyncio.run(exercise_inspect_transport(case["input"], case["expected"])) return False +async def exercise_inspect_transport(source: JsonObject, expected: JsonObject) -> bool: + transport = QueueTransport( + HttpResponse(status=307, headers={"Location": source["redirect_url"]}), + json_response(agent_document(), content_type=source["content_type"]), + ) + session, _, _ = agent_session(inspect_transport=transport) + inspected = await session.inspect() + if ( + inspected.final_url != source["redirect_url"] + or transport.requests[0].url != source["request_url"] + or media_type_essence(source["content_type"]) != expected["media_type_essence"] + or expected["redirect_policy"] != "same-scheme-host-effective-port" + ): + return False + cases = ( + ( + "cross_origin_redirect", + HttpResponse(status=307, headers={"Location": "https://other.example/aep"}), + ), + ( + "scheme_downgrade", + HttpResponse(status=307, headers={"Location": "http://api.example.com/aep"}), + ), + ( + "partial_document_after_bound", + HttpResponse( + status=200, headers={"Content-Type": AEP_MEDIA_TYPE}, body=b"{" + b" " * (1 << 20) + ), + ), + ) + for name, response in cases: + session, _, _ = agent_session(response) + try: + await session.inspect() + except ValueError: + if expected[name] != "reject": + return False + else: + return False + return True + + def evaluate_openapi(identifier: str, case: JsonObject) -> bool: source = case["input"] expected = case["expected"] @@ -492,7 +554,9 @@ def evaluate_openapi(identifier: str, case: JsonObject) -> bool: scheme = parse_json_model( json.dumps(source["security_scheme"]), OpenApiAepSecurityScheme, "OpenAPI security" ) - return scheme.authentication_method == "aep-jwt" + if scheme.authentication_method != "aep-jwt": + return False + return _resolves_security_inheritance(expected) if identifier == "url-resolution": relative = resolve_openapi_url(source["final_inspect_url"], source["relative"]) cross_origin = resolve_openapi_url(source["final_inspect_url"], source["cross_origin"]) @@ -502,6 +566,35 @@ def evaluate_openapi(identifier: str, case: JsonObject) -> bool: return False +def _resolves_security_inheritance(expected: JsonObject) -> bool: + """Drives every inheritance shape expected names (CORE-OAS-011/013/014/015).""" + aep, key, mtls = "aepScheme", "apiKeyScheme", "mtlsScheme" + root: tuple[dict[str, tuple[str, ...]], ...] = ({aep: ()},) + supported = (aep, key) + shapes: tuple[tuple[str, tuple[dict[str, tuple[str, ...]], ...] | None], ...] = ( + ("root_inherited", None), + ("operation_empty_array", ()), + ("empty_requirement_alternative", ({}, {aep: ()})), + ("multiple_objects", ({aep: ()}, {key: ()})), + ("multiple_schemes_one_object", ({aep: (), key: ()},)), + ) + for name, operation in shapes: + resolved = resolve_openapi_security( + root=root, operation=operation, supported_schemes=supported + ) + if resolved.disposition.value != expected[name]: + return False + # CORE-OAS-013 and CORE-OAS-015: a compound requirement with an unsatisfiable member is + # unsupported as a whole and is never reduced to the member the Agent does support. + unsupported = resolve_openapi_security( + root=root, operation=({aep: (), mtls: ()},), supported_schemes=supported + ) + return ( + not unsupported.satisfiable + and unsupported.disposition.value == expected["unsupported_compound"] + ) + + def evaluate_authorization(identifier: str, case: JsonObject) -> bool: expected = case["expected"] if identifier == "authorization-carriers": @@ -536,118 +629,919 @@ def evaluate_authorization(identifier: str, case: JsonObject) -> bool: return False +AGENT_ORIGIN = "https://api.example.com" +Clock = Callable[[], datetime] + + +def _header(headers: Mapping[str, str], name: str) -> str | None: + expected = name.lower() + return next((value for key, value in headers.items() if key.lower() == expected), None) + + +def agent_document( + *, + commands: tuple[str, ...] = ("inspect",), + methods: tuple[str, ...] = (), + grant_types: tuple[str, ...] = (), + api_key_header: str | None = None, +) -> JsonObject: + document: JsonObject = { + "aep_version": "1.0", + "bindings": {"supported": ["http"]}, + "claims": {"optional": [], "preferred": [], "required": []}, + "commands": {"supported": list(commands)}, + "core": {"signing_algorithms": ["EdDSA", "ES256"]}, + "http": {"endpoint_base": "/aep/"}, + "identity": {"methods": ["did:web"]}, + "service": {"did": "did:web:api.example.com"}, + } + if methods: + document["authentication"] = {"methods": list(methods)} + if grant_types: + commands_value = document["commands"] + assert isinstance(commands_value, dict) + commands_value["grant_types"] = list(grant_types) + commands_value["grant_types_config"] = { + value: {"supports_per_credential_revoke": "true"} for value in grant_types + } + if api_key_header is not None: + config = commands_value["grant_types_config"] + assert isinstance(config, dict) + config["api-key"] = { + "header_names": [api_key_header], + "supports_per_credential_revoke": "true", + } + return document + + +class AgentSigner: + """An identity provider whose signer returns a well-formed assertion for its claims.""" + + def __init__(self) -> None: + self.claims: list[ClientAssertionClaims] = [] + + async def get_or_create_identity(self, request: IdentityRequest) -> ServiceIdentity: + return ServiceIdentity( + agent_did=AGENT_DID, + identity_method="did:web", + service_did=request.service_did, + signing_algorithms=(SigningAlgorithm.ES256,), + ) + + async def signer_for(self, identity: ServiceIdentity) -> AssertionSigner: + async def sign( + claims: ClientAssertionClaims, algorithms: tuple[SigningAlgorithm, ...] + ) -> str: + self.claims.append(claims) + return sign_client_assertion( + claims, key=ASSERTION_KEY, algorithm=algorithms[0], key_id=identity.agent_did + ) + + return sign + + +def agent_session( + *inspect_responses: HttpResponse, + commands: tuple[HttpResponse, ...] = (), + clock: Clock | None = None, + inspect_transport: QueueTransport | None = None, +) -> tuple[ServiceSession, AgentSigner, QueueTransport]: + provider = AgentSigner() + command_transport = QueueTransport(*commands) + instance = Agent( + AgentOptions( + clock=clock or (lambda: NOW), + command_transport=command_transport, + identity_provider=provider, + inspect_transport=inspect_transport or QueueTransport(*inspect_responses), + ) + ) + return instance.service(AGENT_ORIGIN), provider, command_transport + + +def inspect_response(**headers: str) -> HttpResponse: + values = {name.replace("_", "-"): value for name, value in headers.items()} + return HttpResponse( + status=200, + headers={"Content-Type": AEP_MEDIA_TYPE, **values}, + body=json.dumps(agent_document(), separators=(",", ":")).encode(), + ) + + +def agent_honours_discovery_caching(expected: JsonObject) -> bool: + """Drives the five caching behaviours expected names for a public discovery document.""" + + async def run() -> bool: + return ( + await agent_reuses_within_default_freshness(int(expected["default_freshness_seconds"])) + and expected["no_cache"] == "revalidate" + and await agent_revalidates_no_cache() + and expected["no_store"] == "do-not-persist" + and await agent_discards_no_store() + and expected["status_304"] == "reuse-validated-representation" + and await agent_reuses_a_validated_representation() + and expected["cache_key"] == "final-safe-redirect-url" + and await agent_revalidates_the_final_url() + ) + + return asyncio.run(run()) + + +async def agent_reuses_within_default_freshness(seconds: int) -> bool: + """A response carrying no Cache-Control is reusable for the SDK's own default lifetime.""" + current = [NOW] + transport = QueueTransport(inspect_response(), inspect_response()) + session, _, _ = agent_session(clock=lambda: current[0], inspect_transport=transport) + await session.inspect() + current[0] = NOW + timedelta(seconds=seconds - 1) + await session.inspect() + if len(transport.requests) != 1: + return False + current[0] = NOW + timedelta(seconds=seconds + 1) + await session.inspect() + return len(transport.requests) == 2 + + +async def agent_revalidates_no_cache() -> bool: + """no-cache stores the document but forces a conditional request before reuse.""" + transport = QueueTransport( + inspect_response(cache_control="no-cache", etag='"inspect-1"'), + HttpResponse(status=304, headers={}, body=b""), + ) + session, _, _ = agent_session(inspect_transport=transport) + await session.inspect() + await session.inspect() + return ( + len(transport.requests) == 2 + and _header(transport.requests[1].headers, "if-none-match") == '"inspect-1"' + ) + + +async def agent_discards_no_store() -> bool: + """no-store forbids persisting the document, so the next call has nothing to revalidate.""" + transport = QueueTransport( + inspect_response(cache_control="no-store", etag='"inspect-1"'), + inspect_response(cache_control="no-store", etag='"inspect-1"'), + ) + session, _, _ = agent_session(inspect_transport=transport) + await session.inspect() + await session.inspect() + return ( + len(transport.requests) == 2 + and _header(transport.requests[1].headers, "if-none-match") is None + ) + + +async def agent_reuses_a_validated_representation() -> bool: + """A 304 reuses the stored representation rather than returning an empty document.""" + transport = QueueTransport( + inspect_response(cache_control="no-cache", etag='"inspect-1"'), + HttpResponse(status=304, headers={}, body=b""), + ) + session, _, _ = agent_session(inspect_transport=transport) + await session.inspect() + revalidated = await session.inspect() + return revalidated.document.service.did == "did:web:api.example.com" + + +async def agent_revalidates_the_final_url() -> bool: + """The cache is keyed on the final URL a safe redirect settled on, not the requested one.""" + final_url = f"{AGENT_ORIGIN}/discovery/aep" + transport = QueueTransport( + HttpResponse(status=308, headers={"Location": final_url}, body=b""), + inspect_response(cache_control="no-cache", etag='"inspect-1"'), + HttpResponse(status=304, headers={}, body=b""), + ) + session, _, _ = agent_session(inspect_transport=transport) + inspection = await session.inspect() + await session.inspect() + return ( + inspection.final_url == final_url + and len(transport.requests) == 3 + and transport.requests[2].url == final_url + ) + + +def agent_headers( + document: JsonObject, options: AuthenticationOptions +) -> Mapping[str, str] | Exception: + async def run() -> Mapping[str, str] | Exception: + session, _, _ = agent_session(json_response(document)) + try: + return await session.authentication_headers(options) + except (AepAuthorizationError, AepValidationError, ValueError) as error: + return error + + return asyncio.run(run()) + + +def agent_assertion_claims( + document: JsonObject, options: AuthenticationOptions +) -> ClientAssertionClaims | None: + async def run() -> ClientAssertionClaims | None: + session, provider, _ = agent_session(json_response(document)) + try: + await session.authentication_headers(options) + except (AepAuthorizationError, AepValidationError, ValueError): + return None + return provider.claims[-1] if provider.claims else None + + return asyncio.run(run()) + + def evaluate_protected(identifier: str, case: JsonObject) -> bool: + """Drives the Agent's own protected-resource behaviour. + + These mappings evaluate the Agent role: the party that selects a carrier, mints an assertion + bound to a resource, and presents a stored credential in the field the Service selected. + """ source = case["input"] expected = case["expected"] if identifier == "api-key-wrong-header-rejected": - return bool( - source["issued_header"].lower() != source["presented_header"].lower() - and expected["accepted"] is False - ) + return agent_presents_api_key_in_the_issued_header(source, expected) if identifier == "authenticate-assertion": - claims = parse_json_model( - json.dumps(expected["claims"]), ClientAssertionClaims, "Client assertion" - ) - return claims.op is AssertionOperation.AUTHENTICATE + return agent_mints_an_authenticate_assertion(expected) if identifier == "authorization-ambiguity": - try: - parse_authorization("AEP first,AEP second", AuthorizationCarrier.DEDICATED) - except (AepAuthorizationError, AepValidationError, ValueError): - return expected["fallback"] is False and expected["selected_credential"] is None - return False + return agent_refuses_every_ambiguous_presentation(source, expected) if identifier == "authorization-field-safety": - return bool( - authorization_header_name(AuthorizationCarrier.DEDICATED).lower() - == source["field_name"].lower() - and "Authorization" in expected["strip_on_disallowed_redirect"] - and "AEP-Authorization" in expected["strip_on_disallowed_redirect"] - ) + return agent_selects_the_named_carrier(source, expected) if identifier == "authorization-payment-composition": - aep = ProtectedResourceAuthorization( - carrier=AuthorizationCarrier.DEDICATED, - scheme=AuthorizationScheme.AEP, - credentials="compact-jws", - ) - carrier, value = render_authorization(aep) - return bool( - carrier == "AEP-Authorization" - and value == "AEP compact-jws" - and expected["mpp"]["ambiguous"] is False - and expected["x402"]["ambiguous"] is False - ) + return agent_composes_with_a_payment_credential(expected) if identifier == "operation-substitution-rejected": - return set(expected["allowed"]) == { - "enroll:enroll", - "grant:grant", - "revoke:revoke", - "status:status", - "authenticate:protected-resource", - } + return agent_binds_each_assertion_to_its_operation(expected) if identifier == "redirect-safety": - return same_origin(source["source"], source["same_origin"]) and not same_origin( - source["source"], source["cross_origin"] - ) + return agent_rebinds_an_assertion_after_a_redirect(source, expected) if identifier == "unadvertised-authentication-method": - authentication = case["input"]["advertised_methods"] + return agent_fails_closed_without_an_advertised_method(source, expected) + if identifier == "assertion-and-credential-failures": + return agent_surfaces_the_service_error_code(expected) + return False + + +def agent_presents_api_key_in_the_issued_header(source: JsonObject, expected: JsonObject) -> bool: + """CORE-PR-009: an API key is presented in exactly the Service-selected header.""" + issued, presented = source["issued_header"], source["presented_header"] + document = agent_document( + commands=("inspect", "enroll", "grant"), + methods=("api-key",), + grant_types=("api-key",), + api_key_header=issued, + ) + + async def run() -> bool: + session, _, _ = agent_session( + json_response(document), + commands=( + json_response({"status": "active"}), + json_response( + { + "api_key": "opaque-api-key", + "credential_id": "credential-1", + "expires_at": "2027-01-01T00:00:00Z", + "header": issued, + "scopes": [], + } + ), + ), + ) + await session.enroll() + await session.grant(GrantOptions(grant_type="api-key")) + headers = await session.authentication_headers( + AuthenticationOptions(resource=f"{AGENT_ORIGIN}/orders") + ) + names = {name.lower() for name in headers} + # The key goes in the issued header and nowhere else, so a Service reading the presented + # header finds nothing to accept. + return names == {issued.lower()} and presented.lower() not in names + + return asyncio.run(run()) and expected["accepted"] is False + + +def agent_mints_an_authenticate_assertion(expected: JsonObject) -> bool: + """CORE-PR-024: the Agent answers a challenge with an assertion bound to the resource.""" + wanted = parse_json_model( + json.dumps(expected["claims"]), ClientAssertionClaims, "Client assertion" + ) + resource = wanted.resource + if resource is None: + return False + document = agent_document(methods=("aep-jwt",)) + claims = agent_assertion_claims(document, AuthenticationOptions(resource=resource)) + return ( + claims is not None + and claims.op is AssertionOperation.AUTHENTICATE + and claims.resource == resource + and claims.aud == "did:web:api.example.com" + and claims.iss == claims.sub + ) + + +def agent_refuses_every_ambiguous_presentation(source: JsonObject, expected: JsonObject) -> bool: + """input.cases names four ambiguous presentations; expected names one outcome for all.""" + if expected["fallback"] is not False or expected["selected_credential"] is not None: + return False + code = expected["code"] + return all(agent_refuses_presentation(name, code) for name in source["cases"]) + + +def agent_refuses_presentation(name: str, code: str) -> bool: + if name == "both-aep-recognized": + # CORE-PR-010: the Agent uses at most one carrier, so it cannot be the party that fills + # both. Whichever carrier is selected, exactly one field is rendered. + document = agent_document(methods=("aep-jwt",)) + for carrier in AuthorizationCarrier: + headers = agent_headers( + document, + AuthenticationOptions(resource=f"{AGENT_ORIGIN}/orders", carrier=carrier.value), + ) + if isinstance(headers, Exception) or set(headers) != {carrier.value}: + return False + return True + values = { + "invalid-dedicated-plus-valid-standard": "AEP", + "duplicate-dedicated-lines": "AEP first,AEP second", + "combined-dedicated-values": "AEP first Bearer second", + } + if name not in values: + raise ValueError(f"Unknown authorization ambiguity case: {name}") + try: + parse_authorization(values[name], AuthorizationCarrier.DEDICATED) + except AepAuthorizationError as error: + return bool(error.code == code) + return False + + +def agent_selects_the_named_carrier(source: JsonObject, expected: JsonObject) -> bool: + """expected.redirect_new_assertion_carrier names the field a fresh assertion goes in.""" + carrier = expected["redirect_new_assertion_carrier"] + if carrier.lower() != source["field_name"].lower(): + return False + document = agent_document(methods=("aep-jwt",)) + headers = agent_headers( + document, AuthenticationOptions(resource=f"{AGENT_ORIGIN}/orders", carrier=carrier) + ) + return ( + not isinstance(headers, Exception) + and set(headers) == {carrier} + and expected["field_name_match"] == "case-insensitive" + and expected["sensitive_headers_redacted"] is True + and expected["cache_key_excluded"] is True + and expected["idempotency_fingerprint_excluded"] is True + and expected["strip_on_disallowed_redirect"] + == ["Authorization", "AEP-Authorization", "PAYMENT-SIGNATURE", "api-key-header"] + and asyncio.run(exercise_field_safety(source, expected)) + ) + + +def agent_composes_with_a_payment_credential(expected: JsonObject) -> bool: + """CORE-PR-014: a dedicated-carrier AEP credential composes with a payment credential.""" + document = agent_document(methods=("aep-jwt",)) + for name in ("mpp", "x402"): + composition = expected[name] + if composition["ambiguous"] is not False: + return False + aep_field = next( + field + for field, value in composition.items() + if field != "ambiguous" and str(value).split(" ", 1)[0] in {"AEP", "Bearer"} + ) + headers = agent_headers( + document, + AuthenticationOptions(resource=f"{AGENT_ORIGIN}/orders", carrier=aep_field), + ) + if isinstance(headers, Exception): + return False + # The Agent fills the field the composition names for it and claims none of the others, + # which is what leaves the payment credential beside it unambiguous. + others = {field for field in composition if field not in {"ambiguous", aep_field}} + if set(headers) != {aep_field} or others & set(headers): + return False + return True + + +def agent_binds_each_assertion_to_its_operation(expected: JsonObject) -> bool: + """Only a command's own assertion reaches it, and only authenticate carries a resource.""" + allowed = {value.split(":", 1)[0]: value.split(":", 1)[1] for value in expected["allowed"]} + + async def run() -> bool: + for command in (Command.ENROLL, Command.STATUS): + document = agent_document(commands=("inspect", command.value), methods=("aep-jwt",)) + session, provider, _ = agent_session( + json_response(document), commands=(json_response({"status": "active"}),) + ) + if command is Command.ENROLL: + await session.enroll() + else: + await session.status() + minted = provider.claims[-1] + if minted.op.value != allowed.get(command.value) or minted.resource is not None: + return False + session, provider, _ = agent_session(json_response(agent_document(methods=("aep-jwt",)))) + await session.authentication_headers( + AuthenticationOptions(resource=f"{AGENT_ORIGIN}/orders") + ) + claims = provider.claims[-1] return ( - source["unadvertised_credential"] not in authentication - and source["unadvertised_grant_type"] not in authentication - and expected["inferred_method"] is None + allowed.get("authenticate") == "protected-resource" + and claims.op is AssertionOperation.AUTHENTICATE + and claims.resource is not None ) - if identifier == "assertion-and-credential-failures": - return all( - valid( - ProblemDetails, - { - "code": code, - "status": 401, - "title": code.replace("_", " ").title(), - "type": f"urn:aep:error:{code}", - }, + + return asyncio.run(run()) + + +def agent_rebinds_an_assertion_after_a_redirect(source: JsonObject, expected: JsonObject) -> bool: + return asyncio.run(exercise_redirects(source, expected)) + + +def agent_fails_closed_without_an_advertised_method( + source: JsonObject, expected: JsonObject +) -> bool: + """CORE-IDM-003: a method the Service does not advertise cannot be inferred or presented.""" + advertised = tuple(source["advertised_methods"]) + document = agent_document(methods=advertised) + unadvertised = source["unadvertised_grant_type"] + if unadvertised in advertised or source["unadvertised_credential"] in advertised: + return False + refused = agent_headers( + document, + AuthenticationOptions(resource=f"{AGENT_ORIGIN}/orders", grant_type=unadvertised), + ) + if not isinstance(refused, Exception): + return False + # With no advertised method at all the Agent has nothing to present and must not invent one. + empty = agent_headers( + agent_document(), AuthenticationOptions(resource=f"{AGENT_ORIGIN}/orders") + ) + return isinstance(empty, Exception) and expected["inferred_method"] is None + + +def agent_surfaces_the_service_error_code(expected: JsonObject) -> bool: + """The Agent carries the Service's Problem Details code through rather than collapsing it.""" + document = agent_document(commands=("inspect", "status"), methods=("aep-jwt",)) + + async def run() -> bool: + for code in dict.fromkeys(expected.values()): + session, _, _ = agent_session( + json_response(document), + commands=( + json_response( + { + "code": code, + "status": 401, + "title": code.replace("_", " ").title(), + "type": f"urn:aep:error:{code}", + }, + 401, + AEP_PROBLEM_MEDIA_TYPE, + ), + ), ) - for code in set(expected.values()) + try: + await session.status() + except AgentCommandError as error: + if error.problem is None or error.problem.code != code: + return False + else: + return False + return True + + return asyncio.run(run()) + + +class SigningKeyStore: + """A key store that mints real ES256 assertions, so signing paths are genuinely exercised.""" + + def __init__(self) -> None: + self.key = ec.generate_private_key(ec.SECP256R1()) + + async def create_key(self, identity: IdentityRecord) -> None: + del identity + + async def did_verification_method(self, identity: IdentityRecord) -> DidVerificationMethod: + numbers = self.key.public_key().public_numbers() + size = (self.key.key_size + 7) // 8 + + def encode(value: int) -> str: + return base64.urlsafe_b64encode(value.to_bytes(size, "big")).rstrip(b"=").decode() + + return DidVerificationMethod( + controller=identity.agent_did, + id=identity.key_id, + public_key_jwk={ + "crv": "P-256", + "kty": "EC", + "x": encode(numbers.x), + "y": encode(numbers.y), + }, + type="JsonWebKey2020", ) - return False + + async def sign(self, identity: IdentityRecord, claims: ClientAssertionClaims) -> str: + return sign_client_assertion( + claims, key=self.key, algorithm=SigningAlgorithm.ES256, key_id=identity.key_id + ) + + async def verification_key(self, identity: IdentityRecord) -> Any: + del identity + return self.key.public_key() + + +class AllowAuthorizer: + async def authorize(self, request: AuthorizationRequest, context: RequestContext) -> bool: + del request, context + return True + + +def working_platform(**changes: Any) -> Platform: + identifiers = iter([f"pai_01J0AEPPLATFORM{index:012d}" for index in range(1, 9)]) + agent_did_ids = iter([f"agent-{index}" for index in range(1, 9)]) + values: dict[str, Any] = { + "agent_did_id_generator": lambda: next(agent_did_ids), + "authorizer": AllowAuthorizer(), + "clock": lambda: NOW, + "did_host": "p.example", + "did_path_prefix": "a", + "did_url_template": "https://p.example/a/{agent_did_id}/did.json", + "discovery": DiscoveryOptions( + endpoint_base="/v1/aep", + hosted_verification_endpoint="/v1/aep/verifications", + lifecycle_endpoint="/v1/aep/agent-identities/{agent_identity_id}", + list_endpoint="/v1/aep/agent-identities", + platform_did="did:web:p.example", + platform_name="Example Platform", + provision_endpoint="/v1/aep/agent-identities", + sign_endpoint="/v1/aep/agent-identities/{agent_identity_id}/sign", + ), + "hosted_verification": True, + "identifier": lambda: next(identifiers), + "identity_store": MemoryIdentityStore(), + "key_store": SigningKeyStore(), + "replay_store": MemoryReplayStore(), + "service_did_resolver": ResolvedServiceDid(), + "signing_algorithms": (SigningAlgorithm.ES256,), + } + values.update(changes) + return Platform(PlatformOptions(**values)) + + +def platform_context(key: str, principal: str = "stable-principal-123") -> RequestContext: + return RequestContext(current_time=NOW, idempotency_key=key, principal=principal) def evaluate_platform(identifier: str, case: JsonObject) -> bool: source = case["input"] expected = case["expected"] - if identifier == "discovery": - return bool(wire(PlatformDiscoveryDocument, expected) == expected) - if identifier == "provision-request": - return valid(PlatformProvisionRequest, source) - if identifier in {"provision-response", "lifecycle-response"}: - return valid(PlatformAgentIdentity, expected) + if identifier in { + "discovery", + "provision-request", + "provision-response", + "list-response", + "lifecycle-request", + "lifecycle-response", + "sign-request", + "sign-response", + "sign-response-pending", + "verification-request", + "verification-response-recognized", + "verification-response-unrecognized", + }: + return asyncio.run(exercise_platform_operation(identifier, source, expected)) if identifier == "provision-response-distinct-services": - first = source["first_request"]["service_did"] - second = source["second_request"]["service_did"] - return create_service_scoped_agent_did( - "platform.example", "agents", first - ) != create_service_scoped_agent_did("platform.example", "agents", second) - if identifier == "list-response": - return valid(PlatformAgentIdentityListResponse, expected) - if identifier == "lifecycle-request": - return valid(PlatformLifecycleRequest, source) - if identifier == "sign-request": - return valid(PlatformSignRequest, source) - if identifier in {"sign-response", "sign-response-pending"}: - from agent_enrollment_protocol.core import parse_platform_sign_response - - return bool(parse_platform_sign_response(json.dumps(expected)).to_wire() == expected) - if identifier in {"verification-request", "verification-authenticate-missing-resource"}: + return asyncio.run(exercise_distinct_service_scopes(source, expected)) + if identifier == "verification-authenticate-missing-resource": value = source.get("request", source) expected_valid = identifier != "verification-authenticate-missing-resource" return valid(PlatformVerificationRequest, value) is expected_valid - if identifier in {"verification-response-recognized", "verification-response-unrecognized"}: - return valid(PlatformVerificationResponse, expected) if identifier == "authorization-required": return asyncio.run(exercise_platform_authorization(source, expected)) if identifier in IDEMPOTENCY_CASES: - return asyncio.run(exercise_platform_idempotency()) + return asyncio.run(exercise_platform_idempotency(source, expected)) return False +async def exercise_platform_operation( + identifier: str, source: JsonObject, expected: JsonObject +) -> bool: + current = [datetime(2026, 7, 6, 12, tzinfo=UTC)] + keys = SigningKeyStore() + changes: JsonObject = { + "clock": lambda: current[0], + "key_store": keys, + "agent_did_id_generator": lambda: "4Yf7p2xQd9", + } + if identifier == "sign-response-pending": + from agent_enrollment_protocol.core import PlatformSignPending + + async def pending(request: Any, context: Any) -> PlatformResult[Any]: + return PlatformResult( + status=202, + body=parse_json_model(json.dumps(expected), PlatformSignPending, "pending"), + ) + + changes["sign_handler"] = pending + instance = working_platform(**changes) + if identifier == "discovery": + result = instance.discovery() + return ( + result.status == 200 and result.body is not None and result.body.to_wire() == expected + ) + service_did = source.get("service_did", "did:web:api.service.example") + created = await instance.provision( + PlatformProvisionRequest(service_did=service_did), + RequestContext( + current_time=current[0], + principal="owner", + idempotency_key=expected.get("idempotency_key_header", "provision") + if identifier == "provision-request" + else "provision", + ), + ) + if created.status != 200 or not isinstance(created.body, PlatformAgentIdentity): + return False + identity = created.body + context = RequestContext(current_time=current[0], principal="owner", idempotency_key="next") + if identifier == "provision-request": + return identity.service_did == source["service_did"] + if identifier == "provision-response": + return identity.to_wire() == expected + if identifier == "list-response": + query = dict(source["query"]) + query["status"] = ManagedAgentStatus(query["status"]) + result = await instance.list(IdentityListQuery(**query), context) + return ( + result.status == 200 and result.body is not None and result.body.to_wire() == expected + ) + if identifier.startswith("lifecycle-"): + current[0] += timedelta(minutes=10) + context = replace(context, current_time=current[0]) + request = parse_json_model( + json.dumps(source or {"status": "suspended"}), PlatformLifecycleRequest, "lifecycle" + ) + result = await instance.update_identity(identity.agent_identity_id, request, context) + stored = await instance.get_identity(identity.agent_identity_id, context) + return ( + result.status == 200 + and result.body is not None + and stored.body == result.body + and ( + result.body.to_wire() == expected + if expected + else result.body.status == request.status + ) + ) + if identifier in {"sign-request", "sign-response", "sign-response-pending"}: + request_data = ( + source + if identifier == "sign-request" + else { + "service_did": service_did, + "op": "enroll", + "jti": "01J0AEPASSERTION0000000001", + "platform_context": {"authorization_handle": "opaque-value"}, + "lifetime_seconds": "300", + } + ) + request = parse_json_model(json.dumps(request_data), PlatformSignRequest, "sign") + result = await instance.sign( + identity.agent_identity_id, + request, + replace( + context, + idempotency_key=source.get( + "idempotency_key_header", expected.get("idempotency_key_header", "sign") + ), + ), + ) + if identifier == "sign-response-pending": + return ( + result.status == 202 + and result.body is not None + and result.body.to_wire() == expected + ) + if result.status != 200 or not isinstance(result.body, PlatformSignCompleted): + return False + claims = verify_client_assertion( + result.body.client_assertion, + key=keys.key.public_key(), + options=VerifyClientAssertionOptions( + audience=service_did, current_time=int(current[0].timestamp()), operation=request.op + ), + ) + if claims.jti != request.jti: + return False + if identifier == "sign-request": + return claims.aud == source["service_did"] and claims.exp - claims.iat == int( + source["lifetime_seconds"] + ) + actual = result.body.to_wire() + # JWS signature bytes are nondeterministic; the fixture's token is illustrative, while + # the generated token is cryptographically verified against the actual Platform key. + actual.pop("client_assertion") + return actual == { + key: value for key, value in expected.items() if key != "client_assertion" + } + claims = ClientAssertionClaims( + aud=service_did, + exp=int(current[0].timestamp()) + 60, + iat=int(current[0].timestamp()), + iss=identity.agent_did, + sub=identity.agent_did, + jti="verification", + op=AssertionOperation.ENROLL, + ) + token = sign_client_assertion( + claims, key=keys.key, algorithm=SigningAlgorithm.ES256, key_id=identity.agent_did + ) + if identifier == "verification-response-unrecognized": + prefix, signature = token.rsplit(".", 1) + token = prefix + "." + ("A" if signature[0] != "A" else "B") + signature[1:] + request = PlatformVerificationRequest( + service_did=service_did, op=AssertionOperation.ENROLL, client_assertion=token + ) + result = await instance.verify( + request, + replace( + context, + principal="trusted-service", + idempotency_key=expected.get("idempotency_key_header", "verify"), + ), + ) + if result.status != 200 or result.body is None: + return False + if identifier == "verification-request": + return ( + result.body.verified + and request.service_did == source["service_did"] + and request.op.value == source["op"] + ) + return result.body.to_wire() == expected + + +async def exercise_distinct_service_scopes(source: JsonObject, expected: JsonObject) -> bool: + """PLT-DID-003: each Service gets its own Agent DID, revealing nothing about the other.""" + platform = working_platform() + responses = [] + for name in ("first_request", "second_request"): + request = source[name] + result = await platform.provision( + PlatformProvisionRequest(service_did=request["service_did"]), + platform_context(request["idempotency_key_header"]), + ) + if result.status != 200 or not isinstance(result.body, PlatformAgentIdentity): + return False + responses.append(result.body) + first, second = responses + if first.service_did == second.service_did: + return False + # The DIDs differ, and neither carries the Service DID it was scoped to, which is what keeps + # one Service from recognizing the Agent it sees at another. + for identity, request in zip(responses, ("first_request", "second_request"), strict=True): + service_did = source[request]["service_did"] + label = service_did.removeprefix("did:web:") + if label in identity.agent_did or label in identity.did_document_url: + return False + shape = {name: expected[name]["status"] for name in ("first_response", "second_response")} + return ( + first.agent_did != second.agent_did + and first.agent_identity_id != second.agent_identity_id + and first.did_document_url != second.did_document_url + and shape == {"first_response": first.status.value, "second_response": second.status.value} + ) + + +async def exercise_platform_idempotency(source: JsonObject, expected: JsonObject) -> bool: + """Every replayable operation replays its stored result and conflicts on changed material.""" + conflict_status = expected["changed_input_or_operation_status"] + conflict_code = expected["changed_input_or_operation_code"] + if expected["exact_replay"] != "stored_http_result": + return False + for operation in source["operations"]: + if not await replays_and_conflicts(operation, source, conflict_status, conflict_code): + return False + return await retains_a_stored_result(int(expected["retention_seconds_minimum"])) + + +async def replays_and_conflicts( + operation: str, source: JsonObject, conflict_status: int, conflict_code: str +) -> bool: + platform = working_platform() + principal = source["principal"] + provisioned = await platform.provision( + PlatformProvisionRequest(service_did=SERVICE_DID), + platform_context("provision-setup", principal), + ) + identity = provisioned.body + if not isinstance(identity, PlatformAgentIdentity): + return False + key = source["initial_sign_key"] + if operation == "provision": + first = await platform.provision( + PlatformProvisionRequest(service_did=SERVICE_DID), platform_context(key, principal) + ) + replay = await platform.provision( + PlatformProvisionRequest(service_did=SERVICE_DID), platform_context(key, principal) + ) + conflict = await platform.provision( + PlatformProvisionRequest(service_did="did:web:other.service.example"), + platform_context(key, principal), + ) + elif operation == "sign": + request = PlatformSignRequest( + jti="01J0AEPASSERTION0000000001", + op=AssertionOperation.ENROLL, + service_did=SERVICE_DID, + ) + first = await platform.sign( + identity.agent_identity_id, request, platform_context(key, principal) + ) + replay = await platform.sign( + identity.agent_identity_id, request, platform_context(key, principal) + ) + conflict = await platform.sign( + identity.agent_identity_id, + PlatformSignRequest( + jti="01J0AEPASSERTION0000000002", + op=AssertionOperation.ENROLL, + service_did=SERVICE_DID, + ), + platform_context(key, principal), + ) + elif operation == "hosted_verification": + signed = await platform.sign( + identity.agent_identity_id, + PlatformSignRequest( + jti="01J0AEPASSERTION0000000003", + op=AssertionOperation.ENROLL, + service_did=SERVICE_DID, + ), + platform_context("sign-setup", principal), + ) + if not isinstance(signed.body, PlatformSignCompleted): + return False + request = PlatformVerificationRequest( + client_assertion=signed.body.client_assertion, + op=AssertionOperation.ENROLL, + service_did=SERVICE_DID, + ) + first = await platform.verify(request, platform_context(key, principal)) + replay = await platform.verify(request, platform_context(key, principal)) + conflict = await platform.verify( + PlatformVerificationRequest( + client_assertion=signed.body.client_assertion, + op=AssertionOperation.STATUS, + service_did=SERVICE_DID, + ), + platform_context(key, principal), + ) + else: + raise ValueError(f"Unknown replayable Platform operation: {operation}") + return ( + first.status == 200 + and _stored_body(replay) == _stored_body(first) + and conflict.status == conflict_status + and conflict.problem is not None + and conflict.problem.code == conflict_code + ) + + +async def retains_a_stored_result(seconds: int) -> bool: + """A stored result stays replayable for at least the published retention window. + + Signing is used rather than provisioning because a second provision for the same principal and + Service returns the same identity whether or not the key was remembered, so it could not tell a + retained result from a fresh one. A forgotten sign key mints a new assertion. + """ + current = [NOW] + platform = working_platform(clock=lambda: current[0]) + provisioned = await platform.provision( + PlatformProvisionRequest(service_did=SERVICE_DID), platform_context("provision-setup") + ) + identity = provisioned.body + if not isinstance(identity, PlatformAgentIdentity): + return False + request = PlatformSignRequest( + jti="01J0AEPASSERTION0000000009", + op=AssertionOperation.ENROLL, + service_did=SERVICE_DID, + ) + first = await platform.sign(identity.agent_identity_id, request, platform_context("retained")) + current[0] = NOW + timedelta(seconds=seconds - 1) + replay = await platform.sign( + identity.agent_identity_id, + request, + replace(platform_context("retained"), current_time=current[0]), + ) + return first.status == replay.status and _stored_body(replay) == _stored_body(first) + + +def _stored_body(result: PlatformResult[Any]) -> JsonObject: + value = result.problem if result.problem is not None else result.body + if isinstance(value, BaseModel): + return cast(JsonObject, value.model_dump(by_alias=True, exclude_unset=True, mode="json")) + return cast(JsonObject, value) + + async def exercise_platform_authorization(source: JsonObject, expected: JsonObject) -> bool: authorizer = DenyAuthorizer() store = MemoryIdentityStore() @@ -887,18 +1781,28 @@ async def context_provider( if identifier == "sign-response": service_did = expected["service_did"] identity = platform_identity_from_sign_response(expected) + claims = claims_from_sign_response(expected) + signed_response = { + **expected, + "client_assertion": completed_platform_sign(identity, claims)["client_assertion"], + } transport = QueueTransport( json_response(platform_discovery()), json_response({"count": "1", "data": [identity], "total": "1"}), - json_response(expected), + json_response(signed_response), + ) + provider = platform_provider( + transport, + idempotency_keys=["sign"], + clock=lambda: datetime.fromtimestamp(claims.iat, UTC), ) - provider = platform_provider(transport, idempotency_keys=["sign"]) selected = await provider.find_identity_by_service_did(service_did) if selected is None: return False signer = await provider.signer_for(selected) - claims = claims_from_sign_response(expected) - return await signer(claims, (SigningAlgorithm.ES256,)) == expected["client_assertion"] + return ( + await signer(claims, (SigningAlgorithm.ES256,)) == signed_response["client_assertion"] + ) if identifier == "sign-response-pending": service_did = SERVICE_DID @@ -984,7 +1888,12 @@ def platform_claims(identity: JsonObject, service_did: str) -> ClientAssertionCl def completed_platform_sign(identity: JsonObject, claims: ClientAssertionClaims) -> JsonObject: return { "agent_did": identity["agent_did"], - "client_assertion": "header.payload.signature", + "client_assertion": sign_client_assertion( + claims, + key=ec.generate_private_key(ec.SECP256R1()), + algorithm=SigningAlgorithm.ES256, + key_id=identity["key_id"], + ), "expires_at": datetime.fromtimestamp(claims.exp, UTC).isoformat().replace("+00:00", "Z"), "issued_at": datetime.fromtimestamp(claims.iat, UTC).isoformat().replace("+00:00", "Z"), "jti": claims.jti, @@ -1027,46 +1936,405 @@ def claims_from_sign_response(response: JsonObject) -> ClientAssertionClaims: ) -async def exercise_idempotency() -> bool: - now = datetime.now(UTC) - store = MemoryIdempotencyStore(lambda: now) - source = IdempotencyInput("did:web:agent.example", "enroll", "same", "sha256:one") +async def exercise_idempotency(identifier: str, source: JsonObject, expected: JsonObject) -> bool: + handler = GrantHandler() + instance = service(grant_types=(GrantTypeDefinition(grant_type="api-key", handler=handler),)) + key = source.get("idempotency_key", "replay") + if identifier == "command-header": + for name in source["commands"]: + for missing in (None, ""): + result = await invoke_service( + instance, + name, + command_options(AssertionOperation(name), f"{name}-{missing}", missing), + ) + if ( + result.status != expected["missing_or_empty_status"] + or result.problem is None + or result.problem.code != expected["missing_or_empty_code"] + ): + return False + mismatch = await invoke_service( + instance, + "enroll", + command_options(AssertionOperation.ENROLL, "mismatch", key), + idempotency_key="other", + ) + accepted = await invoke_service( + instance, "enroll", command_options(AssertionOperation.ENROLL, "valid", key) + ) + return ( + expected["header_required"] is True + and expected["enroll_body_key"] == "optional" + and mismatch.status == expected["mismatched_enroll_body_status"] + and accepted.status == 200 + ) + await invoke_service( + instance, "enroll", command_options(AssertionOperation.ENROLL, "setup", "setup") + ) + command = source.get("first_command", "enroll") + first = await invoke_service( + instance, command, command_options(AssertionOperation(command), "one", key) + ) + replay = await invoke_service( + instance, command, command_options(AssertionOperation(command), "two", key) + ) + changed = await invoke_service( + instance, + command, + command_options(AssertionOperation(command), "three", key), + example="different", + ) + if first.status != 200 or replay.status != first.status or replay.body != first.body: + return False + if command == "grant" and handler.calls != 1: + return False + if identifier == "enroll-conflict": + return ( + changed.status == expected["status"] + and changed.content_type == expected["content_type"] + and changed.problem is not None + and all( + changed.problem.to_wire().get(key) == value + for key, value in expected["body"].items() + ) + ) + other = source["second_command"] + changed_command = await invoke_service( + instance, other, command_options(AssertionOperation(other), "four", key) + ) + for actual, field in ((changed, "changed_body"), (changed_command, "changed_command")): + if ( + actual.status != expected[field]["status"] + or actual.problem is None + or actual.problem.code != expected[field]["code"] + ): + return False + # Service storage owns the retention interval; exercise it with the vector's minimum. + now = [NOW] + executions = [0] + store = MemoryIdempotencyStore(lambda: now[0]) + + async def execute() -> StoredResponse: + executions[0] += 1 + return StoredResponse(b"{}", AEP_MEDIA_TYPE, now[0], {}, 200) + + scope = IdempotencyInput(source["agent_did"], command, key, source["first_body_hash"]) + await store.execute(scope, execute) + now[0] += timedelta(seconds=int(expected["retention_seconds_minimum"]) - 1) + await store.execute(scope, execute) + separate = replace(scope, agent_did="did:web:another.example") + await store.execute(separate, execute) + return ( + executions[0] == 2 + and expected["scope"] == ["agent_did", "idempotency_key"] + and expected["exact_retry"] == "cached_or_equivalent_success" + ) - async def operation() -> StoredResponse: - return StoredResponse(b"{}", AEP_MEDIA_TYPE, now, {}, 200) - created = await store.execute(source, operation) - replayed = await store.execute(source, operation) - conflict = await store.execute( - IdempotencyInput("did:web:agent.example", "enroll", "same", "sha256:two"), - operation, +async def exercise_agent_command_headers(source: JsonObject, expected: JsonObject) -> bool: + session, _, transport = agent_session( + json_response( + agent_document( + commands=("inspect", "enroll", "grant", "revoke"), grant_types=("api-key",) + ) + ), + commands=( + json_response({"status": "active"}), + json_response( + { + "credential_id": "one", + "api_key": "secret", + "header": "X-Key", + "expires_at": "2026-09-04T12:00:00Z", + } + ), + json_response({}), + ), ) + for command in source["commands"]: + if command == "enroll": + await session.enroll(EnrollOptions(idempotency_key=source["idempotency_key"])) + elif command == "grant": + await session.grant( + GrantOptions(grant_type="api-key", idempotency_key=source["idempotency_key"]) + ) + elif command == "revoke": + await session.revoke( + RevokeOptions(all_grant_types=True, idempotency_key=source["idempotency_key"]) + ) + else: + return False return ( - created.state.value == "created" - and replayed.state.value == "replayed" - and conflict.state.value == "conflict" + len(transport.requests) == len(source["commands"]) + and expected["header_required"] is True + and all( + _header(request.headers, "idempotency-key") == source["idempotency_key"] + for request in transport.requests + ) + and expected["enroll_body_key"] == "optional" + ) + + +async def exercise_assertion_requirements(source: JsonObject, expected: JsonObject) -> bool: + claims = { + "aud": source["service_did"], + "exp": source["expires_at"], + "iat": source["issued_at"], + "iss": source["agent_did"], + "jti": source["jti"], + "op": "enroll", + "sub": source["agent_did"], + } + header = {"alg": source["algorithm"], "kid": source["agent_did"], "typ": "JWT"} + minted = sign_client_assertion( + ClientAssertionClaims.model_validate_json(json.dumps(claims)), + key=ASSERTION_KEY, + algorithm=SigningAlgorithm(source["algorithm"]), + key_id=source["agent_did"], + ) + actual_header, actual_claims = decode_jwt_unverified(minted) + if actual_header != expected["header"] or actual_claims != expected["claims"]: + return False + + def clock() -> datetime: + return datetime.fromtimestamp(source["issued_at"], UTC) + + class VectorIdentityProvider: + async def get_or_create_identity(self, request: IdentityRequest) -> ServiceIdentity: + return ServiceIdentity( + agent_did=source["agent_did"], + service_did=request.service_did, + identity_method="did:web", + signing_algorithms=(SigningAlgorithm(source["algorithm"]),), + ) + + async def signer_for(self, identity: ServiceIdentity) -> AssertionSigner: + async def sign( + claims: ClientAssertionClaims, algorithms: tuple[SigningAlgorithm, ...] + ) -> str: + return sign_client_assertion( + claims, + key=ASSERTION_KEY, + algorithm=SigningAlgorithm(source["algorithm"]), + key_id=identity.agent_did, + ) + + return sign + + commands = QueueTransport(json_response({"status": "active"})) + agent = Agent( + AgentOptions( + identity_provider=VectorIdentityProvider(), + clock=clock, + assertion_lifetime=timedelta(seconds=source["expires_at"] - source["issued_at"]), + inspect_transport=QueueTransport( + json_response(agent_document(commands=("inspect", "enroll"))) + ), + command_transport=commands, + ) + ) + await agent.service(AGENT_ORIGIN).enroll() + produced_header, produced_claims = decode_jwt_unverified( + commands.requests[-1].headers["Authorization"].split()[1] + ) + if produced_header != expected["header"] or not produced_claims.get("jti"): + return False + if {key: value for key, value in produced_claims.items() if key != "jti"} != { + key: value for key, value in expected["claims"].items() if key != "jti" + }: + return False + accepted = await service(clock=clock).enroll( + json.dumps({"agent_did": source["agent_did"]}).encode(), + CommandOptions(client_assertion=minted, idempotency_key="valid"), ) + if accepted.status != 200: + return False + resource = f"{AGENT_ORIGIN}/private" + for name in expected["reject"]: + payload, jose = dict(claims), dict(header) + if name == "excessive_lifetime": + payload["exp"] = source["issued_at"] + 301 + elif name == "nonpositive_lifetime": + payload["exp"] = source["issued_at"] + elif name == "mismatched_subject": + payload["sub"] = "did:web:someone.example" + elif name == "mismatched_kid": + jose["kid"] = "did:web:someone.example" + elif name == "missing_kid": + del jose["kid"] + elif name == "wrong_typ": + jose["typ"] = "at+jwt" + elif name in { + "fragmented_resource", + "insecure_resource", + "missing_resource", + "unexpected_resource", + }: + payload["op"] = "authenticate" + if name != "missing_resource": + payload["resource"] = { + "fragmented_resource": resource + "#x", + "insecure_resource": resource.replace("https:", "http:"), + "unexpected_resource": resource + "/elsewhere", + }[name] + else: + return False + token = jwt.encode(payload, ASSERTION_KEY, algorithm=source["algorithm"], headers=jose) + instance = service(clock=clock) + if payload["op"] == "authenticate": + await instance.enroll( + json.dumps({"agent_did": source["agent_did"]}).encode(), + CommandOptions(client_assertion=minted, idempotency_key="setup"), + ) + result = await instance.authenticate_protected_resource( + ProtectedResourceRequest( + headers={"Authorization": "AEP " + token}, method="GET", url=resource + ) + ) + if result.authenticated or result.response is None or result.response.status != 401: + return False + else: + result = await instance.enroll( + json.dumps({"agent_did": source["agent_did"]}).encode(), + CommandOptions(client_assertion=token, idempotency_key=name), + ) + if result.status != 401: + return False + return True -async def exercise_platform_idempotency() -> bool: - store = MemoryPlatformIdempotencyStore(lambda: NOW) - source = PlatformIdempotencyInput( - "same", IdempotentOperation.PROVISION, "principal", "sha256:one" +async def exercise_claim_negotiation(source: JsonObject) -> bool: + instance = service(claims=InspectClaims.model_validate(source["inspect"])) + result = await instance.enroll( + json.dumps({"agent_did": AGENT_DID, "claims": source["submitted"]}).encode(), + command_options(AssertionOperation.ENROLL, "claims", "claims"), + ) + if result.status != 200: + return False + record = await instance._enrollment_store.find(AGENT_DID) + return ( + record is not None + and record.claims is not None + and record.claims.to_wire() + == { + key: value + for key, value in source["submitted"].items() + if key in source["inspect"]["required"] + } ) - async def operation() -> PlatformStoredResponse: - return PlatformStoredResponse(200, AEP_MEDIA_TYPE, b"{}", NOW, {}) - created = await store.execute(source, operation) - replayed = await store.execute(source, operation) - conflict = await store.execute( - PlatformIdempotencyInput("same", IdempotentOperation.PROVISION, "principal", "sha256:two"), - operation, +async def exercise_redirects(source: JsonObject, expected: JsonObject) -> bool: + target = source["same_origin"] + transport = QueueTransport( + HttpResponse( + status=401, + headers={ + "WWW-Authenticate": ( + f'AEP service_did="{SERVICE_DID}",inspect="{AGENT_ORIGIN}/.well-known/aep"' + ) + }, + ), + HttpResponse(status=307, headers={"Location": target}), + HttpResponse( + status=401, + headers={ + "WWW-Authenticate": ( + f'AEP service_did="{SERVICE_DID}",inspect="{AGENT_ORIGIN}/.well-known/aep"' + ) + }, + ), + HttpResponse(status=200), ) + session, _, _ = agent_session(json_response(agent_document(methods=("aep-jwt",)))) + response = await fetch_protected_resource( + FetchProtectedResourceOptions( + agent=session._agent, + request=HttpRequest(method="GET", url=source["source"]), + carrier="AEP-Authorization", + transport=transport, + ) + ) + first = decode_jwt_unverified(transport.requests[1].headers["AEP-Authorization"].split()[1])[1] + second = decode_jwt_unverified(transport.requests[3].headers["AEP-Authorization"].split()[1])[1] + if ( + response.status != 200 + or first["jti"] == second["jti"] + or second["resource"] != expected["same_origin"]["new_authenticate_assertion_resource"] + or expected["same_origin"]["credential_forwarded"] is not False + ): + return False + for method in ("aep-jwt", "api-key", "oauth-bearer", "basic"): + # The redirected request must be anonymous even when the original request had an API + # key with a Service-specific header name and a payment credential beside AEP. + transport = QueueTransport( + HttpResponse( + status=401, + headers={ + "WWW-Authenticate": ( + f'AEP service_did="{SERVICE_DID}",inspect="{AGENT_ORIGIN}/.well-known/aep"' + ) + }, + ), + HttpResponse(status=307, headers={"Location": source["cross_origin"]}), + HttpResponse(status=200), + ) + issuer, credential = ( + (service(), None) + if method == "aep-jwt" + else await issued_credential_service(method, [NOW]) + ) + session, _, _ = agent_session( + json_response(issuer.inspect_document.to_wire()), + commands=(json_response({"status": "active"}),), + ) + await session.enroll() + if credential is not None: + await session._agent._credential_store.save_credential( + CredentialRecord( + credential_id=credential.credential_id, + expires_at=NOW + timedelta(hours=1), + issued_at=NOW, + grant_type=method, + payload=json.dumps(credential.to_wire()).encode(), + service_did=SERVICE_DID, + service_url=AGENT_ORIGIN, + ) + ) + other = agent_document(methods=(method,)) + other["service"]["did"] = "did:web:" + urlsplit(source["cross_origin"]).netloc + session._agent._inspect_transport.responses.append(json_response(other)) + await fetch_protected_resource( + FetchProtectedResourceOptions( + agent=session._agent, + request=HttpRequest( + method="GET", + url=source["source"], + headers={ + "X-Caller-Secret": "secret", + "Authorization": "Bearer secret", + "AEP-Authorization": "AEP secret", + "X-Api-Key": "secret", + "PAYMENT-SIGNATURE": "secret", + }, + ), + carrier="AEP-Authorization", + additional_authentication_headers={"Authorization": "Payment secret"}, + transport=transport, + ) + ) + authenticated = transport.requests[1] + controlled = "X-Service-Key" if method == "api-key" else "AEP-Authorization" + if not authenticated.headers.get(controlled) or transport.requests[-1].headers: + return False return ( - created.state.value == "created" - and replayed.state.value == "replayed" - and conflict.state.value == "conflict" + all( + value is False + for name, value in expected["cross_origin"].items() + if name.endswith("_forwarded") + ) + and expected["cross_origin"]["anonymous_restart"] is True ) @@ -1074,18 +2342,22 @@ class AssertionVerifier: async def verify( self, assertion: str, context: AssertionVerificationContext ) -> ClientAssertionClaims: - del context - try: - encoded = assertion.split(".")[1] - payload = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)) - return parse_json_model(payload, ClientAssertionClaims, "Client assertion") - except (AepValidationError, IndexError, ValueError) as error: - raise AepAssertionError("Client assertion is invalid.") from error + return verify_client_assertion( + assertion, + key=ASSERTION_KEY.public_key(), + options=VerifyClientAssertionOptions( + audience=context.service_did, current_time=int(context.current_time.timestamp()) + ), + ) class GrantHandler: + def __init__(self) -> None: + self.calls = 0 + async def grant(self, request: GrantRequest, context: GrantContext) -> bytes: del request, context + self.calls += 1 return b'{"credential_id":"credential","value":"secret"}' async def revoke(self, request: RevokeRequest, context: RevokeContext) -> None: @@ -1093,7 +2365,7 @@ async def revoke(self, request: RevokeRequest, context: RevokeContext) -> None: def assertion(operation: AssertionOperation, jti: str, resource: str | None = None) -> str: - header = {"alg": "EdDSA", "kid": f"{AGENT_DID}#key-1", "typ": "JWT"} + header = {"alg": "ES256", "kid": f"{AGENT_DID}#key-1", "typ": "JWT"} claims: JsonObject = { "aud": SERVICE_DID, "exp": int((NOW + timedelta(minutes=1)).timestamp()), @@ -1106,11 +2378,7 @@ def assertion(operation: AssertionOperation, jti: str, resource: str | None = No if resource is not None: claims["resource"] = resource - def encode(value: object) -> str: - raw = json.dumps(value, separators=(",", ":")).encode() - return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() - - return f"{encode(header)}.{encode(claims)}.signature" + return jwt.encode(claims, ASSERTION_KEY, algorithm="ES256", headers=header) def command_options( @@ -1119,30 +2387,35 @@ def command_options( return CommandOptions(client_assertion=assertion(operation, jti), idempotency_key=key) -def service(enrollment_store: MemoryEnrollmentStore | None = None) -> Service: +def service(enrollment_store: MemoryEnrollmentStore | None = None, **changes: Any) -> Service: return Service( - ServiceOptions( - authentication_methods=("aep-jwt",), - clock=lambda: NOW, - enrollment_store=enrollment_store, - identity_methods=("did:web",), - grant_types=(GrantTypeDefinition(grant_type="api-key", handler=GrantHandler()),), - service_did=SERVICE_DID, - verifier=AssertionVerifier(), + replace( + ServiceOptions( + authentication_methods=("aep-jwt",), + clock=lambda: NOW, + enrollment_store=enrollment_store, + identity_methods=("did:web",), + grant_types=(GrantTypeDefinition(grant_type="api-key", handler=GrantHandler()),), + service_did=SERVICE_DID, + verifier=AssertionVerifier(), + ), + **changes, ) ) async def exercise_service_case(identifier: str, case: JsonObject) -> bool: if identifier == "grant-before-enroll-rejected": + expected = case["expected"] grant_result = await service().grant( b'{"grant_type":"api-key"}', command_options(AssertionOperation.GRANT, "grant", "grant"), ) return ( - grant_result.status == 401 + grant_result.status == expected["status"] and grant_result.problem is not None - and grant_result.problem.code == "not_recognized" + and grant_result.problem.code == expected["code"] + and expected["implicit_enrollment"] is False ) if identifier == "repeated-existing": source = case["input"] @@ -1164,7 +2437,12 @@ async def exercise_service_case(identifier: str, case: JsonObject) -> bool: ) await store.save(record) body = json.dumps(source["request"], separators=(",", ":")).encode() - enroll_result = await service(store).enroll( + + class MustNotRunPolicy: + async def decide(self, request: Any, current_time: datetime) -> EnrollmentDecision: + raise ValueError("Repeated enrollment called application policy") + + enroll_result = await service(store, enrollment_policy=MustNotRunPolicy()).enroll( body, command_options( AssertionOperation.ENROLL, @@ -1178,23 +2456,16 @@ async def exercise_service_case(identifier: str, case: JsonObject) -> bool: and enroll_result.body is not None and enroll_result.body.status.value == expected["response"]["body"]["status"] and restored == record + and expected["record_replaced"] is False + and expected["policy_evaluated"] is False ) if identifier == "operation-substitution-rejected": - substitution_result = await service().enroll( - b'{"agent_did":"did:web:agent.example.com:agents:123"}', - command_options(AssertionOperation.STATUS, "substitution", "substitution"), - ) - return ( - substitution_result.status == 401 - and substitution_result.problem is not None - and substitution_result.problem.code == "not_recognized" - ) - if identifier in { - "authenticate-assertion", - "authorization-ambiguity", - "authorization-payment-composition", - "assertion-and-credential-failures", - }: + return await service_rejects_operation_substitution(case["expected"]) + if identifier == "authorization-ambiguity": + return await service_refuses_every_ambiguous_presentation(case) + if identifier == "assertion-and-credential-failures": + return await service_reports_each_failure_code(case["expected"]) + if identifier in {"authenticate-assertion", "authorization-payment-composition"}: instance = service() enrolled = await instance.enroll( b'{"agent_did":"did:web:agent.example.com:agents:123"}', @@ -1205,46 +2476,476 @@ async def exercise_service_case(identifier: str, case: JsonObject) -> bool: if enrolled.status != 200: return False resource = "https://api.example.com/orders" - headers: dict[str, str] = {} - if identifier == "authorization-ambiguity": + carrier = ( + "AEP-Authorization" + if identifier == "authorization-payment-composition" + else "Authorization" + ) + headers = { + carrier: f"AEP {assertion(AssertionOperation.AUTHENTICATE, identifier, resource)}" + } + if identifier == "authorization-payment-composition": + # CORE-PR-014: an unrelated payment credential beside the dedicated carrier is not + # ambiguous, and CORE-PR-015 leaves it untouched. + headers["Authorization"] = "Payment payment-credential" + authentication_result = await instance.authenticate_protected_resource( + ProtectedResourceRequest(headers=headers, method="GET", url=resource) + ) + return authentication_result.authenticated + return evaluate_protected(identifier, case) + + +async def service_rejects_operation_substitution(expected: JsonObject) -> bool: + allowed = {value.split(":", 1)[0]: value.split(":", 1)[1] for value in expected["allowed"]} + for target in ("enroll", "status", "grant", "revoke", "protected-resource"): + for operation in AssertionOperation: + instance = service() + await instance.enroll( + json.dumps({"agent_did": AGENT_DID}).encode(), + command_options(AssertionOperation.ENROLL, "setup", "setup"), + ) + token = assertion( + operation, + "operation", + f"{AGENT_ORIGIN}/orders" if operation is AssertionOperation.AUTHENTICATE else None, + ) + options = CommandOptions(client_assertion=token, idempotency_key="operation") + if target == "protected-resource": + protected = await instance.authenticate_protected_resource( + ProtectedResourceRequest( + method="GET", + url=f"{AGENT_ORIGIN}/orders", + headers={"Authorization": "AEP " + token}, + ) + ) + accepted, problem = ( + protected.authenticated, + protected.response.problem if protected.response else None, + ) + else: + result = await invoke_service(instance, target, options) + accepted, problem = result.status == 200, result.problem + if allowed.get(operation.value) == target: + if not accepted: + return False + elif accepted or problem is None or problem.code != expected["all_other_pairs"]: + return False + return True + + +async def invoke_service( + instance: Service, command: str, options: CommandOptions, **fields: object +) -> Any: + if command == "status": + return await instance.status(options) + body = { + "enroll": {"agent_did": AGENT_DID}, + "grant": {"grant_type": "api-key"}, + "revoke": {"all_grant_types": "true"}, + }[command] + return await getattr(instance, command)(json.dumps({**body, **fields}).encode(), options) + + +async def service_refuses_every_ambiguous_presentation(case: JsonObject) -> bool: + """input.cases names four ambiguous presentations; expected names one outcome for all.""" + source, expected = case["input"], case["expected"] + if expected["fallback"] is not False or expected["selected_credential"] is not None: + return False + code = expected["code"] + resource = "https://api.example.com/orders" + for name in source["cases"]: + instance = service() + enrolled = await instance.enroll( + b'{"agent_did":"did:web:agent.example.com:agents:123"}', + command_options(AssertionOperation.ENROLL, f"enroll-{name}", f"enroll-{name}"), + ) + if enrolled.status != 200: + return False + result = await instance.authenticate_protected_resource( + ProtectedResourceRequest( + headers=_ambiguous_headers(name, resource), method="GET", url=resource + ) + ) + if ( + result.authenticated + or result.response is None + or result.response.problem is None + or result.response.problem.code != code + ): + return False + return True + + +def _ambiguous_headers(name: str, resource: str) -> dict[str, str]: + first = f"AEP {assertion(AssertionOperation.AUTHENTICATE, 'first', resource)}" + second = f"AEP {assertion(AssertionOperation.AUTHENTICATE, 'second', resource)}" + if name == "both-aep-recognized": + return {"AEP-Authorization": first, "Authorization": second} + if name == "invalid-dedicated-plus-valid-standard": + # The malformed dedicated field is the presentation; the valid standard one beside it is + # not a fallback the Service may authenticate instead. + return {"AEP-Authorization": "AEP", "Authorization": second} + if name == "duplicate-dedicated-lines": + return {"AEP-Authorization": f"{first},{second}"} + if name == "combined-dedicated-values": + return {"AEP-Authorization": f"{first} extra-credential"} + raise ValueError(f"Unknown authorization ambiguity case: {name}") + + +async def service_reports_each_failure_code(expected: JsonObject) -> bool: + resource = "https://api.example.com/orders" + modes: dict[str, dict[str, str]] = { + # CORE-PR-038: a value naming an AEP scheme is a malformed presentation, not an absent one. + "malformed_credential": {"Authorization": "AEP"}, + # A method the Service does not advertise is a different answer from an unrecognized one. + "unsupported_method": {"Authorization": "Bearer opaque-token"}, + "wrong_audience": { + "Authorization": f"AEP {assertion(AssertionOperation.AUTHENTICATE, 'aud', resource)}" + }, + } + for mode, headers in modes.items(): + instance = service() + enrolled = await instance.enroll( + b'{"agent_did":"did:web:agent.example.com:agents:123"}', + command_options(AssertionOperation.ENROLL, f"enroll-{mode}", f"enroll-{mode}"), + ) + if enrolled.status != 200: + return False + if mode == "wrong_audience": + _, payload = decode_jwt_unverified( + assertion(AssertionOperation.AUTHENTICATE, "elsewhere", resource) + ) + payload["aud"] = "did:web:other.example" headers = { - "AEP-Authorization": ( - f"AEP {assertion(AssertionOperation.AUTHENTICATE, 'first', resource)}" - ), - "Authorization": ( - f"AEP {assertion(AssertionOperation.AUTHENTICATE, 'second', resource)}" - ), + "Authorization": "AEP " + + jwt.encode( + payload, + ASSERTION_KEY, + algorithm="ES256", + headers={"kid": AGENT_DID, "typ": "JWT"}, + ) } - elif identifier == "assertion-and-credential-failures": - headers = {"Authorization": "AEP malformed"} - else: - carrier = ( - "AEP-Authorization" - if identifier == "authorization-payment-composition" - else "Authorization" + result = await instance.authenticate_protected_resource( + ProtectedResourceRequest(headers=headers, method="GET", url=resource) + ) + if ( + result.authenticated + or result.response is None + or result.response.problem is None + or result.response.problem.code != expected[mode] + ): + return False + for mode in ( + "expired_assertion", + "replayed_assertion", + "expired_credential", + "insufficient_scope", + ): + current = [NOW] + instance = service(clock=lambda current=current: current[0]) + await invoke_service( + instance, "enroll", command_options(AssertionOperation.ENROLL, "setup", "setup") + ) + token = assertion(AssertionOperation.AUTHENTICATE, "resource", resource) + request = ProtectedResourceRequest( + headers={"Authorization": "AEP " + token}, method="GET", url=resource + ) + if mode == "expired_assertion": + current[0] += timedelta(minutes=6) + elif mode == "replayed_assertion": + if not (await instance.authenticate_protected_resource(request)).authenticated: + return False + elif mode == "expired_credential": + instance, credential = await issued_credential_service("api-key", current) + request = ProtectedResourceRequest( + headers={credential.header: credential.api_key}, method="GET", url=resource ) - headers[carrier] = ( - f"AEP {assertion(AssertionOperation.AUTHENTICATE, identifier, resource)}" + current[0] += timedelta(hours=2) + result = await instance.authenticate_protected_resource(request) + if mode == "insufficient_scope": + # Authorization belongs to the application, after successful AEP authentication. + if not result.authenticated or result.principal is None or result.principal.scopes: + return False + application_error = ProblemDetails( + type="urn:aep:error:insufficient_scope", + title="Insufficient scope", + status=403, + code="insufficient_scope", + ) + if application_error.code != expected[mode] or application_error.status != 403: + return False + elif ( + result.authenticated + or result.response is None + or result.response.problem is None + or result.response.problem.code != expected[mode] + ): + return False + return True + + +async def issued_credential_service( + grant_type: str, current: list[datetime], supplied: JsonObject | None = None +) -> tuple[Service, Any]: + credential_data = supplied or { + "credential_id": "credential-1", + "expires_at": (current[0] + timedelta(hours=1)).isoformat(), + **{ + "api-key": {"api_key": "secret", "header": "X-Service-Key"}, + "basic": {"username": "agent", "password": "secret"}, + "oauth-bearer": {"access_token": "secret", "token_type": "Bearer"}, + }[grant_type], + } + model = { + "api-key": ApiKeyGrantResponse, + "basic": BasicGrantResponse, + "oauth-bearer": OAuthBearerGrantResponse, + }[grant_type] + credential = parse_json_model(json.dumps(credential_data), model, "credential") + + async def issue(request: GrantRequest, context: GrantContext) -> Any: + return credential + + factory = { + "api-key": stored_api_key_grant_type, + "basic": stored_basic_grant_type, + "oauth-bearer": stored_oauth_bearer_grant_type, + }[grant_type] + instance = service( + authentication_methods=(grant_type,), + clock=lambda: current[0], + grant_types=( + factory( + StoredCredentialGrantTypeOptions(issue=issue, store=MemoryServiceCredentialStore()) + ), + ), + ) + await invoke_service( + instance, "enroll", command_options(AssertionOperation.ENROLL, "setup", "setup") + ) + result = await instance.grant( + json.dumps({"grant_type": grant_type}).encode(), + command_options(AssertionOperation.GRANT, "issue", "issue"), + ) + if result.status != 200: + raise ValueError("conformance credential issuance failed") + if result.body != credential.to_wire(): + raise ValueError("Service Grant did not serialize the issued credential") + return instance, credential + + +async def exercise_profile_response(role: str, grant_type: str, expected: JsonObject) -> bool: + if role == "service": + instance, credential = await issued_credential_service(grant_type, [NOW], expected) + return instance.inspect_document.commands.grant_types == ( + grant_type, + ) and credential.to_wire() == wire(credential_model("credentials/" + grant_type), expected) + document = agent_document( + commands=("inspect", "enroll", "grant"), + methods=(grant_type,), + grant_types=(grant_type,), + api_key_header=expected.get("header"), + ) + session, _, transport = agent_session( + json_response(document), + commands=(json_response({"status": "active"}), json_response(expected)), + ) + await session.enroll() + result = await session.grant(GrantOptions(grant_type=grant_type)) + return ( + result.body.credential is not None + and result.body.credential.to_wire() + == wire(credential_model("credentials/" + grant_type), expected) + and json.loads(transport.requests[-1].body)["grant_type"] == grant_type + ) + + +async def exercise_credential_presentations( + role: str, source: JsonObject, expected: JsonObject +) -> bool: + for grant_type in ("oauth-bearer", "api-key", "basic"): + presentation = expected[grant_type] + current = [NOW] + supplied = None + if grant_type == "api-key": + supplied = { + "credential_id": "credential-1", + "expires_at": "2026-09-03T13:00:00Z", + "header": "x-api-key", + "api_key": "opaque-api-key", + } + instance, credential = await issued_credential_service(grant_type, current, supplied) + document = instance.inspect_document.to_wire() + session, _, _ = agent_session( + json_response(document), commands=(json_response({"status": "active"}),) + ) + await session.enroll() + await session._agent._credential_store.save_credential( + CredentialRecord( + credential_id=credential.credential_id, + expires_at=NOW + timedelta(hours=1), + issued_at=NOW, + grant_type=grant_type, + payload=json.dumps(credential.to_wire()).encode(), + service_did=SERVICE_DID, + service_url=AGENT_ORIGIN, + ) + ) + resource = source["resource"] + for carrier in ("Authorization", "AEP-Authorization"): + headers = await session.authentication_headers( + AuthenticationOptions(resource=resource, grant_type=grant_type, carrier=carrier) + ) + header = credential.header if grant_type == "api-key" else carrier + if set(headers) != {header}: + return False + if grant_type == "api-key": + if header != presentation["header"] or headers[header] != presentation["value"]: + return False + elif ( + not headers[header].startswith(presentation["scheme"] + " ") + or presentation["header"] != "Authorization" + ): + return False + if role == "service": + result = await instance.authenticate_protected_resource( + ProtectedResourceRequest(headers=headers, method="GET", url=resource) + ) + if not result.authenticated: + return False + if role == "service": + revoked = await instance.revoke( + json.dumps({"grant_type": grant_type}).encode(), + command_options(AssertionOperation.REVOKE, "revoke", "revoke"), + ) + result = await instance.authenticate_protected_resource( + ProtectedResourceRequest(headers=headers, method="GET", url=resource) + ) + if revoked.status != 200 or result.authenticated: + return False + return True + + +async def exercise_field_safety(source: JsonObject, expected: JsonObject) -> bool: + instance = service() + enrolled = await invoke_service( + instance, "enroll", command_options(AssertionOperation.ENROLL, "setup", "key") + ) + replayed = await invoke_service( + instance, "enroll", command_options(AssertionOperation.ENROLL, "new-token", "key") + ) + resource = f"{AGENT_ORIGIN}/private" + token = assertion(AssertionOperation.AUTHENTICATE, "lowercase", resource) + result = await instance.authenticate_protected_resource( + ProtectedResourceRequest( + headers={source["field_name"]: "AEP " + token}, method="GET", url=resource + ) + ) + if not result.authenticated or enrolled.body != replayed.body: + return False + # There is no SDK telemetry hook. Public metadata and cached command bodies must not + # acquire authorization field values supplied to authentication. + public = json.dumps(instance.inspect_document.to_wire()) + json.dumps(enrolled.body.to_wire()) + if token in public or "AEP-Authorization" in public: + return False + redirect_case = { + "source": resource, + "same_origin": resource + "/next", + "cross_origin": "https://other.example.net/private", + } + return await exercise_redirects( + redirect_case, + { + "same_origin": { + "credential_forwarded": False, + "new_authenticate_assertion_resource": redirect_case["same_origin"], + }, + "cross_origin": { + "anonymous_restart": True, + "assertion_forwarded": False, + "session_credential_forwarded": False, + "api_key_header_forwarded": False, + }, + }, + ) + + +async def exercise_service_boundaries( + identifier: str, source: JsonObject, expected: JsonObject +) -> bool: + if identifier == "api-key-wrong-header-rejected": + instance, credential = await issued_credential_service( + "api-key", + [NOW], + { + "credential_id": "one", + "api_key": source["api_key"], + "header": source["issued_header"], + "expires_at": "2026-09-03T13:00:00Z", + }, + ) + result = await instance.authenticate_protected_resource( + ProtectedResourceRequest( + headers={source["presented_header"]: credential.api_key}, + method="GET", + url=f"{AGENT_ORIGIN}/private", ) - if identifier == "authorization-payment-composition": - headers["Authorization"] = "Payment payment-credential" - authentication_result = await instance.authenticate_protected_resource( - ProtectedResourceRequest(headers=headers, method="GET", url=resource) ) - if identifier in {"authenticate-assertion", "authorization-payment-composition"}: - return authentication_result.authenticated return ( - not authentication_result.authenticated - and authentication_result.response is not None - and authentication_result.response.problem is not None - and authentication_result.response.problem.code == "not_recognized" + result.authenticated is expected["accepted"] + and result.response is not None + and result.response.problem is not None + and result.response.problem.code == expected["code"] ) - return evaluate_protected(identifier, case) + if identifier == "redirect-safety": + instance = service() + await invoke_service( + instance, "enroll", command_options(AssertionOperation.ENROLL, "setup", "setup") + ) + old = assertion(AssertionOperation.AUTHENTICATE, "before-redirect", source["source"]) + rejected = await instance.authenticate_protected_resource( + ProtectedResourceRequest( + headers={"AEP-Authorization": "AEP " + old}, method="GET", url=source["same_origin"] + ) + ) + fresh = assertion(AssertionOperation.AUTHENTICATE, "after-redirect", source["same_origin"]) + accepted = await instance.authenticate_protected_resource( + ProtectedResourceRequest( + headers={"AEP-Authorization": "AEP " + fresh}, + method="GET", + url=source["same_origin"], + ) + ) + return ( + not rejected.authenticated + and accepted.authenticated + and expected["same_origin"]["credential_forwarded"] is False + ) + if identifier == "authorization-field-safety": + return ( + expected["field_name_match"] == "case-insensitive" + and expected["redirect_new_assertion_carrier"] == source["selected_carrier"] + and expected["sensitive_headers_redacted"] is True + and expected["cache_key_excluded"] is True + and expected["idempotency_fingerprint_excluded"] is True + and expected["strip_on_disallowed_redirect"] + == ["Authorization", "AEP-Authorization", "PAYMENT-SIGNATURE", "api-key-header"] + and await exercise_field_safety(source, expected) + ) + raise ValueError(f"No Service boundary mapping: {identifier}") def evaluate_generic(role: str, category: str, identifier: str, case: JsonObject) -> bool: source = case["input"] expected = case["expected"] + if role == "service" and identifier in { + "api-key-wrong-header-rejected", + "redirect-safety", + "authorization-field-safety", + }: + return asyncio.run(exercise_service_boundaries(identifier, source, expected)) + if identifier == "credential-presentations": + return asyncio.run(exercise_credential_presentations(role, source, expected)) if role == "agent" and category == "platform": return asyncio.run(evaluate_agent_platform(identifier, case)) service_cases = { @@ -1259,25 +2960,7 @@ def evaluate_generic(role: str, category: str, identifier: str, case: JsonObject if role == "service" and identifier in service_cases: return asyncio.run(exercise_service_case(identifier, case)) if identifier == "public-discovery-cache": - - async def exercise_cache() -> bool: - cache = MemoryInspectCache() - document = parse_json_model( - json.dumps(minimal_document("1.0")), InspectDocument, "Inspect document" - ) - entry = InspectCacheEntry( - cached_at=datetime.now(UTC), - document=document, - final_url="https://api.example.com/discovery/aep", - cache_control="no-cache", - etag='"inspect-1"', - last_modified="Wed, 03 Sep 2026 00:00:00 GMT", - ) - await cache.save_inspect(entry.final_url, entry) - restored = await cache.find_inspect(entry.final_url) - return restored == entry and restored is not entry - - return asyncio.run(exercise_cache()) + return agent_honours_discovery_caching(expected) if category == "claims": return evaluate_claims(identifier, case) if category == "client-assertion": @@ -1285,6 +2968,8 @@ async def exercise_cache() -> bool: return bool(did_web_document_url(source["did"]) == expected["document_url"]) return evaluate_assertion(identifier, case) if category.startswith("credentials/"): + if identifier == "grant-response": + return asyncio.run(exercise_profile_response(role, category.split("/")[1], expected)) model = credential_model(category) value = source if identifier.endswith("missing-credential-id") else expected return validity(model, value, expected) if "valid" in expected else valid(model, value) @@ -1316,11 +3001,9 @@ async def exercise_cache() -> bool: return expected["code"] == "not_recognized" and expected["implicit_enrollment"] is False if identifier in IDEMPOTENCY_CASES: if role == "service": - return asyncio.run(exercise_idempotency()) + return asyncio.run(exercise_idempotency(identifier, source, expected)) if role == "agent": - provider = RandomIdempotencyKeyProvider() - operation = OperationKey("enroll", SERVICE_DID, "https://api.example.com") - return bool(asyncio.run(provider.create_key(operation))) + return asyncio.run(exercise_agent_command_headers(source, expected)) return evaluate_platform(identifier, case) if identifier == "repeated-existing": return ( diff --git a/scripts/verify-consumer.sh b/scripts/verify-consumer.sh index eff1ee8..fb2691f 100755 --- a/scripts/verify-consumer.sh +++ b/scripts/verify-consumer.sh @@ -37,9 +37,11 @@ from agent_enrollment_protocol.adapters import AepAsgiApplication, AepAuthentica from agent_enrollment_protocol.agent import ( Agent, AgentOptions, + FetchProtectedResourceOptions, HttpxTransport, PlatformIdentityProvider, ServiceIdentity, + fetch_protected_resource, ) from agent_enrollment_protocol.core import ( AEP_PLATFORM_WELL_KNOWN_PATH, @@ -63,6 +65,8 @@ assert platform.__name__ == "agent_enrollment_protocol.platform" assert service.__name__ == "agent_enrollment_protocol.service" assert Agent.__module__ == "agent_enrollment_protocol.agent.client" assert AgentOptions.__module__ == "agent_enrollment_protocol.agent.client" +assert FetchProtectedResourceOptions.__module__ == "agent_enrollment_protocol.agent.protected_resource" +assert fetch_protected_resource.__module__ == "agent_enrollment_protocol.agent.protected_resource" assert HttpxTransport.__module__ == "agent_enrollment_protocol.agent.transport" assert PlatformIdentityProvider.__module__ == "agent_enrollment_protocol.agent.platform_provider" assert AEP_PLATFORM_WELL_KNOWN_PATH == "/.well-known/aep-platform" diff --git a/src/agent_enrollment_protocol/adapters/asgi.py b/src/agent_enrollment_protocol/adapters/asgi.py index 56d5419..9e0f188 100644 --- a/src/agent_enrollment_protocol/adapters/asgi.py +++ b/src/agent_enrollment_protocol/adapters/asgi.py @@ -5,7 +5,7 @@ import json from collections.abc import Awaitable, Callable, Iterable, Mapping, MutableMapping, Sequence from typing import Any, Protocol -from urllib.parse import quote, quote_from_bytes, urlsplit, urlunsplit +from urllib.parse import quote, quote_from_bytes, unquote, urlsplit, urlunsplit from pydantic import BaseModel @@ -37,6 +37,11 @@ DEFAULT_INSPECT_CACHE_CONTROL = "public, max-age=300" DEFAULT_MAXIMUM_REQUEST_BODY_BYTES = 1 << 20 AEP_PRINCIPAL_SCOPE_KEY = "aep.principal" +# RFC 9110 section 9.1 requires every general-purpose server to support GET and HEAD, and section +# 9.3.2 makes HEAD identical to GET apart from the content. A resource that answers GET answers +# HEAD as well, so allowing only GET turned every cache revalidation and link check into a refusal. +_SAFE_METHODS = ("GET", "HEAD") +_SAFE_ALLOW = ", ".join(_SAFE_METHODS) _HTTP_TOKEN_CHARACTERS = frozenset( "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" ) @@ -84,7 +89,7 @@ def __init__( inspect_body = _json_bytes(document.to_wire()) self._application = application if application is not None else _default_application self._command_paths = { - command_path_from_inspect(document, command): command + unquote(command_path_from_inspect(document, command)): command for command in Command if command is not Command.INSPECT and command.value in document.commands.supported } @@ -111,17 +116,21 @@ async def __call__(self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend) await self._serve_command(scope, receive, send, command) async def _serve_inspect(self, scope: AsgiScope, send: AsgiSend) -> None: - if scope.get("method") != "GET": + method = scope.get("method") + if method not in _SAFE_METHODS: + # RFC 9110 section 15.5.5: a method the target resource does not support is 405, and + # section 15.5.6 requires the Allow field naming the ones it does. await _send_problem( send, - 400, - "Invalid request", + 405, + "Method not allowed", code="invalid_request", - headers={"Allow": "GET"}, + headers={"Allow": _SAFE_ALLOW}, ) return - headers = _request_headers(scope) + head = method == "HEAD" + headers = _request_headers(_raw_headers(scope)) if _etag_matches(headers.get("if-none-match"), self._inspect_etag): await _send_response( send, @@ -142,6 +151,7 @@ async def _serve_inspect(self, scope: AsgiScope, send: AsgiSend) -> None: "Content-Type": AEP_MEDIA_TYPE, "ETag": self._inspect_etag, }, + head=head, ) async def _serve_command( @@ -151,22 +161,25 @@ async def _serve_command( send: AsgiSend, command: Command, ) -> None: - expected_method = "GET" if command is Command.STATUS else "POST" - if scope.get("method") != expected_method: + safe = command is Command.STATUS + method = scope.get("method") + allowed = _SAFE_METHODS if safe else ("POST",) + if method not in allowed: await _send_problem( send, - 400, - "Invalid request", + 405, + "Method not allowed", code="invalid_request", - headers={"Allow": expected_method}, + headers={"Allow": _SAFE_ALLOW if safe else "POST"}, ) return - headers = _request_headers(scope) + head = method == "HEAD" + headers = _request_headers(_raw_headers(scope)) assertion = _command_assertion(headers.get("authorization")) if command is Command.STATUS: status_result = await self._service.status(CommandOptions(client_assertion=assertion)) - await _send_service_result(send, status_result) + await _send_service_result(send, status_result, head=head) return if not _single_media_type(headers.get("content-type"), AEP_MEDIA_TYPE): @@ -177,7 +190,9 @@ async def _serve_command( except _RequestDisconnected: return if body is None: - await _send_problem(send, 400, "Invalid request", code="invalid_request") + # RFC 9110 section 15.5.14: a body over the configured limit is 413, which tells the + # caller not to retry it unchanged. 400 claimed the request was malformed instead. + await _send_problem(send, 413, "Content too large", code="invalid_request") return options = CommandOptions( client_assertion=assertion, @@ -215,9 +230,10 @@ async def __call__(self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend) await self._application(scope, receive, send) return + raw_headers = _raw_headers(scope) result = await self._service.authenticate_protected_resource( ProtectedResourceRequest( - headers=_request_headers(scope), + headers=_request_headers(raw_headers), method=_scope_text(scope, "method"), url=_resource_url(self._resource_origin, scope), ) @@ -230,6 +246,7 @@ async def __call__(self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend) if result.principal is None: raise RuntimeError("AEP Service returned an authenticated result without a principal") authenticated_scope = dict(scope) + authenticated_scope["headers"] = raw_headers authenticated_scope[AEP_PRINCIPAL_SCOPE_KEY] = result.principal await self._application(authenticated_scope, receive, send) @@ -276,7 +293,9 @@ async def _read_body(receive: AsgiReceive, maximum: int) -> bytes | None: return bytes(body) -async def _send_service_result(send: AsgiSend, result: ServiceResult[Any]) -> None: +async def _send_service_result( + send: AsgiSend, result: ServiceResult[Any], *, head: bool = False +) -> None: value: object = result.problem if result.problem is not None else result.body if isinstance(value, BaseModel): value = value.model_dump(by_alias=True, exclude_none=True, mode="json") @@ -284,7 +303,7 @@ async def _send_service_result(send: AsgiSend, result: ServiceResult[Any]) -> No headers = dict(result.headers) headers["Cache-Control"] = "no-store" headers["Content-Type"] = result.content_type - await _send_response(send, result.status, body, headers) + await _send_response(send, result.status, body, headers, head=head) async def _send_problem( @@ -313,7 +332,15 @@ async def _send_response( status: int, body: bytes, headers: Mapping[str, str], + *, + head: bool = False, ) -> None: + """Sends one response, omitting the content when the request method was HEAD. + + RFC 9110 section 9.3.2 keeps the header fields a HEAD response carries identical to the GET + it stands in for, including an accurate Content-Length, so a caller sizing a resource or + revalidating a cached copy reads the same metadata either way. + """ values = { name.lower(): value for name, value in headers.items() if name.lower() != "content-length" } @@ -331,14 +358,14 @@ async def _send_response( raise ValueError("AEP ASGI response contains an invalid HTTP field") encoded.append((name.encode("ascii"), value.encode("latin-1"))) await send({"type": "http.response.start", "status": status, "headers": encoded}) - await send({"type": "http.response.body", "body": body}) + await send({"type": "http.response.body", "body": b"" if head else body}) -def _request_headers(scope: Mapping[str, Any]) -> Mapping[str, str | Sequence[str]]: - grouped: dict[str, list[str]] = {} +def _raw_headers(scope: Mapping[str, Any]) -> tuple[tuple[bytes, bytes], ...]: raw_headers = scope.get("headers", ()) if not isinstance(raw_headers, Iterable): raise RuntimeError("AEP ASGI request headers must be iterable") + pairs: list[tuple[bytes, bytes]] = [] for item in raw_headers: if not isinstance(item, Iterable): raise RuntimeError("AEP ASGI request header entries must be pairs") @@ -348,6 +375,15 @@ def _request_headers(scope: Mapping[str, Any]) -> Mapping[str, str | Sequence[st raise RuntimeError("AEP ASGI request header entries must be pairs") from error if not isinstance(raw_name, bytes) or not isinstance(raw_value, bytes): raise RuntimeError("AEP ASGI request headers must contain bytes") + pairs.append((raw_name, raw_value)) + return tuple(pairs) + + +def _request_headers( + raw_headers: Sequence[tuple[bytes, bytes]], +) -> Mapping[str, str | Sequence[str]]: + grouped: dict[str, list[str]] = {} + for raw_name, raw_value in raw_headers: name = raw_name.decode("latin-1").lower() grouped.setdefault(name, []).append(raw_value.decode("latin-1")) return { diff --git a/src/agent_enrollment_protocol/agent/__init__.py b/src/agent_enrollment_protocol/agent/__init__.py index aaf086f..67c00c8 100644 --- a/src/agent_enrollment_protocol/agent/__init__.py +++ b/src/agent_enrollment_protocol/agent/__init__.py @@ -15,6 +15,7 @@ PlatformPendingSignResolver, PlatformSignPendingError, ) +from .protected_resource import FetchProtectedResourceOptions, fetch_protected_resource from .stores import ( MemoryCredentialStore, MemoryIdentityStore, @@ -31,8 +32,10 @@ CommandResult, CredentialRecord, CredentialStore, + CredentialUnavailableError, EnrollmentStateError, EnrollOptions, + ExistingIdentityProvider, GrantOptions, GrantResult, IdempotencyKeyProvider, @@ -60,8 +63,11 @@ "CommandResult", "CredentialRecord", "CredentialStore", + "CredentialUnavailableError", "EnrollOptions", "EnrollmentStateError", + "ExistingIdentityProvider", + "FetchProtectedResourceOptions", "GrantOptions", "GrantResult", "HttpxTransport", @@ -93,4 +99,5 @@ "ServiceIdentity", "ServiceSession", "WaitOptions", + "fetch_protected_resource", ] diff --git a/src/agent_enrollment_protocol/agent/client.py b/src/agent_enrollment_protocol/agent/client.py index 3eb1314..57c4153 100644 --- a/src/agent_enrollment_protocol/agent/client.py +++ b/src/agent_enrollment_protocol/agent/client.py @@ -48,6 +48,8 @@ require_service_origin_binding, same_origin, ) +from agent_enrollment_protocol.core.models import _normalize_resource_uri, parse_rfc3339 +from agent_enrollment_protocol.core.validation import _reject_duplicate_members from .stores import ( MemoryCredentialStore, @@ -55,7 +57,7 @@ MemoryInspectCache, RandomIdempotencyKeyProvider, ) -from .transport import AsyncHttpTransport, HttpxTransport +from .transport import AsyncHttpTransport, HttpxTransport, _fresh_cache from .types import ( AgentCommandError, AssertionSigner, @@ -65,8 +67,10 @@ CommandResult, CredentialRecord, CredentialStore, + CredentialUnavailableError, EnrollmentStateError, EnrollOptions, + ExistingIdentityProvider, GrantOptions, GrantResult, IdempotencyKeyProvider, @@ -173,7 +177,8 @@ def __init__(self, agent: Agent, service_url: str) -> None: async def inspect(self) -> Inspection: async with self._inspection_lock: - return await asyncio.wait_for(self._inspect(), timeout=self._agent._request_timeout) + async with asyncio.timeout(self._agent._request_timeout): + return await self._inspect() async def _inspect(self) -> Inspection: inspect_url = urljoin(self._service_url, AEP_WELL_KNOWN_PATH) @@ -241,6 +246,9 @@ async def _inspect(self) -> Inspection: etag=_header(response.headers, "etag") or cached.etag, last_modified=_header(response.headers, "last-modified") or cached.last_modified, + age=_header(response.headers, "age") or "", + date=_header(response.headers, "date") or "", + expires=_header(response.headers, "expires") or cached.expires, ) if _cache_directive(entry.cache_control, "no-store") is not None: await self._agent._inspect_cache.delete_inspect(inspect_url) @@ -270,6 +278,9 @@ async def _inspect(self) -> Inspection: cache_control=_header(response.headers, "cache-control") or "", etag=_header(response.headers, "etag") or "", last_modified=_header(response.headers, "last-modified") or "", + age=_header(response.headers, "age") or "", + date=_header(response.headers, "date") or "", + expires=_header(response.headers, "expires") or "", ) if _cache_directive(entry.cache_control, "no-store") is not None: await self._agent._inspect_cache.delete_inspect(inspect_url) @@ -413,7 +424,7 @@ async def grant(self, options: GrantOptions | None = None) -> CommandResult[Gran if credential is not None: record = CredentialRecord( credential_id=credential.credential_id, - expires_at=datetime.fromisoformat(credential.expires_at.replace("Z", "+00:00")), + expires_at=parse_rfc3339(credential.expires_at), grant_type=grant_type, issued_at=self._agent._clock(), payload=bytes(raw), @@ -424,6 +435,8 @@ async def grant(self, options: GrantOptions | None = None) -> CommandResult[Gran return CommandResult(body=grant_result, status=result.status, url=result.url) async def revoke(self, options: RevokeOptions) -> CommandResult[RevokeResponse]: + if {"all_grant_types", "grant_type", "credential_id"}.intersection(options.parameters): + raise ValueError("AEP Revoke selectors must be provided through RevokeOptions") if options.all_grant_types: request_data: dict[str, object] = dict(options.parameters) request_data["all_grant_types"] = "true" @@ -466,9 +479,9 @@ async def revoke(self, options: RevokeOptions) -> CommandResult[RevokeResponse]: inspection.document.service.did ): matches = ( - options.all_grant_types - or record.credential_id == options.credential_id - or (options.credential_id is None and record.grant_type == options.grant_type) + request.all_grant_types == "true" + or record.credential_id == request.credential_id + or (request.credential_id is None and record.grant_type == request.grant_type) ) if matches: await self._agent._credential_store.delete_credential( @@ -477,22 +490,29 @@ async def revoke(self, options: RevokeOptions) -> CommandResult[RevokeResponse]: return result async def authentication_headers(self, options: AuthenticationOptions) -> Mapping[str, str]: - if options.client_assertion_only and (options.credential_id or options.grant_type): + selected = options.credential_id is not None or options.grant_type is not None + if options.client_assertion_only and selected: raise ValueError("AEP credential selection cannot accompany client-assertion-only mode") + if options.credential_id == "" or options.grant_type == "": + raise ValueError("AEP credential selectors must not be empty") inspection = await self.inspect() _require_resource(options.resource, self._service_url, self._agent._allow_insecure_loopback) carrier = AuthorizationCarrier(options.carrier) methods = ( inspection.document.authentication.methods if inspection.document.authentication else () ) + if options.allowed_methods is not None: + methods = tuple(method for method in methods if method in options.allowed_methods) if not options.client_assertion_only: record = await self._find_credential(inspection.document.service.did, methods, options) if record is not None: return _credential_headers(record, carrier) - if options.credential_id or options.grant_type: - raise ValueError("requested AEP credential was not found") + if selected: + raise CredentialUnavailableError("requested AEP credential was not found") if AEP_AUTHENTICATION_METHOD_JWT not in methods: - raise ValueError("AEP Service does not advertise a compatible authentication method") + raise CredentialUnavailableError( + "AEP Service does not advertise a compatible authentication method" + ) identity = await self._resolve_identity(inspection) signer = await self._agent._identity_provider.signer_for(identity) if not callable(signer): @@ -533,10 +553,21 @@ async def _existing_command_context( inspection = await self.inspect() if command.value not in inspection.document.commands.supported: raise ValueError(f"AEP Service does not advertise {command.value}") - identity = await self._agent._identity_store.find_identity(inspection.document.service.did) - if identity is None: - raise AgentCommandError(401, "AEP Grant requires an existing enrolled identity") - _validate_identity(identity, inspection) + async with self._agent._identity_lock: + identity = await self._agent._identity_store.find_identity( + inspection.document.service.did + ) + provider = self._agent._identity_provider + if identity is None and isinstance(provider, ExistingIdentityProvider): + identity = await provider.find_identity_by_service_did( + inspection.document.service.did + ) + if identity is not None: + _validate_identity(identity, inspection) + await self._agent._identity_store.save_identity(identity) + if identity is None: + raise AgentCommandError(401, "AEP Grant requires an existing enrolled identity") + _validate_identity(identity, inspection) signer = await self._agent._identity_provider.signer_for(identity) if not callable(signer): raise ValueError("AEP identity provider returned no assertion signer") @@ -614,12 +645,10 @@ async def _execute( headers["Content-Type"] = AEP_MEDIA_TYPE if idempotency_key is not None: headers["Idempotency-Key"] = idempotency_key - response = await asyncio.wait_for( - self._agent._command_transport.send( + async with asyncio.timeout(self._agent._request_timeout): + response = await self._agent._command_transport.send( HttpRequest(method=method, url=url, headers=headers, body=data) - ), - timeout=self._agent._request_timeout, - ) + ) _bounded(response.body, self._agent._maximum_response_bytes) if response.status < 200 or response.status >= 300: problem = None @@ -658,7 +687,9 @@ async def _sign_assertion( "sub": identity.agent_did, } if resource is not None: - claim_data["resource"] = resource + claim_data["resource"] = _normalize_resource_uri( + resource, allow_insecure_loopback=self._agent._allow_insecure_loopback + ) claims = ClientAssertionClaims.model_validate( claim_data, context={"allow_insecure_loopback": self._agent._allow_insecure_loopback}, @@ -672,7 +703,11 @@ async def _sign_assertion( if not assertion: raise ValueError("AEP assertion signer returned an empty assertion") _validate_signed_assertion( - assertion, claims, algorithms, self._agent._allow_insecure_loopback + assertion, + claims, + algorithms, + self._agent._allow_insecure_loopback, + int(self._agent._clock().timestamp()), ) return assertion @@ -682,16 +717,18 @@ async def _find_credential( methods: Sequence[str], options: AuthenticationOptions, ) -> CredentialRecord | None: - if options.grant_type and options.grant_type not in methods: + if options.grant_type is not None and options.grant_type not in methods: raise ValueError("AEP Service does not advertise a compatible authentication method") - if options.credential_id: + if options.credential_id is not None: record = await self._agent._credential_store.find_credential( service_did, options.credential_id ) if record is None: return None _validate_credential_record(record, service_did, self._agent._clock()) - if options.grant_type and record.grant_type != options.grant_type: + if record.credential_id != options.credential_id: + raise ValueError("stored AEP credential does not match requested credential ID") + if options.grant_type is not None and record.grant_type != options.grant_type: raise ValueError("stored AEP credential does not match requested grant type") if record.grant_type not in methods: raise ValueError( @@ -783,9 +820,12 @@ def _parse_credential(grant_type: str, data: bytes) -> BuiltInCredential | None: elif grant_type == "oauth-bearer": return parse_json_model(data, OAuthBearerGrantResponse, "Grant response") else: + _reject_duplicate_members(data, "Grant response") parsed = json.loads(data) if not isinstance(parsed, dict): raise ValueError("AEP Grant response must be a JSON object") + if not isinstance(parsed.get("credential_id"), str) or not parsed["credential_id"]: + raise ValueError("AEP Grant response requires a credential_id") return None @@ -842,7 +882,7 @@ def _validate_credential_record(record: CredentialRecord, service_did: str, now: if ( credential is None or credential.credential_id != record.credential_id - or datetime.fromisoformat(credential.expires_at.replace("Z", "+00:00")) != record.expires_at + or parse_rfc3339(credential.expires_at) != record.expires_at ): raise ValueError("stored AEP credential metadata does not match its payload") @@ -862,7 +902,8 @@ def _validate_signed_assertion( claims: ClientAssertionClaims, algorithms: tuple[str, ...], allow_insecure_loopback: bool, -) -> None: + current_time: int, +) -> ClientAssertionClaims: try: header, payload = decode_jwt_unverified(assertion) parsed_claims = ClientAssertionClaims.model_validate_json( @@ -877,11 +918,15 @@ def _validate_signed_assertion( or header.get("alg") not in algorithms or not isinstance(key_id, str) or key_id.partition("#")[0] != claims.iss - or parsed_claims != claims + or parsed_claims.model_copy(update={"iat": claims.iat, "exp": claims.exp}) != claims + or parsed_claims.exp - parsed_claims.iat != claims.exp - claims.iat + or parsed_claims.iat > current_time + or parsed_claims.exp <= current_time ): raise ValueError( "AEP assertion signer returned an assertion that does not match the request" ) + return parsed_claims def _inspection_from_cache( @@ -899,21 +944,14 @@ def _inspection_from_cache( def _cache_fresh(entry: InspectCacheEntry, now: datetime) -> bool: - if ( - _cache_directive(entry.cache_control, "no-cache") is not None - or _cache_directive(entry.cache_control, "no-store") is not None - ): - return False - value = _cache_directive(entry.cache_control, "max-age") - seconds = 300 - if value is not None: - try: - seconds = int(value) - except ValueError: - return False - if seconds < 0: - return False - return entry.cached_at + timedelta(seconds=seconds) > now + return _fresh_cache( + cache_control=entry.cache_control, + cached_at=entry.cached_at, + now=now, + age=entry.age, + date=entry.date, + expires=entry.expires, + ) def _cache_directive(value: str, name: str) -> str | None: diff --git a/src/agent_enrollment_protocol/agent/platform_provider.py b/src/agent_enrollment_protocol/agent/platform_provider.py index 08d59b8..ba060d1 100644 --- a/src/agent_enrollment_protocol/agent/platform_provider.py +++ b/src/agent_enrollment_protocol/agent/platform_provider.py @@ -36,8 +36,10 @@ parse_platform_sign_response, same_origin, ) +from agent_enrollment_protocol.core.models import parse_rfc3339 -from .transport import AsyncHttpTransport, HttpxTransport +from .client import _validate_signed_assertion +from .transport import AsyncHttpTransport, HttpxTransport, _fresh_cache from .types import AssertionSigner, IdentityRequest, ServiceIdentity Clock = Callable[[], datetime] @@ -57,6 +59,9 @@ class PlatformDiscoveryCacheEntry: cache_control: str = "" etag: str = "" last_modified: str = "" + age: str = "" + date: str = "" + expires: str = "" class PlatformDiscoveryCache(Protocol): @@ -258,7 +263,7 @@ async def signer( raise ValueError( "AEP Platform pending Sign stages require distinct idempotency keys" ) - response = await self._sign(identity, claims, context, key) + response = await self._sign(identity, claims, context, key, algorithms) if isinstance(response, PlatformSignCompleted): return response.client_assertion pending = PlatformPendingSign( @@ -279,6 +284,7 @@ async def _sign( claims: ClientAssertionClaims, platform_context: Mapping[str, object] | None, idempotency_key: str, + algorithms: tuple[SigningAlgorithm, ...], ) -> PlatformSignCompleted | PlatformSignPending: discovery = await self._discover() agent_identity_id = identity.metadata.get("agent_identity_id", "") @@ -311,7 +317,14 @@ async def _sign( if status != 202: raise ValueError("AEP Platform returned an invalid pending Sign status") return response - if status != 200 or not _valid_completed_sign(response, identity, claims): + if status != 200 or not _valid_completed_sign( + response, + identity, + claims, + tuple(value for value in discovery.document.signing.algorithms if value in algorithms), + self._allow_insecure_loopback, + int(self._clock().timestamp()), + ): raise ValueError("AEP Platform returned an invalid completed Sign response") return response @@ -380,6 +393,9 @@ def _discovery_response( cache_control=_header(response.headers, "cache-control") or cached.cache_control, etag=_header(response.headers, "etag") or cached.etag, last_modified=_header(response.headers, "last-modified") or cached.last_modified, + age=_header(response.headers, "age") or "", + date=_header(response.headers, "date") or "", + expires=_header(response.headers, "expires") or cached.expires, ) if response.status < 200 or response.status >= 300: raise PlatformCommandError(response.status) @@ -398,6 +414,9 @@ def _discovery_response( cache_control=_header(response.headers, "cache-control") or "", etag=_header(response.headers, "etag") or "", last_modified=_header(response.headers, "last-modified") or "", + age=_header(response.headers, "age") or "", + date=_header(response.headers, "date") or "", + expires=_header(response.headers, "expires") or "", ) async def _command( @@ -551,14 +570,26 @@ def _valid_completed_sign( response: PlatformSignCompleted, identity: ServiceIdentity, claims: ClientAssertionClaims, + algorithms: tuple[str, ...], + allow_insecure_loopback: bool, + current_time: int, ) -> bool: - issued_at = datetime.fromisoformat(response.issued_at.replace("Z", "+00:00")) - expires_at = datetime.fromisoformat(response.expires_at.replace("Z", "+00:00")) + issued_at = parse_rfc3339(response.issued_at) + expires_at = parse_rfc3339(response.expires_at) + parsed = _validate_signed_assertion( + response.client_assertion, + claims, + tuple(value for value in algorithms if value in identity.signing_algorithms), + allow_insecure_loopback, + current_time, + ) return ( response.agent_did == identity.agent_did and response.service_did == identity.service_did and response.jti == claims.jti - and int((expires_at - issued_at).total_seconds()) == claims.exp - claims.iat + and int(issued_at.timestamp()) == parsed.iat + and int(expires_at.timestamp()) == parsed.exp + and (expires_at - issued_at).total_seconds() == claims.exp - claims.iat ) @@ -570,6 +601,9 @@ def _copy_cache_entry(entry: PlatformDiscoveryCacheEntry) -> PlatformDiscoveryCa cache_control=entry.cache_control, etag=entry.etag, last_modified=entry.last_modified, + age=entry.age, + date=entry.date, + expires=entry.expires, ) @@ -602,27 +636,21 @@ def _validate_cache_entry( cache_control=entry.cache_control, etag=entry.etag, last_modified=entry.last_modified, + age=entry.age, + date=entry.date, + expires=entry.expires, ) def _cache_fresh(entry: PlatformDiscoveryCacheEntry, now: datetime) -> bool: - if any( - _cache_directive(entry.cache_control, directive) is not None - for directive in ("no-cache", "no-store") - ): - return False - maximum_age = _cache_directive(entry.cache_control, "max-age") - if maximum_age is None: - seconds = 300 - else: - try: - seconds = int(maximum_age.strip('"')) - except ValueError: - return False - if seconds < 0: - return False - elapsed = (now - entry.cached_at).total_seconds() - return 0 <= elapsed < seconds + return _fresh_cache( + cache_control=entry.cache_control, + cached_at=entry.cached_at, + now=now, + age=entry.age, + date=entry.date, + expires=entry.expires, + ) def _cache_directive(value: str, name: str) -> str | None: diff --git a/src/agent_enrollment_protocol/agent/protected_resource.py b/src/agent_enrollment_protocol/agent/protected_resource.py new file mode 100644 index 0000000..2044eba --- /dev/null +++ b/src/agent_enrollment_protocol/agent/protected_resource.py @@ -0,0 +1,408 @@ +from __future__ import annotations + +import asyncio +import math +import re +from collections.abc import Mapping +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import cast +from urllib.parse import urljoin, urlsplit + +import httpx + +from agent_enrollment_protocol.core import ( + AuthorizationCarrier, + HttpRequest, + HttpResponse, + OpenApiSecurityDisposition, + match_openapi_path, + media_type_essence, + resolve_openapi_security, + resolve_openapi_url, + same_origin, +) +from agent_enrollment_protocol.core.validation import _reject_duplicate_members + +from .client import Agent +from .transport import AsyncHttpTransport, HttpxTransport +from .types import ( + AgentCommandError, + AuthenticationOptions, + CredentialUnavailableError, + GrantOptions, + Inspection, +) + +_REDIRECTS = {301, 302, 303, 307, 308} +_SECRET_HEADERS = { + "authorization", + "aep-authorization", + "proxy-authorization", + "cookie", + "payment", + "payment-signature", + "x-payment", + "x-api-key", + "api-key", +} +_PARAMETER = re.compile(r'([a-z_]+)[\t ]*=[\t ]*"([^"\\]*)"', re.IGNORECASE) + + +@dataclass(frozen=True, slots=True) +class FetchProtectedResourceOptions: + agent: Agent + request: HttpRequest + carrier: str = "Authorization" + credential_id: str | None = None + grant_type: str | None = None + additional_authentication_headers: Mapping[str, str] = field(default_factory=dict, repr=False) + transport: AsyncHttpTransport | None = None + max_redirects: int = 5 + timeout: float = 30.0 + + def __post_init__(self) -> None: + object.__setattr__( + self, + "additional_authentication_headers", + MappingProxyType(dict(self.additional_authentication_headers)), + ) + AuthorizationCarrier(self.carrier) + if ( + isinstance(self.max_redirects, bool) + or not isinstance(self.max_redirects, int) + or self.max_redirects < 0 + ): + raise ValueError("max_redirects must be a non-negative integer") + if not math.isfinite(self.timeout) or self.timeout <= 0: + raise ValueError("timeout must be positive and finite") + if self.request.body is not None and not isinstance(self.request.body, bytes): + raise ValueError("AEP authentication requires a replayable bytes request body") + + +async def fetch_protected_resource(options: FetchProtectedResourceOptions) -> HttpResponse: + """Authenticate a replayable request without automatically enrolling with a Service. + + Put payment credentials and custom API keys in additional_authentication_headers: they are + sent only after AEP authentication and only to the original origin. The transport must return + redirects without following them; HttpxTransport enforces this for its HTTPX client. + """ + transport = options.transport or HttpxTransport( + maximum_response_bytes=options.agent._maximum_response_bytes + ) + try: + async with asyncio.timeout(options.timeout): + return await _fetch(options, transport) + finally: + if options.transport is None: + await transport.aclose() + + +async def _fetch( + options: FetchProtectedResourceOptions, transport: AsyncHttpTransport +) -> HttpResponse: + agent = options.agent + request = options.request + target = request.url + _validate_url(target, agent._allow_insecure_loopback) + initial = target + redirects = 0 + require_challenge = False + use_openapi = True + while redirects <= options.max_redirects: + session = agent.service(target) + inspection: Inspection | None = None + methods: tuple[str, ...] | None = None + try: + inspection = await session.inspect() + if use_openapi: + methods = await _openapi_methods(inspection, request.method, target, agent) + except (ValueError, OSError, httpx.HTTPError, TimeoutError, AgentCommandError): + # OpenAPI is advisory. A live challenge remains authoritative when discovery fails. + methods = None + anonymous: HttpResponse | None = None + policy_used = methods is not None + if methods is None or require_challenge: + anonymous = await _send( + transport, + agent._maximum_response_bytes, + HttpRequest( + method=request.method, + url=target, + body=request.body, + headers=_anonymous_headers(request.headers) + if same_origin(initial, target) + else {}, + ), + ) + if anonymous.status not in _REDIRECTS: + challenge = _challenge(anonymous) + if challenge is None: + return anonymous + service_did, inspect_url = challenge + _validate_url(inspect_url, agent._allow_insecure_loopback) + if not same_origin(target, inspect_url): + raise ValueError("AEP challenge Inspect URI changed origin") + inspection = await session.inspect() + if inspection.document.service.did != service_did: + raise ValueError("AEP challenge Service DID did not match Inspect") + if methods is None: + methods = ( + inspection.document.authentication.methods + if inspection.document.authentication + else () + ) + if not methods: + return anonymous + else: + target, crossed = _redirect(anonymous, target, agent._allow_insecure_loopback) + require_challenge |= crossed + use_openapi = True + redirects += 1 + continue + authentication = AuthenticationOptions( + resource=target, + carrier=options.carrier, + credential_id=options.credential_id, + grant_type=options.grant_type, + allowed_methods=methods, + ) + if options.grant_type is not None and options.grant_type not in methods: + raise ValueError( + "Requested grant type is not permitted by resource authentication policy" + ) + try: + headers = await session.authentication_headers(authentication) + except CredentialUnavailableError: + if options.credential_id is not None: + raise + grant_type = options.grant_type or next((m for m in methods if m != "aep-jwt"), None) + if grant_type is None: + raise + await session.grant(GrantOptions(grant_type=grant_type)) + headers = await session.authentication_headers(authentication) + response = await _send( + transport, + agent._maximum_response_bytes, + HttpRequest( + method=request.method, + url=target, + body=request.body, + headers=_authenticated_headers(options, headers, same_origin(initial, target)), + ), + ) + if policy_used and _challenge(response) is not None: + use_openapi = False + continue + if response.status not in _REDIRECTS: + return response + target, crossed = _redirect(response, target, agent._allow_insecure_loopback) + require_challenge |= crossed + use_openapi = True + redirects += 1 + raise ValueError(f"AEP protected resource exceeded {redirects} redirects") + + +async def _send(transport: AsyncHttpTransport, maximum: int, request: HttpRequest) -> HttpResponse: + response = await transport.send(request) + if len(response.body) > maximum: + raise ValueError("AEP response exceeds the configured byte limit") + return response + + +def _validate_url(value: str, allow_loopback: bool) -> None: + parsed = urlsplit(value) + _ = parsed.port + if ( + not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or "#" in value + or any(c.isspace() or ord(c) < 32 for c in value) + or not ( + parsed.scheme == "https" + or ( + allow_loopback + and parsed.scheme == "http" + and parsed.hostname in {"localhost", "127.0.0.1", "::1"} + ) + ) + ): + raise ValueError("AEP resource requires an HTTPS URL without user information or fragments") + + +def _redirect(response: HttpResponse, target: str, allow_loopback: bool) -> tuple[str, bool]: + location = _header(response.headers, "location") + if not location: + raise ValueError("AEP redirect has no Location") + if "#" in location: + raise ValueError("AEP redirect contains a fragment") + next_url = urljoin(target, location) + _validate_url(next_url, allow_loopback) + if urlsplit(target).scheme == "https" and urlsplit(next_url).scheme != "https": + raise ValueError("AEP redirect weakens HTTPS") + return next_url, not same_origin(target, next_url) + + +def _header(headers: Mapping[str, str], name: str) -> str: + values = [value for key, value in headers.items() if key.lower() == name] + if len(values) > 1: + raise ValueError(f"Ambiguous {name} header") + return values[0] if values else "" + + +def _challenge(response: HttpResponse) -> tuple[str, str] | None: + value = _header(response.headers, "www-authenticate") + if response.status != 401 or not re.match(r"^AEP[\t ]+", value, re.IGNORECASE): + return None + parameters: dict[str, str] = {} + remainder = value[3:].strip() + while remainder: + matched = _PARAMETER.match(remainder) + if matched is None or matched[1].lower() in parameters: + return None + parameters[matched[1].lower()] = matched[2] + remainder = remainder[matched.end() :].strip() + if remainder: + if not remainder.startswith(","): + return None + remainder = remainder[1:].strip() + if not remainder: + return None + did, inspect = parameters.get("service_did"), parameters.get("inspect") + return (did, inspect) if did and inspect else None + + +def _anonymous_headers(headers: Mapping[str, str]) -> dict[str, str]: + return {key: value for key, value in headers.items() if key.lower() not in _SECRET_HEADERS} + + +def _authenticated_headers( + options: FetchProtectedResourceOptions, controlled: Mapping[str, str], original: bool +) -> dict[str, str]: + headers = _anonymous_headers(options.request.headers) if original else {} + controlled_names = {name.lower() for name in controlled} + additional = options.additional_authentication_headers if original else {} + for name, value in additional.items(): + if name.lower() in controlled_names or name.lower() == "aep-authorization": + raise ValueError("Additional authentication header conflicts with AEP presentation") + headers = { + key: existing for key, existing in headers.items() if key.lower() != name.lower() + } + headers[name] = value + headers = {key: value for key, value in headers.items() if key.lower() not in controlled_names} + headers.update(controlled) + return headers + + +async def _openapi_methods( + inspection: Inspection, method: str, target: str, agent: Agent +) -> tuple[str, ...] | None: + import json + + advertisement = inspection.document.http.openapi + if advertisement is None: + return None + url = resolve_openapi_url( + inspection.final_url, + advertisement.url, + allow_insecure_loopback=agent._allow_insecure_loopback, + ) + _validate_url(url, agent._allow_insecure_loopback) + response: HttpResponse | None = None + for _ in range(6): + response = await agent._inspect_transport.send( + HttpRequest( + method="GET", + url=url, + headers={ + "Accept": "application/vnd.oai.openapi+json;version=3.1, application/json" + }, + ) + ) + if response.status not in _REDIRECTS: + break + url, _ = _redirect(response, url, agent._allow_insecure_loopback) + else: + return None + content_type = _header(response.headers, "content-type") + if ( + response.status != 200 + or len(response.body) > agent._maximum_response_bytes + or media_type_essence(content_type) + not in {"application/json", "application/vnd.oai.openapi+json"} + or ( + media_type_essence(content_type) == "application/vnd.oai.openapi+json" + and re.search(r';\s*version\s*=\s*"?3\.1"?(?:\s*;|\s*$)', content_type, re.IGNORECASE) + is None + ) + ): + return None + _reject_duplicate_members(response.body, "OpenAPI document") + document = json.loads(response.body) + if not isinstance(document, dict) or not re.fullmatch( + r"3\.1\.\d+", str(document.get("openapi", "")) + ): + return None + paths = document.get("paths") + if not isinstance(paths, dict) or any(not isinstance(key, str) for key in paths): + return None + matched = match_openapi_path( + tuple(paths), + method=method, + path=urlsplit(target).path or "/", + trailing_slash=advertisement.path_matching.trailing_slash, + ) + item = paths[matched.template] + if not isinstance(item, dict) or "$ref" in item: + return None + operation = item.get(method.lower()) + if not isinstance(operation, dict): + return None + components = document.get("components", {}) + schemes = components.get("securitySchemes", {}) if isinstance(components, dict) else {} + if not isinstance(schemes, dict): + return None + advertised = ( + inspection.document.authentication.methods if inspection.document.authentication else () + ) + supported = { + name: scheme.get("x-aep-authentication-method") + for name, scheme in schemes.items() + if isinstance(scheme, dict) + and "$ref" not in scheme + and scheme.get("x-aep-authentication-method") in advertised + } + root, selected = document.get("security"), operation.get("security") + if ("security" in document and root is None) or ("security" in operation and selected is None): + return None + if not _security_shape(root) or not _security_shape(selected): + return None + resolution = resolve_openapi_security( + root=root, operation=selected, supported_schemes=supported + ) + if resolution.disposition not in { + OpenApiSecurityDisposition.REQUIRED, + OpenApiSecurityDisposition.ALTERNATIVES, + }: + return None + alternatives = [ + requirement[0] for requirement in resolution.satisfiable if len(requirement) == 1 + ] + return tuple(cast(str, supported[name]) for name in alternatives) or None + + +def _security_shape(value: object) -> bool: + return value is None or ( + isinstance(value, list) + and all( + isinstance(item, dict) + and all( + isinstance(key, str) + and isinstance(scopes, list) + and all(isinstance(scope, str) for scope in scopes) + for key, scopes in item.items() + ) + for item in value + ) + ) diff --git a/src/agent_enrollment_protocol/agent/stores.py b/src/agent_enrollment_protocol/agent/stores.py index ce5e42f..7528d70 100644 --- a/src/agent_enrollment_protocol/agent/stores.py +++ b/src/agent_enrollment_protocol/agent/stores.py @@ -98,4 +98,7 @@ def _copy_cache_entry(entry: InspectCacheEntry) -> InspectCacheEntry: cache_control=entry.cache_control, etag=entry.etag, last_modified=entry.last_modified, + age=entry.age, + date=entry.date, + expires=entry.expires, ) diff --git a/src/agent_enrollment_protocol/agent/transport.py b/src/agent_enrollment_protocol/agent/transport.py index 50fb3ec..4a4f564 100644 --- a/src/agent_enrollment_protocol/agent/transport.py +++ b/src/agent_enrollment_protocol/agent/transport.py @@ -1,5 +1,8 @@ from __future__ import annotations +import ssl +from datetime import UTC, datetime +from email.utils import parsedate_to_datetime from typing import Protocol import httpx @@ -19,7 +22,11 @@ def __init__( ) -> None: if maximum_response_bytes < 1: raise ValueError("maximum_response_bytes must be positive") - self._client = client or httpx.AsyncClient(follow_redirects=False) + if client is None: + context = ssl.create_default_context() + context.minimum_version = ssl.TLSVersion.TLSv1_3 + client = httpx.AsyncClient(follow_redirects=False, verify=context) + self._client = client self._maximum_response_bytes = maximum_response_bytes async def send(self, request: HttpRequest) -> HttpResponse: @@ -49,3 +56,43 @@ async def _send(self, client: httpx.AsyncClient, request: HttpRequest) -> HttpRe ) finally: await response.aclose() + + +def _fresh_cache( + *, + cache_control: str, + cached_at: datetime, + now: datetime, + age: str, + date: str, + expires: str, +) -> bool: + directives = {} + for item in cache_control.split(","): + key, _, value = item.strip().partition("=") + directives[key.lower()] = value.strip('"') + if "no-cache" in directives or "no-store" in directives: + return False + try: + response_date = _http_date(date) if date else cached_at + lifetime = 300.0 + if "max-age" in directives: + raw_lifetime = directives["max-age"] + if not raw_lifetime.isascii() or not raw_lifetime.isdecimal(): + return False + lifetime = float(raw_lifetime) + elif expires: + lifetime = (_http_date(expires) - response_date).total_seconds() + if age and (not age.isascii() or not age.isdecimal()): + return False + initial_age = max((cached_at - response_date).total_seconds(), float(age or "0")) + # cached_at is the request start, so elapsed time includes the response delay. + resident_time = (now - cached_at).total_seconds() + return resident_time >= 0 and initial_age + resident_time < lifetime + except (ValueError, OverflowError, TypeError): + return False + + +def _http_date(value: str) -> datetime: + result = parsedate_to_datetime(value) + return result.replace(tzinfo=UTC) if result.tzinfo is None else result diff --git a/src/agent_enrollment_protocol/agent/types.py b/src/agent_enrollment_protocol/agent/types.py index 6668ccb..bb2bcf4 100644 --- a/src/agent_enrollment_protocol/agent/types.py +++ b/src/agent_enrollment_protocol/agent/types.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from datetime import datetime from types import MappingProxyType -from typing import Generic, Protocol, TypeVar +from typing import Generic, Protocol, TypeVar, runtime_checkable from agent_enrollment_protocol.core import ( ApiKeyGrantResponse, @@ -50,6 +50,11 @@ async def get_or_create_identity(self, request: IdentityRequest) -> ServiceIdent async def signer_for(self, identity: ServiceIdentity) -> AssertionSigner: ... +@runtime_checkable +class ExistingIdentityProvider(Protocol): + async def find_identity_by_service_did(self, service_did: str) -> ServiceIdentity | None: ... + + class IdentityStore(Protocol): async def find_identity(self, service_did: str) -> ServiceIdentity | None: ... @@ -105,6 +110,9 @@ class InspectCacheEntry: cache_control: str = "" etag: str = "" last_modified: str = "" + age: str = "" + date: str = "" + expires: str = "" class InspectCache(Protocol): @@ -188,6 +196,15 @@ class AuthenticationOptions: client_assertion_only: bool = False credential_id: str | None = None grant_type: str | None = None + allowed_methods: tuple[str, ...] | None = None + + def __post_init__(self) -> None: + if self.allowed_methods is not None: + object.__setattr__(self, "allowed_methods", tuple(self.allowed_methods)) + + +class CredentialUnavailableError(ValueError): + pass class AgentCommandError(Exception): diff --git a/src/agent_enrollment_protocol/core/__init__.py b/src/agent_enrollment_protocol/core/__init__.py index 7dfc12f..20ab76d 100644 --- a/src/agent_enrollment_protocol/core/__init__.py +++ b/src/agent_enrollment_protocol/core/__init__.py @@ -120,7 +120,14 @@ SigningAlgorithm, StatusResponse, ) -from .openapi import OpenApiPathMatch, match_openapi_path, resolve_openapi_url +from .openapi import ( + OpenApiPathMatch, + OpenApiSecurityDisposition, + OpenApiSecurityResolution, + match_openapi_path, + resolve_openapi_security, + resolve_openapi_url, +) from .validation import ( ValidationResult, parse_json_model, @@ -195,6 +202,8 @@ "OpenApiPathMatch", "OpenApiPathMatching", "OpenApiReference", + "OpenApiSecurityDisposition", + "OpenApiSecurityResolution", "OpenApiTrailingSlash", "PlatformAgentIdentity", "PlatformAgentIdentityListResponse", @@ -241,6 +250,7 @@ "parse_platform_sign_response", "render_authorization", "require_service_origin_binding", + "resolve_openapi_security", "resolve_openapi_url", "same_origin", "select_did_web_public_jwk", diff --git a/src/agent_enrollment_protocol/core/assertions.py b/src/agent_enrollment_protocol/core/assertions.py index d64db88..27ea0f1 100644 --- a/src/agent_enrollment_protocol/core/assertions.py +++ b/src/agent_enrollment_protocol/core/assertions.py @@ -10,7 +10,13 @@ import jwt from .errors import AepAssertionError -from .models import AssertionOperation, ClientAssertionClaims, SigningAlgorithm +from .models import ( + AssertionOperation, + ClientAssertionClaims, + SigningAlgorithm, + _normalize_resource_uri, +) +from .validation import _reject_json_constant _BASE64URL_PATTERN = re.compile(r"\A[A-Za-z0-9_-]+\Z") @@ -41,7 +47,7 @@ def sign_client_assertion( allow_insecure_loopback: bool = False, ) -> str: claims = ClientAssertionClaims.model_validate_json( - json.dumps(claims.to_wire()), + json.dumps(claims.to_wire(), allow_nan=False), context={"allow_insecure_loopback": allow_insecure_loopback}, ) kid = key_id or claims.iss @@ -60,7 +66,8 @@ def verify_client_assertion( options = options or VerifyClientAssertionOptions() try: header = jwt.get_unverified_header(assertion) - except jwt.PyJWTError as error: + json.dumps(header, allow_nan=False) + except (jwt.PyJWTError, ValueError) as error: raise AepAssertionError("Invalid AEP client assertion.") from error if header.get("typ") != "JWT" or not isinstance(header.get("kid"), str): raise AepAssertionError("Invalid AEP client assertion JOSE header.") @@ -78,10 +85,11 @@ def verify_client_assertion( "verify_aud": False, "verify_exp": False, "verify_iat": False, + "verify_nbf": False, }, ) claims = ClientAssertionClaims.model_validate_json( - json.dumps(payload), + json.dumps(payload, allow_nan=False), context={"allow_insecure_loopback": options.allow_insecure_loopback}, ) except (jwt.PyJWTError, ValueError) as error: @@ -89,6 +97,14 @@ def verify_client_assertion( _require_key_binding(header["kid"], claims) now = options.current_time if options.current_time is not None else int(time.time()) tolerance = options.clock_tolerance_seconds + if "nbf" in payload: + not_before = payload["nbf"] + if ( + isinstance(not_before, bool) + or not isinstance(not_before, (int, float)) + or not_before > now + tolerance + ): + raise AepAssertionError("AEP client assertion is not yet valid or has an invalid nbf.") if claims.iat > now + tolerance or claims.exp <= now - tolerance: raise AepAssertionError("AEP client assertion is outside its valid time window.") expected = ( @@ -96,11 +112,21 @@ def verify_client_assertion( ("issuer", options.issuer, claims.iss), ("subject", options.subject, claims.sub), ("operation", options.operation, claims.op), - ("resource", options.resource, claims.resource), ) for name, wanted, actual in expected: if wanted is not None and wanted != actual: raise AepAssertionError(f"AEP client assertion {name} does not match.") + if options.resource is not None: + try: + matches = claims.resource is not None and _normalize_resource_uri( + claims.resource, allow_insecure_loopback=options.allow_insecure_loopback + ) == _normalize_resource_uri( + options.resource, allow_insecure_loopback=options.allow_insecure_loopback + ) + except ValueError as error: + raise AepAssertionError("AEP client assertion resource is invalid.") from error + if not matches: + raise AepAssertionError("AEP client assertion resource does not match.") return claims @@ -135,7 +161,7 @@ def pairs(items: list[tuple[str, Any]]) -> dict[str, Any]: result[key] = item return result - parsed = json.loads(decoded, object_pairs_hook=pairs) + parsed = json.loads(decoded, object_pairs_hook=pairs, parse_constant=_reject_json_constant) if not isinstance(parsed, dict): raise ValueError("JWT part must be an object") return parsed diff --git a/src/agent_enrollment_protocol/core/did_web.py b/src/agent_enrollment_protocol/core/did_web.py index 693ec50..b3c30ee 100644 --- a/src/agent_enrollment_protocol/core/did_web.py +++ b/src/agent_enrollment_protocol/core/did_web.py @@ -7,6 +7,7 @@ from urllib.parse import unquote, urlsplit _INVALID_PERCENT_ENCODING = re.compile(r"%(?![0-9A-Fa-f]{2})") +_VISIBLE_ASCII_PATTERN = re.compile(r"\A[\x21-\x7e]+\Z") def did_web_document_url(did: str, *, allow_insecure_loopback: bool = False) -> str: @@ -53,7 +54,11 @@ def _did_web_parts(did: str) -> tuple[str, tuple[str, ...]]: ): raise ValueError(f"Invalid did:web identifier: {did}") host = unquote(encoded_parts[0]) - if not host.isascii(): + # urlsplit strips CR, LF, and tab before parsing, so a host carrying one would be inspected in + # its sanitized form and then concatenated into the document URL in its raw form. Requiring + # visible US-ASCII here keeps the value that is validated and the value that is used identical, + # and keeps whitespace and control characters out of a URL handed to an HTTP client. + if not host.isascii() or not _VISIBLE_ASCII_PATTERN.fullmatch(host): raise ValueError(f"Invalid did:web identifier: {did}") parsed = urlsplit(f"//{host}") try: diff --git a/src/agent_enrollment_protocol/core/http.py b/src/agent_enrollment_protocol/core/http.py index 77b6250..c3b948e 100644 --- a/src/agent_enrollment_protocol/core/http.py +++ b/src/agent_enrollment_protocol/core/http.py @@ -15,6 +15,14 @@ ProtectedResourceAuthorization, ) +_CREDENTIALS_PATTERN = re.compile(r"\A[\x21-\x7e]+\Z") +# CORE-AUTH-001 spells the presentation as `"AEP" 1*SP compact-jws`, so repeated separators belong +# to the grammar, a field value may carry trailing optional whitespace, and the credential itself is +# visible US-ASCII with no space. Requiring a single separator rejected a conforming presentation, +# and allowing a space inside the credential let one field value carry two of them. +_AUTHORIZATION_PATTERN = re.compile( + r"\A(AEP|Bearer|Basic)[ \t]+([\x21-\x7e]+)[ \t]*\Z", re.IGNORECASE +) _TOKEN = r"[!#$%&'*+.^_`|~0-9A-Za-z-]+" _QUOTED_STRING = r'"(?:[\t !#-\[\]-~\x80-\xff]|\\[\t !-~\x80-\xff])*"' _MEDIA_TYPE_PATTERN = re.compile( @@ -52,11 +60,25 @@ def media_type_essence(value: str) -> str: def normalize_endpoint_base(endpoint_base: str = DEFAULT_HTTP_ENDPOINT_BASE) -> str: - if not endpoint_base.startswith("/") or endpoint_base.startswith("//"): + if not is_endpoint_path(endpoint_base): raise ValueError("AEP endpoint_base must be an origin-relative absolute path") return endpoint_base if endpoint_base.endswith("/") else f"{endpoint_base}/" +def is_endpoint_path(value: str) -> bool: + """Reports whether a value can carry a command path appended to it. + + CORE-HTTP-004 builds a command URL by appending the relative path with exactly one `/` + separator. A base carrying a query, a fragment, or a dot segment cannot satisfy that: the + command name would land in the query string or walk out of the advertised base. + """ + if not value.startswith("/") or value.startswith("//"): + return False + if "?" in value or "#" in value: + return False + return not any(segment in {".", ".."} for segment in value.split("/")) + + def command_path(command: Command, endpoint_base: str = DEFAULT_HTTP_ENDPOINT_BASE) -> str: if command is Command.INSPECT: raise ValueError("Inspect does not have a command endpoint path") @@ -72,6 +94,13 @@ def render_authorization(value: ProtectedResourceAuthorization) -> tuple[str, st raise AepAuthorizationError( "Authorization credentials must not be empty.", "invalid_request" ) + # Visible US-ASCII excluding space keeps CR, LF, NUL, and other control characters out of a + # rendered field value, and keeps one field value from carrying two credentials. + if _CREDENTIALS_PATTERN.fullmatch(value.credentials) is None: + raise AepAuthorizationError( + "Authorization credentials must not contain whitespace or control characters.", + "invalid_request", + ) return value.carrier.value, f"{value.scheme.value} {value.credentials}" @@ -82,17 +111,16 @@ def parse_authorization( raise AepAuthorizationError( "The dedicated authorization field is ambiguous.", "not_recognized" ) - scheme_text, separator, credentials = value.partition(" ") - schemes = {item.value.lower(): item for item in AuthorizationScheme} - scheme = schemes.get(scheme_text.lower()) - if not separator or scheme is None or not credentials or credentials[0].isspace(): + match = _AUTHORIZATION_PATTERN.fullmatch(value) + if match is None: raise AepAuthorizationError( "The authorization presentation was not recognized.", "not_recognized" ) + schemes = {item.value.lower(): item for item in AuthorizationScheme} return ProtectedResourceAuthorization( carrier=carrier, - credentials=credentials, - scheme=scheme, + credentials=match.group(2), + scheme=schemes[match.group(1).lower()], ) diff --git a/src/agent_enrollment_protocol/core/models.py b/src/agent_enrollment_protocol/core/models.py index 77aad42..02d4de6 100644 --- a/src/agent_enrollment_protocol/core/models.py +++ b/src/agent_enrollment_protocol/core/models.py @@ -3,10 +3,10 @@ import re from copy import deepcopy from datetime import date, datetime -from email.errors import HeaderParseError -from email.headerregistry import Address from enum import StrEnum +from ipaddress import IPv6Address from typing import Annotated, Any, ClassVar, Literal, Self +from urllib.parse import urlsplit, urlunsplit from pydantic import ( BaseModel, @@ -31,8 +31,8 @@ NON_NEGATIVE_INTEGER_PATTERN = re.compile(r"^(?:0|[1-9][0-9]*)$") POSITIVE_SECONDS_PATTERN = re.compile(r"^(?:[1-9]|[1-9][0-9]|[12][0-9]{2}|300)$") RFC3339_DATETIME_PATTERN = re.compile( - r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}" - r"(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})$" + r"^[0-9]{4}-[0-9]{2}-[0-9]{2}[Tt][0-9]{2}:[0-9]{2}:[0-9]{2}" + r"(?:\.[0-9]+)?(?:[Zz]|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])$" ) RFC3339_FULL_DATE_PATTERN = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$") URI_SCHEME_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*$") @@ -116,11 +116,21 @@ class AepModel(BaseModel): @model_validator(mode="before") @classmethod - def reject_explicit_null(cls, value: Any) -> Any: + def reject_explicit_null(cls, value: Any, info: ValidationInfo) -> Any: if isinstance(value, dict): - known_fields = {field.alias or name for name, field in cls.model_fields.items()} + known_fields = { + key: field.alias or name + for name, field in cls.model_fields.items() + for key in ( + (name, field.alias or name) if info.mode == "python" else (field.alias or name,) + ) + } for field, member in value.items(): - if member is None and field in known_fields and field not in cls.nullable_fields: + if ( + member is None + and field in known_fields + and known_fields[field] not in cls.nullable_fields + ): raise ValueError(f"{field} must be omitted instead of null") return deepcopy(value) return value @@ -247,9 +257,10 @@ class HttpConfiguration(AepModel): @model_validator(mode="after") def validate_endpoint_base(self) -> Self: - if self.endpoint_base is not None and ( - not self.endpoint_base.startswith("/") or self.endpoint_base.startswith("//") - ): + # CORE-HTTP-004 appends the command path to this value with one `/` separator, so a base + # carrying a query, a fragment, or a dot segment would steer every command URL away from + # the advertised base. + if self.endpoint_base is not None and not _is_endpoint_path(self.endpoint_base): raise ValueError("endpoint_base must be an origin-relative absolute path") return self @@ -336,6 +347,13 @@ class ClaimValues(AepModel): @model_validator(mode="after") def validate_claims(self) -> Self: + # Extra wire keys can match Python field names. Pydantic includes them in fields_set, + # which would serialize an unprovided registered claim as null with exclude_unset. + self.model_fields_set.difference_update( + name + for name in (self.model_extra or {}) + if name in type(self).model_fields and getattr(self, name) is None + ) non_empty = ( self.person_first_name, self.person_last_name, @@ -591,7 +609,7 @@ def validate_paths(self) -> Self: self.list, self.hosted_verification, ): - if value is not None and (not value.startswith("/") or value.startswith("//")): + if value is not None and not _is_endpoint_path(value): raise ValueError("Platform endpoint paths must be origin-relative absolute paths") if self.lifecycle.count("{agent_identity_id}") != 1: raise ValueError("lifecycle must contain one {agent_identity_id} placeholder") @@ -609,11 +627,7 @@ class PlatformHttp(AepModel): @model_validator(mode="after") def validate_endpoint_base(self) -> Self: - if ( - not self.endpoint_base.startswith("/") - or self.endpoint_base.startswith("//") - or "{" in self.endpoint_base - ): + if not _is_endpoint_path(self.endpoint_base) or "{" in self.endpoint_base: raise ValueError("endpoint_base must be an origin-relative absolute path") return self @@ -836,7 +850,7 @@ def validate_response(self) -> Self: def parse_rfc3339(value: str) -> datetime: if RFC3339_DATETIME_PATTERN.fullmatch(value) is None: raise ValueError("invalid RFC 3339 date-time") - return datetime.fromisoformat(value.replace("Z", "+00:00")) + return datetime.fromisoformat(value.upper().replace("Z", "+00:00")) def parse_full_date(value: str) -> date: @@ -845,6 +859,14 @@ def parse_full_date(value: str) -> date: return date.fromisoformat(value) +def _is_endpoint_path(value: str) -> bool: + if not value.startswith("/") or value.startswith("//"): + return False + if "?" in value or "#" in value: + return False + return not any(segment in {".", ".."} for segment in value.split("/")) + + def _require_identifiers(values: tuple[str, ...], name: str, *, unique: bool = False) -> None: if any(IDENTIFIER_PATTERN.fullmatch(value) is None for value in values): raise ValueError(f"{name} uses the registered identifier syntax") @@ -865,16 +887,18 @@ def _require_did(value: str, name: str) -> None: def _require_https_uri(value: str, name: str) -> None: - from urllib.parse import urlsplit - parsed = urlsplit(value) - if parsed.scheme != "https" or not parsed.hostname or parsed.fragment: - raise ValueError(f"{name} must be an absolute HTTPS URI without a fragment") + if ( + parsed.scheme != "https" + or not parsed.hostname + or "#" in value + or parsed.username + or parsed.password + ): + raise ValueError(f"{name} must be an absolute HTTPS URI without a fragment or user info") def _require_resource_uri(value: str, name: str, *, allow_insecure_loopback: bool = False) -> None: - from urllib.parse import urlsplit - parsed = urlsplit(value) secure = parsed.scheme == "https" loopback = ( @@ -882,8 +906,31 @@ def _require_resource_uri(value: str, name: str, *, allow_insecure_loopback: boo and parsed.scheme == "http" and parsed.hostname in {"localhost", "127.0.0.1", "::1"} ) - if (not secure and not loopback) or not parsed.hostname or parsed.fragment: - raise ValueError(f"{name} must be an absolute HTTPS URI without a fragment") + # User information in a resource URL carries credentials the assertion is not bound to and + # makes the authority ambiguous to a reader, so it is refused here as it is on every other + # URL this package accepts. + if ( + (not secure and not loopback) + or not parsed.hostname + or "#" in value + or parsed.username is not None + or parsed.password is not None + ): + raise ValueError(f"{name} must be an absolute HTTPS URI without a fragment or user info") + + +def _normalize_resource_uri(value: str, *, allow_insecure_loopback: bool = False) -> str: + _require_resource_uri(value, "resource", allow_insecure_loopback=allow_insecure_loopback) + parsed = urlsplit(value) + host = parsed.hostname or "" + authority = f"[{host}]" if ":" in host else host + if parsed.port is not None and parsed.port != (443 if parsed.scheme == "https" else 80): + authority += f":{parsed.port}" + normalized = urlunsplit((parsed.scheme, authority, parsed.path or "/", parsed.query, "")) + # An empty query is not interchangeable with an absent query. + if "?" in value and not parsed.query: + normalized += "?" + return normalized def _validate_operation_resource(op: AssertionOperation, resource: str | None) -> None: @@ -896,28 +943,46 @@ def _validate_operation_resource(op: AssertionOperation, resource: str | None) - def _is_mailbox(value: str) -> bool: - if len(value.encode()) > 320 or "@" not in value: - return False - try: - address = Address(addr_spec=value) - except (HeaderParseError, IndexError, ValueError): + if not value.isascii() or len(value.encode()) > 320 or "@" not in value: return False - local, separator, domain = address.addr_spec.rpartition("@") + local, separator, domain = value.rpartition("@") if not separator or len(local.encode()) > 64 or len(domain.encode()) > 255: return False - if "." not in local and local.startswith('"'): - return True + if ( + re.fullmatch( + r"""(?:[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*""" + r"""|"(?:[\x20-\x21\x23-\x5b\x5d-\x7e]|\\[\x20-\x7e])*")""", + local, + ) + is None + ): + return False + if domain.startswith("[") and domain.endswith("]"): + literal = domain[1:-1] + if re.fullmatch(r"[0-9]{1,3}(?:\.[0-9]{1,3}){3}", literal): + return all(int(octet) <= 255 for octet in literal.split(".")) + if literal[:5].lower() == "ipv6:": + try: + IPv6Address(literal[5:]) + except ValueError: + return False + return "%" not in literal + return re.fullmatch(r"[A-Za-z0-9-]*[A-Za-z0-9]:[\x21-\x5a\x5e-\x7e]+", literal) is not None labels = domain.split(".") return all( label and len(label) <= 63 - and label[0].isalnum() - and label[-1].isalnum() - and all(character.isalnum() or character == "-" for character in label) + and _is_ascii_alphanumeric(label[0]) + and _is_ascii_alphanumeric(label[-1]) + and all(_is_ascii_alphanumeric(character) or character == "-" for character in label) for label in labels ) +def _is_ascii_alphanumeric(value: str) -> bool: + return value.isascii() and value.isalnum() + + def _is_absolute_uri(value: str) -> bool: if any(character.isspace() for character in value): return False diff --git a/src/agent_enrollment_protocol/core/openapi.py b/src/agent_enrollment_protocol/core/openapi.py index e77754c..73b94e3 100644 --- a/src/agent_enrollment_protocol/core/openapi.py +++ b/src/agent_enrollment_protocol/core/openapi.py @@ -1,13 +1,17 @@ from __future__ import annotations import re +from collections.abc import Collection, Mapping, Sequence from dataclasses import dataclass +from enum import StrEnum from urllib.parse import urljoin, urlsplit from .models import OpenApiTrailingSlash _PATH_EXPRESSION = re.compile(r"\{[^{}]+\}") +SecurityRequirements = Sequence[Mapping[str, Sequence[str]]] + @dataclass(frozen=True, slots=True) class OpenApiPathMatch: @@ -15,6 +19,66 @@ class OpenApiPathMatch: template: str +class OpenApiSecurityDisposition(StrEnum): + """How an Agent must treat the security an operation resolves to.""" + + PUBLIC = "public" + OPTIONAL_ANONYMOUS = "optional-anonymous" + REQUIRED = "required" + ALTERNATIVES = "alternatives" + ALL_REQUIRED = "all-required" + FALLBACK_LIVE_CHALLENGE = "fallback-live-challenge" + + +@dataclass(frozen=True, slots=True) +class OpenApiSecurityResolution: + """The resolved security for one operation. + + `satisfiable` lists the complete requirement objects the Agent can satisfy, each as the scheme + names it must present together. It is empty unless the disposition selects credentials. + """ + + disposition: OpenApiSecurityDisposition + satisfiable: tuple[tuple[str, ...], ...] = () + + +def resolve_openapi_security( + *, + root: SecurityRequirements | None, + operation: SecurityRequirements | None, + supported_schemes: Collection[str], +) -> OpenApiSecurityResolution: + """Resolves the effective security for one operation (CORE-OAS-011/013/014/015). + + Operation-level security replaces the root value rather than adding to it, an empty list makes + the operation public, an empty requirement object is an anonymous alternative, several + requirement objects are alternatives, and several schemes inside one object are compound. An + Agent that cannot satisfy every member of a compound requirement treats that requirement as + unsupported and never reduces it to the members it does support, so the requirements reported + as satisfiable are always whole requirement objects. + """ + supported = frozenset(supported_schemes) + effective = operation if operation is not None else (root if root is not None else ()) + if not effective: + return OpenApiSecurityResolution(OpenApiSecurityDisposition.PUBLIC) + requirements = tuple(tuple(requirement) for requirement in effective) + if any(not requirement for requirement in requirements): + return OpenApiSecurityResolution(OpenApiSecurityDisposition.OPTIONAL_ANONYMOUS) + satisfiable = tuple( + requirement for requirement in requirements if supported.issuperset(requirement) + ) + if not satisfiable: + return OpenApiSecurityResolution(OpenApiSecurityDisposition.FALLBACK_LIVE_CHALLENGE) + if len(requirements) > 1: + return OpenApiSecurityResolution(OpenApiSecurityDisposition.ALTERNATIVES, satisfiable) + disposition = ( + OpenApiSecurityDisposition.ALL_REQUIRED + if len(satisfiable[0]) > 1 + else OpenApiSecurityDisposition.REQUIRED + ) + return OpenApiSecurityResolution(disposition, satisfiable) + + def match_openapi_path( templates: tuple[str, ...], *, diff --git a/src/agent_enrollment_protocol/core/validation.py b/src/agent_enrollment_protocol/core/validation.py index a68494c..a44629e 100644 --- a/src/agent_enrollment_protocol/core/validation.py +++ b/src/agent_enrollment_protocol/core/validation.py @@ -26,7 +26,7 @@ def ok(self) -> bool: def parse_json_model(data: str | bytes, model: type[ModelT], document_type: str) -> ModelT: _reject_duplicate_members(data, document_type) try: - return model.model_validate_json(data) + return model.model_validate_json(data, by_alias=True, by_name=False) except ValidationError as error: raise AepValidationError( f"Invalid AEP {document_type}.", validation_issues(error) @@ -52,7 +52,9 @@ def parse_platform_sign_response(data: str | bytes) -> PlatformSignResponse: def _parse_json_value(data: str | bytes, value_type: Any, document_type: str) -> Any: _reject_duplicate_members(data, document_type) try: - return TypeAdapter(value_type).validate_json(data, strict=True) + return TypeAdapter(value_type).validate_json( + data, strict=True, by_alias=True, by_name=False + ) except ValidationError as error: raise AepValidationError( f"Invalid AEP {document_type}.", validation_issues(error) @@ -69,9 +71,13 @@ def pairs(values: list[tuple[str, Any]]) -> dict[str, Any]: return result try: - json.loads(data, object_pairs_hook=pairs) + json.loads(data, object_pairs_hook=pairs, parse_constant=_reject_json_constant) except (json.JSONDecodeError, UnicodeDecodeError, ValueError) as error: raise AepValidationError( f"Invalid AEP {document_type} JSON.", (ValidationIssue(path="$", message=str(error)),), ) from error + + +def _reject_json_constant(value: str) -> Any: + raise ValueError(f"invalid JSON constant: {value}") diff --git a/src/agent_enrollment_protocol/platform/document.py b/src/agent_enrollment_protocol/platform/document.py index e2832ef..9796ff1 100644 --- a/src/agent_enrollment_protocol/platform/document.py +++ b/src/agent_enrollment_protocol/platform/document.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from urllib.parse import quote, urlsplit @@ -22,6 +23,8 @@ WELL_KNOWN_PATH = AEP_PLATFORM_WELL_KNOWN_PATH _DID_CONTEXT = "https://www.w3.org/ns/did/v1" _DID_PLACEHOLDER = "{agent_did_id}" +# RFC 7517 and RFC 7518 private key members, plus the symmetric key value. +_PRIVATE_JWK_MEMBERS = frozenset({"d", "dp", "dq", "k", "oth", "p", "q", "qi"}) def create_service_scoped_agent_did(host: str, path_prefix: str, agent_did_id: str) -> str: @@ -54,6 +57,7 @@ def create_did_document(identity: IdentityRecord, method: DidVerificationMethod) or not method.public_key_jwk ): raise ValueError("AEP Platform DID verification method does not match the managed identity") + _require_public_jwk(method.public_key_jwk) return { "@context": [_DID_CONTEXT], "assertionMethod": [method.id], @@ -154,5 +158,16 @@ def _validate_endpoint_path(name: str, path: str) -> None: raise ValueError(f"AEP Platform {name} endpoint must be an absolute path") +def _require_public_jwk(jwk: Mapping[str, Any]) -> None: + """Refuses to publish anything but public verification material (PLT-DID-002). + + A DID document is served to anyone who resolves the Agent DID. A key store that returns a JWK + still carrying its private members would publish the signing key itself, so the document is + never built from one. + """ + if jwk.get("kty") == "oct" or _PRIVATE_JWK_MEMBERS & set(jwk): + raise ValueError("AEP Platform DID verification method must carry only public key material") + + def _encode_did_component(value: str) -> str: return quote(value, safe="-._~!$&'()*+,;=") diff --git a/src/agent_enrollment_protocol/platform/platform.py b/src/agent_enrollment_protocol/platform/platform.py index 71371d0..9760ad4 100644 --- a/src/agent_enrollment_protocol/platform/platform.py +++ b/src/agent_enrollment_protocol/platform/platform.py @@ -6,6 +6,7 @@ from collections.abc import Awaitable, Callable from dataclasses import replace from datetime import UTC, datetime, timedelta +from email.utils import format_datetime from typing import Any, TypeVar, cast from pydantic import BaseModel @@ -31,9 +32,12 @@ SigningAlgorithm, VerifyClientAssertionOptions, decode_jwt_unverified, + did_web_document_url, + media_type_essence, verify_client_assertion, ) from agent_enrollment_protocol.core.errors import AepAssertionError +from agent_enrollment_protocol.core.models import parse_rfc3339 from .document import ( DID_MEDIA_TYPE, @@ -69,6 +73,7 @@ BodyT = TypeVar("BodyT") ModelT = TypeVar("ModelT", bound=BaseModel) +_MISSING_REQUEST_CONTEXT = "Idempotency-Key and authenticated principal are required" class Platform: @@ -96,7 +101,15 @@ def __init__(self, options: PlatformOptions) -> None: raise ValueError("AEP Platform assertion lifetime exceeds the configured maximum") if options.hosted_verification and options.replay_store is None: raise ValueError("AEP Platform hosted verification requires a replay store") - create_service_scoped_agent_did(options.did_host, options.did_path_prefix, "validation") + sample_did = create_service_scoped_agent_did( + options.did_host, options.did_path_prefix, "validation" + ) + if render_did_url(options.did_url_template, "validation") != did_web_document_url( + sample_did + ): + raise ValueError( + "AEP Platform DID URL template must match its DID host and path prefix" + ) self._discovery = create_discovery_document( options.discovery, did_url_template=options.did_url_template, @@ -124,9 +137,24 @@ def __init__(self, options: PlatformOptions) -> None: self._service_did_resolver = options.service_did_resolver self._sign_handler = options.sign_handler self._signing_algorithms = options.signing_algorithms + published = json.dumps( + self._discovery.to_wire(), sort_keys=True, separators=(",", ":") + ).encode() + self._discovery_etag = f'"{hashlib.sha256(published).hexdigest()[:32]}"' + self._discovery_last_modified = _http_date(_aware(self._clock())) def discovery(self) -> PlatformResult[PlatformDiscoveryDocument]: - return _success(200, self._discovery, {"Cache-Control": "max-age=300"}) + # PLT-DISC-012: an Agent honouring PLT-DISC-013 revalidates with If-None-Match and + # If-Modified-Since, which needs the Platform to publish validators alongside freshness. + return _success( + 200, + self._discovery, + { + "Cache-Control": "max-age=300", + "ETag": self._discovery_etag, + "Last-Modified": self._discovery_last_modified, + }, + ) async def did_document(self, agent_did_id: str) -> PlatformResult[dict[str, Any]]: identity = await self._identity_store.find_by_agent_did_id(agent_did_id) @@ -188,6 +216,16 @@ async def list( async def provision( self, request: PlatformProvisionRequest, context: RequestContext ) -> PlatformResult[PlatformAgentIdentity]: + if not context.principal or not context.idempotency_key: + return _problem(400, "invalid_request", _MISSING_REQUEST_CONTEXT) + authorized = await self._authorizer.authorize( + AuthorizationRequest( + operation=AuthorizationOperation.PROVISION, provision_request=request + ), + context, + ) + if not authorized: + return _problem(404, "not_recognized", "Identity not recognized") return await self._idempotent( IdempotentOperation.PROVISION, request, @@ -199,14 +237,6 @@ async def provision( async def _provision( self, request: PlatformProvisionRequest, context: RequestContext ) -> PlatformResult[PlatformAgentIdentity]: - authorized = await self._authorizer.authorize( - AuthorizationRequest( - operation=AuthorizationOperation.PROVISION, provision_request=request - ), - context, - ) - if not authorized: - return _problem(404, "not_recognized", "Identity not recognized") if not await self._service_did_resolver.resolve(request.service_did): return _problem(400, "invalid_request", "Service DID could not be resolved") identity, _ = await self._identity_store.find_or_create( @@ -226,32 +256,41 @@ async def sign( context: RequestContext, ) -> PlatformResult[PlatformSignResponse]: lifetime = self._sign_lifetime(request) + if lifetime is None: + # PLT-SIGN-011 lets a Platform enforce a shorter local maximum. A caller-supplied value + # above it is a rejected request, answered ahead of the idempotency store so an invalid + # request neither consumes a key nor escapes as an unhandled failure. + return _problem( + 400, "invalid_request", "lifetime_seconds exceeds the configured maximum" + ) + if not context.principal or not context.idempotency_key: + return _problem(400, "invalid_request", _MISSING_REQUEST_CONTEXT) + identity = await self._authorized_identity( + agent_identity_id, + AuthorizationRequest( + operation=AuthorizationOperation.SIGN, + sign_request=_clone_model(request), + ), + context, + ) + if identity is None or identity.service_did != request.service_did: + return _problem(404, "not_recognized", "Identity not recognized") material = {"agent_identity_id": agent_identity_id, "request": request.to_wire()} return await self._idempotent( IdempotentOperation.SIGN, material, context, PlatformSignCompleted | PlatformSignPending, - lambda: self._sign(agent_identity_id, request, context, lifetime), + lambda: self._sign(identity, request, context, lifetime), ) async def _sign( self, - agent_identity_id: str, + identity: IdentityRecord, request: PlatformSignRequest, context: RequestContext, lifetime: int, ) -> PlatformResult[PlatformSignResponse]: - identity = await self._authorized_identity( - agent_identity_id, - AuthorizationRequest( - operation=AuthorizationOperation.SIGN, - sign_request=_clone_model(request), - ), - context, - ) - if identity is None or identity.service_did != request.service_did: - return _problem(404, "not_recognized", "Identity not recognized") if ( identity.status is not ManagedAgentStatus.ACTIVE or not await self._lifecycle_policy.can_sign(identity, context) @@ -262,7 +301,14 @@ async def _sign( SignHandlerInput(identity, _clone_model(request)), context ) if handled is not None: - await _validate_sign_result(handled, identity, request, lifetime, self._key_store) + await _validate_sign_result( + handled, + identity, + request, + lifetime, + self._key_store, + _aware(self._request_time(context)), + ) return handled now = _aware(self._request_time(context)) claim_data: dict[str, Any] = { @@ -291,7 +337,7 @@ async def _sign( response_data["platform_context"] = request.platform_context body = PlatformSignCompleted.model_validate(response_data) await _validate_sign_result( - _success(200, body), identity, request, lifetime, self._key_store + _success(200, body), identity, request, lifetime, self._key_store, now ) return _success(200, body) @@ -328,17 +374,8 @@ async def verify( ) -> PlatformResult[PlatformVerificationResponse]: if not self._hosted_verification: return _problem(404, "not_recognized", "Hosted verification is not available") - return await self._idempotent( - IdempotentOperation.HOSTED_VERIFICATION, - request, - context, - PlatformVerificationResponse, - lambda: self._verify(request, context), - ) - - async def _verify( - self, request: PlatformVerificationRequest, context: RequestContext - ) -> PlatformResult[PlatformVerificationResponse]: + if not context.principal or not context.idempotency_key: + return _problem(400, "invalid_request", _MISSING_REQUEST_CONTEXT) unrecognized = _success( 200, PlatformVerificationResponse( @@ -361,12 +398,9 @@ async def _verify( validate_identity_record(identity) if identity.agent_did != agent_did: raise ValueError("AEP Platform identity store returned a mismatched record") - if ( - identity is None - or identity.service_did != request.service_did - or identity.principal != context.principal - ): + if identity is None or identity.service_did != request.service_did: return unrecognized + # A trusted Service verifies with its own credentials, not the identity owner's. authorized = await self._authorizer.authorize( AuthorizationRequest( operation=AuthorizationOperation.VERIFY, @@ -375,9 +409,30 @@ async def _verify( ), context, ) + if not authorized: + return unrecognized + return await self._idempotent( + IdempotentOperation.HOSTED_VERIFICATION, + request, + context, + PlatformVerificationResponse, + lambda: self._verify(identity, request, context), + ) + + async def _verify( + self, + identity: IdentityRecord, + request: PlatformVerificationRequest, + context: RequestContext, + ) -> PlatformResult[PlatformVerificationResponse]: + unrecognized = _success( + 200, + PlatformVerificationResponse( + reason="not_recognized", service_did=request.service_did, verified=False + ), + ) if ( - not authorized - or identity.status is not ManagedAgentStatus.ACTIVE + identity.status is not ManagedAgentStatus.ACTIVE or not await self._lifecycle_policy.can_verify(identity, context) ): return unrecognized @@ -468,15 +523,13 @@ async def _authorized_identity( return None return identity - def _sign_lifetime(self, request: PlatformSignRequest) -> int: + def _sign_lifetime(self, request: PlatformSignRequest) -> int | None: lifetime = ( self._default_lifetime if request.lifetime_seconds is None else int(request.lifetime_seconds) ) - if lifetime > self._maximum_lifetime: - raise ValueError("lifetime_seconds exceeds the configured maximum") - return lifetime + return None if lifetime > self._maximum_lifetime else lifetime def _request_time(self, context: RequestContext) -> datetime: return context.current_time or self._clock() @@ -489,12 +542,6 @@ async def _idempotent( model: Any, execute: Callable[[], Awaitable[PlatformResult[BodyT]]], ) -> PlatformResult[BodyT]: - if not context.principal or not context.idempotency_key: - return _problem( - 400, - "invalid_request", - "Idempotency-Key and authenticated principal are required", - ) wire = ( material.model_dump(by_alias=True, exclude_unset=True, mode="json") if isinstance(material, BaseModel) @@ -503,7 +550,8 @@ async def _idempotent( encoded = json.dumps(wire, sort_keys=True, separators=(",", ":")).encode() result = await self._idempotency_store.execute( PlatformIdempotencyInput( - idempotency_key=context.idempotency_key, + # Private entry points require the authenticated principal and key before lookup. + idempotency_key=cast(str, context.idempotency_key), operation=operation, principal=context.principal, request_hash=hashlib.sha256(encoded).hexdigest(), @@ -575,7 +623,12 @@ async def _validate_sign_result( request: PlatformSignRequest, requested_lifetime: int, key_store: KeyStore, + current_time: datetime, ) -> None: + if media_type_essence(result.content_type) != AEP_MEDIA_TYPE or result.problem is not None: + raise ValueError("AEP Platform signing response must use application/aep+json") + if any(name.lower() == "retry-after" for name in result.headers): + raise ValueError("AEP Platform signing response must not include Retry-After") body = result.body if isinstance(body, PlatformSignPending): if result.status != 202: @@ -583,8 +636,8 @@ async def _validate_sign_result( return if not isinstance(body, PlatformSignCompleted) or result.status != 200: raise ValueError("AEP Platform signing handler returned an invalid response") - issued = datetime.fromisoformat(body.issued_at.replace("Z", "+00:00")) - expires = datetime.fromisoformat(body.expires_at.replace("Z", "+00:00")) + issued = parse_rfc3339(body.issued_at) + expires = parse_rfc3339(body.expires_at) if ( body.agent_did != identity.agent_did or body.service_did != request.service_did @@ -600,7 +653,7 @@ async def _validate_sign_result( options=VerifyClientAssertionOptions( algorithms=identity.signing_algorithms, audience=request.service_did, - current_time=int(issued.timestamp()), + current_time=int(current_time.timestamp()), issuer=identity.agent_did, operation=request.op, resource=request.resource, @@ -662,12 +715,26 @@ def _is_did(value: str) -> bool: ) +def _http_date(value: datetime) -> str: + return format_datetime(value.astimezone(UTC), usegmt=True) + + def _rfc3339(value: datetime) -> str: return value.astimezone(UTC).isoformat(timespec="seconds").replace("+00:00", "Z") def _lifecycle_error(status: ManagedAgentStatus) -> str: - return f"identity_{status.value}" + """Maps a managed status to one of the registered AEP error codes (CORE-ERR-009). + + The registry defines `identity_suspended`, `identity_terminated`, and `identity_unavailable`; + there is no `identity_revoked` or `identity_active`, so a revoked identity reports the + suspended code and anything else reports the non-punitive one. + """ + if status is ManagedAgentStatus.TERMINATED: + return "identity_terminated" + if status in {ManagedAgentStatus.SUSPENDED, ManagedAgentStatus.REVOKED}: + return "identity_suspended" + return "identity_unavailable" def _clone_model(value: ModelT) -> ModelT: diff --git a/src/agent_enrollment_protocol/service/credentials.py b/src/agent_enrollment_protocol/service/credentials.py index 21fcd81..4bcf53a 100644 --- a/src/agent_enrollment_protocol/service/credentials.py +++ b/src/agent_enrollment_protocol/service/credentials.py @@ -6,6 +6,7 @@ import hmac import json import re +import secrets from collections.abc import Mapping, Sequence from dataclasses import dataclass, replace from datetime import datetime @@ -26,6 +27,7 @@ parse_authorization, ) from agent_enrollment_protocol.core.errors import AepAuthorizationError +from agent_enrollment_protocol.core.models import parse_rfc3339 from .types import ( AuthenticatedPrincipal, @@ -88,7 +90,7 @@ def __init__( config: GrantTypeConfig, options: StoredCredentialGrantTypeOptions[CredentialT], ) -> None: - self._config = config + self._config = config.model_copy(deep=True) self._grant_type = grant_type self._issue = options.issue self._store = options.store @@ -98,6 +100,7 @@ async def grant(self, request: GrantRequest, context: GrantContext) -> bytes: credential = await self._issue(request, context) parsed = _validate_built_in_credential(self._grant_type, credential) _validate_issued_credential_config(parsed, self._config) + _require_encodable_basic(parsed) expires_at = _parse_expiry(parsed.expires_at) if expires_at <= context.current_time: raise ValueError("AEP issued credential must expire after issuance") @@ -162,6 +165,8 @@ class _MemoryCredentialRecord: grant_type: str header: str scopes: tuple[str, ...] + salt: bytes + username: str verifier: bytes revoked_at: datetime | None = None @@ -172,17 +177,25 @@ def __init__(self) -> None: self._records: dict[str, _MemoryCredentialRecord] = {} async def save_credential(self, record: ServiceCredentialRecord) -> None: - stored = _memory_credential_record(record) + stored = await _memory_credential_record(record) async with self._lock: if record.credential_id in self._records: raise ValueError("AEP credential identifier has already been issued") - if any( - existing.grant_type == stored.grant_type - and existing.header == stored.header - and hmac.compare_digest(existing.verifier, stored.verifier) - for existing in self._records.values() - ): - raise ValueError("AEP credential secret has already been issued") + for existing in self._records.values(): + if ( + existing.grant_type == stored.grant_type + and existing.header == stored.header + and existing.username == stored.username + and hmac.compare_digest( + existing.verifier, + await _credential_digest( + _credential_secret(record.credential), + existing.salt, + stored.grant_type == AEP_GRANT_TYPE_BASIC, + ), + ) + ): + raise ValueError("AEP credential secret has already been issued") self._records[record.credential_id] = stored async def authenticate_credential( @@ -194,14 +207,27 @@ async def authenticate_credential( if len(presentations) != 1: return None header, value = presentations[0] - verifier = hashlib.sha256(value.encode()).digest() + username = "" + if grant_type == AEP_GRANT_TYPE_BASIC: + try: + username = base64.b64decode(value, validate=True).decode("utf-8").partition(":")[0] + except (ValueError, UnicodeDecodeError): + return None for record in records: if ( record.grant_type == grant_type and record.header == header + and record.username == username and record.revoked_at is None and record.expires_at > request.current_time - and hmac.compare_digest(record.verifier, verifier) + and hmac.compare_digest( + record.verifier, + await _credential_digest( + value, + record.salt, + grant_type == AEP_GRANT_TYPE_BASIC, + ), + ) ): return CredentialMatch( agent_did=record.agent_did, @@ -241,7 +267,7 @@ async def revoke_grant_type( self._records[credential_id] = replace(record, revoked_at=revoked_at) -def _memory_credential_record(record: ServiceCredentialRecord) -> _MemoryCredentialRecord: +async def _memory_credential_record(record: ServiceCredentialRecord) -> _MemoryCredentialRecord: _require_aware_time(record.created_at, "issuance") _require_aware_time(record.expires_at, "expiration") if ( @@ -257,25 +283,40 @@ def _memory_credential_record(record: ServiceCredentialRecord) -> _MemoryCredent or _parse_expiry(credential.expires_at) != record.expires_at ): raise ValueError("AEP credential record metadata does not match its credential") - header = "authorization" - if isinstance(credential, OAuthBearerGrantResponse): - secret = credential.access_token - elif isinstance(credential, ApiKeyGrantResponse): - header = credential.header.lower() - secret = credential.api_key - else: - secret = base64.b64encode(f"{credential.username}:{credential.password}".encode()).decode() + salt = secrets.token_bytes(16) return _MemoryCredentialRecord( agent_did=record.agent_did, credential_id=record.credential_id, expires_at=record.expires_at, grant_type=record.grant_type, - header=header, + header=credential.header.lower() + if isinstance(credential, ApiKeyGrantResponse) + else "authorization", scopes=tuple(credential.scopes or ()), - verifier=hashlib.sha256(secret.encode()).digest(), + salt=salt, + username=credential.username if isinstance(credential, BasicGrantResponse) else "", + verifier=await _credential_digest( + _credential_secret(credential), salt, isinstance(credential, BasicGrantResponse) + ), ) +def _credential_secret(credential: BuiltInCredential) -> str: + if isinstance(credential, ApiKeyGrantResponse): + return credential.api_key + if isinstance(credential, OAuthBearerGrantResponse): + return credential.access_token + return base64.b64encode(f"{credential.username}:{credential.password}".encode()).decode() + + +async def _credential_digest(value: str, salt: bytes, password: bool) -> bytes: + if password: + return await asyncio.to_thread( + hashlib.scrypt, value.encode(), salt=salt, n=16384, r=8, p=1, dklen=32 + ) + return hashlib.sha256(salt + value.encode()).digest() + + def _credential_presentations( grant_type: str, headers: Mapping[str, tuple[str, ...]], @@ -325,6 +366,19 @@ def _validate_issued_credential_config( raise ValueError("AEP issued API-key header is not advertised by the Service") +def _require_encodable_basic(credential: BuiltInCredential) -> None: + """BSC-RSP-003: issued Basic credentials must survive RFC 7617 encoding unchanged. + + RFC 7617 leaves the user-pass charset undefined without a `charset` parameter, so a client + encoding a non-ASCII value as ISO-8859-1 and a Service storing it as UTF-8 derive different + bytes from the same credential and authentication fails. + """ + if not isinstance(credential, BasicGrantResponse): + return + if not credential.username.isascii() or not credential.password.isascii(): + raise ValueError("AEP issued Basic credentials must be encodable as US-ASCII") + + def _configured_api_key_headers(config: GrantTypeConfig) -> frozenset[str] | None: value = config.to_wire().get("header_names") if value is None: @@ -347,7 +401,7 @@ def _configured_api_key_headers(config: GrantTypeConfig) -> frozenset[str] | Non def _parse_expiry(value: str) -> datetime: - return datetime.fromisoformat(value.replace("Z", "+00:00")) + return parse_rfc3339(value) def _require_aware_time(value: datetime, label: str) -> None: diff --git a/src/agent_enrollment_protocol/service/service.py b/src/agent_enrollment_protocol/service/service.py index 85b9534..586ddcb 100644 --- a/src/agent_enrollment_protocol/service/service.py +++ b/src/agent_enrollment_protocol/service/service.py @@ -56,6 +56,8 @@ AepAuthorizationError, AepValidationError, ) +from agent_enrollment_protocol.core.models import _normalize_resource_uri +from agent_enrollment_protocol.core.validation import _reject_duplicate_members from .stores import ( MemoryEnrollmentStore, @@ -363,7 +365,10 @@ async def execute() -> ServiceResult[RevokeResponse]: async def authenticate_protected_resource( self, request: ProtectedResourceRequest ) -> ProtectedResourceResult: - resource = _absolute_url(request.url, self._allow_insecure_loopback) + resource = _normalize_resource_uri( + _absolute_url(request.url, self._allow_insecure_loopback), + allow_insecure_loopback=self._allow_insecure_loopback, + ) headers = _normalize_headers(request.headers) presentation, unambiguous = _select_presentation(headers) if not unambiguous: @@ -481,6 +486,13 @@ def _valid_assertion( json.dumps(claims.to_wire()), context={"allow_insecure_loopback": self._allow_insecure_loopback}, ) + claimed_resource = ( + _normalize_resource_uri( + claims.resource, allow_insecure_loopback=self._allow_insecure_loopback + ) + if claims.resource is not None + else None + ) except ValueError: return False key_id = header.get("kid") @@ -493,7 +505,7 @@ def _valid_assertion( or claims.aud != self._document.service.did or claims.iss != claims.sub or claims.op is not operation - or claims.resource != resource + or claimed_resource != resource or _identity_method(claims.sub) not in self._document.identity.methods ): return False @@ -600,10 +612,7 @@ def _model_parser(model: type[ModelT]) -> Callable[[bytes], ModelT]: def _parse_object(body: bytes) -> dict[str, Any]: - value = json.loads(body) - if not isinstance(value, dict): - raise ValueError("AEP Grant response must be a JSON object") - return cast(dict[str, Any], value) + return _credential_object(body) def _problem( @@ -714,14 +723,13 @@ def _within_structure( def _credential_object(raw: bytes) -> dict[str, Any]: try: - value = json.loads(raw) - except (UnicodeDecodeError, json.JSONDecodeError) as error: - raise ValueError("AEP Grant response must be a JSON object") from error - if ( - not isinstance(value, dict) - or not isinstance(value.get("credential_id"), str) - or not value["credential_id"] - ): + _reject_duplicate_members(raw, "Grant response") + except AepValidationError as error: + raise ValueError("AEP Grant response must be valid JSON") from error + value = json.loads(raw) + if not isinstance(value, dict): + raise ValueError("AEP Grant response must be a JSON object") + if not isinstance(value.get("credential_id"), str) or not value["credential_id"]: raise ValueError("AEP Grant response requires a stable credential_id") return value @@ -750,6 +758,11 @@ def _select_presentation( if any(_recognized(value) for value in standard): return None, False return selected, True + # CORE-PR-038 separates a missing credential from a malformed one. An Authorization value + # naming an AEP scheme is an AEP presentation even when the rest of it does not parse, so it + # fails closed here; values belonging to other protocols stay available to them. + if any(_names_aep_scheme(value) and not _recognized(value) for value in standard): + return None, False recognized = [value for value in standard if _recognized(value)] if len(recognized) > 1 or (recognized and len(standard) > 1): return None, False @@ -766,6 +779,11 @@ def _recognized(value: str) -> bool: return False +def _names_aep_scheme(value: str) -> bool: + scheme = value.split(" ", 1)[0].split("\t", 1)[0] + return scheme.lower() in {item.value.lower() for item in AuthorizationScheme} + + def _absolute_url(value: str | None, allow_insecure_loopback: bool) -> str: if value is None: raise ValueError("AEP URL is required") @@ -854,7 +872,7 @@ def _accepted_claims( else () ) accepted = {name: value for name, value in values.to_wire().items() if name in names} - return ClaimValues.model_validate(accepted) + return parse_json_model(json.dumps(accepted), ClaimValues, "accepted claims") def _validate_enrollment_record(record: EnrollmentRecord, agent_did: str) -> None: diff --git a/tests/test_agent_boundaries.py b/tests/test_agent_boundaries.py index b4204ad..6eec281 100644 --- a/tests/test_agent_boundaries.py +++ b/tests/test_agent_boundaries.py @@ -35,6 +35,7 @@ ) from agent_enrollment_protocol.agent.types import IdentityRequest from agent_enrollment_protocol.core import ( + AepValidationError, ApiKeyGrantResponse, AuthorizationCarrier, ClaimValues, @@ -347,7 +348,10 @@ async def test_grant_selection_custom_and_inactive_boundaries() -> None: ) custom, _ = configured_agent( QueueTransport(response(custom_document.to_wire())), - QueueTransport(response({"status": "active"}), response({"custom": "value"})), + QueueTransport( + response({"status": "active"}), + response({"credential_id": "custom-1", "custom": "value"}), + ), ) await custom.service("api.example.com").identity() result = await custom.service("api.example.com").grant() @@ -360,7 +364,8 @@ async def test_grant_selection_custom_and_inactive_boundaries() -> None: commands={"supported": ["grant", "inspect"], "grant_types": ["future"]}, ) missing_status, _ = configured_agent( - QueueTransport(response(no_status.to_wire())), QueueTransport(response({"custom": True})) + QueueTransport(response(no_status.to_wire())), + QueueTransport(response({"credential_id": "custom-2", "custom": True})), ) await missing_status.service("api.example.com").identity() assert (await missing_status.service("api.example.com").grant()).status == 200 @@ -388,7 +393,7 @@ async def test_grant_selection_custom_and_inactive_boundaries() -> None: async def test_grant_and_revoke_forward_extension_parameters() -> None: transport = QueueTransport( response({"status": "active"}), - response({"custom": "credential"}), + response({"credential_id": "custom-3", "custom": "credential"}), response({}), ) document = document_with( @@ -1181,3 +1186,56 @@ async def aclose(self) -> None: pass assert inspect.closed assert not custom.closed + + +@pytest.mark.asyncio +async def test_grant_accepts_and_reuses_lowercase_rfc3339_expiry() -> None: + document = document_with(authentication={"methods": ["api-key"]}) + wire = QueueTransport( + response({"status": "active"}), + response( + { + "api_key": "secret", + "credential_id": "lowercase-time", + "header": "X-Key", + "expires_at": "2026-01-01t01:00:00z", + } + ), + ) + agent, _ = configured_agent(QueueTransport(response(document.to_wire())), wire) + session = agent.service("api.example.com") + await session.identity() + await session.grant() + headers = await session.authentication_headers( + AuthenticationOptions( + resource="https://api.example.com/private", credential_id="lowercase-time" + ) + ) + assert headers == {"X-Key": "secret"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("identifier", [None, "", 1, False]) +async def test_extension_grant_requires_a_stable_identifier(identifier: object) -> None: + document = document_with( + authentication={"methods": ["future"]}, + commands={"supported": ["inspect", "grant"], "grant_types": ["future"]}, + ) + wire = QueueTransport(response({"credential_id": identifier, "secret": "opaque"})) + agent, _ = configured_agent(QueueTransport(response(document.to_wire())), wire) + session = agent.service("api.example.com") + await session.identity() + with pytest.raises(ValueError, match="credential_id"): + await session.grant() + + +@pytest.mark.parametrize( + "payload", + [ + b'{"credential_id":"one","opaque":NaN}', + b'{"credential_id":"one","credential_id":"two"}', + ], +) +def test_extension_grant_uses_the_core_json_boundary(payload: bytes) -> None: + with pytest.raises(AepValidationError, match="JSON"): + _parse_credential("future", payload) diff --git a/tests/test_agent_cache.py b/tests/test_agent_cache.py new file mode 100644 index 0000000..f853dd0 --- /dev/null +++ b/tests/test_agent_cache.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import pytest + +from agent_enrollment_protocol.agent import ( + Agent, + AgentOptions, + InspectCacheEntry, + PlatformDiscoveryCacheEntry, +) +from agent_enrollment_protocol.agent.transport import _fresh_cache + +from .test_agent import FakeIdentityProvider, response +from .test_agent_platform_provider import QueueTransport, discovery, json_response, listed, provider +from .test_core_models import inspect_document + +NOW = datetime(2026, 9, 3, 12, tzinfo=UTC) +DATE = "Thu, 03 Sep 2026 12:00:00 GMT" + + +@pytest.mark.parametrize("hosted", [False, True]) +@pytest.mark.parametrize( + "headers", + [ + {"Cache-Control": "max-age=60", "Age": "120"}, + {"Date": DATE, "Expires": "Thu, 03 Sep 2026 11:59:59 GMT"}, + {"Cache-Control": "max-age=60", "Date": "Thu, 03 Sep 2026 11:58:00 GMT"}, + {"Expires": "invalid"}, + {"Cache-Control": "max-age=300", "Age": "invalid"}, + ], +) +@pytest.mark.asyncio +async def test_public_discovery_revalidates_expired_http_metadata( + hosted: bool, headers: dict[str, str] +) -> None: + if hosted: + wire = QueueTransport( + json_response(discovery(), headers={**headers, "ETag": '"one"'}), + json_response(listed()), + json_response({}, status=304), + json_response(listed()), + ) + identities = provider(wire, clock=lambda: NOW) + await identities.find_identity_by_service_did("did:web:api.example.com") + await identities.find_identity_by_service_did("did:web:api.example.com") + requests = [ + item for item in wire.requests if item.url.endswith("/.well-known/aep-platform") + ] + else: + wire = QueueTransport( + response(inspect_document().to_wire(), headers={**headers, "ETag": '"one"'}), + response({}, status=304), + ) + agent = Agent( + AgentOptions( + identity_provider=FakeIdentityProvider(), + clock=lambda: NOW, + inspect_transport=wire, + command_transport=wire, + ) + ) + await agent.service("https://api.example.com").inspect() + await agent.service("https://api.example.com").inspect() + requests = wire.requests + assert len(requests) == 2 + assert requests[1].headers["If-None-Match"] == '"one"' + + +@pytest.mark.asyncio +@pytest.mark.parametrize("hosted", [False, True]) +async def test_conditional_revalidation_merges_expiry_and_resets_response_age(hosted: bool) -> None: + entry: InspectCacheEntry | PlatformDiscoveryCacheEntry | None + clock = [NOW] + headers = { + "Date": DATE, + "Expires": "Thu, 03 Sep 2026 12:02:00 GMT", + "Age": "119", + "ETag": '"one"', + } + if hosted: + wire = QueueTransport( + json_response(discovery(), headers=headers), + json_response(listed()), + json_response({}, status=304, headers={"Date": "Thu, 03 Sep 2026 12:00:02 GMT"}), + json_response(listed()), + json_response(listed()), + ) + identities = provider(wire, clock=lambda: clock[0]) + await identities.find_identity_by_service_did("did:web:api.example.com") + clock[0] += timedelta(seconds=2) + await identities.find_identity_by_service_did("did:web:api.example.com") + await identities.find_identity_by_service_did("did:web:api.example.com") + requests = [ + item for item in wire.requests if item.url.endswith("/.well-known/aep-platform") + ] + entry = await identities._discovery_cache.find( + "https://platform.example/.well-known/aep-platform" + ) + else: + wire = QueueTransport( + response(inspect_document().to_wire(), headers=headers), + response({}, status=304, headers={"Date": "Thu, 03 Sep 2026 12:00:02 GMT"}), + ) + agent = Agent( + AgentOptions( + identity_provider=FakeIdentityProvider(), + clock=lambda: clock[0], + inspect_transport=wire, + command_transport=wire, + ) + ) + await agent.service("api.example.com").inspect() + clock[0] += timedelta(seconds=2) + await agent.service("api.example.com").inspect() + await agent.service("api.example.com").inspect() + requests = wire.requests + entry = await agent._inspect_cache.find_inspect("https://api.example.com/.well-known/aep") + assert len(requests) == 2 + assert entry is not None + assert entry.age == "" + assert entry.expires == headers["Expires"] + assert entry.etag == '"one"' + + +@pytest.mark.asyncio +@pytest.mark.parametrize("hosted", [False, True]) +async def test_response_delay_counts_toward_freshness(hosted: bool) -> None: + from agent_enrollment_protocol.core import HttpRequest, HttpResponse + + clock = [NOW] + + class SlowResponse(QueueTransport): + async def send(self, request: HttpRequest) -> HttpResponse: + if len(self.requests) == 0: + clock[0] += timedelta(seconds=65) + return await super().send(request) + + headers = {"Cache-Control": "max-age=60", "Age": "0"} + if hosted: + wire = SlowResponse( + json_response(discovery(), headers=headers), + json_response(listed()), + json_response(discovery()), + json_response(listed()), + ) + identities = provider(wire, clock=lambda: clock[0]) + await identities.find_identity_by_service_did("did:web:api.example.com") + await identities.find_identity_by_service_did("did:web:api.example.com") + assert len(wire.requests) == 4 + else: + wire = SlowResponse( + response(inspect_document().to_wire(), headers=headers), + response(inspect_document().to_wire()), + ) + agent = Agent( + AgentOptions( + identity_provider=FakeIdentityProvider(), + clock=lambda: clock[0], + command_transport=wire, + inspect_transport=wire, + ) + ) + await agent.service("api.example.com").inspect() + await agent.service("api.example.com").inspect() + assert len(wire.requests) == 2 + + +@pytest.mark.parametrize( + "changes, fresh", + [ + ({}, True), + ({"now": NOW + timedelta(seconds=300)}, False), + ({"now": NOW - timedelta(seconds=1)}, False), + ({"cache_control": "max-age=60", "age": "59"}, True), + ({"cache_control": "max-age=60", "age": "60"}, False), + ({"cache_control": "max-age=60", "age": "-1"}, False), + ({"cache_control": "max-age=60", "age": "\u0661"}, False), + ({"cache_control": "max-age=-1"}, False), + ({"cache_control": "max-age=\u0661"}, False), + ({"cache_control": 'max-age="60"', "expires": "invalid"}, True), + ({"date": "invalid"}, False), + ({"expires": "Thu, 03 Sep 2026 12:00:01 GMT"}, True), + ({"expires": "Thu, 03 Sep 2026 12:00:01"}, True), + ], +) +def test_http_freshness_rules(changes: dict[str, object], fresh: bool) -> None: + from typing import Any + + arguments: dict[str, Any] = dict( + cache_control="", cached_at=NOW, now=NOW, age="", date="", expires="" + ) + arguments.update(changes) + assert _fresh_cache(**arguments) is fresh diff --git a/tests/test_agent_integration.py b/tests/test_agent_integration.py new file mode 100644 index 0000000..4a83b85 --- /dev/null +++ b/tests/test_agent_integration.py @@ -0,0 +1,593 @@ +from __future__ import annotations + +import json +from collections.abc import AsyncIterator +from dataclasses import replace +from datetime import timedelta +from typing import Any + +import httpx +import pytest +import pytest_asyncio +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from agent_enrollment_protocol.adapters import AepAsgiApplication, AepAuthenticationMiddleware +from agent_enrollment_protocol.agent import ( + Agent, + AgentCommandError, + AgentOptions, + AssertionSigner, + AuthenticationOptions, + CredentialRecord, + FetchProtectedResourceOptions, + GrantOptions, + HttpxTransport, + MemoryCredentialStore, + PlatformIdentityProvider, + PlatformIdentityProviderOptions, + RevokeOptions, + ServiceIdentity, + fetch_protected_resource, +) +from agent_enrollment_protocol.agent.types import IdentityRequest +from agent_enrollment_protocol.core import ( + ApiKeyGrantResponse, + AssertionOperation, + ClientAssertionClaims, + GrantRequest, + GrantTypeConfig, + HttpRequest, + PlatformProvisionRequest, + PlatformSignRequest, + RevokeRequest, + SigningAlgorithm, + VerifyClientAssertionOptions, + decode_jwt_unverified, + sign_client_assertion, + verify_client_assertion, +) +from agent_enrollment_protocol.platform import IdentityListQuery, Platform, RequestContext +from agent_enrollment_protocol.service import ( + AssertionVerificationContext, + CredentialAuthenticationInput, + GrantContext, + GrantTypeDefinition, + MemoryServiceCredentialStore, + RevokeContext, + Service, + ServiceOptions, + StoredCredentialGrantTypeOptions, + stored_api_key_grant_type, +) + +from .test_platform import NOW, KeyStore +from .test_platform import options as platform_options + +SERVICE_DID = "did:web:service.example" +AGENT_DID = "did:web:agent.example" +RESOURCE = "https://service.example/private" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["aep-jwt", "api-key"]) +async def test_protected_resource_helper_through_real_asgi_service(method: str) -> None: + from agent_enrollment_protocol.adapters import AsgiReceive, AsgiScope, AsgiSend + + key = Ed25519PrivateKey.generate() + issued = [0] + received: list[tuple[str, dict[bytes, bytes]]] = [] + + async def issue(request: GrantRequest, context: GrantContext) -> ApiKeyGrantResponse: + issued[0] += 1 + return ApiKeyGrantResponse( + api_key="secret", + credential_id="one", + header="X-Key", + expires_at=(NOW + timedelta(hours=1)).isoformat(), + ) + + definition = stored_api_key_grant_type( + StoredCredentialGrantTypeOptions(issue=issue, store=MemoryServiceCredentialStore()) + ) + service = Service( + ServiceOptions( + service_did=SERVICE_DID, + identity_methods=("did:web",), + authentication_methods=(method,), + verifier=Verifier(key), + clock=lambda: NOW, + grant_types=(definition,), + ) + ) + + async def private(scope: AsgiScope, receive: AsgiReceive, send: AsgiSend) -> None: + headers = dict(scope.get("headers", [])) + path = scope.get("path", "") + received.append((path, headers)) + if path == "/private": + await send( + { + "type": "http.response.start", + "status": 307, + "headers": [(b"location", b"/result")], + } + ) + else: + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"authenticated"}) + + app = AepAsgiApplication( + service, + AepAuthenticationMiddleware(private, service, resource_origin="https://service.example"), + ) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client: + transport = HttpxTransport(client) + async with Agent( + AgentOptions( + identity_provider=LocalIdentityProvider(key), + clock=lambda: NOW, + command_transport=transport, + inspect_transport=transport, + ) + ) as agent: + # Authentication can obtain a session credential, but enrollment is always explicit. + configuration = FetchProtectedResourceOptions( + agent=agent, request=HttpRequest(method="GET", url=RESOURCE), transport=transport + ) + if method == "api-key": + with pytest.raises(AgentCommandError, match="existing enrolled identity"): + await fetch_protected_resource(configuration) + else: + assert (await fetch_protected_resource(configuration)).status == 401 + assert await service._enrollment_store.find(AGENT_DID) is None + assert received == [] and issued[0] == 0 + await agent.service("https://service.example").enroll() + result = await fetch_protected_resource( + FetchProtectedResourceOptions( + agent=agent, + request=HttpRequest(method="GET", url=RESOURCE), + carrier="AEP-Authorization", + transport=transport, + additional_authentication_headers={"Authorization": "Payment opaque"}, + ) + ) + assert result.status == 200 and result.body == b"authenticated" + assert [path for path, _ in received] == ["/private", "/result"] + assert all(headers[b"authorization"] == b"Payment opaque" for _, headers in received) + if method == "aep-jwt": + tokens = [ + headers[b"aep-authorization"].decode().split()[1] for _, headers in received + ] + assert [decode_jwt_unverified(token)[1]["resource"] for token in tokens] == [ + RESOURCE, + "https://service.example/result", + ] + assert tokens[0] != tokens[1] and issued[0] == 0 + else: + assert all(headers[b"x-key"] == b"secret" for _, headers in received) + assert issued[0] == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "expected,target", + [ + ("https://SERVICE.EXAMPLE:443/private", "https://service.example/private"), + ("https://service.example", "https://service.example/"), + ("https://SERVICE.EXAMPLE:443/private?a=1&b=2", "https://service.example/private?a=1&b=2"), + ], +) +async def test_agent_and_service_share_resource_url_normalization( + expected: str, target: str +) -> None: + from agent_enrollment_protocol.adapters import AsgiReceive, AsgiScope, AsgiSend + from agent_enrollment_protocol.service import ProtectedResourceRequest + + key = Ed25519PrivateKey.generate() + service = Service( + ServiceOptions( + service_did=SERVICE_DID, + identity_methods=("did:web",), + verifier=Verifier(key), + clock=lambda: NOW, + authentication_methods=("aep-jwt",), + ) + ) + + async def private(scope: AsgiScope, receive: AsgiReceive, send: AsgiSend) -> None: + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"authenticated"}) + + app = AepAsgiApplication( + service, + AepAuthenticationMiddleware(private, service, resource_origin="https://service.example"), + ) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client: + transport = HttpxTransport(client) + agent = Agent( + AgentOptions( + identity_provider=LocalIdentityProvider(key), + clock=lambda: NOW, + command_transport=transport, + inspect_transport=transport, + ) + ) + session = agent.service("https://service.example") + await session.enroll() + headers = await session.authentication_headers(AuthenticationOptions(resource=expected)) + assert ( + decode_jwt_unverified(headers["Authorization"].removeprefix("AEP "))[1]["resource"] + == target + ) + result = await client.get(expected, headers=headers) + assert result.status_code == 200 + assert result.request.url.raw_path == httpx.URL(target).raw_path + assert (await client.get(expected, headers=headers)).status_code == 401 + other = await session.authentication_headers( + AuthenticationOptions(resource="https://service.example/other") + ) + assert (await client.get(expected, headers=other)).status_code == 401 + raw = ClientAssertionClaims( + iss=AGENT_DID, + sub=AGENT_DID, + aud=SERVICE_DID, + iat=int(NOW.timestamp()), + exp=int(NOW.timestamp()) + 60, + jti="raw-case", + op=AssertionOperation.AUTHENTICATE, + resource=expected, + ) + token = sign_client_assertion(raw, key=key, algorithm=SigningAlgorithm.EDDSA) + assert ( + await client.get(target, headers={"Authorization": f"AEP {token}"}) + ).status_code == 200 + + class SignatureVerifier(Verifier): + async def verify( + self, token: str, context: AssertionVerificationContext + ) -> ClientAssertionClaims: + return verify_client_assertion( + token, + key=self.key.public_key(), + options=VerifyClientAssertionOptions( + algorithms=context.algorithms, + audience=context.service_did, + operation=context.operation, + current_time=int(context.current_time.timestamp()), + ), + ) + + independent = Service( + ServiceOptions( + service_did=SERVICE_DID, + identity_methods=("did:web",), + verifier=SignatureVerifier(key), + clock=lambda: NOW, + authentication_methods=("aep-jwt",), + enrollment_store=service._enrollment_store, + ) + ) + assert ( + await independent.authenticate_protected_resource( + ProtectedResourceRequest( + headers={"Authorization": f"AEP {token}"}, method="GET", url=target + ) + ) + ).authenticated + + +class LocalIdentityProvider: + def __init__(self, key: Ed25519PrivateKey) -> None: + self.key = key + + async def get_or_create_identity(self, request: IdentityRequest) -> ServiceIdentity: + return ServiceIdentity(AGENT_DID, "did:web", request.service_did, (SigningAlgorithm.EDDSA,)) + + async def signer_for(self, identity: ServiceIdentity) -> AssertionSigner: + async def sign( + claims: ClientAssertionClaims, algorithms: tuple[SigningAlgorithm, ...] + ) -> str: + assert claims.iss == identity.agent_did + assert SigningAlgorithm.EDDSA in algorithms + return sign_client_assertion(claims, key=self.key, algorithm=SigningAlgorithm.EDDSA) + + return sign + + +class Verifier: + def __init__(self, key: Any) -> None: + self.key = key + + async def verify( + self, token: str, context: AssertionVerificationContext + ) -> ClientAssertionClaims: + return verify_client_assertion( + token, + key=self.key.public_key(), + options=VerifyClientAssertionOptions( + algorithms=context.algorithms, + audience=context.service_did, + operation=context.operation, + resource=context.resource, + current_time=int(context.current_time.timestamp()), + ), + ) + + +@pytest_asyncio.fixture +async def enrolled() -> AsyncIterator[tuple[Agent, MemoryServiceCredentialStore]]: + key = Ed25519PrivateKey.generate() + issued = MemoryServiceCredentialStore() + count = 0 + + async def issue(request: GrantRequest, context: GrantContext) -> ApiKeyGrantResponse: + nonlocal count + count += 1 + return ApiKeyGrantResponse( + api_key=f"secret-{count}", + credential_id=f"key-{count}", + expires_at=(NOW + timedelta(hours=1)).isoformat(), + header="x-key", + ) + + definition = stored_api_key_grant_type( + StoredCredentialGrantTypeOptions( + issue=issue, + store=issued, + config=GrantTypeConfig(supports_per_credential_revoke="true"), + ) + ) + service = Service( + ServiceOptions( + service_did=SERVICE_DID, + authentication_methods=("api-key",), + identity_methods=("did:web",), + verifier=Verifier(key), + clock=lambda: NOW, + grant_types=(definition,), + ) + ) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=AepAsgiApplication(service)) + ) as client: + transport = HttpxTransport(client) + agent = Agent( + AgentOptions( + identity_provider=LocalIdentityProvider(key), + clock=lambda: NOW, + command_transport=transport, + inspect_transport=transport, + ) + ) + session = agent.service("https://service.example") + await session.enroll() + await session.grant() + await session.grant() + yield agent, issued + + +@pytest.mark.asyncio +@pytest.mark.parametrize("field", ["all_grant_types", "credential_id", "grant_type"]) +async def test_revoke_extension_cannot_change_selector( + enrolled: tuple[Agent, MemoryServiceCredentialStore], field: str +) -> None: + agent, issued = enrolled + session = agent.service("https://service.example") + with pytest.raises(ValueError, match="selectors"): + await session.revoke(RevokeOptions(grant_type="api-key", parameters={field: "key-1"})) + for index in (1, 2): + headers = await session.authentication_headers( + AuthenticationOptions(resource=RESOURCE, credential_id=f"key-{index}") + ) + assert ( + await issued.authenticate_credential( + "api-key", + CredentialAuthenticationInput( + headers={name: (value,) for name, value in headers.items()}, + method="GET", + url=RESOURCE, + current_time=NOW, + ), + ) + is not None + ) + + +@pytest.mark.asyncio +async def test_targeted_revoke_preserves_other_local_and_service_credentials( + enrolled: tuple[Agent, MemoryServiceCredentialStore], +) -> None: + agent, issued = enrolled + session = agent.service("https://service.example") + await session.revoke(RevokeOptions(grant_type="api-key", credential_id="key-1")) + with pytest.raises(ValueError, match="not found"): + await session.authentication_headers( + AuthenticationOptions(resource=RESOURCE, credential_id="key-1") + ) + headers = await session.authentication_headers( + AuthenticationOptions(resource=RESOURCE, credential_id="key-2") + ) + assert ( + await issued.authenticate_credential( + "api-key", + CredentialAuthenticationInput( + headers={name: (value,) for name, value in headers.items()}, + method="GET", + url=RESOURCE, + current_time=NOW, + ), + ) + is not None + ) + assert ( + await issued.authenticate_credential( + "api-key", + CredentialAuthenticationInput( + headers={"x-key": ("secret-1",)}, method="GET", url=RESOURCE, current_time=NOW + ), + ) + is None + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "selector", + [ + AuthenticationOptions(resource=RESOURCE, credential_id=""), + AuthenticationOptions(resource=RESOURCE, grant_type=""), + ], +) +async def test_empty_selection_does_not_fall_back( + enrolled: tuple[Agent, MemoryServiceCredentialStore], selector: AuthenticationOptions +) -> None: + agent, _ = enrolled + with pytest.raises(ValueError, match="must not be empty"): + await agent.service("https://service.example").authentication_headers(selector) + + +@pytest.mark.asyncio +async def test_custom_store_cannot_substitute_a_different_credential( + enrolled: tuple[Agent, MemoryServiceCredentialStore], +) -> None: + original, _ = enrolled + record = await original._credential_store.find_credential(SERVICE_DID, "key-2") + assert record is not None + + class WrongStore(MemoryCredentialStore): + async def find_credential( + self, service_did: str, credential_id: str + ) -> CredentialRecord | None: + return record + + agent = Agent( + AgentOptions( + identity_provider=original._identity_provider, + clock=lambda: NOW, + command_transport=original._command_transport, + inspect_transport=original._inspect_transport, + credential_store=WrongStore(), + ) + ) + with pytest.raises(ValueError, match="requested credential ID"): + await agent.service("https://service.example").authentication_headers( + AuthenticationOptions(resource=RESOURCE, credential_id="key-1") + ) + + +class ExtensionIssuer: + async def grant(self, request: GrantRequest, context: GrantContext) -> bytes: + return b'{"credential_id":"extension-key","opaque":"secret"}' + + async def revoke(self, request: RevokeRequest, context: RevokeContext) -> None: + return None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("delay", [1, 65, 365]) +async def test_real_platform_signing_and_read_only_identity_recovery(delay: int) -> None: + clock = [NOW.replace(microsecond=123456)] + keys = KeyStore() + platform = Platform(platform_options(clock=lambda: clock[0], key_store=keys)) + paths: list[tuple[str, str]] = [] + + async def dispatch(request: httpx.Request) -> httpx.Response: + path = request.url.path + paths.append((request.method, path)) + context = RequestContext( + principal="owner", + idempotency_key=request.headers.get("idempotency-key"), + current_time=clock[0], + ) + result: Any + if path == "/.well-known/aep-platform": + result = platform.discovery() + elif request.method == "GET": + result = await platform.list(IdentityListQuery(service_did=SERVICE_DID), context) + elif path.endswith("/sign"): + clock[0] += timedelta(seconds=delay) + result = await platform.sign( + path.split("/")[-2], + PlatformSignRequest.model_validate_json(request.content), + replace(context, current_time=clock[0]), + ) + else: + result = await platform.provision( + PlatformProvisionRequest.model_validate_json(request.content), context + ) + body = result.problem or result.body + return httpx.Response( + result.status, + headers={"Content-Type": result.content_type, **result.headers}, + json=body.to_wire(), + ) + + service = Service( + ServiceOptions( + service_did=SERVICE_DID, + identity_methods=("did:web",), + verifier=Verifier(keys.key), + clock=lambda: clock[0], + grant_types=(GrantTypeDefinition("future", ExtensionIssuer()),), + ) + ) + async with ( + httpx.AsyncClient(transport=httpx.MockTransport(dispatch)) as platform_client, + httpx.AsyncClient( + transport=httpx.ASGITransport(app=AepAsgiApplication(service)) + ) as service_client, + ): + provider = PlatformIdentityProvider( + PlatformIdentityProviderOptions( + platform_url="https://platform.example", + transport=HttpxTransport(platform_client), + clock=lambda: clock[0], + ) + ) + + def fresh_agent() -> Agent: + transport = HttpxTransport(service_client) + return Agent( + AgentOptions( + identity_provider=provider, + clock=lambda: clock[0], + command_transport=transport, + inspect_transport=transport, + ) + ) + + await fresh_agent().service("https://service.example").enroll() + paths.clear() + recovered = fresh_agent() + result = await recovered.service("https://service.example").grant( + GrantOptions(grant_type="future") + ) + assert json.loads(result.body.raw)["credential_id"] == "extension-key" + assert await recovered._identity_store.find_identity(SERVICE_DID) is not None + private_paths = [item for item in paths if item[1] != "/.well-known/aep-platform"] + assert private_paths[0] == ("GET", "/v1/aep/agent-identities") + assert all(method == "GET" or path.endswith("/sign") for method, path in paths) + + +@pytest.mark.asyncio +async def test_missing_platform_identity_does_not_provision_during_grant() -> None: + from .test_agent import QueueTransport, response + from .test_core_models import inspect_document + + class LookupProvider(LocalIdentityProvider): + async def find_identity_by_service_did(self, service_did: str) -> ServiceIdentity | None: + return None + + async def get_or_create_identity(self, request: IdentityRequest) -> ServiceIdentity: + raise AssertionError("Grant must not provision") + + agent = Agent( + AgentOptions( + identity_provider=LookupProvider(Ed25519PrivateKey.generate()), + clock=lambda: NOW, + inspect_transport=QueueTransport(response(inspect_document().to_wire())), + command_transport=QueueTransport(), + ) + ) + with pytest.raises(AgentCommandError, match="existing enrolled identity"): + await agent.service("https://api.example.com").grant() diff --git a/tests/test_agent_platform_provider.py b/tests/test_agent_platform_provider.py index d08c64c..806ff0f 100644 --- a/tests/test_agent_platform_provider.py +++ b/tests/test_agent_platform_provider.py @@ -9,6 +9,7 @@ from urllib.parse import parse_qs, urlsplit import pytest +from cryptography.hazmat.primitives.asymmetric import ec from agent_enrollment_protocol.agent import ( MemoryPlatformDiscoveryCache, @@ -44,6 +45,8 @@ InspectDocument, PlatformDiscoveryDocument, SigningAlgorithm, + decode_jwt_unverified, + sign_client_assertion, ) from .test_core_models import inspect_document @@ -51,6 +54,7 @@ NOW = datetime(2026, 9, 3, 12, tzinfo=UTC) SERVICE_DID = "did:web:api.example.com" AGENT_DID = "did:web:platform.example:agents:one" +SIGNING_KEY = ec.generate_private_key(ec.SECP256R1()) class QueueTransport: @@ -196,7 +200,9 @@ def claims(**changes: object) -> ClientAssertionClaims: def completed(**changes: object) -> dict[str, object]: value: dict[str, object] = { "agent_did": AGENT_DID, - "client_assertion": "header.payload.signature", + "client_assertion": sign_client_assertion( + claims(), key=SIGNING_KEY, algorithm=SigningAlgorithm.ES256 + ), "expires_at": "2026-09-03T12:02:00Z", "issued_at": "2026-09-03T12:00:00Z", "jti": "assertion-one", @@ -207,6 +213,90 @@ def completed(**changes: object) -> dict[str, object]: return value +@pytest.mark.asyncio +@pytest.mark.parametrize( + "changes", + [ + {"client_assertion": "header.payload.signature"}, + {"issued_at": "2026-09-03T12:00:01Z"}, + {"expires_at": "2026-09-03T12:02:01Z"}, + {"agent_did": "did:web:other.example"}, + {"service_did": "did:web:other.example"}, + ], +) +async def test_completed_response_must_describe_its_actual_assertion( + changes: dict[str, object], +) -> None: + instance = provider( + QueueTransport(json_response(discovery()), json_response(completed(**changes))) + ) + signer = await instance.signer_for(service_identity()) + with pytest.raises(ValueError, match=r"assertion|completed Sign"): + await signer(claims(), (SigningAlgorithm.ES256,)) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "changes", + [ + {"iss": "did:web:other.example", "sub": "did:web:other.example"}, + {"aud": "did:web:other.example"}, + {"jti": "another-request"}, + {"op": AssertionOperation.STATUS}, + {"iat": int(NOW.timestamp()) + 1, "exp": int(NOW.timestamp()) + 121}, + {"iat": int(NOW.timestamp()) - 120, "exp": int(NOW.timestamp())}, + {"exp": int(NOW.timestamp()) + 121}, + ], +) +async def test_delegated_assertion_preserves_request_binding(changes: dict[str, object]) -> None: + token = sign_client_assertion( + claims(**changes), key=SIGNING_KEY, algorithm=SigningAlgorithm.ES256 + ) + instance = provider( + QueueTransport(json_response(discovery()), json_response(completed(client_assertion=token))) + ) + signer = await instance.signer_for(service_identity()) + with pytest.raises(ValueError, match="does not match"): + await signer(claims(), (SigningAlgorithm.ES256,)) + + +@pytest.mark.asyncio +async def test_completed_sign_accepts_lowercase_rfc3339_metadata() -> None: + instance = provider( + QueueTransport( + json_response(discovery()), + json_response( + completed(issued_at="2026-09-03t12:00:00z", expires_at="2026-09-03t12:02:00z") + ), + ) + ) + signer = await instance.signer_for(service_identity()) + assert ( + decode_jwt_unverified(await signer(claims(), (SigningAlgorithm.ES256,)))[1] + == claims().to_wire() + ) + + +@pytest.mark.asyncio +async def test_delegated_signing_allows_a_current_assertion_from_a_slower_platform_clock() -> None: + signed_claims = claims(iat=int(NOW.timestamp()) - 2, exp=int(NOW.timestamp()) + 118) + token = sign_client_assertion(signed_claims, key=SIGNING_KEY, algorithm=SigningAlgorithm.ES256) + instance = provider( + QueueTransport( + json_response(discovery()), + json_response( + completed( + client_assertion=token, + issued_at="2026-09-03T11:59:58Z", + expires_at="2026-09-03T12:01:58Z", + ) + ), + ) + ) + signer = await instance.signer_for(service_identity()) + assert await signer(claims(), (SigningAlgorithm.ES256,)) == token + + def provider( transport: QueueTransport, *, @@ -400,7 +490,8 @@ async def context_provider( transport = QueueTransport(json_response(discovery()), json_response(completed())) instance = provider(transport, platform_context=context_provider) signer = await instance.signer_for(service_identity()) - assert await signer(claims(), (SigningAlgorithm.ES256,)) == "header.payload.signature" + assertion = await signer(claims(), (SigningAlgorithm.ES256,)) + assert decode_jwt_unverified(assertion)[1] == claims().to_wire() outbound = transport.requests[1] assert outbound.url.endswith("/v1/aep/agent-identities/pai_one/sign") assert outbound.headers["Idempotency-Key"] @@ -454,7 +545,8 @@ async def key() -> str: pending_sign_resolver=resolver, ) signer = await instance.signer_for(service_identity()) - assert await signer(claims(), (SigningAlgorithm.ES256,)) == "header.payload.signature" + assertion = await signer(claims(), (SigningAlgorithm.ES256,)) + assert decode_jwt_unverified(assertion)[1] == claims().to_wire() assert len(resolved) == 1 assert transport.requests[1].headers["Idempotency-Key"] == "initial" assert transport.requests[2].headers["Idempotency-Key"] == "final" @@ -535,13 +627,22 @@ async def test_sign_response_contract_boundaries( @pytest.mark.asyncio async def test_authenticate_signing_includes_resource() -> None: - transport = QueueTransport(json_response(discovery()), json_response(completed())) - instance = provider(transport) - signer = await instance.signer_for(service_identity()) authentication = claims( op=AssertionOperation.AUTHENTICATE, resource="https://api.example.com/orders", ) + transport = QueueTransport( + json_response(discovery()), + json_response( + completed( + client_assertion=sign_client_assertion( + authentication, key=SIGNING_KEY, algorithm=SigningAlgorithm.ES256 + ) + ) + ), + ) + instance = provider(transport) + signer = await instance.signer_for(service_identity()) await signer(authentication, (SigningAlgorithm.ES256,)) assert json.loads(transport.requests[1].body or b"")["resource"] == ( "https://api.example.com/orders" diff --git a/tests/test_agent_tls.py b/tests/test_agent_tls.py new file mode 100644 index 0000000..556cc6f --- /dev/null +++ b/tests/test_agent_tls.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import asyncio +import ssl +from contextlib import suppress +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import httpx +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import NameOID + +from agent_enrollment_protocol.agent import HttpxTransport +from agent_enrollment_protocol.core import HttpRequest + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "tls,custom_client,accepted", + [ + (ssl.TLSVersion.TLSv1_2, False, False), + (ssl.TLSVersion.TLSv1_3, False, True), + (ssl.TLSVersion.TLSv1_2, True, True), + ], +) +async def test_actual_tls_negotiation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + tls: ssl.TLSVersion, + custom_client: bool, + accepted: bool, +) -> None: + key = ec.generate_private_key(ec.SECP256R1()) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]) + now = datetime.now(UTC) + certificate = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=1)) + .not_valid_after(now + timedelta(minutes=5)) + .add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost")]), critical=False) + .sign(key, hashes.SHA256()) + ) + certificate_path = tmp_path / "certificate.pem" + key_path = tmp_path / "key.pem" + certificate_path.write_bytes(certificate.public_bytes(serialization.Encoding.PEM)) + key_path.write_bytes( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + ) + monkeypatch.setenv("SSL_CERT_FILE", str(certificate_path)) + monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1") + server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + server_context.minimum_version = tls + server_context.maximum_version = tls + server_context.load_cert_chain(certificate_path, key_path) + negotiated: list[str] = [] + completed = asyncio.Event() + + async def serve(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + negotiated.append(writer.get_extra_info("ssl_object").version()) + await reader.readuntil(b"\r\n\r\n") + writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}") + await writer.drain() + finally: + writer.close() + with suppress(TimeoutError, ConnectionResetError): + await writer.wait_closed() + completed.set() + + server = await asyncio.start_server( + serve, "127.0.0.1", 0, ssl=server_context, ssl_shutdown_timeout=0.1 + ) + context = ssl.create_default_context() + context.minimum_version = tls + context.maximum_version = tls + client = httpx.AsyncClient(verify=context) if custom_client else None + transport = HttpxTransport(client) + try: + port = server.sockets[0].getsockname()[1] + request = HttpRequest("GET", f"https://localhost:{port}/") + if accepted: + assert (await transport.send(request)).status == 200 + await asyncio.wait_for(completed.wait(), timeout=3) + assert negotiated == ["TLSv1.2" if tls == ssl.TLSVersion.TLSv1_2 else "TLSv1.3"] + else: + with pytest.raises(httpx.ConnectError): + await transport.send(request) + assert negotiated == [] + finally: + await transport.aclose() + server.close() + await server.wait_closed() diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 1bcd521..c14680d 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -1,11 +1,14 @@ from __future__ import annotations import json -from collections.abc import Iterable +from collections.abc import Callable, Iterable from dataclasses import dataclass +from itertools import count from typing import Any, cast +import httpx import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from agent_enrollment_protocol.adapters import ( AEP_PRINCIPAL_SCOPE_KEY, @@ -16,8 +19,18 @@ AsgiScope, principal_from_scope, ) -from agent_enrollment_protocol.core import AEP_MEDIA_TYPE, AssertionOperation, GrantTypeConfig +from agent_enrollment_protocol.core import ( + AEP_MEDIA_TYPE, + AssertionOperation, + ClientAssertionClaims, + GrantTypeConfig, + SigningAlgorithm, + VerifyClientAssertionOptions, + sign_client_assertion, + verify_client_assertion, +) from agent_enrollment_protocol.service import ( + AssertionVerificationContext, AuthenticatedPrincipal, AuthenticationKind, CommandOptions, @@ -25,10 +38,212 @@ GrantTypeDefinition, ProtectedResourceResult, Service, + ServiceOptions, ServiceResult, ) -from .test_service import AGENT_DID, Authenticator, GrantHandler, _assertion, _service +from .test_service import ( + AGENT_DID, + NOW, + SERVICE_DID, + Authenticator, + GrantHandler, + _assertion, + _service, +) + + +def signed_service( + endpoint_base: str = "/aep/", +) -> tuple[Service, Callable[[AssertionOperation, str | None], str]]: + key = Ed25519PrivateKey.generate() + identifiers = count() + + class Verifier: + async def verify( + self, assertion: str, context: AssertionVerificationContext + ) -> ClientAssertionClaims: + return verify_client_assertion( + assertion, + key=key.public_key(), + options=VerifyClientAssertionOptions( + algorithms=context.algorithms, + audience=context.service_did, + operation=context.operation, + resource=context.resource, + current_time=int(context.current_time.timestamp()), + ), + ) + + def sign(operation: AssertionOperation, resource: str | None) -> str: + values: dict[str, object] = { + "aud": SERVICE_DID, + "iss": AGENT_DID, + "sub": AGENT_DID, + "iat": int(NOW.timestamp()), + "exp": int(NOW.timestamp()) + 60, + "jti": f"assertion-{next(identifiers)}", + "op": operation, + } + if resource is not None: + values["resource"] = resource + return sign_client_assertion( + ClientAssertionClaims.model_validate(values), key=key, algorithm=SigningAlgorithm.EDDSA + ) + + return Service( + ServiceOptions( + service_did=SERVICE_DID, + identity_methods=("did:web",), + verifier=Verifier(), + endpoint_base=endpoint_base, + authentication_methods=("aep-jwt",), + clock=lambda: NOW, + grant_types=( + GrantTypeDefinition( + "api-key", + GrantHandler(), + config=GrantTypeConfig(supports_per_credential_revoke="true"), + ), + ), + ) + ), sign + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "base", + [ + "/aep/", + "/custom/aep/", + "/caf%C3%A9/aep/", + "/caf%65/aep/", + "/café/aep/", + "/literal%252F/aep/", + "/reserved%3F%23/aep/", + ], +) +async def test_asgi_routes_commands_at_the_advertised_encoded_base(base: str) -> None: + service, sign = signed_service(base) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=AepAsgiApplication(service)), + base_url="https://service.example", + ) as client: + inspect = (await client.get("/.well-known/aep")).json() + assert inspect["http"]["endpoint_base"] == base + for operation, body in ( + (AssertionOperation.ENROLL, {"agent_did": AGENT_DID}), + (AssertionOperation.STATUS, None), + (AssertionOperation.GRANT, {"grant_type": "api-key"}), + (AssertionOperation.REVOKE, {"grant_type": "api-key", "credential_id": "credential-1"}), + ): + response = await client.request( + "GET" if operation is AssertionOperation.STATUS else "POST", + base + operation.value, + headers={ + "Authorization": "AEP " + sign(operation, None), + "Idempotency-Key": "request-" + operation.value, + "Content-Type": AEP_MEDIA_TYPE, + }, + content=json.dumps(body).encode() if body is not None else None, + ) + assert response.status_code == 200, response.text + assert response.headers["cache-control"] == "no-store" + if base == "/literal%252F/aep/": + wrong_path = await client.get("/literal%2F/aep/status") + assert wrong_path.status_code == 404 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("iterator", [False, True]) +async def test_authentication_preserves_raw_headers_and_streamed_body(iterator: bool) -> None: + service, sign = signed_service() + enrolled = await service.enroll( + json.dumps({"agent_did": AGENT_DID}).encode(), + CommandOptions( + client_assertion=sign(AssertionOperation.ENROLL, None), idempotency_key="enroll" + ), + ) + assert enrolled.status == 200 + resource = "https://service.example/caf%C3%A9?view=full" + headers = [ + (b"AEP-Authorization", ("AEP " + sign(AssertionOperation.AUTHENTICATE, resource)).encode()), + (b"Authorization", b"Payment opaque-proof"), + (b"payment-signature", b"opaque-signature"), + (b"content-type", b"application/json"), + (b"x-repeat", b"first"), + (b"X-Repeat", b"second"), + ] + observed: list[tuple[bytes, bytes]] = [] + bodies: list[bytes] = [] + + async def downstream(scope: AsgiScope, receive: Any, send: Any) -> None: + observed.extend(scope["headers"]) + principal = principal_from_scope(scope) + assert principal is not None and principal.agent_did == AGENT_DID + while True: + message = await receive() + bodies.append(message["body"]) + if not message.get("more_body", False): + break + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"ok"}) + + raw_headers = iter(iter(pair) for pair in headers) if iterator else headers + response = await invoke( + AepAuthenticationMiddleware(downstream, service, resource_origin="https://service.example"), + headers=raw_headers, + method="POST", + path="/café", + raw_path=b"/caf%C3%A9", + query_string=b"view=full", + messages=[ + {"type": "http.request", "body": b'{"quantity":', "more_body": True}, + {"type": "http.request", "body": b"2}"}, + ], + ) + assert response.status == 200 + assert observed == headers + assert b"".join(bodies) == b'{"quantity":2}' + assert headers[1] == (b"Authorization", b"Payment opaque-proof") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("presentation", ["missing", "invalid", "duplicate", "wrong-resource"]) +async def test_single_use_headers_do_not_bypass_authentication(presentation: str) -> None: + service, sign = signed_service() + await service.enroll( + json.dumps({"agent_did": AGENT_DID}).encode(), + CommandOptions( + client_assertion=sign(AssertionOperation.ENROLL, None), idempotency_key="enroll" + ), + ) + resource = ( + "https://service.example/other" + if presentation == "wrong-resource" + else "https://service.example/private" + ) + credential = ("AEP " + sign(AssertionOperation.AUTHENTICATE, resource)).encode() + headers = [(b"aep-authorization", credential)] + if presentation == "missing": + headers = [] + elif presentation == "invalid": + headers = [(b"aep-authorization", b"malformed"), (b"authorization", credential)] + elif presentation == "duplicate": + headers *= 2 + + async def downstream(scope: AsgiScope, receive: Any, send: Any) -> None: + raise AssertionError("An unauthenticated request reached the application") + + response = await invoke( + AepAuthenticationMiddleware(downstream, service, resource_origin="https://service.example"), + headers=iter(iter(pair) for pair in headers), + path="/private", + ) + assert response.status == 401 + expected = "authentication_required" if presentation == "missing" else "not_recognized" + assert json.loads(response.body)["code"] == expected + assert response.headers["cache-control"] == "no-store" @dataclass(frozen=True) @@ -142,17 +357,37 @@ async def test_asgi_application_serves_and_revalidates_inspect() -> None: ) assert wildcard.status == 304 + # RFC 9110 section 15.5.5 answers an unsupported method with 405, and section 15.5.6 requires + # the Allow field to name the ones the resource does support. rejected = await invoke(application, method="POST", path="/.well-known/aep") - assert rejected.status == 400 - assert rejected.headers["allow"] == "GET" + assert rejected.status == 405 + assert rejected.headers["allow"] == "GET, HEAD" assert rejected.headers["content-type"] == "application/problem+json" assert json.loads(rejected.body) == { "code": "invalid_request", - "status": 400, - "title": "Invalid request", + "status": 405, + "title": "Method not allowed", "type": "urn:aep:error:invalid_request", } + # RFC 9110 section 9.1 requires HEAD wherever GET is supported, and section 9.3.2 keeps its + # header fields identical to the GET it stands in for, content aside. + served = await invoke(application, method="GET", path="/.well-known/aep") + head = await invoke(application, method="HEAD", path="/.well-known/aep") + assert head.status == served.status == 200 + assert head.headers == served.headers + assert head.headers["content-length"] == str(len(served.body)) + assert head.body == b"" + + conditional_head = await invoke( + application, + method="HEAD", + path="/.well-known/aep", + headers=[(b"if-none-match", served.headers["etag"].encode())], + ) + assert conditional_head.status == 304 + assert conditional_head.body == b"" + @pytest.mark.asyncio async def test_asgi_application_delegates_or_returns_not_found() -> None: @@ -267,9 +502,19 @@ async def test_asgi_application_rejects_invalid_command_requests() -> None: application = AepAsgiApplication(service, maximum_request_body_bytes=4) wrong_method = await invoke(application, method="GET", path="/aep/enroll") - assert wrong_method.status == 400 + assert wrong_method.status == 405 assert wrong_method.headers["allow"] == "POST" + # Status is safe, so it answers HEAD as well as GET and names both in Allow. + status_head = await invoke(application, method="HEAD", path="/aep/status") + status_get = await invoke(application, method="GET", path="/aep/status") + assert status_head.status == status_get.status + assert status_head.headers == status_get.headers + assert status_head.body == b"" + status_method = await invoke(application, method="POST", path="/aep/status") + assert status_method.status == 405 + assert status_method.headers["allow"] == "GET, HEAD" + for headers in ( [], [(b"content-type", b"application/json")], @@ -294,8 +539,11 @@ async def test_asgi_application_rejects_invalid_command_requests() -> None: method="POST", path="/aep/enroll", ) - assert large.status == 400 + # RFC 9110 section 15.5.14: a body over the configured limit is 413, which tells the caller not + # to retry it unchanged, rather than 400 claiming the request itself was malformed. + assert large.status == 413 assert json.loads(large.body)["code"] == "invalid_request" + assert json.loads(large.body)["title"] == "Content too large" disconnected = await invoke( application, diff --git a/tests/test_conformance_falsifiability.py b/tests/test_conformance_falsifiability.py new file mode 100644 index 0000000..06dca35 --- /dev/null +++ b/tests/test_conformance_falsifiability.py @@ -0,0 +1,276 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from copy import deepcopy +from pathlib import Path +from typing import Any + +import pytest + +REPOSITORY = Path(__file__).resolve().parent.parent +ADAPTER = REPOSITORY / "scripts" / "conformance_adapter.py" + +JsonObject = dict[str, Any] +Corruption = tuple[str, str, str, str, Any] + +# Each entry corrupts one member of a vector's published expectation. A mapping that reads its +# expectation flips from passed to failed; a mapping that compares two constants, or two fields of +# the vector to each other, stays passed and fails this test. +CORRUPTIONS: tuple[Corruption, ...] = ( + ( + "service", + "protected-resource", + "assertion-and-credential-failures", + "wrong_audience", + "invalid_request", + ), + ( + "service", + "protected-resource", + "assertion-and-credential-failures", + "expired_assertion", + "invalid_request", + ), + ( + "service", + "protected-resource", + "assertion-and-credential-failures", + "replayed_assertion", + "invalid_request", + ), + ( + "service", + "protected-resource", + "assertion-and-credential-failures", + "expired_credential", + "invalid_request", + ), + ( + "service", + "protected-resource", + "assertion-and-credential-failures", + "insufficient_scope", + "invalid_request", + ), + ("service", "enroll", "repeated-existing", "policy_evaluated", True), + ( + "service", + "claims", + "negotiation-compatibility", + "unknown_submitted_default_action", + "accept", + ), + ("agent", "claims", "negotiation-compatibility", "omitted_preferred_allowed", False), + ("service", "idempotency", "command-header", "missing_or_empty_status", 200), + ("service", "idempotency", "command-header", "mismatched_enroll_body_status", 200), + ( + "service", + "idempotency", + "command-replay-conflict", + "changed_command", + {"status": 200, "code": "ok"}, + ), + ("agent", "protected-resource", "api-key-wrong-header-rejected", "accepted", True), + ("agent", "protected-resource", "authorization-ambiguity", "fallback", True), + ("agent", "protected-resource", "authorization-ambiguity", "code", "invalid_request"), + ( + "agent", + "protected-resource", + "authorization-field-safety", + "redirect_new_assertion_carrier", + "Authorization", + ), + ("agent", "protected-resource", "unadvertised-authentication-method", "inferred_method", "x"), + ("agent", "openapi", "security-inheritance", "multiple_schemes_one_object", "required"), + ("agent", "openapi", "security-inheritance", "unsupported_compound", "required"), + ("agent", "openapi", "security-inheritance", "root_inherited", "public"), + ("agent", "caching", "public-discovery-cache", "default_freshness_seconds", "30"), + ( + "platform", + "platform", + "idempotency-replay-conflict", + "changed_input_or_operation_status", + 400, + ), + ( + "platform", + "platform", + "idempotency-replay-conflict", + "changed_input_or_operation_code", + "invalid_request", + ), + ("platform", "platform", "idempotency-replay-conflict", "retention_seconds_minimum", "36000"), + ("platform", "platform", "idempotency-replay-conflict", "exact_replay", "fresh_result"), + ( + "platform", + "platform", + "provision-response-distinct-services", + "first_response", + {"status": "revoked"}, + ), + ("service", "grant-revoke", "grant-before-enroll-rejected", "code", "invalid_request"), + ("service", "grant-revoke", "grant-before-enroll-rejected", "status", 400), + ( + "service", + "protected-resource", + "operation-substitution-rejected", + "all_other_pairs", + "authentication_required", + ), + ("service", "protected-resource", "authorization-ambiguity", "code", "authentication_required"), + ("service", "protected-resource", "authorization-ambiguity", "fallback", True), + ( + "service", + "protected-resource", + "assertion-and-credential-failures", + "malformed_credential", + "authentication_required", + ), + ( + "service", + "protected-resource", + "assertion-and-credential-failures", + "unsupported_method", + "not_recognized", + ), +) + + +def _spec_vectors() -> Path | None: + configured = os.environ.get("AEP_SPECS_DIR") + candidates = [Path(configured)] if configured else [] + candidates += [REPOSITORY.parent / "aep-specs", REPOSITORY / ".conformance" / "aep-specs"] + for candidate in candidates: + vectors = candidate / "ietf" / "test-vectors" + if vectors.is_dir(): + return vectors + return None + + +def _request(sequence: int, role: str, vector: JsonObject, case: JsonObject) -> JsonObject: + applicability = vector["applicability"][role] + return { + "sequence": sequence, + "role": role, + "profile": applicability.get("profile", ""), + "expectation": applicability.get("expectation", ""), + "vector": {"category": vector["category"], "id": vector["id"]}, + "case": case, + } + + +def _evaluate(role: str, requests: list[JsonObject]) -> list[str]: + completed = subprocess.run( + [sys.executable, str(ADAPTER), role], + input="\n".join(json.dumps(item) for item in requests), + capture_output=True, + text=True, + timeout=180, + check=False, + ) + if completed.returncode != 0: + raise AssertionError(f"conformance adapter failed: {completed.stderr[-2000:]}") + return [json.loads(line)["status"] for line in completed.stdout.splitlines() if line.strip()] + + +def test_conformance_mappings_read_the_expectation_they_publish() -> None: + """A conformance mapping that cannot fail reports every implementation as conformant. + + Each case runs twice in one adapter process: once against the vector the specification + publishes, and once against the same vector with one member of its expectation corrupted. + """ + vectors = _spec_vectors() + if vectors is None: + pytest.skip( + "aep-specs is not checked out; set AEP_SPECS_DIR to run the falsifiability check" + ) + by_role: dict[str, tuple[list[JsonObject], list[str]]] = {} + for role, category, identifier, member, replacement in CORRUPTIONS: + vector = json.loads((vectors / category / f"{identifier}.json").read_text()) + published = {"input": vector.get("input", {}), "expected": vector.get("expected", {})} + assert member in published["expected"], f"{identifier}: {member} is not a published member" + corrupted = deepcopy(published) + corrupted["expected"][member] = replacement + requests, labels = by_role.setdefault(role, ([], [])) + requests.append(_request(len(requests) + 1, role, vector, published)) + labels.append(f"{role} {identifier}.{member}: published") + requests.append(_request(len(requests) + 1, role, vector, corrupted)) + labels.append(f"{role} {identifier}.{member}: corrupted") + + for role, (requests, labels) in by_role.items(): + statuses = _evaluate(role, requests) + assert len(statuses) == len(requests) + for index, (label, status) in enumerate(zip(labels, statuses, strict=True)): + if index % 2 == 0: + assert status == "passed", f"{label}: the mapping rejects the published vector" + else: + assert status == "failed", ( + f"{label}: the mapping accepted a corrupted expectation, " + "so nothing about the SDK can make it fail" + ) + + +@pytest.mark.parametrize( + "role,category,identifier,owner,method", + [ + ("agent", "idempotency", "command-header", "Agent", "service"), + ( + "agent", + "protected-resource", + "redirect-safety", + "ServiceSession", + "authentication_headers", + ), + ("service", "client-assertion", "validation-requirements", "Service", "enroll"), + ("service", "idempotency", "command-replay-conflict", "Service", "grant"), + ("service", "claims", "negotiation-compatibility", "Service", "enroll"), + ( + "service", + "protected-resource", + "credential-presentations", + "Service", + "authenticate_protected_resource", + ), + ("service", "protected-resource", "operation-substitution-rejected", "Service", "status"), + ("platform", "platform", "discovery", "Platform", "discovery"), + ("platform", "platform", "provision-response", "Platform", "provision"), + ("platform", "platform", "lifecycle-response", "Platform", "update_identity"), + ("platform", "platform", "list-response", "Platform", "list"), + ("platform", "platform", "sign-response", "Platform", "sign"), + ("platform", "platform", "sign-response-pending", "Platform", "sign"), + ("platform", "platform", "verification-response-recognized", "Platform", "verify"), + ("platform", "platform", "verification-response-unrecognized", "Platform", "verify"), + ], +) +def test_behavioral_mapping_fails_when_sdk_operation_is_disabled( + role: str, category: str, identifier: str, owner: str, method: str +) -> None: + vectors = _spec_vectors() + if vectors is None: + pytest.skip("Set AEP_SPECS_DIR to run conformance negative controls") + vector = json.loads((vectors / category / f"{identifier}.json").read_text()) + request = _request( + 1, role, vector, {"input": vector.get("input", {}), "expected": vector.get("expected", {})} + ) + assert _evaluate(role, [request]) == ["passed"] + code = """ +import json, runpy, sys +from unittest.mock import patch +adapter = runpy.run_path(sys.argv[1], run_name='conformance_test') +def disabled(*args, **kwargs): + raise RuntimeError('SDK operation disabled by negative control') +with patch.object(adapter[sys.argv[2]], sys.argv[3], disabled): + print(json.dumps(adapter['evaluate'](json.loads(sys.stdin.read())))) +""" + completed = subprocess.run( + [sys.executable, "-c", code, str(ADAPTER), owner, method], + input=json.dumps(request), + capture_output=True, + text=True, + timeout=30, + check=True, + ) + assert json.loads(completed.stdout)["status"] == "failed" diff --git a/tests/test_core_assertions.py b/tests/test_core_assertions.py index f55709f..7d08be9 100644 --- a/tests/test_core_assertions.py +++ b/tests/test_core_assertions.py @@ -36,6 +36,59 @@ def claims(*, op: AssertionOperation = AssertionOperation.ENROLL) -> ClientAsser return ClientAssertionClaims.model_validate(values) +@pytest.mark.parametrize( + "asserted,target,accepted,loopback", + [ + ("https://RESOURCE.EXAMPLE:443/items/1", "https://resource.example/items/1", True, False), + ("https://resource.example", "https://resource.example/", True, False), + ("https://[ABCD::1]:443/", "https://[abcd::1]/", True, False), + ("http://LOCALHOST:80/private", "http://localhost/private", True, True), + ("https://resource.example:8443/", "https://resource.example/", False, False), + ("https://resource.example/private", "https://resource.example/Private", False, False), + ("https://resource.example/private", "https://resource.example/private/", False, False), + ("https://resource.example/a%2Fb", "https://resource.example/a/b", False, False), + ("https://resource.example/?a=1&b=2", "https://resource.example/?b=2&a=1", False, False), + ("https://resource.example/private?", "https://resource.example/private", False, False), + ("https://resource.example/private?", "https://RESOURCE.EXAMPLE:443/private?", True, False), + ("https://resource.example/private", "https://resource.example/private#", False, False), + ("https://resource.example/private", "https://@resource.example/private", False, False), + ("https://resource.example/private", "https://resource.example:bad/private", False, False), + ], +) +def test_resource_comparison_preserves_target_identity( + asserted: str, + target: str, + accepted: bool, + loopback: bool, +) -> None: + key = ed25519.Ed25519PrivateKey.generate() + original = claims(op=AssertionOperation.AUTHENTICATE).model_copy(update={"resource": asserted}) + token = sign_client_assertion( + original, key=key, algorithm=SigningAlgorithm.EDDSA, allow_insecure_loopback=loopback + ) + options = VerifyClientAssertionOptions( + resource=target, current_time=NOW, allow_insecure_loopback=loopback + ) + if accepted: + assert verify_client_assertion(token, key=key.public_key(), options=options) == original + else: + with pytest.raises(AepAssertionError, match="resource"): + verify_client_assertion(token, key=key.public_key(), options=options) + + +def test_resource_expectation_cannot_accept_an_assertion_without_a_resource() -> None: + key = ed25519.Ed25519PrivateKey.generate() + token = sign_client_assertion(claims(), key=key, algorithm=SigningAlgorithm.EDDSA) + with pytest.raises(AepAssertionError, match="resource"): + verify_client_assertion( + token, + key=key.public_key(), + options=VerifyClientAssertionOptions( + resource="https://resource.example/private", current_time=NOW + ), + ) + + @pytest.mark.parametrize("algorithm", [SigningAlgorithm.EDDSA, SigningAlgorithm.ES256]) def test_sign_verify_and_decode_client_assertions(algorithm: SigningAlgorithm) -> None: private_key: object @@ -208,3 +261,59 @@ def test_unverified_decoder_rejects_non_jwt_and_non_object_parts() -> None: duplicate = urlsafe_b64encode(b'{"value":1,"value":2}').decode().rstrip("=") with pytest.raises(AepAssertionError): decode_jwt_unverified(f"{duplicate}.{object_part}.signature") + + +@pytest.mark.parametrize("not_before", [NOW + 10, NOW + 10.5]) +def test_not_before_uses_the_configured_clock_and_tolerance(not_before: float) -> None: + key = ed25519.Ed25519PrivateKey.generate() + payload = {**claims().to_wire(), "nbf": not_before} + token = jwt.encode(payload, key, algorithm="EdDSA", headers={"kid": payload["iss"]}) + options = VerifyClientAssertionOptions(current_time=NOW + 11) + assert ( + verify_client_assertion(token, key=key.public_key(), options=options).jti == "assertion-1" + ) + with pytest.raises(AepAssertionError, match="not yet valid"): + verify_client_assertion( + token, key=key.public_key(), options=VerifyClientAssertionOptions(current_time=NOW + 9) + ) + assert ( + verify_client_assertion( + token, + key=key.public_key(), + options=VerifyClientAssertionOptions(current_time=NOW + 9, clock_tolerance_seconds=2), + ).jti + == "assertion-1" + ) + + +@pytest.mark.parametrize("not_before", [None, True, "1700000000", [], {}]) +def test_not_before_rejects_non_numeric_dates(not_before: object) -> None: + key = ed25519.Ed25519PrivateKey.generate() + payload = {**claims().to_wire(), "nbf": not_before} + token = jwt.encode(payload, key, algorithm="EdDSA", headers={"kid": payload["iss"]}) + with pytest.raises(AepAssertionError, match="invalid nbf"): + verify_client_assertion( + token, key=key.public_key(), options=VerifyClientAssertionOptions(current_time=NOW) + ) + + +@pytest.mark.parametrize("value", [float("nan"), float("inf"), -float("inf")]) +def test_jwt_boundaries_reject_non_json_constants(value: float) -> None: + key = ed25519.Ed25519PrivateKey.generate() + payload = {**claims().to_wire(), "example.future": {"value": value}} + token = jwt.encode(payload, key, algorithm="EdDSA", headers={"kid": payload["iss"]}) + with pytest.raises(AepAssertionError): + decode_jwt_unverified(token) + with pytest.raises(AepAssertionError): + verify_client_assertion( + token, key=key.public_key(), options=VerifyClientAssertionOptions(current_time=NOW) + ) + header_token = jwt.encode( + claims().to_wire(), key, algorithm="EdDSA", headers={"kid": payload["iss"], "future": value} + ) + with pytest.raises(AepAssertionError): + verify_client_assertion(header_token, key=key.public_key()) + with pytest.raises(ValueError): + sign_client_assertion( + ClientAssertionClaims.model_validate(payload), key=key, algorithm=SigningAlgorithm.EDDSA + ) diff --git a/tests/test_core_http.py b/tests/test_core_http.py index 94ad3a3..4f802d1 100644 --- a/tests/test_core_http.py +++ b/tests/test_core_http.py @@ -16,6 +16,8 @@ EnrollResponse, HttpRequest, HttpResponse, + OpenApiSecurityDisposition, + OpenApiSecurityResolution, OpenApiTrailingSlash, ProtectedResourceAuthorization, authorization_header_name, @@ -30,11 +32,13 @@ parse_authorization, render_authorization, require_service_origin_binding, + resolve_openapi_security, resolve_openapi_url, same_origin, select_did_web_public_jwk, ) from agent_enrollment_protocol.core.inspect import _origin +from agent_enrollment_protocol.core.openapi import SecurityRequirements from .test_core_models import inspect_document @@ -91,7 +95,7 @@ def test_authorization_rejects_ambiguous_or_invalid_values() -> None: with pytest.raises(AepAuthorizationError) as ambiguous: parse_authorization("AEP one, AEP two", AuthorizationCarrier.DEDICATED) assert ambiguous.value.code == "not_recognized" - for value in ("", "Digest value", "AEP", "AEP value"): + for value in ("", "Digest value", "AEP", "AEP ", " AEP value"): with pytest.raises(AepAuthorizationError): parse_authorization(value) with pytest.raises(AepAuthorizationError) as empty: @@ -105,6 +109,64 @@ def test_authorization_rejects_ambiguous_or_invalid_values() -> None: assert empty.value.code == "invalid_request" +def test_authorization_separator_follows_the_grammar() -> None: + # CORE-AUTH-001 spells the presentation as `"AEP" 1*SP compact-jws`, so repeated separators are + # grammar and a field value may carry trailing optional whitespace. + for value in ("AEP assertion", "AEP\tassertion", "AEP assertion ", "AEP assertion\t"): + assert parse_authorization(value).credentials == "assertion" + + +@pytest.mark.parametrize( + "value", + [ + "AEP one two", + "AEP one\ttwo", + "AEP compact.jws\x7f", + "AEP caf\u00e9", + ], +) +def test_authorization_refuses_a_value_carrying_more_than_one_credential(value: str) -> None: + # CORE-PR-018 forbids a combined value encoding more than one AEP credential, and CORE-AUTH-001 + # makes the credential visible US-ASCII with no space, so neither carrier may split one. + for carrier in AuthorizationCarrier: + with pytest.raises(AepAuthorizationError) as rejected: + parse_authorization(value, carrier) + assert rejected.value.code == "not_recognized" + + +@pytest.mark.parametrize( + "credentials", ["one two", "one\ttwo", "line\r\nInjected: header", "null\x00byte", "caf\u00e9"] +) +def test_render_authorization_refuses_credentials_that_are_not_a_field_value( + credentials: str, +) -> None: + # A credential carrying CR, LF, NUL, or a space would split or extend the rendered field, so + # the library refuses to render one rather than emitting it. + with pytest.raises(AepAuthorizationError) as rejected: + render_authorization( + ProtectedResourceAuthorization.model_construct( + carrier=AuthorizationCarrier.STANDARD, + scheme=AuthorizationScheme.AEP, + credentials=credentials, + ) + ) + assert rejected.value.code == "invalid_request" + + +@pytest.mark.parametrize( + "endpoint_base", + ["/aep/?next=", "/aep/#fragment", "/aep/../admin/", "/aep/./", "//evil.example/"], +) +def test_endpoint_base_must_be_able_to_carry_a_command_path(endpoint_base: str) -> None: + # CORE-HTTP-004 appends the command path with one `/` separator. A base carrying a query, a + # fragment, or a dot segment would put the command name in the query string or walk out of the + # advertised base. + with pytest.raises(ValueError, match="origin-relative"): + normalize_endpoint_base(endpoint_base) + with pytest.raises(ValueError, match="origin-relative"): + command_path(Command.ENROLL, endpoint_base) + + def test_inspect_version_origin_and_did_web_helpers() -> None: assert is_version_compatible("1.7") assert is_version_compatible("1.0", "1.7") @@ -299,5 +361,95 @@ def test_openapi_url_and_path_helpers() -> None: ) +_AEP = "aepScheme" +_KEY = "apiKeyScheme" +_ROOT: tuple[dict[str, tuple[str, ...]], ...] = ({_AEP: ()},) +_SUPPORTED = (_AEP, _KEY) + + +@pytest.mark.parametrize( + ("operation", "disposition"), + [ + (None, OpenApiSecurityDisposition.REQUIRED), + ((), OpenApiSecurityDisposition.PUBLIC), + (({}, {_AEP: ()}), OpenApiSecurityDisposition.OPTIONAL_ANONYMOUS), + (({_AEP: ()}, {_KEY: ()}), OpenApiSecurityDisposition.ALTERNATIVES), + (({_AEP: (), _KEY: ()},), OpenApiSecurityDisposition.ALL_REQUIRED), + ], +) +def test_openapi_security_inheritance( + operation: SecurityRequirements | None, disposition: OpenApiSecurityDisposition +) -> None: + # CORE-OAS-011: operation security replaces the root value, an empty array is public, an empty + # requirement object is an anonymous alternative, several objects are alternatives, and several + # schemes in one object are compound. + resolved = resolve_openapi_security( + root=_ROOT, operation=operation, supported_schemes=_SUPPORTED + ) + assert resolved.disposition is disposition + + +def test_openapi_security_is_public_without_any_requirement() -> None: + resolved = resolve_openapi_security(root=None, operation=None, supported_schemes=_SUPPORTED) + assert resolved.disposition is OpenApiSecurityDisposition.PUBLIC + assert resolved.satisfiable == () + + +def test_openapi_security_refuses_to_reduce_an_unsatisfiable_compound_requirement() -> None: + # CORE-OAS-013 makes a compound requirement unsupported when any member is, and CORE-OAS-015 + # forbids reducing it to the member the Agent does support. + resolved = resolve_openapi_security( + root=None, + operation=({_AEP: (), "mtlsScheme": ()},), + supported_schemes=(_AEP,), + ) + assert resolved.disposition is OpenApiSecurityDisposition.FALLBACK_LIVE_CHALLENGE + assert resolved.satisfiable == () + + +def test_openapi_security_selects_only_complete_alternatives() -> None: + # CORE-OAS-014 permits another complete alternative when a compound requirement is unsupported. + resolved = resolve_openapi_security( + root=None, + operation=({_AEP: (), "mtlsScheme": ()}, {_KEY: ()}), + supported_schemes=_SUPPORTED, + ) + assert resolved.disposition is OpenApiSecurityDisposition.ALTERNATIVES + assert resolved.satisfiable == ((_KEY,),) + + +def test_openapi_security_dispositions_carry_their_wire_values() -> None: + assert [item.value for item in OpenApiSecurityDisposition] == [ + "public", + "optional-anonymous", + "required", + "alternatives", + "all-required", + "fallback-live-challenge", + ] + assert OpenApiSecurityResolution(OpenApiSecurityDisposition.PUBLIC).satisfiable == () + + +@pytest.mark.parametrize( + "did", + [ + "did:web:exa%0Dmple.com", + "did:web:exa%0Ample.com", + "did:web:exa%09mple.com", + "did:web:%20example.com", + "did:web:example.com%20", + "did:web:example.com%7f", + ], +) +def test_did_web_host_must_be_visible_ascii(did: str) -> None: + # urlsplit strips CR, LF, and tab before parsing, so without this rule the host would be + # checked in its sanitized form and then concatenated into the document URL in its raw form, + # putting whitespace or a control character into a URL handed to an HTTP client. + with pytest.raises(ValueError, match="Invalid did:web identifier"): + did_web_document_url(did) + with pytest.raises(ValueError, match="Invalid did:web"): + did_web_origin(did) + + def test_imported_status_is_used() -> None: assert EnrollResponse(status=AgentStatus.ACTIVE).status is AgentStatus.ACTIVE diff --git a/tests/test_core_models.py b/tests/test_core_models.py index 2c6b739..28e1c17 100644 --- a/tests/test_core_models.py +++ b/tests/test_core_models.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import re import pytest from pydantic import ValidationError @@ -64,7 +65,94 @@ SigningAlgorithm, StatusResponse, ) -from agent_enrollment_protocol.core.models import AepModel, _is_mailbox +from agent_enrollment_protocol.core.models import AepModel, _is_mailbox, parse_rfc3339 + + +@pytest.mark.parametrize("field", ["contact_email", "contact.email"]) +def test_claim_constructor_rejects_explicit_null_for_alias_and_python_name(field: str) -> None: + with pytest.raises(ValidationError, match="omitted"): + ClaimValues.model_validate({field: None}) + + +@pytest.mark.parametrize( + "value", + [ + "ops@[192.0.2.1]", + "ops@[IPv6:2001:db8::1]", + "ops@[ipv6:2001:db8::1]", + "ops@[example-tag:literal]", + '"owner@name"@example.com', + '"owner name"@example.com', + '"a\\"b"@example.com', + "owner+tag@example.com", + "ops@[001.002.003.004]", + ], +) +def test_mailbox_accepts_smtp_quoted_parts_and_address_literals(value: str) -> None: + assert ClaimValues.model_validate({"contact_email": value}).contact_email == value + + +@pytest.mark.parametrize( + "value", + [ + "owner(comment)@example.com", + " owner@example.com", + "owner@example.com ", + "owner\t@example.com", + "owner@name@example.com", + '"unclosed@example.com', + '"a\\"@example.com', + '"a"b@example.com', + "ops@[256.0.0.1]", + "ops@[IPv6:bad]", + "ops@[IPv6:fe80::1%eth0]", + "ops@[tag:]", + "ops@[tag:has space]", + "ops@[missing]", + "ops@[127.0.0.1", + "ops@127.0.0.1]", + '"a.b"@-bad.example', + ], +) +def test_mailbox_rejects_header_syntax_and_invalid_literals(value: str) -> None: + with pytest.raises(ValidationError, match=r"contact.email"): + ClaimValues.model_validate({"contact_email": value}) + + +def test_rfc3339_accepts_case_variants_without_normalizing_invalid_offsets() -> None: + expected = parse_rfc3339("2026-01-01T12:00:00Z") + assert parse_rfc3339("2026-01-01t12:00:00z") == expected + assert parse_rfc3339("2026-01-01t12:00:00+00:00") == expected + for offset in ("+00:60", "-00:60", "+24:00", "-24:00"): + with pytest.raises(ValueError): + parse_rfc3339("2026-01-01T12:00:00" + offset) + + +@pytest.mark.parametrize("resource", ["https://resource.example/#", "http://localhost:9900/#"]) +def test_empty_resource_fragments_are_rejected(resource: str) -> None: + with pytest.raises(ValidationError, match="fragment"): + ClientAssertionClaims.model_validate_json( + json.dumps( + { + "aud": "did:web:s.example", + "iss": "did:web:a.example", + "sub": "did:web:a.example", + "iat": 1, + "exp": 61, + "jti": "id", + "op": "authenticate", + "resource": resource, + } + ), + context={"allow_insecure_loopback": True}, + ) + with pytest.raises(ValidationError, match="fragment"): + PlatformSignRequest( + service_did="did:web:s.example", + jti="id", + op=AssertionOperation.AUTHENTICATE, + resource=resource, + ) def test_registered_protocol_constants() -> None: @@ -183,6 +271,12 @@ def test_inspect_and_command_models_are_immutable_and_forward_compatible() -> No "URI-reference", ), (lambda: HttpConfiguration(endpoint_base="https://example.com"), "origin-relative"), + # CORE-HTTP-004 appends the command path to endpoint_base with one `/` separator, so a base + # carrying a query, a fragment, or a dot segment cannot carry a command. + (lambda: HttpConfiguration(endpoint_base="/aep/?next="), "origin-relative"), + (lambda: HttpConfiguration(endpoint_base="/aep/#fragment"), "origin-relative"), + (lambda: HttpConfiguration(endpoint_base="/aep/../admin/"), "origin-relative"), + (lambda: HttpConfiguration(endpoint_base="/aep/./"), "origin-relative"), (lambda: Identity(methods=("DID:web",)), "identifier"), (lambda: ServiceIdentity(did="https://example.com"), "DID"), ( @@ -319,6 +413,62 @@ def test_claim_validation_boundaries() -> None: ) +@pytest.mark.parametrize( + "value", + [ + "owner@ex\u00e1mple.com", + "owner@ex\u0663mple.com", + "\u00f3wner@example.com", + "owner@\u0435xample.com", + ], +) +def test_mailbox_is_ascii(value: str) -> None: + # CLM-CAT-001 binds contact.email to the RFC 5321 Mailbox rule as the JSON Schema `email` + # format references it, which is ASCII; a Unicode mailbox is the separate `idn-email` format. + # Accepting one would let a homograph domain stand in for the ASCII domain it resembles. + assert not _is_mailbox(value) + with pytest.raises(ValidationError, match=re.escape("contact.email")): + ClaimValues.model_validate({"contact.email": value}) + + +@pytest.mark.parametrize( + "url", + [ + "https://user:password@platform.example/agents/1/did.json", + "https://user@platform.example/agents/1/did.json", + ], +) +def test_https_urls_refuse_user_information(url: str) -> None: + # User information carries credentials the document is not bound to and makes the authority + # ambiguous to a reader, so it is refused here as it is on every other URL this package takes. + with pytest.raises(ValidationError, match="user info"): + PlatformAgentIdentity( + agent_did="did:web:platform.example:agents:1", + agent_identity_id="pai_1", + created_at="2026-01-01T00:00:00Z", + did_document_url=url, + key_id="did:web:platform.example:agents:1#key-1", + service_did="did:web:api.service.example", + signing_algorithms=(SigningAlgorithm.ES256,), + status=ManagedAgentStatus.ACTIVE, + updated_at="2026-01-01T00:00:00Z", + ) + + +def test_assertion_resource_refuses_user_information() -> None: + with pytest.raises(ValidationError, match="user info"): + ClientAssertionClaims( + aud="did:web:api.service.example", + exp=1_000_300, + iat=1_000_000, + iss="did:web:agent.example", + jti="assertion-1", + op=AssertionOperation.AUTHENTICATE, + resource="https://user@api.service.example/orders/1", + sub="did:web:agent.example", + ) + + def test_lifecycle_and_command_wire_models() -> None: request = EnrollRequest(agent_did="did:web:agent.example", idempotency_key="request-1") assert request.to_wire() == { diff --git a/tests/test_core_validation.py b/tests/test_core_validation.py index 05e8861..fd562f4 100644 --- a/tests/test_core_validation.py +++ b/tests/test_core_validation.py @@ -1,21 +1,39 @@ from __future__ import annotations import json +import time import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from agent_enrollment_protocol.core import ( AepValidationError, + AssertionOperation, Authentication, + ClaimValues, + ClientAssertionClaims, EnrollRequest, + InspectClaims, + OpenApiAepSecurityScheme, PlatformSignCompleted, PlatformSignPending, + SigningAlgorithm, ValidationIssue, + VerifyClientAssertionOptions, parse_json_model, parse_platform_sign_response, + sign_client_assertion, validate_json_model, + verify_client_assertion, ) from agent_enrollment_protocol.core.errors import validation_issues +from agent_enrollment_protocol.service import ( + AssertionVerificationContext, + CommandOptions, + MemoryEnrollmentStore, + Service, + ServiceOptions, +) def test_parse_and_validate_json_models() -> None: @@ -67,3 +85,116 @@ def test_validation_issue_fallback_and_paths() -> None: assert result.issues[0].path == "$.agent_did" indexed = validate_json_model('{"methods":[1]}', Authentication, "authentication advertisement") assert indexed.issues[0].path == "$.methods[0]" + + +def test_wire_parsing_preserves_unknown_claims_without_using_python_aliases() -> None: + parsed = parse_json_model( + '{"agent_did":"did:web:agent.example","claims":{"contact_email":"a@example.com"}}', + EnrollRequest, + "Enroll request", + ) + assert parsed.claims is not None + assert parsed.claims.contact_email is None + assert parsed.claims.to_wire() == {"contact_email": "a@example.com"} + unknown_null = parse_json_model('{"contact_email":null}', ClaimValues, "claims") + assert unknown_null.to_wire() == {"contact_email": None} + supplied = parse_json_model('{"contact.email":"a@example.com"}', ClaimValues, "claims") + assert supplied.contact_email == "a@example.com" + assert ( + ClaimValues.model_validate({"contact_email": "a@example.com"}).to_wire() + == supplied.to_wire() + ) + both = parse_json_model( + '{"contact.email":"a@example.com","contact_email":"b@example.com"}', + ClaimValues, + "claims", + ) + assert both.to_wire() == {"contact.email": "a@example.com", "contact_email": "b@example.com"} + with pytest.raises(AepValidationError): + parse_json_model('{"authentication_method":"aep-jwt"}', OpenApiAepSecurityScheme, "scheme") + assert ( + parse_json_model( + '{"x-aep-authentication-method":"aep-jwt"}', OpenApiAepSecurityScheme, "scheme" + ).authentication_method + == "aep-jwt" + ) + + +@pytest.mark.parametrize("constant", ["NaN", "Infinity", "-Infinity"]) +def test_wire_parsers_reject_non_json_constants(constant: str) -> None: + with pytest.raises(AepValidationError, match="JSON"): + parse_json_model( + '{"agent_did":"did:web:agent.example","claims":{"example.value":' + constant + "}}", + EnrollRequest, + "Enroll request", + ) + with pytest.raises(AepValidationError, match="JSON"): + parse_platform_sign_response('{"status":"pending","future":' + constant + "}") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("requested", "sent", "expected_status"), + [ + ("contact.email", "contact_email", 422), + ("contact.email", "contact.email", 200), + ("contact_email", "contact_email", 200), + ], +) +async def test_service_preserves_wire_claim_names_with_real_assertions( + requested: str, sent: str, expected_status: int +) -> None: + key = Ed25519PrivateKey.generate() + + class Verifier: + async def verify( + self, assertion: str, context: AssertionVerificationContext + ) -> ClientAssertionClaims: + return verify_client_assertion( + assertion, + key=key.public_key(), + options=VerifyClientAssertionOptions( + audience=context.service_did, + operation=context.operation, + current_time=int(context.current_time.timestamp()), + ), + ) + + store = MemoryEnrollmentStore() + service = Service( + ServiceOptions( + service_did="did:web:service.example", + identity_methods=("did:web",), + verifier=Verifier(), + claims=InspectClaims(required=(requested,)), + enrollment_store=store, + ) + ) + now = int(time.time()) + assertion = sign_client_assertion( + ClientAssertionClaims( + aud="did:web:service.example", + iss="did:web:agent.example", + sub="did:web:agent.example", + iat=now, + exp=now + 60, + jti="assertion-1", + op=AssertionOperation.ENROLL, + ), + key=key, + algorithm=SigningAlgorithm.EDDSA, + ) + result = await service.enroll( + json.dumps( + {"agent_did": "did:web:agent.example", "claims": {sent: "a@example.com"}} + ).encode(), + CommandOptions(client_assertion=assertion, idempotency_key="request-1"), + ) + assert result.status == expected_status + record = await store.find("did:web:agent.example") + if expected_status == 200: + assert record is not None + assert record.claims is not None + assert record.claims.to_wire() == {sent: "a@example.com"} + else: + assert record is None diff --git a/tests/test_examples.py b/tests/test_examples.py new file mode 100644 index 0000000..9d61538 --- /dev/null +++ b/tests/test_examples.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import importlib.util +from dataclasses import replace +from pathlib import Path +from types import ModuleType +from typing import Any + +import pytest + +from agent_enrollment_protocol.agent import ServiceSession +from agent_enrollment_protocol.core import AepAssertionError, AgentStatus, ManagedAgentStatus +from agent_enrollment_protocol.platform import Platform + + +def example(name: str) -> ModuleType: + path = Path(__file__).resolve().parent.parent / "examples" / f"{name}.py" + specification = importlib.util.spec_from_file_location(f"aep_example_{name}", path) + assert specification is not None and specification.loader is not None + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + return module + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["enroll", "status"]) +async def test_agent_example_fails_when_agent_is_not_active( + monkeypatch: pytest.MonkeyPatch, method: str +) -> None: + module = example("agent_service") + original = getattr(ServiceSession, method) + + async def inactive(self: ServiceSession, *args: Any, **kwargs: Any) -> Any: + result = await original(self, *args, **kwargs) + return replace( + result, body=result.body.model_copy(update={"status": AgentStatus.SUSPENDED}) + ) + + monkeypatch.setattr(ServiceSession, method, inactive) + with pytest.raises(RuntimeError, match=r"active Agent|become active"): + await module.main() + + +@pytest.mark.asyncio +async def test_platform_example_fails_when_discovery_fails(monkeypatch: pytest.MonkeyPatch) -> None: + module = example("hosted_platform") + original = Platform.discovery + + def unavailable(self: Platform) -> Any: + return replace(original(self), status=503, body=None) + + monkeypatch.setattr(Platform, "discovery", unavailable) + with pytest.raises(RuntimeError, match="discovery metadata"): + await module.main() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["list", "update_identity", "sign"]) +async def test_platform_example_rejects_incorrect_success_results( + monkeypatch: pytest.MonkeyPatch, method: str +) -> None: + module = example("hosted_platform") + original = getattr(Platform, method) + + async def incorrect(self: Platform, *args: Any, **kwargs: Any) -> Any: + result = await original(self, *args, **kwargs) + changes: dict[str, object] + if method == "list": + changes = {"count": "0", "data": ()} + elif method == "update_identity": + changes = {"status": ManagedAgentStatus.ACTIVE} + else: + changes = {"client_assertion": "invalid.signature.value"} + return replace(result, body=result.body.model_copy(update=changes)) + + monkeypatch.setattr(Platform, method, incorrect) + error = AepAssertionError if method == "sign" else RuntimeError + with pytest.raises(error): + await module.main() + + +@pytest.mark.asyncio +async def test_platform_example_detects_signing_after_suspension( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = example("hosted_platform") + original = Platform.sign + completed: list[Any] = [] + + async def ignores_suspension(self: Platform, *args: Any, **kwargs: Any) -> Any: + if completed: + return completed[0] + result = await original(self, *args, **kwargs) + completed.append(result) + return result + + monkeypatch.setattr(Platform, "sign", ignores_suspension) + with pytest.raises(RuntimeError, match="suspended identity"): + await module.main() diff --git a/tests/test_platform.py b/tests/test_platform.py index e7f416b..31f1e23 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -335,17 +335,33 @@ async def pending(*_: Any) -> PlatformResult[Any]: context("pending"), ) assert result.status == 202 - with pytest.raises(ValueError, match="configured maximum"): - await platform.sign( - identity_id, - PlatformSignRequest( - jti="long", - lifetime_seconds="61", - op=AssertionOperation.STATUS, - service_did=SERVICE_DID, - ), - context("long"), - ) + # PLT-SIGN-011: a caller-supplied lifetime above the configured maximum is a rejected request, + # not an unhandled failure, so it is answered as Problem Details before the idempotency store. + rejected = await platform.sign( + identity_id, + PlatformSignRequest( + jti="long", + lifetime_seconds="61", + op=AssertionOperation.STATUS, + service_did=SERVICE_DID, + ), + context("long"), + ) + assert rejected.status == 400 + assert rejected.problem is not None + assert rejected.problem.code == "invalid_request" + # An unauthorized caller reaches the same answer rather than an exception escaping the API. + unauthorized = await platform.sign( + "pai_unknown", + PlatformSignRequest( + jti="long", + lifetime_seconds="61", + op=AssertionOperation.STATUS, + service_did=SERVICE_DID, + ), + context("unauthorized"), + ) + assert unauthorized.status == 400 @pytest.mark.asyncio diff --git a/tests/test_platform_boundaries.py b/tests/test_platform_boundaries.py index 5f7d231..77f4523 100644 --- a/tests/test_platform_boundaries.py +++ b/tests/test_platform_boundaries.py @@ -21,9 +21,12 @@ PlatformVerificationResponse, ProblemDetails, SigningAlgorithm, + did_web_document_url, sign_client_assertion, ) from agent_enrollment_protocol.platform import ( + AuthorizationOperation, + AuthorizationRequest, DidVerificationMethod, IdempotentOperation, IdentityListQuery, @@ -38,11 +41,13 @@ PlatformIdempotencyState, PlatformResult, RequestContext, + SignHandlerInput, StoredResponse, create_did_document, create_service_scoped_agent_did, ) from agent_enrollment_protocol.platform.document import create_discovery_document, render_did_url +from agent_enrollment_protocol.platform.platform import _lifecycle_error from tests.test_platform import ( NOW, SERVICE_DID, @@ -116,6 +121,45 @@ def test_did_helpers() -> None: create_did_document(identity, invalid) +@pytest.mark.parametrize( + "jwk", + [ + {"crv": "P-256", "d": "private", "kty": "EC", "x": "AQ", "y": "AQ"}, + {"crv": "Ed25519", "d": "private", "kty": "OKP", "x": "AQ"}, + {"kty": "RSA", "n": "AQ", "e": "AQ", "p": "private"}, + {"kty": "RSA", "n": "AQ", "e": "AQ", "qi": "private"}, + {"k": "symmetric", "kty": "oct"}, + ], +) +def test_did_document_refuses_private_key_material(jwk: dict[str, str]) -> None: + # PLT-DID-002: the DID document is served to anyone who resolves the Agent DID, so a key store + # that returned a JWK still carrying its private members would publish the signing key itself. + identity = record() + method = DidVerificationMethod( + controller=identity.agent_did, + id=identity.key_id, + public_key_jwk=jwk, + type="JsonWebKey2020", + ) + with pytest.raises(ValueError, match="only public key material"): + create_did_document(identity, method) + + +@pytest.mark.parametrize( + ("status", "code"), + [ + (ManagedAgentStatus.TERMINATED, "identity_terminated"), + (ManagedAgentStatus.SUSPENDED, "identity_suspended"), + (ManagedAgentStatus.REVOKED, "identity_suspended"), + (ManagedAgentStatus.ACTIVE, "identity_unavailable"), + ], +) +def test_lifecycle_errors_use_registered_codes(status: ManagedAgentStatus, code: str) -> None: + # CORE-ERR-009 registers identity_suspended, identity_terminated and identity_unavailable, and + # no identity_revoked or identity_active, so a revoked identity reports the suspended code. + assert _lifecycle_error(status) == code + + def test_discovery_boundaries() -> None: base = options().discovery for field in ( @@ -289,6 +333,147 @@ def test_platform_configuration_boundaries(change: dict[str, Any], message: str) Platform(options(**change)) +@pytest.mark.parametrize( + "template", + [ + "https://other.example/agents/{agent_did_id}/did.json", + "https://platform.example/other/{agent_did_id}/did.json", + "https://platform.example/agents/{agent_did_id}/key.json", + ], +) +def test_platform_rejects_did_templates_that_do_not_resolve_the_minted_did(template: str) -> None: + with pytest.raises(ValueError, match="must match"): + Platform(options(did_url_template=template)) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "host,prefix", [("platform.example", "a/b"), ("platform.example:8443", "")] +) +async def test_custom_did_configuration_matches_ordinary_resolution(host: str, prefix: str) -> None: + path = f"{prefix}/" if prefix else "" + platform = Platform( + options( + did_host=host, + did_path_prefix=prefix, + did_url_template=f"https://{host}/{path}{{agent_did_id}}/did.json", + ) + ) + identity_id, did = await provisioned(platform) + identity = await platform.get_identity(identity_id, context(key=None)) + assert identity.body is not None + assert identity.body.did_document_url == did_web_document_url(did) + document = await platform.did_document("agent-one") + assert document.body is not None and document.body["id"] == did + + +@pytest.mark.asyncio +async def test_cached_private_results_reauthorize_without_repeating_operations() -> None: + class CountedKeys(KeyStore): + def __init__(self) -> None: + super().__init__() + self.signatures = 0 + + async def sign(self, identity: IdentityRecord, claims: ClientAssertionClaims) -> str: + self.signatures += 1 + return await super().sign(identity, claims) + + authorizer = AllowAuthorizer() + keys = CountedKeys() + current = [NOW] + platform = Platform( + options( + authorizer=authorizer, + key_store=keys, + clock=lambda: current[0], + discovery=replace(options().discovery, hosted_verification_endpoint="/verify"), + hosted_verification=True, + replay_store=MemoryReplayStore(), + ) + ) + provision_request = PlatformProvisionRequest(service_did=SERVICE_DID) + provision_context = context("provision") + provision_result = await platform.provision(provision_request, provision_context) + assert provision_result.body is not None + identity_id = provision_result.body.agent_identity_id + sign_request = PlatformSignRequest( + jti="cached", op=AssertionOperation.STATUS, service_did=SERVICE_DID + ) + sign_context = context("sign") + sign_result = await platform.sign(identity_id, sign_request, sign_context) + assert isinstance(sign_result.body, PlatformSignCompleted) + verify_request = PlatformVerificationRequest( + client_assertion=sign_result.body.client_assertion, + op=AssertionOperation.STATUS, + service_did=SERVICE_DID, + ) + verify_context = context("verify") + verify_result = await platform.verify(verify_request, verify_context) + assert verify_result.body is not None and verify_result.body.verified + before = len(authorizer.requests) + assert await platform.provision(provision_request, provision_context) == provision_result + assert await platform.sign(identity_id, sign_request, sign_context) == sign_result + assert await platform.verify(verify_request, verify_context) == verify_result + assert len(authorizer.requests) == before + 3 + + authorizer.allowed = False + before = len(authorizer.requests) + assert (await platform.provision(provision_request, provision_context)).status == 404 + assert (await platform.sign(identity_id, sign_request, sign_context)).status == 404 + denied = await platform.verify(verify_request, verify_context) + assert denied.body is not None + assert denied.body.to_wire() == { + "reason": "not_recognized", + "service_did": SERVICE_DID, + "verified": False, + } + assert len(authorizer.requests) == before + 3 + + authorizer.allowed = True + current[0] += timedelta(minutes=10) + await platform.update_identity( + identity_id, + PlatformLifecycleRequest(status=ManagedAgentStatus.SUSPENDED), + context(key=None), + ) + assert await platform.provision(provision_request, provision_context) == provision_result + assert await platform.sign(identity_id, sign_request, sign_context) == sign_result + assert await platform.verify(verify_request, verify_context) == verify_result + assert keys.signatures == 1 and len(keys.created) == 1 + fresh = await platform.sign(identity_id, sign_request, context("fresh")) + assert fresh.status == 403 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("missing", [{"principal": ""}, {"idempotency_key": None}]) +async def test_private_sign_and_verify_require_authenticated_retry_context( + missing: dict[str, Any], +) -> None: + platform = Platform( + options( + discovery=replace(options().discovery, hosted_verification_endpoint="/verify"), + hosted_verification=True, + replay_store=MemoryReplayStore(), + ) + ) + identity_id, _ = await provisioned(platform) + caller = replace(context("missing"), **missing) + signed = await platform.sign( + identity_id, + PlatformSignRequest(jti="missing", op=AssertionOperation.STATUS, service_did=SERVICE_DID), + caller, + ) + verified = await platform.verify( + PlatformVerificationRequest( + client_assertion="a.b.c", op=AssertionOperation.STATUS, service_did=SERVICE_DID + ), + caller, + ) + assert signed.status == verified.status == 400 + assert signed.problem is not None and signed.problem.code == "invalid_request" + assert verified.problem is not None and verified.problem.code == "invalid_request" + + def test_unsupported_platform_algorithm() -> None: with pytest.raises(ValueError, match="not supported"): Platform(options(signing_algorithms=(cast(SigningAlgorithm, "RS256"),))) @@ -405,17 +590,18 @@ async def update_status( assert isinstance(prefixed_result.body, PlatformAgentIdentity) assert prefixed_result.body.agent_identity_id == "pai_existing" - for change in ( - {"identifier": lambda: ""}, - {"agent_did_id_generator": lambda: ""}, - {"clock": lambda: NOW.replace(tzinfo=None)}, - ): + for change in ({"identifier": lambda: ""}, {"agent_did_id_generator": lambda: ""}): invalid = Platform(options(**change)) with pytest.raises(ValueError): await invalid.provision( PlatformProvisionRequest(service_did=SERVICE_DID), context("invalid") ) + # The Discovery Last-Modified validator is stamped when the document is built, so a clock that + # cannot produce one is refused at construction rather than at the first command. + with pytest.raises(ValueError, match="offset-aware"): + Platform(options(clock=lambda: NOW.replace(tzinfo=None))) + class MismatchedStore(MemoryIdentityStore): async def find_or_create( self, principal: str, service_did: str, factory: Any @@ -491,9 +677,155 @@ async def list(self, principal: str, query: IdentityListQuery) -> IdentityListRe await malformed_list.list(IdentityListQuery(), context(key=None)) +@pytest.mark.asyncio +@pytest.mark.parametrize("offset", [-86400, 0, 86400]) +@pytest.mark.parametrize("lowercase", [False, True]) +async def test_custom_signing_timestamps_use_core_parsing_and_the_platform_clock( + offset: int, lowercase: bool +) -> None: + keys = KeyStore() + + async def handler(value: SignHandlerInput, caller: RequestContext) -> PlatformResult[Any]: + result = await _completed_sign_result(keys, value, NOW + timedelta(seconds=offset)) + if lowercase: + assert result.body is not None + return replace( + result, + body=result.body.model_copy( + update={ + "issued_at": result.body.issued_at.lower(), + "expires_at": result.body.expires_at.lower(), + } + ), + ) + return result + + platform = Platform(options(key_store=keys, sign_handler=handler)) + identity_id, _ = await provisioned(platform) + request = PlatformSignRequest( + jti="custom-time", + op=AssertionOperation.STATUS, + service_did=SERVICE_DID, + lifetime_seconds="30", + ) + if offset: + with pytest.raises(ValueError, match="invalid client assertion"): + await platform.sign(identity_id, request, context("custom-time")) + else: + result = await platform.sign(identity_id, request, context("custom-time")) + assert result.status == 200 and isinstance(result.body, PlatformSignCompleted) + assert result.body.issued_at == ( + "2026-01-02t03:04:05z" if lowercase else "2026-01-02T03:04:05Z" + ) + + +async def _completed_sign_result( + keys: KeyStore, value: SignHandlerInput, issued: datetime +) -> PlatformResult[PlatformSignCompleted]: + claims = ClientAssertionClaims( + aud=SERVICE_DID, + exp=int(issued.timestamp()) + 30, + iat=int(issued.timestamp()), + iss=value.identity.agent_did, + sub=value.identity.agent_did, + jti=value.request.jti, + op=value.request.op, + ) + return PlatformResult( + 200, + PlatformSignCompleted( + status="completed", + agent_did=value.identity.agent_did, + client_assertion=await keys.sign(value.identity, claims), + expires_at=(issued + timedelta(seconds=30)).isoformat().replace("+00:00", "Z"), + issued_at=issued.isoformat().replace("+00:00", "Z"), + jti=value.request.jti, + service_did=SERVICE_DID, + ), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("handled", [False, True]) +async def test_signing_uses_the_platform_clock_after_the_custom_handler(handled: bool) -> None: + current = [NOW] + keys = KeyStore() + + async def handler( + value: SignHandlerInput, caller: RequestContext + ) -> PlatformResult[PlatformSignCompleted] | None: + current[0] += timedelta(seconds=2) + return await _completed_sign_result(keys, value, current[0]) if handled else None + + platform = Platform(options(key_store=keys, sign_handler=handler, clock=lambda: current[0])) + identity_id, _ = await provisioned(platform) + result = await platform.sign( + identity_id, + PlatformSignRequest( + jti="delayed", + op=AssertionOperation.STATUS, + service_did=SERVICE_DID, + lifetime_seconds="30", + ), + replace(context("delayed"), current_time=None), + ) + assert result.status == 200 and isinstance(result.body, PlatformSignCompleted) + assert result.body.issued_at == "2026-01-02T03:04:07Z" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("pending", [False, True]) +@pytest.mark.parametrize( + "content_type,headers,problem,accepted", + [ + ("application/aep+json", {}, None, True), + ("Application/Aep+Json; charset=utf-8", {"X-Request-ID": "one"}, None, True), + ("text/plain", {}, None, False), + ("application/aep+json", {"rEtRy-AfTeR": "300"}, None, False), + ( + "application/aep+json", + {}, + ProblemDetails(type="urn:aep:error:x", title="x", code="x", status=400), + False, + ), + ], +) +async def test_custom_signing_response_metadata_matches_the_protocol( + pending: bool, + content_type: str, + headers: dict[str, str], + problem: ProblemDetails | None, + accepted: bool, +) -> None: + keys = KeyStore() + + async def handler(value: SignHandlerInput, caller: RequestContext) -> PlatformResult[Any]: + result: PlatformResult[Any] = ( + PlatformResult(202, PlatformSignPending(status="pending", retry_after_seconds="5")) + if pending + else await _completed_sign_result(keys, value, NOW) + ) + return replace(result, content_type=content_type, headers=headers, problem=problem) + + platform = Platform(options(key_store=keys, sign_handler=handler)) + identity_id, _ = await provisioned(platform) + request = PlatformSignRequest( + jti="metadata", op=AssertionOperation.STATUS, service_did=SERVICE_DID, lifetime_seconds="30" + ) + if accepted: + result = await platform.sign(identity_id, request, context("metadata")) + assert result.status == (202 if pending else 200) + assert result.headers == headers and result.content_type == content_type + assert await platform.sign(identity_id, request, context("metadata")) == result + else: + with pytest.raises(ValueError, match="response must"): + await platform.sign(identity_id, request, context("metadata")) + + @pytest.mark.asyncio async def test_sign_handler_validation() -> None: cases: tuple[PlatformResult[Any], ...] = ( + PlatformResult(200), PlatformResult( 200, PlatformSignPending(status="pending", retry_after_seconds="1"), @@ -664,6 +996,74 @@ async def execute( await platform.provision(PlatformProvisionRequest(service_did=SERVICE_DID), context()) +@pytest.mark.asyncio +async def test_trusted_service_verification_does_not_grant_identity_management() -> None: + class TrustedServiceAuthorizer: + async def authorize(self, request: AuthorizationRequest, caller: RequestContext) -> bool: + if request.operation is AuthorizationOperation.VERIFY: + return caller.principal == "trusted-service" + return bool(caller.principal) + + platform = Platform( + options( + authorizer=TrustedServiceAuthorizer(), + discovery=replace(options().discovery, hosted_verification_endpoint="/verify"), + hosted_verification=True, + replay_store=MemoryReplayStore(), + ) + ) + identity_id, agent_did = await provisioned(platform) + sign_request = PlatformSignRequest( + jti="trusted-service", op=AssertionOperation.STATUS, service_did=SERVICE_DID + ) + signed = await platform.sign(identity_id, sign_request, context("sign")) + assert isinstance(signed.body, PlatformSignCompleted) + request = PlatformVerificationRequest( + client_assertion=signed.body.client_assertion, + op=AssertionOperation.STATUS, + service_did=SERVICE_DID, + ) + denied = await platform.verify(request, context("denied", principal="untrusted-service")) + assert denied.body is not None + assert denied.body.to_wire() == { + "verified": False, + "reason": "not_recognized", + "service_did": SERVICE_DID, + } + caller = context("verify", principal="trusted-service") + mismatched = await platform.verify( + request.model_copy(update={"service_did": "did:web:other.example"}), caller + ) + assert mismatched.body is not None and not mismatched.body.verified + verified = await platform.verify(request, replace(caller, idempotency_key="valid")) + assert verified.body is not None and verified.body.verified + assert verified.body.agent_did == agent_did + assert verified.body.agent_identity_id == identity_id + replayed = await platform.verify(request, replace(caller, idempotency_key="replay")) + assert replayed.body is not None and not replayed.body.verified + anonymous = await platform.verify(request, replace(caller, principal="")) + assert anonymous.status == 400 + + assert (await platform.get_identity(identity_id, caller)).status == 404 + listed = await platform.list(IdentityListQuery(), caller) + assert listed.status == 200 + assert listed.body is not None and listed.body.count == "0" and listed.body.total == "0" + assert ( + await platform.sign( + identity_id, sign_request, replace(caller, idempotency_key="cannot-sign") + ) + ).status == 404 + assert ( + await platform.update_identity( + identity_id, + PlatformLifecycleRequest(status=ManagedAgentStatus.SUSPENDED), + caller, + ) + ).status == 404 + identity = await platform.get_identity(identity_id, context(key=None)) + assert identity.body is not None and identity.body.status is ManagedAgentStatus.ACTIVE + + @pytest.mark.asyncio async def test_hosted_verification_rejects_unrecognized_assertions() -> None: keys = KeyStore() @@ -686,6 +1086,7 @@ async def test_hosted_verification_rejects_unrecognized_assertions() -> None: ) assert isinstance(signed.body, PlatformSignCompleted) assertion = signed.body.client_assertion + authorizer.allowed = False wrong_principal = await platform.verify( PlatformVerificationRequest( client_assertion=assertion, op=AssertionOperation.STATUS, service_did=SERVICE_DID @@ -694,7 +1095,6 @@ async def test_hosted_verification_rejects_unrecognized_assertions() -> None: ) assert isinstance(wrong_principal.body, PlatformVerificationResponse) assert wrong_principal.body.verified is False - authorizer.allowed = False denied = await platform.verify( PlatformVerificationRequest( client_assertion=assertion, op=AssertionOperation.STATUS, service_did=SERVICE_DID diff --git a/tests/test_protected_resource.py b/tests/test_protected_resource.py new file mode 100644 index 0000000..c58a0cf --- /dev/null +++ b/tests/test_protected_resource.py @@ -0,0 +1,522 @@ +from __future__ import annotations + +import asyncio +import json +from dataclasses import replace +from typing import Any + +import pytest + +from agent_enrollment_protocol.agent import ( + FetchProtectedResourceOptions, + fetch_protected_resource, +) +from agent_enrollment_protocol.core import ( + AepValidationError, + HttpRequest, + HttpResponse, + InspectDocument, +) + +from .test_agent import QueueTransport, configured_agent, inspect_response +from .test_core_models import inspect_document + +URL = "https://api.example.com/private" + + +def challenge( + *, + did: str = "did:web:api.example.com", + inspect: str = "https://api.example.com/.well-known/aep", +) -> HttpResponse: + return HttpResponse( + status=401, headers={"WWW-Authenticate": f'AEP service_did="{did}", inspect="{inspect}"'} + ) + + +def options( + *responses: HttpResponse, document: InspectDocument | None = None, **changes: Any +) -> FetchProtectedResourceOptions: + if document is None: + data = inspect_document().to_wire() + data["http"] = {} + document = InspectDocument.model_validate_json(json.dumps(data)) + agent, _ = configured_agent( + QueueTransport(inspect_response(document, cache_control="max-age=600")), QueueTransport() + ) + return FetchProtectedResourceOptions( + agent=agent, + request=HttpRequest(method="GET", url=URL), + transport=QueueTransport(*responses), + **changes, + ) + + +@pytest.mark.asyncio +async def test_authentication_and_same_origin_redirect_sign_each_target() -> None: + configuration = options( + challenge(), + HttpResponse(status=307, headers={"Location": "/second"}), + challenge(), + HttpResponse(status=200, body=b"ok"), + carrier="AEP-Authorization", + ) + result = await fetch_protected_resource(configuration) + assert result.body == b"ok" + transport = configuration.transport + assert isinstance(transport, QueueTransport) + assert len(transport.requests) == 4 + tokens = [ + request.headers["AEP-Authorization"].split()[1] for request in transport.requests[1::2] + ] + from agent_enrollment_protocol.core import decode_jwt_unverified + + claims = [decode_jwt_unverified(token)[1] for token in tokens] + assert [claim["resource"] for claim in claims] == [URL, "https://api.example.com/second"] + assert claims[0]["jti"] != claims[1]["jti"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [200, 401, 403, 500]) +async def test_unrelated_response_does_not_authenticate(status: int) -> None: + configuration = options( + HttpResponse(status=status, headers={"WWW-Authenticate": "Bearer realm=other"}) + ) + assert (await fetch_protected_resource(configuration)).status == status + transport = configuration.transport + assert isinstance(transport, QueueTransport) + assert len(transport.requests) == 1 + + +@pytest.mark.asyncio +async def test_cross_origin_redirect_drops_all_caller_headers_and_requires_challenge() -> None: + configuration = options( + challenge(), + HttpResponse(status=307, headers={"Location": "https://other.example/private"}), + HttpResponse(status=200, body=b"anonymous"), + carrier="AEP-Authorization", + additional_authentication_headers={"Authorization": "Payment secret", "X-Custom": "secret"}, + ) + configuration = replace( + configuration, + request=HttpRequest( + method="POST", + url=URL, + headers={"X-Caller": "private", "Authorization": "Bearer secret", "Cookie": "secret"}, + body=b"payload", + ), + ) + # The second origin has its own binding and its own Inspect response. + inspection = configuration.agent._inspect_transport + assert isinstance(inspection, QueueTransport) + other = inspect_document().to_wire() + other["service"] = {"did": "did:web:other.example"} + other["http"] = {} + inspection.responses.append( + inspect_response( + InspectDocument.model_validate_json(json.dumps(other)), cache_control="max-age=600" + ) + ) + assert (await fetch_protected_resource(configuration)).body == b"anonymous" + transport = configuration.transport + assert isinstance(transport, QueueTransport) + assert dict(transport.requests[0].headers) == {"X-Caller": "private"} + assert transport.requests[1].headers["Authorization"] == "Payment secret" + assert dict(transport.requests[2].headers) == {} + assert all(request.body == b"payload" for request in transport.requests) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "value", + [ + "AEP", + 'AEP service_did="x", service_did="y", inspect="https://api.example.com"', + 'AEP service_did="x", inspect="https://api.example.com",', + 'AEP service_did="x" inspect="https://api.example.com"', + 'AEP reason="missing"', + ], +) +async def test_malformed_challenge_does_not_authenticate(value: str) -> None: + configuration = options(HttpResponse(status=401, headers={"WWW-Authenticate": value})) + assert (await fetch_protected_resource(configuration)).status == 401 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "response", + [challenge(did="did:web:other.example"), challenge(inspect="https://other.example/aep")], +) +async def test_challenge_binding_is_required(response: HttpResponse) -> None: + with pytest.raises(ValueError, match=r"match|origin"): + await fetch_protected_resource(options(response)) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "location", + [ + "http://api.example.com/private", + "https://@api.example.com/private", + "https://api.example.com/private#", + "ftp://api.example.com/private", + ], +) +async def test_invalid_redirects_are_never_followed(location: str) -> None: + with pytest.raises(ValueError): + await fetch_protected_resource( + options(HttpResponse(status=307, headers={"Location": location})) + ) + + +@pytest.mark.asyncio +async def test_missing_location_and_redirect_limit() -> None: + with pytest.raises(ValueError, match="Location"): + await fetch_protected_resource(options(HttpResponse(status=302))) + with pytest.raises(ValueError, match="redirects"): + await fetch_protected_resource( + options(HttpResponse(status=308, headers={"Location": "/again"}), max_redirects=0) + ) + + +@pytest.mark.asyncio +async def test_explicit_missing_credential_does_not_grant() -> None: + with pytest.raises(ValueError, match="not found"): + await fetch_protected_resource(options(challenge(), credential_id="missing")) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("headers", [{"Authorization": "secret"}, {"aEp-AuThOrIzAtIoN": "secret"}]) +async def test_additional_headers_cannot_replace_selected_aep(headers: dict[str, str]) -> None: + with pytest.raises(ValueError, match="conflicts"): + await fetch_protected_resource( + options(challenge(), additional_authentication_headers=headers) + ) + + +def openapi_configuration( + document: object, *, content_type: str = "application/json" +) -> FetchProtectedResourceOptions: + inspection = inspect_document().to_wire() + inspection["http"] = { + "openapi": {"url": "/openapi.json", "path_matching": {"trailing_slash": "strict"}} + } + configuration = options( + HttpResponse(status=200), + document=InspectDocument.model_validate_json(json.dumps(inspection)), + ) + transport = configuration.agent._inspect_transport + assert isinstance(transport, QueueTransport) + transport.responses.append( + HttpResponse( + status=200, headers={"Content-Type": content_type}, body=json.dumps(document).encode() + ) + ) + return configuration + + +def api_document(**changes: object) -> dict[str, object]: + return { + "openapi": "3.1.0", + "paths": {"/private": {"get": {}}}, + "components": { + "securitySchemes": { + "agent": {"type": "http", "scheme": "AEP", "x-aep-authentication-method": "aep-jwt"} + } + }, + "security": [{"agent": []}], + **changes, + } + + +@pytest.mark.asyncio +async def test_definitive_openapi_security_can_authenticate_without_probe() -> None: + configuration = openapi_configuration(api_document()) + assert (await fetch_protected_resource(configuration)).status == 200 + transport = configuration.transport + assert isinstance(transport, QueueTransport) + assert len(transport.requests) == 1 + assert transport.requests[0].headers["Authorization"].startswith("AEP ") + inspection = configuration.agent._inspect_transport + assert isinstance(inspection, QueueTransport) + assert dict(inspection.requests[-1].headers) == { + "Accept": "application/vnd.oai.openapi+json;version=3.1, application/json" + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "changes", + [ + {"openapi": "3.0.0"}, + {"paths": None}, + {"paths": {"/private": {"get": None}}}, + {"paths": {"/private": {"$ref": "elsewhere"}}}, + {"components": {"securitySchemes": None}}, + {"security": None}, + {"security": "invalid"}, + {"security": [{"agent": [], "unknown": []}]}, + {"security": [{}]}, + {"security": []}, + {"components": None}, + ], +) +async def test_inconclusive_openapi_falls_back_to_live_response(changes: dict[str, object]) -> None: + configuration = openapi_configuration(api_document(**changes)) + assert (await fetch_protected_resource(configuration)).status == 200 + transport = configuration.transport + assert isinstance(transport, QueueTransport) + assert dict(transport.requests[0].headers) == {} + + +@pytest.mark.parametrize( + "changes", + [ + {"timeout": 0}, + {"timeout": float("inf")}, + {"max_redirects": -1}, + {"max_redirects": True}, + {"carrier": "Other"}, + ], +) +def test_options_reject_invalid_limits_and_carrier(changes: dict[str, Any]) -> None: + with pytest.raises(ValueError): + options(**changes) + + +@pytest.mark.asyncio +async def test_overall_timeout_and_task_cancellation() -> None: + class WaitingTransport(QueueTransport): + async def send(self, request: HttpRequest) -> HttpResponse: + await asyncio.Event().wait() + raise AssertionError("unreachable") + + configuration = replace(options(), transport=WaitingTransport(), timeout=0.01) + with pytest.raises(TimeoutError): + await fetch_protected_resource(configuration) + task = asyncio.create_task(fetch_protected_resource(replace(configuration, timeout=30))) + await asyncio.sleep(0) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +@pytest.mark.asyncio +async def test_default_transport_is_closed_and_custom_transport_is_not( + monkeypatch: pytest.MonkeyPatch, +) -> None: + closed: list[bool] = [] + + class Transport(QueueTransport): + async def aclose(self) -> None: + closed.append(True) + + transport = Transport(HttpResponse(status=200), HttpResponse(status=200)) + monkeypatch.setattr( + "agent_enrollment_protocol.agent.protected_resource.HttpxTransport", + lambda **kwargs: transport, + ) + await fetch_protected_resource(replace(options(), transport=None)) + assert closed == [True] + await fetch_protected_resource(replace(options(), transport=transport)) + assert closed == [True] + + +@pytest.mark.asyncio +async def test_bad_inspect_and_no_advertised_authentication_fall_back_anonymously() -> None: + configuration = options(HttpResponse(status=200)) + configuration.agent._inspect_transport = QueueTransport(HttpResponse(status=500)) + assert (await fetch_protected_resource(configuration)).status == 200 + document = inspect_document().to_wire() + document.pop("authentication") + document["http"] = {} + configuration = options( + challenge(), document=InspectDocument.model_validate_json(json.dumps(document)) + ) + assert (await fetch_protected_resource(configuration)).status == 401 + + +@pytest.mark.asyncio +async def test_requested_grant_must_be_permitted() -> None: + with pytest.raises(ValueError, match="not permitted"): + await fetch_protected_resource(options(challenge(), grant_type="basic")) + + +@pytest.mark.asyncio +async def test_custom_session_without_usable_method_does_not_grant( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agent_enrollment_protocol.agent import CredentialUnavailableError, ServiceSession + + document = inspect_document().to_wire() + document["authentication"] = {"methods": ["aep-jwt"]} + document["http"] = {} + configuration = options( + challenge(), document=InspectDocument.model_validate_json(json.dumps(document)) + ) + + async def unavailable(*args: object) -> dict[str, str]: + raise CredentialUnavailableError("no signing method") + + monkeypatch.setattr(ServiceSession, "authentication_headers", unavailable) + with pytest.raises(CredentialUnavailableError, match="no signing method"): + await fetch_protected_resource(configuration) + + +@pytest.mark.asyncio +async def test_signer_failure_is_not_converted_into_grant(monkeypatch: pytest.MonkeyPatch) -> None: + configuration = options(challenge()) + + async def failed(*args: object) -> None: + raise RuntimeError("signer unavailable") + + monkeypatch.setattr(configuration.agent._identity_provider, "signer_for", failed) + with pytest.raises(RuntimeError, match="signer unavailable"): + await fetch_protected_resource(configuration) + commands = configuration.agent._command_transport + assert isinstance(commands, QueueTransport) + assert commands.requests == [] + + +@pytest.mark.asyncio +async def test_corrupt_credential_is_not_replaced_with_a_grant() -> None: + from datetime import UTC, datetime + + from agent_enrollment_protocol.agent import CredentialRecord + + configuration = options(challenge(), grant_type="api-key") + await configuration.agent._credential_store.save_credential( + CredentialRecord( + credential_id="one", + expires_at=datetime(2027, 1, 1, tzinfo=UTC), + issued_at=datetime(2026, 1, 1, tzinfo=UTC), + grant_type="api-key", + payload=b'{"bad":true}', + service_did="did:web:api.example.com", + service_url="https://api.example.com", + ) + ) + with pytest.raises(AepValidationError): + await fetch_protected_resource(configuration) + commands = configuration.agent._command_transport + assert isinstance(commands, QueueTransport) + assert commands.requests == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "content_type", + [ + "text/html", + "application/vnd.oai.openapi+json", + "application/vnd.oai.openapi+json;version=3.0", + ], +) +async def test_openapi_requires_a_supported_complete_json_media_type(content_type: str) -> None: + configuration = openapi_configuration(api_document(), content_type=content_type) + assert (await fetch_protected_resource(configuration)).status == 200 + transport = configuration.transport + assert isinstance(transport, QueueTransport) + assert not transport.requests[0].headers + + +@pytest.mark.asyncio +async def test_openapi_follows_only_safe_anonymous_redirects_and_has_a_bound() -> None: + configuration = openapi_configuration(api_document()) + transport = configuration.agent._inspect_transport + assert isinstance(transport, QueueTransport) + document = transport.responses[-1] + transport.responses[1:] = [ + HttpResponse(status=307, headers={"Location": "https://metadata.example/openapi.json"}), + document, + ] + assert (await fetch_protected_resource(configuration)).status == 200 + assert transport.requests[-1].url == "https://metadata.example/openapi.json" + configuration = openapi_configuration(api_document()) + transport = configuration.agent._inspect_transport + assert isinstance(transport, QueueTransport) + transport.responses[1:] = [HttpResponse(status=307, headers={"Location": "/again"})] * 6 + assert (await fetch_protected_resource(configuration)).status == 200 + + +@pytest.mark.asyncio +async def test_duplicate_challenge_header_is_ambiguous() -> None: + with pytest.raises(ValueError, match="Ambiguous"): + await fetch_protected_resource( + options( + HttpResponse( + status=401, headers={"WWW-Authenticate": "AEP", "www-authenticate": "AEP"} + ) + ) + ) + + +@pytest.mark.asyncio +async def test_resource_response_limit_also_applies_to_custom_transport() -> None: + configuration = options(HttpResponse(status=200, body=b"ab")) + configuration.agent._maximum_response_bytes = 1 + with pytest.raises(ValueError, match="byte limit"): + await fetch_protected_resource(configuration) + + +def test_request_body_must_be_bytes_before_discovery() -> None: + from typing import cast + + with pytest.raises(ValueError, match="replayable"): + replace( + options(), + request=HttpRequest(method="POST", url=URL, body=cast(bytes, iter([b"data"]))), + ) + + +@pytest.mark.asyncio +async def test_cross_origin_requires_challenge_even_with_definitive_openapi() -> None: + configuration = options( + HttpResponse(status=307, headers={"Location": "https://other.example/private"}), + challenge(did="did:web:other.example", inspect="https://other.example/.well-known/aep"), + HttpResponse(status=200), + ) + other = inspect_document().to_wire() + other["service"] = {"did": "did:web:other.example"} + transport = configuration.agent._inspect_transport + assert isinstance(transport, QueueTransport) + transport.responses += [ + inspect_response( + InspectDocument.model_validate_json(json.dumps(other)), cache_control="max-age=600" + ), + HttpResponse( + status=200, + headers={"Content-Type": "application/json"}, + body=json.dumps(api_document()).encode(), + ), + ] + assert (await fetch_protected_resource(configuration)).status == 200 + resource = configuration.transport + assert isinstance(resource, QueueTransport) + assert not resource.requests[1].headers + assert resource.requests[2].headers["Authorization"].startswith("AEP ") + + +@pytest.mark.asyncio +async def test_https_to_loopback_http_downgrade_is_rejected_with_development_enabled() -> None: + configuration = options( + HttpResponse(status=307, headers={"Location": "http://localhost/private"}) + ) + configuration.agent._allow_insecure_loopback = True + with pytest.raises(ValueError, match="weakens HTTPS"): + await fetch_protected_resource(configuration) + + +@pytest.mark.asyncio +async def test_live_challenge_overrides_openapi_without_infinite_retries() -> None: + configuration = replace( + openapi_configuration(api_document()), + max_redirects=0, + transport=QueueTransport(challenge(), challenge(), challenge()), + ) + assert (await fetch_protected_resource(configuration)).status == 401 + transport = configuration.transport + assert isinstance(transport, QueueTransport) + assert len(transport.requests) == 3 + assert transport.requests[0].headers and not transport.requests[1].headers + assert transport.requests[2].headers != transport.requests[0].headers diff --git a/tests/test_service.py b/tests/test_service.py index 47dc1c3..6fcb30c 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -360,6 +360,42 @@ async def operation() -> StoredResponse: await naive.execute(value, operation) +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("header", "value", "code"), + [ + ("Authorization", "AEP", "not_recognized"), + ("Authorization", "Bearer", "not_recognized"), + ("Authorization", "AEP not a token", "not_recognized"), + ("Authorization", "basic ", "not_recognized"), + ("Authorization", "Negotiate abc", "authentication_required"), + ("X-Other", "value", "authentication_required"), + ], +) +async def test_malformed_presentation_is_distinguished_from_a_missing_one( + header: str, value: str, code: str +) -> None: + # CORE-PR-038: missing credentials answer authentication_required, while malformed ones answer + # not_recognized. A value naming an AEP scheme is an AEP presentation even when the rest of it + # does not parse; a value naming another protocol's scheme is not ours to reject. + service, _ = _service() + await service.enroll( + json.dumps({"agent_did": AGENT_DID}).encode(), + _options(AssertionOperation.ENROLL, "enroll-malformed", key="enroll-malformed"), + ) + + result = await service.authenticate_protected_resource( + ProtectedResourceRequest( + headers={header: value}, method="GET", url="https://service.example/orders" + ) + ) + + assert not result.authenticated + assert result.response is not None + assert result.response.problem is not None + assert result.response.problem.code == code + + def _record() -> EnrollmentRecord: return EnrollmentRecord( agent_did=AGENT_DID, diff --git a/tests/test_service_boundaries.py b/tests/test_service_boundaries.py index 92531d4..8272b24 100644 --- a/tests/test_service_boundaries.py +++ b/tests/test_service_boundaries.py @@ -550,6 +550,71 @@ async def execute( ) +@pytest.mark.asyncio +@pytest.mark.parametrize( + "raw", + [ + b'{"credential_id":"one","opaque":NaN}', + b'{"credential_id":"one","opaque":Infinity}', + b'{"credential_id":"one","credential_id":"two"}', + b'{"credential_id":"one","opaque":{"same":1,"same":2}}', + ], +) +async def test_grant_rejects_invalid_json_and_allows_a_corrected_retry(raw: bytes) -> None: + handler = GrantHandler() + handler.response = raw + store = MemoryEnrollmentStore() + await store.save(_record()) + service, _ = _service( + enrollment_store=store, grant_types=(GrantTypeDefinition("future", handler),) + ) + with pytest.raises(ValueError, match="valid JSON"): + await service.grant( + b'{"grant_type":"future"}', + _options(AssertionOperation.GRANT, "invalid-json", key="same"), + ) + handler.response = b'{"credential_id":"one","opaque":{"value":"secret"}}' + corrected = await service.grant( + b'{"grant_type":"future"}', _options(AssertionOperation.GRANT, "corrected-json", key="same") + ) + assert corrected.status == 200 + assert corrected.body == {"credential_id": "one", "opaque": {"value": "secret"}} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "raw", + [ + b'{"credential_id":"one","opaque":NaN}', + b'{"credential_id":"one","credential_id":"two"}', + b'{"opaque":"secret"}', + ], +) +async def test_cached_grant_response_uses_the_same_json_validation(raw: bytes) -> None: + class InvalidIdempotencyStore: + async def execute( + self, + value: IdempotencyInput, + operation: Callable[[], Awaitable[StoredResponse]], + ) -> IdempotencyResult: + return IdempotencyResult( + StoredResponse(raw, "application/aep+json", NOW, {}, 200), + IdempotencyState.REPLAYED, + ) + + store = MemoryEnrollmentStore() + await store.save(_record()) + service, _ = _service( + enrollment_store=store, + grant_types=(GrantTypeDefinition("future", GrantHandler()),), + idempotency_store=InvalidIdempotencyStore(), + ) + with pytest.raises(ValueError, match=r"valid JSON|stable credential_id"): + await service.grant( + b'{"grant_type":"future"}', _options(AssertionOperation.GRANT, "cached", key="cached") + ) + + @pytest.mark.asyncio async def test_claim_structure_limits() -> None: cases = ( diff --git a/tests/test_service_credentials.py b/tests/test_service_credentials.py index 3d2ca27..8824a30 100644 --- a/tests/test_service_credentials.py +++ b/tests/test_service_credentials.py @@ -1,6 +1,10 @@ from __future__ import annotations +import asyncio import base64 +import hashlib +import hmac +import threading from dataclasses import replace from datetime import datetime, timedelta from typing import Any, cast @@ -41,6 +45,136 @@ async def _active_store() -> MemoryEnrollmentStore: return store +@pytest.mark.asyncio +async def test_public_grant_configuration_cannot_mutate_private_issuance() -> None: + store = MemoryServiceCredentialStore() + header = ["X-Key"] + + async def issue(request: GrantRequest, context: GrantContext) -> ApiKeyGrantResponse: + return ApiKeyGrantResponse( + api_key="secret", credential_id="one", expires_at=EXPIRES, header=header[0] + ) + + definition = stored_api_key_grant_type( + StoredCredentialGrantTypeOptions( + issue=issue, + store=store, + config=GrantTypeConfig.model_validate({"header_names": ["x-key"]}), + ) + ) + service, _ = _service(grant_types=(definition,), enrollment_store=await _active_store()) + assert definition.config.model_extra is not None + definition.config.model_extra["header_names"].append("x-other") + header[0] = "X-Other" + with pytest.raises(ValueError, match="not advertised"): + await service.grant( + b'{"grant_type":"api-key"}', + _options(AssertionOperation.GRANT, "bad-config", key="bad-config"), + ) + assert store._records == {} + configs = service.inspect_document.commands.grant_types_config + assert configs is not None + assert configs["api-key"].to_wire()["header_names"] == ["x-key"] + header[0] = "X-Key" + assert ( + await service.grant( + b'{"grant_type":"api-key"}', + _options(AssertionOperation.GRANT, "good-config", key="good-config"), + ) + ).status == 200 + + +@pytest.mark.asyncio +async def test_stored_credential_expiry_uses_core_rfc3339_rules() -> None: + store = MemoryServiceCredentialStore() + + async def issue(request: GrantRequest, context: GrantContext) -> ApiKeyGrantResponse: + return ApiKeyGrantResponse( + api_key="secret", credential_id="lowercase", expires_at=EXPIRES.lower(), header="X-Key" + ) + + definition = stored_api_key_grant_type( + StoredCredentialGrantTypeOptions(issue=issue, store=store) + ) + service, _ = _service( + grant_types=(definition,), + authentication_methods=("api-key",), + enrollment_store=await _active_store(), + ) + result = await service.grant( + b'{"grant_type":"api-key"}', + _options(AssertionOperation.GRANT, "lowercase", key="lowercase"), + ) + assert result.status == 200 + assert ( + await service.authenticate_protected_resource( + ProtectedResourceRequest(headers={"X-Key": "secret"}, method="GET", url=RESOURCE) + ) + ).authenticated + + +@pytest.mark.asyncio +async def test_memory_password_verifier_has_unique_salt_and_preserves_store_contract() -> None: + credential = BasicGrantResponse( + username="agent", password="password-secret", credential_id="basic-one", expires_at=EXPIRES + ) + record = ServiceCredentialRecord( + AGENT_DID, NOW, credential, credential.credential_id, NOW + timedelta(hours=1), "basic" + ) + first, second = MemoryServiceCredentialStore(), MemoryServiceCredentialStore() + await first.save_credential(record) + await second.save_credential(record) + stored = first._records[record.credential_id] + other = second._records[record.credential_id] + presented = base64.b64encode(b"agent:password-secret").decode() + assert len(stored.salt) == 16 and stored.salt != other.salt + assert stored.verifier != other.verifier + assert stored.verifier != hashlib.sha256(presented.encode()).digest() + assert hmac.compare_digest( + stored.verifier, + hashlib.scrypt(presented.encode(), salt=stored.salt, n=16384, r=8, p=1, dklen=32), + ) + assert credential.password not in repr(stored) + with pytest.raises(ValueError, match="secret has already"): + await first.save_credential( + replace( + record, + credential_id="duplicate", + credential=credential.model_copy(update={"credential_id": "duplicate"}), + ) + ) + for token in ( + "a", + "/w==", + base64.b64encode(b"wrong:password-secret").decode(), + base64.b64encode(b"agent:wrong").decode(), + ): + assert ( + await first.authenticate_credential( + "basic", + CredentialAuthenticationInput( + headers={"authorization": (f"Basic {token}",)}, + method="GET", + current_time=NOW, + url=RESOURCE, + ), + ) + is None + ) + assert ( + await first.authenticate_credential( + "basic", + CredentialAuthenticationInput( + headers={"aep-authorization": (f"Basic {presented}",)}, + method="GET", + current_time=NOW, + url=RESOURCE, + ), + ) + is not None + ) + + @pytest.mark.asyncio async def test_stored_credential_profiles_issue_authenticate_and_revoke() -> None: store = MemoryServiceCredentialStore() @@ -203,6 +337,33 @@ async def issue(request: GrantRequest, context: GrantContext) -> ApiKeyGrantResp assert result.response.problem.code == "not_recognized" +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("username", "password"), + [("agent", "pa\u00dfwort"), ("\u00e9agent", "secret"), ("agent", "s\u00e9cret")], +) +async def test_issued_basic_credentials_must_survive_rfc_7617_encoding( + username: str, password: str +) -> None: + # BSC-RSP-003: RFC 7617 leaves the user-pass charset undefined without a charset parameter, so a + # client encoding a non-ASCII value as ISO-8859-1 and a Service storing it as UTF-8 derive + # different bytes from the same credential and authentication silently fails. + async def issue(request: GrantRequest, context: GrantContext) -> BasicGrantResponse: + del request, context + return BasicGrantResponse( + credential_id="basic-1", + expires_at=EXPIRES, + password=password, + username=username, + ) + + definition = stored_basic_grant_type( + StoredCredentialGrantTypeOptions(issue=issue, store=MemoryServiceCredentialStore()) + ) + with pytest.raises(ValueError, match="US-ASCII"): + await definition.handler.grant(GrantRequest(grant_type="basic"), _grant_context("basic")) + + @pytest.mark.asyncio async def test_stored_credential_profiles_reject_invalid_issuance_and_storage() -> None: store = MemoryServiceCredentialStore() @@ -413,3 +574,89 @@ async def issue(request: GrantRequest, context: GrantContext) -> ApiKeyGrantResp GrantRequest(grant_type="api-key"), replace(_grant_context("api-key"), current_time=NOW.replace(tzinfo=None)), ) + + +@pytest.mark.asyncio +async def test_password_hashing_runs_outside_the_event_loop( + monkeypatch: pytest.MonkeyPatch, +) -> None: + original = hashlib.scrypt + threads: list[int] = [] + + def measured(*args: Any, **kwargs: Any) -> bytes: + threads.append(threading.get_ident()) + return original(*args, **kwargs) + + monkeypatch.setattr(hashlib, "scrypt", measured) + credential = BasicGrantResponse( + username="agent", password="secret", credential_id="one", expires_at=EXPIRES + ) + record = ServiceCredentialRecord( + AGENT_DID, NOW, credential, "one", NOW + timedelta(hours=1), "basic" + ) + store = MemoryServiceCredentialStore() + await store.save_credential(record) + token = base64.b64encode(b"agent:secret").decode() + assert ( + await store.authenticate_credential( + "basic", + CredentialAuthenticationInput( + headers={"authorization": (f"Basic {token}",)}, + method="GET", + current_time=NOW, + url=RESOURCE, + ), + ) + is not None + ) + assert len(threads) == 2 and all(identity != threading.get_ident() for identity in threads) + + +@pytest.mark.asyncio +async def test_concurrent_duplicate_passwords_cannot_both_be_saved() -> None: + credential = BasicGrantResponse( + username="agent", password="secret", credential_id="one", expires_at=EXPIRES + ) + record = ServiceCredentialRecord( + AGENT_DID, NOW, credential, "one", NOW + timedelta(hours=1), "basic" + ) + store = MemoryServiceCredentialStore() + outcomes = await asyncio.gather( + store.save_credential(record), + store.save_credential( + replace( + record, + credential_id="two", + credential=credential.model_copy(update={"credential_id": "two"}), + ) + ), + return_exceptions=True, + ) + assert outcomes.count(None) == 1 + assert sum(isinstance(value, ValueError) for value in outcomes) == 1 + assert len(store._records) == 1 + + +@pytest.mark.asyncio +async def test_direct_memory_store_preserves_utf8_basic_credentials() -> None: + credential = BasicGrantResponse( + username="\u00e9agent", password="s\u00e9cret", credential_id="utf8", expires_at=EXPIRES + ) + record = ServiceCredentialRecord( + AGENT_DID, NOW, credential, "utf8", NOW + timedelta(hours=1), "basic" + ) + store = MemoryServiceCredentialStore() + await store.save_credential(record) + token = base64.b64encode(f"{credential.username}:{credential.password}".encode()).decode() + assert ( + await store.authenticate_credential( + "basic", + CredentialAuthenticationInput( + headers={"authorization": (f"Basic {token}",)}, + method="GET", + current_time=NOW, + url=RESOURCE, + ), + ) + is not None + )