End-to-end PoC demonstrating Agent Context Token authorization and Execution Context Token accountability over MCP tool calls, using a LangGraph agent with ES256-signed JWT tokens and DAG verification.
239 lines
7.1 KiB
Python
239 lines
7.1 KiB
Python
"""Minimal RFC 9421 HTTP Message Signatures for the PoC.
|
|
|
|
Covers just enough of RFC 9421 + draft-ietf-wimse-http-signature-03 to
|
|
bind an ECT-bearing request to its method, target URI, body digest, and
|
|
the Wimse-ECT header itself. Not a general-purpose implementation — the
|
|
signed-component serialization follows RFC 9421 §2.3 for this fixed set
|
|
of components only.
|
|
|
|
Signature metadata parameters per draft-ietf-wimse-http-signature-03:
|
|
keyid — kid of the signing workload
|
|
alg — "ecdsa-p256-sha256"
|
|
created — NumericDate seconds
|
|
wimse-aud — target audience workload identity (new in -03, replaces
|
|
the removed Wimse-Audience HTTP header)
|
|
|
|
Format:
|
|
Signature-Input: sig1=(\"@method\" \"@target-uri\" \"content-digest\" \
|
|
\"wimse-ect\");created=...;keyid=\"...\";\
|
|
alg=\"ecdsa-p256-sha256\";wimse-aud=\"...\"
|
|
Signature: sig1=:<base64>:
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Iterable
|
|
from urllib.parse import urlsplit
|
|
|
|
from cryptography.hazmat.primitives.asymmetric.ec import (
|
|
EllipticCurvePrivateKey,
|
|
EllipticCurvePublicKey,
|
|
)
|
|
|
|
from act.crypto import sign as act_sign, verify as act_verify
|
|
from act.errors import ACTSignatureError
|
|
|
|
|
|
COVERED_COMPONENTS: tuple[str, ...] = (
|
|
"@method",
|
|
"@target-uri",
|
|
"content-digest",
|
|
"wimse-ect",
|
|
)
|
|
|
|
SIG_ALG = "ecdsa-p256-sha256"
|
|
|
|
|
|
def content_digest(body: bytes) -> str:
|
|
"""RFC 9530 Content-Digest header value using sha-256."""
|
|
digest = hashlib.sha256(body).digest()
|
|
return "sha-256=:" + base64.b64encode(digest).decode("ascii") + ":"
|
|
|
|
|
|
def _serialize_components(
|
|
*,
|
|
method: str,
|
|
target_uri: str,
|
|
content_digest_hdr: str,
|
|
wimse_ect_hdr: str,
|
|
params: str,
|
|
) -> bytes:
|
|
"""RFC 9421 §2.3 signature base for the fixed PoC component set."""
|
|
lines = [
|
|
f'"@method": {method.upper()}',
|
|
f'"@target-uri": {target_uri}',
|
|
f'"content-digest": {content_digest_hdr}',
|
|
f'"wimse-ect": {wimse_ect_hdr}',
|
|
f'"@signature-params": {params}',
|
|
]
|
|
return "\n".join(lines).encode("ascii")
|
|
|
|
|
|
def _signature_params(
|
|
*,
|
|
created: int,
|
|
keyid: str,
|
|
wimse_aud: str,
|
|
) -> str:
|
|
"""Render the signature-params string (quoted inner-list + params)."""
|
|
components = " ".join(f'"{c}"' for c in COVERED_COMPONENTS)
|
|
return (
|
|
f"({components})"
|
|
f";created={created}"
|
|
f';keyid="{keyid}"'
|
|
f';alg="{SIG_ALG}"'
|
|
f';wimse-aud="{wimse_aud}"'
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class SignedRequest:
|
|
content_digest: str
|
|
signature_input: str
|
|
signature: str
|
|
|
|
|
|
def sign_request(
|
|
*,
|
|
method: str,
|
|
target_uri: str,
|
|
body: bytes,
|
|
wimse_ect: str,
|
|
wimse_aud: str,
|
|
keyid: str,
|
|
private_key: EllipticCurvePrivateKey,
|
|
created: int | None = None,
|
|
) -> SignedRequest:
|
|
"""Produce the three headers needed to send a signed PoC request."""
|
|
created = int(created if created is not None else time.time())
|
|
cd = content_digest(body)
|
|
params = _signature_params(created=created, keyid=keyid, wimse_aud=wimse_aud)
|
|
base = _serialize_components(
|
|
method=method,
|
|
target_uri=target_uri,
|
|
content_digest_hdr=cd,
|
|
wimse_ect_hdr=wimse_ect,
|
|
params=params,
|
|
)
|
|
sig = act_sign(private_key, base)
|
|
sig_b64 = base64.b64encode(sig).decode("ascii")
|
|
return SignedRequest(
|
|
content_digest=cd,
|
|
signature_input=f"sig1={params}",
|
|
signature=f"sig1=:{sig_b64}:",
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class ParsedSignature:
|
|
covered: tuple[str, ...]
|
|
created: int
|
|
keyid: str
|
|
alg: str
|
|
wimse_aud: str
|
|
raw_params: str
|
|
signature_b64: str
|
|
|
|
|
|
def _parse_signature_input(value: str) -> ParsedSignature:
|
|
"""Parse 'sig1=(...);created=...;keyid="...";alg="...";wimse-aud="..."'."""
|
|
if "=" not in value:
|
|
raise ValueError("signature-input: missing label")
|
|
_label, _, inner = value.partition("=")
|
|
# inner: '("a" "b" ...);created=...;keyid="...";...'
|
|
if not inner.startswith("("):
|
|
raise ValueError("signature-input: missing covered components list")
|
|
close = inner.index(")")
|
|
components_raw = inner[1:close]
|
|
covered = tuple(
|
|
part.strip().strip('"') for part in components_raw.split() if part.strip()
|
|
)
|
|
rest = inner[close + 1 :]
|
|
params: dict[str, str] = {}
|
|
for part in rest.split(";"):
|
|
part = part.strip()
|
|
if not part or "=" not in part:
|
|
continue
|
|
k, _, v = part.partition("=")
|
|
params[k.strip()] = v.strip().strip('"')
|
|
return ParsedSignature(
|
|
covered=covered,
|
|
created=int(params["created"]),
|
|
keyid=params["keyid"],
|
|
alg=params["alg"],
|
|
wimse_aud=params["wimse-aud"],
|
|
raw_params=inner, # full params string (for sig base reconstruction)
|
|
signature_b64="",
|
|
)
|
|
|
|
|
|
def _parse_signature(value: str) -> str:
|
|
"""Parse 'sig1=:<base64>:' → base64 string."""
|
|
if "=" not in value:
|
|
raise ValueError("signature: missing label")
|
|
_label, _, inner = value.partition("=")
|
|
inner = inner.strip()
|
|
if not (inner.startswith(":") and inner.endswith(":")):
|
|
raise ValueError("signature: expected byte-sequence form :...:" )
|
|
return inner[1:-1]
|
|
|
|
|
|
def verify_request(
|
|
*,
|
|
method: str,
|
|
target_uri: str,
|
|
body: bytes,
|
|
wimse_ect_header: str,
|
|
content_digest_header: str,
|
|
signature_input_header: str,
|
|
signature_header: str,
|
|
expected_audience: str,
|
|
public_key: EllipticCurvePublicKey,
|
|
max_age_seconds: int = 300,
|
|
now: int | None = None,
|
|
) -> ParsedSignature:
|
|
"""Verify the signature covers the expected components and matches.
|
|
|
|
Returns the parsed signature metadata (keyid, alg, wimse-aud, created)
|
|
so the caller can cross-check against ECT/ACT claims.
|
|
"""
|
|
parsed = _parse_signature_input(signature_input_header)
|
|
if parsed.covered != COVERED_COMPONENTS:
|
|
raise ACTSignatureError(
|
|
f"signed components {parsed.covered!r} differ from expected "
|
|
f"{COVERED_COMPONENTS!r}"
|
|
)
|
|
if parsed.alg != SIG_ALG:
|
|
raise ACTSignatureError(f"unexpected alg {parsed.alg!r}")
|
|
if parsed.wimse_aud != expected_audience:
|
|
raise ACTSignatureError(
|
|
f"wimse-aud {parsed.wimse_aud!r} != expected {expected_audience!r}"
|
|
)
|
|
|
|
current = int(now if now is not None else time.time())
|
|
if current - parsed.created > max_age_seconds:
|
|
raise ACTSignatureError(
|
|
f"signature too old: created={parsed.created}, now={current}"
|
|
)
|
|
|
|
expected_digest = content_digest(body)
|
|
if content_digest_header != expected_digest:
|
|
raise ACTSignatureError("content-digest does not match body")
|
|
|
|
sig_b64 = _parse_signature(signature_header)
|
|
sig = base64.b64decode(sig_b64)
|
|
base = _serialize_components(
|
|
method=method,
|
|
target_uri=target_uri,
|
|
content_digest_hdr=content_digest_header,
|
|
wimse_ect_hdr=wimse_ect_header,
|
|
params=parsed.raw_params,
|
|
)
|
|
act_verify(public_key, sig, base) # raises ACTSignatureError on bad sig
|
|
parsed.signature_b64 = sig_b64
|
|
return parsed
|