feat: ACT/ECT strategy, package restructure, draft -01/-02 prep

Strategic work for IETF submission of draft-nennemann-act-01 and
draft-nennemann-wimse-ect-02:

Package restructure:
- move ACT and ECT refimpls to workspace/packages/{act,ect}/
- ietf-act and ietf-ect distribution names (sibling packages)
- cross-spec interop test plan (INTEROP-TEST-PLAN.md)

ACT draft -01 revisions:
- rename 'par' claim to 'pred' (align with ECT)
- rename 'Agent Compact Token' to 'Agent Context Token' (semantic
  alignment with ECT family)
- add Applicability section (MCP, OpenAI, LangGraph, A2A, CrewAI)
- add DAG vs Linear Delegation Chains section (differentiator vs
  txn-tokens-for-agents actchain, Agentic JWT, AIP/IBCTs)
- add Related Work: AIP, SentinelAgent, Agentic JWT, txn-tokens-for-agents,
  HDP, SCITT-AI-agent-execution
- pin SCITT arch to -22, note AUTH48 status

Outreach drafts:
- Emirdag liaison email (SCITT-AI coordination)
- OAuth ML response on txn-tokens-for-agents-06

Strategy document:
- STRATEGY.md with phased action plan, risk register, timeline

Submodule:
- update workspace/drafts/ietf-wimse-ect pointer to -02 commit
This commit is contained in:
2026-04-12 07:33:08 +02:00
parent b38747ad92
commit 3a139dfc7e
53 changed files with 8718 additions and 1 deletions

View File

@@ -0,0 +1,119 @@
"""Agent Context Token (ACT) — Reference Implementation.
A JWT-based format for autonomous AI agents that unifies authorization
and execution accountability in a single token lifecycle.
Reference: draft-nennemann-act-01.
"""
from .errors import (
ACTAudienceMismatchError,
ACTCapabilityError,
ACTDAGError,
ACTDelegationError,
ACTError,
ACTExpiredError,
ACTKeyResolutionError,
ACTLedgerImmutabilityError,
ACTPhaseError,
ACTPrivilegeEscalationError,
ACTSignatureError,
ACTValidationError,
)
from .token import (
ACTMandate,
ACTRecord,
Capability,
Delegation,
DelegationEntry,
ErrorClaim,
Oversight,
TaskClaim,
decode_jws,
encode_jws,
parse_token,
)
from .crypto import (
ACTKeyResolver,
KeyRegistry,
PublicKey,
PrivateKey,
X509TrustStore,
b64url_sha256,
compute_sha256,
did_key_from_ed25519,
generate_ed25519_keypair,
generate_p256_keypair,
resolve_did_key,
sign,
verify,
)
from .lifecycle import transition_to_record
from .delegation import (
create_delegated_mandate,
verify_capability_subset,
verify_delegation_chain,
)
from .dag import validate_dag, ACTStore
from .ledger import ACTLedger
from .verify import ACTVerifier
from .vectors import generate_vectors, validate_vectors
__all__ = [
# Errors
"ACTError",
"ACTValidationError",
"ACTSignatureError",
"ACTExpiredError",
"ACTAudienceMismatchError",
"ACTCapabilityError",
"ACTDelegationError",
"ACTDAGError",
"ACTPhaseError",
"ACTKeyResolutionError",
"ACTLedgerImmutabilityError",
"ACTPrivilegeEscalationError",
# Token structures
"ACTMandate",
"ACTRecord",
"TaskClaim",
"Capability",
"Delegation",
"DelegationEntry",
"Oversight",
"ErrorClaim",
# Token serialization
"encode_jws",
"decode_jws",
"parse_token",
# Crypto
"generate_ed25519_keypair",
"generate_p256_keypair",
"sign",
"verify",
"compute_sha256",
"b64url_sha256",
"resolve_did_key",
"did_key_from_ed25519",
"KeyRegistry",
"X509TrustStore",
"ACTKeyResolver",
"PublicKey",
"PrivateKey",
# Lifecycle
"transition_to_record",
# Delegation
"create_delegated_mandate",
"verify_capability_subset",
"verify_delegation_chain",
# DAG
"validate_dag",
"ACTStore",
# Ledger
"ACTLedger",
# Verify
"ACTVerifier",
# Vectors
"generate_vectors",
"validate_vectors",
]

View File

@@ -0,0 +1,467 @@
"""ACT cryptographic primitives and key management.
Provides sign/verify operations and key resolution across all three
ACT trust tiers:
- Tier 1: Pre-shared Ed25519 and P-256 keys
- Tier 2: PKI / X.509 certificate chains
- Tier 3: DID (did:key self-contained, did:web via resolver callback)
Reference: ACT §5 (Trust Model), §8 (Verification Procedure).
"""
from __future__ import annotations
import base64
import hashlib
import re
from typing import Any, Callable, Protocol
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ec import (
ECDSA,
SECP256R1,
EllipticCurvePrivateKey,
EllipticCurvePublicKey,
generate_private_key as ec_generate_private_key,
)
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.x509 import (
Certificate,
load_der_x509_certificate,
)
from .errors import (
ACTKeyResolutionError,
ACTSignatureError,
ACTValidationError,
)
# Type aliases for public/private keys supported by ACT.
PublicKey = Ed25519PublicKey | EllipticCurvePublicKey
PrivateKey = Ed25519PrivateKey | EllipticCurvePrivateKey
# Callback type for DID:web resolution.
DIDResolver = Callable[[str], PublicKey | None]
def generate_ed25519_keypair() -> tuple[Ed25519PrivateKey, Ed25519PublicKey]:
"""Generate an Ed25519 key pair for ACT signing.
Returns a (private_key, public_key) tuple. The private key object
carries its associated public key per ACT security requirements.
Reference: ACT §5.2 (Tier 1 pre-shared keys).
"""
private_key = Ed25519PrivateKey.generate()
return private_key, private_key.public_key()
def generate_p256_keypair() -> tuple[EllipticCurvePrivateKey, EllipticCurvePublicKey]:
"""Generate a P-256 (ES256) key pair for ACT signing.
Returns a (private_key, public_key) tuple.
Reference: ACT §5.2 (Tier 1 pre-shared keys).
"""
private_key = ec_generate_private_key(SECP256R1())
return private_key, private_key.public_key()
def sign(private_key: PrivateKey, data: bytes) -> bytes:
"""Sign data using the appropriate algorithm for the key type.
Uses Ed25519 for Ed25519PrivateKey, ECDSA with SHA-256 for P-256.
Returns raw signature bytes (for Ed25519: 64 bytes; for ES256:
raw r||s format per RFC 7518 §3.4).
Reference: ACT §5, RFC 7515 §5.1.
Raises:
ACTValidationError: If the key type is not supported.
"""
if isinstance(private_key, Ed25519PrivateKey):
return private_key.sign(data)
elif isinstance(private_key, EllipticCurvePrivateKey):
from cryptography.hazmat.primitives.asymmetric.utils import (
decode_dss_signature,
)
# Sign with DER-encoded signature, then convert to raw r||s
der_sig = private_key.sign(data, ECDSA(SHA256()))
r, s = decode_dss_signature(der_sig)
# P-256 uses 32-byte integers
return r.to_bytes(32, "big") + s.to_bytes(32, "big")
else:
raise ACTValidationError(f"Unsupported key type: {type(private_key)}")
def verify(public_key: PublicKey, signature: bytes, data: bytes) -> None:
"""Verify a signature against the given public key and data.
Reference: ACT §8.1 step 5.
Raises:
ACTSignatureError: If the signature is invalid.
ACTValidationError: If the key type is not supported.
"""
try:
if isinstance(public_key, Ed25519PublicKey):
public_key.verify(signature, data)
elif isinstance(public_key, EllipticCurvePublicKey):
from cryptography.hazmat.primitives.asymmetric.utils import (
encode_dss_signature,
)
# Convert raw r||s back to DER
r = int.from_bytes(signature[:32], "big")
s = int.from_bytes(signature[32:], "big")
der_sig = encode_dss_signature(r, s)
public_key.verify(der_sig, data, ECDSA(SHA256()))
else:
raise ACTValidationError(
f"Unsupported key type: {type(public_key)}"
)
except InvalidSignature as e:
raise ACTSignatureError("Signature verification failed") from e
def compute_sha256(data: bytes) -> bytes:
"""Compute SHA-256 hash of data.
Used for delegation chain signatures and inp_hash/out_hash claims.
Reference: ACT §6.1 (delegation sig), §4.3 (inp_hash, out_hash).
"""
return hashlib.sha256(data).digest()
def b64url_sha256(data: bytes) -> str:
"""Compute base64url(SHA-256(data)) without padding.
Used for inp_hash and out_hash claims.
Reference: ACT §4.3.
"""
digest = compute_sha256(data)
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
def x509_kid(cert_der: bytes) -> str:
"""Compute the Tier 2 kid: SHA-256 thumbprint of DER certificate.
Reference: ACT §5.3 (Tier 2 kid format).
"""
return hashlib.sha256(cert_der).hexdigest()
class KeyRegistry:
"""Tier 1 pre-shared key registry.
Maps kid strings to public keys. Configured at initialization time
with no external resolution needed.
Reference: ACT §5.2 (Tier 1 Pre-Shared Keys).
"""
def __init__(self) -> None:
self._keys: dict[str, PublicKey] = {}
def register(self, kid: str, public_key: PublicKey) -> None:
"""Register a public key under the given kid.
Reference: ACT §5.2.
"""
self._keys[kid] = public_key
def get(self, kid: str) -> PublicKey | None:
"""Retrieve the public key for a kid, or None if not found."""
return self._keys.get(kid)
def __contains__(self, kid: str) -> bool:
return kid in self._keys
def __len__(self) -> int:
return len(self._keys)
class X509TrustStore:
"""Tier 2 PKI/X.509 trust store.
Holds trusted CA certificates and resolves kid (certificate
thumbprint) to public keys. Supports x5c header chain validation.
Reference: ACT §5.3 (Tier 2 PKI).
"""
def __init__(self) -> None:
self._trusted_certs: dict[str, Certificate] = {}
def add_trusted_cert(self, cert: Certificate) -> str:
"""Add a trusted certificate to the store.
Returns the kid (SHA-256 thumbprint of DER encoding).
Reference: ACT §5.3.
"""
from cryptography.hazmat.primitives.serialization import Encoding
der_bytes = cert.public_bytes(Encoding.DER)
kid = x509_kid(der_bytes)
self._trusted_certs[kid] = cert
return kid
def resolve(self, kid: str) -> PublicKey | None:
"""Resolve kid to a public key from a trusted certificate.
Reference: ACT §5.3, §8.1 step 4.
"""
cert = self._trusted_certs.get(kid)
if cert is None:
return None
pub = cert.public_key()
if isinstance(pub, (Ed25519PublicKey, EllipticCurvePublicKey)):
return pub
return None
def resolve_x5c(self, x5c: list[str]) -> PublicKey | None:
"""Resolve public key from x5c certificate chain.
The first entry in x5c is the end-entity certificate.
Validates that the chain terminates in a trusted CA.
Reference: ACT §4.1 (x5c header), §5.3.
"""
if not x5c:
return None
try:
# Decode certificates from base64 DER
certs = [
load_der_x509_certificate(base64.b64decode(c)) for c in x5c
]
except Exception:
return None
# Check if any cert in the chain is in our trust store
from cryptography.hazmat.primitives.serialization import Encoding
for cert in certs:
der_bytes = cert.public_bytes(Encoding.DER)
kid = x509_kid(der_bytes)
if kid in self._trusted_certs:
# End-entity cert is the first one
ee_pub = certs[0].public_key()
if isinstance(ee_pub, (Ed25519PublicKey, EllipticCurvePublicKey)):
return ee_pub
return None
return None
# --- Tier 3: DID Support ---
# Multicodec prefixes for did:key
_ED25519_MULTICODEC = b"\xed\x01"
_P256_MULTICODEC = b"\x80\x24"
def _multibase_decode(encoded: str) -> bytes:
"""Decode a multibase-encoded string (base58btc 'z' prefix).
Reference: ACT §5.4 (Tier 3 DID:key).
"""
if not encoded.startswith("z"):
raise ACTKeyResolutionError(
f"Unsupported multibase encoding prefix: {encoded[0]!r}"
)
return _base58btc_decode(encoded[1:])
def _base58btc_decode(s: str) -> bytes:
"""Decode a base58btc string."""
alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
n = 0
for ch in s:
idx = alphabet.index(ch)
n = n * 58 + idx
# Compute byte length
byte_length = (n.bit_length() + 7) // 8
result = n.to_bytes(byte_length, "big") if byte_length > 0 else b""
# Preserve leading zeros
leading_zeros = len(s) - len(s.lstrip("1"))
return b"\x00" * leading_zeros + result
def resolve_did_key(did: str) -> PublicKey:
"""Resolve a did:key identifier to a public key.
Supports Ed25519 and P-256 key types. The did:key method is
self-contained — no external resolution is needed.
Reference: ACT §5.4 (Tier 3 DID:key).
Raises:
ACTKeyResolutionError: If the DID cannot be resolved.
"""
# Strip fragment if present (e.g., did:key:z6Mk...#z6Mk...)
did_base = did.split("#")[0]
if not did_base.startswith("did:key:"):
raise ACTKeyResolutionError(
f"Not a did:key identifier: {did!r}"
)
multibase_value = did_base[len("did:key:"):]
try:
decoded = _multibase_decode(multibase_value)
except Exception as e:
raise ACTKeyResolutionError(
f"Failed to decode did:key multibase value: {e}"
) from e
if decoded[:2] == _ED25519_MULTICODEC:
raw_key = decoded[2:]
if len(raw_key) != 32:
raise ACTKeyResolutionError(
f"Ed25519 public key must be 32 bytes, got {len(raw_key)}"
)
return Ed25519PublicKey.from_public_bytes(raw_key)
elif decoded[:2] == _P256_MULTICODEC:
raw_key = decoded[2:]
from cryptography.hazmat.primitives.asymmetric.ec import (
EllipticCurvePublicKey as ECPub,
)
from cryptography.hazmat.primitives.serialization import (
load_der_public_key,
)
# P-256 compressed point (33 bytes) or uncompressed (65 bytes)
# Wrap in SubjectPublicKeyInfo for loading
try:
return EllipticCurvePublicKey.from_encoded_point(
SECP256R1(), raw_key
)
except Exception as e:
raise ACTKeyResolutionError(
f"Failed to load P-256 key from did:key: {e}"
) from e
else:
raise ACTKeyResolutionError(
f"Unsupported multicodec prefix in did:key: {decoded[:2]!r}"
)
def did_key_from_ed25519(public_key: Ed25519PublicKey) -> str:
"""Create a did:key identifier from an Ed25519 public key.
Reference: ACT §5.4 (Tier 3 DID:key).
"""
from cryptography.hazmat.primitives.serialization import (
Encoding,
PublicFormat,
)
raw = public_key.public_bytes(Encoding.Raw, PublicFormat.Raw)
multicodec = _ED25519_MULTICODEC + raw
encoded = "z" + _base58btc_encode(multicodec)
return f"did:key:{encoded}"
def _base58btc_encode(data: bytes) -> str:
"""Encode bytes as base58btc."""
alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
# Count leading zeros
leading_zeros = 0
for b in data:
if b == 0:
leading_zeros += 1
else:
break
n = int.from_bytes(data, "big")
if n == 0:
return "1" * leading_zeros
chars: list[str] = []
while n > 0:
n, remainder = divmod(n, 58)
chars.append(alphabet[remainder])
return "1" * leading_zeros + "".join(reversed(chars))
class ACTKeyResolver:
"""Unified key resolver across all trust tiers.
Tries Tier 1 (pre-shared), then Tier 2 (X.509), then Tier 3 (DID)
to resolve a kid to a public key.
Reference: ACT §5 (Trust Model), §8.1 step 4.
"""
def __init__(
self,
registry: KeyRegistry | None = None,
x509_store: X509TrustStore | None = None,
did_web_resolver: DIDResolver | None = None,
) -> None:
self._registry = registry or KeyRegistry()
self._x509_store = x509_store or X509TrustStore()
self._did_web_resolver = did_web_resolver
@property
def registry(self) -> KeyRegistry:
"""Access the Tier 1 key registry."""
return self._registry
@property
def x509_store(self) -> X509TrustStore:
"""Access the Tier 2 X.509 trust store."""
return self._x509_store
def resolve(
self,
kid: str,
header: dict[str, Any] | None = None,
) -> PublicKey:
"""Resolve a kid to a public key, trying all configured tiers.
Resolution order:
1. Tier 1: Pre-shared key registry lookup by kid
2. Tier 2: X.509 certificate lookup by kid (thumbprint)
or x5c header chain validation
3. Tier 3: DID resolution (did:key or did:web)
Reference: ACT §5 (Trust Model), §8.1 step 4.
Raises:
ACTKeyResolutionError: If no key can be resolved for the kid.
"""
header = header or {}
# Tier 1: Pre-shared keys
key = self._registry.get(kid)
if key is not None:
return key
# Tier 2: X.509
key = self._x509_store.resolve(kid)
if key is not None:
return key
# Tier 2: x5c chain in header
x5c = header.get("x5c")
if x5c:
key = self._x509_store.resolve_x5c(x5c)
if key is not None:
return key
# Tier 3: DID
did_value = header.get("did") or kid
if did_value.startswith("did:key:"):
try:
return resolve_did_key(did_value)
except ACTKeyResolutionError:
pass
if did_value.startswith("did:web:") and self._did_web_resolver:
resolved = self._did_web_resolver(did_value)
if resolved is not None:
return resolved
raise ACTKeyResolutionError(
f"Cannot resolve kid {kid!r} to a public key via any trust tier"
)

View File

@@ -0,0 +1,136 @@
"""ACT DAG validation for Phase 2 execution records.
Validates the directed acyclic graph formed by pred (predecessor) references
in Phase 2 ACTs, ensuring uniqueness, predecessor existence, temporal ordering,
acyclicity, and capability consistency.
Reference: ACT §7 (DAG Structure and Causal Ordering).
"""
from __future__ import annotations
from typing import Protocol
from .errors import ACTCapabilityError, ACTDAGError
from .token import ACTRecord
# Maximum ancestor traversal limit for cycle detection — ACT §7.1 step 4.
MAX_TRAVERSAL_LIMIT: int = 10_000
# Clock skew tolerance for temporal ordering — ACT §7.1 step 3.
DAG_CLOCK_SKEW_TOLERANCE: int = 30
class ACTStore(Protocol):
"""Protocol for an ACT store used in DAG validation.
Any object implementing get() and has() can serve as the store.
The ACTLedger in ledger.py implements this protocol.
"""
def get(self, jti: str) -> ACTRecord | None:
"""Retrieve a Phase 2 ACT record by jti."""
...
def validate_dag(
record: ACTRecord,
store: ACTStore,
*,
clock_skew_tolerance: int = DAG_CLOCK_SKEW_TOLERANCE,
) -> None:
"""Validate the DAG constraints for a Phase 2 execution record.
Performs all five DAG validation checks defined in ACT §7.1:
1. jti uniqueness within wid scope (or globally)
2. Predecessor existence in store
3. Temporal ordering with clock skew tolerance
4. Acyclicity (max traversal limit)
5. Capability consistency (exec_act matches cap[].action)
Reference: ACT §7.1 (DAG Validation).
Args:
record: The Phase 2 ACTRecord to validate.
store: An ACT store providing get() for predecessor lookup.
clock_skew_tolerance: Seconds of allowed clock skew (default 30).
Raises:
ACTDAGError: If any DAG constraint is violated.
ACTCapabilityError: If exec_act does not match cap actions.
"""
# Step 1: jti uniqueness — ACT §7.1 step 1
existing = store.get(record.jti)
if existing is not None:
raise ACTDAGError(
f"Duplicate jti {record.jti!r} already exists in store"
)
# Step 5: Capability consistency — ACT §7.1 step 5
cap_actions = {c.action for c in record.cap}
if record.exec_act not in cap_actions:
raise ACTCapabilityError(
f"exec_act {record.exec_act!r} does not match any "
f"cap[].action: {sorted(cap_actions)}"
)
# Step 2 & 3: Predecessor existence and temporal ordering
for pred_jti in record.pred:
parent = store.get(pred_jti)
if parent is None:
raise ACTDAGError(
f"Predecessor jti {pred_jti!r} not found in store"
)
# Temporal ordering: predecessor.exec_ts < child.exec_ts + tolerance
if parent.exec_ts >= record.exec_ts + clock_skew_tolerance:
raise ACTDAGError(
f"Temporal ordering violation: predecessor {pred_jti!r} "
f"exec_ts={parent.exec_ts} >= child exec_ts="
f"{record.exec_ts} + tolerance={clock_skew_tolerance}"
)
# Step 4: Acyclicity — ACT §7.1 step 4
_check_acyclicity(record.jti, record.pred, store)
def _check_acyclicity(
current_jti: str,
pred_jtis: list[str],
store: ACTStore,
) -> None:
"""Check that following pred references does not lead back to current_jti.
Uses breadth-first traversal with a maximum node limit.
Reference: ACT §7.1 step 4.
Raises:
ACTDAGError: If a cycle is detected or traversal limit exceeded.
"""
visited: set[str] = set()
queue: list[str] = list(pred_jtis)
nodes_visited = 0
while queue:
if nodes_visited >= MAX_TRAVERSAL_LIMIT:
raise ACTDAGError(
f"DAG traversal limit ({MAX_TRAVERSAL_LIMIT}) exceeded; "
f"possible cycle or excessively deep DAG"
)
jti = queue.pop(0)
if jti == current_jti:
raise ACTDAGError(
f"DAG cycle detected: jti {current_jti!r} appears in "
f"its own ancestor chain"
)
if jti in visited:
continue
visited.add(jti)
nodes_visited += 1
parent = store.get(jti)
if parent is not None:
queue.extend(parent.pred)

View File

@@ -0,0 +1,333 @@
"""ACT delegation chain construction and verification.
Handles peer-to-peer delegation where Agent A authorizes Agent B
with reduced privileges, building a cryptographic chain of authority.
Reference: ACT §6 (Delegation Chain).
"""
from __future__ import annotations
import base64
from typing import Any
from .crypto import (
PrivateKey,
PublicKey,
compute_sha256,
sign as crypto_sign,
verify as crypto_verify,
)
from .errors import (
ACTDelegationError,
ACTPrivilegeEscalationError,
ACTValidationError,
)
from .token import (
ACTMandate,
Capability,
Delegation,
DelegationEntry,
_b64url_encode,
_b64url_decode,
encode_jws,
)
def create_delegated_mandate(
parent_mandate: ACTMandate,
parent_compact: str,
delegator_private_key: PrivateKey,
*,
sub: str,
kid: str,
iss: str,
aud: str | list[str],
iat: int,
exp: int,
jti: str,
cap: list[Capability],
task: Any,
alg: str = "EdDSA",
wid: str | None = None,
max_depth: int | None = None,
oversight: Any | None = None,
) -> tuple[ACTMandate, str]:
"""Create a delegated ACT mandate from a parent mandate.
Agent A (delegator) creates a new mandate for Agent B (sub) with
reduced privileges. The delegation chain is extended with a new
entry linking back to the parent ACT.
Reference: ACT §6.1 (Peer-to-Peer Delegation).
Args:
parent_mandate: The parent ACT that authorizes delegation.
parent_compact: JWS compact serialization of the parent ACT.
delegator_private_key: The delegator's private key for chain sig.
sub: Target agent identifier.
kid: Key identifier for the new mandate's signing key.
iss: Issuer identifier (the delegator).
aud: Audience for the new mandate.
iat: Issuance time.
exp: Expiration time.
jti: Unique identifier for the new mandate.
cap: Capabilities (must be subset of parent).
task: TaskClaim for the new mandate.
alg: Algorithm (default EdDSA).
wid: Workflow identifier (optional).
max_depth: Max delegation depth (must be <= parent's).
oversight: Oversight claim (optional).
Returns:
Tuple of (ACTMandate, needs to be signed by delegator).
Raises:
ACTDelegationError: If delegation depth would exceed max_depth.
ACTPrivilegeEscalationError: If cap exceeds parent capabilities.
"""
# Determine parent delegation state
if parent_mandate.delegation is not None:
parent_depth = parent_mandate.delegation.depth
parent_max_depth = parent_mandate.delegation.max_depth
parent_chain = list(parent_mandate.delegation.chain)
else:
# Root mandate without del claim — delegation not permitted
raise ACTDelegationError(
"Parent mandate has no 'del' claim; delegation is not permitted"
)
new_depth = parent_depth + 1
# Validate depth constraints — ACT §6.3 step 3
if new_depth > parent_max_depth:
raise ACTDelegationError(
f"Delegation depth {new_depth} exceeds max_depth {parent_max_depth}"
)
# Validate max_depth — ACT §6.1 step 4
if max_depth is None:
effective_max_depth = parent_max_depth
else:
if max_depth > parent_max_depth:
raise ACTDelegationError(
f"Requested max_depth {max_depth} exceeds parent max_depth "
f"{parent_max_depth}"
)
effective_max_depth = max_depth
# Validate capability subset — ACT §6.2
verify_capability_subset(parent_mandate.cap, cap)
# Compute chain entry signature — ACT §6.1 step 5
parent_hash = compute_sha256(parent_compact.encode("utf-8"))
chain_sig = crypto_sign(delegator_private_key, parent_hash)
chain_sig_b64 = _b64url_encode(chain_sig)
# Build new chain entry
new_entry = DelegationEntry(
delegator=iss,
jti=parent_mandate.jti,
sig=chain_sig_b64,
)
# Extend chain — ordered root → immediate parent
new_chain = parent_chain + [new_entry]
delegation = Delegation(
depth=new_depth,
max_depth=effective_max_depth,
chain=new_chain,
)
mandate = ACTMandate(
alg=alg,
kid=kid,
iss=iss,
sub=sub,
aud=aud,
iat=iat,
exp=exp,
jti=jti,
wid=wid if wid is not None else parent_mandate.wid,
task=task,
cap=cap,
delegation=delegation,
oversight=oversight,
)
return mandate, ""
def verify_capability_subset(
parent_caps: list[Capability],
child_caps: list[Capability],
) -> None:
"""Verify that child capabilities are a subset of parent capabilities.
Each child capability action must exist in the parent. Constraints
must be at least as restrictive.
Reference: ACT §6.2 (Privilege Reduction Requirements).
Raises:
ACTPrivilegeEscalationError: If child cap exceeds parent cap.
"""
parent_actions = {c.action: c for c in parent_caps}
for child_cap in child_caps:
if child_cap.action not in parent_actions:
raise ACTPrivilegeEscalationError(
f"Capability action {child_cap.action!r} not present in "
f"parent capabilities: {sorted(parent_actions.keys())}"
)
parent_cap = parent_actions[child_cap.action]
_verify_constraints_subset(
parent_cap.constraints, child_cap.constraints, child_cap.action
)
def _verify_constraints_subset(
parent_constraints: dict[str, Any] | None,
child_constraints: dict[str, Any] | None,
action: str,
) -> None:
"""Verify child constraints are at least as restrictive as parent.
Reference: ACT §6.2 (Privilege Reduction Requirements).
Rules:
- Numeric values: child must be <= parent (lower = more restrictive)
- data_sensitivity enum: child must be >= parent in ordering
- Unknown/domain-specific: must be byte-for-byte identical
Raises:
ACTPrivilegeEscalationError: If child constraint is less restrictive.
"""
if parent_constraints is None:
# Parent has no constraints — child may add constraints (more restrictive)
return
if child_constraints is None:
# Parent has constraints but child does not — escalation
raise ACTPrivilegeEscalationError(
f"Capability {action!r}: parent has constraints but child does not"
)
# Sensitivity ordering per ACT §6.2
_SENSITIVITY_ORDER = {
"public": 0,
"internal": 1,
"confidential": 2,
"restricted": 3,
}
for key, parent_val in parent_constraints.items():
if key not in child_constraints:
# Missing constraint in child = less restrictive
raise ACTPrivilegeEscalationError(
f"Capability {action!r}: constraint {key!r} present in "
f"parent but missing in child"
)
child_val = child_constraints[key]
if key == "data_sensitivity" or key == "data_classification_max":
# Enum comparison — higher = more restrictive
p_ord = _SENSITIVITY_ORDER.get(parent_val)
c_ord = _SENSITIVITY_ORDER.get(child_val)
if p_ord is not None and c_ord is not None:
if c_ord < p_ord:
raise ACTPrivilegeEscalationError(
f"Capability {action!r}: constraint {key!r} "
f"value {child_val!r} is less restrictive than "
f"parent value {parent_val!r}"
)
continue
if isinstance(parent_val, (int, float)) and isinstance(child_val, (int, float)):
# Numeric: lower/equal = more restrictive
if child_val > parent_val:
raise ACTPrivilegeEscalationError(
f"Capability {action!r}: numeric constraint {key!r} "
f"value {child_val} exceeds parent value {parent_val}"
)
continue
# Unknown/domain-specific: must be identical — ACT §6.2
if child_val != parent_val:
raise ACTPrivilegeEscalationError(
f"Capability {action!r}: constraint {key!r} value "
f"{child_val!r} differs from parent value {parent_val!r} "
f"(non-comparable constraints must be identical)"
)
def verify_delegation_chain(
mandate: ACTMandate,
resolve_key: Any,
resolve_parent_compact: Any | None = None,
) -> None:
"""Verify the delegation chain of a mandate.
Reference: ACT §6.3 (Delegation Verification).
Args:
mandate: The ACT mandate to verify.
resolve_key: Callable(delegator_id: str) -> PublicKey to resolve
the public key of a delegator.
resolve_parent_compact: Optional callable(jti: str) -> str|None
to retrieve the parent ACT compact form.
Required for full chain sig verification.
Raises:
ACTDelegationError: If the chain is structurally invalid.
ACTPrivilegeEscalationError: If capabilities were escalated.
"""
if mandate.delegation is None:
# No delegation — root mandate, nothing to verify
return
delegation = mandate.delegation
# Step 3: depth <= max_depth
if delegation.depth > delegation.max_depth:
raise ACTDelegationError(
f"Delegation depth {delegation.depth} exceeds "
f"max_depth {delegation.max_depth}"
)
# Step 4: chain length == depth
if len(delegation.chain) != delegation.depth:
raise ACTDelegationError(
f"Delegation chain length {len(delegation.chain)} does not "
f"match depth {delegation.depth}"
)
# Step 2: verify each chain entry
for i, entry in enumerate(delegation.chain):
# Step 2a: resolve delegator's public key
try:
pub_key = resolve_key(entry.delegator)
except Exception as e:
raise ACTDelegationError(
f"Cannot resolve key for delegator {entry.delegator!r} "
f"at chain index {i}: {e}"
) from e
# Step 2b: verify signature if parent compact is available
if resolve_parent_compact is not None:
parent_compact = resolve_parent_compact(entry.jti)
if parent_compact is not None:
parent_hash = compute_sha256(
parent_compact.encode("utf-8")
)
sig_bytes = _b64url_decode(entry.sig)
try:
crypto_verify(pub_key, sig_bytes, parent_hash)
except Exception as e:
raise ACTDelegationError(
f"Chain entry signature verification failed at "
f"index {i} (delegator={entry.delegator!r}): {e}"
) from e

View File

@@ -0,0 +1,131 @@
"""ACT-specific exception types.
All exceptions defined in this module correspond to specific failure
modes in the Agent Context Token lifecycle as defined in
draft-nennemann-act-01.
Reference: ACT §8 (Verification Procedure), §6 (Delegation Chain),
§7 (DAG Structure), §10 (Audit Ledger Interface).
"""
from __future__ import annotations
class ACTError(Exception):
"""Base exception for all ACT operations.
All ACT-specific exceptions inherit from this class, allowing
callers to catch any ACT error with a single except clause.
Reference: draft-nennemann-act-01.
"""
class ACTValidationError(ACTError):
"""Malformed token structure or invalid field values.
Raised when an ACT fails structural validation: missing required
claims, invalid claim types, unsupported algorithm ("none", HS*),
or invalid typ header.
Reference: ACT §4 (Token Format), §8.1 steps 2-3, 11.
"""
class ACTSignatureError(ACTError):
"""Signature verification failed.
Raised when a JWS signature cannot be verified against the
resolved public key, or when a Phase 2 token is signed by the
wrong key (e.g., iss key instead of sub key).
Reference: ACT §8.1 step 5, §8.2 step 17.
"""
class ACTExpiredError(ACTError):
"""Token has expired.
Raised when the current time exceeds exp + clock_skew_tolerance.
The default clock skew tolerance is 300 seconds.
Reference: ACT §8.1 step 6.
"""
class ACTAudienceMismatchError(ACTError):
"""The aud claim does not contain the verifier's identity.
Reference: ACT §8.1 step 8.
"""
class ACTCapabilityError(ACTError):
"""No matching capability or exec_act not in cap actions.
Raised when exec_act does not match any cap[].action value,
or when a requested action is not authorized by any capability.
Reference: ACT §8.2 step 13, §4.2.2 (cap).
"""
class ACTDelegationError(ACTError):
"""Delegation chain is invalid.
Raised when delegation chain verification fails: depth > max_depth,
chain length != depth, or any chain entry signature fails.
Reference: ACT §6 (Delegation Chain), §8.1 step 12.
"""
class ACTDAGError(ACTError):
"""DAG validation failed.
Raised on cycle detection, missing parent jti, temporal ordering
violations, or traversal limit exceeded.
Reference: ACT §7 (DAG Structure and Causal Ordering).
"""
class ACTPhaseError(ACTError):
"""Wrong phase for the requested operation.
Raised when a mandate is used where a record is expected, or
vice versa. Phase is determined by the presence of exec_act.
Reference: ACT §3 (Lifecycle), §8.
"""
class ACTKeyResolutionError(ACTError):
"""Cannot resolve kid to a public key.
Raised when the kid in the JOSE header cannot be resolved to a
public key via any configured trust tier (pre-shared, PKI, DID).
Reference: ACT §5 (Trust Model), §8.1 step 4.
"""
class ACTLedgerImmutabilityError(ACTError):
"""Attempt to modify or delete a ledger record.
The audit ledger enforces append-only semantics. Once appended,
a record cannot be modified or deleted.
Reference: ACT §10 (Audit Ledger Interface).
"""
class ACTPrivilegeEscalationError(ACTError):
"""Delegated capability exceeds parent capability.
Raised when a delegated ACT contains actions not present in the
parent ACT's cap array, or when constraints are less restrictive
than the parent's constraints.
Reference: ACT §6.2 (Privilege Reduction Requirements).
"""

View File

@@ -0,0 +1,152 @@
"""ACT in-memory append-only audit ledger.
Provides an in-memory reference implementation of the audit ledger
interface. Enforces append-only semantics and hash-chain integrity.
Reference: ACT §10 (Audit Ledger Interface).
"""
from __future__ import annotations
import hashlib
import json
from typing import Any
from .errors import ACTLedgerImmutabilityError
from .token import ACTRecord
class ACTLedger:
"""In-memory append-only audit ledger for ACT execution records.
Records are stored in insertion order with monotonically increasing
sequence numbers. A hash chain provides integrity verification.
Reference: ACT §10.
This is a reference implementation suitable for testing. Production
deployments should use a persistent backend implementing the same
interface.
"""
def __init__(self) -> None:
self._records: list[tuple[int, ACTRecord, str]] = []
# jti → index mapping for efficient lookup
self._jti_index: dict[str, int] = {}
# wid → list of indices for workflow queries
self._wid_index: dict[str | None, list[int]] = {}
self._seq_counter: int = 0
# Hash chain: each entry's hash includes the previous hash
self._chain_hashes: list[bytes] = []
def append(self, act_record: ACTRecord) -> int:
"""Append an execution record to the ledger.
Returns the sequence number assigned to the record.
Reference: ACT §10, requirement 1 (append-only), requirement 2 (ordering).
Raises:
ACTLedgerImmutabilityError: If a record with the same jti
already exists.
"""
if act_record.jti in self._jti_index:
raise ACTLedgerImmutabilityError(
f"Record with jti {act_record.jti!r} already exists in ledger"
)
seq = self._seq_counter
self._seq_counter += 1
# Compute hash chain entry
record_hash = self._hash_record(act_record, seq)
if self._chain_hashes:
chained = hashlib.sha256(
self._chain_hashes[-1] + record_hash
).digest()
else:
chained = record_hash
self._chain_hashes.append(chained)
idx = len(self._records)
self._records.append((seq, act_record, act_record.jti))
self._jti_index[act_record.jti] = idx
wid = act_record.wid
if wid not in self._wid_index:
self._wid_index[wid] = []
self._wid_index[wid].append(idx)
return seq
def get(self, jti: str) -> ACTRecord | None:
"""Retrieve a record by jti.
Reference: ACT §10, requirement 3 (lookup).
"""
idx = self._jti_index.get(jti)
if idx is None:
return None
return self._records[idx][1]
def list(self, wid: str | None = None) -> list[ACTRecord]:
"""List records, optionally filtered by workflow id.
If wid is None, returns all records. If wid is a string,
returns only records with that wid value.
Reference: ACT §10.
"""
if wid is None:
return [r[1] for r in self._records]
indices = self._wid_index.get(wid, [])
return [self._records[i][1] for i in indices]
def verify_integrity(self) -> bool:
"""Verify the hash chain integrity of the ledger.
Recomputes the hash chain from scratch and compares against
stored chain hashes. Returns True if all hashes match.
Reference: ACT §10, requirement 4 (integrity).
"""
if not self._records:
return True
prev_hash: bytes | None = None
for i, (seq, record, _jti) in enumerate(self._records):
record_hash = self._hash_record(record, seq)
if prev_hash is not None:
expected = hashlib.sha256(prev_hash + record_hash).digest()
else:
expected = record_hash
if i >= len(self._chain_hashes):
return False
if self._chain_hashes[i] != expected:
return False
prev_hash = expected
return True
def __len__(self) -> int:
return len(self._records)
def _hash_record(self, record: ACTRecord, seq: int) -> bytes:
"""Compute a deterministic hash of a record for chain integrity."""
claims = record.to_claims()
# Include sequence number in hash for ordering integrity
claims["_seq"] = seq
canonical = json.dumps(claims, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).digest()
def _immutable_guard(self) -> None:
"""Internal method — not callable externally.
The ledger has no update/delete methods by design.
This exists to make the intent explicit.
"""
raise ACTLedgerImmutabilityError(
"Ledger records cannot be modified or deleted"
)

View File

@@ -0,0 +1,96 @@
"""ACT Phase 1 to Phase 2 transition logic.
Handles the transition from Authorization Mandate to Execution Record,
including re-signing by the executing agent (sub).
Reference: ACT §3.2, §3.3 (Lifecycle State Machine).
"""
from __future__ import annotations
import time
from typing import Any
from .crypto import PrivateKey, sign as crypto_sign
from .errors import ACTCapabilityError, ACTPhaseError, ACTValidationError
from .token import (
ACTMandate,
ACTRecord,
ErrorClaim,
encode_jws,
)
def transition_to_record(
mandate: ACTMandate,
*,
sub_kid: str,
sub_private_key: PrivateKey,
exec_act: str,
pred: list[str] | None = None,
exec_ts: int | None = None,
status: str = "completed",
inp_hash: str | None = None,
out_hash: str | None = None,
err: ErrorClaim | None = None,
) -> tuple[ACTRecord, str]:
"""Transition a Phase 1 mandate to a Phase 2 execution record.
The executing agent (sub) adds execution claims and re-signs the
complete token with its own private key. The kid in the Phase 2
JOSE header MUST reference sub's key, not iss's key.
All Phase 1 claims are preserved unchanged in the Phase 2 token.
Reference: ACT §3.2, §8.2 step 17.
Args:
mandate: The Phase 1 ACTMandate to transition.
sub_kid: The kid for the sub agent's signing key.
sub_private_key: The sub agent's private key for re-signing.
exec_act: The action actually performed (must match a cap[].action).
pred: Predecessor task jti values (DAG dependencies). Empty list for root tasks.
exec_ts: Execution timestamp (defaults to current time).
status: Execution status: "completed", "failed", or "partial".
inp_hash: Base64url SHA-256 hash of input data (optional).
out_hash: Base64url SHA-256 hash of output data (optional).
err: Error details when status is "failed" or "partial".
Returns:
Tuple of (ACTRecord, JWS compact serialization string).
Raises:
ACTPhaseError: If the mandate is already a Phase 2 token.
ACTCapabilityError: If exec_act does not match any cap[].action.
ACTValidationError: If the resulting record fails validation.
"""
if mandate.is_phase2():
raise ACTPhaseError("Cannot transition: token is already Phase 2")
# Verify exec_act matches a capability
cap_actions = {c.action for c in mandate.cap}
if exec_act not in cap_actions:
raise ACTCapabilityError(
f"exec_act {exec_act!r} does not match any cap[].action: "
f"{sorted(cap_actions)}"
)
record = ACTRecord.from_mandate(
mandate,
kid=sub_kid,
exec_act=exec_act,
pred=pred if pred is not None else [],
exec_ts=exec_ts if exec_ts is not None else int(time.time()),
status=status,
inp_hash=inp_hash,
out_hash=out_hash,
err=err,
)
record.validate()
# Re-sign with sub's private key
signature = crypto_sign(sub_private_key, record.signing_input())
compact = encode_jws(record, signature)
return record, compact

View File

@@ -0,0 +1,734 @@
"""ACT token structures and JWS Compact Serialization.
Defines ACTMandate (Phase 1) and ACTRecord (Phase 2) dataclasses,
plus JWS encoding/decoding primitives for ACT tokens.
Reference: ACT §3 (Lifecycle), §4 (Token Format).
"""
from __future__ import annotations
import base64
import json
import re
import time
import uuid
from dataclasses import dataclass, field
from typing import Any
from .errors import ACTPhaseError, ACTValidationError
# Allowed algorithms per ACT §4.1 — symmetric and "none" are forbidden.
ALLOWED_ALGORITHMS: frozenset[str] = frozenset({"EdDSA", "ES256"})
# Forbidden algorithm prefixes/values per ACT §4.1.
_FORBIDDEN_ALGORITHMS: frozenset[str] = frozenset({
"none", "HS256", "HS384", "HS512",
})
# Required typ value per ACT §4.1.
ACT_TYP: str = "act+jwt"
# ABNF for action names: component *("." component)
# component = ALPHA *(ALPHA / DIGIT / "-" / "_")
_ACTION_RE = re.compile(
r"^[A-Za-z][A-Za-z0-9\-_]*(?:\.[A-Za-z][A-Za-z0-9\-_]*)*$"
)
def _b64url_encode(data: bytes) -> str:
"""Base64url encode without padding (RFC 7515 §2)."""
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64url_decode(s: str) -> bytes:
"""Base64url decode with padding restoration."""
s = s + "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s)
def validate_action_name(action: str) -> None:
"""Validate an action name against ACT ABNF grammar.
Reference: ACT §4.2.2 (cap action names).
Raises:
ACTValidationError: If action does not match the required grammar.
"""
if not _ACTION_RE.match(action):
raise ACTValidationError(
f"Action name {action!r} does not conform to ACT ABNF grammar"
)
@dataclass(frozen=True)
class TaskClaim:
"""The 'task' claim object.
Reference: ACT §4.2.2.
"""
purpose: str
data_sensitivity: str | None = None
created_by: str | None = None
expires_at: int | None = None
def to_dict(self) -> dict[str, Any]:
d: dict[str, Any] = {"purpose": self.purpose}
if self.data_sensitivity is not None:
d["data_sensitivity"] = self.data_sensitivity
if self.created_by is not None:
d["created_by"] = self.created_by
if self.expires_at is not None:
d["expires_at"] = self.expires_at
return d
@classmethod
def from_dict(cls, d: dict[str, Any]) -> TaskClaim:
if "purpose" not in d:
raise ACTValidationError("task.purpose is required")
return cls(
purpose=d["purpose"],
data_sensitivity=d.get("data_sensitivity"),
created_by=d.get("created_by"),
expires_at=d.get("expires_at"),
)
@dataclass(frozen=True)
class Capability:
"""A single capability entry in the 'cap' array.
Reference: ACT §4.2.2.
"""
action: str
constraints: dict[str, Any] | None = None
def __post_init__(self) -> None:
validate_action_name(self.action)
def to_dict(self) -> dict[str, Any]:
d: dict[str, Any] = {"action": self.action}
if self.constraints is not None:
d["constraints"] = self.constraints
return d
@classmethod
def from_dict(cls, d: dict[str, Any]) -> Capability:
if "action" not in d:
raise ACTValidationError("cap[].action is required")
return cls(
action=d["action"],
constraints=d.get("constraints"),
)
@dataclass(frozen=True)
class DelegationEntry:
"""A single entry in del.chain.
Reference: ACT §4.2.2 (del), §6 (Delegation Chain).
"""
delegator: str
jti: str
sig: str
def to_dict(self) -> dict[str, str]:
return {"delegator": self.delegator, "jti": self.jti, "sig": self.sig}
@classmethod
def from_dict(cls, d: dict[str, Any]) -> DelegationEntry:
for key in ("delegator", "jti", "sig"):
if key not in d:
raise ACTValidationError(f"del.chain[].{key} is required")
return cls(
delegator=d["delegator"], jti=d["jti"], sig=d["sig"]
)
@dataclass(frozen=True)
class Delegation:
"""The 'del' claim object.
Reference: ACT §4.2.2 (del), §6 (Delegation Chain).
"""
depth: int
max_depth: int
chain: list[DelegationEntry] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"depth": self.depth,
"max_depth": self.max_depth,
"chain": [e.to_dict() for e in self.chain],
}
@classmethod
def from_dict(cls, d: dict[str, Any]) -> Delegation:
for key in ("depth", "max_depth"):
if key not in d:
raise ACTValidationError(f"del.{key} is required")
chain_raw = d.get("chain", [])
chain = [DelegationEntry.from_dict(e) for e in chain_raw]
return cls(depth=d["depth"], max_depth=d["max_depth"], chain=chain)
@dataclass(frozen=True)
class Oversight:
"""The 'oversight' claim object.
Reference: ACT §4.2.2 (oversight).
"""
requires_approval_for: list[str] = field(default_factory=list)
approval_ref: str | None = None
def to_dict(self) -> dict[str, Any]:
d: dict[str, Any] = {
"requires_approval_for": self.requires_approval_for
}
if self.approval_ref is not None:
d["approval_ref"] = self.approval_ref
return d
@classmethod
def from_dict(cls, d: dict[str, Any]) -> Oversight:
return cls(
requires_approval_for=d.get("requires_approval_for", []),
approval_ref=d.get("approval_ref"),
)
@dataclass(frozen=True)
class ErrorClaim:
"""The 'err' claim object for failed/partial execution.
Reference: ACT §4.3.
"""
code: str
detail: str
def to_dict(self) -> dict[str, str]:
return {"code": self.code, "detail": self.detail}
@classmethod
def from_dict(cls, d: dict[str, Any]) -> ErrorClaim:
for key in ("code", "detail"):
if key not in d:
raise ACTValidationError(f"err.{key} is required")
return cls(code=d["code"], detail=d["detail"])
@dataclass
class ACTMandate:
"""Phase 1 Authorization Mandate.
Represents a signed authorization from an issuing agent to a target
agent, encoding capabilities, constraints, and delegation provenance.
Reference: ACT §3.1, §4.1, §4.2.
"""
# JOSE header fields
alg: str
kid: str
x5c: list[str] | None = None
did: str | None = None
# Required JWT claims
iss: str = ""
sub: str = ""
aud: str | list[str] = ""
iat: int = 0
exp: int = 0
jti: str = field(default_factory=lambda: str(uuid.uuid4()))
# Optional standard claims
wid: str | None = None
# Required ACT claims
task: TaskClaim = field(default_factory=lambda: TaskClaim(purpose=""))
cap: list[Capability] = field(default_factory=list)
# Optional ACT claims
delegation: Delegation | None = None
oversight: Oversight | None = None
def validate(self) -> None:
"""Validate structural correctness of this mandate.
Reference: ACT §4.1, §4.2, §8.1 step 11.
Raises:
ACTValidationError: If any required field is missing or invalid.
"""
_validate_algorithm(self.alg)
if not self.kid:
raise ACTValidationError("kid is required in JOSE header")
for claim_name in ("iss", "sub", "aud", "jti"):
val = getattr(self, claim_name)
if not val:
raise ACTValidationError(f"{claim_name} claim is required")
if self.iat <= 0:
raise ACTValidationError("iat must be a positive NumericDate")
if self.exp <= 0:
raise ACTValidationError("exp must be a positive NumericDate")
if not self.task.purpose:
raise ACTValidationError("task.purpose is required")
if not self.cap:
raise ACTValidationError("cap must contain at least one capability")
def to_header(self) -> dict[str, Any]:
"""Build JOSE header dict.
Reference: ACT §4.1.
"""
h: dict[str, Any] = {
"alg": self.alg,
"typ": ACT_TYP,
"kid": self.kid,
}
if self.x5c is not None:
h["x5c"] = self.x5c
if self.did is not None:
h["did"] = self.did
return h
def to_claims(self) -> dict[str, Any]:
"""Build JWT claims dict (Phase 1 claims only).
Reference: ACT §4.2.
"""
c: dict[str, Any] = {
"iss": self.iss,
"sub": self.sub,
"aud": self.aud,
"iat": self.iat,
"exp": self.exp,
"jti": self.jti,
"task": self.task.to_dict(),
"cap": [cap.to_dict() for cap in self.cap],
}
if self.wid is not None:
c["wid"] = self.wid
if self.delegation is not None:
c["del"] = self.delegation.to_dict()
if self.oversight is not None:
c["oversight"] = self.oversight.to_dict()
return c
def signing_input(self) -> bytes:
"""Compute the JWS signing input (header.payload) as bytes.
Reference: RFC 7515 §5.1.
"""
header_b64 = _b64url_encode(
json.dumps(self.to_header(), separators=(",", ":")).encode()
)
payload_b64 = _b64url_encode(
json.dumps(self.to_claims(), separators=(",", ":")).encode()
)
return f"{header_b64}.{payload_b64}".encode("ascii")
def is_phase2(self) -> bool:
"""Return False; mandates are always Phase 1."""
return False
@classmethod
def from_claims(
cls,
header: dict[str, Any],
claims: dict[str, Any],
) -> ACTMandate:
"""Construct an ACTMandate from parsed header and claims dicts.
Reference: ACT §4.1, §4.2.
Raises:
ACTValidationError: If required fields are missing.
ACTPhaseError: If exec_act is present (this is a Phase 2 token).
"""
if "exec_act" in claims:
raise ACTPhaseError(
"Token contains exec_act; use ACTRecord.from_claims instead"
)
del_raw = claims.get("del")
delegation = Delegation.from_dict(del_raw) if del_raw else None
oversight_raw = claims.get("oversight")
oversight_obj = Oversight.from_dict(oversight_raw) if oversight_raw else None
task_raw = claims.get("task")
if task_raw is None:
raise ACTValidationError("task claim is required")
cap_raw = claims.get("cap")
if cap_raw is None:
raise ACTValidationError("cap claim is required")
return cls(
alg=header.get("alg", ""),
kid=header.get("kid", ""),
x5c=header.get("x5c"),
did=header.get("did"),
iss=claims.get("iss", ""),
sub=claims.get("sub", ""),
aud=claims.get("aud", ""),
iat=claims.get("iat", 0),
exp=claims.get("exp", 0),
jti=claims.get("jti", ""),
wid=claims.get("wid"),
task=TaskClaim.from_dict(task_raw),
cap=[Capability.from_dict(c) for c in cap_raw],
delegation=delegation,
oversight=oversight_obj,
)
@dataclass
class ACTRecord:
"""Phase 2 Execution Record.
Contains all Phase 1 claims preserved unchanged, plus execution
claims added by the executing agent. Re-signed by sub's key.
Reference: ACT §3.2, §4.3.
"""
# JOSE header fields (Phase 2 header uses sub's kid)
alg: str
kid: str
x5c: list[str] | None = None
did: str | None = None
# Phase 1 claims (preserved)
iss: str = ""
sub: str = ""
aud: str | list[str] = ""
iat: int = 0
exp: int = 0
jti: str = ""
wid: str | None = None
task: TaskClaim = field(default_factory=lambda: TaskClaim(purpose=""))
cap: list[Capability] = field(default_factory=list)
delegation: Delegation | None = None
oversight: Oversight | None = None
# Phase 2 claims (execution)
exec_act: str = ""
pred: list[str] = field(default_factory=list)
exec_ts: int = 0
status: str = ""
inp_hash: str | None = None
out_hash: str | None = None
err: ErrorClaim | None = None
def validate(self) -> None:
"""Validate structural correctness of this record.
Reference: ACT §4.3, §8.2 steps 13-16.
Raises:
ACTValidationError: If any required field is missing or invalid.
"""
_validate_algorithm(self.alg)
if not self.kid:
raise ACTValidationError("kid is required in JOSE header")
for claim_name in ("iss", "sub", "aud", "jti"):
val = getattr(self, claim_name)
if not val:
raise ACTValidationError(f"{claim_name} claim is required")
if self.iat <= 0:
raise ACTValidationError("iat must be a positive NumericDate")
if self.exp <= 0:
raise ACTValidationError("exp must be a positive NumericDate")
if not self.task.purpose:
raise ACTValidationError("task.purpose is required")
if not self.cap:
raise ACTValidationError("cap must contain at least one capability")
if not self.exec_act:
raise ACTValidationError("exec_act is required in Phase 2")
validate_action_name(self.exec_act)
if self.exec_ts <= 0:
raise ACTValidationError("exec_ts must be a positive NumericDate")
if self.status not in ("completed", "failed", "partial"):
raise ACTValidationError(
f"status must be one of completed/failed/partial, got {self.status!r}"
)
def to_header(self) -> dict[str, Any]:
"""Build JOSE header dict for Phase 2.
In Phase 2, kid MUST reference the sub agent's key.
Reference: ACT §4.1, §8.2 step 17.
"""
h: dict[str, Any] = {
"alg": self.alg,
"typ": ACT_TYP,
"kid": self.kid,
}
if self.x5c is not None:
h["x5c"] = self.x5c
if self.did is not None:
h["did"] = self.did
return h
def to_claims(self) -> dict[str, Any]:
"""Build JWT claims dict (Phase 1 + Phase 2 claims).
Reference: ACT §4.2, §4.3.
"""
c: dict[str, Any] = {
"iss": self.iss,
"sub": self.sub,
"aud": self.aud,
"iat": self.iat,
"exp": self.exp,
"jti": self.jti,
"task": self.task.to_dict(),
"cap": [cap.to_dict() for cap in self.cap],
"exec_act": self.exec_act,
"pred": self.pred,
"exec_ts": self.exec_ts,
"status": self.status,
}
if self.wid is not None:
c["wid"] = self.wid
if self.delegation is not None:
c["del"] = self.delegation.to_dict()
if self.oversight is not None:
c["oversight"] = self.oversight.to_dict()
if self.inp_hash is not None:
c["inp_hash"] = self.inp_hash
if self.out_hash is not None:
c["out_hash"] = self.out_hash
if self.err is not None:
c["err"] = self.err.to_dict()
return c
def signing_input(self) -> bytes:
"""Compute the JWS signing input (header.payload) as bytes.
Reference: RFC 7515 §5.1.
"""
header_b64 = _b64url_encode(
json.dumps(self.to_header(), separators=(",", ":")).encode()
)
payload_b64 = _b64url_encode(
json.dumps(self.to_claims(), separators=(",", ":")).encode()
)
return f"{header_b64}.{payload_b64}".encode("ascii")
def is_phase2(self) -> bool:
"""Return True; records are always Phase 2."""
return True
@classmethod
def from_mandate(
cls,
mandate: ACTMandate,
*,
kid: str,
exec_act: str,
pred: list[str] | None = None,
exec_ts: int | None = None,
status: str = "completed",
inp_hash: str | None = None,
out_hash: str | None = None,
err: ErrorClaim | None = None,
) -> ACTRecord:
"""Create an ACTRecord by transitioning a mandate to Phase 2.
The kid MUST be the sub agent's key identifier.
Reference: ACT §3.2, §4.3.
"""
return cls(
alg=mandate.alg,
kid=kid,
x5c=mandate.x5c,
did=mandate.did,
iss=mandate.iss,
sub=mandate.sub,
aud=mandate.aud,
iat=mandate.iat,
exp=mandate.exp,
jti=mandate.jti,
wid=mandate.wid,
task=mandate.task,
cap=mandate.cap,
delegation=mandate.delegation,
oversight=mandate.oversight,
exec_act=exec_act,
pred=pred if pred is not None else [],
exec_ts=exec_ts if exec_ts is not None else int(time.time()),
status=status,
inp_hash=inp_hash,
out_hash=out_hash,
err=err,
)
@classmethod
def from_claims(
cls,
header: dict[str, Any],
claims: dict[str, Any],
) -> ACTRecord:
"""Construct an ACTRecord from parsed header and claims dicts.
Reference: ACT §4.1, §4.2, §4.3.
Raises:
ACTValidationError: If required fields are missing.
ACTPhaseError: If exec_act is absent (this is a Phase 1 token).
"""
if "exec_act" not in claims:
raise ACTPhaseError(
"Token does not contain exec_act; use ACTMandate.from_claims instead"
)
del_raw = claims.get("del")
delegation = Delegation.from_dict(del_raw) if del_raw else None
oversight_raw = claims.get("oversight")
oversight_obj = Oversight.from_dict(oversight_raw) if oversight_raw else None
task_raw = claims.get("task")
if task_raw is None:
raise ACTValidationError("task claim is required")
cap_raw = claims.get("cap")
if cap_raw is None:
raise ACTValidationError("cap claim is required")
err_raw = claims.get("err")
err_obj = ErrorClaim.from_dict(err_raw) if err_raw else None
return cls(
alg=header.get("alg", ""),
kid=header.get("kid", ""),
x5c=header.get("x5c"),
did=header.get("did"),
iss=claims.get("iss", ""),
sub=claims.get("sub", ""),
aud=claims.get("aud", ""),
iat=claims.get("iat", 0),
exp=claims.get("exp", 0),
jti=claims.get("jti", ""),
wid=claims.get("wid"),
task=TaskClaim.from_dict(task_raw),
cap=[Capability.from_dict(c) for c in cap_raw],
delegation=delegation,
oversight=oversight_obj,
exec_act=claims["exec_act"],
pred=claims.get("pred", []),
exec_ts=claims.get("exec_ts", 0),
status=claims.get("status", ""),
inp_hash=claims.get("inp_hash"),
out_hash=claims.get("out_hash"),
err=err_obj,
)
# --- JWS Compact Serialization ---
def encode_jws(
token: ACTMandate | ACTRecord,
signature: bytes,
) -> str:
"""Encode a token and signature as JWS Compact Serialization.
Returns header.payload.signature (three base64url segments).
Reference: RFC 7515 §3.1, ACT §4.
"""
signing_input = token.signing_input().decode("ascii")
sig_b64 = _b64url_encode(signature)
return f"{signing_input}.{sig_b64}"
def decode_jws(compact: str) -> tuple[dict[str, Any], dict[str, Any], bytes, bytes]:
"""Decode a JWS Compact Serialization string.
Returns (header_dict, claims_dict, signature_bytes, signing_input_bytes).
Reference: RFC 7515 §5.2, ACT §4.
Raises:
ACTValidationError: If the token is malformed.
"""
parts = compact.split(".")
if len(parts) != 3:
raise ACTValidationError(
f"JWS Compact Serialization requires 3 parts, got {len(parts)}"
)
try:
header = json.loads(_b64url_decode(parts[0]))
except (json.JSONDecodeError, Exception) as e:
raise ACTValidationError(f"Invalid JOSE header: {e}") from e
try:
claims = json.loads(_b64url_decode(parts[1]))
except (json.JSONDecodeError, Exception) as e:
raise ACTValidationError(f"Invalid JWT claims: {e}") from e
try:
signature = _b64url_decode(parts[2])
except Exception as e:
raise ACTValidationError(f"Invalid signature encoding: {e}") from e
signing_input = f"{parts[0]}.{parts[1]}".encode("ascii")
# Validate header requirements per ACT §4.1
typ = header.get("typ")
if typ != ACT_TYP:
raise ACTValidationError(
f"typ must be {ACT_TYP!r}, got {typ!r}"
)
alg = header.get("alg", "")
_validate_algorithm(alg)
if "kid" not in header:
raise ACTValidationError("kid is required in JOSE header")
return header, claims, signature, signing_input
def parse_token(compact: str) -> ACTMandate | ACTRecord:
"""Parse a JWS compact string into an ACTMandate or ACTRecord.
Determines phase by presence of exec_act claim.
Reference: ACT §3 (phase determination).
Returns:
ACTMandate for Phase 1, ACTRecord for Phase 2.
"""
header, claims, _, _ = decode_jws(compact)
if "exec_act" in claims:
return ACTRecord.from_claims(header, claims)
return ACTMandate.from_claims(header, claims)
def _validate_algorithm(alg: str) -> None:
"""Check algorithm is allowed per ACT §4.1.
Raises:
ACTValidationError: If algorithm is forbidden or unsupported.
"""
if alg in _FORBIDDEN_ALGORITHMS or alg.upper() in _FORBIDDEN_ALGORITHMS:
raise ACTValidationError(
f"Algorithm {alg!r} is forbidden by ACT specification"
)
if alg not in ALLOWED_ALGORITHMS:
raise ACTValidationError(
f"Unsupported algorithm {alg!r}; allowed: {sorted(ALLOWED_ALGORITHMS)}"
)

View File

@@ -0,0 +1,639 @@
"""ACT Appendix B test vectors.
Generates and validates all 15 test vectors from Appendix B of
draft-nennemann-act-01. Each vector includes description, input
parameters, and expected output or exception.
Reference: ACT Appendix B (Test Vectors).
"""
from __future__ import annotations
import time
import uuid
from dataclasses import dataclass, field
from typing import Any
from .crypto import (
ACTKeyResolver,
KeyRegistry,
PrivateKey,
PublicKey,
b64url_sha256,
compute_sha256,
generate_ed25519_keypair,
sign as crypto_sign,
verify as crypto_verify,
)
from .dag import validate_dag
from .delegation import create_delegated_mandate, verify_capability_subset
from .errors import (
ACTAudienceMismatchError,
ACTCapabilityError,
ACTDAGError,
ACTDelegationError,
ACTExpiredError,
ACTPrivilegeEscalationError,
ACTSignatureError,
ACTValidationError,
)
from .ledger import ACTLedger
from .lifecycle import transition_to_record
from .token import (
ACTMandate,
ACTRecord,
Capability,
Delegation,
DelegationEntry,
ErrorClaim,
Oversight,
TaskClaim,
_b64url_encode,
decode_jws,
encode_jws,
)
from .verify import ACTVerifier
@dataclass
class TestVector:
"""A single test vector."""
id: str
description: str
valid: bool
expected_exception: type[Exception] | None = None
compact: str = ""
record: ACTMandate | ACTRecord | None = None
def generate_vectors() -> tuple[list[TestVector], dict[str, Any]]:
"""Generate all Appendix B test vectors.
Returns a list of TestVector objects and a context dict containing
keys and other state needed for validation.
Reference: ACT Appendix B.
"""
# Fixed timestamp for deterministic vectors
base_time = 1772064000
# Generate key pairs for test agents
iss_priv, iss_pub = generate_ed25519_keypair()
sub_priv, sub_pub = generate_ed25519_keypair()
agent_c_priv, agent_c_pub = generate_ed25519_keypair()
# Fixed JTIs for cross-referencing
jti_b1 = "550e8400-e29b-41d4-a716-446655440001"
jti_b2 = "550e8400-e29b-41d4-a716-446655440002"
jti_b3_parent1 = "550e8400-e29b-41d4-a716-446655440003"
jti_b3_parent2 = "550e8400-e29b-41d4-a716-446655440004"
jti_b3 = "550e8400-e29b-41d4-a716-446655440005"
jti_b4 = "550e8400-e29b-41d4-a716-446655440006"
jti_b5 = "550e8400-e29b-41d4-a716-446655440007"
wid = "a0b1c2d3-e4f5-6789-abcd-ef0123456789"
# Key registry
registry = KeyRegistry()
registry.register("iss-key", iss_pub)
registry.register("sub-key", sub_pub)
registry.register("agent-c-key", agent_c_pub)
resolver = ACTKeyResolver(registry=registry)
vectors: list[TestVector] = []
compacts: dict[str, str] = {} # jti → compact for delegation refs
# --- B.1: Phase 1 — Root mandate, Tier 1, Ed25519, no delegation ---
mandate_b1 = ACTMandate(
alg="EdDSA",
kid="iss-key",
iss="agent-issuer",
sub="agent-subject",
aud=["agent-subject", "https://ledger.example.com"],
iat=base_time,
exp=base_time + 900,
jti=jti_b1,
wid=wid,
task=TaskClaim(
purpose="validate_data",
data_sensitivity="restricted",
),
cap=[
Capability(action="read.data", constraints={"max_records": 10}),
Capability(action="write.result"),
],
delegation=Delegation(depth=0, max_depth=2, chain=[]),
)
mandate_b1.validate()
sig_b1 = crypto_sign(iss_priv, mandate_b1.signing_input())
compact_b1 = encode_jws(mandate_b1, sig_b1)
compacts[jti_b1] = compact_b1
vectors.append(TestVector(
id="B.1",
description="Phase 1 ACT — root mandate, Tier 1 (Ed25519), no delegation",
valid=True,
compact=compact_b1,
record=mandate_b1,
))
# --- B.2: Phase 2 — Completed execution from B.1 ---
record_b2, compact_b2 = transition_to_record(
mandate_b1,
sub_kid="sub-key",
sub_private_key=sub_priv,
exec_act="read.data",
pred=[],
exec_ts=base_time + 300,
status="completed",
inp_hash=b64url_sha256(b"test input data"),
out_hash=b64url_sha256(b"test output data"),
)
compacts[jti_b2] = compact_b2
vectors.append(TestVector(
id="B.2",
description="Phase 2 ACT — completed execution, transition from B.1 mandate",
valid=True,
compact=compact_b2,
record=record_b2,
))
# --- B.3: Phase 2 — Fan-in, two parent jti values ---
# Create two parent records first
parent1_mandate = ACTMandate(
alg="EdDSA", kid="iss-key",
iss="agent-issuer", sub="agent-subject",
aud="agent-subject",
iat=base_time, exp=base_time + 900,
jti=jti_b3_parent1, wid=wid,
task=TaskClaim(purpose="branch_a"),
cap=[Capability(action="compute.result")],
delegation=Delegation(depth=0, max_depth=1, chain=[]),
)
sig_p1 = crypto_sign(iss_priv, parent1_mandate.signing_input())
compact_p1 = encode_jws(parent1_mandate, sig_p1)
parent1_record, parent1_compact = transition_to_record(
parent1_mandate, sub_kid="sub-key", sub_private_key=sub_priv,
exec_act="compute.result", pred=[], exec_ts=base_time + 100,
status="completed",
)
parent2_mandate = ACTMandate(
alg="EdDSA", kid="iss-key",
iss="agent-issuer", sub="agent-subject",
aud="agent-subject",
iat=base_time, exp=base_time + 900,
jti=jti_b3_parent2, wid=wid,
task=TaskClaim(purpose="branch_b"),
cap=[Capability(action="compute.result")],
delegation=Delegation(depth=0, max_depth=1, chain=[]),
)
sig_p2 = crypto_sign(iss_priv, parent2_mandate.signing_input())
compact_p2 = encode_jws(parent2_mandate, sig_p2)
parent2_record, parent2_compact = transition_to_record(
parent2_mandate, sub_kid="sub-key", sub_private_key=sub_priv,
exec_act="compute.result", pred=[], exec_ts=base_time + 150,
status="completed",
)
# Fan-in record depends on both parents
fanin_mandate = ACTMandate(
alg="EdDSA", kid="iss-key",
iss="agent-issuer", sub="agent-subject",
aud="agent-subject",
iat=base_time, exp=base_time + 900,
jti=jti_b3, wid=wid,
task=TaskClaim(purpose="merge_results"),
cap=[Capability(action="compute.result")],
delegation=Delegation(depth=0, max_depth=1, chain=[]),
)
sig_fi = crypto_sign(iss_priv, fanin_mandate.signing_input())
fanin_record, fanin_compact = transition_to_record(
fanin_mandate, sub_kid="sub-key", sub_private_key=sub_priv,
exec_act="compute.result",
pred=[jti_b3_parent1, jti_b3_parent2],
exec_ts=base_time + 200,
status="completed",
)
vectors.append(TestVector(
id="B.3",
description="Phase 2 ACT — fan-in, two predecessor jti values from parallel branches",
valid=True,
compact=fanin_compact,
record=fanin_record,
))
# --- B.4: Phase 1 — Delegated mandate (depth=1) ---
delegated_b4, _ = create_delegated_mandate(
parent_mandate=mandate_b1,
parent_compact=compact_b1,
delegator_private_key=iss_priv,
sub="agent-c",
kid="iss-key",
iss="agent-issuer",
aud="agent-c",
iat=base_time + 10,
exp=base_time + 600,
jti=jti_b4,
cap=[Capability(action="read.data", constraints={"max_records": 5})],
task=TaskClaim(purpose="delegated_read"),
)
sig_b4 = crypto_sign(iss_priv, delegated_b4.signing_input())
compact_b4 = encode_jws(delegated_b4, sig_b4)
compacts[jti_b4] = compact_b4
vectors.append(TestVector(
id="B.4",
description="Phase 1 ACT — delegated mandate (depth=1), chain entry with sig",
valid=True,
compact=compact_b4,
record=delegated_b4,
))
# --- B.5: Phase 2 — Delegated execution record ---
record_b5, compact_b5 = transition_to_record(
delegated_b4,
sub_kid="agent-c-key",
sub_private_key=agent_c_priv,
exec_act="read.data",
pred=[],
exec_ts=base_time + 350,
status="completed",
)
vectors.append(TestVector(
id="B.5",
description="Phase 2 ACT — delegated execution record",
valid=True,
compact=compact_b5,
record=record_b5,
))
# --- B.6: del.depth > del.max_depth → ACTDelegationError ---
bad_depth_mandate = ACTMandate(
alg="EdDSA", kid="iss-key",
iss="agent-issuer", sub="agent-subject",
aud="agent-subject",
iat=base_time, exp=base_time + 900,
jti=str(uuid.uuid4()),
task=TaskClaim(purpose="bad_depth"),
cap=[Capability(action="read.data")],
delegation=Delegation(depth=3, max_depth=2, chain=[
DelegationEntry(delegator="a", jti="j1", sig="sig1"),
DelegationEntry(delegator="b", jti="j2", sig="sig2"),
DelegationEntry(delegator="c", jti="j3", sig="sig3"),
]),
)
sig_b6 = crypto_sign(iss_priv, bad_depth_mandate.signing_input())
compact_b6 = encode_jws(bad_depth_mandate, sig_b6)
vectors.append(TestVector(
id="B.6",
description="del.depth > del.max_depth → ACTDelegationError",
valid=False,
expected_exception=ACTDelegationError,
compact=compact_b6,
))
# --- B.7: cap escalation in delegated ACT → ACTPrivilegeEscalationError ---
vectors.append(TestVector(
id="B.7",
description="cap escalation in delegated ACT → ACTPrivilegeEscalationError",
valid=False,
expected_exception=ACTPrivilegeEscalationError,
))
# --- B.8: exec_act not in cap → ACTCapabilityError ---
bad_exec_mandate = ACTMandate(
alg="EdDSA", kid="iss-key",
iss="agent-issuer", sub="agent-subject",
aud="agent-subject",
iat=base_time, exp=base_time + 900,
jti=str(uuid.uuid4()),
task=TaskClaim(purpose="bad_exec"),
cap=[Capability(action="read.data")],
delegation=Delegation(depth=0, max_depth=1, chain=[]),
)
sig_b8m = crypto_sign(iss_priv, bad_exec_mandate.signing_input())
# Manually construct Phase 2 with wrong exec_act
bad_exec_record = ACTRecord(
alg="EdDSA", kid="sub-key",
iss="agent-issuer", sub="agent-subject",
aud="agent-subject",
iat=base_time, exp=base_time + 900,
jti=bad_exec_mandate.jti,
task=TaskClaim(purpose="bad_exec"),
cap=[Capability(action="read.data")],
exec_act="delete.everything",
pred=[], exec_ts=base_time + 100, status="completed",
)
sig_b8 = crypto_sign(sub_priv, bad_exec_record.signing_input())
compact_b8 = encode_jws(bad_exec_record, sig_b8)
vectors.append(TestVector(
id="B.8",
description="exec_act not in cap → ACTCapabilityError",
valid=False,
expected_exception=ACTCapabilityError,
compact=compact_b8,
))
# --- B.9: DAG cycle (pred references own jti) → ACTDAGError ---
cycle_jti = str(uuid.uuid4())
cycle_record = ACTRecord(
alg="EdDSA", kid="sub-key",
iss="agent-issuer", sub="agent-subject",
aud="agent-subject",
iat=base_time, exp=base_time + 900,
jti=cycle_jti,
task=TaskClaim(purpose="cycle_test"),
cap=[Capability(action="read.data")],
exec_act="read.data",
pred=[cycle_jti],
exec_ts=base_time + 100, status="completed",
)
sig_b9 = crypto_sign(sub_priv, cycle_record.signing_input())
compact_b9 = encode_jws(cycle_record, sig_b9)
vectors.append(TestVector(
id="B.9",
description="DAG cycle (pred references own jti) → ACTDAGError",
valid=False,
expected_exception=ACTDAGError,
compact=compact_b9,
))
# --- B.10: Missing parent jti in DAG → ACTDAGError ---
missing_parent_record = ACTRecord(
alg="EdDSA", kid="sub-key",
iss="agent-issuer", sub="agent-subject",
aud="agent-subject",
iat=base_time, exp=base_time + 900,
jti=str(uuid.uuid4()),
task=TaskClaim(purpose="missing_parent"),
cap=[Capability(action="read.data")],
exec_act="read.data",
pred=["nonexistent-parent-jti"],
exec_ts=base_time + 100, status="completed",
)
sig_b10 = crypto_sign(sub_priv, missing_parent_record.signing_input())
compact_b10 = encode_jws(missing_parent_record, sig_b10)
vectors.append(TestVector(
id="B.10",
description="Missing parent jti in DAG → ACTDAGError",
valid=False,
expected_exception=ACTDAGError,
compact=compact_b10,
))
# --- B.11: Tampered payload (bit flip) → ACTSignatureError ---
# Take a valid compact and flip a byte in the payload
parts = compact_b1.split(".")
payload_bytes = bytearray(parts[1].encode("ascii"))
# Flip a character in the payload
flip_idx = len(payload_bytes) // 2
payload_bytes[flip_idx] = (payload_bytes[flip_idx] + 1) % 128
if payload_bytes[flip_idx] == 0:
payload_bytes[flip_idx] = 65 # 'A'
tampered_compact = f"{parts[0]}.{payload_bytes.decode('ascii')}.{parts[2]}"
vectors.append(TestVector(
id="B.11",
description="Tampered payload (bit flip in claims) → ACTSignatureError",
valid=False,
expected_exception=ACTSignatureError,
compact=tampered_compact,
))
# --- B.12: Expired token → ACTExpiredError ---
expired_mandate = ACTMandate(
alg="EdDSA", kid="iss-key",
iss="agent-issuer", sub="agent-subject",
aud="agent-subject",
iat=base_time - 3600,
exp=base_time - 2700, # expired 45 minutes ago
jti=str(uuid.uuid4()),
task=TaskClaim(purpose="expired_test"),
cap=[Capability(action="read.data")],
)
sig_b12 = crypto_sign(iss_priv, expired_mandate.signing_input())
compact_b12 = encode_jws(expired_mandate, sig_b12)
vectors.append(TestVector(
id="B.12",
description="Expired token → ACTExpiredError",
valid=False,
expected_exception=ACTExpiredError,
compact=compact_b12,
))
# --- B.13: Wrong audience → ACTAudienceMismatchError ---
wrong_aud_mandate = ACTMandate(
alg="EdDSA", kid="iss-key",
iss="agent-issuer", sub="wrong-agent",
aud="wrong-agent",
iat=base_time, exp=base_time + 900,
jti=str(uuid.uuid4()),
task=TaskClaim(purpose="wrong_aud_test"),
cap=[Capability(action="read.data")],
)
sig_b13 = crypto_sign(iss_priv, wrong_aud_mandate.signing_input())
compact_b13 = encode_jws(wrong_aud_mandate, sig_b13)
vectors.append(TestVector(
id="B.13",
description="Wrong audience → ACTAudienceMismatchError",
valid=False,
expected_exception=ACTAudienceMismatchError,
compact=compact_b13,
))
# --- B.14: Phase 2 re-signed by iss key instead of sub → ACTSignatureError ---
record_b14 = ACTRecord.from_mandate(
mandate_b1,
kid="sub-key", # claims to be sub's key
exec_act="read.data",
pred=[], exec_ts=base_time + 300, status="completed",
)
# But signed with ISS's private key (wrong signer)
sig_b14 = crypto_sign(iss_priv, record_b14.signing_input())
compact_b14 = encode_jws(record_b14, sig_b14)
vectors.append(TestVector(
id="B.14",
description="Phase 2 re-signed by iss key instead of sub → ACTSignatureError",
valid=False,
expected_exception=ACTSignatureError,
compact=compact_b14,
))
# --- B.15: Algorithm "none" → ACTValidationError ---
# Manually construct a JWS with alg: none
import json
import base64
none_header = base64.urlsafe_b64encode(
json.dumps({"alg": "none", "typ": "act+jwt", "kid": "k"}, separators=(",", ":")).encode()
).rstrip(b"=").decode()
none_payload = base64.urlsafe_b64encode(
json.dumps({"iss": "a", "sub": "b"}, separators=(",", ":")).encode()
).rstrip(b"=").decode()
compact_b15 = f"{none_header}.{none_payload}."
vectors.append(TestVector(
id="B.15",
description='Algorithm "none" → ACTValidationError',
valid=False,
expected_exception=ACTValidationError,
compact=compact_b15,
))
context = {
"iss_priv": iss_priv,
"iss_pub": iss_pub,
"sub_priv": sub_priv,
"sub_pub": sub_pub,
"agent_c_priv": agent_c_priv,
"agent_c_pub": agent_c_pub,
"registry": registry,
"resolver": resolver,
"base_time": base_time,
"compacts": compacts,
"parent1_record": parent1_record,
"parent2_record": parent2_record,
"mandate_b1": mandate_b1,
}
return vectors, context
def validate_vectors() -> bool:
"""Run all test vectors and validate results.
Returns True if all vectors pass.
Reference: ACT Appendix B.
"""
vectors, ctx = generate_vectors()
resolver = ctx["resolver"]
base_time = ctx["base_time"]
verifier = ACTVerifier(
key_resolver=resolver,
verifier_id="agent-subject",
trusted_issuers={"agent-issuer"},
)
passed = 0
failed = 0
for v in vectors:
try:
if v.id == "B.7":
# Special case: test cap escalation during delegation creation
try:
from .delegation import verify_capability_subset
verify_capability_subset(
[Capability(action="read.data", constraints={"max_records": 10})],
[Capability(action="read.data", constraints={"max_records": 100})],
)
print(f" FAIL {v.id}: Expected {v.expected_exception.__name__}")
failed += 1
except ACTPrivilegeEscalationError:
print(f" PASS {v.id}: {v.description}")
passed += 1
continue
if v.valid:
# Valid vectors: should parse and verify without error
header, claims, sig, si = decode_jws(v.compact)
kid = header["kid"]
pub = resolver.resolve(kid, header=header)
crypto_verify(pub, sig, si)
print(f" PASS {v.id}: {v.description}")
passed += 1
else:
# Invalid vectors: should raise the expected exception
try:
if v.expected_exception == ACTDelegationError:
header, claims, sig, si = decode_jws(v.compact)
kid = header["kid"]
pub = resolver.resolve(kid, header=header)
crypto_verify(pub, sig, si)
# Parse and check delegation
from .token import ACTMandate as _M
m = _M.from_claims(header, claims)
from .delegation import verify_delegation_chain
verify_delegation_chain(m, lambda d: resolver.resolve(d))
print(f" FAIL {v.id}: Expected {v.expected_exception.__name__}")
failed += 1
elif v.expected_exception == ACTCapabilityError:
header, claims, sig, si = decode_jws(v.compact)
kid = header["kid"]
pub = resolver.resolve(kid, header=header)
crypto_verify(pub, sig, si)
r = ACTRecord.from_claims(header, claims)
cap_actions = {c.action for c in r.cap}
if r.exec_act not in cap_actions:
raise ACTCapabilityError("exec_act mismatch")
print(f" FAIL {v.id}: Expected {v.expected_exception.__name__}")
failed += 1
elif v.expected_exception == ACTDAGError:
header, claims, sig, si = decode_jws(v.compact)
kid = header["kid"]
pub = resolver.resolve(kid, header=header)
crypto_verify(pub, sig, si)
r = ACTRecord.from_claims(header, claims)
ledger = ACTLedger()
validate_dag(r, ledger)
print(f" FAIL {v.id}: Expected {v.expected_exception.__name__}")
failed += 1
elif v.expected_exception == ACTExpiredError:
verifier.verify_mandate(v.compact, check_sub=False)
print(f" FAIL {v.id}: Expected {v.expected_exception.__name__}")
failed += 1
elif v.expected_exception == ACTAudienceMismatchError:
verifier.verify_mandate(
v.compact,
now=base_time + 100,
check_sub=False,
)
print(f" FAIL {v.id}: Expected {v.expected_exception.__name__}")
failed += 1
elif v.expected_exception == ACTSignatureError:
header, claims, sig, si = decode_jws(v.compact)
kid = header["kid"]
pub = resolver.resolve(kid, header=header)
crypto_verify(pub, sig, si)
print(f" FAIL {v.id}: Expected {v.expected_exception.__name__}")
failed += 1
elif v.expected_exception == ACTValidationError:
decode_jws(v.compact)
print(f" FAIL {v.id}: Expected {v.expected_exception.__name__}")
failed += 1
else:
print(f" SKIP {v.id}: Unknown expected exception type")
failed += 1
except Exception as e:
if isinstance(e, v.expected_exception):
print(f" PASS {v.id}: {v.description}")
passed += 1
else:
print(f" FAIL {v.id}: Expected {v.expected_exception.__name__}, "
f"got {type(e).__name__}: {e}")
failed += 1
except Exception as e:
print(f" FAIL {v.id}: Unexpected error: {type(e).__name__}: {e}")
failed += 1
print(f"\nResults: {passed} passed, {failed} failed out of {len(vectors)}")
return failed == 0

View File

@@ -0,0 +1,323 @@
"""ACT unified verification entry point.
Provides ACTVerifier with verify_mandate (Phase 1) and verify_record
(Phase 2) methods implementing the full verification procedures.
Reference: ACT §8 (Verification Procedure).
"""
from __future__ import annotations
import logging
import time
from typing import Any
from .crypto import ACTKeyResolver, PublicKey, verify as crypto_verify
from .dag import ACTStore, validate_dag
from .delegation import verify_delegation_chain
from .errors import (
ACTAudienceMismatchError,
ACTCapabilityError,
ACTExpiredError,
ACTPhaseError,
ACTSignatureError,
ACTValidationError,
)
from .token import (
ACTMandate,
ACTRecord,
decode_jws,
)
logger = logging.getLogger(__name__)
# Default clock skew tolerance for exp check — ACT §8.1 step 6.
DEFAULT_EXP_CLOCK_SKEW: int = 300 # 5 minutes
# Default clock skew tolerance for iat future check — ACT §8.1 step 7.
DEFAULT_IAT_FUTURE_TOLERANCE: int = 30 # 30 seconds
class ACTVerifier:
"""Unified ACT verification entry point.
Implements the full verification procedure for both Phase 1
(Authorization Mandate) and Phase 2 (Execution Record) tokens.
Reference: ACT §8.
"""
def __init__(
self,
key_resolver: ACTKeyResolver,
*,
verifier_id: str | None = None,
trusted_issuers: set[str] | None = None,
exp_clock_skew: int = DEFAULT_EXP_CLOCK_SKEW,
iat_future_tolerance: int = DEFAULT_IAT_FUTURE_TOLERANCE,
resolve_parent_compact: Any | None = None,
) -> None:
"""Initialize the verifier.
Args:
key_resolver: Key resolver for all trust tiers.
verifier_id: This verifier's own identifier (for aud/sub checks).
trusted_issuers: Set of trusted issuer identifiers.
If None, iss check is skipped.
exp_clock_skew: Maximum clock skew for expiration (seconds).
iat_future_tolerance: Maximum future iat tolerance (seconds).
resolve_parent_compact: Callback to resolve parent ACT compact
form by jti (for delegation chain).
"""
self._key_resolver = key_resolver
self._verifier_id = verifier_id
self._trusted_issuers = trusted_issuers
self._exp_clock_skew = exp_clock_skew
self._iat_future_tolerance = iat_future_tolerance
self._resolve_parent_compact = resolve_parent_compact
def verify_mandate(
self,
compact: str,
*,
now: int | None = None,
check_aud: bool = True,
check_sub: bool = True,
) -> ACTMandate:
"""Verify a Phase 1 Authorization Mandate.
Implements ACT §8.1 verification steps 1-13.
Args:
compact: JWS Compact Serialization of the Phase 1 ACT.
now: Current time override (for testing). Defaults to time.time().
check_aud: Whether to check aud contains verifier_id.
check_sub: Whether to check sub matches verifier_id.
Returns:
Verified ACTMandate.
Raises:
ACTValidationError: Malformed token (steps 2-3, 11).
ACTSignatureError: Signature failure (step 5).
ACTExpiredError: Token expired (step 6).
ACTAudienceMismatchError: Wrong audience (step 8).
ACTDelegationError: Invalid delegation chain (step 12).
"""
current_time = now if now is not None else int(time.time())
# Step 1: Parse JWS Compact Serialization
header, claims, signature, signing_input = decode_jws(compact)
# Steps 2-3: typ and alg checked by decode_jws
# Phase check: must NOT have exec_act
if "exec_act" in claims:
raise ACTPhaseError(
"Token contains exec_act — this is a Phase 2 token, "
"not a Phase 1 mandate"
)
# Step 4: Resolve public key for kid
kid = header["kid"]
public_key = self._key_resolver.resolve(kid, header=header)
# Step 5: Verify JWS signature
crypto_verify(public_key, signature, signing_input)
# Build mandate object for claim validation
mandate = ACTMandate.from_claims(header, claims)
# Step 6: Check exp not passed
if current_time > mandate.exp + self._exp_clock_skew:
raise ACTExpiredError(
f"Token expired: exp={mandate.exp}, "
f"now={current_time}, skew={self._exp_clock_skew}"
)
# Step 7: Check iat not unreasonably future
if mandate.iat > current_time + self._iat_future_tolerance:
raise ACTValidationError(
f"Token iat is too far in the future: iat={mandate.iat}, "
f"now={current_time}, tolerance={self._iat_future_tolerance}"
)
# Step 8: Check aud contains verifier's identity
if check_aud and self._verifier_id is not None:
aud = mandate.aud
if isinstance(aud, str):
aud_list = [aud]
else:
aud_list = aud
if self._verifier_id not in aud_list:
raise ACTAudienceMismatchError(
f"Verifier id {self._verifier_id!r} not in aud: {aud_list}"
)
# Step 9: Check iss is trusted
if self._trusted_issuers is not None:
if mandate.iss not in self._trusted_issuers:
raise ACTValidationError(
f"Issuer {mandate.iss!r} is not trusted"
)
# Step 10: Check sub matches verifier's identity
if check_sub and self._verifier_id is not None:
if mandate.sub != self._verifier_id:
raise ACTValidationError(
f"sub {mandate.sub!r} does not match verifier id "
f"{self._verifier_id!r}"
)
# Step 11: Check all required claims (done by from_claims + validate)
mandate.validate()
# Step 12: Verify delegation chain
if mandate.delegation is not None and mandate.delegation.chain:
def _resolve_key(delegator_id: str) -> PublicKey:
return self._key_resolver.resolve(delegator_id)
verify_delegation_chain(
mandate,
resolve_key=_resolve_key,
resolve_parent_compact=self._resolve_parent_compact,
)
return mandate
def verify_record(
self,
compact: str,
store: ACTStore | None = None,
*,
now: int | None = None,
check_aud: bool = True,
) -> ACTRecord:
"""Verify a Phase 2 Execution Record.
Implements all Phase 1 steps (§8.1) plus Phase 2 steps (§8.2).
Args:
compact: JWS Compact Serialization of the Phase 2 ACT.
store: ACT store for DAG validation. If None, DAG checks
are limited to capability consistency only.
now: Current time override (for testing).
check_aud: Whether to check aud contains verifier_id.
Returns:
Verified ACTRecord.
Raises:
ACTValidationError: Malformed token.
ACTSignatureError: Signature failure or wrong signer.
ACTExpiredError: Token expired.
ACTAudienceMismatchError: Wrong audience.
ACTCapabilityError: exec_act not in cap.
ACTDAGError: DAG validation failure.
"""
current_time = now if now is not None else int(time.time())
# Step 1: Parse JWS
header, claims, signature, signing_input = decode_jws(compact)
# Phase check
if "exec_act" not in claims:
raise ACTPhaseError(
"Token does not contain exec_act — this is a Phase 1 "
"mandate, not a Phase 2 record"
)
# Step 4: Resolve key — in Phase 2, kid MUST be sub's key
kid = header["kid"]
public_key = self._key_resolver.resolve(kid, header=header)
# Step 5: Verify JWS signature (Step 17: by sub's key)
crypto_verify(public_key, signature, signing_input)
# Build record
record = ACTRecord.from_claims(header, claims)
# Step 6: Check exp
if current_time > record.exp + self._exp_clock_skew:
raise ACTExpiredError(
f"Token expired: exp={record.exp}, "
f"now={current_time}, skew={self._exp_clock_skew}"
)
# Step 7: iat future check
if record.iat > current_time + self._iat_future_tolerance:
raise ACTValidationError(
f"Token iat is too far in the future: iat={record.iat}"
)
# Step 8: aud check
if check_aud and self._verifier_id is not None:
aud = record.aud
if isinstance(aud, str):
aud_list = [aud]
else:
aud_list = aud
if self._verifier_id not in aud_list:
raise ACTAudienceMismatchError(
f"Verifier id {self._verifier_id!r} not in aud: {aud_list}"
)
# Step 9: iss trust check
if self._trusted_issuers is not None:
if record.iss not in self._trusted_issuers:
raise ACTValidationError(
f"Issuer {record.iss!r} is not trusted"
)
# Step 11: required claims validation
record.validate()
# Step 12: delegation chain
if record.delegation is not None and record.delegation.chain:
def _resolve_key(delegator_id: str) -> PublicKey:
return self._key_resolver.resolve(delegator_id)
# Reuse verify_delegation_chain with ACTRecord fields
# (it accesses .delegation which exists on ACTRecord too)
from .delegation import verify_delegation_chain as _vdc
# Create a temporary mandate-like view — delegation chain
# verification only needs delegation and cap fields
mandate_view = ACTMandate(
alg=record.alg, kid=record.kid,
iss=record.iss, sub=record.sub, aud=record.aud,
iat=record.iat, exp=record.exp, jti=record.jti,
task=record.task, cap=record.cap,
delegation=record.delegation,
)
_vdc(
mandate_view,
resolve_key=_resolve_key,
resolve_parent_compact=self._resolve_parent_compact,
)
# Phase 2 step 13: exec_act matches cap[].action
cap_actions = {c.action for c in record.cap}
if record.exec_act not in cap_actions:
raise ACTCapabilityError(
f"exec_act {record.exec_act!r} does not match any "
f"cap[].action: {sorted(cap_actions)}"
)
# Phase 2 step 14: DAG validation
if store is not None:
validate_dag(record, store)
# Phase 2 step 15: exec_ts checks
if record.exec_ts < record.iat:
raise ACTValidationError(
f"exec_ts {record.exec_ts} is before iat {record.iat}"
)
if record.exec_ts > record.exp:
logger.warning(
"exec_ts %d is after exp %d — execution after mandate expiry",
record.exec_ts, record.exp,
)
# Phase 2 step 16: status validation (done by record.validate())
return record