Restructure refimpl into go-lang and python subdirectories

Move Go reference implementation to refimpl/go-lang/ and add new
Python reference implementation in refimpl/python/. Update build.sh
with renamed draft and simplified tool paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 23:11:55 +01:00
parent ff795c72e6
commit bbf557e54b
52 changed files with 3972 additions and 341 deletions

View File

@@ -0,0 +1,61 @@
# WIMSE Execution Context Tokens (ECT) — Python reference implementation
# draft-nennemann-wimse-execution-context-00
from ect.types import (
ECT_TYPE,
POL_DECISION_APPROVED,
POL_DECISION_REJECTED,
POL_DECISION_PENDING_HUMAN_REVIEW,
Payload,
valid_pol_decision,
)
from ect.create import create, generate_key, CreateOptions, default_create_options
from ect.verify import (
ParsedECT,
parse,
verify,
VerifyOptions,
default_verify_options,
KeyResolver,
)
from ect.dag import (
ECTStore,
DAGConfig,
default_dag_config,
validate_dag,
)
from ect.ledger import Ledger, MemoryLedger, LedgerEntry, ErrTaskIDExists
from ect.config import Config, default_config, load_config_from_env
from ect.jti_cache import JTICache, new_jti_cache
__all__ = [
"ECT_TYPE",
"POL_DECISION_APPROVED",
"POL_DECISION_REJECTED",
"POL_DECISION_PENDING_HUMAN_REVIEW",
"Payload",
"valid_pol_decision",
"create",
"generate_key",
"CreateOptions",
"default_create_options",
"ParsedECT",
"parse",
"verify",
"VerifyOptions",
"default_verify_options",
"KeyResolver",
"ECTStore",
"DAGConfig",
"default_dag_config",
"validate_dag",
"Ledger",
"MemoryLedger",
"LedgerEntry",
"ErrTaskIDExists",
"Config",
"default_config",
"load_config_from_env",
"JTICache",
"new_jti_cache",
]

View File

@@ -0,0 +1,61 @@
"""Production config from environment."""
from __future__ import annotations
import os
from dataclasses import dataclass
ENV_IAT_MAX_AGE_MINUTES = "ECT_IAT_MAX_AGE_MINUTES"
ENV_IAT_MAX_FUTURE_SEC = "ECT_IAT_MAX_FUTURE_SEC"
ENV_DEFAULT_EXPIRY_MIN = "ECT_DEFAULT_EXPIRY_MIN"
ENV_JTI_REPLAY_CACHE_SIZE = "ECT_JTI_REPLAY_CACHE_SIZE"
ENV_JTI_REPLAY_TTL_MIN = "ECT_JTI_REPLAY_TTL_MIN"
@dataclass
class Config:
iat_max_age_sec: int = 900
iat_max_future_sec: int = 30
default_expiry_sec: int = 600
jti_replay_size: int = 0
jti_replay_ttl_sec: int = 3600
def create_options(self, key_id: str) -> "CreateOptions":
from ect.create import CreateOptions
return CreateOptions(
key_id=key_id,
default_expiry_sec=self.default_expiry_sec,
)
def verify_options(self) -> "VerifyOptions":
from ect.verify import VerifyOptions
from ect.dag import default_dag_config
return VerifyOptions(
iat_max_age_sec=self.iat_max_age_sec,
iat_max_future_sec=self.iat_max_future_sec,
dag=default_dag_config(),
)
def default_config() -> Config:
return Config()
def _int_env(name: str, default: int) -> int:
v = os.environ.get(name)
if v is None or v == "":
return default
try:
return int(v)
except ValueError:
return default
def load_config_from_env() -> Config:
c = default_config()
c.iat_max_age_sec = _int_env(ENV_IAT_MAX_AGE_MINUTES, 15) * 60
c.iat_max_future_sec = _int_env(ENV_IAT_MAX_FUTURE_SEC, 30)
c.default_expiry_sec = _int_env(ENV_DEFAULT_EXPIRY_MIN, 10) * 60
c.jti_replay_size = _int_env(ENV_JTI_REPLAY_CACHE_SIZE, 0)
c.jti_replay_ttl_sec = _int_env(ENV_JTI_REPLAY_TTL_MIN, 60) * 60
return c

View File

@@ -0,0 +1,115 @@
"""ECT creation: build and sign JWT with ES256."""
from __future__ import annotations
import copy
import time
from dataclasses import dataclass
from typing import Optional
import jwt
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
from ect.types import Payload, valid_pol_decision
from ect.validate import (
DEFAULT_MAX_PAR_LENGTH,
validate_ext,
validate_hash_format,
valid_uuid,
)
@dataclass
class CreateOptions:
key_id: str
iat_max_age_sec: int = 900 # 15 min
default_expiry_sec: int = 600 # 10 min
validate_uuids: bool = False
max_par_length: int = 0 # 0 = no limit; use DEFAULT_MAX_PAR_LENGTH for 100
def default_create_options() -> CreateOptions:
return CreateOptions(key_id="")
def _validate_payload(p: Payload, opts: CreateOptions) -> None:
if not p.iss:
raise ValueError("ect: iss required")
if not p.aud:
raise ValueError("ect: aud required")
if not p.jti:
raise ValueError("ect: jti required")
if not p.exec_act:
raise ValueError("ect: exec_act required")
if opts.validate_uuids:
if not valid_uuid(p.jti):
raise ValueError("ect: jti must be UUID format")
if p.wid and not valid_uuid(p.wid):
raise ValueError("ect: wid must be UUID format when set")
max_par = opts.max_par_length or 0
if max_par > 0 and len(p.par) > max_par:
raise ValueError("ect: par exceeds max length")
if p.inp_hash:
validate_hash_format(p.inp_hash)
if p.out_hash:
validate_hash_format(p.out_hash)
validate_ext(p.ext)
# pol/pol_decision OPTIONAL; if either set, both must be present and valid
if p.pol or p.pol_decision:
if not p.pol or not p.pol_decision:
raise ValueError("ect: pol and pol_decision must both be present when either is set")
if not valid_pol_decision(p.pol_decision):
raise ValueError(
"ect: pol_decision must be approved, rejected, or pending_human_review"
)
# compensation in ext per spec
if p.ext and p.ext.get("compensation_reason") and not p.ext.get("compensation_required"):
raise ValueError("ect: ext.compensation_reason requires ext.compensation_required true")
def create(
payload: Payload,
private_key: EllipticCurvePrivateKey,
opts: CreateOptions,
) -> str:
"""Build and sign an ECT. Payload must have required claims; iat/exp can be 0 for defaults.
create() may modify the payload in place (iat, exp, sub, par) when filling defaults;
pass a copy if the original must stay unchanged.
"""
if not opts.key_id:
raise ValueError("ect: KeyID required")
# Work on a copy so we do not mutate the caller's payload.
payload = copy.deepcopy(payload)
now = int(time.time())
if payload.iat == 0:
payload.iat = now
if payload.exp == 0:
payload.exp = now + (opts.default_expiry_sec or 600)
if not payload.sub:
payload.sub = payload.iss
if payload.par is None:
payload.par = []
_validate_payload(payload, opts)
claims = payload.to_claims()
headers = {
"typ": "wimse-exec+jwt",
"alg": "ES256",
"kid": opts.key_id,
}
return jwt.encode(
claims,
private_key,
algorithm="ES256",
headers=headers,
)
def generate_key() -> EllipticCurvePrivateKey:
"""Create an ECDSA P-256 key for ES256 (testing/demo)."""
from cryptography.hazmat.primitives.asymmetric import ec
return ec.generate_private_key(ec.SECP256R1())

97
refimpl/python/ect/dag.py Normal file
View File

@@ -0,0 +1,97 @@
"""DAG validation per Section 6."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ect.types import Payload
from ect.validate import DEFAULT_MAX_PAR_LENGTH
DEFAULT_CLOCK_SKEW_TOLERANCE = 30
DEFAULT_MAX_ANCESTOR_LIMIT = 10000
class ECTStore(ABC):
"""Lookup of ECTs by task ID for DAG validation."""
@abstractmethod
def get_by_tid(self, tid: str) -> "Payload | None":
pass
@abstractmethod
def contains(self, tid: str, wid: str) -> bool:
pass
class DAGConfig:
def __init__(
self,
clock_skew_tolerance: int = DEFAULT_CLOCK_SKEW_TOLERANCE,
max_ancestor_limit: int = DEFAULT_MAX_ANCESTOR_LIMIT,
max_par_length: int = 0,
):
self.clock_skew_tolerance = clock_skew_tolerance or DEFAULT_CLOCK_SKEW_TOLERANCE
self.max_ancestor_limit = max_ancestor_limit or DEFAULT_MAX_ANCESTOR_LIMIT
self.max_par_length = max_par_length or 0
def default_dag_config() -> DAGConfig:
return DAGConfig()
def _has_cycle(
target_tid: str,
parent_ids: list[str],
store: ECTStore,
visited: set[str],
max_depth: int,
) -> bool:
if len(visited) >= max_depth:
return True
for parent_id in parent_ids:
if parent_id == target_tid:
return True
if parent_id in visited:
continue
visited.add(parent_id)
parent = store.get_by_tid(parent_id)
if parent is not None:
if _has_cycle(target_tid, parent.par, store, visited, max_depth):
return True
return False
def validate_dag(
payload: "Payload",
store: ECTStore,
cfg: DAGConfig,
) -> None:
"""Section 6.2: uniqueness (by jti), parent existence, temporal ordering, acyclicity, parent policy."""
if cfg.max_par_length > 0 and len(payload.par) > cfg.max_par_length:
raise ValueError("ect: par exceeds max length")
if store.contains(payload.jti, payload.wid or ""):
raise ValueError(f"ect: task ID (jti) already exists: {payload.jti}")
from ect.types import POL_DECISION_REJECTED, POL_DECISION_PENDING_HUMAN_REVIEW
for parent_id in payload.par:
parent = store.get_by_tid(parent_id)
if parent is None:
raise ValueError(f"ect: parent task not found: {parent_id}")
if parent.iat >= payload.iat + cfg.clock_skew_tolerance:
raise ValueError(f"ect: parent task not earlier than current: {parent_id}")
visited: set[str] = set()
if _has_cycle(payload.jti, payload.par, store, visited, cfg.max_ancestor_limit):
raise ValueError("ect: circular dependency or depth limit exceeded")
# Parent policy decision: only when parent has policy claims per spec
for parent_id in payload.par:
parent = store.get_by_tid(parent_id)
if parent and parent.has_policy_claims() and parent.pol_decision in (POL_DECISION_REJECTED, POL_DECISION_PENDING_HUMAN_REVIEW):
if not payload.compensation_required():
raise ValueError(
"ect: parent has non-approved pol_decision; current ECT must be compensation/remediation or have ext.compensation_required true"
)

View File

@@ -0,0 +1,52 @@
"""JTI replay cache for production verification."""
from __future__ import annotations
import threading
import time
from abc import ABC, abstractmethod
class JTICache(ABC):
@abstractmethod
def seen(self, jti: str) -> bool:
pass
@abstractmethod
def add(self, jti: str) -> None:
pass
class _MemoryJTICache(JTICache):
def __init__(self, max_size: int, ttl_sec: int) -> None:
self._max_size = max_size
self._ttl_sec = ttl_sec
self._by_jti: dict[str, float] = {}
self._lock = threading.RLock()
def seen(self, jti: str) -> bool:
with self._lock:
exp = self._by_jti.get(jti)
if exp is None:
return False
if time.time() > exp:
del self._by_jti[jti]
return False
return True
def add(self, jti: str) -> None:
with self._lock:
now = time.time()
for k, exp in list(self._by_jti.items()):
if now > exp:
del self._by_jti[k]
if self._max_size > 0 and len(self._by_jti) >= self._max_size and jti not in self._by_jti:
# evict one
for k in self._by_jti:
del self._by_jti[k]
break
self._by_jti[jti] = now + self._ttl_sec
def new_jti_cache(max_size: int, ttl_sec: int) -> JTICache:
return _MemoryJTICache(max_size, ttl_sec)

View File

@@ -0,0 +1,97 @@
"""Audit ledger per Section 9."""
from __future__ import annotations
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING
from ect.types import Payload
if TYPE_CHECKING:
pass
class ErrTaskIDExists(Exception):
"""Raised when appending an ECT whose tid already exists."""
@dataclass
class LedgerEntry:
ledger_sequence: int
task_id: str
agent_id: str
action: str
parents: list[str]
ect_jws: str
signature_verified: bool
verification_timestamp: float
stored_timestamp: float
class Ledger(ABC):
"""Append-only audit ledger; lookup by task id (jti)."""
@abstractmethod
def append(self, ect_jws: str, payload: Payload) -> int:
"""Returns new ledger sequence number."""
pass
@abstractmethod
def get_by_tid(self, tid: str) -> Payload | None:
pass
@abstractmethod
def contains(self, tid: str, wid: str) -> bool:
pass
class MemoryLedger(Ledger):
"""In-memory append-only ECT store implementing Ledger and ECTStore."""
def __init__(self) -> None:
self._seq = 0
self._by_tid: dict[str, "Payload"] = {}
self._entries: list[LedgerEntry] = []
self._lock = __import__("threading").Lock()
def append(self, ect_jws: str, payload: Payload) -> int:
if payload is None:
return 0
with self._lock:
wid = payload.wid or ""
if self._contains_locked(payload.jti, wid):
raise ErrTaskIDExists("ect: task ID (jti) already exists in ledger")
self._seq += 1
now = time.time()
entry = LedgerEntry(
ledger_sequence=self._seq,
task_id=payload.jti,
agent_id=payload.iss,
action=payload.exec_act,
parents=list(payload.par) if payload.par else [],
ect_jws=ect_jws,
signature_verified=True,
verification_timestamp=now,
stored_timestamp=now,
)
self._by_tid[payload.jti] = payload
self._entries.append(entry)
return self._seq
def get_by_tid(self, tid: str) -> Payload | None:
with self._lock:
return self._by_tid.get(tid)
def contains(self, tid: str, wid: str) -> bool:
with self._lock:
return self._contains_locked(tid, wid)
def _contains_locked(self, tid: str, wid: str) -> bool:
p = self._by_tid.get(tid)
if p is None:
return False
if not wid:
return True
return (p.wid or "") == wid

128
refimpl/python/ect/types.py Normal file
View File

@@ -0,0 +1,128 @@
"""ECT payload and claim types per draft Section 4."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any
ECT_TYPE = "wimse-exec+jwt"
POL_DECISION_APPROVED = "approved"
POL_DECISION_REJECTED = "rejected"
POL_DECISION_PENDING_HUMAN_REVIEW = "pending_human_review"
def valid_pol_decision(s: str) -> bool:
return s in (
POL_DECISION_APPROVED,
POL_DECISION_REJECTED,
POL_DECISION_PENDING_HUMAN_REVIEW,
)
def _audience_serialize(aud: list[str]) -> str | list[str]:
if len(aud) == 1:
return aud[0]
return aud
def _audience_deserialize(raw: Any) -> list[str]:
if isinstance(raw, list):
return [str(x) for x in raw]
if isinstance(raw, str):
return [raw]
raise ValueError("aud must be string or array of strings")
@dataclass
class Payload:
"""ECT JWT claims per Section 4. Task identity is jti only; no separate tid per spec."""
iss: str
aud: list[str]
iat: int
exp: int
jti: str
exec_act: str
par: list[str]
pol: str = ""
pol_decision: str = ""
sub: str = ""
wid: str = ""
pol_enforcer: str = ""
pol_timestamp: int = 0
inp_hash: str = ""
out_hash: str = ""
inp_classification: str = ""
ext: dict[str, Any] = field(default_factory=dict)
def to_claims(self) -> dict[str, Any]:
"""Export as JWT claims. Compensation in ext per spec."""
out: dict[str, Any] = {
"iss": self.iss,
"aud": _audience_serialize(self.aud),
"iat": self.iat,
"exp": self.exp,
"jti": self.jti,
"exec_act": self.exec_act,
"par": self.par,
}
if self.sub:
out["sub"] = self.sub
if self.wid:
out["wid"] = self.wid
if self.pol:
out["pol"] = self.pol
if self.pol_decision:
out["pol_decision"] = self.pol_decision
if self.pol_enforcer:
out["pol_enforcer"] = self.pol_enforcer
if self.pol_timestamp:
out["pol_timestamp"] = self.pol_timestamp
if self.inp_hash:
out["inp_hash"] = self.inp_hash
if self.out_hash:
out["out_hash"] = self.out_hash
if self.inp_classification:
out["inp_classification"] = self.inp_classification
if self.ext:
out["ext"] = dict(self.ext)
return out
@classmethod
def from_claims(cls, claims: dict[str, Any]) -> Payload:
"""Build Payload from JWT claims. Compensation read from ext per spec."""
ext = claims.get("ext") or {}
return cls(
iss=claims["iss"],
aud=_audience_deserialize(claims["aud"]),
iat=int(claims["iat"]),
exp=int(claims["exp"]),
jti=claims["jti"],
exec_act=claims["exec_act"],
par=claims.get("par") or [],
pol=claims.get("pol", ""),
pol_decision=claims.get("pol_decision", ""),
sub=claims.get("sub", ""),
wid=claims.get("wid", ""),
pol_enforcer=claims.get("pol_enforcer", ""),
pol_timestamp=int(claims.get("pol_timestamp") or 0),
inp_hash=claims.get("inp_hash", ""),
out_hash=claims.get("out_hash", ""),
inp_classification=claims.get("inp_classification", ""),
ext=ext,
)
def contains_audience(self, verifier_id: str) -> bool:
return verifier_id in self.aud
def compensation_required(self) -> bool:
"""Per spec: compensation_required is in ext."""
if not self.ext:
return False
return bool(self.ext.get("compensation_required"))
def has_policy_claims(self) -> bool:
"""True if both pol and pol_decision are present (optional pair per spec)."""
return bool(self.pol and self.pol_decision)

View File

@@ -0,0 +1,65 @@
"""Validation helpers: ext size/depth, UUID, inp_hash/out_hash format."""
from __future__ import annotations
import base64
import json
import re
from typing import Any
EXT_MAX_SIZE = 4096
EXT_MAX_DEPTH = 5
DEFAULT_MAX_PAR_LENGTH = 100
_UUID_RE = re.compile(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
_ALLOWED_HASH_ALGS = frozenset(("sha-256", "sha-384", "sha-512"))
def _json_depth(obj: Any, depth: int = 0) -> int:
if depth > EXT_MAX_DEPTH:
return depth
if isinstance(obj, dict):
return max((_json_depth(v, depth + 1) for v in obj.values()), default=depth + 1)
if isinstance(obj, list):
return max((_json_depth(x, depth + 1) for x in obj), default=depth + 1)
return depth
def validate_ext(ext: dict[str, Any] | None) -> None:
"""Raise ValueError if ext exceeds EXT_MAX_SIZE or nesting depth EXT_MAX_DEPTH."""
if not ext:
return
raw = json.dumps(ext)
if len(raw.encode("utf-8")) > EXT_MAX_SIZE:
raise ValueError("ect: ext exceeds max size (4096 bytes)")
if _json_depth(ext) > EXT_MAX_DEPTH:
raise ValueError("ect: ext exceeds max nesting depth (5)")
def valid_uuid(s: str) -> bool:
"""Return True if s is a UUID string (RFC 9562)."""
return bool(_UUID_RE.match(s))
def validate_hash_format(s: str) -> None:
"""Raise ValueError if s is non-empty and not algorithm:base64url (sha-256, sha-384, sha-512)."""
if not s:
return
idx = s.find(":")
if idx <= 0:
raise ValueError("ect: inp_hash/out_hash must be algorithm:base64url (e.g. sha-256:...)")
alg = s[:idx].lower()
if alg not in _ALLOWED_HASH_ALGS:
raise ValueError("ect: inp_hash/out_hash must be algorithm:base64url (e.g. sha-256:...)")
encoded = s[idx + 1:]
if not encoded:
raise ValueError("ect: inp_hash/out_hash must be algorithm:base64url (e.g. sha-256:...)")
pad = 4 - len(encoded) % 4
if pad != 4:
encoded += "=" * pad
try:
base64.urlsafe_b64decode(encoded)
except Exception:
raise ValueError("ect: inp_hash/out_hash must be algorithm:base64url (e.g. sha-256:...)") from None

View File

@@ -0,0 +1,160 @@
"""ECT verification per Section 7."""
from __future__ import annotations
import hmac
import time
from dataclasses import dataclass
from typing import Callable, Optional
import jwt
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey
from ect.types import ECT_TYPE, Payload, valid_pol_decision
from ect.dag import ECTStore, DAGConfig, validate_dag
from ect.validate import validate_ext, validate_hash_format, valid_uuid
@dataclass
class ParsedECT:
header: dict
payload: Payload
raw: str
KeyResolver = Callable[[str], Optional[EllipticCurvePublicKey]]
@dataclass
class VerifyOptions:
verifier_id: str = ""
resolve_key: Optional[KeyResolver] = None
store: Optional[ECTStore] = None
dag: Optional[DAGConfig] = None
now: Optional[int] = None # unix seconds; None = time.time()
iat_max_age_sec: int = 900
iat_max_future_sec: int = 30
jti_seen: Optional[Callable[[str], bool]] = None
wit_subject: str = ""
validate_uuids: bool = False
max_par_length: int = 0 # 0 = no limit
on_verify_attempt: Optional[Callable[[str, Optional[Exception]], None]] = None # (jti, err) for observability
def default_verify_options() -> VerifyOptions:
from ect.dag import default_dag_config
return VerifyOptions(dag=default_dag_config())
def parse(compact: str) -> ParsedECT:
"""Parse compact JWS and return header + payload without verification."""
try:
unverified = jwt.decode(
compact,
options={"verify_signature": False, "verify_exp": False},
)
except Exception as e:
raise ValueError(f"ect: parse failed: {e}") from e
header = jwt.get_unverified_header(compact)
if header.get("alg") != "ES256":
raise ValueError("ect: expected ES256")
payload = Payload.from_claims(unverified)
return ParsedECT(header=header, payload=payload, raw=compact)
def verify(compact: str, opts: VerifyOptions) -> ParsedECT:
"""Full Section 7 verification and optional DAG validation."""
log_jti: list[str] = [""] # use list so callback sees updated jti
def set_log_jti(jti: str) -> None:
log_jti[0] = jti
err: Optional[Exception] = None
try:
return _verify_impl(compact, opts, set_log_jti)
except Exception as e:
err = e
raise
finally:
if opts.on_verify_attempt is not None:
opts.on_verify_attempt(log_jti[0], err)
def _verify_impl(compact: str, opts: VerifyOptions, set_log_jti: Callable[[str], None]) -> ParsedECT:
header = jwt.get_unverified_header(compact)
typ = header.get("typ") or ""
# Constant-time comparison for typ
if not hmac.compare_digest(typ, ECT_TYPE):
raise ValueError("ect: invalid typ parameter")
alg = header.get("alg")
if alg in ("none", "HS256", "HS384", "HS512"):
raise ValueError("ect: prohibited algorithm")
kid = header.get("kid")
if not kid:
raise ValueError("ect: missing kid")
if not opts.resolve_key:
raise ValueError("ect: ResolveKey required")
pub = opts.resolve_key(kid)
if pub is None:
raise ValueError("ect: unknown key identifier")
try:
claims = jwt.decode(
compact,
pub,
algorithms=["ES256"],
options={"verify_exp": False, "verify_aud": False, "verify_iat": False},
)
except jwt.InvalidSignatureError as e:
raise ValueError(f"ect: invalid signature: {e}") from e
except Exception as e:
raise ValueError(f"ect: verify failed: {e}") from e
payload = Payload.from_claims(claims)
set_log_jti(payload.jti)
validate_ext(payload.ext)
if opts.max_par_length > 0 and len(payload.par) > opts.max_par_length:
raise ValueError("ect: par exceeds max length")
if opts.validate_uuids:
if not valid_uuid(payload.jti):
raise ValueError("ect: jti must be UUID format")
if payload.wid and not valid_uuid(payload.wid):
raise ValueError("ect: wid must be UUID format when set")
if payload.inp_hash:
validate_hash_format(payload.inp_hash)
if payload.out_hash:
validate_hash_format(payload.out_hash)
if opts.wit_subject and payload.iss != opts.wit_subject:
raise ValueError("ect: issuer does not match WIT subject")
if opts.verifier_id and not payload.contains_audience(opts.verifier_id):
raise ValueError("ect: audience does not include verifier")
now = opts.now if opts.now is not None else int(time.time())
if now > payload.exp:
raise ValueError("ect: token expired")
if now - payload.iat > opts.iat_max_age_sec:
raise ValueError("ect: iat too far in the past")
if payload.iat > now + opts.iat_max_future_sec:
raise ValueError("ect: iat in the future")
# Required claims per spec: jti, exec_act, par. par may be set to [] when missing (from_claims already uses []).
if not payload.jti or not payload.exec_act:
raise ValueError("ect: missing required claims (jti, exec_act, par)")
if payload.par is None:
payload.par = []
# If pol or pol_decision present, both must be present and valid
if payload.pol or payload.pol_decision:
if not payload.pol or not payload.pol_decision:
raise ValueError("ect: pol and pol_decision must both be present when either is set")
if not valid_pol_decision(payload.pol_decision):
raise ValueError("ect: invalid pol_decision value")
if opts.store is not None and opts.dag is not None:
validate_dag(payload, opts.store, opts.dag)
if opts.jti_seen is not None and opts.jti_seen(payload.jti):
raise ValueError("ect: jti already seen (replay)")
return ParsedECT(header=header, payload=payload, raw=compact)