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:
2026-04-12 07:33:08 +02:00
parent b38747ad92
commit 3a139dfc7e
53 changed files with 8718 additions and 1 deletions

Binary file not shown.

View File

@@ -0,0 +1,111 @@
# WIMSE ECT — Python Reference Implementation
Python reference implementation of [Execution Context Tokens (ECTs)](../../draft-nennemann-wimse-execution-context-01.txt) for WIMSE. Implements ECT creation (ES256), verification (Section 7), DAG validation (Section 6), and an in-memory audit ledger (Section 9).
## Layout
```
python/
├── pyproject.toml
├── ect/ # library
│ ├── __init__.py
│ ├── types.py # Payload, constants
│ ├── create.py # create(), generate_key()
│ ├── verify.py # parse(), verify(), VerifyOptions
│ ├── dag.py # validate_dag(), ECTStore, DAGConfig
│ ├── ledger.py # Ledger, MemoryLedger
│ ├── config.py # Config, load_config_from_env()
│ ├── jti_cache.py # JTICache for replay protection
│ └── validate.py # validate_ext, valid_uuid, validate_hash_format
├── tests/
│ ├── test_create.py
│ └── test_dag.py
├── testdata/
│ └── valid_root_ect_payload.json
└── demo.py # two-agent workflow demo
```
## Install
```bash
cd refimpl/python && pip install -e .
```
## Usage
```python
from ect import (
Payload,
create,
generate_key,
CreateOptions,
verify,
VerifyOptions,
MemoryLedger,
)
cfg = load_config_from_env()
key = generate_key()
payload = Payload(
iss="spiffe://example.com/agent/a",
aud=["spiffe://example.com/agent/b"],
iat=int(time.time()),
exp=int(time.time()) + 600,
jti="550e8400-e29b-41d4-a716-446655440000",
exec_act="review_spec",
pred=[],
ext={
"pol": "policy_v1",
"pol_decision": "approved",
},
)
compact = create(payload, key, cfg.create_options("agent-a-key"))
store = MemoryLedger()
opts = cfg.verify_options()
opts.verifier_id = "spiffe://example.com/agent/b"
opts.resolve_key = lambda kid: key.public_key() if kid == "agent-a-key" else None
opts.store = store
parsed = verify(compact, opts)
store.append(compact, parsed.payload)
```
## Demo
```bash
cd refimpl/python && python3 demo.py
```
## Tests
```bash
cd refimpl/python && python3 -m pytest tests/ -v
```
Unit tests require **90% coverage** minimum (`pytest` is configured with `--cov-fail-under=90` in `pyproject.toml`). Install dev deps: `pip install -e ".[dev]"`. Uncovered lines are mainly abstract base methods and a few verify branches that need manually built tokens.
## draft-01 claim changes
| -00 (previous) | -01 (current) | Notes |
|----------------|---------------|-------|
| `par` | `pred` | Predecessor task IDs |
| `pol`, `pol_decision` | removed (use `ect_ext`) | Policy claims moved to extension object |
| `sub` | not defined | Standard JWT claim, not part of ECT spec |
| `typ: wimse-exec+jwt` | `typ: exec+jwt` (preferred) | Both accepted for backward compat |
| `max_par_length` | `max_pred_length` | Renamed to match `pred` claim |
## Production configuration (environment)
Same env vars as the Go refimpl: `ECT_IAT_MAX_AGE_MINUTES`, `ECT_IAT_MAX_FUTURE_SEC`, `ECT_DEFAULT_EXPIRY_MIN`, `ECT_JTI_REPLAY_CACHE_SIZE`, `ECT_JTI_REPLAY_TTL_MIN`.
### Replay cache (multi-instance)
The provided JTI cache is in-memory only. For multiple verifier instances, use a shared store (Redis, DB) and pass a `jti_seen` callable that checks/records JTIs there. See refimpl/README for an overview.
## Dependencies
- PyJWT, cryptography (ES256).
## License
Same as the Internet-Draft (IETF Trust). Code under Revised BSD per BCP 78/79.

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Two-agent ECT workflow demo: Agent A creates root ECT, Agent B verifies and creates child."""
import time
from ect import (
Payload,
create,
generate_key,
CreateOptions,
verify,
VerifyOptions,
MemoryLedger,
)
def main():
ledger = MemoryLedger()
now = int(time.time())
key_a = generate_key()
agent_a = "spiffe://example.com/agent/spec-reviewer"
agent_b = "spiffe://example.com/agent/implementer"
kid_a = "agent-a-key"
# 1) Agent A creates root ECT (task id = jti per spec)
root_jti = "550e8400-e29b-41d4-a716-446655440001"
payload_a = Payload(
iss=agent_a,
aud=[agent_b],
iat=now,
exp=now + 600,
jti=root_jti,
wid="wf-demo-001",
exec_act="review_requirements_spec",
pred=[],
ext={
"pol": "spec_review_policy_v2",
"pol_decision": "approved",
},
)
ect_a = create(payload_a, key_a, CreateOptions(key_id=kid_a))
print("Agent A created root ECT (jti=550e8400-..., review_requirements_spec)")
# 2) Agent B verifies
def resolve_key(kid):
if kid == kid_a:
return key_a.public_key()
return None
opts = VerifyOptions(
verifier_id=agent_b,
resolve_key=resolve_key,
store=ledger,
now=now,
)
parsed = verify(ect_a, opts)
ledger.append(ect_a, parsed.payload)
print("Agent B verified root ECT and appended to ledger")
# 3) Agent B creates child ECT (pred contains predecessor jti values per spec)
key_b = generate_key()
kid_b = "agent-b-key"
child_jti = "550e8400-e29b-41d4-a716-446655440002"
payload_b = Payload(
iss=agent_b,
aud=["spiffe://example.com/system/ledger"],
iat=now + 1,
exp=now + 600,
jti=child_jti,
wid="wf-demo-001",
exec_act="implement_module",
pred=[root_jti],
ext={
"pol": "coding_standards_v3",
"pol_decision": "approved",
},
)
ect_b = create(payload_b, key_b, CreateOptions(key_id=kid_b))
print("Agent B created child ECT (jti=550e8400-...002, implement_module, pred=[predecessor jti])")
# 4) Verify child ECT with DAG
def resolver_b(kid):
if kid == kid_b:
return key_b.public_key()
if kid == kid_a:
return key_a.public_key()
return None
opts_b = VerifyOptions(
verifier_id="spiffe://example.com/system/ledger",
resolve_key=resolver_b,
store=ledger,
now=now + 2,
)
parsed_b = verify(ect_b, opts_b)
ledger.append(ect_b, parsed_b.payload)
print("Verified child ECT with DAG validation and appended to ledger")
print(f"Ledger entries: {parsed.payload.jti} ({parsed.payload.exec_act}), {parsed_b.payload.jti} ({parsed_b.payload.exec_act})")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,55 @@
# WIMSE Execution Context Tokens (ECT) — Python reference implementation
# draft-nennemann-wimse-execution-context-01
from ect.types import (
ECT_TYPE,
ECT_TYPE_LEGACY,
Payload,
)
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",
"ECT_TYPE_LEGACY",
"Payload",
"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,104 @@
"""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 ECT_TYPE, Payload
from ect.validate import (
DEFAULT_MAX_PRED_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_pred_length: int = 0 # 0 = no limit; use DEFAULT_MAX_PRED_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_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)
# 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() works on a deep copy so the caller's payload is not modified.
"""
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 payload.pred is None:
payload.pred = []
_validate_payload(payload, opts)
claims = payload.to_claims()
headers = {
"typ": ECT_TYPE,
"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())

View File

@@ -0,0 +1,96 @@
"""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_PRED_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_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_pred_length = max_pred_length or 0
def default_dag_config() -> DAGConfig:
return DAGConfig()
def _has_cycle(
target_tid: str,
pred_ids: list[str],
store: ECTStore,
visited: set[str],
max_depth: int,
) -> bool:
if len(visited) >= max_depth:
return True
for pred_id in pred_ids:
if pred_id == target_tid:
return True
if pred_id in visited:
continue
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
def validate_dag(
payload: "Payload",
store: ECTStore,
cfg: DAGConfig,
) -> None:
"""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}")
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.pred, store, visited, cfg.max_ancestor_limit):
raise ValueError("ect: circular dependency or depth limit exceeded")
# 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: predecessor 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
predecessors: 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,
predecessors=list(payload.pred) if payload.pred 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

View File

@@ -0,0 +1,106 @@
"""ECT payload and claim types per draft-nennemann-wimse-ect-01 Section 4."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any
# 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]:
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
pred: list[str] # predecessor jti values (renamed from par in -01)
wid: str = ""
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. Policy and compensation in ext per -01 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,
"pred": self.pred,
}
if self.wid:
out["wid"] = self.wid
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["ect_ext"] = dict(self.ext)
return out
@classmethod
def from_claims(cls, claims: dict[str, Any]) -> Payload:
"""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"]),
iat=int(claims["iat"]),
exp=int(claims["exp"]),
jti=claims["jti"],
exec_act=claims["exec_act"],
pred=claims.get("pred") or [],
wid=claims.get("wid", ""),
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 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", ""))

View File

@@ -0,0 +1,62 @@
"""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_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}$"
)
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 plain base64url per RFC 9449 / ECT spec.
The ECT spec (draft-nennemann-wimse-ect-01) and RFC 9449 specify
``base64url(SHA-256(data))`` — a plain base64url string without any
algorithm prefix. This matches how ACT handles hashes.
"""
if not s:
return
# Reject strings containing non-base64url characters.
# base64url alphabet: A-Z a-z 0-9 - _ (no padding '=' expected)
if not re.fullmatch(r"[A-Za-z0-9_-]+", s):
raise ValueError("ect: inp_hash/out_hash must be plain base64url (no prefix)")
# Verify it actually decodes.
pad = 4 - len(s) % 4
padded = s + "=" * pad if pad != 4 else s
try:
base64.urlsafe_b64decode(padded)
except Exception:
raise ValueError("ect: inp_hash/out_hash must be plain base64url (no prefix)") from None

View File

@@ -0,0 +1,154 @@
"""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, ECT_TYPE_LEGACY, Payload
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_pred_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; 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"):
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_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")
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, 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, 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)
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)

View File

@@ -0,0 +1,25 @@
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "ietf-ect"
version = "0.1.0"
description = "WIMSE Execution Context Tokens (ECT) reference implementation"
requires-python = ">=3.9"
license = "BSD-3-Clause"
dependencies = [
"PyJWT>=2.8.0",
"cryptography>=42.0.0",
]
[project.optional-dependencies]
dev = ["pytest>=7.0", "pytest-cov>=4.0"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
addopts = "--cov=ect --cov-report=term-missing --cov-fail-under=90 -v"
[tool.setuptools.packages.find]
include = ["ect*"]

View File

@@ -0,0 +1 @@
{"iss":"spiffe://example.com/agent/clinical","aud":"spiffe://example.com/agent/safety","iat":1772064150,"exp":1772064750,"jti":"7f3a8b2c-d1e4-4f56-9a0b-c3d4e5f6a7b8","wid":"a0b1c2d3-e4f5-6789-abcd-ef0123456789","exec_act":"recommend_treatment","pred":[],"ect_ext":{"pol":"clinical_reasoning_policy_v2","pol_decision":"approved"}}

View File

@@ -0,0 +1 @@
# Tests package

View File

@@ -0,0 +1,49 @@
"""Tests for config module."""
import os
import pytest
from ect import default_config, load_config_from_env
from ect.config import ENV_IAT_MAX_AGE_MINUTES, ENV_JTI_REPLAY_CACHE_SIZE
def test_default_config():
c = default_config()
assert c.iat_max_age_sec == 900
assert c.jti_replay_size == 0
def test_load_config_from_env():
os.environ[ENV_IAT_MAX_AGE_MINUTES] = "20"
os.environ[ENV_JTI_REPLAY_CACHE_SIZE] = "500"
try:
c = load_config_from_env()
assert c.iat_max_age_sec == 20 * 60
assert c.jti_replay_size == 500
finally:
os.environ.pop(ENV_IAT_MAX_AGE_MINUTES, None)
os.environ.pop(ENV_JTI_REPLAY_CACHE_SIZE, None)
def test_config_create_options():
c = default_config()
opts = c.create_options("my-kid")
assert opts.key_id == "my-kid"
assert opts.default_expiry_sec == c.default_expiry_sec
def test_config_verify_options():
c = default_config()
opts = c.verify_options()
assert opts.iat_max_age_sec == c.iat_max_age_sec
assert opts.dag is not None
def test_load_config_invalid_int():
os.environ[ENV_IAT_MAX_AGE_MINUTES] = "bad"
try:
c = load_config_from_env()
assert c.iat_max_age_sec == 900
finally:
os.environ.pop(ENV_IAT_MAX_AGE_MINUTES, None)

View File

@@ -0,0 +1,74 @@
"""Tests for ECT creation and roundtrip."""
import json
import os
import time
import pytest
from ect import (
Payload,
create,
generate_key,
CreateOptions,
verify,
VerifyOptions,
)
def test_create_roundtrip():
key = generate_key()
now = int(time.time())
payload = Payload(
iss="spiffe://example.com/agent/a",
aud=["spiffe://example.com/agent/b"],
iat=now,
exp=now + 600,
jti="e4f5a6b7-c8d9-0123-ef01-234567890abc",
exec_act="review_spec",
pred=[],
)
compact = create(payload, key, CreateOptions(key_id="agent-a-key-1"))
assert compact
def resolver(kid):
if kid == "agent-a-key-1":
return key.public_key()
return None
opts = VerifyOptions(
verifier_id="spiffe://example.com/agent/b",
resolve_key=resolver,
now=now,
)
parsed = verify(compact, opts)
assert parsed.payload.jti == payload.jti
assert parsed.payload.exec_act == payload.exec_act
def test_create_with_test_vector():
path = os.path.join(os.path.dirname(__file__), "..", "testdata", "valid_root_ect_payload.json")
if not os.path.exists(path):
pytest.skip(f"test vector not found: {path}")
with open(path) as f:
data = json.load(f)
payload = Payload.from_claims(data)
key = generate_key()
now = int(time.time())
payload.iat = now
payload.exp = now + 600
compact = create(payload, key, CreateOptions(key_id="test-kid"))
assert compact
def resolver(kid):
if kid == "test-kid":
return key.public_key()
return None
opts = VerifyOptions(
verifier_id=payload.aud[0],
resolve_key=resolver,
now=now,
)
verify(compact, opts)

View File

@@ -0,0 +1,94 @@
"""Additional tests for create module."""
import time
import pytest
from ect import Payload, create, generate_key, CreateOptions, default_create_options
def test_default_create_options():
opts = default_create_options()
assert opts.key_id == ""
def test_create_errors():
key = generate_key()
p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", pred=[])
with pytest.raises(ValueError, match="KeyID|required"):
create(p, key, CreateOptions(key_id=""))
with pytest.raises((ValueError, TypeError, AttributeError)):
create(None, key, CreateOptions(key_id="k"))
def test_create_optional_pol():
key = generate_key()
now = int(time.time())
p = Payload(
iss="iss", aud=["a"], iat=now, exp=now + 3600,
jti="jti-nopol", exec_act="act", pred=[],
)
compact = create(p, key, CreateOptions(key_id="kid"))
assert compact
def test_create_validation_errors():
key = generate_key()
base = dict(iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", pred=[])
with pytest.raises(ValueError, match="iss"):
create(Payload(**{**base, "iss": ""}), key, CreateOptions(key_id="k"))
with pytest.raises(ValueError, match="aud"):
create(Payload(**{**base, "aud": []}), key, CreateOptions(key_id="k"))
with pytest.raises(ValueError, match="jti"):
create(Payload(**{**base, "jti": ""}), key, CreateOptions(key_id="k"))
with pytest.raises(ValueError, match="exec_act"):
create(Payload(**{**base, "exec_act": ""}), key, CreateOptions(key_id="k"))
def test_create_ext_compensation_reason_requires_required():
key = generate_key()
p = Payload(
iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", pred=[],
ext={"compensation_reason": "rollback", "compensation_required": False},
)
with pytest.raises(ValueError, match="compensation_required"):
create(p, key, CreateOptions(key_id="k"))
def test_create_zero_expiry_uses_default():
key = generate_key()
p = Payload(iss="i", aud=["a"], iat=0, exp=0, jti="j", exec_act="e", pred=[])
compact = create(p, key, CreateOptions(key_id="k", default_expiry_sec=300))
assert compact
# create() works on a copy; decode the token to verify defaults were applied
import jwt
claims = jwt.decode(compact, options={"verify_signature": False})
assert claims["exp"] > claims["iat"]
def test_create_validate_uuids_rejects_non_uuid_jti():
key = generate_key()
now = int(time.time())
p = Payload(iss="i", aud=["a"], iat=now, exp=now + 3600, jti="not-a-uuid", exec_act="e", pred=[])
with pytest.raises(ValueError, match="jti must be UUID"):
create(p, key, CreateOptions(key_id="k", validate_uuids=True))
def test_create_max_pred_length():
key = generate_key()
now = int(time.time())
p = Payload(iss="i", aud=["a"], iat=now, exp=now + 3600, jti="550e8400-e29b-41d4-a716-446655440000", exec_act="e", pred=["p1", "p2"])
with pytest.raises(ValueError, match="pred exceeds max length"):
create(p, key, CreateOptions(key_id="k", max_pred_length=1))
def test_create_ext_size_rejected():
from ect.validate import EXT_MAX_SIZE
key = generate_key()
now = int(time.time())
p = Payload(
iss="i", aud=["a"], iat=now, exp=now + 3600, jti="550e8400-e29b-41d4-a716-446655440000", exec_act="e", pred=[],
ext={"x": "y" * (EXT_MAX_SIZE - 5)},
)
with pytest.raises(ValueError, match="ext exceeds max size"):
create(p, key, CreateOptions(key_id="k"))

View File

@@ -0,0 +1,111 @@
"""Tests for DAG validation."""
import time
import pytest
from ect import Payload, MemoryLedger, validate_dag, default_dag_config
def test_validate_dag_root():
store = MemoryLedger()
payload = Payload(
iss="",
aud=[],
iat=0,
exp=0,
jti="jti-001",
exec_act="",
pred=[],
wid="wf-1",
)
validate_dag(payload, store, default_dag_config())
def test_validate_dag_duplicate_jti():
store = MemoryLedger()
p = Payload(
iss="x",
aud=["y"],
iat=0,
exp=0,
jti="jti-001",
exec_act="a",
pred=[],
wid="wf-1",
)
store.append("dummy-jws", p)
payload = Payload(
iss="",
aud=[],
iat=0,
exp=0,
jti="jti-001",
exec_act="",
pred=[],
wid="wf-1",
)
with pytest.raises(ValueError, match="task ID.*already exists"):
validate_dag(payload, store, default_dag_config())
def test_validate_dag_pred_exists():
store = MemoryLedger()
now = int(time.time())
p = Payload(
iss="x",
aud=["y"],
iat=now - 60,
exp=now + 600,
jti="jti-001",
exec_act="a",
pred=[],
wid="wf-1",
)
store.append("jws1", p)
payload = Payload(
iss="",
aud=[],
iat=now,
exp=now + 600,
jti="jti-002",
exec_act="b",
pred=["jti-001"],
wid="wf-1",
)
validate_dag(payload, store, default_dag_config())
def test_validate_dag_pred_not_found():
store = MemoryLedger()
now = int(time.time())
payload = Payload(
iss="",
aud=[],
iat=now,
exp=now + 600,
jti="jti-002",
exec_act="",
pred=["jti-missing"],
)
with pytest.raises(ValueError, match="predecessor task not found"):
validate_dag(payload, store, default_dag_config())
def test_validate_dag_pred_policy_rejected_requires_compensation():
store = MemoryLedger()
now = int(time.time())
p = Payload(
iss="x", aud=["y"], iat=now - 60, exp=now + 600,
jti="jti-rej", exec_act="a", pred=[], wid="wf-1",
ext={"pol": "p", "pol_decision": "rejected"},
)
store.append("jws1", p)
payload = Payload(
iss="", aud=[], iat=now, exp=now + 600,
jti="jti-child", exec_act="b", pred=["jti-rej"], wid="wf-1",
)
with pytest.raises(ValueError, match="compensation"):
validate_dag(payload, store, default_dag_config())
payload.ext = {"compensation_required": True}
validate_dag(payload, store, default_dag_config())

View File

@@ -0,0 +1,40 @@
"""Tests for JTI replay cache."""
import time
import pytest
from ect import new_jti_cache
def test_jti_cache_seen_and_add():
cache = new_jti_cache(10, 60)
assert cache.seen("jti-1") is False
cache.add("jti-1")
assert cache.seen("jti-1") is True
assert cache.seen("jti-2") is False
cache.add("jti-2")
assert cache.seen("jti-2") is True
def test_jti_cache_expiry():
cache = new_jti_cache(10, 1) # 1 second TTL
cache.add("jti-1")
assert cache.seen("jti-1") is True
time.sleep(1.1)
assert cache.seen("jti-1") is False
def test_jti_cache_max_size_eviction():
cache = new_jti_cache(2, 60)
cache.add("jti-1")
cache.add("jti-2")
cache.add("jti-3")
assert cache.seen("jti-3") is True
def test_jti_cache_add_when_already_present():
cache = new_jti_cache(2, 60)
cache.add("jti-1")
cache.add("jti-1")
assert cache.seen("jti-1") is True

View File

@@ -0,0 +1,38 @@
"""Additional tests for ledger module."""
import time
import pytest
from ect import Payload, MemoryLedger, ErrTaskIDExists
def test_ledger_append_and_get():
m = MemoryLedger()
p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j1", exec_act="act", pred=[])
seq = m.append("jws1", p)
assert seq == 1
assert m.get_by_tid("j1").jti == "j1"
def test_ledger_err_task_id_exists():
m = MemoryLedger()
p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j-dup", exec_act="e", pred=[])
m.append("jws1", p)
with pytest.raises(ErrTaskIDExists):
m.append("jws2", p)
def test_ledger_contains_wid():
m = MemoryLedger()
p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j1", exec_act="e", pred=[], wid="wf1")
m.append("jws", p)
assert m.contains("j1", "") is True
assert m.contains("j1", "wf1") is True
assert m.contains("j1", "wf2") is False
def test_ledger_append_none():
m = MemoryLedger()
seq = m.append("jws", None)
assert seq == 0

View File

@@ -0,0 +1,64 @@
"""Additional tests for types module."""
import pytest
from ect import Payload
def test_payload_contains_audience():
p = Payload(iss="", aud=["a", "b"], iat=0, exp=0, jti="", exec_act="", pred=[])
assert p.contains_audience("a") is True
assert p.contains_audience("c") is False
def test_payload_compensation_required():
p = Payload(iss="", aud=[], iat=0, exp=0, jti="", exec_act="", pred=[])
assert p.compensation_required() is False
p.ext = {"compensation_required": True}
assert p.compensation_required() is True
def test_payload_has_policy_claims():
p = Payload(iss="", aud=[], iat=0, exp=0, jti="", exec_act="", pred=[],
ext={"pol": "p", "pol_decision": "approved"})
assert p.has_policy_claims() is True
p.ext = {"pol_decision": "approved"}
assert p.has_policy_claims() is False
p.ext = None
assert p.has_policy_claims() is False
def test_payload_pol_decision():
p = Payload(iss="", aud=[], iat=0, exp=0, jti="", exec_act="", pred=[],
ext={"pol_decision": "rejected"})
assert p.pol_decision() == "rejected"
p.ext = None
assert p.pol_decision() == ""
def test_payload_to_claims_optional():
p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", pred=[], wid="wf")
claims = p.to_claims()
assert claims["wid"] == "wf"
assert "ect_ext" not in claims or not claims.get("ect_ext")
def test_payload_from_claims_aud_string():
claims = {"iss": "i", "aud": "single", "iat": 1, "exp": 2, "jti": "j", "exec_act": "e", "pred": []}
p = Payload.from_claims(claims)
assert p.aud == ["single"]
def test_payload_to_claims_all_optional():
p = Payload(
iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", pred=[],
wid="w", inp_hash="h", out_hash="o", inp_classification="c",
ext={"pol": "p", "pol_decision": "approved"},
)
claims = p.to_claims()
assert claims["wid"] == "w"
assert claims["inp_hash"] == "h"
assert claims["out_hash"] == "o"
assert claims["inp_classification"] == "c"
assert claims["ect_ext"]["pol"] == "p"
assert claims["ect_ext"]["pol_decision"] == "approved"

View File

@@ -0,0 +1,64 @@
"""Tests for validate module."""
import json
import pytest
from ect.validate import (
EXT_MAX_DEPTH,
EXT_MAX_SIZE,
validate_ext,
validate_hash_format,
valid_uuid,
)
def test_valid_uuid():
assert valid_uuid("550e8400-e29b-41d4-a716-446655440000") is True
assert valid_uuid("00000000-0000-0000-0000-000000000000") is True
assert valid_uuid("") is False
assert valid_uuid("not-a-uuid") is False
assert valid_uuid("550e8400e29b41d4a716446655440000") is False # no dashes
def test_validate_ext_none():
validate_ext(None)
validate_ext({})
def test_validate_ext_size():
# Serialized JSON must exceed EXT_MAX_SIZE (4096) bytes
big = {"x": "y" * (EXT_MAX_SIZE - 2)} # "{\"x\":\"...\"}" + payload
raw = json.dumps(big)
assert len(raw.encode("utf-8")) > EXT_MAX_SIZE
with pytest.raises(ValueError, match="max size"):
validate_ext(big)
def test_validate_ext_depth():
deep = {"a": 1}
for _ in range(EXT_MAX_DEPTH):
deep = {"n": deep}
with pytest.raises(ValueError, match="depth"):
validate_ext(deep)
def test_validate_hash_format_empty():
validate_hash_format("")
def test_validate_hash_format_ok():
# Plain base64url per RFC 9449 / ECT spec (no algorithm prefix)
validate_hash_format("YQ")
validate_hash_format("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
validate_hash_format("abc123-_XYZ")
def test_validate_hash_format_bad():
# Colon is not valid base64url — rejects old prefixed format
with pytest.raises(ValueError, match="plain base64url"):
validate_hash_format("sha-256:YQ")
with pytest.raises(ValueError, match="plain base64url"):
validate_hash_format("not valid!!")
# Null byte in payload
with pytest.raises(ValueError, match="plain base64url"):
validate_hash_format("YQ\x00")

View File

@@ -0,0 +1,194 @@
"""Tests for verify module."""
import time
import pytest
from ect import (
Payload,
create,
generate_key,
CreateOptions,
parse,
verify,
VerifyOptions,
default_verify_options,
)
def test_parse():
key = generate_key()
now = int(time.time())
p = Payload(
iss="iss", aud=["a"], iat=now, exp=now + 3600,
jti="jti-parse", exec_act="act", pred=[],
)
compact = create(p, key, CreateOptions(key_id="kid"))
parsed = parse(compact)
assert parsed.payload.jti == "jti-parse"
assert parsed.raw == compact
def test_default_verify_options():
opts = default_verify_options()
assert opts.dag is not None
assert opts.iat_max_age_sec == 900
def test_verify_expired():
key = generate_key()
now = int(time.time())
p = Payload(
iss="iss", aud=["v"], iat=now - 3600, exp=now - 60,
jti="jti-exp", exec_act="act", pred=[],
)
compact = create(p, key, CreateOptions(key_id="kid"))
resolver = lambda kid: key.public_key() if kid == "kid" else None
with pytest.raises(ValueError, match="expired"):
verify(compact, VerifyOptions(verifier_id="v", resolve_key=resolver, now=now))
def test_verify_replay():
key = generate_key()
now = int(time.time())
p = Payload(
iss="iss", aud=["v"], iat=now, exp=now + 3600,
jti="jti-replay", exec_act="act", pred=[],
)
compact = create(p, key, CreateOptions(key_id="kid"))
resolver = lambda kid: key.public_key() if kid == "kid" else None
with pytest.raises(ValueError, match="replay"):
verify(compact, VerifyOptions(
verifier_id="v", resolve_key=resolver, now=now,
jti_seen=lambda j: j == "jti-replay",
))
def test_verify_invalid_typ():
import jwt as jwt_lib
with pytest.raises((ValueError, jwt_lib.exceptions.DecodeError)):
verify("not-a-jws", VerifyOptions())
def test_verify_audience_mismatch():
key = generate_key()
now = int(time.time())
p = Payload(
iss="iss", aud=["other"], iat=now, exp=now + 3600,
jti="jti-a", exec_act="act", pred=[],
)
compact = create(p, key, CreateOptions(key_id="kid"))
resolver = lambda kid: key.public_key() if kid == "kid" else None
with pytest.raises(ValueError, match="audience"):
verify(compact, VerifyOptions(verifier_id="verifier", resolve_key=resolver, now=now))
def test_verify_wit_subject_mismatch():
key = generate_key()
now = int(time.time())
p = Payload(
iss="wrong-iss", aud=["v"], iat=now, exp=now + 3600,
jti="jti-w", exec_act="act", pred=[],
)
compact = create(p, key, CreateOptions(key_id="kid"))
resolver = lambda kid: key.public_key() if kid == "kid" else None
with pytest.raises(ValueError, match="WIT subject"):
verify(compact, VerifyOptions(
verifier_id="v", resolve_key=resolver, now=now, wit_subject="correct-iss",
))
def test_verify_iat_too_old():
key = generate_key()
now = int(time.time())
p = Payload(
iss="iss", aud=["v"], iat=now - 2000, exp=now + 3600,
jti="jti-old", exec_act="act", pred=[],
)
compact = create(p, key, CreateOptions(key_id="kid"))
resolver = lambda kid: key.public_key() if kid == "kid" else None
with pytest.raises(ValueError, match="iat"):
verify(compact, VerifyOptions(
verifier_id="v", resolve_key=resolver, now=now, iat_max_age_sec=900,
))
def test_verify_unknown_key():
key = generate_key()
now = int(time.time())
p = Payload(
iss="iss", aud=["v"], iat=now, exp=now + 3600,
jti="jti-k", exec_act="act", pred=[],
)
compact = create(p, key, CreateOptions(key_id="kid"))
resolver = lambda kid: None # unknown key
with pytest.raises(ValueError, match="unknown key"):
verify(compact, VerifyOptions(verifier_id="v", resolve_key=resolver, now=now))
def test_verify_resolve_key_required():
key = generate_key()
now = int(time.time())
p = Payload(
iss="iss", aud=["v"], iat=now, exp=now + 3600,
jti="jti-r", exec_act="act", pred=[],
)
compact = create(p, key, CreateOptions(key_id="kid"))
with pytest.raises(ValueError, match="ResolveKey"):
verify(compact, VerifyOptions(verifier_id="v", resolve_key=None))
def test_verify_with_dag():
from ect import MemoryLedger
key = generate_key()
ledger = MemoryLedger()
now = int(time.time())
root = Payload(
iss="iss", aud=["v"], iat=now, exp=now + 3600,
jti="jti-root", exec_act="act", pred=[],
)
compact_root = create(root, key, CreateOptions(key_id="kid"))
resolver = lambda kid: key.public_key() if kid == "kid" else None
opts = VerifyOptions(verifier_id="v", resolve_key=resolver, store=ledger, now=now)
parsed = verify(compact_root, opts)
ledger.append(compact_root, parsed.payload)
child = Payload(
iss="iss", aud=["v"], iat=now + 1, exp=now + 3600,
jti="jti-child", exec_act="act2", pred=["jti-root"],
)
compact_child = create(child, key, CreateOptions(key_id="kid"))
parsed2 = verify(compact_child, opts)
assert parsed2.payload.jti == "jti-child"
def test_on_verify_attempt_callback():
"""Observability: on_verify_attempt is called with jti and error (or None)."""
key = generate_key()
now = int(time.time())
p = Payload(iss="i", aud=["v"], iat=now, exp=now + 3600, jti="jti-obs", exec_act="a", pred=[])
compact = create(p, key, CreateOptions(key_id="kid"))
resolver = lambda k: key.public_key() if k == "kid" else None
seen = []
def hook(jti, err):
seen.append((jti, err))
opts = VerifyOptions(verifier_id="v", resolve_key=resolver, on_verify_attempt=hook)
result = verify(compact, opts)
assert result.payload.jti == "jti-obs"
assert len(seen) == 1
assert seen[0][0] == "jti-obs"
assert seen[0][1] is None
def test_on_verify_attempt_called_on_failure():
key = generate_key()
now = int(time.time())
p = Payload(iss="i", aud=["v"], iat=now, exp=now - 1, jti="jti-fail", exec_act="a", pred=[])
compact = create(p, key, CreateOptions(key_id="kid"))
resolver = lambda k: key.public_key() if k == "kid" else None
seen = []
opts = VerifyOptions(verifier_id="v", resolve_key=resolver, now=now, on_verify_attempt=lambda jti, err: seen.append((jti, err)))
with pytest.raises(ValueError, match="expired"):
verify(compact, opts)
assert len(seen) == 1
assert seen[0][0] == "jti-fail"
assert seen[0][1] is not None