From 79d99b53e20da07baef0276f2e6210e1aab31a1b Mon Sep 17 00:00:00 2001 From: Nas Kavian Date: Thu, 3 Sep 2026 16:46:14 -0700 Subject: [PATCH] fix: harden Platform conformance --- .conformance/capability-manifest.json | 9 +- scripts/conformance_adapter.py | 566 +++++++++++++++++- .../agent/platform_provider.py | 5 +- tests/test_agent_platform_provider.py | 2 + 4 files changed, 574 insertions(+), 8 deletions(-) diff --git a/.conformance/capability-manifest.json b/.conformance/capability-manifest.json index 27e0200..2f9ecec 100644 --- a/.conformance/capability-manifest.json +++ b/.conformance/capability-manifest.json @@ -1,7 +1,14 @@ { "claims": [ { - "profiles": ["core-http", "claims", "api-key", "basic", "oauth-bearer"], + "profiles": [ + "core-http", + "claims", + "api-key", + "basic", + "oauth-bearer", + "platform-hosted-identity" + ], "role": "agent" }, { diff --git a/scripts/conformance_adapter.py b/scripts/conformance_adapter.py index 2ad5370..c19721e 100755 --- a/scripts/conformance_adapter.py +++ b/scripts/conformance_adapter.py @@ -6,8 +6,11 @@ import json import sys from collections.abc import Mapping +from dataclasses import replace from datetime import UTC, datetime, timedelta +from inspect import Parameter, signature from typing import Any +from urllib.parse import parse_qs, urlsplit from pydantic import BaseModel @@ -15,8 +18,16 @@ InspectCacheEntry, MemoryInspectCache, OperationKey, + PlatformCommandError, + PlatformContextProvider, + PlatformIdentityProvider, + PlatformIdentityProviderOptions, + PlatformPendingSignResolver, + PlatformSignPendingError, RandomIdempotencyKeyProvider, + ServiceIdentity, ) +from agent_enrollment_protocol.agent.types import IdentityRequest from agent_enrollment_protocol.core import ( AEP_MEDIA_TYPE, AepAssertionError, @@ -34,8 +45,11 @@ EnrollRequest, EnrollResponse, GrantRequest, + HttpRequest, + HttpResponse, InspectClaims, InspectDocument, + ManagedAgentStatus, OAuthBearerGrantResponse, OpenApiAepSecurityScheme, OpenApiTrailingSlash, @@ -51,6 +65,7 @@ ProtectedResourceAuthorization, RevokeRequest, RevokeResponse, + SigningAlgorithm, StatusResponse, authorization_header_name, command_path, @@ -68,9 +83,19 @@ same_origin, ) from agent_enrollment_protocol.platform import ( + AuthorizationRequest, + DidVerificationMethod, + DiscoveryOptions, IdempotentOperation, + IdentityListQuery, + IdentityRecord, + MemoryIdentityStore, MemoryPlatformIdempotencyStore, + MemoryReplayStore, + Platform, PlatformIdempotencyInput, + PlatformOptions, + RequestContext, create_service_scoped_agent_did, ) from agent_enrollment_protocol.platform import ( @@ -96,6 +121,7 @@ NOW = datetime(2026, 9, 3, 12, tzinfo=UTC) AGENT_DID = "did:web:agent.example.com:agents:123" SERVICE_DID = "did:web:api.example.com" +PLATFORM_ORIGIN = "https://p.example" CLAIM_VALUE_CASES = frozenset( { @@ -144,6 +170,159 @@ ) +class QueueTransport: + def __init__(self, *responses: HttpResponse) -> None: + self.requests: list[HttpRequest] = [] + self.responses = list(responses) + + async def send(self, request: HttpRequest) -> HttpResponse: + self.requests.append(request) + if not self.responses: + raise ValueError("AEP conformance transport received an unexpected request") + return self.responses.pop(0) + + async def aclose(self) -> None: + return None + + +class DenyAuthorizer: + def __init__(self) -> None: + self.operations: list[str] = [] + + async def authorize(self, request: AuthorizationRequest, context: RequestContext) -> bool: + del context + self.operations.append(request.operation.value) + return False + + +class ResolvedServiceDid: + async def resolve(self, service_did: str) -> bool: + return service_did.startswith("did:") + + +class PublicDidKeyStore: + async def create_key(self, identity: IdentityRecord) -> None: + del identity + + async def did_verification_method(self, identity: IdentityRecord) -> DidVerificationMethod: + return DidVerificationMethod( + controller=identity.agent_did, + id=identity.key_id, + public_key_jwk={"crv": "P-256", "kty": "EC", "x": "AQ", "y": "AQ"}, + type="JsonWebKey2020", + ) + + async def sign(self, identity: IdentityRecord, claims: ClientAssertionClaims) -> str: + del identity, claims + raise ValueError("denied Platform signing reached the key store") + + async def verification_key(self, identity: IdentityRecord) -> object: + del identity + raise ValueError("denied Platform verification reached the key store") + + +def json_response( + value: object, status: int = 200, content_type: str = AEP_MEDIA_TYPE +) -> HttpResponse: + return HttpResponse( + status=status, + headers={"Content-Type": content_type}, + body=json.dumps(value, separators=(",", ":")).encode(), + ) + + +def platform_discovery() -> JsonObject: + return { + "aep_version": "1.0", + "endpoints": { + "hosted_verification": "/v1/aep/verifications", + "lifecycle": "/v1/aep/agent-identities/{agent_identity_id}", + "list": "/v1/aep/agent-identities", + "provision": "/v1/aep/agent-identities", + "sign": "/v1/aep/agent-identities/{agent_identity_id}/sign", + }, + "http": {"endpoint_base": "/v1/aep"}, + "identity": { + "did_methods": ["did:web"], + "did_url_template": "https://p.example/a/{agent_did_id}/did.json", + }, + "platform": { + "did": "did:web:p.example", + "hosted_verification": True, + "name": "Example Platform", + }, + "signing": {"algorithms": ["ES256"], "default_lifetime_seconds": "300"}, + } + + +def platform_identity(service_did: str, suffix: str = "4Yf7p2xQd9") -> JsonObject: + return { + "agent_did": f"did:web:p.example:a:{suffix}", + "agent_identity_id": f"pai_{suffix}", + "created_at": "2026-07-06T12:00:00Z", + "did_document_url": f"https://p.example/a/{suffix}/did.json", + "key_id": f"did:web:p.example:a:{suffix}", + "service_did": service_did, + "signing_algorithms": ["ES256"], + "status": "active", + "updated_at": "2026-07-06T12:00:00Z", + } + + +def platform_verification_assertion(identity: IdentityRecord) -> str: + header = {"alg": "ES256", "kid": identity.agent_did, "typ": "JWT"} + claims = { + "aud": identity.service_did, + "exp": int((NOW + timedelta(minutes=1)).timestamp()), + "iat": int(NOW.timestamp()), + "iss": identity.agent_did, + "jti": "verification", + "op": "enroll", + "sub": identity.agent_did, + } + + def encode(value: object) -> str: + data = json.dumps(value, separators=(",", ":")).encode() + return base64.urlsafe_b64encode(data).rstrip(b"=").decode() + + return f"{encode(header)}.{encode(claims)}.signature" + + +def platform_identity_request(service_did: str) -> IdentityRequest: + document = minimal_document("1.0") + document["identity"] = {"methods": ["did:web"]} + document["service"] = {"did": service_did} + inspection = parse_json_model(json.dumps(document), InspectDocument, "Inspect document") + return IdentityRequest( + inspect=inspection, + service_did=service_did, + service_url="https://api.service.example/", + ) + + +def platform_provider( + transport: QueueTransport, + *, + idempotency_keys: list[str] | None = None, + pending_resolver: PlatformPendingSignResolver | None = None, + platform_context: PlatformContextProvider | None = None, +) -> PlatformIdentityProvider: + keys = iter(idempotency_keys or []) + + async def idempotency_key() -> str: + return next(keys) + + return PlatformIdentityProvider( + PlatformIdentityProviderOptions( + idempotency_key=idempotency_key if idempotency_keys is not None else None, + pending_sign_resolver=pending_resolver, + platform_context=platform_context, + platform_url=PLATFORM_ORIGIN, + transport=transport, + ) + ) + + def valid(model: type[BaseModel], value: object) -> bool: try: parse_json_model(json.dumps(value), model, model.__name__) @@ -463,16 +642,391 @@ def evaluate_platform(identifier: str, case: JsonObject) -> bool: if identifier in {"verification-response-recognized", "verification-response-unrecognized"}: return valid(PlatformVerificationResponse, expected) if identifier == "authorization-required": - return ( - expected["management_denied_status"] == 404 - and expected["management_denied_code"] == "not_recognized" - and expected["side_effects"] is False - ) + return asyncio.run(exercise_platform_authorization(source, expected)) if identifier in IDEMPOTENCY_CASES: return asyncio.run(exercise_platform_idempotency()) return False +async def exercise_platform_authorization(source: JsonObject, expected: JsonObject) -> bool: + authorizer = DenyAuthorizer() + store = MemoryIdentityStore() + identity = IdentityRecord( + agent_did="did:web:p.example:a:existing", + agent_did_id="existing", + agent_identity_id="pai_existing", + created_at=NOW, + did_document_url="https://p.example/a/existing/did.json", + key_id="did:web:p.example:a:existing", + principal="stable-principal-123", + service_did=SERVICE_DID, + signing_algorithms=(SigningAlgorithm.ES256,), + status=ManagedAgentStatus.ACTIVE, + updated_at=NOW, + ) + + async def existing() -> IdentityRecord: + return identity + + await store.find_or_create(identity.principal, identity.service_did, existing) + 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", + ) + platform = Platform( + PlatformOptions( + authorizer=authorizer, + clock=lambda: NOW, + did_host="p.example", + did_path_prefix="a", + did_url_template="https://p.example/a/{agent_did_id}/did.json", + discovery=discovery, + hosted_verification=True, + identity_store=store, + key_store=PublicDidKeyStore(), + replay_store=MemoryReplayStore(), + service_did_resolver=ResolvedServiceDid(), + signing_algorithms=(SigningAlgorithm.ES256,), + ) + ) + authorizer_parameter = signature(PlatformOptions).parameters["authorizer"] + missing_authorizer = ( + "construction-error" if authorizer_parameter.default is Parameter.empty else "accepted" + ) + context = RequestContext( + current_time=NOW, + idempotency_key="operation", + principal=identity.principal, + ) + management = ( + await platform.get_identity(identity.agent_identity_id, context), + await platform.list(IdentityListQuery(), context), + await platform.provision( + PlatformProvisionRequest(service_did=SERVICE_DID), + replace(context, idempotency_key="provision"), + ), + await platform.sign( + identity.agent_identity_id, + PlatformSignRequest( + jti="assertion", + op=AssertionOperation.ENROLL, + service_did=SERVICE_DID, + ), + replace(context, idempotency_key="sign"), + ), + await platform.update_identity( + identity.agent_identity_id, + PlatformLifecycleRequest(status=ManagedAgentStatus.SUSPENDED), + context, + ), + ) + verification = await platform.verify( + PlatformVerificationRequest( + client_assertion=platform_verification_assertion(identity), + op=AssertionOperation.ENROLL, + service_did=SERVICE_DID, + ), + replace(context, idempotency_key="verify"), + ) + public_document = await platform.did_document(identity.agent_did_id) + retained = await store.get(identity.agent_identity_id) + expected_operations = set(source["private_operations"]) + verification_wire = verification.body.to_wire() if verification.body is not None else {} + return bool( + all( + result.status == expected["management_denied_status"] + and result.problem is not None + and result.problem.code == expected["management_denied_code"] + for result in management + ) + and all( + verification_wire.get(key) == value + for key, value in expected["verification_denied"].items() + ) + and public_document.status == 200 + and expected["did_document_public"] is True + and retained == identity + and missing_authorizer == expected["missing_authorizer"] + and expected["side_effects"] is False + and set(authorizer.operations) == expected_operations + ) + + +async def evaluate_agent_platform(identifier: str, case: JsonObject) -> bool: + source = case["input"] + expected = case["expected"] + empty_list = {"count": "0", "data": [], "total": "0"} + + if identifier == "discovery": + transport = QueueTransport(json_response(expected), json_response(empty_list)) + provider = platform_provider(transport) + found = await provider.find_identity_by_service_did(SERVICE_DID) + return bool( + found is None + and transport.requests[0].url == f"{PLATFORM_ORIGIN}/.well-known/aep-platform" + and transport.requests[0].headers.get("Accept") == AEP_MEDIA_TYPE + ) + + if identifier == "provision-request": + service_did = source["service_did"] + response = platform_identity(service_did) + transport = QueueTransport( + json_response(platform_discovery()), + json_response(empty_list), + json_response(response), + ) + provider = platform_provider( + transport, idempotency_keys=[expected["idempotency_key_header"]] + ) + await provider.get_or_create_identity(platform_identity_request(service_did)) + provision = transport.requests[-1] + return bool( + provision.headers.get("Idempotency-Key") == expected["idempotency_key_header"] + and json.loads(provision.body or b"") == source + ) + + if identifier == "provision-response": + service_did = expected["service_did"] + transport = QueueTransport( + json_response(platform_discovery()), + json_response(empty_list), + json_response(expected), + ) + provider = platform_provider(transport, idempotency_keys=["provision"]) + identity = await provider.get_or_create_identity(platform_identity_request(service_did)) + return service_identity_wire(identity) == expected + + if identifier == "provision-response-distinct-services": + responses = [expected["first_response"], expected["second_response"]] + requests = [source["first_request"], source["second_request"]] + transport = QueueTransport( + json_response(platform_discovery()), + json_response(empty_list), + json_response(responses[0]), + json_response(empty_list), + json_response(responses[1]), + ) + provider = platform_provider( + transport, + idempotency_keys=[item["idempotency_key_header"] for item in requests], + ) + identities = [ + await provider.get_or_create_identity( + platform_identity_request(request_value["service_did"]) + ) + for request_value in requests + ] + return bool( + identities[0].agent_did != identities[1].agent_did + and identities[0].service_did != identities[1].service_did + ) + + if identifier == "list-response": + service_did = source["query"]["service_did"] + transport = QueueTransport( + json_response(platform_discovery()), + json_response(expected), + ) + provider = platform_provider(transport) + identity = await provider.find_identity_by_service_did(service_did) + query = parse_qs(urlsplit(transport.requests[-1].url).query) + return bool( + identity is not None + and service_identity_wire(identity) == expected["data"][0] + and query["service_did"] == [service_did] + ) + + if identifier == "sign-request": + service_did = source["service_did"] + identity = platform_identity(service_did) + issued_at = int(NOW.timestamp()) + claims = ClientAssertionClaims( + aud=service_did, + exp=issued_at + int(source["lifetime_seconds"]), + iat=issued_at, + iss=identity["agent_did"], + jti=source["jti"], + op=AssertionOperation(source["op"]), + sub=identity["agent_did"], + ) + completed = completed_platform_sign(identity, claims) + + async def context_provider( + selected: ServiceIdentity, selected_claims: ClientAssertionClaims + ) -> Mapping[str, object]: + del selected, selected_claims + return source["platform_context"] + + transport = QueueTransport( + json_response(platform_discovery()), + json_response({"count": "1", "data": [identity], "total": "1"}), + json_response(completed), + ) + provider = platform_provider( + transport, + idempotency_keys=[expected["idempotency_key_header"]], + platform_context=context_provider, + ) + selected = await provider.find_identity_by_service_did(service_did) + if selected is None: + return False + signer = await provider.signer_for(selected) + await signer(claims, (SigningAlgorithm.ES256,)) + signed = transport.requests[-1] + return bool( + signed.headers.get("Idempotency-Key") == expected["idempotency_key_header"] + and json.loads(signed.body or b"") == source + ) + + if identifier == "sign-response": + service_did = expected["service_did"] + identity = platform_identity_from_sign_response(expected) + transport = QueueTransport( + json_response(platform_discovery()), + json_response({"count": "1", "data": [identity], "total": "1"}), + json_response(expected), + ) + 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"] + + if identifier == "sign-response-pending": + service_did = SERVICE_DID + identity = platform_identity(service_did) + transport = QueueTransport( + json_response(platform_discovery()), + json_response({"count": "1", "data": [identity], "total": "1"}), + json_response(expected, 202), + ) + provider = platform_provider(transport, idempotency_keys=[source["idempotency_key_header"]]) + selected = await provider.find_identity_by_service_did(service_did) + if selected is None: + return False + signer = await provider.signer_for(selected) + try: + await signer(platform_claims(identity, service_did), (SigningAlgorithm.ES256,)) + except PlatformSignPendingError as error: + return bool( + error.pending.retry_after_seconds == int(expected["retry_after_seconds"]) + and dict(error.pending.platform_context) == expected["platform_context"] + and transport.requests[-1].headers.get("Idempotency-Key") + == source["idempotency_key_header"] + ) + return False + + if identifier == "idempotency-replay-conflict": + problem = { + "code": expected["changed_input_or_operation_code"], + "status": expected["changed_input_or_operation_status"], + "title": "Idempotency conflict", + "type": "urn:aep:error:idempotency_conflict", + } + transport = QueueTransport( + json_response(platform_discovery()), + json_response(empty_list), + json_response( + problem, + expected["changed_input_or_operation_status"], + "application/problem+json", + ), + ) + provider = platform_provider(transport, idempotency_keys=[source["initial_sign_key"]]) + try: + await provider.get_or_create_identity(platform_identity_request(SERVICE_DID)) + except PlatformCommandError as error: + return bool( + error.status == expected["changed_input_or_operation_status"] + and error.problem is not None + and error.problem.code == expected["changed_input_or_operation_code"] + ) + return False + + return False + + +def service_identity_wire(identity: ServiceIdentity) -> JsonObject: + return { + "agent_did": identity.agent_did, + "agent_identity_id": identity.metadata["agent_identity_id"], + "created_at": identity.metadata["created_at"], + "did_document_url": identity.metadata["did_document_url"], + "key_id": identity.metadata["key_id"], + "service_did": identity.service_did, + "signing_algorithms": [item.value for item in identity.signing_algorithms], + "status": identity.metadata["status"], + "updated_at": identity.metadata["updated_at"], + } + + +def platform_claims(identity: JsonObject, service_did: str) -> ClientAssertionClaims: + issued_at = int(NOW.timestamp()) + return ClientAssertionClaims( + aud=service_did, + exp=issued_at + 300, + iat=issued_at, + iss=identity["agent_did"], + jti="assertion", + op=AssertionOperation.ENROLL, + sub=identity["agent_did"], + ) + + +def completed_platform_sign(identity: JsonObject, claims: ClientAssertionClaims) -> JsonObject: + return { + "agent_did": identity["agent_did"], + "client_assertion": "header.payload.signature", + "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, + "service_did": claims.aud, + "status": "completed", + } + + +def platform_identity_from_sign_response(response: JsonObject) -> JsonObject: + agent_did = response["agent_did"] + suffix = agent_did.rsplit(":", 1)[-1] + return { + "agent_did": agent_did, + "agent_identity_id": f"pai_{suffix}", + "created_at": response["issued_at"], + "did_document_url": f"https://p.example/a/{suffix}/did.json", + "key_id": agent_did, + "service_did": response["service_did"], + "signing_algorithms": ["ES256"], + "status": "active", + "updated_at": response["issued_at"], + } + + +def claims_from_sign_response(response: JsonObject) -> ClientAssertionClaims: + issued_at = int( + datetime.fromisoformat(response["issued_at"].replace("Z", "+00:00")).timestamp() + ) + expires_at = int( + datetime.fromisoformat(response["expires_at"].replace("Z", "+00:00")).timestamp() + ) + return ClientAssertionClaims( + aud=response["service_did"], + exp=expires_at, + iat=issued_at, + iss=response["agent_did"], + jti=response["jti"], + op=AssertionOperation.ENROLL, + sub=response["agent_did"], + ) + + async def exercise_idempotency() -> bool: now = datetime.now(UTC) store = MemoryIdempotencyStore(lambda: now) @@ -691,6 +1245,8 @@ async def exercise_service_case(identifier: str, case: JsonObject) -> bool: def evaluate_generic(role: str, category: str, identifier: str, case: JsonObject) -> bool: source = case["input"] expected = case["expected"] + if role == "agent" and category == "platform": + return asyncio.run(evaluate_agent_platform(identifier, case)) service_cases = { "assertion-and-credential-failures", "authenticate-assertion", diff --git a/src/agent_enrollment_protocol/agent/platform_provider.py b/src/agent_enrollment_protocol/agent/platform_provider.py index 095fca1..cc90cc4 100644 --- a/src/agent_enrollment_protocol/agent/platform_provider.py +++ b/src/agent_enrollment_protocol/agent/platform_provider.py @@ -7,7 +7,7 @@ from collections.abc import Awaitable, Callable, Mapping from copy import deepcopy from dataclasses import dataclass, field -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from types import MappingProxyType from typing import Protocol, TypeVar from urllib.parse import quote, urlencode, urljoin, urlsplit, urlunsplit @@ -628,7 +628,8 @@ def _cache_fresh(entry: PlatformDiscoveryCacheEntry, now: datetime) -> bool: return False if seconds < 0: return False - return now < entry.cached_at + timedelta(seconds=seconds) + elapsed = (now - entry.cached_at).total_seconds() + return 0 <= elapsed < seconds def _cache_directive(value: str, name: str) -> str | None: diff --git a/tests/test_agent_platform_provider.py b/tests/test_agent_platform_provider.py index 903686e..f954231 100644 --- a/tests/test_agent_platform_provider.py +++ b/tests/test_agent_platform_provider.py @@ -730,6 +730,8 @@ def test_cache_and_pending_value_boundaries() -> None: assert not _cache_fresh(entry, NOW + timedelta(seconds=300)) for control in ("no-cache", "no-store", "max-age=invalid", "max-age=-1"): assert not _cache_fresh(replace(entry, cache_control=control), NOW) + assert _cache_fresh(replace(entry, cache_control="max-age=999999999999999999999"), NOW) + assert not _cache_fresh(replace(entry, cached_at=NOW + timedelta(seconds=1)), NOW) assert _cache_directive('public, max-age="60"', "max-age") == '"60"' assert _cache_directive("public", "missing") is None with pytest.raises(ValueError, match="timestamp"):