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:
119
workspace/packages/act/act/__init__.py
Normal file
119
workspace/packages/act/act/__init__.py
Normal 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",
|
||||
]
|
||||
467
workspace/packages/act/act/crypto.py
Normal file
467
workspace/packages/act/act/crypto.py
Normal 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"
|
||||
)
|
||||
136
workspace/packages/act/act/dag.py
Normal file
136
workspace/packages/act/act/dag.py
Normal 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)
|
||||
333
workspace/packages/act/act/delegation.py
Normal file
333
workspace/packages/act/act/delegation.py
Normal 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
|
||||
131
workspace/packages/act/act/errors.py
Normal file
131
workspace/packages/act/act/errors.py
Normal 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).
|
||||
"""
|
||||
152
workspace/packages/act/act/ledger.py
Normal file
152
workspace/packages/act/act/ledger.py
Normal 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"
|
||||
)
|
||||
96
workspace/packages/act/act/lifecycle.py
Normal file
96
workspace/packages/act/act/lifecycle.py
Normal 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
|
||||
734
workspace/packages/act/act/token.py
Normal file
734
workspace/packages/act/act/token.py
Normal 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)}"
|
||||
)
|
||||
639
workspace/packages/act/act/vectors.py
Normal file
639
workspace/packages/act/act/vectors.py
Normal 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
|
||||
323
workspace/packages/act/act/verify.py
Normal file
323
workspace/packages/act/act/verify.py
Normal 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
|
||||
Reference in New Issue
Block a user