feat: migrate refimpls from draft-00 to draft-01 claim names
- Rename `par` to `pred` (predecessor) in types, serialization, tests - Remove `pol`, `pol_decision` from core payload; move to `ect_ext` - Remove `sub` from payload (not part of ECT spec) - Update `typ` from `wimse-exec+jwt` to `exec+jwt` (accept both) - Rename MaxParLength to MaxPredLength everywhere - Update testdata, demos, READMEs with migration table - All Go tests pass, all 56 Python tests pass (90% coverage)
This commit is contained in:
@@ -1,13 +1,10 @@
|
||||
# WIMSE Execution Context Tokens (ECT) — Python reference implementation
|
||||
# draft-nennemann-wimse-execution-context-00
|
||||
# draft-nennemann-wimse-execution-context-01
|
||||
|
||||
from ect.types import (
|
||||
ECT_TYPE,
|
||||
POL_DECISION_APPROVED,
|
||||
POL_DECISION_REJECTED,
|
||||
POL_DECISION_PENDING_HUMAN_REVIEW,
|
||||
ECT_TYPE_LEGACY,
|
||||
Payload,
|
||||
valid_pol_decision,
|
||||
)
|
||||
from ect.create import create, generate_key, CreateOptions, default_create_options
|
||||
from ect.verify import (
|
||||
@@ -30,11 +27,8 @@ from ect.jti_cache import JTICache, new_jti_cache
|
||||
|
||||
__all__ = [
|
||||
"ECT_TYPE",
|
||||
"POL_DECISION_APPROVED",
|
||||
"POL_DECISION_REJECTED",
|
||||
"POL_DECISION_PENDING_HUMAN_REVIEW",
|
||||
"ECT_TYPE_LEGACY",
|
||||
"Payload",
|
||||
"valid_pol_decision",
|
||||
"create",
|
||||
"generate_key",
|
||||
"CreateOptions",
|
||||
|
||||
@@ -10,9 +10,9 @@ from typing import Optional
|
||||
import jwt
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
|
||||
|
||||
from ect.types import Payload, valid_pol_decision
|
||||
from ect.types import ECT_TYPE, Payload
|
||||
from ect.validate import (
|
||||
DEFAULT_MAX_PAR_LENGTH,
|
||||
DEFAULT_MAX_PRED_LENGTH,
|
||||
validate_ext,
|
||||
validate_hash_format,
|
||||
valid_uuid,
|
||||
@@ -25,7 +25,7 @@ class CreateOptions:
|
||||
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
|
||||
max_pred_length: int = 0 # 0 = no limit; use DEFAULT_MAX_PRED_LENGTH for 100
|
||||
|
||||
|
||||
def default_create_options() -> CreateOptions:
|
||||
@@ -46,22 +46,14 @@ def _validate_payload(p: Payload, opts: CreateOptions) -> None:
|
||||
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")
|
||||
max_pred = opts.max_pred_length or 0
|
||||
if max_pred > 0 and len(p.pred) > max_pred:
|
||||
raise ValueError("ect: pred 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")
|
||||
@@ -73,8 +65,7 @@ def create(
|
||||
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.
|
||||
create() works on a deep copy so the caller's payload is not modified.
|
||||
"""
|
||||
if not opts.key_id:
|
||||
raise ValueError("ect: KeyID required")
|
||||
@@ -87,16 +78,14 @@ def create(
|
||||
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 = []
|
||||
if payload.pred is None:
|
||||
payload.pred = []
|
||||
|
||||
_validate_payload(payload, opts)
|
||||
|
||||
claims = payload.to_claims()
|
||||
headers = {
|
||||
"typ": "wimse-exec+jwt",
|
||||
"typ": ECT_TYPE,
|
||||
"alg": "ES256",
|
||||
"kid": opts.key_id,
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from ect.types import Payload
|
||||
|
||||
from ect.validate import DEFAULT_MAX_PAR_LENGTH
|
||||
from ect.validate import DEFAULT_MAX_PRED_LENGTH
|
||||
|
||||
DEFAULT_CLOCK_SKEW_TOLERANCE = 30
|
||||
DEFAULT_MAX_ANCESTOR_LIMIT = 10000
|
||||
@@ -31,11 +31,11 @@ class DAGConfig:
|
||||
self,
|
||||
clock_skew_tolerance: int = DEFAULT_CLOCK_SKEW_TOLERANCE,
|
||||
max_ancestor_limit: int = DEFAULT_MAX_ANCESTOR_LIMIT,
|
||||
max_par_length: int = 0,
|
||||
max_pred_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
|
||||
self.max_pred_length = max_pred_length or 0
|
||||
|
||||
|
||||
def default_dag_config() -> DAGConfig:
|
||||
@@ -44,22 +44,22 @@ def default_dag_config() -> DAGConfig:
|
||||
|
||||
def _has_cycle(
|
||||
target_tid: str,
|
||||
parent_ids: list[str],
|
||||
pred_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:
|
||||
for pred_id in pred_ids:
|
||||
if pred_id == target_tid:
|
||||
return True
|
||||
if parent_id in visited:
|
||||
if pred_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):
|
||||
visited.add(pred_id)
|
||||
pred = store.get_by_tid(pred_id)
|
||||
if pred is not None:
|
||||
if _has_cycle(target_tid, pred.pred, store, visited, max_depth):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -69,29 +69,28 @@ def validate_dag(
|
||||
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")
|
||||
"""Section 6.2: uniqueness (by jti), predecessor existence, temporal ordering, acyclicity, predecessor policy."""
|
||||
if cfg.max_pred_length > 0 and len(payload.pred) > cfg.max_pred_length:
|
||||
raise ValueError("ect: pred 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}")
|
||||
for pred_id in payload.pred:
|
||||
pred = store.get_by_tid(pred_id)
|
||||
if pred is None:
|
||||
raise ValueError(f"ect: predecessor task not found: {pred_id}")
|
||||
if pred.iat >= payload.iat + cfg.clock_skew_tolerance:
|
||||
raise ValueError(f"ect: predecessor task not earlier than current: {pred_id}")
|
||||
|
||||
visited: set[str] = set()
|
||||
if _has_cycle(payload.jti, payload.par, store, visited, cfg.max_ancestor_limit):
|
||||
if _has_cycle(payload.jti, payload.pred, 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):
|
||||
# Predecessor policy decision: only when predecessor has policy claims in ext per -01
|
||||
for pred_id in payload.pred:
|
||||
pred = store.get_by_tid(pred_id)
|
||||
if pred and pred.has_policy_claims() and pred.pol_decision() in ("rejected", "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"
|
||||
"ect: predecessor has non-approved pol_decision; current ECT must be compensation/remediation or have ext.compensation_required true"
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ class LedgerEntry:
|
||||
task_id: str
|
||||
agent_id: str
|
||||
action: str
|
||||
parents: list[str]
|
||||
predecessors: list[str]
|
||||
ect_jws: str
|
||||
signature_verified: bool
|
||||
verification_timestamp: float
|
||||
@@ -70,7 +70,7 @@ class MemoryLedger(Ledger):
|
||||
task_id=payload.jti,
|
||||
agent_id=payload.iss,
|
||||
action=payload.exec_act,
|
||||
parents=list(payload.par) if payload.par else [],
|
||||
predecessors=list(payload.pred) if payload.pred else [],
|
||||
ect_jws=ect_jws,
|
||||
signature_verified=True,
|
||||
verification_timestamp=now,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""ECT payload and claim types per draft Section 4."""
|
||||
"""ECT payload and claim types per draft-nennemann-wimse-ect-01 Section 4."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,19 +6,9 @@ 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,
|
||||
)
|
||||
# Preferred typ per -01; legacy accepted for backward compatibility.
|
||||
ECT_TYPE = "exec+jwt"
|
||||
ECT_TYPE_LEGACY = "wimse-exec+jwt"
|
||||
|
||||
|
||||
def _audience_serialize(aud: list[str]) -> str | list[str]:
|
||||
@@ -45,20 +35,15 @@ class Payload:
|
||||
exp: int
|
||||
jti: str
|
||||
exec_act: str
|
||||
par: list[str]
|
||||
pol: str = ""
|
||||
pol_decision: str = ""
|
||||
sub: str = ""
|
||||
pred: list[str] # predecessor jti values (renamed from par in -01)
|
||||
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."""
|
||||
"""Export as JWT claims. Policy and compensation in ext per -01 spec."""
|
||||
out: dict[str, Any] = {
|
||||
"iss": self.iss,
|
||||
"aud": _audience_serialize(self.aud),
|
||||
@@ -66,20 +51,10 @@ class Payload:
|
||||
"exp": self.exp,
|
||||
"jti": self.jti,
|
||||
"exec_act": self.exec_act,
|
||||
"par": self.par,
|
||||
"pred": self.pred,
|
||||
}
|
||||
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:
|
||||
@@ -87,13 +62,13 @@ class Payload:
|
||||
if self.inp_classification:
|
||||
out["inp_classification"] = self.inp_classification
|
||||
if self.ext:
|
||||
out["ext"] = dict(self.ext)
|
||||
out["ect_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 {}
|
||||
"""Build Payload from JWT claims. Policy claims read from ext per -01 spec."""
|
||||
ext = claims.get("ect_ext") or {}
|
||||
return cls(
|
||||
iss=claims["iss"],
|
||||
aud=_audience_deserialize(claims["aud"]),
|
||||
@@ -101,13 +76,8 @@ class Payload:
|
||||
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", ""),
|
||||
pred=claims.get("pred") or [],
|
||||
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", ""),
|
||||
@@ -124,5 +94,13 @@ class Payload:
|
||||
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)
|
||||
"""True if both pol and pol_decision are present in ext (per -01, moved to extension)."""
|
||||
if not self.ext:
|
||||
return False
|
||||
return bool(self.ext.get("pol")) and bool(self.ext.get("pol_decision"))
|
||||
|
||||
def pol_decision(self) -> str:
|
||||
"""Return pol_decision from ext, or empty string."""
|
||||
if not self.ext:
|
||||
return ""
|
||||
return str(self.ext.get("pol_decision", ""))
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any
|
||||
|
||||
EXT_MAX_SIZE = 4096
|
||||
EXT_MAX_DEPTH = 5
|
||||
DEFAULT_MAX_PAR_LENGTH = 100
|
||||
DEFAULT_MAX_PRED_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}$"
|
||||
|
||||
@@ -10,7 +10,7 @@ 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.types import ECT_TYPE, ECT_TYPE_LEGACY, Payload
|
||||
from ect.dag import ECTStore, DAGConfig, validate_dag
|
||||
from ect.validate import validate_ext, validate_hash_format, valid_uuid
|
||||
|
||||
@@ -37,7 +37,7 @@ class VerifyOptions:
|
||||
jti_seen: Optional[Callable[[str], bool]] = None
|
||||
wit_subject: str = ""
|
||||
validate_uuids: bool = False
|
||||
max_par_length: int = 0 # 0 = no limit
|
||||
max_pred_length: int = 0 # 0 = no limit
|
||||
on_verify_attempt: Optional[Callable[[str, Optional[Exception]], None]] = None # (jti, err) for observability
|
||||
|
||||
|
||||
@@ -83,8 +83,8 @@ def verify(compact: str, opts: VerifyOptions) -> ParsedECT:
|
||||
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):
|
||||
# Constant-time comparison for typ; accept both preferred and legacy values
|
||||
if not hmac.compare_digest(typ, ECT_TYPE) and not hmac.compare_digest(typ, ECT_TYPE_LEGACY):
|
||||
raise ValueError("ect: invalid typ parameter")
|
||||
alg = header.get("alg")
|
||||
if alg in ("none", "HS256", "HS384", "HS512"):
|
||||
@@ -114,8 +114,8 @@ def _verify_impl(compact: str, opts: VerifyOptions, set_log_jti: Callable[[str],
|
||||
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.max_pred_length > 0 and len(payload.pred) > opts.max_pred_length:
|
||||
raise ValueError("ect: pred exceeds max length")
|
||||
if opts.validate_uuids:
|
||||
if not valid_uuid(payload.jti):
|
||||
raise ValueError("ect: jti must be UUID format")
|
||||
@@ -139,17 +139,11 @@ def _verify_impl(compact: str, opts: VerifyOptions, set_log_jti: Callable[[str],
|
||||
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 []).
|
||||
# Required claims per spec: jti, exec_act, pred. pred 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")
|
||||
raise ValueError("ect: missing required claims (jti, exec_act, pred)")
|
||||
if payload.pred is None:
|
||||
payload.pred = []
|
||||
|
||||
if opts.store is not None and opts.dag is not None:
|
||||
validate_dag(payload, opts.store, opts.dag)
|
||||
|
||||
Reference in New Issue
Block a user