Cross-spec interop validation between ietf-act and ietf-ect: - new packages/interop/ sibling package (ietf-act-ect-interop) - 32 tests pass: shared claims, algorithm matrix, DAG structure, divergence handling, anti-goals - documents ES256 raw signature wire-compatibility - documents airtight typ separation (act+jwt vs exec+jwt) Hazards surfaced: - ACTLedger.append() silently accepts ECT Payload via duck-typing (both have .jti) — documented in interop README as a production hazard requiring external isinstance checks Session handoff: - SESSION-2026-04-12.md — snapshot of decisions, artifacts, open actions, and next-session starting points Also: session-end commit of hash-format fix propagation to packages/ect/ (the fix was applied to the old refimpl location but did not propagate through the parallel package-move agent).
124 lines
4.3 KiB
Python
124 lines
4.3 KiB
Python
"""Shared-claim consistency between ACT and ECT refimpls.
|
|
|
|
Covers the claims both specs declare (jti, wid, iat, exp, aud,
|
|
exec_act, pred, inp_hash, out_hash): verifies that identical wire
|
|
values are accepted and round-trip cleanly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import uuid
|
|
|
|
import pytest
|
|
|
|
from act.crypto import b64url_sha256
|
|
from act.token import ACTRecord
|
|
from ect.types import Payload
|
|
from ect.validate import validate_hash_format
|
|
|
|
|
|
# --- pred array semantics ----------------------------------------------------
|
|
|
|
|
|
class TestPredArray:
|
|
@pytest.mark.parametrize(
|
|
"pred",
|
|
[
|
|
[],
|
|
[str(uuid.uuid4())],
|
|
[str(uuid.uuid4()), str(uuid.uuid4())],
|
|
],
|
|
)
|
|
def test_pred_roundtrips_on_both(self, act_record_builder, ect_payload_builder, pred):
|
|
"""Same pred array shape is serialised identically by both refimpls."""
|
|
act_compact, act_rec = act_record_builder(pred=list(pred))
|
|
ect_compact, ect_pl = ect_payload_builder(pred=list(pred))
|
|
|
|
assert act_rec.pred == pred
|
|
assert ect_pl.pred == pred
|
|
|
|
# Decode and compare the on-wire pred arrays.
|
|
from act.token import decode_jws
|
|
|
|
_, act_claims, _, _ = decode_jws(act_compact)
|
|
assert act_claims["pred"] == pred
|
|
|
|
import jwt
|
|
|
|
ect_claims = jwt.decode(
|
|
ect_compact, options={"verify_signature": False, "verify_exp": False}
|
|
)
|
|
assert ect_claims["pred"] == pred
|
|
|
|
|
|
# --- jti / wid scoping -------------------------------------------------------
|
|
|
|
|
|
class TestJtiWidScoping:
|
|
def test_same_uuid_accepted_independently(self, act_record_builder, ect_payload_builder):
|
|
"""The same jti UUID is accepted by both refimpls independently.
|
|
|
|
jti uniqueness is scoped per-store in each refimpl; there is no
|
|
shared namespace. This test documents that reusing a jti across
|
|
token types is not inherently rejected.
|
|
"""
|
|
shared_jti = str(uuid.uuid4())
|
|
act_compact, _ = act_record_builder(jti=shared_jti)
|
|
ect_compact, _ = ect_payload_builder(jti=shared_jti)
|
|
assert act_compact and ect_compact
|
|
|
|
def test_same_wid_accepted_by_both(self, act_record_builder, ect_payload_builder):
|
|
"""Identical wid value is preserved on both sides."""
|
|
shared_wid = str(uuid.uuid4())
|
|
_, act_rec = act_record_builder(wid=shared_wid)
|
|
_, ect_pl = ect_payload_builder(wid=shared_wid)
|
|
assert act_rec.wid == shared_wid == ect_pl.wid
|
|
|
|
|
|
# --- inp_hash / out_hash format (both now plain base64url) -------------------
|
|
|
|
|
|
class TestHashFormat:
|
|
def test_act_b64url_sha256_is_valid_for_ect(self):
|
|
"""ACT's b64url_sha256() output MUST pass ECT's validate_hash_format.
|
|
|
|
The ECT validator was aligned to plain base64url to match ACT —
|
|
this test would break if either side ever drifts.
|
|
"""
|
|
h = b64url_sha256(b"interop-payload")
|
|
# Should not raise.
|
|
validate_hash_format(h)
|
|
|
|
def test_ect_rejects_prefixed_hash(self):
|
|
"""Prefixed form (sha-256:...) is explicitly rejected by ECT now."""
|
|
digest = b64url_sha256(b"interop-payload")
|
|
with pytest.raises(ValueError, match="plain base64url"):
|
|
validate_hash_format(f"sha-256:{digest}")
|
|
|
|
def test_act_and_ect_agree_on_hash_shape(self):
|
|
"""Identical raw bytes produce the same base64url hash on both sides."""
|
|
raw = b"same bytes hashed on both sides"
|
|
act_hash = b64url_sha256(raw)
|
|
# Recompute independently.
|
|
expected = (
|
|
base64.urlsafe_b64encode(hashlib.sha256(raw).digest())
|
|
.rstrip(b"=")
|
|
.decode("ascii")
|
|
)
|
|
assert act_hash == expected
|
|
validate_hash_format(act_hash) # ECT accepts.
|
|
|
|
|
|
# --- exec_act ----------------------------------------------------------------
|
|
|
|
|
|
class TestExecAct:
|
|
@pytest.mark.parametrize("value", ["read", "read.data", "write.result"])
|
|
def test_exec_act_string_shared(self, act_record_builder, ect_payload_builder, value):
|
|
"""ACT-grammar-legal exec_act values are accepted unchanged by ECT."""
|
|
_, act_rec = act_record_builder(exec_act=value, cap_actions=[value])
|
|
_, ect_pl = ect_payload_builder(exec_act=value)
|
|
assert act_rec.exec_act == value == ect_pl.exec_act
|