Files
ietf-draft-analyzer/workspace/act/act/verify.py
Christian Nennemann 2506b6325a
Some checks failed
CI / test (3.11) (push) Failing after 1m37s
CI / test (3.12) (push) Failing after 57s
feat: add draft data, gap analysis report, and workspace config
2026-04-06 18:47:15 +02:00

324 lines
11 KiB
Python

"""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