feat: interop test package + session handoff doc
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).
This commit is contained in:
215
workspace/packages/interop/tests/conftest.py
Normal file
215
workspace/packages/interop/tests/conftest.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""Shared fixtures for ACT <-> ECT interop tests.
|
||||
|
||||
Provides keypairs (ES256 + Ed25519), token builders with overlapping
|
||||
claims, and a dual key resolver that works for both refimpls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Callable
|
||||
|
||||
import pytest
|
||||
|
||||
from act.crypto import (
|
||||
generate_ed25519_keypair,
|
||||
generate_p256_keypair,
|
||||
sign as act_sign,
|
||||
)
|
||||
from act.token import (
|
||||
ACTMandate,
|
||||
ACTRecord,
|
||||
Capability,
|
||||
TaskClaim,
|
||||
encode_jws,
|
||||
)
|
||||
from ect.create import create as ect_create, CreateOptions
|
||||
from ect.types import Payload
|
||||
|
||||
|
||||
# --- Keypair fixtures ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def es256_keypair():
|
||||
"""Shared ES256 (P-256) keypair usable by both ACT and ECT."""
|
||||
priv, pub = generate_p256_keypair()
|
||||
return priv, pub
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ed25519_keypair():
|
||||
"""Ed25519 keypair — ACT supports this, ECT does not."""
|
||||
priv, pub = generate_ed25519_keypair()
|
||||
return priv, pub
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_time() -> int:
|
||||
return int(time.time())
|
||||
|
||||
|
||||
# --- Builders for overlapping claim fixtures ---
|
||||
|
||||
|
||||
def _new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def act_record_builder(es256_keypair, base_time):
|
||||
"""Build a signed Phase 2 ACTRecord compact string (ES256).
|
||||
|
||||
The builder accepts overrides (alg, key, jti, wid, pred, exec_act,
|
||||
inp_hash, out_hash, aud) so individual tests can shape the record
|
||||
exactly how they want.
|
||||
"""
|
||||
priv_default, _ = es256_keypair
|
||||
|
||||
def _build(
|
||||
*,
|
||||
alg: str = "ES256",
|
||||
priv=None,
|
||||
kid: str = "act-key-1",
|
||||
jti: str | None = None,
|
||||
wid: str | None = None,
|
||||
pred: list[str] | None = None,
|
||||
exec_act: str = "read.data",
|
||||
cap_actions: list[str] | None = None,
|
||||
inp_hash: str | None = None,
|
||||
out_hash: str | None = None,
|
||||
aud: str | list[str] = "agent-b",
|
||||
iss: str = "agent-a",
|
||||
sub: str = "agent-b",
|
||||
iat: int | None = None,
|
||||
exp: int | None = None,
|
||||
exec_ts: int | None = None,
|
||||
status: str = "completed",
|
||||
extra_claims: dict[str, Any] | None = None,
|
||||
) -> tuple[str, ACTRecord]:
|
||||
iat = iat or base_time
|
||||
exp = exp or (base_time + 900)
|
||||
exec_ts = exec_ts or base_time
|
||||
jti = jti or _new_uuid()
|
||||
cap_actions = cap_actions or [exec_act]
|
||||
|
||||
record = ACTRecord(
|
||||
alg=alg,
|
||||
kid=kid,
|
||||
iss=iss,
|
||||
sub=sub,
|
||||
aud=aud,
|
||||
iat=iat,
|
||||
exp=exp,
|
||||
jti=jti,
|
||||
wid=wid,
|
||||
task=TaskClaim(purpose="interop_test"),
|
||||
cap=[Capability(action=a) for a in cap_actions],
|
||||
exec_act=exec_act,
|
||||
pred=pred if pred is not None else [],
|
||||
exec_ts=exec_ts,
|
||||
status=status,
|
||||
inp_hash=inp_hash,
|
||||
out_hash=out_hash,
|
||||
)
|
||||
|
||||
signing_priv = priv if priv is not None else priv_default
|
||||
signing_input = record.signing_input()
|
||||
|
||||
# extra_claims injection requires re-assembly because ACTRecord
|
||||
# is a dataclass with a fixed shape. If a test wants to add
|
||||
# arbitrary top-level claims it must build the compact manually.
|
||||
if extra_claims:
|
||||
import base64
|
||||
import json
|
||||
|
||||
header = record.to_header()
|
||||
claims = record.to_claims()
|
||||
claims.update(extra_claims)
|
||||
|
||||
def b64(b: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")
|
||||
|
||||
h = b64(json.dumps(header, separators=(",", ":")).encode())
|
||||
p = b64(json.dumps(claims, separators=(",", ":")).encode())
|
||||
signing_input = f"{h}.{p}".encode("ascii")
|
||||
sig = act_sign(signing_priv, signing_input)
|
||||
compact = f"{h}.{p}.{b64(sig)}"
|
||||
return compact, record
|
||||
|
||||
sig = act_sign(signing_priv, signing_input)
|
||||
compact = encode_jws(record, sig)
|
||||
return compact, record
|
||||
|
||||
return _build
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ect_payload_builder(es256_keypair, base_time):
|
||||
"""Build a signed ECT compact string."""
|
||||
priv_default, _ = es256_keypair
|
||||
|
||||
def _build(
|
||||
*,
|
||||
priv=None,
|
||||
kid: str = "ect-key-1",
|
||||
jti: str | None = None,
|
||||
wid: str = "",
|
||||
pred: list[str] | None = None,
|
||||
exec_act: str = "read.data",
|
||||
inp_hash: str = "",
|
||||
out_hash: str = "",
|
||||
aud: list[str] | None = None,
|
||||
iss: str = "spiffe://example.com/agent/a",
|
||||
iat: int | None = None,
|
||||
exp: int | None = None,
|
||||
) -> tuple[str, Payload]:
|
||||
iat = iat or base_time
|
||||
exp = exp or (base_time + 600)
|
||||
jti = jti or _new_uuid()
|
||||
aud = aud or ["spiffe://example.com/agent/b"]
|
||||
|
||||
payload = Payload(
|
||||
iss=iss,
|
||||
aud=aud,
|
||||
iat=iat,
|
||||
exp=exp,
|
||||
jti=jti,
|
||||
exec_act=exec_act,
|
||||
pred=pred if pred is not None else [],
|
||||
wid=wid,
|
||||
inp_hash=inp_hash,
|
||||
out_hash=out_hash,
|
||||
)
|
||||
signing_priv = priv if priv is not None else priv_default
|
||||
compact = ect_create(payload, signing_priv, CreateOptions(key_id=kid))
|
||||
return compact, payload
|
||||
|
||||
return _build
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dual_resolver(es256_keypair):
|
||||
"""Return an (act_resolver_obj, ect_resolver_callable) bound to one
|
||||
shared ES256 key for both refimpls.
|
||||
|
||||
ACT uses an ACTKeyResolver object (with .resolve(kid, header=)).
|
||||
ECT uses a plain Callable[[str], Optional[EllipticCurvePublicKey]].
|
||||
"""
|
||||
from act.crypto import ACTKeyResolver, KeyRegistry
|
||||
|
||||
_, pub = es256_keypair
|
||||
# Register the same key under both kids so either refimpl's token
|
||||
# resolves successfully during the compatibility tests.
|
||||
reg = KeyRegistry()
|
||||
reg.register("act-key-1", pub)
|
||||
reg.register("ect-key-1", pub)
|
||||
act_resolver = ACTKeyResolver(registry=reg)
|
||||
|
||||
def ect_resolver(kid: str):
|
||||
if kid in ("act-key-1", "ect-key-1"):
|
||||
return pub
|
||||
return None
|
||||
|
||||
return act_resolver, ect_resolver
|
||||
Reference in New Issue
Block a user