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