Files
ietf-draft-analyzer/workspace/packages/interop/tests/conftest.py
Christian Nennemann 37859beef6 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).
2026-04-12 07:39:41 +02:00

216 lines
6.0 KiB
Python

"""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