324 lines
11 KiB
Python
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
|