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:
1
workspace/packages/ect/tests/__init__.py
Normal file
1
workspace/packages/ect/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tests package
|
||||
49
workspace/packages/ect/tests/test_config.py
Normal file
49
workspace/packages/ect/tests/test_config.py
Normal 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)
|
||||
74
workspace/packages/ect/tests/test_create.py
Normal file
74
workspace/packages/ect/tests/test_create.py
Normal 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)
|
||||
94
workspace/packages/ect/tests/test_create_extra.py
Normal file
94
workspace/packages/ect/tests/test_create_extra.py
Normal 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"))
|
||||
111
workspace/packages/ect/tests/test_dag.py
Normal file
111
workspace/packages/ect/tests/test_dag.py
Normal 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())
|
||||
40
workspace/packages/ect/tests/test_jti_cache.py
Normal file
40
workspace/packages/ect/tests/test_jti_cache.py
Normal 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
|
||||
38
workspace/packages/ect/tests/test_ledger_extra.py
Normal file
38
workspace/packages/ect/tests/test_ledger_extra.py
Normal 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
|
||||
64
workspace/packages/ect/tests/test_types_extra.py
Normal file
64
workspace/packages/ect/tests/test_types_extra.py
Normal 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"
|
||||
64
workspace/packages/ect/tests/test_validate.py
Normal file
64
workspace/packages/ect/tests/test_validate.py
Normal 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")
|
||||
194
workspace/packages/ect/tests/test_verify.py
Normal file
194
workspace/packages/ect/tests/test_verify.py
Normal 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
|
||||
Reference in New Issue
Block a user