Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,069 changes: 904 additions & 165 deletions scripts/conformance_adapter.py

Large diffs are not rendered by default.

53 changes: 39 additions & 14 deletions src/agent_enrollment_protocol/adapters/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -111,16 +116,20 @@ 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

head = method == "HEAD"
headers = _request_headers(scope)
if _etag_matches(headers.get("if-none-match"), self._inspect_etag):
await _send_response(
Expand All @@ -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(
Expand All @@ -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

head = method == "HEAD"
headers = _request_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):
Expand All @@ -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,
Expand Down Expand Up @@ -276,15 +291,17 @@ 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")
body = _json_bytes(value)
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(
Expand Down Expand Up @@ -313,7 +330,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"
}
Expand All @@ -331,7 +356,7 @@ 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]]:
Expand Down
12 changes: 11 additions & 1 deletion src/agent_enrollment_protocol/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -195,6 +202,8 @@
"OpenApiPathMatch",
"OpenApiPathMatching",
"OpenApiReference",
"OpenApiSecurityDisposition",
"OpenApiSecurityResolution",
"OpenApiTrailingSlash",
"PlatformAgentIdentity",
"PlatformAgentIdentityListResponse",
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion src/agent_enrollment_protocol/core/did_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
42 changes: 35 additions & 7 deletions src/agent_enrollment_protocol/core/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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")
Expand All @@ -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}"


Expand All @@ -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()],
)


Expand Down
Loading