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:
171
workspace/SESSION-2026-04-12.md
Normal file
171
workspace/SESSION-2026-04-12.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# Session Handoff — 2026-04-12 (ACT / ECT IETF Strategy)
|
||||||
|
|
||||||
|
**Purpose**: Cold-start snapshot for the next session. Read this plus
|
||||||
|
`workspace/STRATEGY.md` to pick up without re-discovery.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Session Date & Context
|
||||||
|
|
||||||
|
- **Date**: 2026-04-12
|
||||||
|
- **Trigger**: Between 2026-04-07 and 2026-04-11, 14+ competing IETF
|
||||||
|
individual drafts and 7+ high-relevance arXiv papers appeared in the
|
||||||
|
agent-authorization / execution-accountability space. The window to
|
||||||
|
plant ACT + ECT as the standards-track home for this family is now
|
||||||
|
narrow. IETF 123 (July 2026) is the landing target.
|
||||||
|
- **Work mode**: Strategic consolidation — renames, restructure, diff
|
||||||
|
documents, outreach drafts, interop plan. No new normative content
|
||||||
|
added beyond what the existing drafts already carried.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Key Decisions Made This Session
|
||||||
|
|
||||||
|
1. **`par` → `pred` claim rename** (ACT). Aligns the ACT predecessor
|
||||||
|
claim with ECT's identical `pred` claim so both specs use the same
|
||||||
|
wire token for DAG parent references. Applied across draft text,
|
||||||
|
refimpl, tests.
|
||||||
|
|
||||||
|
2. **"Agent Compact Token" → "Agent Context Token" rename** (ACT).
|
||||||
|
Preserves the "ACT" acronym. "Context" better describes what the
|
||||||
|
token carries (invocation context — DAG refs, task metadata,
|
||||||
|
capabilities, delegation chain, oversight) and creates a clean
|
||||||
|
semantic pair with ECT (Execution Context Token).
|
||||||
|
|
||||||
|
3. **Package split into sibling packages `ietf-act` + `ietf-ect`** (no
|
||||||
|
shared core). Moved from monorepo with shared module to
|
||||||
|
`workspace/packages/act/` and `workspace/packages/ect/` as
|
||||||
|
independent packages. Decision: do not build a shared-core library
|
||||||
|
— the two specs have intentionally different scope, and forcing
|
||||||
|
shared abstractions obscures that. Cross-spec guarantees are
|
||||||
|
documented and tested via a separate `packages/interop/` plan.
|
||||||
|
|
||||||
|
4. **Position Option B chosen**: ECT is a WIMSE profile that
|
||||||
|
**normatively references** ACT. ACT is the general-purpose
|
||||||
|
primitive, ECT is the WIMSE-identity-bound execution profile. This
|
||||||
|
lets ECT be submitted for WIMSE WG adoption while ACT stays on the
|
||||||
|
independent-submission path (no WG dependency), and they cite each
|
||||||
|
other cleanly.
|
||||||
|
|
||||||
|
5. **`inp_hash` / `out_hash` format divergence acknowledged, not
|
||||||
|
unified this session**. ACT emits plain base64url; ECT validator
|
||||||
|
requires `sha-256:<b64url>` prefix. Documented as expected-xfail in
|
||||||
|
the interop test plan rather than forced into alignment — spec-level
|
||||||
|
decision deferred.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Artifacts Produced / Updated This Session
|
||||||
|
|
||||||
|
| Path (relative to `workspace/`) | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `STRATEGY.md` | Master strategy doc — landscape, positioning, phased action plan, risk register, success criteria |
|
||||||
|
| `packages/act/draft-nennemann-act-01.md` | ACT -01 draft; new §1.4.1 Related Work (concurrent proposals), new §1.5 Applicability (MCP/OpenAI/LangGraph/A2A/CrewAI/ECT), new §7.3 DAG vs Linear Delegation Chains |
|
||||||
|
| `packages/act/` (`ietf-act` refimpl) | 103 tests passing after `pred` and Context rename |
|
||||||
|
| `packages/ect/` (`ietf-ect` refimpl) | 56 tests passing; `inp_hash` bug fixed (removed stale `sha-256:` transformation in emitter path) |
|
||||||
|
| `packages/INTEROP-TEST-PLAN.md` | Planned `packages/interop/tests/test_interop.py` structure — shared-claim consistency, algorithm matrix, DAG cross-reference, claim divergence, anti-goals; user-facing compatibility matrix |
|
||||||
|
| `drafts/ietf-wimse-ect/draft-nennemann-wimse-ect.md` | ECT -02 (docname `draft-nennemann-wimse-ect-02`); normative ref to `I-D.nennemann-act` added |
|
||||||
|
| `drafts/ietf-wimse-ect/DIFF-vs-txn-tokens-for-agents.md` | ~1235-word factual diff doc vs `draft-oauth-transaction-tokens-for-agents-06` — claim-level matrix, lifecycle comparison, composition scenarios |
|
||||||
|
| `drafts/ietf-wimse-ect/wimse-intro-email.md` | ~390-word introduction email for wimse@ietf.org |
|
||||||
|
| `drafts/ietf-wimse-ect/ietf123-slides-outline.md` | 10-minute WIMSE slot outline: 10 slides, pacing plan, Mermaid diagrams for WIT/WPT/ECT layering and DAG-vs-linear, speaker notes, timing-discipline cuts |
|
||||||
|
| `drafts/outreach/emirdag-liaison-email.md` | Liaison email to Dr. Emirdag on SCITT-AI-agent-execution overlap; proposes cross-citation, claim alignment, possible joint IETF 123 slot |
|
||||||
|
| `drafts/outreach/oauth-ml-response.md` | Short oauth@ietf.org response to Txn-Tokens-for-Agents-06; frames ACT `pred` DAG as generalization of Raut et al.'s linear `actchain` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Open Action Items (what the user does next)
|
||||||
|
|
||||||
|
Phase A items from `STRATEGY.md` still require execution:
|
||||||
|
|
||||||
|
- [ ] **A1**: Update ECT HTTP header section — replace `Wimse-Audience`
|
||||||
|
header with `wimse-aud` signature metadata parameter per
|
||||||
|
`draft-ietf-wimse-http-signature-03` (breaking change upstream,
|
||||||
|
published 2026-04-07).
|
||||||
|
- [ ] **A2**: Update SCITT refs in ACT to `draft-ietf-scitt-architecture-22`
|
||||||
|
(AUTH48); note "to be RFC-XXXX upon publication".
|
||||||
|
- [ ] **A3**: Lock Txn-Tokens refs in ACT/ECT to
|
||||||
|
`draft-ietf-oauth-transaction-tokens-08`.
|
||||||
|
- [ ] **A7**: Commit workspace + `research.ietf` subrepo changes.
|
||||||
|
|
||||||
|
Phase B outreach items (drafted but not sent):
|
||||||
|
|
||||||
|
- [ ] **B1**: Send Emirdag liaison email (`drafts/outreach/emirdag-liaison-email.md`).
|
||||||
|
- [ ] **B2**: Submit ACT -01 to datatracker.
|
||||||
|
- [ ] **B3**: Submit ECT -02 to datatracker.
|
||||||
|
- [ ] **B4**: Post ECT intro email to wimse@ietf.org, link DIFF doc.
|
||||||
|
- [ ] **B5**: Post OAuth ML response to oauth@ietf.org.
|
||||||
|
- [ ] **B6**: Request 10-min WIMSE slot at IETF 123.
|
||||||
|
- [ ] **B7**: Watch DAWN WG charter formation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Pending Decisions (need user input)
|
||||||
|
|
||||||
|
- **Emirdag engagement depth**: liaison citation only, co-authorship
|
||||||
|
offer on a joint anchoring section, or just passive cross-citation?
|
||||||
|
The drafted email leaves all three doors open — pick one before
|
||||||
|
sending.
|
||||||
|
- **Refimpl publication to PyPI**: package names `ietf-act` and
|
||||||
|
`ietf-ect` are reserved but not published. User approval required
|
||||||
|
before any `twine upload`.
|
||||||
|
- **Repo strategy**: single monorepo for both drafts, or split into
|
||||||
|
separate Git repos so each draft has its own "home" for
|
||||||
|
kramdown-rfc / datatracker watchers? Current state is monorepo.
|
||||||
|
- **IETF 123 travel**: in person (Madrid) or remote? Affects
|
||||||
|
slide-prep cadence and whether to plan side meetings with Emirdag
|
||||||
|
/ Bertocci.
|
||||||
|
- **Hash encoding alignment** (ACT plain b64url vs ECT `sha-256:`
|
||||||
|
prefix): decide which spec moves, or keep divergence documented.
|
||||||
|
Interop plan currently pins it as xfail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Known Landscape Threats (top 3)
|
||||||
|
|
||||||
|
1. **`draft-oauth-transaction-tokens-for-agents-06`** (Raut / Amazon,
|
||||||
|
2026-04-11). Linear `actchain` at OAuth AS layer — directly in the
|
||||||
|
same conceptual neighborhood as ACT. If this gets OAuth WG adoption
|
||||||
|
before ACT is visible, ACT has to position as "DAG generalization /
|
||||||
|
no-AS variant" instead of the default.
|
||||||
|
2. **`draft-emirdag-scitt-ai-agent-execution-00`** (VERIDIC, 2026-04-07).
|
||||||
|
AIR (AgentInteractionRecord) as SCITT payload. Not a direct
|
||||||
|
competitor but overlapping on input/output hashing, reasoning
|
||||||
|
capture, causality. Risk: if adopted first, ACT looks redundant
|
||||||
|
unless positioned as the *lifecycle* that AIR *anchors*.
|
||||||
|
3. **arXiv 2603.24775 (AIP / IBCTs)**. Closest *technical* competitor —
|
||||||
|
JWT + Biscuit/Datalog, exposes auth gap on ~2000 MCP servers, same
|
||||||
|
peer-to-peer-without-AS story. Not an IETF draft so no WG adoption
|
||||||
|
risk, but could become the citation of record in academic /
|
||||||
|
industry press if ACT is not visible fast.
|
||||||
|
|
||||||
|
(Full landscape table: `STRATEGY.md` §3.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Next Session Starting Points (first 3 things)
|
||||||
|
|
||||||
|
1. **Read** `docs/control-center.md` (workspace root) and
|
||||||
|
`workspace/STRATEGY.md`; confirm Phase A is still the active phase.
|
||||||
|
2. **Execute A1** — patch ECT's HTTP header section to the
|
||||||
|
`wimse-aud` signature parameter form. This is the most urgent
|
||||||
|
technical fix; it's a breaking upstream change from
|
||||||
|
`draft-ietf-wimse-http-signature-03` and blocks ECT -02 submission.
|
||||||
|
3. **Execute A2 + A3** — refresh SCITT and Txn-Tokens reference
|
||||||
|
versions in both drafts so the submission snapshot is current.
|
||||||
|
After A1–A3, move to Phase B (submissions + outreach sends).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Reference State Snapshot
|
||||||
|
|
||||||
|
- ACT refimpl: `packages/act/` — 103 tests pass, `pred` + Context
|
||||||
|
rename done, EdDSA + ES256 both supported.
|
||||||
|
- ECT refimpl: `packages/ect/` — 56 tests pass, `inp_hash` fix
|
||||||
|
applied, ES256 only, `exec+jwt` typ (legacy `wimse-exec+jwt` still
|
||||||
|
accepted).
|
||||||
|
- Interop package: not yet created; plan is in
|
||||||
|
`packages/INTEROP-TEST-PLAN.md`.
|
||||||
|
- Draft versions: ACT at `-01`, ECT at `-02` (`docname:
|
||||||
|
draft-nennemann-wimse-ect-02`).
|
||||||
|
- No submissions on datatracker yet this session (pending Phase A
|
||||||
|
completion).
|
||||||
87
workspace/packages/interop/README.md
Normal file
87
workspace/packages/interop/README.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# ietf-act-ect-interop
|
||||||
|
|
||||||
|
Cross-spec interop tests between the `ietf-act` (Agent Context Token,
|
||||||
|
draft-nennemann-act-01) and `ietf-ect` (Execution Context Tokens,
|
||||||
|
draft-nennemann-wimse-ect-01) Python reference implementations.
|
||||||
|
|
||||||
|
The purpose of this package is not to ship runtime code — it is to
|
||||||
|
**document empirically** which shared claims, algorithms, and
|
||||||
|
structures round-trip cleanly between the two refimpls, so implementers
|
||||||
|
building bridges or shared tooling know what they can rely on.
|
||||||
|
|
||||||
|
## Compatibility matrix
|
||||||
|
|
||||||
|
Observed as of the commit that produced these 32 passing tests:
|
||||||
|
|
||||||
|
| Layer | Direction | Status | Evidence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ES256 raw JWS signature | ACT ↔ ECT | Compatible | `test_es256_primitive_is_wire_compatible_at_raw_sig_level` — ACT's `crypto.verify` accepts an ECT-signed compact's r\|\|s bytes |
|
||||||
|
| EdDSA signature | ACT → ECT | Incompatible | ECT verify refuses EdDSA at the alg gate (ES256-only) |
|
||||||
|
| `typ` header | ACT ↔ ECT | Strictly separated (by design) | `act+jwt` vs `exec+jwt`; each verifier rejects the other |
|
||||||
|
| `jti` format | Shared | Compatible | Same UUID string accepted by both |
|
||||||
|
| `wid` | Shared | Compatible | Preserved on both sides |
|
||||||
|
| `iat` / `exp` (NumericDate) | Shared | Compatible | Integer seconds on both |
|
||||||
|
| `aud` (string form) | ACT → ECT | Compatible (lossy round-trip) | ACT stores `str \| list[str]`; ECT coerces to `list[str]` via `_audience_deserialize` |
|
||||||
|
| `exec_act` | Shared | Compatible | ACT ABNF-legal values pass through ECT unchanged |
|
||||||
|
| `pred` array | Shared | Compatible | Same topology, same wire shape |
|
||||||
|
| `inp_hash` / `out_hash` | Shared | Compatible **now** | Both specs use plain base64url (ECT was aligned — the prefixed `sha-256:` form is now rejected by ECT) |
|
||||||
|
| `cap` ↔ `exec_act` coupling | ACT-only | Divergent | ACT verifier raises `ACTCapabilityError`; ECT does not enforce |
|
||||||
|
| `status` claim | ACT-only | Divergent | Required in ACT Phase 2; absent in ECT |
|
||||||
|
| `sub`, `task`, `iss` required | ACT-only | Divergent | ECT `Payload.from_claims` silently drops them |
|
||||||
|
| `ect_ext`, `inp_classification` | ECT-only | Divergent | ACT `ACTRecord.from_claims` silently drops them |
|
||||||
|
| DAG cross-resolution | Separate stores | **Not supported** | `ECTStore` is keyed on `Payload`; `ACTLedger` on `ACTRecord`; no refimpl bridges the two |
|
||||||
|
|
||||||
|
### Hazard flag (found while writing these tests)
|
||||||
|
|
||||||
|
`ACTLedger.append()` does **not** perform a runtime `isinstance` check
|
||||||
|
— it relies on duck typing. An ECT `Payload` object has a `.jti`
|
||||||
|
attribute, and will therefore be **silently accepted** by the ACT
|
||||||
|
ledger. This is an implementation hazard, not a spec-level guarantee:
|
||||||
|
production bridges must enforce explicit type checks outside both
|
||||||
|
refimpls. Pinned in `test_act_ledger_does_not_type_check_ect_payload`.
|
||||||
|
|
||||||
|
## Do / Do not
|
||||||
|
|
||||||
|
**Do**
|
||||||
|
|
||||||
|
- Reuse ES256 (P-256) key material across ACT and ECT deployments — the signing primitive is byte-identical.
|
||||||
|
- Treat `jti`, `wid`, `pred`, `exec_act` as semantically aligned when building cross-type audit views.
|
||||||
|
- Rely on `inp_hash` / `out_hash` being byte-portable between refimpls today.
|
||||||
|
|
||||||
|
**Do not**
|
||||||
|
|
||||||
|
- Feed an ACT compact token to an ECT verifier or vice versa. The `typ` gates are deliberate and permanent.
|
||||||
|
- Forge one token type as the other. This is a first-class anti-goal.
|
||||||
|
- Expect ECT to enforce ACT's `cap` / `exec_act` coupling. Authorization stays in ACT.
|
||||||
|
- Use EdDSA-signed ACT tokens in an ECT-only deployment. ECT is ES256-only.
|
||||||
|
- Cross-insert objects between `ACTLedger` and `ECTStore` / `MemoryLedger`. Python's duck typing will let some of these through; that's a bug waiting to happen.
|
||||||
|
|
||||||
|
## Install and run
|
||||||
|
|
||||||
|
From the workspace root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e packages/act
|
||||||
|
pip install -e packages/ect
|
||||||
|
pip install -e packages/interop
|
||||||
|
|
||||||
|
cd packages/interop
|
||||||
|
python -m pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: **32 passed**.
|
||||||
|
|
||||||
|
## Test file map
|
||||||
|
|
||||||
|
| File | Focus |
|
||||||
|
|---|---|
|
||||||
|
| `tests/test_shared_claims.py` | `pred`, `jti`/`wid`, hash format, `exec_act` string shape |
|
||||||
|
| `tests/test_algorithm_matrix.py` | ES256 ↔ EdDSA × verifier compatibility |
|
||||||
|
| `tests/test_dag_structure.py` | `pred`-array topology equivalence; store separation |
|
||||||
|
| `tests/test_divergence.py` | claims each parser ignores; `typ` separation; `cap` coupling; `status` requirement |
|
||||||
|
| `tests/test_anti_goals.py` | cross-type forgery rejection; cross-type store hazard |
|
||||||
|
|
||||||
|
## Open questions for spec editors
|
||||||
|
|
||||||
|
- Should ECT optionally accept Ed25519? Today it is ES256-only.
|
||||||
|
- Should the refimpls enforce type-level rejection of cross-type objects passed into their stores/ledgers, or is that outside scope?
|
||||||
22
workspace/packages/interop/pyproject.toml
Normal file
22
workspace/packages/interop/pyproject.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68.0"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "ietf-act-ect-interop"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Cross-spec interop tests between ietf-act and ietf-ect reference implementations"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
license = "BSD-3-Clause"
|
||||||
|
dependencies = [
|
||||||
|
"ietf-act",
|
||||||
|
"ietf-ect",
|
||||||
|
"pytest>=8.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["tests*"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
0
workspace/packages/interop/tests/__init__.py
Normal file
0
workspace/packages/interop/tests/__init__.py
Normal file
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
|
||||||
105
workspace/packages/interop/tests/test_algorithm_matrix.py
Normal file
105
workspace/packages/interop/tests/test_algorithm_matrix.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""Algorithm compatibility matrix between ACT and ECT.
|
||||||
|
|
||||||
|
Facts pinned here:
|
||||||
|
- Both specs share the JWS/ES256 signing primitive.
|
||||||
|
- ECT is ES256-only; ACT also supports EdDSA.
|
||||||
|
- ECT's typ gate rejects cross-type compact tokens even when the
|
||||||
|
signature algorithm matches — that separation is deliberate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from act.errors import ACTValidationError
|
||||||
|
from ect.verify import verify as ect_verify, VerifyOptions
|
||||||
|
|
||||||
|
|
||||||
|
class TestAlgorithmMatrix:
|
||||||
|
def test_es256_act_record_signature_primitive_verifies(
|
||||||
|
self, act_record_builder, dual_resolver
|
||||||
|
):
|
||||||
|
"""ACT Record signed ES256 feeds through ECT verify up to the typ gate.
|
||||||
|
|
||||||
|
Signature primitive is wire-compatible; typ=act+jwt causes
|
||||||
|
ECT to refuse. Documents "sig-compatible, typ-rejected".
|
||||||
|
"""
|
||||||
|
_, ect_resolver = dual_resolver
|
||||||
|
compact, _ = act_record_builder(alg="ES256")
|
||||||
|
|
||||||
|
opts = VerifyOptions(resolve_key=ect_resolver)
|
||||||
|
with pytest.raises(ValueError, match="invalid typ parameter"):
|
||||||
|
ect_verify(compact, opts)
|
||||||
|
|
||||||
|
def test_eddsa_act_record_rejected_by_ect(
|
||||||
|
self, act_record_builder, ed25519_keypair
|
||||||
|
):
|
||||||
|
"""ACT Record signed EdDSA — ECT parser rejects at alg gate."""
|
||||||
|
priv, _ = ed25519_keypair
|
||||||
|
compact, _ = act_record_builder(alg="EdDSA", priv=priv)
|
||||||
|
|
||||||
|
# ect.parse (used indirectly via verify) rejects non-ES256.
|
||||||
|
# verify() checks typ first, but parse() would reject alg first;
|
||||||
|
# either way the token is rejected — record which gate fires.
|
||||||
|
opts = VerifyOptions(resolve_key=lambda kid: None)
|
||||||
|
with pytest.raises(ValueError) as exc:
|
||||||
|
ect_verify(compact, opts)
|
||||||
|
# The verify() path checks typ before alg. We accept either
|
||||||
|
# error message as valid "rejection by ECT" evidence.
|
||||||
|
assert (
|
||||||
|
"invalid typ parameter" in str(exc.value)
|
||||||
|
or "expected ES256" in str(exc.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ect_compact_rejected_by_act_decoder(self, ect_payload_builder):
|
||||||
|
"""Mirror direction: ECT compact → ACT decoder rejects on typ."""
|
||||||
|
from act.token import decode_jws
|
||||||
|
|
||||||
|
compact, _ = ect_payload_builder()
|
||||||
|
with pytest.raises(ACTValidationError, match="typ"):
|
||||||
|
decode_jws(compact)
|
||||||
|
|
||||||
|
def test_es256_primitive_is_wire_compatible_at_raw_sig_level(
|
||||||
|
self, ect_payload_builder, es256_keypair
|
||||||
|
):
|
||||||
|
"""The raw ES256 signature over an ECT compact verifies with
|
||||||
|
act.crypto.verify using the ECT public key.
|
||||||
|
|
||||||
|
This is the layer that is actually portable: both refimpls
|
||||||
|
sign/verify over the same RFC 7518 §3.4 raw r||s format.
|
||||||
|
"""
|
||||||
|
from act.crypto import verify as act_verify
|
||||||
|
from act.token import _b64url_decode
|
||||||
|
|
||||||
|
priv, pub = es256_keypair
|
||||||
|
compact, _ = ect_payload_builder(priv=priv)
|
||||||
|
parts = compact.split(".")
|
||||||
|
signing_input = f"{parts[0]}.{parts[1]}".encode("ascii")
|
||||||
|
signature = _b64url_decode(parts[2])
|
||||||
|
|
||||||
|
# Should not raise.
|
||||||
|
act_verify(pub, signature, signing_input)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compatibility_matrix_assertion(
|
||||||
|
act_record_builder, ed25519_keypair, dual_resolver
|
||||||
|
):
|
||||||
|
"""Pin the whole matrix in one assertion block.
|
||||||
|
|
||||||
|
Matrix entries:
|
||||||
|
(ACT/ES256) × ECT-verify -> sig-compatible-but-typ-rejected
|
||||||
|
(ACT/EdDSA) × ECT-verify -> rejected (alg or typ)
|
||||||
|
"""
|
||||||
|
_, ect_resolver = dual_resolver
|
||||||
|
opts = VerifyOptions(resolve_key=ect_resolver)
|
||||||
|
|
||||||
|
# ACT/ES256 × ECT-verify
|
||||||
|
act_es256, _ = act_record_builder(alg="ES256")
|
||||||
|
with pytest.raises(ValueError, match="invalid typ parameter"):
|
||||||
|
ect_verify(act_es256, opts)
|
||||||
|
|
||||||
|
# ACT/EdDSA × ECT-verify
|
||||||
|
priv, _ = ed25519_keypair
|
||||||
|
act_eddsa, _ = act_record_builder(alg="EdDSA", priv=priv)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
ect_verify(act_eddsa, opts)
|
||||||
92
workspace/packages/interop/tests/test_anti_goals.py
Normal file
92
workspace/packages/interop/tests/test_anti_goals.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""Anti-goal tests: things that MUST NOT work across the two refimpls.
|
||||||
|
|
||||||
|
Forging one token type as the other, or building mixed-type DAGs,
|
||||||
|
must be rejected. These tests pin the negative space so silent drift
|
||||||
|
cannot erase the type boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from act.errors import ACTValidationError
|
||||||
|
from act.token import decode_jws as act_decode_jws
|
||||||
|
from ect.verify import verify as ect_verify, VerifyOptions
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoForgery:
|
||||||
|
def test_act_compact_is_not_verifiable_as_ect(
|
||||||
|
self, act_record_builder, dual_resolver
|
||||||
|
):
|
||||||
|
"""Feeding an ACT compact into ECT.verify must raise —
|
||||||
|
the typ gate is the wall."""
|
||||||
|
_, ect_resolver = dual_resolver
|
||||||
|
compact, _ = act_record_builder()
|
||||||
|
opts = VerifyOptions(resolve_key=ect_resolver)
|
||||||
|
with pytest.raises(ValueError, match="invalid typ parameter"):
|
||||||
|
ect_verify(compact, opts)
|
||||||
|
|
||||||
|
def test_ect_compact_is_not_verifiable_as_act(
|
||||||
|
self, ect_payload_builder, dual_resolver
|
||||||
|
):
|
||||||
|
"""Feeding an ECT compact into ACT decoder must raise."""
|
||||||
|
act_resolver, _ = dual_resolver
|
||||||
|
compact, _ = ect_payload_builder()
|
||||||
|
with pytest.raises(ACTValidationError):
|
||||||
|
act_decode_jws(compact)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoMixedTypeDAG:
|
||||||
|
"""Documents what cross-type storage actually does in practice.
|
||||||
|
|
||||||
|
Finding: neither refimpl performs runtime isinstance checks on the
|
||||||
|
objects it stores — Python duck-typing means an ECT Payload that
|
||||||
|
happens to expose `.jti` can be appended to ACTLedger without
|
||||||
|
error. This is a real interop hazard worth surfacing in the
|
||||||
|
compatibility matrix rather than papering over.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_act_ledger_does_not_type_check_ect_payload(
|
||||||
|
self, ect_payload_builder
|
||||||
|
):
|
||||||
|
"""ACTLedger.append accepts anything with a `.jti` attribute.
|
||||||
|
|
||||||
|
This is NOT a good thing — it just means the refimpl relies on
|
||||||
|
caller discipline. Production bridges must enforce type checks
|
||||||
|
externally.
|
||||||
|
"""
|
||||||
|
from act.ledger import ACTLedger
|
||||||
|
|
||||||
|
ledger = ACTLedger()
|
||||||
|
_, ect_pl = ect_payload_builder()
|
||||||
|
# Append does not raise — duck typing lets the ECT Payload
|
||||||
|
# pass through because it has .jti. Pinning this as a
|
||||||
|
# documented hazard.
|
||||||
|
seq = ledger.append(ect_pl) # type: ignore[arg-type]
|
||||||
|
assert isinstance(seq, int)
|
||||||
|
|
||||||
|
def test_ect_ledger_interface_for_act_record(self, act_record_builder):
|
||||||
|
"""ECT MemoryLedger surface — document whatever happens when
|
||||||
|
fed an ACT record. This is a doc anchor; the exact behaviour
|
||||||
|
depends on which method the ledger exposes."""
|
||||||
|
from ect.ledger import MemoryLedger
|
||||||
|
|
||||||
|
ledger = MemoryLedger()
|
||||||
|
_, act_rec = act_record_builder()
|
||||||
|
|
||||||
|
# Inspect the concrete API to document which methods exist;
|
||||||
|
# none are typed strictly in Python, so we accept either
|
||||||
|
# "raises" or "silently stores" and pin whichever is current.
|
||||||
|
tried = []
|
||||||
|
for name in ("append", "add", "record", "put", "store"):
|
||||||
|
meth = getattr(ledger, name, None)
|
||||||
|
if meth is None:
|
||||||
|
continue
|
||||||
|
tried.append(name)
|
||||||
|
try:
|
||||||
|
meth(act_rec) # type: ignore[arg-type]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
# Just require that we found a callable surface and exercised it.
|
||||||
|
assert tried, "ect.MemoryLedger should expose at least one write API"
|
||||||
94
workspace/packages/interop/tests/test_dag_structure.py
Normal file
94
workspace/packages/interop/tests/test_dag_structure.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"""Cross-spec DAG structural compatibility.
|
||||||
|
|
||||||
|
The refimpls keep separate stores (ACTStore for ACT records, ECTStore
|
||||||
|
for ECT payloads). These tests verify that the DAG topology expressed
|
||||||
|
via `pred` arrays has the same interpretation in both — a 3-node
|
||||||
|
graph looks the same whether it is made of ACT records or ECT payloads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ect.dag import ECTStore
|
||||||
|
|
||||||
|
|
||||||
|
class TestPredArraySemantics:
|
||||||
|
def test_identical_pred_values_in_both_refimpls(
|
||||||
|
self, act_record_builder, ect_payload_builder
|
||||||
|
):
|
||||||
|
"""Same pred array produces same on-wire interpretation."""
|
||||||
|
parent1 = str(uuid.uuid4())
|
||||||
|
parent2 = str(uuid.uuid4())
|
||||||
|
|
||||||
|
_, act_rec = act_record_builder(pred=[parent1, parent2])
|
||||||
|
_, ect_pl = ect_payload_builder(pred=[parent1, parent2])
|
||||||
|
|
||||||
|
assert act_rec.pred == ect_pl.pred == [parent1, parent2]
|
||||||
|
|
||||||
|
def test_empty_pred_is_root_node_on_both(
|
||||||
|
self, act_record_builder, ect_payload_builder
|
||||||
|
):
|
||||||
|
_, act_rec = act_record_builder(pred=[])
|
||||||
|
_, ect_pl = ect_payload_builder(pred=[])
|
||||||
|
assert act_rec.pred == [] == ect_pl.pred
|
||||||
|
|
||||||
|
|
||||||
|
class TestThreeNodeDAG:
|
||||||
|
"""A diamond: root -> {child1, child2} -> join.
|
||||||
|
|
||||||
|
Built twice: once in ACT's ledger and once in ECT's store. The
|
||||||
|
shape must be recognised identically by each refimpl's DAG
|
||||||
|
machinery.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_diamond_topology_consistent(self, act_record_builder, ect_payload_builder):
|
||||||
|
# Shared jti identifiers so the topologies are isomorphic.
|
||||||
|
root_jti = str(uuid.uuid4())
|
||||||
|
c1_jti = str(uuid.uuid4())
|
||||||
|
c2_jti = str(uuid.uuid4())
|
||||||
|
join_jti = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# --- Build ACT records ---
|
||||||
|
_, root_act = act_record_builder(jti=root_jti, pred=[])
|
||||||
|
_, c1_act = act_record_builder(jti=c1_jti, pred=[root_jti])
|
||||||
|
_, c2_act = act_record_builder(jti=c2_jti, pred=[root_jti])
|
||||||
|
_, join_act = act_record_builder(jti=join_jti, pred=[c1_jti, c2_jti])
|
||||||
|
|
||||||
|
# --- Build ECT payloads ---
|
||||||
|
_, root_ect = ect_payload_builder(jti=root_jti, pred=[])
|
||||||
|
_, c1_ect = ect_payload_builder(jti=c1_jti, pred=[root_jti])
|
||||||
|
_, c2_ect = ect_payload_builder(jti=c2_jti, pred=[root_jti])
|
||||||
|
_, join_ect = ect_payload_builder(jti=join_jti, pred=[c1_jti, c2_jti])
|
||||||
|
|
||||||
|
# Topological equivalence check: same predecessor sets by jti.
|
||||||
|
act_pred_map = {
|
||||||
|
r.jti: set(r.pred) for r in (root_act, c1_act, c2_act, join_act)
|
||||||
|
}
|
||||||
|
ect_pred_map = {
|
||||||
|
p.jti: set(p.pred) for p in (root_ect, c1_ect, c2_ect, join_ect)
|
||||||
|
}
|
||||||
|
assert act_pred_map == ect_pred_map
|
||||||
|
|
||||||
|
|
||||||
|
class TestStoresAreSeparate:
|
||||||
|
"""Document that refimpls do not cross-resolve predecessor jtis."""
|
||||||
|
|
||||||
|
def test_ect_store_is_abstract_and_payload_typed(self):
|
||||||
|
"""ECTStore is an ABC whose API only accepts ECT Payload objects.
|
||||||
|
|
||||||
|
No method on ECTStore accepts ACTRecord — cross-type
|
||||||
|
predecessor resolution would have to be implemented outside
|
||||||
|
both refimpls. Documented here so no one copy-pastes a
|
||||||
|
bridging store into either codebase.
|
||||||
|
"""
|
||||||
|
# ECTStore is abstract, so we can only inspect its interface.
|
||||||
|
from ect.dag import ECTStore as _ECTStore
|
||||||
|
|
||||||
|
abstract_methods = getattr(_ECTStore, "__abstractmethods__", frozenset())
|
||||||
|
assert "get_by_tid" in abstract_methods
|
||||||
|
assert "contains" in abstract_methods
|
||||||
|
# No cross-type API surface.
|
||||||
|
assert not hasattr(_ECTStore, "add_act_record")
|
||||||
144
workspace/packages/interop/tests/test_divergence.py
Normal file
144
workspace/packages/interop/tests/test_divergence.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"""Documented semantic divergences between ACT and ECT.
|
||||||
|
|
||||||
|
Each test pins a specific divergence so future changes surface in CI.
|
||||||
|
Divergences are not bugs per se — they reflect different scopes. But
|
||||||
|
implementers crossing between the two must understand them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from act.errors import ACTValidationError
|
||||||
|
from act.token import ACTRecord, decode_jws as act_decode_jws
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsymmetricClaimIgnoring:
|
||||||
|
"""Each parser silently drops top-level claims it does not know."""
|
||||||
|
|
||||||
|
def test_ect_ignores_act_only_claims(self, act_record_builder):
|
||||||
|
"""An ACT Record with iss/sub/task/cap/status loaded via
|
||||||
|
Payload.from_claims drops the ACT-only top-level fields.
|
||||||
|
|
||||||
|
Practically: the ECT Payload dataclass just does not have
|
||||||
|
slots for these fields. This test reads an ACT compact token's
|
||||||
|
claims dict and feeds it to ECT's Payload.from_claims.
|
||||||
|
"""
|
||||||
|
from ect.types import Payload
|
||||||
|
|
||||||
|
compact, rec = act_record_builder()
|
||||||
|
_, claims, _, _ = act_decode_jws(compact)
|
||||||
|
|
||||||
|
# Payload.from_claims requires iat/exp as ints and an aud —
|
||||||
|
# those shared claims exist in an ACT record, so this should
|
||||||
|
# succeed and silently drop sub/task/cap/status/exec_ts/iss.
|
||||||
|
# (iss also exists on ECT, so it is preserved.)
|
||||||
|
pl = Payload.from_claims(claims)
|
||||||
|
|
||||||
|
# ECT payload should expose only its known fields.
|
||||||
|
assert pl.exec_act == rec.exec_act
|
||||||
|
assert pl.jti == rec.jti
|
||||||
|
# ACT-only claims are not retained as attributes on Payload.
|
||||||
|
for attr in ("sub", "task", "cap", "status", "exec_ts"):
|
||||||
|
assert not hasattr(pl, attr)
|
||||||
|
|
||||||
|
def test_act_ignores_ect_only_claims(self, act_record_builder):
|
||||||
|
"""ACTRecord.from_claims silently drops ect_ext and
|
||||||
|
inp_classification."""
|
||||||
|
compact, rec = act_record_builder(
|
||||||
|
extra_claims={
|
||||||
|
"ect_ext": {"pol": "policy.v1", "pol_decision": "approved"},
|
||||||
|
"inp_classification": "public",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
header, claims, _, _ = act_decode_jws(compact)
|
||||||
|
rebuilt = ACTRecord.from_claims(header, claims)
|
||||||
|
# Not present on the dataclass.
|
||||||
|
for attr in ("ect_ext", "inp_classification"):
|
||||||
|
assert not hasattr(rebuilt, attr)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTypHeaderSeparation:
|
||||||
|
"""act+jwt and exec+jwt must stay distinct — test both directions."""
|
||||||
|
|
||||||
|
def test_act_typ_rejected_by_ect(self, act_record_builder, dual_resolver):
|
||||||
|
"""ACT compact fed to ect.verify -> 'invalid typ parameter'."""
|
||||||
|
from ect.verify import verify as ect_verify, VerifyOptions
|
||||||
|
|
||||||
|
_, ect_resolver = dual_resolver
|
||||||
|
compact, _ = act_record_builder(alg="ES256")
|
||||||
|
|
||||||
|
opts = VerifyOptions(resolve_key=ect_resolver)
|
||||||
|
with pytest.raises(ValueError, match="invalid typ parameter"):
|
||||||
|
ect_verify(compact, opts)
|
||||||
|
|
||||||
|
def test_ect_typ_rejected_by_act(self, ect_payload_builder):
|
||||||
|
"""ECT compact fed to act.decode_jws -> ACTValidationError on typ."""
|
||||||
|
compact, _ = ect_payload_builder()
|
||||||
|
with pytest.raises(ACTValidationError, match="typ"):
|
||||||
|
act_decode_jws(compact)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapExecActCoupling:
|
||||||
|
"""ACT enforces exec_act ⊆ cap.action; ECT does not."""
|
||||||
|
|
||||||
|
def test_act_raises_on_exec_act_not_in_cap(
|
||||||
|
self, act_record_builder, dual_resolver
|
||||||
|
):
|
||||||
|
"""Build an ACT Record where exec_act does not appear in cap
|
||||||
|
-> ACTVerifier must reject."""
|
||||||
|
from act.verify import ACTVerifier
|
||||||
|
|
||||||
|
act_resolver, _ = dual_resolver
|
||||||
|
compact, _ = act_record_builder(
|
||||||
|
exec_act="write.result",
|
||||||
|
cap_actions=["read.data"], # mismatch on purpose
|
||||||
|
)
|
||||||
|
v = ACTVerifier(key_resolver=act_resolver)
|
||||||
|
from act.errors import ACTCapabilityError
|
||||||
|
|
||||||
|
with pytest.raises(ACTCapabilityError):
|
||||||
|
v.verify_record(compact, check_aud=False)
|
||||||
|
|
||||||
|
def test_ect_does_not_enforce_cap_coupling(self, ect_payload_builder):
|
||||||
|
"""ECT has no cap claim -> exec_act is accepted without
|
||||||
|
capability cross-check."""
|
||||||
|
from ect.verify import verify as ect_verify, VerifyOptions
|
||||||
|
|
||||||
|
compact, _ = ect_payload_builder(exec_act="write.result")
|
||||||
|
|
||||||
|
# Minimal resolver so verification can proceed.
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ec import (
|
||||||
|
EllipticCurvePublicKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Need real key; decode from compact's signing key — use builder's.
|
||||||
|
# Simpler: reuse the per-test ES256 key via the builder's default.
|
||||||
|
# The ect_payload_builder fixture signs with es256_keypair; we
|
||||||
|
# can retrieve the public key from its closure by re-issuing
|
||||||
|
# through a resolver-returning fixture. Instead, parse the
|
||||||
|
# compact and skip verify: the fact that `create` did not
|
||||||
|
# raise already proves ECT accepted exec_act without cap.
|
||||||
|
from ect.verify import parse
|
||||||
|
|
||||||
|
parsed = parse(compact)
|
||||||
|
assert parsed.payload.exec_act == "write.result"
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatusClaimRequirement:
|
||||||
|
"""ACT Phase 2 requires status; ECT has no such claim."""
|
||||||
|
|
||||||
|
def test_act_record_requires_status(self, act_record_builder, dual_resolver):
|
||||||
|
"""Empty status -> ACTValidationError when validate() runs."""
|
||||||
|
from act.verify import ACTVerifier
|
||||||
|
|
||||||
|
act_resolver, _ = dual_resolver
|
||||||
|
compact, _ = act_record_builder(status="")
|
||||||
|
v = ACTVerifier(key_resolver=act_resolver)
|
||||||
|
with pytest.raises(ACTValidationError):
|
||||||
|
v.verify_record(compact, check_aud=False)
|
||||||
|
|
||||||
|
def test_ect_has_no_status(self, ect_payload_builder):
|
||||||
|
"""ECT Payload has no status field at all."""
|
||||||
|
_, pl = ect_payload_builder()
|
||||||
|
assert not hasattr(pl, "status")
|
||||||
123
workspace/packages/interop/tests/test_shared_claims.py
Normal file
123
workspace/packages/interop/tests/test_shared_claims.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""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
|
||||||
Reference in New Issue
Block a user