From 3a139dfc7e5da9d9ffe791c3634983a040491880 Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Sun, 12 Apr 2026 07:33:08 +0200 Subject: [PATCH] feat: ACT/ECT strategy, package restructure, draft -01/-02 prep Strategic work for IETF submission of draft-nennemann-act-01 and draft-nennemann-wimse-ect-02: Package restructure: - move ACT and ECT refimpls to workspace/packages/{act,ect}/ - ietf-act and ietf-ect distribution names (sibling packages) - cross-spec interop test plan (INTEROP-TEST-PLAN.md) ACT draft -01 revisions: - rename 'par' claim to 'pred' (align with ECT) - rename 'Agent Compact Token' to 'Agent Context Token' (semantic alignment with ECT family) - add Applicability section (MCP, OpenAI, LangGraph, A2A, CrewAI) - add DAG vs Linear Delegation Chains section (differentiator vs txn-tokens-for-agents actchain, Agentic JWT, AIP/IBCTs) - add Related Work: AIP, SentinelAgent, Agentic JWT, txn-tokens-for-agents, HDP, SCITT-AI-agent-execution - pin SCITT arch to -22, note AUTH48 status Outreach drafts: - Emirdag liaison email (SCITT-AI coordination) - OAuth ML response on txn-tokens-for-agents-06 Strategy document: - STRATEGY.md with phased action plan, risk register, timeline Submodule: - update workspace/drafts/ietf-wimse-ect pointer to -02 commit --- workspace/STRATEGY.md | 224 ++ workspace/act/MOVED.md | 1 + workspace/drafts/ietf-wimse-ect | 2 +- .../drafts/outreach/emirdag-liaison-email.md | 30 + .../drafts/outreach/oauth-ml-response.md | 71 + workspace/packages/INTEROP-TEST-PLAN.md | 113 + workspace/packages/act/act/__init__.py | 119 ++ workspace/packages/act/act/crypto.py | 467 +++++ workspace/packages/act/act/dag.py | 136 ++ workspace/packages/act/act/delegation.py | 333 +++ workspace/packages/act/act/errors.py | 131 ++ workspace/packages/act/act/ledger.py | 152 ++ workspace/packages/act/act/lifecycle.py | 96 + workspace/packages/act/act/token.py | 734 +++++++ workspace/packages/act/act/vectors.py | 639 ++++++ workspace/packages/act/act/verify.py | 323 +++ workspace/packages/act/bench/bench_act.py | 174 ++ .../act/docs/section-1.5-applicability.md | 194 ++ .../packages/act/draft-nennemann-act-01.md | 1866 +++++++++++++++++ workspace/packages/act/pyproject.toml | 23 + workspace/packages/act/tests/test_crypto.py | 145 ++ workspace/packages/act/tests/test_dag.py | 103 + .../packages/act/tests/test_delegation.py | 229 ++ workspace/packages/act/tests/test_ledger.py | 84 + .../packages/act/tests/test_lifecycle.py | 103 + workspace/packages/act/tests/test_token.py | 244 +++ workspace/packages/act/tests/test_vectors.py | 35 + workspace/packages/act/tests/test_verify.py | 191 ++ workspace/packages/ect/.coverage | Bin 0 -> 53248 bytes workspace/packages/ect/README.md | 111 + workspace/packages/ect/demo.py | 102 + workspace/packages/ect/ect/__init__.py | 55 + workspace/packages/ect/ect/config.py | 61 + workspace/packages/ect/ect/create.py | 104 + workspace/packages/ect/ect/dag.py | 96 + workspace/packages/ect/ect/jti_cache.py | 52 + workspace/packages/ect/ect/ledger.py | 97 + workspace/packages/ect/ect/types.py | 106 + workspace/packages/ect/ect/validate.py | 62 + workspace/packages/ect/ect/verify.py | 154 ++ workspace/packages/ect/pyproject.toml | 25 + .../ect/testdata/valid_root_ect_payload.json | 1 + workspace/packages/ect/tests/__init__.py | 1 + workspace/packages/ect/tests/test_config.py | 49 + workspace/packages/ect/tests/test_create.py | 74 + .../packages/ect/tests/test_create_extra.py | 94 + workspace/packages/ect/tests/test_dag.py | 111 + .../packages/ect/tests/test_jti_cache.py | 40 + .../packages/ect/tests/test_ledger_extra.py | 38 + .../packages/ect/tests/test_types_extra.py | 64 + workspace/packages/ect/tests/test_validate.py | 64 + workspace/packages/ect/tests/test_verify.py | 194 ++ workspace/packages/pyproject.toml | 2 + 53 files changed, 8718 insertions(+), 1 deletion(-) create mode 100644 workspace/STRATEGY.md create mode 100644 workspace/act/MOVED.md create mode 100644 workspace/drafts/outreach/emirdag-liaison-email.md create mode 100644 workspace/drafts/outreach/oauth-ml-response.md create mode 100644 workspace/packages/INTEROP-TEST-PLAN.md create mode 100644 workspace/packages/act/act/__init__.py create mode 100644 workspace/packages/act/act/crypto.py create mode 100644 workspace/packages/act/act/dag.py create mode 100644 workspace/packages/act/act/delegation.py create mode 100644 workspace/packages/act/act/errors.py create mode 100644 workspace/packages/act/act/ledger.py create mode 100644 workspace/packages/act/act/lifecycle.py create mode 100644 workspace/packages/act/act/token.py create mode 100644 workspace/packages/act/act/vectors.py create mode 100644 workspace/packages/act/act/verify.py create mode 100644 workspace/packages/act/bench/bench_act.py create mode 100644 workspace/packages/act/docs/section-1.5-applicability.md create mode 100644 workspace/packages/act/draft-nennemann-act-01.md create mode 100644 workspace/packages/act/pyproject.toml create mode 100644 workspace/packages/act/tests/test_crypto.py create mode 100644 workspace/packages/act/tests/test_dag.py create mode 100644 workspace/packages/act/tests/test_delegation.py create mode 100644 workspace/packages/act/tests/test_ledger.py create mode 100644 workspace/packages/act/tests/test_lifecycle.py create mode 100644 workspace/packages/act/tests/test_token.py create mode 100644 workspace/packages/act/tests/test_vectors.py create mode 100644 workspace/packages/act/tests/test_verify.py create mode 100644 workspace/packages/ect/.coverage create mode 100644 workspace/packages/ect/README.md create mode 100644 workspace/packages/ect/demo.py create mode 100644 workspace/packages/ect/ect/__init__.py create mode 100644 workspace/packages/ect/ect/config.py create mode 100644 workspace/packages/ect/ect/create.py create mode 100644 workspace/packages/ect/ect/dag.py create mode 100644 workspace/packages/ect/ect/jti_cache.py create mode 100644 workspace/packages/ect/ect/ledger.py create mode 100644 workspace/packages/ect/ect/types.py create mode 100644 workspace/packages/ect/ect/validate.py create mode 100644 workspace/packages/ect/ect/verify.py create mode 100644 workspace/packages/ect/pyproject.toml create mode 100644 workspace/packages/ect/testdata/valid_root_ect_payload.json create mode 100644 workspace/packages/ect/tests/__init__.py create mode 100644 workspace/packages/ect/tests/test_config.py create mode 100644 workspace/packages/ect/tests/test_create.py create mode 100644 workspace/packages/ect/tests/test_create_extra.py create mode 100644 workspace/packages/ect/tests/test_dag.py create mode 100644 workspace/packages/ect/tests/test_jti_cache.py create mode 100644 workspace/packages/ect/tests/test_ledger_extra.py create mode 100644 workspace/packages/ect/tests/test_types_extra.py create mode 100644 workspace/packages/ect/tests/test_validate.py create mode 100644 workspace/packages/ect/tests/test_verify.py create mode 100644 workspace/packages/pyproject.toml diff --git a/workspace/STRATEGY.md b/workspace/STRATEGY.md new file mode 100644 index 0000000..6a8f875 --- /dev/null +++ b/workspace/STRATEGY.md @@ -0,0 +1,224 @@ +# ACT + ECT IETF Strategy + +**Author**: Christian Nennemann +**Date**: 2026-04-12 +**Status**: Active + +--- + +## 1. Executive Summary + +Two Internet-Drafts, one strategy: **ACT** (general) + **ECT** (WIMSE profile) as a complementary spec family for AI agent authorization and execution accountability. + +**The window**: In the last 8 weeks, 14+ competing IETF individual drafts and 7+ high-relevance arXiv papers appeared. The space is crowding fast. **Ship -01/-02 within 2 weeks**; establish IETF 123 (July 2026) as the landing point. + +**The position**: ACT is the only spec combining (a) two-phase JWT lifecycle, (b) DAG-based DAG predecessor structure, and (c) standards-track independence from proprietary agent frameworks. ECT is the only WIMSE-aligned execution-context spec. + +--- + +## 2. Current State (What We Have) + +### Artifacts in place + +| Artifact | Location | Status | +|---|---|---| +| ACT draft | `packages/act/draft-nennemann-act-01.md` | -01, ready to review | +| ECT draft | `drafts/ietf-wimse-ect/draft-nennemann-wimse-ect.md` | -02, needs HTTP header update | +| ACT refimpl | `packages/act/` (ietf-act) | 103 tests pass, `pred` + Context rename done | +| ECT refimpl | `packages/ect/` (ietf-ect) | 56 tests pass, `inp_hash` bug fixed | +| ACT applicability section | In draft §1.5 | MCP, OpenAI, LangGraph, A2A, CrewAI, WIMSE-ECT | +| Diff doc vs Txn-Agents | `drafts/ietf-wimse-ect/DIFF-vs-txn-tokens-for-agents.md` | Done, ~1235 words | +| WIMSE mailing list email | `drafts/ietf-wimse-ect/wimse-intro-email.md` | Done, ~390 words | + +### Recent completed work + +- `par` → `pred` rename across ACT (spec alignment with ECT) +- "Agent Compact Token" → "Agent Context Token" rename (semantic alignment with ECT) +- Package restructure to `workspace/packages/{act,ect}/` +- ECT `inp_hash` format bug fix (removed `sha-256:` prefix) + +--- + +## 3. Landscape (What Just Happened) + +### Critical drafts published April 7–11, 2026 + +| Draft | Impact | Response | +|---|---|---| +| `draft-emirdag-scitt-ai-agent-execution-00` | SCITT profile for AgentInteractionRecord (AIR) | **Propose liaison**: ACT = lifecycle, AIR = anchor payload | +| `draft-oauth-transaction-tokens-for-agents-06` | Amazon's `actchain` competes with ACT's DAG | **Differentiate**: linear chain vs DAG (fork/join) | +| `draft-ietf-wimse-http-signature-03` | `Wimse-Audience` header **removed** → `wimse-aud` param | **Breaking change — fix ECT immediately** | +| `draft-ietf-oauth-transaction-tokens-08` | In WG Last Call → RFC imminent | Lock references before publication | +| `draft-ietf-scitt-architecture-22` | In AUTH48 → RFC imminent | Update SCITT refs to RFC number | + +### Competitive arXiv papers (Mar–Apr 2026) + +- **2603.24775 (AIP/IBCTs)** — closest technical competitor, JWT + Biscuit/Datalog, zero auth on ~2000 MCP servers +- **2604.02767 (SentinelAgent)** — formal Delegation Chain Calculus +- **2509.13597 (Agentic JWT)** — prior linear chain JWT +- **2603.23801 (AgentRFC — Composition Safety)** — theoretical grounding for DAG-level tracking + +### Strategic openings + +- `draft-ietf-wimse-arch-07 §3.3.9` — WG arch doc **already names AI/ML intermediaries as workloads**; ECT fills this gap +- **DAWN potential new WG** (`draft-king-dawn-requirements-00`, 2026-04-11) — agent discovery; ACT identity claims are natural payload +- **NIST/NCCoE Concept Paper** — US government validation of standards-first agent identity approach + +--- + +## 4. Positioning Strategy + +### The three-sentence pitch + +> ACT is a two-phase JWT lifecycle — the authorization mandate transitions to a tamper-evident execution record, producing a cryptographically verifiable DAG of agent invocations. ECT is the WIMSE profile that binds ACT-style execution records to workload identity with assurance levels. Together they close the agent accountability gap that OAuth/WIMSE/SCITT leave partially open. + +### Differentiation matrix + +| Against | How ACT/ECT differ | +|---|---| +| `draft-oauth-transaction-tokens-for-agents` | Two-phase lifecycle (authorization → proof-of-execution), DAG (not linear `actchain`), works without AuthZ server | +| `draft-emirdag-scitt-ai-agent-execution` | Lifecycle layer complement, not competitor; ACT produces what AIR anchors | +| AIP/IBCTs (arXiv 2603.24775) | Standards-track IETF home; JWT-only (no Biscuit/Datalog complexity) | +| `draft-helixar-hdp-agentic-delegation` | JWT/JOSE-standard (vs raw JSON), DAG (vs linear), IETF path | +| SentinelAgent (arXiv 2604.02767) | Standards deployability (vs formal calculus) | +| Agentic JWT (arXiv 2509.13597) | Two-phase lifecycle; DAG vs linear chain | + +### Non-goals (say this explicitly) + +- ACT does not replace WIMSE WIT/WPT — it sits above +- ACT does not replace OAuth/Txn-Tokens — it profiles for agent semantics +- ACT does not require SCITT — but integrates cleanly with it +- ECT does not carry identity — it carries execution context + +--- + +## 5. Action Plan + +### Phase A — Urgent technical updates (this week) + +- [ ] **A1**: Update ECT HTTP header section — replace `Wimse-Audience` with `wimse-aud` signature metadata parameter per `draft-ietf-wimse-http-signature-03` +- [ ] **A2**: Update SCITT references in ACT — point to `draft-ietf-scitt-architecture-22` (AUTH48); note RFC-to-be +- [ ] **A3**: Update Txn-Tokens references in ACT/ECT — lock to `draft-ietf-oauth-transaction-tokens-08` +- [ ] **A4**: Add "DAG vs linear chain" section to ACT — key technical differentiator +- [ ] **A5**: Add Related Work additions to ACT: + - AIP/IBCTs (arXiv 2603.24775) + - SentinelAgent (arXiv 2604.02767) + - Agentic JWT (arXiv 2509.13597) + - Txn-Tokens-for-Agents-06 + - HDP (`draft-helixar-hdp-agentic-delegation`) +- [ ] **A6**: Add Related Work additions to ECT: + - WIMSE arch §3.3.9 (explicit) + - Composition Safety (arXiv 2603.23801) + - MIGT taxonomy (arXiv 2604.06148) + - NIST/NCCoE Concept Paper +- [ ] **A7**: Commit all current work to git (workspace + research.ietf subrepo) + +### Phase B — External engagement (next 1–2 weeks) + +- [ ] **B1**: Email Emirdag (VERIDIC) — propose SCITT-AI + ACT liaison; coordinate AIR payload format with ACT execution-phase claims +- [ ] **B2**: Submit ACT -01 to datatracker +- [ ] **B3**: Submit ECT -02 to datatracker +- [ ] **B4**: Post ECT intro email to wimse@ietf.org with diff doc link +- [ ] **B5**: Post short response to OAuth WG on Txn-Tokens-for-Agents-06 — compare `actchain` (linear) vs ACT `pred` (DAG), offer as complementary not competitive +- [ ] **B6**: Request 10-min slot at IETF 123 WIMSE session (July 2026) +- [ ] **B7**: Track DAWN WG charter formation — if charters, submit positioning comment on how ACT identity claims serve discovery + +### Phase C — IETF 123 preparation (May–June 2026) + +- [ ] **C1**: Iterate ACT/ECT based on mailing list feedback +- [ ] **C2**: Prepare 10-min WIMSE slides (focus on: gap filled, relationship to adopted drafts, ECT's role in execution context propagation) +- [ ] **C3**: Prepare 5-min OAuth slot request if Txn-Tokens-for-Agents discussion opens +- [ ] **C4**: Reference implementation hardening: test vectors, interop with at least one other implementation + +### Phase D — Post-IETF 123 (August 2026+) + +- [ ] **D1**: Based on WIMSE reception: either iterate toward WG adoption or pivot to BoF-style workshop +- [ ] **D2**: If SCITT-AI liaison forms: draft joint implementation report +- [ ] **D3**: If DAWN charters: submit ACT positioning statement + +--- + +## 6. Timeline + +``` +2026-04-12 Strategy finalized (today) +2026-04-12 Phase A starts +2026-04-19 Phase A complete, ACT-01 + ECT-02 submitted +2026-04-20 Phase B starts (WIMSE ML post + Emirdag outreach) +2026-05-01 All external engagement initiated +2026-07-xx IETF 123 (target: WIMSE 10-min slot) +2026-08-xx Post-IETF 123 review, decide WG adoption strategy +``` + +--- + +## 7. Risk Register + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| WIMSE WG rejects ECT as out-of-charter | Medium | High | Cite arch §3.3.9 explicitly; frame as charter-aligned | +| Amazon Txn-Tokens-for-Agents gets OAuth WG adoption first | High | Medium | Differentiate at DAG/lifecycle level; position as complementary layer | +| SCITT-AI (Emirdag) adopted, ACT seen as redundant | Medium | High | Proactive liaison; position as lifecycle vs anchoring | +| DAWN charters without ACT positioning | Medium | Medium | Submit positioning statement during charter review | +| 14+ competing drafts fragment the space | High | Medium | Focus on ACT's unique two-phase lifecycle; cite competitors as related work | +| Independent-submission path stalls for ACT | Medium | Medium | Keep ECT on WG-adoption path; ACT can stay independent longer if needed | + +--- + +## 8. Success Criteria + +### 30-day criteria +- ACT-01 + ECT-02 on datatracker +- WIMSE mailing list engagement (≥3 replies from chairs/contributors) +- Emirdag liaison conversation started + +### 90-day criteria (IETF 123 timing) +- 10-minute WIMSE agenda slot secured +- ≥1 independent implementation of ACT or ECT outside our refimpl +- Referenced by at least 2 other drafts + +### 180-day criteria +- WIMSE WG adoption call for ECT (or clear path to it) +- SCITT-AI joint profile or explicit coordination +- ACT independent submission moving toward RFC Editor queue + +--- + +## 9. Dependencies and Open Decisions + +### External dependencies +- `draft-ietf-scitt-architecture` → RFC (timing unknown, AUTH48 now) +- `draft-ietf-oauth-transaction-tokens-08` → RFC (WG Last Call now) +- `draft-ietf-wimse-http-signature` → needs breaking change propagated +- WIMSE WG charter interpretation (chairs' call) + +### Open decisions (need user input) +- Approach to Emirdag: liaison email, co-authorship offer, or just citation? +- Publish refimpls to PyPI? (currently package names `ietf-act`/`ietf-ect` reserved but not published — **no publishing without explicit user approval**) +- Repo strategy: single monorepo, or split ACT/ECT into separate Git repos for separate draft homes? +- IETF 123 travel: attend in person or remote? + +--- + +## 10. References + +### Our work +- `packages/act/draft-nennemann-act-01.md` +- `drafts/ietf-wimse-ect/draft-nennemann-wimse-ect.md` (docname -02) +- `drafts/ietf-wimse-ect/DIFF-vs-txn-tokens-for-agents.md` +- `drafts/ietf-wimse-ect/wimse-intro-email.md` + +### Key competing/complementary drafts +- draft-oauth-transaction-tokens-for-agents-06 (Raut/Amazon) +- draft-emirdag-scitt-ai-agent-execution-00 (VERIDIC) +- draft-helixar-hdp-agentic-delegation-00 +- draft-king-dawn-requirements-00 (potential new WG) +- draft-ietf-wimse-arch-07 (cite §3.3.9) +- draft-ietf-wimse-http-signature-03 (breaking change) + +### Key arXiv references +- 2603.24775 — AIP / IBCTs +- 2604.02767 — SentinelAgent +- 2603.23801 — AgentRFC (Composition Safety) +- 2509.13597 — Agentic JWT +- 2604.06148 — MIGT taxonomy diff --git a/workspace/act/MOVED.md b/workspace/act/MOVED.md new file mode 100644 index 0000000..78511bf --- /dev/null +++ b/workspace/act/MOVED.md @@ -0,0 +1 @@ +Canonical location moved to workspace/packages/act/ diff --git a/workspace/drafts/ietf-wimse-ect b/workspace/drafts/ietf-wimse-ect index ba38569..d47f041 160000 --- a/workspace/drafts/ietf-wimse-ect +++ b/workspace/drafts/ietf-wimse-ect @@ -1 +1 @@ -Subproject commit ba385693195b1e4a2b75ef5483fd50fb0a379eef +Subproject commit d47f041265e1bc3a142fb14fdfe4431f5340c8b5 diff --git a/workspace/drafts/outreach/emirdag-liaison-email.md b/workspace/drafts/outreach/emirdag-liaison-email.md new file mode 100644 index 0000000..033a3f0 --- /dev/null +++ b/workspace/drafts/outreach/emirdag-liaison-email.md @@ -0,0 +1,30 @@ +Dear Dr. Emirdag, + +Congratulations on the publication of draft-emirdag-scitt-ai-agent-execution-00 earlier today. I came across it while tracking SCITT-adjacent work on AI agent accountability, and I wanted to reach out because the positioning looks genuinely complementary to a pair of drafts I have been developing. + +Brief introduction: I am Christian Nennemann, an independent researcher working on execution-context and lifecycle tokens for agentic systems. My current IETF work consists of: + +- draft-nennemann-act-01 (Agent Context Token): a JWT-based two-phase lifecycle — a pre-execution Mandate token carrying authorization, scope, and input commitments, followed by a post-execution Record token committing to outputs and linking back via `pred`. Multiple Records form a DAG, signed with Ed25519 or ES256. +- draft-nennemann-wimse-ect-02 (Execution Context Token): a WIMSE profile with three assurance levels and identity binding for the workload that produced a given execution. + +Reading your AIR specification, the layering seems fairly clean: ACT defines *what* is being anchored — the lifecycle token with its authorization proof, input/output commitments, and causal predecessor links — while AIR defines *how* it is anchored on a SCITT transparency service as a COSE_Sign1 payload with its hash-chain, four-step verification, and EU AI Act / NIST AI RMF mappings. There is real conceptual overlap on input/output hashing, reasoning capture, identity, timing, and causality, which suggests that coordinating now would save both of us retrofitting later. + +A few concrete options, in rough order of effort: + +(a) Cross-citations in both drafts, establishing the "ACT record → AIR payload → SCITT receipt" flow as the intended pipeline. +(b) A short shared section on "Anchoring ACT Records in SCITT" — either folded into ACT-02 or as a small companion draft if you prefer neutral ground. +(c) Aligning claim semantics where they overlap — in particular input/output hash representation (I currently use `inp_hash` / `out_hash`, JWT-side) so that translation to AIR is lossless. +(d) If we both attend IETF 123, a joint slot in SCITT or a side meeting could make the layering concrete for the WG. + +I would be happy to send you the current ACT and ECT drafts and to review yours in detail before either of us adds formal cross-references. Low-pressure — mainly wanted to flag the alignment while the drafts are still malleable. + +Looking forward to your thoughts. + +Best regards, +Christian Nennemann +Independent Researcher +[contact details] + +--- + +**Suggested subject line:** Liaison proposal: ACT/ECT lifecycle tokens and SCITT-AI AIR — complementary layering diff --git a/workspace/drafts/outreach/oauth-ml-response.md b/workspace/drafts/outreach/oauth-ml-response.md new file mode 100644 index 0000000..184a1a6 --- /dev/null +++ b/workspace/drafts/outreach/oauth-ml-response.md @@ -0,0 +1,71 @@ +**To:** oauth@ietf.org +**From:** Christian Nennemann +**Subject:** draft-oauth-transaction-tokens-for-agents-06: complementary work on DAG-based delegation (draft-nennemann-act) + +Hi all, + +I noticed the publication of draft-oauth-transaction-tokens-for-agents-06 +(Raut et al., 2026-04-11) and wanted to share some complementary work that +addresses an adjacent slice of the agent-delegation problem space. The +Amazon draft fills a real gap at the OAuth authorization-server layer, and +I think there is useful coordination potential rather than overlap. + +# Technical difference in one paragraph + +draft-oauth-transaction-tokens-for-agents introduces `actchain` as an +ordered array documenting delegation history, plus `agentic_ctx` carrying +type/version/intent/operational constraints, with a split between +principal-initiated and autonomous flow types. Our work +(draft-nennemann-act-01) models delegation history as a DAG through a +`pred` (predecessor) claim that is itself an array of parent token +references. A linear `actchain` is a special case of the DAG form where +every node has exactly one predecessor. + +# Why a DAG, concretely + +Consider an agent that fans out to N parallel sub-agents (e.g. one per +data source) and then synthesizes a single response from their results. +The synthesis step has N predecessors, not one. A linear `actchain` +cannot express this fan-in; you would have to either linearize artificially +(losing causality) or emit N parallel chains (losing the join). With a +DAG-valued `pred`, the synthesis token references all N predecessor tokens +directly, and a verifier can walk the graph to check that each parallel +branch was authorized and unexpired. Fork, join, and diamond topologies +fall out of the same structure. + +# Layering, not competition + +These two drafts sit at different layers: + +- Txn-Tokens-for-Agents is anchored at an OAuth authorization server: + the AS mints and validates tokens, and `actchain` is read in the + context of an AS-issued transaction token. +- ACT is designed for peer-to-peer agent orchestration without + requiring an AS in the hot path — useful for multi-vendor agent + meshes where no single AS is authoritative. It is transport-agnostic + and leans on JWS for provenance. + +An AS-issued txn-token could carry an ACT-shaped `pred` graph +internally, or an ACT chain could terminate at an AS that upgrades it +into a txn-token for a specific resource. The two seem stackable. + +# Offer + +Happy to compare test vectors, especially around: + +- claim naming: `agentic_ctx` (Raut) vs ACT's `task` claim — is there + an opportunity to align on a shared intent/constraint shape so + downstream verifiers don't have to parse both? +- linear-subset interop: confirming that a degenerate DAG (each node + one parent) round-trips cleanly to/from `actchain`. +- autonomous-flow semantics: how ACT's unattended-delegation marker + maps onto Raut's autonomous flow type. + +ACT draft: https://datatracker.ietf.org/doc/draft-nennemann-act/ + +Feedback welcome, on- or off-list. + +Best, +Christian Nennemann +Independent Researcher +ietf@nennemann.de diff --git a/workspace/packages/INTEROP-TEST-PLAN.md b/workspace/packages/INTEROP-TEST-PLAN.md new file mode 100644 index 0000000..abdfb6b --- /dev/null +++ b/workspace/packages/INTEROP-TEST-PLAN.md @@ -0,0 +1,113 @@ +# ACT / ECT Cross-Spec Interop Test Plan + +**Status**: Draft (Task C4 preparation — planning only, not yet implemented) +**Scope**: Python refimpls `ietf-act` (Phase 1/2, 103 tests) and `ietf-ect` (single-phase, 56 tests) +**Deliverable**: `packages/interop/tests/test_interop.py` + compatibility matrix docs + +## 1. Goals and Non-Goals + +### Goals +- Empirically document which shared claims round-trip cleanly between refimpls. +- Surface real format-level incompatibilities (hash encoding, typ header, algorithm support) rather than assume the spec-level claim overlap implies wire interop. +- Produce a user-facing **compatibility matrix** that implementers can rely on when building bridges between Phase 2 ACT Records and ECT payloads. +- Provide executable regression tests so future changes to either refimpl cannot silently break the documented interop level without CI noticing. + +### Non-Goals +- Propose spec unification or new shared claim registries. +- Build a lossy translator/bridge between ACT Records and ECT payloads. +- Test `typ` cross-acceptance — `act+jwt` vs `exec+jwt` MUST remain distinct token types. +- Forge one token type as the other. +- Add new crypto backends (e.g., Ed25519 support) to ECT as part of this work. + +## 2. Known Shape of the Problem + +Shared claims (by name): `jti`, `wid`, `iat`, `exp`, `aud`, `exec_act`, `pred`, `inp_hash`, `out_hash`. + +Confirmed divergences discovered while reading the code: +- **Hash encoding mismatch**: ACT `b64url_sha256()` emits plain base64url (e.g. `n4bQgYhMfWWaL-qgxVrQFaO_TxsrC4Is0V1sFbDwCgg`). ECT `validate_hash_format()` requires `alg:base64url` form (e.g. `sha-256:...`) and raises on plain b64url. The briefing says this was "recently fixed to match ACT's plain base64url format" but the ECT validator still requires the prefix — plan must include a reproducer. +- **Algorithm**: ACT supports `EdDSA` + `ES256`; ECT hard-codes `ES256` (see `ect/verify.py`, line 59, `"ect: expected ES256"`). +- **Typ header**: ACT requires `act+jwt`; ECT requires `exec+jwt` (with legacy `wimse-exec+jwt`). Neither accepts the other — and per anti-goals, neither should. +- **aud shape**: ACT stores `aud` as `str | list[str]`; ECT normalises to `list[str]` via `_audience_deserialize`. +- **Claims unique to ACT**: `sub`, `iss` (required string), `task`, `cap`, `del`, `oversight`, `exec_ts`, `status`, `err`. +- **Claims unique to ECT**: `ect_ext`, `inp_classification`, and policy claims inside `ect_ext` (`pol`, `pol_decision`, `compensation_required`). + +## 3. Test Categories + +### 3.1 Shared claim consistency (`TestSharedClaims`) +- `test_jti_format_roundtrips`: UUID-v4 jti accepted by both refimpls; non-UUID jti accepted by ACT (no UUID check) but only by ECT when `validate_uuids=False` (document the asymmetry). +- `test_wid_shared_semantics`: same wid value on an ACT Record and an ECT payload — both accept. +- `test_iat_exp_numericdate`: identical integer NumericDate accepted by both (ACT uses strict `> 0`, ECT uses `int(claims["iat"])`). +- `test_aud_string_vs_list`: string `aud` preserved by ACT, coerced to list by ECT; list form is lossless on both. +- `test_exec_act_string_both_sides`: same `exec_act` value (e.g. `read.data`) serialises identically; ACT additionally validates ABNF grammar — test that ECT accepts an ACT-grammar-legal value unchanged. +- `test_pred_array_shape`: `pred=[]`, `pred=[jti1]`, `pred=[jti1, jti2]` — both refimpls serialise/deserialise identically. +- `test_inp_hash_format_divergence` (**expected xfail/documented**): feed ACT's plain b64url output into ECT validator — expect `ValueError("ect: inp_hash/out_hash must be algorithm:base64url...")`. This pins the incompatibility so a future fix flips the test green. +- `test_inp_hash_prefixed_form`: `sha-256:` value accepted by ECT; ACT treats it as opaque string (no validation), roundtrips without error. +- `test_out_hash_same_as_inp`: mirror the above for `out_hash`. + +### 3.2 Algorithm compatibility (`TestAlgorithmMatrix`) +- `test_es256_act_record_signature_verifies_with_ect_key_resolver`: build a Phase 2 ACTRecord, sign with ES256 P-256 key. Feed the compact JWS bytes *and an ECT-shaped resolver* through `ect.verify`. Expect `ValueError("ect: invalid typ parameter")` because typ is `act+jwt`. Document: JWS/ES256 signature layer is compatible, but typ gate prevents verifier reuse as-is. +- `test_eddsa_act_record_rejected_by_ect`: Phase 2 ACTRecord signed EdDSA. ECT must reject at alg gate (`"ect: expected ES256"`). Documents the ES256-only limitation. +- `test_ect_payload_signature_verifies_with_act_crypto`: sign an ECT payload (ES256), strip to raw JWS, feed signature bytes through `act.crypto.verify` with the ECT public key. Expect success — proves the ES256 primitive is wire-compatible at the raw-sig level. + +### 3.3 DAG cross-reference (`TestDagInterop`) +- `test_pred_array_referenceable_both_ways`: construct ACT Record with `pred=[ect_jti]` and an ECT payload with `pred=[act_jti]`. Both refimpls accept the arrays structurally (they're opaque strings). +- `test_mixed_dag_is_out_of_scope`: document and assert that `ACTStore` only stores ACT records and `ECTStore` only stores ECT payloads; neither is designed to resolve a `pred` jti from the other type. A bridging verifier would have to walk both stores — out of scope for refimpls. +- `test_jti_collision_across_types`: the same UUID used as `jti` in an ACT Record and an unrelated ECT payload — both refimpls accept independently; document that jti uniqueness is scoped per-token-type in the refimpls. + +### 3.4 Semantic divergence (`TestClaimDivergence`) +- `test_ect_ignores_act_only_claims`: ECT `Payload.from_claims` is called on a dict that includes `sub`, `task`, `cap`, `oversight`, `exec_ts`, `status`. Expect: silently ignored (no error, no retention). Document as "ECT is lenient on unknown top-level claims". +- `test_act_ignores_ect_only_claims`: feed `ACTRecord.from_claims` a claim dict with `ect_ext`, `inp_classification`. Expect: silently ignored and not retained. +- `test_exec_act_not_validated_against_cap_in_ect`: ACT Record with `exec_act="read.data"` and `cap=[{"action":"write.result"}]` → ACT verifier raises `ACTCapabilityError`. Same `exec_act` in an ECT payload with no `cap` → ECT accepts. Documents the cap-validation asymmetry; guards against anyone accidentally copy-pasting cap logic into ECT. +- `test_act_requires_status_ect_does_not`: ACTRecord without `status` → `ACTValidationError`. ECT without `status` → accepted. + +### 3.5 Anti-goals (encoded as negative tests) +- `test_act_jwt_typ_rejected_by_ect`: ACT compact with `typ=act+jwt` fed to `ect.verify` → MUST raise "invalid typ parameter". +- `test_exec_jwt_typ_rejected_by_act`: ECT compact with `typ=exec+jwt` fed to `act.decode_jws` → MUST raise `ACTValidationError` on typ check. +- `test_no_forgery_as_other_type`: explicit comment-only placeholder asserting we do not re-encode one type as the other; kept as a doc anchor. + +## 4. Expected Compatibility Matrix (user-facing) + +| Layer | Direction | Status | Notes | +|---|---|---|---| +| ES256 raw signature | ACT ↔ ECT | Compatible | Same JWS/ES256 primitive | +| EdDSA signature | ACT → ECT | Incompatible | ECT is ES256-only | +| `typ` header | ACT ↔ ECT | Strictly separated | By design | +| `jti`, `wid`, `iat`, `exp`, `aud`, `exec_act`, `pred` | Shared | Compatible | Identical wire shapes | +| `inp_hash`/`out_hash` | ACT → ECT | **Incompatible today** | ACT emits plain b64url, ECT requires `sha-256:` | +| `inp_hash`/`out_hash` | ECT → ACT | Compatible | ACT treats as opaque string | +| `cap` / `exec_act` coupling | ACT-only | N/A | ECT does not enforce | +| DAG `pred` traversal | Separate stores | Manual bridging required | Refimpls do not cross-resolve | + +## 5. Dependencies and Structure + +Both packages must be importable in a single venv: + +``` +pip install -e packages/act packages/ect packages/interop[dev] +``` + +Proposed layout: + +``` +packages/ + act/ … + ect/ … + interop/ + pyproject.toml # declares ietf-act, ietf-ect as deps + tests/ + __init__.py + conftest.py # shared ES256 keypair + resolver fixtures + test_interop.py # classes Test{SharedClaims,AlgorithmMatrix,DagInterop,ClaimDivergence,AntiGoals} + README.md # published compatibility matrix +``` + +`conftest.py` exposes fixtures: `es256_keypair`, `act_record_builder`, `ect_payload_builder`, `dual_resolver` (one kid → same ES256 pubkey for both refimpls). + +## 6. What the Compatibility Matrix Docs Should Tell Users + +- **Do** reuse ES256 key material across ACT and ECT deployments — the signing primitive is identical. +- **Do not** feed ACT compact tokens to an ECT verifier or vice versa; `typ` gates are deliberate. +- **Do** treat `jti`, `wid`, `pred`, `exec_act` as semantically aligned when building cross-type audit logs. +- **Do not** rely on `inp_hash`/`out_hash` being portable today — raise a spec issue if portability matters for your deployment. +- **Do not** expect ECT to enforce ACT's `cap`/`exec_act` coupling — authorization remains an ACT concern. +- **Open question for spec editors**: align hash encoding (plain b64url vs prefixed), and decide whether Ed25519 should be optional-to-support for ECT. diff --git a/workspace/packages/act/act/__init__.py b/workspace/packages/act/act/__init__.py new file mode 100644 index 0000000..09b0c07 --- /dev/null +++ b/workspace/packages/act/act/__init__.py @@ -0,0 +1,119 @@ +"""Agent Context Token (ACT) — Reference Implementation. + +A JWT-based format for autonomous AI agents that unifies authorization +and execution accountability in a single token lifecycle. + +Reference: draft-nennemann-act-01. +""" + +from .errors import ( + ACTAudienceMismatchError, + ACTCapabilityError, + ACTDAGError, + ACTDelegationError, + ACTError, + ACTExpiredError, + ACTKeyResolutionError, + ACTLedgerImmutabilityError, + ACTPhaseError, + ACTPrivilegeEscalationError, + ACTSignatureError, + ACTValidationError, +) +from .token import ( + ACTMandate, + ACTRecord, + Capability, + Delegation, + DelegationEntry, + ErrorClaim, + Oversight, + TaskClaim, + decode_jws, + encode_jws, + parse_token, +) +from .crypto import ( + ACTKeyResolver, + KeyRegistry, + PublicKey, + PrivateKey, + X509TrustStore, + b64url_sha256, + compute_sha256, + did_key_from_ed25519, + generate_ed25519_keypair, + generate_p256_keypair, + resolve_did_key, + sign, + verify, +) +from .lifecycle import transition_to_record +from .delegation import ( + create_delegated_mandate, + verify_capability_subset, + verify_delegation_chain, +) +from .dag import validate_dag, ACTStore +from .ledger import ACTLedger +from .verify import ACTVerifier +from .vectors import generate_vectors, validate_vectors + +__all__ = [ + # Errors + "ACTError", + "ACTValidationError", + "ACTSignatureError", + "ACTExpiredError", + "ACTAudienceMismatchError", + "ACTCapabilityError", + "ACTDelegationError", + "ACTDAGError", + "ACTPhaseError", + "ACTKeyResolutionError", + "ACTLedgerImmutabilityError", + "ACTPrivilegeEscalationError", + # Token structures + "ACTMandate", + "ACTRecord", + "TaskClaim", + "Capability", + "Delegation", + "DelegationEntry", + "Oversight", + "ErrorClaim", + # Token serialization + "encode_jws", + "decode_jws", + "parse_token", + # Crypto + "generate_ed25519_keypair", + "generate_p256_keypair", + "sign", + "verify", + "compute_sha256", + "b64url_sha256", + "resolve_did_key", + "did_key_from_ed25519", + "KeyRegistry", + "X509TrustStore", + "ACTKeyResolver", + "PublicKey", + "PrivateKey", + # Lifecycle + "transition_to_record", + # Delegation + "create_delegated_mandate", + "verify_capability_subset", + "verify_delegation_chain", + # DAG + "validate_dag", + "ACTStore", + # Ledger + "ACTLedger", + # Verify + "ACTVerifier", + # Vectors + "generate_vectors", + "validate_vectors", +] diff --git a/workspace/packages/act/act/crypto.py b/workspace/packages/act/act/crypto.py new file mode 100644 index 0000000..b2c166a --- /dev/null +++ b/workspace/packages/act/act/crypto.py @@ -0,0 +1,467 @@ +"""ACT cryptographic primitives and key management. + +Provides sign/verify operations and key resolution across all three +ACT trust tiers: + - Tier 1: Pre-shared Ed25519 and P-256 keys + - Tier 2: PKI / X.509 certificate chains + - Tier 3: DID (did:key self-contained, did:web via resolver callback) + +Reference: ACT §5 (Trust Model), §8 (Verification Procedure). +""" + +from __future__ import annotations + +import base64 +import hashlib +import re +from typing import Any, Callable, Protocol + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ec import ( + ECDSA, + SECP256R1, + EllipticCurvePrivateKey, + EllipticCurvePublicKey, + generate_private_key as ec_generate_private_key, +) +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, +) +from cryptography.hazmat.primitives.hashes import SHA256 +from cryptography.x509 import ( + Certificate, + load_der_x509_certificate, +) + +from .errors import ( + ACTKeyResolutionError, + ACTSignatureError, + ACTValidationError, +) + +# Type aliases for public/private keys supported by ACT. +PublicKey = Ed25519PublicKey | EllipticCurvePublicKey +PrivateKey = Ed25519PrivateKey | EllipticCurvePrivateKey + +# Callback type for DID:web resolution. +DIDResolver = Callable[[str], PublicKey | None] + + +def generate_ed25519_keypair() -> tuple[Ed25519PrivateKey, Ed25519PublicKey]: + """Generate an Ed25519 key pair for ACT signing. + + Returns a (private_key, public_key) tuple. The private key object + carries its associated public key per ACT security requirements. + + Reference: ACT §5.2 (Tier 1 pre-shared keys). + """ + private_key = Ed25519PrivateKey.generate() + return private_key, private_key.public_key() + + +def generate_p256_keypair() -> tuple[EllipticCurvePrivateKey, EllipticCurvePublicKey]: + """Generate a P-256 (ES256) key pair for ACT signing. + + Returns a (private_key, public_key) tuple. + + Reference: ACT §5.2 (Tier 1 pre-shared keys). + """ + private_key = ec_generate_private_key(SECP256R1()) + return private_key, private_key.public_key() + + +def sign(private_key: PrivateKey, data: bytes) -> bytes: + """Sign data using the appropriate algorithm for the key type. + + Uses Ed25519 for Ed25519PrivateKey, ECDSA with SHA-256 for P-256. + Returns raw signature bytes (for Ed25519: 64 bytes; for ES256: + raw r||s format per RFC 7518 §3.4). + + Reference: ACT §5, RFC 7515 §5.1. + + Raises: + ACTValidationError: If the key type is not supported. + """ + if isinstance(private_key, Ed25519PrivateKey): + return private_key.sign(data) + elif isinstance(private_key, EllipticCurvePrivateKey): + from cryptography.hazmat.primitives.asymmetric.utils import ( + decode_dss_signature, + ) + # Sign with DER-encoded signature, then convert to raw r||s + der_sig = private_key.sign(data, ECDSA(SHA256())) + r, s = decode_dss_signature(der_sig) + # P-256 uses 32-byte integers + return r.to_bytes(32, "big") + s.to_bytes(32, "big") + else: + raise ACTValidationError(f"Unsupported key type: {type(private_key)}") + + +def verify(public_key: PublicKey, signature: bytes, data: bytes) -> None: + """Verify a signature against the given public key and data. + + Reference: ACT §8.1 step 5. + + Raises: + ACTSignatureError: If the signature is invalid. + ACTValidationError: If the key type is not supported. + """ + try: + if isinstance(public_key, Ed25519PublicKey): + public_key.verify(signature, data) + elif isinstance(public_key, EllipticCurvePublicKey): + from cryptography.hazmat.primitives.asymmetric.utils import ( + encode_dss_signature, + ) + # Convert raw r||s back to DER + r = int.from_bytes(signature[:32], "big") + s = int.from_bytes(signature[32:], "big") + der_sig = encode_dss_signature(r, s) + public_key.verify(der_sig, data, ECDSA(SHA256())) + else: + raise ACTValidationError( + f"Unsupported key type: {type(public_key)}" + ) + except InvalidSignature as e: + raise ACTSignatureError("Signature verification failed") from e + + +def compute_sha256(data: bytes) -> bytes: + """Compute SHA-256 hash of data. + + Used for delegation chain signatures and inp_hash/out_hash claims. + + Reference: ACT §6.1 (delegation sig), §4.3 (inp_hash, out_hash). + """ + return hashlib.sha256(data).digest() + + +def b64url_sha256(data: bytes) -> str: + """Compute base64url(SHA-256(data)) without padding. + + Used for inp_hash and out_hash claims. + + Reference: ACT §4.3. + """ + digest = compute_sha256(data) + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +def x509_kid(cert_der: bytes) -> str: + """Compute the Tier 2 kid: SHA-256 thumbprint of DER certificate. + + Reference: ACT §5.3 (Tier 2 kid format). + """ + return hashlib.sha256(cert_der).hexdigest() + + +class KeyRegistry: + """Tier 1 pre-shared key registry. + + Maps kid strings to public keys. Configured at initialization time + with no external resolution needed. + + Reference: ACT §5.2 (Tier 1 Pre-Shared Keys). + """ + + def __init__(self) -> None: + self._keys: dict[str, PublicKey] = {} + + def register(self, kid: str, public_key: PublicKey) -> None: + """Register a public key under the given kid. + + Reference: ACT §5.2. + """ + self._keys[kid] = public_key + + def get(self, kid: str) -> PublicKey | None: + """Retrieve the public key for a kid, or None if not found.""" + return self._keys.get(kid) + + def __contains__(self, kid: str) -> bool: + return kid in self._keys + + def __len__(self) -> int: + return len(self._keys) + + +class X509TrustStore: + """Tier 2 PKI/X.509 trust store. + + Holds trusted CA certificates and resolves kid (certificate + thumbprint) to public keys. Supports x5c header chain validation. + + Reference: ACT §5.3 (Tier 2 PKI). + """ + + def __init__(self) -> None: + self._trusted_certs: dict[str, Certificate] = {} + + def add_trusted_cert(self, cert: Certificate) -> str: + """Add a trusted certificate to the store. + + Returns the kid (SHA-256 thumbprint of DER encoding). + + Reference: ACT §5.3. + """ + from cryptography.hazmat.primitives.serialization import Encoding + der_bytes = cert.public_bytes(Encoding.DER) + kid = x509_kid(der_bytes) + self._trusted_certs[kid] = cert + return kid + + def resolve(self, kid: str) -> PublicKey | None: + """Resolve kid to a public key from a trusted certificate. + + Reference: ACT §5.3, §8.1 step 4. + """ + cert = self._trusted_certs.get(kid) + if cert is None: + return None + pub = cert.public_key() + if isinstance(pub, (Ed25519PublicKey, EllipticCurvePublicKey)): + return pub + return None + + def resolve_x5c(self, x5c: list[str]) -> PublicKey | None: + """Resolve public key from x5c certificate chain. + + The first entry in x5c is the end-entity certificate. + Validates that the chain terminates in a trusted CA. + + Reference: ACT §4.1 (x5c header), §5.3. + """ + if not x5c: + return None + try: + # Decode certificates from base64 DER + certs = [ + load_der_x509_certificate(base64.b64decode(c)) for c in x5c + ] + except Exception: + return None + + # Check if any cert in the chain is in our trust store + from cryptography.hazmat.primitives.serialization import Encoding + for cert in certs: + der_bytes = cert.public_bytes(Encoding.DER) + kid = x509_kid(der_bytes) + if kid in self._trusted_certs: + # End-entity cert is the first one + ee_pub = certs[0].public_key() + if isinstance(ee_pub, (Ed25519PublicKey, EllipticCurvePublicKey)): + return ee_pub + return None + return None + + +# --- Tier 3: DID Support --- + +# Multicodec prefixes for did:key +_ED25519_MULTICODEC = b"\xed\x01" +_P256_MULTICODEC = b"\x80\x24" + + +def _multibase_decode(encoded: str) -> bytes: + """Decode a multibase-encoded string (base58btc 'z' prefix). + + Reference: ACT §5.4 (Tier 3 DID:key). + """ + if not encoded.startswith("z"): + raise ACTKeyResolutionError( + f"Unsupported multibase encoding prefix: {encoded[0]!r}" + ) + return _base58btc_decode(encoded[1:]) + + +def _base58btc_decode(s: str) -> bytes: + """Decode a base58btc string.""" + alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + n = 0 + for ch in s: + idx = alphabet.index(ch) + n = n * 58 + idx + # Compute byte length + byte_length = (n.bit_length() + 7) // 8 + result = n.to_bytes(byte_length, "big") if byte_length > 0 else b"" + # Preserve leading zeros + leading_zeros = len(s) - len(s.lstrip("1")) + return b"\x00" * leading_zeros + result + + +def resolve_did_key(did: str) -> PublicKey: + """Resolve a did:key identifier to a public key. + + Supports Ed25519 and P-256 key types. The did:key method is + self-contained — no external resolution is needed. + + Reference: ACT §5.4 (Tier 3 DID:key). + + Raises: + ACTKeyResolutionError: If the DID cannot be resolved. + """ + # Strip fragment if present (e.g., did:key:z6Mk...#z6Mk...) + did_base = did.split("#")[0] + + if not did_base.startswith("did:key:"): + raise ACTKeyResolutionError( + f"Not a did:key identifier: {did!r}" + ) + + multibase_value = did_base[len("did:key:"):] + try: + decoded = _multibase_decode(multibase_value) + except Exception as e: + raise ACTKeyResolutionError( + f"Failed to decode did:key multibase value: {e}" + ) from e + + if decoded[:2] == _ED25519_MULTICODEC: + raw_key = decoded[2:] + if len(raw_key) != 32: + raise ACTKeyResolutionError( + f"Ed25519 public key must be 32 bytes, got {len(raw_key)}" + ) + return Ed25519PublicKey.from_public_bytes(raw_key) + elif decoded[:2] == _P256_MULTICODEC: + raw_key = decoded[2:] + from cryptography.hazmat.primitives.asymmetric.ec import ( + EllipticCurvePublicKey as ECPub, + ) + from cryptography.hazmat.primitives.serialization import ( + load_der_public_key, + ) + # P-256 compressed point (33 bytes) or uncompressed (65 bytes) + # Wrap in SubjectPublicKeyInfo for loading + try: + return EllipticCurvePublicKey.from_encoded_point( + SECP256R1(), raw_key + ) + except Exception as e: + raise ACTKeyResolutionError( + f"Failed to load P-256 key from did:key: {e}" + ) from e + else: + raise ACTKeyResolutionError( + f"Unsupported multicodec prefix in did:key: {decoded[:2]!r}" + ) + + +def did_key_from_ed25519(public_key: Ed25519PublicKey) -> str: + """Create a did:key identifier from an Ed25519 public key. + + Reference: ACT §5.4 (Tier 3 DID:key). + """ + from cryptography.hazmat.primitives.serialization import ( + Encoding, + PublicFormat, + ) + raw = public_key.public_bytes(Encoding.Raw, PublicFormat.Raw) + multicodec = _ED25519_MULTICODEC + raw + encoded = "z" + _base58btc_encode(multicodec) + return f"did:key:{encoded}" + + +def _base58btc_encode(data: bytes) -> str: + """Encode bytes as base58btc.""" + alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + # Count leading zeros + leading_zeros = 0 + for b in data: + if b == 0: + leading_zeros += 1 + else: + break + n = int.from_bytes(data, "big") + if n == 0: + return "1" * leading_zeros + chars: list[str] = [] + while n > 0: + n, remainder = divmod(n, 58) + chars.append(alphabet[remainder]) + return "1" * leading_zeros + "".join(reversed(chars)) + + +class ACTKeyResolver: + """Unified key resolver across all trust tiers. + + Tries Tier 1 (pre-shared), then Tier 2 (X.509), then Tier 3 (DID) + to resolve a kid to a public key. + + Reference: ACT §5 (Trust Model), §8.1 step 4. + """ + + def __init__( + self, + registry: KeyRegistry | None = None, + x509_store: X509TrustStore | None = None, + did_web_resolver: DIDResolver | None = None, + ) -> None: + self._registry = registry or KeyRegistry() + self._x509_store = x509_store or X509TrustStore() + self._did_web_resolver = did_web_resolver + + @property + def registry(self) -> KeyRegistry: + """Access the Tier 1 key registry.""" + return self._registry + + @property + def x509_store(self) -> X509TrustStore: + """Access the Tier 2 X.509 trust store.""" + return self._x509_store + + def resolve( + self, + kid: str, + header: dict[str, Any] | None = None, + ) -> PublicKey: + """Resolve a kid to a public key, trying all configured tiers. + + Resolution order: + 1. Tier 1: Pre-shared key registry lookup by kid + 2. Tier 2: X.509 certificate lookup by kid (thumbprint) + or x5c header chain validation + 3. Tier 3: DID resolution (did:key or did:web) + + Reference: ACT §5 (Trust Model), §8.1 step 4. + + Raises: + ACTKeyResolutionError: If no key can be resolved for the kid. + """ + header = header or {} + + # Tier 1: Pre-shared keys + key = self._registry.get(kid) + if key is not None: + return key + + # Tier 2: X.509 + key = self._x509_store.resolve(kid) + if key is not None: + return key + + # Tier 2: x5c chain in header + x5c = header.get("x5c") + if x5c: + key = self._x509_store.resolve_x5c(x5c) + if key is not None: + return key + + # Tier 3: DID + did_value = header.get("did") or kid + if did_value.startswith("did:key:"): + try: + return resolve_did_key(did_value) + except ACTKeyResolutionError: + pass + + if did_value.startswith("did:web:") and self._did_web_resolver: + resolved = self._did_web_resolver(did_value) + if resolved is not None: + return resolved + + raise ACTKeyResolutionError( + f"Cannot resolve kid {kid!r} to a public key via any trust tier" + ) diff --git a/workspace/packages/act/act/dag.py b/workspace/packages/act/act/dag.py new file mode 100644 index 0000000..ad66e75 --- /dev/null +++ b/workspace/packages/act/act/dag.py @@ -0,0 +1,136 @@ +"""ACT DAG validation for Phase 2 execution records. + +Validates the directed acyclic graph formed by pred (predecessor) references +in Phase 2 ACTs, ensuring uniqueness, predecessor existence, temporal ordering, +acyclicity, and capability consistency. + +Reference: ACT §7 (DAG Structure and Causal Ordering). +""" + +from __future__ import annotations + +from typing import Protocol + +from .errors import ACTCapabilityError, ACTDAGError +from .token import ACTRecord + +# Maximum ancestor traversal limit for cycle detection — ACT §7.1 step 4. +MAX_TRAVERSAL_LIMIT: int = 10_000 + +# Clock skew tolerance for temporal ordering — ACT §7.1 step 3. +DAG_CLOCK_SKEW_TOLERANCE: int = 30 + + +class ACTStore(Protocol): + """Protocol for an ACT store used in DAG validation. + + Any object implementing get() and has() can serve as the store. + The ACTLedger in ledger.py implements this protocol. + """ + + def get(self, jti: str) -> ACTRecord | None: + """Retrieve a Phase 2 ACT record by jti.""" + ... + + +def validate_dag( + record: ACTRecord, + store: ACTStore, + *, + clock_skew_tolerance: int = DAG_CLOCK_SKEW_TOLERANCE, +) -> None: + """Validate the DAG constraints for a Phase 2 execution record. + + Performs all five DAG validation checks defined in ACT §7.1: + 1. jti uniqueness within wid scope (or globally) + 2. Predecessor existence in store + 3. Temporal ordering with clock skew tolerance + 4. Acyclicity (max traversal limit) + 5. Capability consistency (exec_act matches cap[].action) + + Reference: ACT §7.1 (DAG Validation). + + Args: + record: The Phase 2 ACTRecord to validate. + store: An ACT store providing get() for predecessor lookup. + clock_skew_tolerance: Seconds of allowed clock skew (default 30). + + Raises: + ACTDAGError: If any DAG constraint is violated. + ACTCapabilityError: If exec_act does not match cap actions. + """ + # Step 1: jti uniqueness — ACT §7.1 step 1 + existing = store.get(record.jti) + if existing is not None: + raise ACTDAGError( + f"Duplicate jti {record.jti!r} already exists in store" + ) + + # Step 5: Capability consistency — ACT §7.1 step 5 + cap_actions = {c.action for c in record.cap} + if record.exec_act not in cap_actions: + raise ACTCapabilityError( + f"exec_act {record.exec_act!r} does not match any " + f"cap[].action: {sorted(cap_actions)}" + ) + + # Step 2 & 3: Predecessor existence and temporal ordering + for pred_jti in record.pred: + parent = store.get(pred_jti) + if parent is None: + raise ACTDAGError( + f"Predecessor jti {pred_jti!r} not found in store" + ) + + # Temporal ordering: predecessor.exec_ts < child.exec_ts + tolerance + if parent.exec_ts >= record.exec_ts + clock_skew_tolerance: + raise ACTDAGError( + f"Temporal ordering violation: predecessor {pred_jti!r} " + f"exec_ts={parent.exec_ts} >= child exec_ts=" + f"{record.exec_ts} + tolerance={clock_skew_tolerance}" + ) + + # Step 4: Acyclicity — ACT §7.1 step 4 + _check_acyclicity(record.jti, record.pred, store) + + +def _check_acyclicity( + current_jti: str, + pred_jtis: list[str], + store: ACTStore, +) -> None: + """Check that following pred references does not lead back to current_jti. + + Uses breadth-first traversal with a maximum node limit. + + Reference: ACT §7.1 step 4. + + Raises: + ACTDAGError: If a cycle is detected or traversal limit exceeded. + """ + visited: set[str] = set() + queue: list[str] = list(pred_jtis) + nodes_visited = 0 + + while queue: + if nodes_visited >= MAX_TRAVERSAL_LIMIT: + raise ACTDAGError( + f"DAG traversal limit ({MAX_TRAVERSAL_LIMIT}) exceeded; " + f"possible cycle or excessively deep DAG" + ) + + jti = queue.pop(0) + if jti == current_jti: + raise ACTDAGError( + f"DAG cycle detected: jti {current_jti!r} appears in " + f"its own ancestor chain" + ) + + if jti in visited: + continue + visited.add(jti) + nodes_visited += 1 + + parent = store.get(jti) + if parent is not None: + queue.extend(parent.pred) diff --git a/workspace/packages/act/act/delegation.py b/workspace/packages/act/act/delegation.py new file mode 100644 index 0000000..c8a4332 --- /dev/null +++ b/workspace/packages/act/act/delegation.py @@ -0,0 +1,333 @@ +"""ACT delegation chain construction and verification. + +Handles peer-to-peer delegation where Agent A authorizes Agent B +with reduced privileges, building a cryptographic chain of authority. + +Reference: ACT §6 (Delegation Chain). +""" + +from __future__ import annotations + +import base64 +from typing import Any + +from .crypto import ( + PrivateKey, + PublicKey, + compute_sha256, + sign as crypto_sign, + verify as crypto_verify, +) +from .errors import ( + ACTDelegationError, + ACTPrivilegeEscalationError, + ACTValidationError, +) +from .token import ( + ACTMandate, + Capability, + Delegation, + DelegationEntry, + _b64url_encode, + _b64url_decode, + encode_jws, +) + + +def create_delegated_mandate( + parent_mandate: ACTMandate, + parent_compact: str, + delegator_private_key: PrivateKey, + *, + sub: str, + kid: str, + iss: str, + aud: str | list[str], + iat: int, + exp: int, + jti: str, + cap: list[Capability], + task: Any, + alg: str = "EdDSA", + wid: str | None = None, + max_depth: int | None = None, + oversight: Any | None = None, +) -> tuple[ACTMandate, str]: + """Create a delegated ACT mandate from a parent mandate. + + Agent A (delegator) creates a new mandate for Agent B (sub) with + reduced privileges. The delegation chain is extended with a new + entry linking back to the parent ACT. + + Reference: ACT §6.1 (Peer-to-Peer Delegation). + + Args: + parent_mandate: The parent ACT that authorizes delegation. + parent_compact: JWS compact serialization of the parent ACT. + delegator_private_key: The delegator's private key for chain sig. + sub: Target agent identifier. + kid: Key identifier for the new mandate's signing key. + iss: Issuer identifier (the delegator). + aud: Audience for the new mandate. + iat: Issuance time. + exp: Expiration time. + jti: Unique identifier for the new mandate. + cap: Capabilities (must be subset of parent). + task: TaskClaim for the new mandate. + alg: Algorithm (default EdDSA). + wid: Workflow identifier (optional). + max_depth: Max delegation depth (must be <= parent's). + oversight: Oversight claim (optional). + + Returns: + Tuple of (ACTMandate, needs to be signed by delegator). + + Raises: + ACTDelegationError: If delegation depth would exceed max_depth. + ACTPrivilegeEscalationError: If cap exceeds parent capabilities. + """ + # Determine parent delegation state + if parent_mandate.delegation is not None: + parent_depth = parent_mandate.delegation.depth + parent_max_depth = parent_mandate.delegation.max_depth + parent_chain = list(parent_mandate.delegation.chain) + else: + # Root mandate without del claim — delegation not permitted + raise ACTDelegationError( + "Parent mandate has no 'del' claim; delegation is not permitted" + ) + + new_depth = parent_depth + 1 + + # Validate depth constraints — ACT §6.3 step 3 + if new_depth > parent_max_depth: + raise ACTDelegationError( + f"Delegation depth {new_depth} exceeds max_depth {parent_max_depth}" + ) + + # Validate max_depth — ACT §6.1 step 4 + if max_depth is None: + effective_max_depth = parent_max_depth + else: + if max_depth > parent_max_depth: + raise ACTDelegationError( + f"Requested max_depth {max_depth} exceeds parent max_depth " + f"{parent_max_depth}" + ) + effective_max_depth = max_depth + + # Validate capability subset — ACT §6.2 + verify_capability_subset(parent_mandate.cap, cap) + + # Compute chain entry signature — ACT §6.1 step 5 + parent_hash = compute_sha256(parent_compact.encode("utf-8")) + chain_sig = crypto_sign(delegator_private_key, parent_hash) + chain_sig_b64 = _b64url_encode(chain_sig) + + # Build new chain entry + new_entry = DelegationEntry( + delegator=iss, + jti=parent_mandate.jti, + sig=chain_sig_b64, + ) + + # Extend chain — ordered root → immediate parent + new_chain = parent_chain + [new_entry] + + delegation = Delegation( + depth=new_depth, + max_depth=effective_max_depth, + chain=new_chain, + ) + + mandate = ACTMandate( + alg=alg, + kid=kid, + iss=iss, + sub=sub, + aud=aud, + iat=iat, + exp=exp, + jti=jti, + wid=wid if wid is not None else parent_mandate.wid, + task=task, + cap=cap, + delegation=delegation, + oversight=oversight, + ) + + return mandate, "" + + +def verify_capability_subset( + parent_caps: list[Capability], + child_caps: list[Capability], +) -> None: + """Verify that child capabilities are a subset of parent capabilities. + + Each child capability action must exist in the parent. Constraints + must be at least as restrictive. + + Reference: ACT §6.2 (Privilege Reduction Requirements). + + Raises: + ACTPrivilegeEscalationError: If child cap exceeds parent cap. + """ + parent_actions = {c.action: c for c in parent_caps} + + for child_cap in child_caps: + if child_cap.action not in parent_actions: + raise ACTPrivilegeEscalationError( + f"Capability action {child_cap.action!r} not present in " + f"parent capabilities: {sorted(parent_actions.keys())}" + ) + + parent_cap = parent_actions[child_cap.action] + _verify_constraints_subset( + parent_cap.constraints, child_cap.constraints, child_cap.action + ) + + +def _verify_constraints_subset( + parent_constraints: dict[str, Any] | None, + child_constraints: dict[str, Any] | None, + action: str, +) -> None: + """Verify child constraints are at least as restrictive as parent. + + Reference: ACT §6.2 (Privilege Reduction Requirements). + + Rules: + - Numeric values: child must be <= parent (lower = more restrictive) + - data_sensitivity enum: child must be >= parent in ordering + - Unknown/domain-specific: must be byte-for-byte identical + + Raises: + ACTPrivilegeEscalationError: If child constraint is less restrictive. + """ + if parent_constraints is None: + # Parent has no constraints — child may add constraints (more restrictive) + return + + if child_constraints is None: + # Parent has constraints but child does not — escalation + raise ACTPrivilegeEscalationError( + f"Capability {action!r}: parent has constraints but child does not" + ) + + # Sensitivity ordering per ACT §6.2 + _SENSITIVITY_ORDER = { + "public": 0, + "internal": 1, + "confidential": 2, + "restricted": 3, + } + + for key, parent_val in parent_constraints.items(): + if key not in child_constraints: + # Missing constraint in child = less restrictive + raise ACTPrivilegeEscalationError( + f"Capability {action!r}: constraint {key!r} present in " + f"parent but missing in child" + ) + + child_val = child_constraints[key] + + if key == "data_sensitivity" or key == "data_classification_max": + # Enum comparison — higher = more restrictive + p_ord = _SENSITIVITY_ORDER.get(parent_val) + c_ord = _SENSITIVITY_ORDER.get(child_val) + if p_ord is not None and c_ord is not None: + if c_ord < p_ord: + raise ACTPrivilegeEscalationError( + f"Capability {action!r}: constraint {key!r} " + f"value {child_val!r} is less restrictive than " + f"parent value {parent_val!r}" + ) + continue + + if isinstance(parent_val, (int, float)) and isinstance(child_val, (int, float)): + # Numeric: lower/equal = more restrictive + if child_val > parent_val: + raise ACTPrivilegeEscalationError( + f"Capability {action!r}: numeric constraint {key!r} " + f"value {child_val} exceeds parent value {parent_val}" + ) + continue + + # Unknown/domain-specific: must be identical — ACT §6.2 + if child_val != parent_val: + raise ACTPrivilegeEscalationError( + f"Capability {action!r}: constraint {key!r} value " + f"{child_val!r} differs from parent value {parent_val!r} " + f"(non-comparable constraints must be identical)" + ) + + +def verify_delegation_chain( + mandate: ACTMandate, + resolve_key: Any, + resolve_parent_compact: Any | None = None, +) -> None: + """Verify the delegation chain of a mandate. + + Reference: ACT §6.3 (Delegation Verification). + + Args: + mandate: The ACT mandate to verify. + resolve_key: Callable(delegator_id: str) -> PublicKey to resolve + the public key of a delegator. + resolve_parent_compact: Optional callable(jti: str) -> str|None + to retrieve the parent ACT compact form. + Required for full chain sig verification. + + Raises: + ACTDelegationError: If the chain is structurally invalid. + ACTPrivilegeEscalationError: If capabilities were escalated. + """ + if mandate.delegation is None: + # No delegation — root mandate, nothing to verify + return + + delegation = mandate.delegation + + # Step 3: depth <= max_depth + if delegation.depth > delegation.max_depth: + raise ACTDelegationError( + f"Delegation depth {delegation.depth} exceeds " + f"max_depth {delegation.max_depth}" + ) + + # Step 4: chain length == depth + if len(delegation.chain) != delegation.depth: + raise ACTDelegationError( + f"Delegation chain length {len(delegation.chain)} does not " + f"match depth {delegation.depth}" + ) + + # Step 2: verify each chain entry + for i, entry in enumerate(delegation.chain): + # Step 2a: resolve delegator's public key + try: + pub_key = resolve_key(entry.delegator) + except Exception as e: + raise ACTDelegationError( + f"Cannot resolve key for delegator {entry.delegator!r} " + f"at chain index {i}: {e}" + ) from e + + # Step 2b: verify signature if parent compact is available + if resolve_parent_compact is not None: + parent_compact = resolve_parent_compact(entry.jti) + if parent_compact is not None: + parent_hash = compute_sha256( + parent_compact.encode("utf-8") + ) + sig_bytes = _b64url_decode(entry.sig) + try: + crypto_verify(pub_key, sig_bytes, parent_hash) + except Exception as e: + raise ACTDelegationError( + f"Chain entry signature verification failed at " + f"index {i} (delegator={entry.delegator!r}): {e}" + ) from e diff --git a/workspace/packages/act/act/errors.py b/workspace/packages/act/act/errors.py new file mode 100644 index 0000000..3a212dc --- /dev/null +++ b/workspace/packages/act/act/errors.py @@ -0,0 +1,131 @@ +"""ACT-specific exception types. + +All exceptions defined in this module correspond to specific failure +modes in the Agent Context Token lifecycle as defined in +draft-nennemann-act-01. + +Reference: ACT §8 (Verification Procedure), §6 (Delegation Chain), +§7 (DAG Structure), §10 (Audit Ledger Interface). +""" + +from __future__ import annotations + + +class ACTError(Exception): + """Base exception for all ACT operations. + + All ACT-specific exceptions inherit from this class, allowing + callers to catch any ACT error with a single except clause. + + Reference: draft-nennemann-act-01. + """ + + +class ACTValidationError(ACTError): + """Malformed token structure or invalid field values. + + Raised when an ACT fails structural validation: missing required + claims, invalid claim types, unsupported algorithm ("none", HS*), + or invalid typ header. + + Reference: ACT §4 (Token Format), §8.1 steps 2-3, 11. + """ + + +class ACTSignatureError(ACTError): + """Signature verification failed. + + Raised when a JWS signature cannot be verified against the + resolved public key, or when a Phase 2 token is signed by the + wrong key (e.g., iss key instead of sub key). + + Reference: ACT §8.1 step 5, §8.2 step 17. + """ + + +class ACTExpiredError(ACTError): + """Token has expired. + + Raised when the current time exceeds exp + clock_skew_tolerance. + The default clock skew tolerance is 300 seconds. + + Reference: ACT §8.1 step 6. + """ + + +class ACTAudienceMismatchError(ACTError): + """The aud claim does not contain the verifier's identity. + + Reference: ACT §8.1 step 8. + """ + + +class ACTCapabilityError(ACTError): + """No matching capability or exec_act not in cap actions. + + Raised when exec_act does not match any cap[].action value, + or when a requested action is not authorized by any capability. + + Reference: ACT §8.2 step 13, §4.2.2 (cap). + """ + + +class ACTDelegationError(ACTError): + """Delegation chain is invalid. + + Raised when delegation chain verification fails: depth > max_depth, + chain length != depth, or any chain entry signature fails. + + Reference: ACT §6 (Delegation Chain), §8.1 step 12. + """ + + +class ACTDAGError(ACTError): + """DAG validation failed. + + Raised on cycle detection, missing parent jti, temporal ordering + violations, or traversal limit exceeded. + + Reference: ACT §7 (DAG Structure and Causal Ordering). + """ + + +class ACTPhaseError(ACTError): + """Wrong phase for the requested operation. + + Raised when a mandate is used where a record is expected, or + vice versa. Phase is determined by the presence of exec_act. + + Reference: ACT §3 (Lifecycle), §8. + """ + + +class ACTKeyResolutionError(ACTError): + """Cannot resolve kid to a public key. + + Raised when the kid in the JOSE header cannot be resolved to a + public key via any configured trust tier (pre-shared, PKI, DID). + + Reference: ACT §5 (Trust Model), §8.1 step 4. + """ + + +class ACTLedgerImmutabilityError(ACTError): + """Attempt to modify or delete a ledger record. + + The audit ledger enforces append-only semantics. Once appended, + a record cannot be modified or deleted. + + Reference: ACT §10 (Audit Ledger Interface). + """ + + +class ACTPrivilegeEscalationError(ACTError): + """Delegated capability exceeds parent capability. + + Raised when a delegated ACT contains actions not present in the + parent ACT's cap array, or when constraints are less restrictive + than the parent's constraints. + + Reference: ACT §6.2 (Privilege Reduction Requirements). + """ diff --git a/workspace/packages/act/act/ledger.py b/workspace/packages/act/act/ledger.py new file mode 100644 index 0000000..5545b1b --- /dev/null +++ b/workspace/packages/act/act/ledger.py @@ -0,0 +1,152 @@ +"""ACT in-memory append-only audit ledger. + +Provides an in-memory reference implementation of the audit ledger +interface. Enforces append-only semantics and hash-chain integrity. + +Reference: ACT §10 (Audit Ledger Interface). +""" + +from __future__ import annotations + +import hashlib +import json +from typing import Any + +from .errors import ACTLedgerImmutabilityError +from .token import ACTRecord + + +class ACTLedger: + """In-memory append-only audit ledger for ACT execution records. + + Records are stored in insertion order with monotonically increasing + sequence numbers. A hash chain provides integrity verification. + + Reference: ACT §10. + + This is a reference implementation suitable for testing. Production + deployments should use a persistent backend implementing the same + interface. + """ + + def __init__(self) -> None: + self._records: list[tuple[int, ACTRecord, str]] = [] + # jti → index mapping for efficient lookup + self._jti_index: dict[str, int] = {} + # wid → list of indices for workflow queries + self._wid_index: dict[str | None, list[int]] = {} + self._seq_counter: int = 0 + # Hash chain: each entry's hash includes the previous hash + self._chain_hashes: list[bytes] = [] + + def append(self, act_record: ACTRecord) -> int: + """Append an execution record to the ledger. + + Returns the sequence number assigned to the record. + + Reference: ACT §10, requirement 1 (append-only), requirement 2 (ordering). + + Raises: + ACTLedgerImmutabilityError: If a record with the same jti + already exists. + """ + if act_record.jti in self._jti_index: + raise ACTLedgerImmutabilityError( + f"Record with jti {act_record.jti!r} already exists in ledger" + ) + + seq = self._seq_counter + self._seq_counter += 1 + + # Compute hash chain entry + record_hash = self._hash_record(act_record, seq) + if self._chain_hashes: + chained = hashlib.sha256( + self._chain_hashes[-1] + record_hash + ).digest() + else: + chained = record_hash + self._chain_hashes.append(chained) + + idx = len(self._records) + self._records.append((seq, act_record, act_record.jti)) + self._jti_index[act_record.jti] = idx + + wid = act_record.wid + if wid not in self._wid_index: + self._wid_index[wid] = [] + self._wid_index[wid].append(idx) + + return seq + + def get(self, jti: str) -> ACTRecord | None: + """Retrieve a record by jti. + + Reference: ACT §10, requirement 3 (lookup). + """ + idx = self._jti_index.get(jti) + if idx is None: + return None + return self._records[idx][1] + + def list(self, wid: str | None = None) -> list[ACTRecord]: + """List records, optionally filtered by workflow id. + + If wid is None, returns all records. If wid is a string, + returns only records with that wid value. + + Reference: ACT §10. + """ + if wid is None: + return [r[1] for r in self._records] + + indices = self._wid_index.get(wid, []) + return [self._records[i][1] for i in indices] + + def verify_integrity(self) -> bool: + """Verify the hash chain integrity of the ledger. + + Recomputes the hash chain from scratch and compares against + stored chain hashes. Returns True if all hashes match. + + Reference: ACT §10, requirement 4 (integrity). + """ + if not self._records: + return True + + prev_hash: bytes | None = None + for i, (seq, record, _jti) in enumerate(self._records): + record_hash = self._hash_record(record, seq) + if prev_hash is not None: + expected = hashlib.sha256(prev_hash + record_hash).digest() + else: + expected = record_hash + + if i >= len(self._chain_hashes): + return False + if self._chain_hashes[i] != expected: + return False + prev_hash = expected + + return True + + def __len__(self) -> int: + return len(self._records) + + def _hash_record(self, record: ACTRecord, seq: int) -> bytes: + """Compute a deterministic hash of a record for chain integrity.""" + claims = record.to_claims() + # Include sequence number in hash for ordering integrity + claims["_seq"] = seq + canonical = json.dumps(claims, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode("utf-8")).digest() + + def _immutable_guard(self) -> None: + """Internal method — not callable externally. + + The ledger has no update/delete methods by design. + This exists to make the intent explicit. + """ + raise ACTLedgerImmutabilityError( + "Ledger records cannot be modified or deleted" + ) diff --git a/workspace/packages/act/act/lifecycle.py b/workspace/packages/act/act/lifecycle.py new file mode 100644 index 0000000..654fdbd --- /dev/null +++ b/workspace/packages/act/act/lifecycle.py @@ -0,0 +1,96 @@ +"""ACT Phase 1 to Phase 2 transition logic. + +Handles the transition from Authorization Mandate to Execution Record, +including re-signing by the executing agent (sub). + +Reference: ACT §3.2, §3.3 (Lifecycle State Machine). +""" + +from __future__ import annotations + +import time +from typing import Any + +from .crypto import PrivateKey, sign as crypto_sign +from .errors import ACTCapabilityError, ACTPhaseError, ACTValidationError +from .token import ( + ACTMandate, + ACTRecord, + ErrorClaim, + encode_jws, +) + + +def transition_to_record( + mandate: ACTMandate, + *, + sub_kid: str, + sub_private_key: PrivateKey, + exec_act: str, + pred: list[str] | None = None, + exec_ts: int | None = None, + status: str = "completed", + inp_hash: str | None = None, + out_hash: str | None = None, + err: ErrorClaim | None = None, +) -> tuple[ACTRecord, str]: + """Transition a Phase 1 mandate to a Phase 2 execution record. + + The executing agent (sub) adds execution claims and re-signs the + complete token with its own private key. The kid in the Phase 2 + JOSE header MUST reference sub's key, not iss's key. + + All Phase 1 claims are preserved unchanged in the Phase 2 token. + + Reference: ACT §3.2, §8.2 step 17. + + Args: + mandate: The Phase 1 ACTMandate to transition. + sub_kid: The kid for the sub agent's signing key. + sub_private_key: The sub agent's private key for re-signing. + exec_act: The action actually performed (must match a cap[].action). + pred: Predecessor task jti values (DAG dependencies). Empty list for root tasks. + exec_ts: Execution timestamp (defaults to current time). + status: Execution status: "completed", "failed", or "partial". + inp_hash: Base64url SHA-256 hash of input data (optional). + out_hash: Base64url SHA-256 hash of output data (optional). + err: Error details when status is "failed" or "partial". + + Returns: + Tuple of (ACTRecord, JWS compact serialization string). + + Raises: + ACTPhaseError: If the mandate is already a Phase 2 token. + ACTCapabilityError: If exec_act does not match any cap[].action. + ACTValidationError: If the resulting record fails validation. + """ + if mandate.is_phase2(): + raise ACTPhaseError("Cannot transition: token is already Phase 2") + + # Verify exec_act matches a capability + cap_actions = {c.action for c in mandate.cap} + if exec_act not in cap_actions: + raise ACTCapabilityError( + f"exec_act {exec_act!r} does not match any cap[].action: " + f"{sorted(cap_actions)}" + ) + + record = ACTRecord.from_mandate( + mandate, + kid=sub_kid, + exec_act=exec_act, + pred=pred if pred is not None else [], + exec_ts=exec_ts if exec_ts is not None else int(time.time()), + status=status, + inp_hash=inp_hash, + out_hash=out_hash, + err=err, + ) + + record.validate() + + # Re-sign with sub's private key + signature = crypto_sign(sub_private_key, record.signing_input()) + compact = encode_jws(record, signature) + + return record, compact diff --git a/workspace/packages/act/act/token.py b/workspace/packages/act/act/token.py new file mode 100644 index 0000000..1748b74 --- /dev/null +++ b/workspace/packages/act/act/token.py @@ -0,0 +1,734 @@ +"""ACT token structures and JWS Compact Serialization. + +Defines ACTMandate (Phase 1) and ACTRecord (Phase 2) dataclasses, +plus JWS encoding/decoding primitives for ACT tokens. + +Reference: ACT §3 (Lifecycle), §4 (Token Format). +""" + +from __future__ import annotations + +import base64 +import json +import re +import time +import uuid +from dataclasses import dataclass, field +from typing import Any + +from .errors import ACTPhaseError, ACTValidationError + +# Allowed algorithms per ACT §4.1 — symmetric and "none" are forbidden. +ALLOWED_ALGORITHMS: frozenset[str] = frozenset({"EdDSA", "ES256"}) + +# Forbidden algorithm prefixes/values per ACT §4.1. +_FORBIDDEN_ALGORITHMS: frozenset[str] = frozenset({ + "none", "HS256", "HS384", "HS512", +}) + +# Required typ value per ACT §4.1. +ACT_TYP: str = "act+jwt" + +# ABNF for action names: component *("." component) +# component = ALPHA *(ALPHA / DIGIT / "-" / "_") +_ACTION_RE = re.compile( + r"^[A-Za-z][A-Za-z0-9\-_]*(?:\.[A-Za-z][A-Za-z0-9\-_]*)*$" +) + + +def _b64url_encode(data: bytes) -> str: + """Base64url encode without padding (RFC 7515 §2).""" + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _b64url_decode(s: str) -> bytes: + """Base64url decode with padding restoration.""" + s = s + "=" * (-len(s) % 4) + return base64.urlsafe_b64decode(s) + + +def validate_action_name(action: str) -> None: + """Validate an action name against ACT ABNF grammar. + + Reference: ACT §4.2.2 (cap action names). + + Raises: + ACTValidationError: If action does not match the required grammar. + """ + if not _ACTION_RE.match(action): + raise ACTValidationError( + f"Action name {action!r} does not conform to ACT ABNF grammar" + ) + + +@dataclass(frozen=True) +class TaskClaim: + """The 'task' claim object. + + Reference: ACT §4.2.2. + """ + + purpose: str + data_sensitivity: str | None = None + created_by: str | None = None + expires_at: int | None = None + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"purpose": self.purpose} + if self.data_sensitivity is not None: + d["data_sensitivity"] = self.data_sensitivity + if self.created_by is not None: + d["created_by"] = self.created_by + if self.expires_at is not None: + d["expires_at"] = self.expires_at + return d + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> TaskClaim: + if "purpose" not in d: + raise ACTValidationError("task.purpose is required") + return cls( + purpose=d["purpose"], + data_sensitivity=d.get("data_sensitivity"), + created_by=d.get("created_by"), + expires_at=d.get("expires_at"), + ) + + +@dataclass(frozen=True) +class Capability: + """A single capability entry in the 'cap' array. + + Reference: ACT §4.2.2. + """ + + action: str + constraints: dict[str, Any] | None = None + + def __post_init__(self) -> None: + validate_action_name(self.action) + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"action": self.action} + if self.constraints is not None: + d["constraints"] = self.constraints + return d + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> Capability: + if "action" not in d: + raise ACTValidationError("cap[].action is required") + return cls( + action=d["action"], + constraints=d.get("constraints"), + ) + + +@dataclass(frozen=True) +class DelegationEntry: + """A single entry in del.chain. + + Reference: ACT §4.2.2 (del), §6 (Delegation Chain). + """ + + delegator: str + jti: str + sig: str + + def to_dict(self) -> dict[str, str]: + return {"delegator": self.delegator, "jti": self.jti, "sig": self.sig} + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> DelegationEntry: + for key in ("delegator", "jti", "sig"): + if key not in d: + raise ACTValidationError(f"del.chain[].{key} is required") + return cls( + delegator=d["delegator"], jti=d["jti"], sig=d["sig"] + ) + + +@dataclass(frozen=True) +class Delegation: + """The 'del' claim object. + + Reference: ACT §4.2.2 (del), §6 (Delegation Chain). + """ + + depth: int + max_depth: int + chain: list[DelegationEntry] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return { + "depth": self.depth, + "max_depth": self.max_depth, + "chain": [e.to_dict() for e in self.chain], + } + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> Delegation: + for key in ("depth", "max_depth"): + if key not in d: + raise ACTValidationError(f"del.{key} is required") + chain_raw = d.get("chain", []) + chain = [DelegationEntry.from_dict(e) for e in chain_raw] + return cls(depth=d["depth"], max_depth=d["max_depth"], chain=chain) + + +@dataclass(frozen=True) +class Oversight: + """The 'oversight' claim object. + + Reference: ACT §4.2.2 (oversight). + """ + + requires_approval_for: list[str] = field(default_factory=list) + approval_ref: str | None = None + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = { + "requires_approval_for": self.requires_approval_for + } + if self.approval_ref is not None: + d["approval_ref"] = self.approval_ref + return d + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> Oversight: + return cls( + requires_approval_for=d.get("requires_approval_for", []), + approval_ref=d.get("approval_ref"), + ) + + +@dataclass(frozen=True) +class ErrorClaim: + """The 'err' claim object for failed/partial execution. + + Reference: ACT §4.3. + """ + + code: str + detail: str + + def to_dict(self) -> dict[str, str]: + return {"code": self.code, "detail": self.detail} + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> ErrorClaim: + for key in ("code", "detail"): + if key not in d: + raise ACTValidationError(f"err.{key} is required") + return cls(code=d["code"], detail=d["detail"]) + + +@dataclass +class ACTMandate: + """Phase 1 Authorization Mandate. + + Represents a signed authorization from an issuing agent to a target + agent, encoding capabilities, constraints, and delegation provenance. + + Reference: ACT §3.1, §4.1, §4.2. + """ + + # JOSE header fields + alg: str + kid: str + x5c: list[str] | None = None + did: str | None = None + + # Required JWT claims + iss: str = "" + sub: str = "" + aud: str | list[str] = "" + iat: int = 0 + exp: int = 0 + jti: str = field(default_factory=lambda: str(uuid.uuid4())) + + # Optional standard claims + wid: str | None = None + + # Required ACT claims + task: TaskClaim = field(default_factory=lambda: TaskClaim(purpose="")) + cap: list[Capability] = field(default_factory=list) + + # Optional ACT claims + delegation: Delegation | None = None + oversight: Oversight | None = None + + def validate(self) -> None: + """Validate structural correctness of this mandate. + + Reference: ACT §4.1, §4.2, §8.1 step 11. + + Raises: + ACTValidationError: If any required field is missing or invalid. + """ + _validate_algorithm(self.alg) + if not self.kid: + raise ACTValidationError("kid is required in JOSE header") + for claim_name in ("iss", "sub", "aud", "jti"): + val = getattr(self, claim_name) + if not val: + raise ACTValidationError(f"{claim_name} claim is required") + if self.iat <= 0: + raise ACTValidationError("iat must be a positive NumericDate") + if self.exp <= 0: + raise ACTValidationError("exp must be a positive NumericDate") + if not self.task.purpose: + raise ACTValidationError("task.purpose is required") + if not self.cap: + raise ACTValidationError("cap must contain at least one capability") + + def to_header(self) -> dict[str, Any]: + """Build JOSE header dict. + + Reference: ACT §4.1. + """ + h: dict[str, Any] = { + "alg": self.alg, + "typ": ACT_TYP, + "kid": self.kid, + } + if self.x5c is not None: + h["x5c"] = self.x5c + if self.did is not None: + h["did"] = self.did + return h + + def to_claims(self) -> dict[str, Any]: + """Build JWT claims dict (Phase 1 claims only). + + Reference: ACT §4.2. + """ + c: dict[str, Any] = { + "iss": self.iss, + "sub": self.sub, + "aud": self.aud, + "iat": self.iat, + "exp": self.exp, + "jti": self.jti, + "task": self.task.to_dict(), + "cap": [cap.to_dict() for cap in self.cap], + } + if self.wid is not None: + c["wid"] = self.wid + if self.delegation is not None: + c["del"] = self.delegation.to_dict() + if self.oversight is not None: + c["oversight"] = self.oversight.to_dict() + return c + + def signing_input(self) -> bytes: + """Compute the JWS signing input (header.payload) as bytes. + + Reference: RFC 7515 §5.1. + """ + header_b64 = _b64url_encode( + json.dumps(self.to_header(), separators=(",", ":")).encode() + ) + payload_b64 = _b64url_encode( + json.dumps(self.to_claims(), separators=(",", ":")).encode() + ) + return f"{header_b64}.{payload_b64}".encode("ascii") + + def is_phase2(self) -> bool: + """Return False; mandates are always Phase 1.""" + return False + + @classmethod + def from_claims( + cls, + header: dict[str, Any], + claims: dict[str, Any], + ) -> ACTMandate: + """Construct an ACTMandate from parsed header and claims dicts. + + Reference: ACT §4.1, §4.2. + + Raises: + ACTValidationError: If required fields are missing. + ACTPhaseError: If exec_act is present (this is a Phase 2 token). + """ + if "exec_act" in claims: + raise ACTPhaseError( + "Token contains exec_act; use ACTRecord.from_claims instead" + ) + + del_raw = claims.get("del") + delegation = Delegation.from_dict(del_raw) if del_raw else None + + oversight_raw = claims.get("oversight") + oversight_obj = Oversight.from_dict(oversight_raw) if oversight_raw else None + + task_raw = claims.get("task") + if task_raw is None: + raise ACTValidationError("task claim is required") + + cap_raw = claims.get("cap") + if cap_raw is None: + raise ACTValidationError("cap claim is required") + + return cls( + alg=header.get("alg", ""), + kid=header.get("kid", ""), + x5c=header.get("x5c"), + did=header.get("did"), + iss=claims.get("iss", ""), + sub=claims.get("sub", ""), + aud=claims.get("aud", ""), + iat=claims.get("iat", 0), + exp=claims.get("exp", 0), + jti=claims.get("jti", ""), + wid=claims.get("wid"), + task=TaskClaim.from_dict(task_raw), + cap=[Capability.from_dict(c) for c in cap_raw], + delegation=delegation, + oversight=oversight_obj, + ) + + +@dataclass +class ACTRecord: + """Phase 2 Execution Record. + + Contains all Phase 1 claims preserved unchanged, plus execution + claims added by the executing agent. Re-signed by sub's key. + + Reference: ACT §3.2, §4.3. + """ + + # JOSE header fields (Phase 2 header uses sub's kid) + alg: str + kid: str + x5c: list[str] | None = None + did: str | None = None + + # Phase 1 claims (preserved) + iss: str = "" + sub: str = "" + aud: str | list[str] = "" + iat: int = 0 + exp: int = 0 + jti: str = "" + wid: str | None = None + task: TaskClaim = field(default_factory=lambda: TaskClaim(purpose="")) + cap: list[Capability] = field(default_factory=list) + delegation: Delegation | None = None + oversight: Oversight | None = None + + # Phase 2 claims (execution) + exec_act: str = "" + pred: list[str] = field(default_factory=list) + exec_ts: int = 0 + status: str = "" + inp_hash: str | None = None + out_hash: str | None = None + err: ErrorClaim | None = None + + def validate(self) -> None: + """Validate structural correctness of this record. + + Reference: ACT §4.3, §8.2 steps 13-16. + + Raises: + ACTValidationError: If any required field is missing or invalid. + """ + _validate_algorithm(self.alg) + if not self.kid: + raise ACTValidationError("kid is required in JOSE header") + for claim_name in ("iss", "sub", "aud", "jti"): + val = getattr(self, claim_name) + if not val: + raise ACTValidationError(f"{claim_name} claim is required") + if self.iat <= 0: + raise ACTValidationError("iat must be a positive NumericDate") + if self.exp <= 0: + raise ACTValidationError("exp must be a positive NumericDate") + if not self.task.purpose: + raise ACTValidationError("task.purpose is required") + if not self.cap: + raise ACTValidationError("cap must contain at least one capability") + if not self.exec_act: + raise ACTValidationError("exec_act is required in Phase 2") + validate_action_name(self.exec_act) + if self.exec_ts <= 0: + raise ACTValidationError("exec_ts must be a positive NumericDate") + if self.status not in ("completed", "failed", "partial"): + raise ACTValidationError( + f"status must be one of completed/failed/partial, got {self.status!r}" + ) + + def to_header(self) -> dict[str, Any]: + """Build JOSE header dict for Phase 2. + + In Phase 2, kid MUST reference the sub agent's key. + Reference: ACT §4.1, §8.2 step 17. + """ + h: dict[str, Any] = { + "alg": self.alg, + "typ": ACT_TYP, + "kid": self.kid, + } + if self.x5c is not None: + h["x5c"] = self.x5c + if self.did is not None: + h["did"] = self.did + return h + + def to_claims(self) -> dict[str, Any]: + """Build JWT claims dict (Phase 1 + Phase 2 claims). + + Reference: ACT §4.2, §4.3. + """ + c: dict[str, Any] = { + "iss": self.iss, + "sub": self.sub, + "aud": self.aud, + "iat": self.iat, + "exp": self.exp, + "jti": self.jti, + "task": self.task.to_dict(), + "cap": [cap.to_dict() for cap in self.cap], + "exec_act": self.exec_act, + "pred": self.pred, + "exec_ts": self.exec_ts, + "status": self.status, + } + if self.wid is not None: + c["wid"] = self.wid + if self.delegation is not None: + c["del"] = self.delegation.to_dict() + if self.oversight is not None: + c["oversight"] = self.oversight.to_dict() + if self.inp_hash is not None: + c["inp_hash"] = self.inp_hash + if self.out_hash is not None: + c["out_hash"] = self.out_hash + if self.err is not None: + c["err"] = self.err.to_dict() + return c + + def signing_input(self) -> bytes: + """Compute the JWS signing input (header.payload) as bytes. + + Reference: RFC 7515 §5.1. + """ + header_b64 = _b64url_encode( + json.dumps(self.to_header(), separators=(",", ":")).encode() + ) + payload_b64 = _b64url_encode( + json.dumps(self.to_claims(), separators=(",", ":")).encode() + ) + return f"{header_b64}.{payload_b64}".encode("ascii") + + def is_phase2(self) -> bool: + """Return True; records are always Phase 2.""" + return True + + @classmethod + def from_mandate( + cls, + mandate: ACTMandate, + *, + kid: str, + exec_act: str, + pred: list[str] | None = None, + exec_ts: int | None = None, + status: str = "completed", + inp_hash: str | None = None, + out_hash: str | None = None, + err: ErrorClaim | None = None, + ) -> ACTRecord: + """Create an ACTRecord by transitioning a mandate to Phase 2. + + The kid MUST be the sub agent's key identifier. + + Reference: ACT §3.2, §4.3. + """ + return cls( + alg=mandate.alg, + kid=kid, + x5c=mandate.x5c, + did=mandate.did, + iss=mandate.iss, + sub=mandate.sub, + aud=mandate.aud, + iat=mandate.iat, + exp=mandate.exp, + jti=mandate.jti, + wid=mandate.wid, + task=mandate.task, + cap=mandate.cap, + delegation=mandate.delegation, + oversight=mandate.oversight, + exec_act=exec_act, + pred=pred if pred is not None else [], + exec_ts=exec_ts if exec_ts is not None else int(time.time()), + status=status, + inp_hash=inp_hash, + out_hash=out_hash, + err=err, + ) + + @classmethod + def from_claims( + cls, + header: dict[str, Any], + claims: dict[str, Any], + ) -> ACTRecord: + """Construct an ACTRecord from parsed header and claims dicts. + + Reference: ACT §4.1, §4.2, §4.3. + + Raises: + ACTValidationError: If required fields are missing. + ACTPhaseError: If exec_act is absent (this is a Phase 1 token). + """ + if "exec_act" not in claims: + raise ACTPhaseError( + "Token does not contain exec_act; use ACTMandate.from_claims instead" + ) + + del_raw = claims.get("del") + delegation = Delegation.from_dict(del_raw) if del_raw else None + + oversight_raw = claims.get("oversight") + oversight_obj = Oversight.from_dict(oversight_raw) if oversight_raw else None + + task_raw = claims.get("task") + if task_raw is None: + raise ACTValidationError("task claim is required") + + cap_raw = claims.get("cap") + if cap_raw is None: + raise ACTValidationError("cap claim is required") + + err_raw = claims.get("err") + err_obj = ErrorClaim.from_dict(err_raw) if err_raw else None + + return cls( + alg=header.get("alg", ""), + kid=header.get("kid", ""), + x5c=header.get("x5c"), + did=header.get("did"), + iss=claims.get("iss", ""), + sub=claims.get("sub", ""), + aud=claims.get("aud", ""), + iat=claims.get("iat", 0), + exp=claims.get("exp", 0), + jti=claims.get("jti", ""), + wid=claims.get("wid"), + task=TaskClaim.from_dict(task_raw), + cap=[Capability.from_dict(c) for c in cap_raw], + delegation=delegation, + oversight=oversight_obj, + exec_act=claims["exec_act"], + pred=claims.get("pred", []), + exec_ts=claims.get("exec_ts", 0), + status=claims.get("status", ""), + inp_hash=claims.get("inp_hash"), + out_hash=claims.get("out_hash"), + err=err_obj, + ) + + +# --- JWS Compact Serialization --- + + +def encode_jws( + token: ACTMandate | ACTRecord, + signature: bytes, +) -> str: + """Encode a token and signature as JWS Compact Serialization. + + Returns header.payload.signature (three base64url segments). + + Reference: RFC 7515 §3.1, ACT §4. + """ + signing_input = token.signing_input().decode("ascii") + sig_b64 = _b64url_encode(signature) + return f"{signing_input}.{sig_b64}" + + +def decode_jws(compact: str) -> tuple[dict[str, Any], dict[str, Any], bytes, bytes]: + """Decode a JWS Compact Serialization string. + + Returns (header_dict, claims_dict, signature_bytes, signing_input_bytes). + + Reference: RFC 7515 §5.2, ACT §4. + + Raises: + ACTValidationError: If the token is malformed. + """ + parts = compact.split(".") + if len(parts) != 3: + raise ACTValidationError( + f"JWS Compact Serialization requires 3 parts, got {len(parts)}" + ) + + try: + header = json.loads(_b64url_decode(parts[0])) + except (json.JSONDecodeError, Exception) as e: + raise ACTValidationError(f"Invalid JOSE header: {e}") from e + + try: + claims = json.loads(_b64url_decode(parts[1])) + except (json.JSONDecodeError, Exception) as e: + raise ACTValidationError(f"Invalid JWT claims: {e}") from e + + try: + signature = _b64url_decode(parts[2]) + except Exception as e: + raise ACTValidationError(f"Invalid signature encoding: {e}") from e + + signing_input = f"{parts[0]}.{parts[1]}".encode("ascii") + + # Validate header requirements per ACT §4.1 + typ = header.get("typ") + if typ != ACT_TYP: + raise ACTValidationError( + f"typ must be {ACT_TYP!r}, got {typ!r}" + ) + + alg = header.get("alg", "") + _validate_algorithm(alg) + + if "kid" not in header: + raise ACTValidationError("kid is required in JOSE header") + + return header, claims, signature, signing_input + + +def parse_token(compact: str) -> ACTMandate | ACTRecord: + """Parse a JWS compact string into an ACTMandate or ACTRecord. + + Determines phase by presence of exec_act claim. + + Reference: ACT §3 (phase determination). + + Returns: + ACTMandate for Phase 1, ACTRecord for Phase 2. + """ + header, claims, _, _ = decode_jws(compact) + if "exec_act" in claims: + return ACTRecord.from_claims(header, claims) + return ACTMandate.from_claims(header, claims) + + +def _validate_algorithm(alg: str) -> None: + """Check algorithm is allowed per ACT §4.1. + + Raises: + ACTValidationError: If algorithm is forbidden or unsupported. + """ + if alg in _FORBIDDEN_ALGORITHMS or alg.upper() in _FORBIDDEN_ALGORITHMS: + raise ACTValidationError( + f"Algorithm {alg!r} is forbidden by ACT specification" + ) + if alg not in ALLOWED_ALGORITHMS: + raise ACTValidationError( + f"Unsupported algorithm {alg!r}; allowed: {sorted(ALLOWED_ALGORITHMS)}" + ) diff --git a/workspace/packages/act/act/vectors.py b/workspace/packages/act/act/vectors.py new file mode 100644 index 0000000..408ccd3 --- /dev/null +++ b/workspace/packages/act/act/vectors.py @@ -0,0 +1,639 @@ +"""ACT Appendix B test vectors. + +Generates and validates all 15 test vectors from Appendix B of +draft-nennemann-act-01. Each vector includes description, input +parameters, and expected output or exception. + +Reference: ACT Appendix B (Test Vectors). +""" + +from __future__ import annotations + +import time +import uuid +from dataclasses import dataclass, field +from typing import Any + +from .crypto import ( + ACTKeyResolver, + KeyRegistry, + PrivateKey, + PublicKey, + b64url_sha256, + compute_sha256, + generate_ed25519_keypair, + sign as crypto_sign, + verify as crypto_verify, +) +from .dag import validate_dag +from .delegation import create_delegated_mandate, verify_capability_subset +from .errors import ( + ACTAudienceMismatchError, + ACTCapabilityError, + ACTDAGError, + ACTDelegationError, + ACTExpiredError, + ACTPrivilegeEscalationError, + ACTSignatureError, + ACTValidationError, +) +from .ledger import ACTLedger +from .lifecycle import transition_to_record +from .token import ( + ACTMandate, + ACTRecord, + Capability, + Delegation, + DelegationEntry, + ErrorClaim, + Oversight, + TaskClaim, + _b64url_encode, + decode_jws, + encode_jws, +) +from .verify import ACTVerifier + + +@dataclass +class TestVector: + """A single test vector.""" + + id: str + description: str + valid: bool + expected_exception: type[Exception] | None = None + compact: str = "" + record: ACTMandate | ACTRecord | None = None + + +def generate_vectors() -> tuple[list[TestVector], dict[str, Any]]: + """Generate all Appendix B test vectors. + + Returns a list of TestVector objects and a context dict containing + keys and other state needed for validation. + + Reference: ACT Appendix B. + """ + # Fixed timestamp for deterministic vectors + base_time = 1772064000 + + # Generate key pairs for test agents + iss_priv, iss_pub = generate_ed25519_keypair() + sub_priv, sub_pub = generate_ed25519_keypair() + agent_c_priv, agent_c_pub = generate_ed25519_keypair() + + # Fixed JTIs for cross-referencing + jti_b1 = "550e8400-e29b-41d4-a716-446655440001" + jti_b2 = "550e8400-e29b-41d4-a716-446655440002" + jti_b3_parent1 = "550e8400-e29b-41d4-a716-446655440003" + jti_b3_parent2 = "550e8400-e29b-41d4-a716-446655440004" + jti_b3 = "550e8400-e29b-41d4-a716-446655440005" + jti_b4 = "550e8400-e29b-41d4-a716-446655440006" + jti_b5 = "550e8400-e29b-41d4-a716-446655440007" + wid = "a0b1c2d3-e4f5-6789-abcd-ef0123456789" + + # Key registry + registry = KeyRegistry() + registry.register("iss-key", iss_pub) + registry.register("sub-key", sub_pub) + registry.register("agent-c-key", agent_c_pub) + + resolver = ACTKeyResolver(registry=registry) + + vectors: list[TestVector] = [] + compacts: dict[str, str] = {} # jti → compact for delegation refs + + # --- B.1: Phase 1 — Root mandate, Tier 1, Ed25519, no delegation --- + mandate_b1 = ACTMandate( + alg="EdDSA", + kid="iss-key", + iss="agent-issuer", + sub="agent-subject", + aud=["agent-subject", "https://ledger.example.com"], + iat=base_time, + exp=base_time + 900, + jti=jti_b1, + wid=wid, + task=TaskClaim( + purpose="validate_data", + data_sensitivity="restricted", + ), + cap=[ + Capability(action="read.data", constraints={"max_records": 10}), + Capability(action="write.result"), + ], + delegation=Delegation(depth=0, max_depth=2, chain=[]), + ) + mandate_b1.validate() + sig_b1 = crypto_sign(iss_priv, mandate_b1.signing_input()) + compact_b1 = encode_jws(mandate_b1, sig_b1) + compacts[jti_b1] = compact_b1 + + vectors.append(TestVector( + id="B.1", + description="Phase 1 ACT — root mandate, Tier 1 (Ed25519), no delegation", + valid=True, + compact=compact_b1, + record=mandate_b1, + )) + + # --- B.2: Phase 2 — Completed execution from B.1 --- + record_b2, compact_b2 = transition_to_record( + mandate_b1, + sub_kid="sub-key", + sub_private_key=sub_priv, + exec_act="read.data", + pred=[], + exec_ts=base_time + 300, + status="completed", + inp_hash=b64url_sha256(b"test input data"), + out_hash=b64url_sha256(b"test output data"), + ) + compacts[jti_b2] = compact_b2 + + vectors.append(TestVector( + id="B.2", + description="Phase 2 ACT — completed execution, transition from B.1 mandate", + valid=True, + compact=compact_b2, + record=record_b2, + )) + + # --- B.3: Phase 2 — Fan-in, two parent jti values --- + # Create two parent records first + parent1_mandate = ACTMandate( + alg="EdDSA", kid="iss-key", + iss="agent-issuer", sub="agent-subject", + aud="agent-subject", + iat=base_time, exp=base_time + 900, + jti=jti_b3_parent1, wid=wid, + task=TaskClaim(purpose="branch_a"), + cap=[Capability(action="compute.result")], + delegation=Delegation(depth=0, max_depth=1, chain=[]), + ) + sig_p1 = crypto_sign(iss_priv, parent1_mandate.signing_input()) + compact_p1 = encode_jws(parent1_mandate, sig_p1) + + parent1_record, parent1_compact = transition_to_record( + parent1_mandate, sub_kid="sub-key", sub_private_key=sub_priv, + exec_act="compute.result", pred=[], exec_ts=base_time + 100, + status="completed", + ) + + parent2_mandate = ACTMandate( + alg="EdDSA", kid="iss-key", + iss="agent-issuer", sub="agent-subject", + aud="agent-subject", + iat=base_time, exp=base_time + 900, + jti=jti_b3_parent2, wid=wid, + task=TaskClaim(purpose="branch_b"), + cap=[Capability(action="compute.result")], + delegation=Delegation(depth=0, max_depth=1, chain=[]), + ) + sig_p2 = crypto_sign(iss_priv, parent2_mandate.signing_input()) + compact_p2 = encode_jws(parent2_mandate, sig_p2) + + parent2_record, parent2_compact = transition_to_record( + parent2_mandate, sub_kid="sub-key", sub_private_key=sub_priv, + exec_act="compute.result", pred=[], exec_ts=base_time + 150, + status="completed", + ) + + # Fan-in record depends on both parents + fanin_mandate = ACTMandate( + alg="EdDSA", kid="iss-key", + iss="agent-issuer", sub="agent-subject", + aud="agent-subject", + iat=base_time, exp=base_time + 900, + jti=jti_b3, wid=wid, + task=TaskClaim(purpose="merge_results"), + cap=[Capability(action="compute.result")], + delegation=Delegation(depth=0, max_depth=1, chain=[]), + ) + sig_fi = crypto_sign(iss_priv, fanin_mandate.signing_input()) + + fanin_record, fanin_compact = transition_to_record( + fanin_mandate, sub_kid="sub-key", sub_private_key=sub_priv, + exec_act="compute.result", + pred=[jti_b3_parent1, jti_b3_parent2], + exec_ts=base_time + 200, + status="completed", + ) + + vectors.append(TestVector( + id="B.3", + description="Phase 2 ACT — fan-in, two predecessor jti values from parallel branches", + valid=True, + compact=fanin_compact, + record=fanin_record, + )) + + # --- B.4: Phase 1 — Delegated mandate (depth=1) --- + delegated_b4, _ = create_delegated_mandate( + parent_mandate=mandate_b1, + parent_compact=compact_b1, + delegator_private_key=iss_priv, + sub="agent-c", + kid="iss-key", + iss="agent-issuer", + aud="agent-c", + iat=base_time + 10, + exp=base_time + 600, + jti=jti_b4, + cap=[Capability(action="read.data", constraints={"max_records": 5})], + task=TaskClaim(purpose="delegated_read"), + ) + sig_b4 = crypto_sign(iss_priv, delegated_b4.signing_input()) + compact_b4 = encode_jws(delegated_b4, sig_b4) + compacts[jti_b4] = compact_b4 + + vectors.append(TestVector( + id="B.4", + description="Phase 1 ACT — delegated mandate (depth=1), chain entry with sig", + valid=True, + compact=compact_b4, + record=delegated_b4, + )) + + # --- B.5: Phase 2 — Delegated execution record --- + record_b5, compact_b5 = transition_to_record( + delegated_b4, + sub_kid="agent-c-key", + sub_private_key=agent_c_priv, + exec_act="read.data", + pred=[], + exec_ts=base_time + 350, + status="completed", + ) + + vectors.append(TestVector( + id="B.5", + description="Phase 2 ACT — delegated execution record", + valid=True, + compact=compact_b5, + record=record_b5, + )) + + # --- B.6: del.depth > del.max_depth → ACTDelegationError --- + bad_depth_mandate = ACTMandate( + alg="EdDSA", kid="iss-key", + iss="agent-issuer", sub="agent-subject", + aud="agent-subject", + iat=base_time, exp=base_time + 900, + jti=str(uuid.uuid4()), + task=TaskClaim(purpose="bad_depth"), + cap=[Capability(action="read.data")], + delegation=Delegation(depth=3, max_depth=2, chain=[ + DelegationEntry(delegator="a", jti="j1", sig="sig1"), + DelegationEntry(delegator="b", jti="j2", sig="sig2"), + DelegationEntry(delegator="c", jti="j3", sig="sig3"), + ]), + ) + sig_b6 = crypto_sign(iss_priv, bad_depth_mandate.signing_input()) + compact_b6 = encode_jws(bad_depth_mandate, sig_b6) + + vectors.append(TestVector( + id="B.6", + description="del.depth > del.max_depth → ACTDelegationError", + valid=False, + expected_exception=ACTDelegationError, + compact=compact_b6, + )) + + # --- B.7: cap escalation in delegated ACT → ACTPrivilegeEscalationError --- + vectors.append(TestVector( + id="B.7", + description="cap escalation in delegated ACT → ACTPrivilegeEscalationError", + valid=False, + expected_exception=ACTPrivilegeEscalationError, + )) + + # --- B.8: exec_act not in cap → ACTCapabilityError --- + bad_exec_mandate = ACTMandate( + alg="EdDSA", kid="iss-key", + iss="agent-issuer", sub="agent-subject", + aud="agent-subject", + iat=base_time, exp=base_time + 900, + jti=str(uuid.uuid4()), + task=TaskClaim(purpose="bad_exec"), + cap=[Capability(action="read.data")], + delegation=Delegation(depth=0, max_depth=1, chain=[]), + ) + sig_b8m = crypto_sign(iss_priv, bad_exec_mandate.signing_input()) + + # Manually construct Phase 2 with wrong exec_act + bad_exec_record = ACTRecord( + alg="EdDSA", kid="sub-key", + iss="agent-issuer", sub="agent-subject", + aud="agent-subject", + iat=base_time, exp=base_time + 900, + jti=bad_exec_mandate.jti, + task=TaskClaim(purpose="bad_exec"), + cap=[Capability(action="read.data")], + exec_act="delete.everything", + pred=[], exec_ts=base_time + 100, status="completed", + ) + sig_b8 = crypto_sign(sub_priv, bad_exec_record.signing_input()) + compact_b8 = encode_jws(bad_exec_record, sig_b8) + + vectors.append(TestVector( + id="B.8", + description="exec_act not in cap → ACTCapabilityError", + valid=False, + expected_exception=ACTCapabilityError, + compact=compact_b8, + )) + + # --- B.9: DAG cycle (pred references own jti) → ACTDAGError --- + cycle_jti = str(uuid.uuid4()) + cycle_record = ACTRecord( + alg="EdDSA", kid="sub-key", + iss="agent-issuer", sub="agent-subject", + aud="agent-subject", + iat=base_time, exp=base_time + 900, + jti=cycle_jti, + task=TaskClaim(purpose="cycle_test"), + cap=[Capability(action="read.data")], + exec_act="read.data", + pred=[cycle_jti], + exec_ts=base_time + 100, status="completed", + ) + sig_b9 = crypto_sign(sub_priv, cycle_record.signing_input()) + compact_b9 = encode_jws(cycle_record, sig_b9) + + vectors.append(TestVector( + id="B.9", + description="DAG cycle (pred references own jti) → ACTDAGError", + valid=False, + expected_exception=ACTDAGError, + compact=compact_b9, + )) + + # --- B.10: Missing parent jti in DAG → ACTDAGError --- + missing_parent_record = ACTRecord( + alg="EdDSA", kid="sub-key", + iss="agent-issuer", sub="agent-subject", + aud="agent-subject", + iat=base_time, exp=base_time + 900, + jti=str(uuid.uuid4()), + task=TaskClaim(purpose="missing_parent"), + cap=[Capability(action="read.data")], + exec_act="read.data", + pred=["nonexistent-parent-jti"], + exec_ts=base_time + 100, status="completed", + ) + sig_b10 = crypto_sign(sub_priv, missing_parent_record.signing_input()) + compact_b10 = encode_jws(missing_parent_record, sig_b10) + + vectors.append(TestVector( + id="B.10", + description="Missing parent jti in DAG → ACTDAGError", + valid=False, + expected_exception=ACTDAGError, + compact=compact_b10, + )) + + # --- B.11: Tampered payload (bit flip) → ACTSignatureError --- + # Take a valid compact and flip a byte in the payload + parts = compact_b1.split(".") + payload_bytes = bytearray(parts[1].encode("ascii")) + # Flip a character in the payload + flip_idx = len(payload_bytes) // 2 + payload_bytes[flip_idx] = (payload_bytes[flip_idx] + 1) % 128 + if payload_bytes[flip_idx] == 0: + payload_bytes[flip_idx] = 65 # 'A' + tampered_compact = f"{parts[0]}.{payload_bytes.decode('ascii')}.{parts[2]}" + + vectors.append(TestVector( + id="B.11", + description="Tampered payload (bit flip in claims) → ACTSignatureError", + valid=False, + expected_exception=ACTSignatureError, + compact=tampered_compact, + )) + + # --- B.12: Expired token → ACTExpiredError --- + expired_mandate = ACTMandate( + alg="EdDSA", kid="iss-key", + iss="agent-issuer", sub="agent-subject", + aud="agent-subject", + iat=base_time - 3600, + exp=base_time - 2700, # expired 45 minutes ago + jti=str(uuid.uuid4()), + task=TaskClaim(purpose="expired_test"), + cap=[Capability(action="read.data")], + ) + sig_b12 = crypto_sign(iss_priv, expired_mandate.signing_input()) + compact_b12 = encode_jws(expired_mandate, sig_b12) + + vectors.append(TestVector( + id="B.12", + description="Expired token → ACTExpiredError", + valid=False, + expected_exception=ACTExpiredError, + compact=compact_b12, + )) + + # --- B.13: Wrong audience → ACTAudienceMismatchError --- + wrong_aud_mandate = ACTMandate( + alg="EdDSA", kid="iss-key", + iss="agent-issuer", sub="wrong-agent", + aud="wrong-agent", + iat=base_time, exp=base_time + 900, + jti=str(uuid.uuid4()), + task=TaskClaim(purpose="wrong_aud_test"), + cap=[Capability(action="read.data")], + ) + sig_b13 = crypto_sign(iss_priv, wrong_aud_mandate.signing_input()) + compact_b13 = encode_jws(wrong_aud_mandate, sig_b13) + + vectors.append(TestVector( + id="B.13", + description="Wrong audience → ACTAudienceMismatchError", + valid=False, + expected_exception=ACTAudienceMismatchError, + compact=compact_b13, + )) + + # --- B.14: Phase 2 re-signed by iss key instead of sub → ACTSignatureError --- + record_b14 = ACTRecord.from_mandate( + mandate_b1, + kid="sub-key", # claims to be sub's key + exec_act="read.data", + pred=[], exec_ts=base_time + 300, status="completed", + ) + # But signed with ISS's private key (wrong signer) + sig_b14 = crypto_sign(iss_priv, record_b14.signing_input()) + compact_b14 = encode_jws(record_b14, sig_b14) + + vectors.append(TestVector( + id="B.14", + description="Phase 2 re-signed by iss key instead of sub → ACTSignatureError", + valid=False, + expected_exception=ACTSignatureError, + compact=compact_b14, + )) + + # --- B.15: Algorithm "none" → ACTValidationError --- + # Manually construct a JWS with alg: none + import json + import base64 + + none_header = base64.urlsafe_b64encode( + json.dumps({"alg": "none", "typ": "act+jwt", "kid": "k"}, separators=(",", ":")).encode() + ).rstrip(b"=").decode() + none_payload = base64.urlsafe_b64encode( + json.dumps({"iss": "a", "sub": "b"}, separators=(",", ":")).encode() + ).rstrip(b"=").decode() + compact_b15 = f"{none_header}.{none_payload}." + + vectors.append(TestVector( + id="B.15", + description='Algorithm "none" → ACTValidationError', + valid=False, + expected_exception=ACTValidationError, + compact=compact_b15, + )) + + context = { + "iss_priv": iss_priv, + "iss_pub": iss_pub, + "sub_priv": sub_priv, + "sub_pub": sub_pub, + "agent_c_priv": agent_c_priv, + "agent_c_pub": agent_c_pub, + "registry": registry, + "resolver": resolver, + "base_time": base_time, + "compacts": compacts, + "parent1_record": parent1_record, + "parent2_record": parent2_record, + "mandate_b1": mandate_b1, + } + + return vectors, context + + +def validate_vectors() -> bool: + """Run all test vectors and validate results. + + Returns True if all vectors pass. + + Reference: ACT Appendix B. + """ + vectors, ctx = generate_vectors() + resolver = ctx["resolver"] + base_time = ctx["base_time"] + + verifier = ACTVerifier( + key_resolver=resolver, + verifier_id="agent-subject", + trusted_issuers={"agent-issuer"}, + ) + + passed = 0 + failed = 0 + + for v in vectors: + try: + if v.id == "B.7": + # Special case: test cap escalation during delegation creation + try: + from .delegation import verify_capability_subset + verify_capability_subset( + [Capability(action="read.data", constraints={"max_records": 10})], + [Capability(action="read.data", constraints={"max_records": 100})], + ) + print(f" FAIL {v.id}: Expected {v.expected_exception.__name__}") + failed += 1 + except ACTPrivilegeEscalationError: + print(f" PASS {v.id}: {v.description}") + passed += 1 + continue + + if v.valid: + # Valid vectors: should parse and verify without error + header, claims, sig, si = decode_jws(v.compact) + kid = header["kid"] + pub = resolver.resolve(kid, header=header) + crypto_verify(pub, sig, si) + print(f" PASS {v.id}: {v.description}") + passed += 1 + else: + # Invalid vectors: should raise the expected exception + try: + if v.expected_exception == ACTDelegationError: + header, claims, sig, si = decode_jws(v.compact) + kid = header["kid"] + pub = resolver.resolve(kid, header=header) + crypto_verify(pub, sig, si) + # Parse and check delegation + from .token import ACTMandate as _M + m = _M.from_claims(header, claims) + from .delegation import verify_delegation_chain + verify_delegation_chain(m, lambda d: resolver.resolve(d)) + print(f" FAIL {v.id}: Expected {v.expected_exception.__name__}") + failed += 1 + elif v.expected_exception == ACTCapabilityError: + header, claims, sig, si = decode_jws(v.compact) + kid = header["kid"] + pub = resolver.resolve(kid, header=header) + crypto_verify(pub, sig, si) + r = ACTRecord.from_claims(header, claims) + cap_actions = {c.action for c in r.cap} + if r.exec_act not in cap_actions: + raise ACTCapabilityError("exec_act mismatch") + print(f" FAIL {v.id}: Expected {v.expected_exception.__name__}") + failed += 1 + elif v.expected_exception == ACTDAGError: + header, claims, sig, si = decode_jws(v.compact) + kid = header["kid"] + pub = resolver.resolve(kid, header=header) + crypto_verify(pub, sig, si) + r = ACTRecord.from_claims(header, claims) + ledger = ACTLedger() + validate_dag(r, ledger) + print(f" FAIL {v.id}: Expected {v.expected_exception.__name__}") + failed += 1 + elif v.expected_exception == ACTExpiredError: + verifier.verify_mandate(v.compact, check_sub=False) + print(f" FAIL {v.id}: Expected {v.expected_exception.__name__}") + failed += 1 + elif v.expected_exception == ACTAudienceMismatchError: + verifier.verify_mandate( + v.compact, + now=base_time + 100, + check_sub=False, + ) + print(f" FAIL {v.id}: Expected {v.expected_exception.__name__}") + failed += 1 + elif v.expected_exception == ACTSignatureError: + header, claims, sig, si = decode_jws(v.compact) + kid = header["kid"] + pub = resolver.resolve(kid, header=header) + crypto_verify(pub, sig, si) + print(f" FAIL {v.id}: Expected {v.expected_exception.__name__}") + failed += 1 + elif v.expected_exception == ACTValidationError: + decode_jws(v.compact) + print(f" FAIL {v.id}: Expected {v.expected_exception.__name__}") + failed += 1 + else: + print(f" SKIP {v.id}: Unknown expected exception type") + failed += 1 + except Exception as e: + if isinstance(e, v.expected_exception): + print(f" PASS {v.id}: {v.description}") + passed += 1 + else: + print(f" FAIL {v.id}: Expected {v.expected_exception.__name__}, " + f"got {type(e).__name__}: {e}") + failed += 1 + + except Exception as e: + print(f" FAIL {v.id}: Unexpected error: {type(e).__name__}: {e}") + failed += 1 + + print(f"\nResults: {passed} passed, {failed} failed out of {len(vectors)}") + return failed == 0 diff --git a/workspace/packages/act/act/verify.py b/workspace/packages/act/act/verify.py new file mode 100644 index 0000000..fbe14e3 --- /dev/null +++ b/workspace/packages/act/act/verify.py @@ -0,0 +1,323 @@ +"""ACT unified verification entry point. + +Provides ACTVerifier with verify_mandate (Phase 1) and verify_record +(Phase 2) methods implementing the full verification procedures. + +Reference: ACT §8 (Verification Procedure). +""" + +from __future__ import annotations + +import logging +import time +from typing import Any + +from .crypto import ACTKeyResolver, PublicKey, verify as crypto_verify +from .dag import ACTStore, validate_dag +from .delegation import verify_delegation_chain +from .errors import ( + ACTAudienceMismatchError, + ACTCapabilityError, + ACTExpiredError, + ACTPhaseError, + ACTSignatureError, + ACTValidationError, +) +from .token import ( + ACTMandate, + ACTRecord, + decode_jws, +) + +logger = logging.getLogger(__name__) + +# Default clock skew tolerance for exp check — ACT §8.1 step 6. +DEFAULT_EXP_CLOCK_SKEW: int = 300 # 5 minutes + +# Default clock skew tolerance for iat future check — ACT §8.1 step 7. +DEFAULT_IAT_FUTURE_TOLERANCE: int = 30 # 30 seconds + + +class ACTVerifier: + """Unified ACT verification entry point. + + Implements the full verification procedure for both Phase 1 + (Authorization Mandate) and Phase 2 (Execution Record) tokens. + + Reference: ACT §8. + """ + + def __init__( + self, + key_resolver: ACTKeyResolver, + *, + verifier_id: str | None = None, + trusted_issuers: set[str] | None = None, + exp_clock_skew: int = DEFAULT_EXP_CLOCK_SKEW, + iat_future_tolerance: int = DEFAULT_IAT_FUTURE_TOLERANCE, + resolve_parent_compact: Any | None = None, + ) -> None: + """Initialize the verifier. + + Args: + key_resolver: Key resolver for all trust tiers. + verifier_id: This verifier's own identifier (for aud/sub checks). + trusted_issuers: Set of trusted issuer identifiers. + If None, iss check is skipped. + exp_clock_skew: Maximum clock skew for expiration (seconds). + iat_future_tolerance: Maximum future iat tolerance (seconds). + resolve_parent_compact: Callback to resolve parent ACT compact + form by jti (for delegation chain). + """ + self._key_resolver = key_resolver + self._verifier_id = verifier_id + self._trusted_issuers = trusted_issuers + self._exp_clock_skew = exp_clock_skew + self._iat_future_tolerance = iat_future_tolerance + self._resolve_parent_compact = resolve_parent_compact + + def verify_mandate( + self, + compact: str, + *, + now: int | None = None, + check_aud: bool = True, + check_sub: bool = True, + ) -> ACTMandate: + """Verify a Phase 1 Authorization Mandate. + + Implements ACT §8.1 verification steps 1-13. + + Args: + compact: JWS Compact Serialization of the Phase 1 ACT. + now: Current time override (for testing). Defaults to time.time(). + check_aud: Whether to check aud contains verifier_id. + check_sub: Whether to check sub matches verifier_id. + + Returns: + Verified ACTMandate. + + Raises: + ACTValidationError: Malformed token (steps 2-3, 11). + ACTSignatureError: Signature failure (step 5). + ACTExpiredError: Token expired (step 6). + ACTAudienceMismatchError: Wrong audience (step 8). + ACTDelegationError: Invalid delegation chain (step 12). + """ + current_time = now if now is not None else int(time.time()) + + # Step 1: Parse JWS Compact Serialization + header, claims, signature, signing_input = decode_jws(compact) + + # Steps 2-3: typ and alg checked by decode_jws + + # Phase check: must NOT have exec_act + if "exec_act" in claims: + raise ACTPhaseError( + "Token contains exec_act — this is a Phase 2 token, " + "not a Phase 1 mandate" + ) + + # Step 4: Resolve public key for kid + kid = header["kid"] + public_key = self._key_resolver.resolve(kid, header=header) + + # Step 5: Verify JWS signature + crypto_verify(public_key, signature, signing_input) + + # Build mandate object for claim validation + mandate = ACTMandate.from_claims(header, claims) + + # Step 6: Check exp not passed + if current_time > mandate.exp + self._exp_clock_skew: + raise ACTExpiredError( + f"Token expired: exp={mandate.exp}, " + f"now={current_time}, skew={self._exp_clock_skew}" + ) + + # Step 7: Check iat not unreasonably future + if mandate.iat > current_time + self._iat_future_tolerance: + raise ACTValidationError( + f"Token iat is too far in the future: iat={mandate.iat}, " + f"now={current_time}, tolerance={self._iat_future_tolerance}" + ) + + # Step 8: Check aud contains verifier's identity + if check_aud and self._verifier_id is not None: + aud = mandate.aud + if isinstance(aud, str): + aud_list = [aud] + else: + aud_list = aud + if self._verifier_id not in aud_list: + raise ACTAudienceMismatchError( + f"Verifier id {self._verifier_id!r} not in aud: {aud_list}" + ) + + # Step 9: Check iss is trusted + if self._trusted_issuers is not None: + if mandate.iss not in self._trusted_issuers: + raise ACTValidationError( + f"Issuer {mandate.iss!r} is not trusted" + ) + + # Step 10: Check sub matches verifier's identity + if check_sub and self._verifier_id is not None: + if mandate.sub != self._verifier_id: + raise ACTValidationError( + f"sub {mandate.sub!r} does not match verifier id " + f"{self._verifier_id!r}" + ) + + # Step 11: Check all required claims (done by from_claims + validate) + mandate.validate() + + # Step 12: Verify delegation chain + if mandate.delegation is not None and mandate.delegation.chain: + def _resolve_key(delegator_id: str) -> PublicKey: + return self._key_resolver.resolve(delegator_id) + + verify_delegation_chain( + mandate, + resolve_key=_resolve_key, + resolve_parent_compact=self._resolve_parent_compact, + ) + + return mandate + + def verify_record( + self, + compact: str, + store: ACTStore | None = None, + *, + now: int | None = None, + check_aud: bool = True, + ) -> ACTRecord: + """Verify a Phase 2 Execution Record. + + Implements all Phase 1 steps (§8.1) plus Phase 2 steps (§8.2). + + Args: + compact: JWS Compact Serialization of the Phase 2 ACT. + store: ACT store for DAG validation. If None, DAG checks + are limited to capability consistency only. + now: Current time override (for testing). + check_aud: Whether to check aud contains verifier_id. + + Returns: + Verified ACTRecord. + + Raises: + ACTValidationError: Malformed token. + ACTSignatureError: Signature failure or wrong signer. + ACTExpiredError: Token expired. + ACTAudienceMismatchError: Wrong audience. + ACTCapabilityError: exec_act not in cap. + ACTDAGError: DAG validation failure. + """ + current_time = now if now is not None else int(time.time()) + + # Step 1: Parse JWS + header, claims, signature, signing_input = decode_jws(compact) + + # Phase check + if "exec_act" not in claims: + raise ACTPhaseError( + "Token does not contain exec_act — this is a Phase 1 " + "mandate, not a Phase 2 record" + ) + + # Step 4: Resolve key — in Phase 2, kid MUST be sub's key + kid = header["kid"] + public_key = self._key_resolver.resolve(kid, header=header) + + # Step 5: Verify JWS signature (Step 17: by sub's key) + crypto_verify(public_key, signature, signing_input) + + # Build record + record = ACTRecord.from_claims(header, claims) + + # Step 6: Check exp + if current_time > record.exp + self._exp_clock_skew: + raise ACTExpiredError( + f"Token expired: exp={record.exp}, " + f"now={current_time}, skew={self._exp_clock_skew}" + ) + + # Step 7: iat future check + if record.iat > current_time + self._iat_future_tolerance: + raise ACTValidationError( + f"Token iat is too far in the future: iat={record.iat}" + ) + + # Step 8: aud check + if check_aud and self._verifier_id is not None: + aud = record.aud + if isinstance(aud, str): + aud_list = [aud] + else: + aud_list = aud + if self._verifier_id not in aud_list: + raise ACTAudienceMismatchError( + f"Verifier id {self._verifier_id!r} not in aud: {aud_list}" + ) + + # Step 9: iss trust check + if self._trusted_issuers is not None: + if record.iss not in self._trusted_issuers: + raise ACTValidationError( + f"Issuer {record.iss!r} is not trusted" + ) + + # Step 11: required claims validation + record.validate() + + # Step 12: delegation chain + if record.delegation is not None and record.delegation.chain: + def _resolve_key(delegator_id: str) -> PublicKey: + return self._key_resolver.resolve(delegator_id) + + # Reuse verify_delegation_chain with ACTRecord fields + # (it accesses .delegation which exists on ACTRecord too) + from .delegation import verify_delegation_chain as _vdc + # Create a temporary mandate-like view — delegation chain + # verification only needs delegation and cap fields + mandate_view = ACTMandate( + alg=record.alg, kid=record.kid, + iss=record.iss, sub=record.sub, aud=record.aud, + iat=record.iat, exp=record.exp, jti=record.jti, + task=record.task, cap=record.cap, + delegation=record.delegation, + ) + _vdc( + mandate_view, + resolve_key=_resolve_key, + resolve_parent_compact=self._resolve_parent_compact, + ) + + # Phase 2 step 13: exec_act matches cap[].action + cap_actions = {c.action for c in record.cap} + if record.exec_act not in cap_actions: + raise ACTCapabilityError( + f"exec_act {record.exec_act!r} does not match any " + f"cap[].action: {sorted(cap_actions)}" + ) + + # Phase 2 step 14: DAG validation + if store is not None: + validate_dag(record, store) + + # Phase 2 step 15: exec_ts checks + if record.exec_ts < record.iat: + raise ACTValidationError( + f"exec_ts {record.exec_ts} is before iat {record.iat}" + ) + if record.exec_ts > record.exp: + logger.warning( + "exec_ts %d is after exp %d — execution after mandate expiry", + record.exec_ts, record.exp, + ) + + # Phase 2 step 16: status validation (done by record.validate()) + + return record diff --git a/workspace/packages/act/bench/bench_act.py b/workspace/packages/act/bench/bench_act.py new file mode 100644 index 0000000..88acdda --- /dev/null +++ b/workspace/packages/act/bench/bench_act.py @@ -0,0 +1,174 @@ +"""ACT performance benchmarks. + +Measures Phase 1 creation time (construct + sign + encode) against +the 500µs target from the specification. +""" + +import time +import uuid +import statistics + +from act import ( + ACTMandate, + ACTRecord, + Capability, + TaskClaim, + encode_jws, + decode_jws, + generate_ed25519_keypair, + generate_p256_keypair, + sign, + verify, + transition_to_record, +) + + +def bench_phase1_ed25519(n: int = 10000) -> None: + """Benchmark Phase 1 creation with Ed25519.""" + priv, pub = generate_ed25519_keypair() + + # Warmup + for _ in range(100): + m = ACTMandate( + alg="EdDSA", kid="k", iss="a", sub="b", aud="b", + iat=1772064000, exp=1772064900, jti=str(uuid.uuid4()), + task=TaskClaim(purpose="t"), cap=[Capability(action="x.y")], + ) + sig = sign(priv, m.signing_input()) + encode_jws(m, sig) + + times = [] + for _ in range(n): + start = time.perf_counter() + m = ACTMandate( + alg="EdDSA", kid="k", iss="a", sub="b", aud="b", + iat=1772064000, exp=1772064900, jti=str(uuid.uuid4()), + task=TaskClaim(purpose="benchmark"), + cap=[Capability(action="read.data")], + ) + sig = sign(priv, m.signing_input()) + encode_jws(m, sig) + elapsed = time.perf_counter() - start + times.append(elapsed * 1_000_000) # µs + + mean = statistics.mean(times) + median = statistics.median(times) + p99 = sorted(times)[int(n * 0.99)] + print(f"Phase 1 Ed25519 (n={n}):") + print(f" Mean: {mean:.1f} µs") + print(f" Median: {median:.1f} µs") + print(f" P99: {p99:.1f} µs") + print(f" Target: <= 500 µs {'PASS' if mean <= 500 else 'FAIL'}") + print() + + +def bench_phase1_p256(n: int = 5000) -> None: + """Benchmark Phase 1 creation with P-256.""" + priv, pub = generate_p256_keypair() + + for _ in range(50): + m = ACTMandate( + alg="ES256", kid="k", iss="a", sub="b", aud="b", + iat=1772064000, exp=1772064900, jti=str(uuid.uuid4()), + task=TaskClaim(purpose="t"), cap=[Capability(action="x.y")], + ) + sig = sign(priv, m.signing_input()) + encode_jws(m, sig) + + times = [] + for _ in range(n): + start = time.perf_counter() + m = ACTMandate( + alg="ES256", kid="k", iss="a", sub="b", aud="b", + iat=1772064000, exp=1772064900, jti=str(uuid.uuid4()), + task=TaskClaim(purpose="benchmark"), + cap=[Capability(action="read.data")], + ) + sig = sign(priv, m.signing_input()) + encode_jws(m, sig) + elapsed = time.perf_counter() - start + times.append(elapsed * 1_000_000) + + mean = statistics.mean(times) + median = statistics.median(times) + p99 = sorted(times)[int(n * 0.99)] + print(f"Phase 1 ES256 (n={n}):") + print(f" Mean: {mean:.1f} µs") + print(f" Median: {median:.1f} µs") + print(f" P99: {p99:.1f} µs") + print() + + +def bench_phase2_transition(n: int = 5000) -> None: + """Benchmark Phase 1 -> Phase 2 transition.""" + iss_priv, _ = generate_ed25519_keypair() + sub_priv, _ = generate_ed25519_keypair() + + mandate = ACTMandate( + alg="EdDSA", kid="k", iss="a", sub="b", aud="b", + iat=1772064000, exp=1772064900, jti=str(uuid.uuid4()), + task=TaskClaim(purpose="t"), cap=[Capability(action="x.y")], + ) + + # Warmup + for _ in range(50): + transition_to_record( + mandate, sub_kid="sk", sub_private_key=sub_priv, + exec_act="x.y", pred=[], status="completed", + ) + + times = [] + for _ in range(n): + start = time.perf_counter() + transition_to_record( + mandate, sub_kid="sk", sub_private_key=sub_priv, + exec_act="x.y", pred=[], status="completed", + ) + elapsed = time.perf_counter() - start + times.append(elapsed * 1_000_000) + + mean = statistics.mean(times) + median = statistics.median(times) + print(f"Phase 2 Transition (n={n}):") + print(f" Mean: {mean:.1f} µs") + print(f" Median: {median:.1f} µs") + print() + + +def bench_verify(n: int = 5000) -> None: + """Benchmark JWS decode + verify.""" + priv, pub = generate_ed25519_keypair() + m = ACTMandate( + alg="EdDSA", kid="k", iss="a", sub="b", aud="b", + iat=1772064000, exp=1772064900, jti=str(uuid.uuid4()), + task=TaskClaim(purpose="t"), cap=[Capability(action="x.y")], + ) + sig = sign(priv, m.signing_input()) + compact = encode_jws(m, sig) + + # Warmup + for _ in range(50): + _, _, s, si = decode_jws(compact) + verify(pub, s, si) + + times = [] + for _ in range(n): + start = time.perf_counter() + _, _, s, si = decode_jws(compact) + verify(pub, s, si) + elapsed = time.perf_counter() - start + times.append(elapsed * 1_000_000) + + mean = statistics.mean(times) + median = statistics.median(times) + print(f"Decode + Verify (n={n}):") + print(f" Mean: {mean:.1f} µs") + print(f" Median: {median:.1f} µs") + print() + + +if __name__ == "__main__": + bench_phase1_ed25519() + bench_phase1_p256() + bench_phase2_transition() + bench_verify() diff --git a/workspace/packages/act/docs/section-1.5-applicability.md b/workspace/packages/act/docs/section-1.5-applicability.md new file mode 100644 index 0000000..7d66f7f --- /dev/null +++ b/workspace/packages/act/docs/section-1.5-applicability.md @@ -0,0 +1,194 @@ +# Section 1.5: Applicability (for draft-nennemann-act-01) + +Insert after Section 1.4 (Relationship to Related Work). + +--- + +### 1.5. Applicability + +ACT is designed as a general-purpose primitive for AI agent +authorization and execution accountability. While a sibling +specification [I-D.nennemann-wimse-ect] profiles execution context +tokens specifically for the WIMSE working group's workload identity +infrastructure, ACT operates without any shared identity plane. This +section identifies deployment contexts where ACT applies independently +of WIMSE, and clarifies how ACT complements — rather than competes +with — ecosystem-specific agent protocols. + +#### 1.5.1. Model Context Protocol (MCP) Tool-Use Flows + +The Model Context Protocol [MCP-SPEC] defines a client-server +interface by which LLM hosts invoke external tools via structured +JSON-RPC calls. MCP 2025-11-25 mandates OAuth 2.1 for transport-layer +authentication, but provides no mechanism for carrying per-invocation +authorization constraints or for producing a tamper-evident record +of what arguments were passed and what result was returned. + +ACT addresses this gap as follows: when an MCP host is about to +dispatch a tool call on behalf of an agent, it SHOULD issue a Phase 1 +ACT Mandate encoding the permitted tool name (e.g., as a capability +constraint), the declaring scope, and any parameter-level constraints +applicable to that invocation. The MCP server, upon receiving the +request, MAY validate the ACT Mandate and, upon completing the tool +execution, SHOULD transition the token to Phase 2 by appending +SHA-256 hashes of the serialized input arguments and the JSON +response, then re-sign. The resulting Phase 2 ACT constitutes an +unforgeable record that a specific tool was called with specific +arguments and returned a specific result, independently of MCP's +OAuth layer. + +This integration requires no modification to MCP transport; the ACT +SHOULD be carried in the `ACT-Mandate` and `ACT-Record` HTTP headers +defined in Section 9.1 of this document. + +#### 1.5.2. OpenAI Agents SDK and Function Calling + +The OpenAI Agents SDK [OPENAI-AGENTS-SDK] enables composition of +agents via handoffs — structured transfers of control from one agent +to another, each potentially invoking registered function tools. The +SDK provides no built-in mechanism for a receiving agent to verify +that the handoff was authorized by a named principal, nor for the +invoking agent to produce a verifiable record of what functions it +called. + +ACT is applicable at the handoff boundary: the orchestrating agent +SHOULD issue a Phase 1 ACT Mandate to the receiving agent at the +moment of handoff, encoding the permitted function set as +capability constraints and the maximum privilege the receiving agent +MAY exercise. The receiving agent SHOULD attach its Phase 2 ACT +Record to any callback or downstream response, providing the +orchestrator with cryptographic evidence of the actions taken. In +multi-turn chains involving multiple handoffs, the DAG linkage +(Section 7) allows each handoff to be expressed as a parent-child +edge, preserving the full causal ordering of the agent invocation +sequence. + +Implementations that use the OpenAI function calling API directly, +without the Agents SDK, MAY apply ACT at the application layer: the +calling process issues a Phase 1 ACT before the function call +parameter block is finalized, and the receiving function handler +returns a Phase 2 ACT alongside its JSON result. + +#### 1.5.3. LangGraph and LangChain Agent Graphs + +LangGraph [LANGGRAPH] models agent workflows as typed StateGraphs in +which nodes represent agent invocations or tool calls and edges +represent conditional transitions. The DAG structure of ACT (Section +7) is a natural fit for this model: each LangGraph node that performs +an observable action corresponds to exactly one ACT task identifier +(`tid`), and directed edges in the LangGraph correspond to `pred` +(predecessor) references in successor ACTs. + +ACT is applicable at the node boundary: when a LangGraph node +dispatches a sub-agent or invokes a tool with side effects, it SHOULD +issue a Phase 1 ACT Mandate encoding the node's permitted actions +before any external call is made. Upon transition out of the node, +a Phase 2 ACT Record SHOULD be produced and attached to the +LangGraph state object alongside the node's output. Downstream nodes +that fan-in from multiple predecessors MAY retrieve the set of parent +ACT identifiers from the shared state to populate their `pred` array, +thereby expressing LangGraph's fan-in semantics within the ACT DAG +without any additional infrastructure. + +In contrast to LangGraph's built-in state audit trail, which is +mutable in-process memory, Phase 2 ACTs are cryptographically signed +and portable: they can be exported from a LangGraph run and +submitted to an external audit ledger, satisfying compliance +requirements that cannot be met by in-process logging alone. + +#### 1.5.4. Google Agent2Agent (A2A) Protocol + +The Agent2Agent protocol [A2A-SPEC] defines a task-oriented JSON-RPC +interface for inter-agent communication, with authentication +delegated to OAuth 2.0 or API key schemes declared in each agent's +Agent Card. A2A provides no mechanism for a receiving agent to +verify the authorization provenance of a task request beyond the +transport-layer credential, and produces no token that represents +the execution of the task in a verifiable, portable form. + +ACT is applicable as a session-layer accountability complement to +A2A: a client agent SHOULD include a Phase 1 ACT Mandate in the +`metadata` field of the A2A Task object, encoding the task type as +a capability constraint and the delegating agent's identity as the +ACT issuer. The receiving agent SHOULD validate the Mandate before +beginning task execution and SHOULD return a Phase 2 ACT Record +as an artifact in the A2A TaskResult, enabling the client agent to +retain cryptographic proof of what was executed on its behalf. + +This integration does not require modification to A2A's transport or +authentication scheme; ACT and A2A's OAuth credentials operate at +independent layers and are not redundant. A2A's credential answers +"is this client permitted to contact this server?"; the ACT Mandate +answers "is this agent permitted to request this specific task +under these constraints?". + +#### 1.5.5. Enterprise Orchestration Without WIMSE (CrewAI, AutoGen) + +Enterprise orchestration frameworks such as CrewAI [CREWAI] and +AutoGen [AUTOGEN] deploy multi-agent systems within a single +organizational boundary, typically without SPIFFE/SPIRE workload +identity infrastructure. In these environments, OAuth Authorization +Servers are often unavailable or impractical to deploy for intra- +process agent communication. + +ACT is applicable in this context via its Tier 1 (pre-shared key) +trust model (Section 5.2): each agent role in a CrewAI Crew or +AutoGen ConversableAgent graph is assigned an Ed25519 keypair at +instantiation time. The orchestrating agent issues Phase 1 Mandates +to worker agents before delegating tasks, constraining each worker +to only the tools and actions relevant to its role. Worker agents +produce Phase 2 Records on task completion. The resulting ACT chain +is exportable as a structured audit trail that satisfies the +per-action logging requirements of DORA [DORA] and EU AI Act +Article 12 [EUAIA] without requiring shared infrastructure beyond +the ability to exchange public keys at deployment time. + +Implementations SHOULD NOT use ACT's self-assertion mode (where an +agent issues and records its own mandate without external sign-off) +in regulated workflows; at minimum, the orchestrating agent MUST +sign the initial Mandate so that accountability is anchored to a +principal outside the executing agent. + +#### 1.5.6. Relationship to WIMSE ECT + +Where WIMSE infrastructure is deployed, ACT and the WIMSE Execution +Context Token [I-D.nennemann-wimse-ect] serve complementary and +non-overlapping functions. The ECT records workload-level execution +in WIMSE terms — which SPIFFE workload executed, in which trust +domain, against which service. ACT records the authorization +provenance — which agent was permitted to request which action, +under what capability constraints, by whose authority — and +transitions that authorization record into an execution record upon +task completion. + +In mixed environments, both tokens SHOULD be carried simultaneously: +the `Workload-Identity` header carries the WIMSE ECT; the +`ACT-Record` header carries the ACT. Verifiers MAY correlate the +two by matching the ACT `tid` claim against application-layer +identifiers present in the ECT's task context. Neither token is a +profile or extension of the other; they operate at different +abstraction layers and their co-presence is additive. + +--- + +## Informative References to Add + +``` +[MCP-SPEC] Model Context Protocol Specification, 2025-11-25, + + +[OPENAI-AGENTS-SDK] OpenAI, "Agents SDK", + + +[LANGGRAPH] LangChain, "LangGraph Documentation", + + +[A2A-SPEC] Google, "Agent2Agent (A2A) Protocol", + + +[CREWAI] CrewAI, "CrewAI Documentation", + + +[AUTOGEN] Microsoft, "AutoGen Documentation", + +``` diff --git a/workspace/packages/act/draft-nennemann-act-01.md b/workspace/packages/act/draft-nennemann-act-01.md new file mode 100644 index 0000000..92b364f --- /dev/null +++ b/workspace/packages/act/draft-nennemann-act-01.md @@ -0,0 +1,1866 @@ +# Agent Context Token (ACT) + +``` +Independent Submission C. Nennemann +Internet-Draft Independent +Intended status: Standards Track April 2026 +Expires: October 2026 + + Agent Context Token (ACT) + draft-nennemann-act-01 +``` + +## Abstract + +This document defines the Agent Context Token (ACT), a self-contained +JWT-based format that captures the full invocation context of an +autonomous AI agent — its capabilities, constraints, delegation +provenance, oversight requirements, task metadata, and DAG position — +and unifies authorization and execution accountability in a single +token lifecycle. An ACT begins as a signed authorization mandate and +transitions into a tamper-evident execution record once the agent +completes its task, appending cryptographic hashes of inputs and +outputs and linking to predecessor tasks via a directed acyclic graph +(DAG). ACT requires no Authorization Server, no workload identity +infrastructure, and no transparency service for basic operation. Trust +is bootstrapped via pre-shared keys and is upgradeable to PKI or +Decentralized Identifiers (DIDs). ACT is designed for cross- +organizational agent federation in regulated and unregulated +environments alike. ACT is the general-purpose agent context +primitive; the WIMSE Execution Context Token (ECT) +[I-D.nennemann-wimse-ect] is a sibling profile specialized for +workload-identity-bound execution recording in WIMSE deployments. + +--- + +## Status of This Memo + +This Internet-Draft is submitted in full conformance with the +provisions of BCP 78 and BCP 79. + +Internet-Drafts are working documents of the Internet Engineering Task +Force (IETF). Note that other groups may also distribute working +documents as Internet-Drafts. The list of current Internet-Drafts is +at https://datatracker.ietf.org/drafts/current/. + +This Internet-Draft will expire on 11 October 2026. + +--- + +## Copyright Notice + +Copyright (c) 2026 IETF Trust and the persons identified as the +document authors. All rights reserved. + +This document is subject to BCP 78 and the IETF Trust's Legal +Provisions Relating to IETF Documents +(https://trustee.ietf.org/license-info). + +--- + +## Table of Contents + +1. Introduction + - 1.1. Problem Statement + - 1.2. Design Goals + - 1.3. Non-Goals + - 1.4. Relationship to Related Work + - 1.4.1. Concurrent Agent Authorization Proposals + - 1.5. Applicability + - 1.5.1. Model Context Protocol (MCP) Tool-Use Flows + - 1.5.2. OpenAI Agents SDK and Function Calling + - 1.5.3. LangGraph and LangChain Agent Graphs + - 1.5.4. Google Agent2Agent (A2A) Protocol + - 1.5.5. Enterprise Orchestration Without WIMSE (CrewAI, AutoGen) + - 1.5.6. Relationship to WIMSE ECT +2. Conventions and Definitions +3. ACT Lifecycle + - 3.1. Phase 1: Authorization Mandate + - 3.2. Phase 2: Execution Record + - 3.3. Lifecycle State Machine +4. ACT Token Format + - 4.1. JOSE Header + - 4.2. JWT Claims: Authorization Phase + - 4.3. JWT Claims: Execution Phase + - 4.4. Complete Examples +5. Trust Model + - 5.1. Tier 0: Bootstrap (TOFU) + - 5.2. Tier 1: Pre-Shared Keys (Mandatory-to-Implement) + - 5.3. Tier 2: PKI / X.509 + - 5.4. Tier 3: Decentralized Identifiers (DID) + - 5.5. Cross-Tier Interoperability +6. Delegation Chain + - 6.1. Peer-to-Peer Delegation + - 6.2. Privilege Reduction Requirements + - 6.3. Delegation Verification +7. DAG Structure and Causal Ordering + - 7.1. DAG Validation + - 7.2. Root Tasks and Fan-in +8. Verification Procedure + - 8.1. Authorization Phase Verification + - 8.2. Execution Phase Verification +9. Transport + - 9.1. HTTP Header Transport + - 9.2. Non-HTTP Transports +10. Audit Ledger Interface +11. Security Considerations + - 11.1. Threat Model + - 11.2. Self-Assertion Limitation + - 11.3. Key Compromise + - 11.4. Replay Attack Prevention + - 11.5. Equivocation + - 11.6. Privilege Escalation + - 11.7. Denial of Service +12. Privacy Considerations +13. IANA Considerations + - 13.1. Media Type Registration + - 13.2. HTTP Header Field Registration + - 13.3. JWT Claims Registration +14. References + - 14.1. Normative References + - 14.2. Informative References +- Appendix A: Complete JSON Schema +- Appendix B: Test Vectors +- Appendix C: Deployment Scenarios + +--- + +## 1. Introduction + +Autonomous AI agents increasingly operate across organizational +boundaries, executing multi-step workflows where individual tasks are +delegated from one agent to another. These workflows create two +distinct, inseparable compliance requirements: + +1. **Authorization**: was the agent permitted to perform the action, + under what constraints, and by whose authority? + +2. **Accountability**: what did the agent actually do, with what + inputs, producing what outputs, in what causal relationship to + prior tasks? + +Existing specifications address these requirements in isolation. +The Agent Authorization Profile (AAP) [I-D.aap-oauth-profile] +provides structured authorization via OAuth 2.0 but requires a +central Authorization Server. The WIMSE Execution Context Token +[I-D.nennemann-wimse-ect] provides execution accountability but +requires WIMSE workload identity infrastructure (SPIFFE/SPIRE). + +This document defines the Agent Context Token (ACT), which addresses +both requirements in a single, self-contained token that requires no +shared infrastructure beyond the ability to verify asymmetric +signatures. The word "Context" in the name reflects what the token +carries: the complete invocation context of an agent — DAG references, +task metadata, capabilities, delegation chain, and oversight claims +— bound together in one cryptographically verifiable envelope. ACT +is positioned as the general agent context primitive, with the +WIMSE Execution Context Token (ECT) [I-D.nennemann-wimse-ect] as a +sibling profile specialized for workload-identity-bound execution +contexts in WIMSE deployments. + +### 1.1. Problem Statement + +Cross-organizational agent federation today faces a bootstrapping +problem: deploying shared OAuth infrastructure or a common SPIFFE +trust domain requires organizational agreement before the first +message is exchanged. In practice this means either: + +(a) agents operate without cryptographic authorization or audit + trails, relying on application-layer access control only; or + +(b) organizations adopt one party's identity infrastructure, creating + a hub-and-spoke dependency that contradicts the decentralized + nature of agent networks. + +ACT solves this by making pre-shared keys the mandatory-to-implement +trust baseline — two agents can begin a secure, auditable interaction +with nothing more than an out-of-band key exchange — while providing +a clean upgrade path to PKI or DID-based trust without changing the +token format. + +### 1.2. Design Goals + +- **G1 — Zero infrastructure baseline**: ACT MUST be deployable with + no shared servers, no common identity provider, and no transparency + service. + +- **G2 — Single token lifecycle**: Authorization and accountability + MUST be expressed in the same token format to prevent + authorization-accountability gaps. + +- **G3 — Peer-to-peer delegation**: Delegation chains MUST be + verifiable without contacting an Authorization Server, using + cryptographic chaining of agent signatures. + +- **G4 — DAG-native causal ordering**: Workflows with parallel + branches and fan-in dependencies MUST be expressible natively, + without flattening to a linear chain. + +- **G5 — Cross-organizational interoperability**: ACTs issued by + agents in different trust domains MUST be verifiable by any + participant holding the issuing agent's public key. + +- **G6 — Regulatory applicability**: ACT MUST provide sufficient + evidence for audit requirements in DORA [DORA], EU AI Act + Article 12 [EUAIA], and IEC 62304 [IEC62304] without requiring + additional log formats. + +- **G7 — Upgrade path**: The trust model MUST support migration from + pre-shared keys to PKI or DID without breaking existing ACT chains. + +### 1.3. Non-Goals + +The following are explicitly out of scope: + +- Defining internal AI model behavior or decision logic. +- Replacing organizational security policies or procedures. +- Defining storage formats for audit ledgers. +- Specifying token revocation infrastructure (deployments MAY use + existing mechanisms such as [RFC7009] for this purpose). +- Providing non-equivocation guarantees in standalone mode (see + Section 11.5 for the equivocation discussion and optional + transparency anchoring). + +### 1.4. Relationship to Related Work + +**AAP [I-D.aap-oauth-profile]**: ACT addresses the same authorization +problem as AAP but does not require an Authorization Server. ACT +delegation is peer-to-peer via cryptographic signature chaining; +AAP delegation requires OAuth Token Exchange [RFC8693] against a +central AS. ACT is not a profile of AAP; it is an infrastructure- +independent alternative for the same problem class. + +**WIMSE ECT [I-D.nennemann-wimse-ect]**: ACT addresses the same +execution accountability problem as the WIMSE Execution Context Token +but does not require WIMSE workload identity infrastructure. ACT is +not a profile of WIMSE; it is deployable in environments without +SPIFFE/SPIRE. In environments where WIMSE is deployed, ACT MAY be +carried alongside WIMSE tokens to augment accountability with +authorization provenance. + +**SCITT [I-D.ietf-scitt-architecture]**: For deployments requiring +non-equivocation guarantees (see Section 11.5), ACT execution records +MAY be anchored to a SCITT Transparency Service as a Layer 2 +mechanism. This is OPTIONAL and not required for basic ACT operation. +Note: The SCITT architecture draft is currently in AUTH48 (RFC +Editor queue) at version -22 and is about to become an RFC; readers +should use the RFC number once assigned. + +#### 1.4.1. Concurrent Agent Authorization Proposals + +Several concurrent proposals in the IETF and academic communities +address overlapping portions of the agent authorization problem +space. This subsection situates ACT relative to those proposals. +Protocol-layer comparison of linear versus DAG delegation +structure is deferred to Section 7.3; the summaries below focus on +scope and deployability. + +**AIP / IBCTs [AIP-IBCT]**: The Agent Interaction Protocol proposes +Interaction-Bound Capability Tokens in two modes: compact signed +JWTs for single-hop invocation and Biscuit/Datalog tokens for +multi-hop delegation, motivated by a survey of approximately 2,000 +Model Context Protocol servers that found no authorization +enforcement. ACT addresses the same problem class but relies +exclusively on JWT/JOSE throughout (no Biscuit or Datalog +dependency), defines an explicit two-phase lifecycle separating +authorization (Mandate) from proof-of-execution (Record), and +supports DAG delegation structure. IBCTs are modeled as append-only +chains at the protocol layer; ACT operates at the authorization +graph layer with revocable lifecycle states. + +**SentinelAgent [SentinelAgent]**: SentinelAgent defines a formal +Delegation Chain Calculus with seven verifiable properties, a TLA+ +mechanization, and reports 100% true-positive and 0% false-positive +rates against the DelegationBench v4 benchmark. It addresses the +same accountability question as ACT — namely, which principal +authorized a given chain of actions. The differentiator is +deployment substrate: SentinelAgent expresses its guarantees in a +domain-specific formal calculus, whereas ACT encodes the same +invariants in IETF-standard JWT infrastructure (RFC 7519, RFC 7515, +RFC 8032) already deployable in existing OAuth- and JOSE-aware +stacks. + +**Agentic JWT [AgenticJWT]**: Agentic JWT derives a per-agent +identity as a one-way hash of the agent's prompt, registered tools, +and configuration, and chains delegation assertions across +invocations. It is the closest prior-art JWT-based construction +for agentic delegation. ACT differs in that it adds an explicit +two-phase lifecycle — separating the authorization mandate from +the proof-of-execution record — and expresses delegation as a DAG +via the array-valued `pred` claim rather than a strictly linear +chain. + +**OAuth Transaction Tokens for Agents +[I-D.draft-oauth-transaction-tokens-for-agents-06]**: This draft +extends OAuth Transaction Tokens with an `actchain` claim (an +ordered delegation array), an `agentic_ctx` claim conveying intent +and constraints, and flow-type markers distinguishing interactive +from autonomous invocations. It is complementary to ACT at the +OAuth layer. The primary differentiators are topology and +infrastructure dependency: Transaction Tokens for Agents presume +an OAuth Authorization Server and use a linear `actchain`, whereas +ACT operates peer-to-peer without any AS and uses a DAG-valued +`pred`. A detailed differencing document is referenced in +Section 11. + +**Helixar Delegation Protocol (HDP) +[I-D.draft-helixar-hdp-agentic-delegation-00]**: HDP specifies +Ed25519 signatures over RFC 8785-canonicalized JSON, an +append-only linear delegation chain with session binding, and +offline verification. ACT addresses the same problem but is +encoded in JWT/JOSE (aligning with the broader IETF token +ecosystem) rather than raw canonical JSON, and its `pred` claim +admits DAG topologies rather than strictly linear chains. + +**SCITT Profile for AI Agent Execution Records +[I-D.draft-emirdag-scitt-ai-agent-execution-00]**: This draft +defines a SCITT profile in which AgentInteractionRecord (AIR) +payloads are carried as COSE_Sign1 statements anchored to a +SCITT Transparency Service. It is highly complementary to ACT: +where ACT defines the two-phase lifecycle token issued and +consumed by agents at runtime, the SCITT AI Agent Execution +draft defines the payload format suitable for long-term anchoring. +Implementations that anchor Phase 2 ACTs to SCITT (Section 11) +SHOULD consider the AIR payload structure defined in that draft +as the canonical encoding for anchored records. + +### 1.5. Applicability + +ACT is designed as a general-purpose primitive for AI agent +authorization and execution accountability. While a sibling +specification [I-D.nennemann-wimse-ect] profiles execution context +tokens specifically for the WIMSE working group's workload identity +infrastructure, ACT operates without any shared identity plane. This +section identifies deployment contexts where ACT applies independently +of WIMSE, and clarifies how ACT complements — rather than competes +with — ecosystem-specific agent protocols. + +#### 1.5.1. Model Context Protocol (MCP) Tool-Use Flows + +The Model Context Protocol [MCP-SPEC] defines a client-server +interface by which LLM hosts invoke external tools via structured +JSON-RPC calls. MCP 2025-11-25 mandates OAuth 2.1 for transport-layer +authentication, but provides no mechanism for carrying per-invocation +authorization constraints or for producing a tamper-evident record +of what arguments were passed and what result was returned. + +ACT addresses this gap as follows: when an MCP host is about to +dispatch a tool call on behalf of an agent, it SHOULD issue a Phase 1 +ACT Mandate encoding the permitted tool name (e.g., as a capability +constraint), the declaring scope, and any parameter-level constraints +applicable to that invocation. The MCP server, upon receiving the +request, MAY validate the ACT Mandate and, upon completing the tool +execution, SHOULD transition the token to Phase 2 by appending +SHA-256 hashes of the serialized input arguments and the JSON +response, then re-sign. The resulting Phase 2 ACT constitutes an +unforgeable record that a specific tool was called with specific +arguments and returned a specific result, independently of MCP's +OAuth layer. + +This integration requires no modification to MCP transport; the ACT +SHOULD be carried in the `ACT-Mandate` and `ACT-Record` HTTP headers +defined in Section 9.1 of this document. + +#### 1.5.2. OpenAI Agents SDK and Function Calling + +The OpenAI Agents SDK [OPENAI-AGENTS-SDK] enables composition of +agents via handoffs — structured transfers of control from one agent +to another, each potentially invoking registered function tools. The +SDK provides no built-in mechanism for a receiving agent to verify +that the handoff was authorized by a named principal, nor for the +invoking agent to produce a verifiable record of what functions it +called. + +ACT is applicable at the handoff boundary: the orchestrating agent +SHOULD issue a Phase 1 ACT Mandate to the receiving agent at the +moment of handoff, encoding the permitted function set as +capability constraints and the maximum privilege the receiving agent +MAY exercise. The receiving agent SHOULD attach its Phase 2 ACT +Record to any callback or downstream response, providing the +orchestrator with cryptographic evidence of the actions taken. In +multi-turn chains involving multiple handoffs, the DAG linkage +(Section 7) allows each handoff to be expressed as a parent-child +edge, preserving the full causal ordering of the agent invocation +sequence. + +Implementations that use the OpenAI function calling API directly, +without the Agents SDK, MAY apply ACT at the application layer: the +calling process issues a Phase 1 ACT before the function call +parameter block is finalized, and the receiving function handler +returns a Phase 2 ACT alongside its JSON result. + +#### 1.5.3. LangGraph and LangChain Agent Graphs + +LangGraph [LANGGRAPH] models agent workflows as typed StateGraphs in +which nodes represent agent invocations or tool calls and edges +represent conditional transitions. The DAG structure of ACT (Section +7) is a natural fit for this model: each LangGraph node that performs +an observable action corresponds to exactly one ACT task identifier +(`tid`), and directed edges in the LangGraph correspond to `pred` +(predecessor) references in successor ACTs. + +ACT is applicable at the node boundary: when a LangGraph node +dispatches a sub-agent or invokes a tool with side effects, it SHOULD +issue a Phase 1 ACT Mandate encoding the node's permitted actions +before any external call is made. Upon transition out of the node, +a Phase 2 ACT Record SHOULD be produced and attached to the +LangGraph state object alongside the node's output. Downstream nodes +that fan-in from multiple predecessors MAY retrieve the set of parent +ACT identifiers from the shared state to populate their `pred` array, +thereby expressing LangGraph's fan-in semantics within the ACT DAG +without any additional infrastructure. + +In contrast to LangGraph's built-in state audit trail, which is +mutable in-process memory, Phase 2 ACTs are cryptographically signed +and portable: they can be exported from a LangGraph run and +submitted to an external audit ledger, satisfying compliance +requirements that cannot be met by in-process logging alone. + +#### 1.5.4. Google Agent2Agent (A2A) Protocol + +The Agent2Agent protocol [A2A-SPEC] defines a task-oriented JSON-RPC +interface for inter-agent communication, with authentication +delegated to OAuth 2.0 or API key schemes declared in each agent's +Agent Card. A2A provides no mechanism for a receiving agent to +verify the authorization provenance of a task request beyond the +transport-layer credential, and produces no token that represents +the execution of the task in a verifiable, portable form. + +ACT is applicable as a session-layer accountability complement to +A2A: a client agent SHOULD include a Phase 1 ACT Mandate in the +`metadata` field of the A2A Task object, encoding the task type as +a capability constraint and the delegating agent's identity as the +ACT issuer. The receiving agent SHOULD validate the Mandate before +beginning task execution and SHOULD return a Phase 2 ACT Record +as an artifact in the A2A TaskResult, enabling the client agent to +retain cryptographic proof of what was executed on its behalf. + +This integration does not require modification to A2A's transport or +authentication scheme; ACT and A2A's OAuth credentials operate at +independent layers and are not redundant. A2A's credential answers +"is this client permitted to contact this server?"; the ACT Mandate +answers "is this agent permitted to request this specific task +under these constraints?". + +#### 1.5.5. Enterprise Orchestration Without WIMSE (CrewAI, AutoGen) + +Enterprise orchestration frameworks such as CrewAI [CREWAI] and +AutoGen [AUTOGEN] deploy multi-agent systems within a single +organizational boundary, typically without SPIFFE/SPIRE workload +identity infrastructure. In these environments, OAuth Authorization +Servers are often unavailable or impractical to deploy for intra- +process agent communication. + +ACT is applicable in this context via its Tier 1 (pre-shared key) +trust model (Section 5.2): each agent role in a CrewAI Crew or +AutoGen ConversableAgent graph is assigned an Ed25519 keypair at +instantiation time. The orchestrating agent issues Phase 1 Mandates +to worker agents before delegating tasks, constraining each worker +to only the tools and actions relevant to its role. Worker agents +produce Phase 2 Records on task completion. The resulting ACT chain +is exportable as a structured audit trail that satisfies the +per-action logging requirements of DORA [DORA] and EU AI Act +Article 12 [EUAIA] without requiring shared infrastructure beyond +the ability to exchange public keys at deployment time. + +Implementations SHOULD NOT use ACT's self-assertion mode (where an +agent issues and records its own mandate without external sign-off) +in regulated workflows; at minimum, the orchestrating agent MUST +sign the initial Mandate so that accountability is anchored to a +principal outside the executing agent. + +#### 1.5.6. Relationship to WIMSE ECT + +Where WIMSE infrastructure is deployed, ACT and the WIMSE Execution +Context Token [I-D.nennemann-wimse-ect] serve complementary and +non-overlapping functions. The ECT records workload-level execution +in WIMSE terms — which SPIFFE workload executed, in which trust +domain, against which service. ACT records the authorization +provenance — which agent was permitted to request which action, +under what capability constraints, by whose authority — and +transitions that authorization record into an execution record upon +task completion. + +In mixed environments, both tokens SHOULD be carried simultaneously: +the `Workload-Identity` header carries the WIMSE ECT; the +`ACT-Record` header carries the ACT. Verifiers MAY correlate the +two by matching the ACT `tid` claim against application-layer +identifiers present in the ECT's task context. Neither token is a +profile or extension of the other; they operate at different +abstraction layers and their co-presence is additive. + +--- + +## 2. Conventions and Definitions + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", +"SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and +"OPTIONAL" in this document are to be interpreted as described in +BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all +capitals, as shown here. + +**Agent**: An autonomous software entity that executes tasks, issues +ACTs as mandates for sub-agents, and produces ACTs as execution +records of its own actions. + +**Authorization Mandate**: An ACT in Phase 1, encoding what an agent +is permitted to do, under what constraints, and by whose authority. + +**Execution Record**: An ACT in Phase 2, encoding what an agent +actually did, including cryptographic hashes of inputs and outputs +and causal links to predecessor tasks. + +**Directed Acyclic Graph (DAG)**: A graph structure representing task +dependency ordering where edges are directed and no cycles exist. Used +by ACT to model causal relationships between tasks in a workflow. + +**Delegation Chain**: A cryptographically verifiable sequence of ACT +issuances from a root authority through one or more agents, each +signing a new ACT that reduces privileges relative to the one it +received. + +**Trust Tier**: A level of key management infrastructure used to +establish the public key of an ACT issuer. Tiers range from +pre-shared keys (Tier 1, mandatory) to PKI (Tier 2) and DIDs +(Tier 3). + +**Workflow**: A set of related tasks, identified by a shared `wid` +claim, forming a single logical unit of work. + +--- + +## 3. ACT Lifecycle + +An ACT has a two-phase lifecycle. The same token format is used in +both phases; the presence or absence of execution claims determines +which phase a token represents. + +A token is a **Phase 2 Execution Record** if and only if the claim +`exec_act` is present. A token that does not contain `exec_act` is +a **Phase 1 Authorization Mandate**. Verifiers MUST determine the +phase before applying verification rules, and MUST reject a token +that is presented in the wrong phase for the operation being +performed. + +### 3.1. Phase 1: Authorization Mandate + +In Phase 1, an ACT is created by a delegating agent (or a human +operator) to authorize a target agent to perform a specific task. The +token carries: + +- The identity of the issuing agent and the target agent. +- The capabilities granted, with associated constraints. +- Human oversight requirements for high-impact actions. +- The delegation provenance (who authorized the issuer to delegate). +- A task identifier and declared purpose. + +The Phase 1 ACT is signed by the issuing agent using its private key. +The target agent receives the ACT and uses it as a bearer mandate — +evidence that it is authorized to proceed. + +Phase 1 ACTs are short-lived. Implementations SHOULD set expiration +(`exp`) to no more than 15 minutes after issuance (`iat`) for +automated agent-to-agent workflows. Longer lifetimes MAY be used for +human-initiated mandates where the agent may not act immediately. + +### 3.2. Phase 2: Execution Record + +Upon completing the authorized task, the executing agent MUST +transition the ACT to Phase 2 by: + +1. Adding the `exec_act` claim describing the action performed. +2. Optionally adding `inp_hash` and/or `out_hash` SHA-256 hashes + of task inputs and outputs (RECOMMENDED for regulated environments). +3. Adding the `pred` array referencing predecessor task identifiers (DAG + dependencies). +4. Adding `exec_ts` and `status` claims. +5. Re-signing the complete token with its own private key. + +The re-signing is critical: it produces a new signature over the +combined authorization + execution claims, binding the executing +agent's cryptographic identity to both the mandate it received and +the execution it performed. This creates a single, non-repudiable +record that answers both "was this agent authorized?" and "what +did it do?" + +Note on issuer signature preservation: re-signing replaces the +Phase 1 signature produced by the issuing agent (`iss`). The +integrity of the original mandate is preserved through the +`del.chain` mechanism: the chain entry's `sig` field is the `iss` +agent's signature over the Phase 1 ACT, and this signature remains +intact and verifiable in the Phase 2 token. For root mandates where +`del.chain` is empty, the issuer's signature is not independently +preserved in Phase 2. Deployments requiring independent +verifiability of the original mandate SHOULD retain the Phase 1 +ACT separately alongside the Phase 2 record. + +The resulting Phase 2 ACT SHOULD be submitted to an audit ledger +(Section 10) and MAY be sent to the next agent in the workflow as +evidence of completed prerequisites. + +### 3.3. Lifecycle State Machine + +``` + [Issuer creates Phase 1 ACT] + | + | sign(issuer_key) + v + +------------------+ + | MANDATE | Phase 1: Authorization Mandate + | (unsigned by | Carried as bearer token by target agent + | target agent) | + +------------------+ + | + | Target agent executes task + | adds exec_act, inp_hash, out_hash, pred + | re-signs with target_agent_key + v + +------------------+ + | RECORD | Phase 2: Execution Record + | (signed by | Submitted to ledger, passed to next agent + | target agent) | + +------------------+ + | + | (optional) anchor to SCITT Transparency Service + v + +------------------+ + | ANCHORED | Phase 2 + external non-equivocation + +------------------+ +``` + +--- + +## 4. ACT Token Format + +An ACT is a JSON Web Token [RFC7519] signed as a JSON Web Signature +[RFC7515] using JWS Compact Serialization. All ACTs MUST use JWS +Compact Serialization to ensure they can be carried in a single HTTP +header value. + +### 4.1. JOSE Header + +The ACT JOSE header MUST contain: + +```json +{ + "alg": "ES256", + "typ": "act+jwt", + "kid": "agent-a-key-2026-03" +} +``` + +**alg** (REQUIRED): The digital signature algorithm. Implementations +MUST support ES256 [RFC7518]. EdDSA (Ed25519) [RFC8037] is +RECOMMENDED for new deployments due to smaller signatures and +resistance to side-channel attacks. Symmetric algorithms (HS256, +HS384, HS512) MUST NOT be used. The "alg" value MUST NOT be "none". + +**typ** (REQUIRED): MUST be "act+jwt" to distinguish ACTs from other +JWT types. + +**kid** (REQUIRED): An identifier for the signing key. In Tier 1 +deployments (pre-shared keys), this is an opaque string agreed +out-of-band. In Tier 2 deployments (PKI), this is the X.509 +certificate thumbprint. In Tier 3 deployments (DID), this is the +DID key fragment (e.g., `did:key:z6Mk...#key-1`). + +**x5c** (OPTIONAL): In Tier 2 deployments, the X.509 certificate +chain MAY be included to enable verification without out-of-band +key distribution. + +**did** (OPTIONAL): In Tier 3 deployments, the full DID of the +issuing agent MAY be included for resolution. + +### 4.2. JWT Claims: Authorization Phase + +#### 4.2.1. Standard JWT Claims + +**iss** (REQUIRED): The identifier of the agent issuing the mandate. +Format depends on trust tier: an opaque string (Tier 1), an X.509 +Subject DN (Tier 2), or a DID (Tier 3). + +**sub** (REQUIRED): The identifier of the agent authorized to act. +MUST use the same format convention as `iss`. + +**aud** (REQUIRED): The intended recipient(s). MUST include the +identifier of the target agent (`sub`). When an audit ledger is +deployed, MUST also include the ledger's identifier. When multiple +recipients are present, MUST be an array. Verifiers that are audit +ledgers MUST verify that their own identifier appears in `aud`. + +**iat** (REQUIRED): Issuance time as a NumericDate [RFC7519]. + +**exp** (REQUIRED): Expiration time. Implementations SHOULD set to +no more than 15 minutes after `iat` for automated workflows. + +**jti** (REQUIRED): A UUID [RFC9562] uniquely identifying this ACT +and, in Phase 2, the task it records. Used as the task identifier +for DAG predecessor references in `pred`. + +#### 4.2.2. ACT Authorization Claims + +**wid** (OPTIONAL): A UUID identifying the workflow to which this +task belongs. When present, groups related ACTs and scopes `jti` +uniqueness to the workflow. + +**task** (REQUIRED): An object describing the authorized task: + +```json +{ + "task": { + "purpose": "validate_patient_dosage", + "data_sensitivity": "restricted", + "created_by": "operator:clinical-admin-01", + "expires_at": 1772064750 + } +} +``` + +- `purpose` (REQUIRED): A string describing the intended task. + Implementations SHOULD use a controlled vocabulary or reverse- + domain notation (e.g., "com.example.validate_dosage") to enable + semantic consistency checking by the receiving agent. +- `data_sensitivity` (OPTIONAL): One of "public", "internal", + "confidential", "restricted". Receiving agents MUST NOT perform + actions that would expose data above this classification. +- `created_by` (OPTIONAL): An identifier for the human or system + that initiated the workflow. SHOULD be pseudonymous (see + Section 12). +- `expires_at` (OPTIONAL): A NumericDate after which the task + mandate is no longer valid, independent of `exp`. + +**cap** (REQUIRED): An array of capability objects, each specifying +an action the agent is authorized to perform and the constraints +under which it may do so: + +```json +{ + "cap": [ + { + "action": "read.patient_record", + "constraints": { + "patient_id_scope": "current_task_only", + "max_records": 1, + "data_classification_max": "restricted" + } + }, + { + "action": "write.dosage_recommendation", + "constraints": { + "status": "draft_only" + } + } + ] +} +``` + +Action names MUST conform to the ABNF grammar: + +``` +action-name = component *( "." component ) +component = ALPHA *( ALPHA / DIGIT / "-" / "_" ) +``` + +Receiving agents MUST perform exact string matching on action names. +Wildcard matching is NOT part of this specification. + +When multiple capabilities match the same action, OR semantics +apply: if ANY capability grants the action, the request is +authorized subject to that capability's constraints. When multiple +constraints exist within a single capability, AND semantics apply: +ALL constraints MUST be satisfied. When the same constraint key +appears in both a capability-level and a policy-level context, the +more restrictive value applies: lower numeric limits, narrower +allow-lists (intersection), broader block-lists (union), and +narrower time windows. + +**oversight** (OPTIONAL): Human oversight requirements: + +```json +{ + "oversight": { + "requires_approval_for": ["write.publish", "execute.payment"], + "approval_ref": "https://approval.example.com/workflow/w-123" + } +} +``` + +When `requires_approval_for` lists an action, the receiving agent +MUST NOT execute that action autonomously. The approval mechanism +is out of scope for this specification. + +**del** (OPTIONAL): Delegation provenance, establishing the chain +of authority from the root mandate to this ACT. If `del` is absent, +the ACT MUST be treated as a root mandate with `depth` = 0 and +further delegation is not permitted (i.e., the receiving agent MUST +NOT issue sub-mandates based on this ACT). + +```json +{ + "del": { + "depth": 1, + "max_depth": 3, + "chain": [ + { + "delegator": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "jti": "550e8400-e29b-41d4-a716-446655440000", + "sig": "base64url-encoded-signature-of-parent-act-hash" + } + ] + } +} +``` + +- `depth`: The current delegation depth. 0 means this is a root + mandate issued by a human or root authority. +- `max_depth`: The maximum permitted delegation depth. Receiving + agents MUST NOT issue sub-mandates that would exceed this depth. +- `chain`: An array of delegation provenance records ordered from + root to immediate parent (chain[0] is the root authority, + chain[depth-1] is the direct parent of this ACT). Each entry + contains: + - `delegator`: The identifier of the agent that authorized this + delegation step (i.e., the `iss` of the parent ACT at that + depth). + - `jti`: The `jti` of the parent ACT that authorized this + delegation step. + - `sig`: The delegating agent's signature over the SHA-256 hash + of that parent ACT, providing cryptographic linkage without + requiring the full parent ACT to be transmitted. + +The `sig` field in each chain entry is the critical departure from +AAP's delegation model: rather than requiring a central AS to +validate the chain, any verifier holding the delegating agent's +public key can independently verify each step by recomputing the +hash and checking the signature. + +### 4.3. JWT Claims: Execution Phase + +The following claims are added by the executing agent when +transitioning to Phase 2. Their presence distinguishes an Execution +Record from an Authorization Mandate. + +**exec_act** (REQUIRED in Phase 2): A string identifying the action +actually performed. MUST conform to the same ABNF grammar as +capability action names. MUST match one of the `action` values in +the `cap` array of the Phase 1 claims. + +**pred** (REQUIRED in Phase 2): An array of `jti` values of predecessor +tasks in the DAG. An empty array indicates a root task. Each value +MUST be the `jti` of a previously verified ACT (Phase 2) within +the same workflow (same `wid`) or the global ACT store if `wid` is +absent. + +**inp_hash** (OPTIONAL): The base64url encoding (without padding) of +the SHA-256 hash of the task's input data, computed over the raw +octets of the serialized input. Provides cryptographic evidence of +what data the agent processed. + +**out_hash** (OPTIONAL): The base64url encoding (without padding) of +the SHA-256 hash of the task's output data, using the same format +as `inp_hash`. Provides cryptographic evidence of what data the +agent produced. + +**exec_ts** (REQUIRED in Phase 2): A NumericDate recording the +actual time of task execution. MAY differ from `iat` when the agent +queued the mandate before execution. MUST be greater than or equal +to `iat`. SHOULD be less than or equal to `exp`; execution after +mandate expiry is possible when tasks are long-running and MUST NOT +cause automatic rejection, but implementors SHOULD log a warning. + +**status** (REQUIRED in Phase 2): One of "completed", "failed", +"partial". Allows audit systems to distinguish successful execution +from partial or failed attempts, which is essential for regulated +environments where failed attempts must be recorded. + +**err** (OPTIONAL, present when `status` is "failed" or "partial"): +An object providing error context: + +```json +{ + "err": { + "code": "constraint_violation", + "detail": "data_classification_max exceeded" + } +} +``` + +Error detail SHOULD NOT reveal internal system state beyond what is +necessary for audit purposes. + +### 4.4. Complete Examples + +#### Phase 1 — Authorization Mandate + +```json +{ + "alg": "ES256", + "typ": "act+jwt", + "kid": "agent-clinical-key-2026-03" +} +. +{ + "iss": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "sub": "did:key:z6MknGc3omCyas4b1GmEn4xySHgLuSHxrKrUBnrhJekxZHFz", + "aud": [ + "did:key:z6MknGc3omCyas4b1GmEn4xySHgLuSHxrKrUBnrhJekxZHFz", + "https://ledger.hospital.example.com" + ], + "iat": 1772064000, + "exp": 1772064900, + "jti": "550e8400-e29b-41d4-a716-446655440001", + + "wid": "a0b1c2d3-e4f5-6789-abcd-ef0123456789", + + "task": { + "purpose": "validate_treatment_recommendation", + "data_sensitivity": "restricted", + "created_by": "operator:clinical-admin-01" + }, + + "cap": [ + { + "action": "read.patient_record", + "constraints": { + "patient_id_scope": "current_task_only", + "max_records": 1 + } + }, + { + "action": "write.safety_assessment", + "constraints": { + "status": "draft_only" + } + } + ], + + "oversight": { + "requires_approval_for": ["write.publish_assessment"] + }, + + "del": { + "depth": 0, + "max_depth": 2, + "chain": [] + } +} +``` + +#### Phase 2 — Execution Record (same token, re-signed by target agent) + +```json +{ + "alg": "EdDSA", + "typ": "act+jwt", + "kid": "agent-safety-key-2026-03" +} +. +{ + "iss": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "sub": "did:key:z6MknGc3omCyas4b1GmEn4xySHgLuSHxrKrUBnrhJekxZHFz", + "aud": [ + "did:key:z6MknGc3omCyas4b1GmEn4xySHgLuSHxrKrUBnrhJekxZHFz", + "https://ledger.hospital.example.com" + ], + "iat": 1772064000, + "exp": 1772064900, + "jti": "550e8400-e29b-41d4-a716-446655440001", + + "wid": "a0b1c2d3-e4f5-6789-abcd-ef0123456789", + + "task": { + "purpose": "validate_treatment_recommendation", + "data_sensitivity": "restricted", + "created_by": "operator:clinical-admin-01" + }, + + "cap": [ + { + "action": "read.patient_record", + "constraints": { + "patient_id_scope": "current_task_only", + "max_records": 1 + } + }, + { + "action": "write.safety_assessment", + "constraints": { + "status": "draft_only" + } + } + ], + + "oversight": { + "requires_approval_for": ["write.publish_assessment"] + }, + + "del": { + "depth": 0, + "max_depth": 2, + "chain": [] + }, + + "exec_act": "write.safety_assessment", + "pred": ["550e8400-e29b-41d4-a716-446655440000"], + "inp_hash": "n4bQgYhMfWWaL-qgxVrQFaO_TxsrC4Is0V1sFbDwCgg", + "out_hash": "LCa0a2j_xo_5m0U8HTBBNBNCLXBkg7-g-YpeiGJm564", + "exec_ts": 1772064300, + "status": "completed" +} +``` + +--- + +## 5. Trust Model + +ACT defines four trust tiers. Tier 1 is mandatory-to-implement; all +others are optional upgrades. An ACT verifier MUST be able to process +ACTs from any tier it has configured. The trust tier in use is +determined by the `kid` format and the presence of `x5c` or `did` +header parameters. + +### 5.1. Tier 0: Bootstrap (TOFU — Trust On First Use) + +Tier 0 is NOT part of the normative trust model and MUST NOT be used +in regulated environments. It is defined here for documentation +purposes only, to describe the common bootstrapping scenario. + +In Tier 0, the first ACT received from an agent establishes its +public key. This is equivalent to SSH TOFU behavior: an attacker +who intercepts the first message can substitute their own key. Tier 0 +deployments MUST transition to Tier 1 or higher before exchanging +ACTs that carry sensitive capabilities. + +### 5.2. Tier 1: Pre-Shared Keys (Mandatory-to-Implement) + +In Tier 1, both parties exchange public keys out-of-band prior to +the first ACT exchange. The `kid` is an opaque string agreed during +the key exchange. Implementations MUST support Tier 1. + +Key exchange MAY occur via any out-of-band mechanism: manual +configuration, a configuration management system, or a prior +authenticated channel. This specification does not mandate a +specific key exchange protocol. + +Tier 1 public keys MUST be Ed25519 [RFC8037] or P-256 (ES256) +[RFC7518] keys. RSA keys SHOULD NOT be used in Tier 1 deployments due to +key size. Key rotation MUST be performed out-of-band using the same +mechanism as the initial exchange. + +### 5.3. Tier 2: PKI / X.509 + +In Tier 2, agent identity is bound to an X.509 certificate issued +by a mutually trusted Certificate Authority (CA). The `kid` is the +certificate thumbprint (SHA-256 of the DER-encoded certificate). + +Cross-organizational ACT exchange in Tier 2 requires either: + +(a) a mutually trusted root CA, or +(b) cross-certification between the organizations' CAs, or +(c) explicit trust anchoring (one organization's CA is added to + the other's trust store). + +The `x5c` JOSE header parameter [RFC7515] MAY carry the full +certificate chain to enable verification without out-of-band trust +store configuration. + +### 5.4. Tier 3: Decentralized Identifiers (DID) + +In Tier 3, agent identity is expressed as a DID [W3C-DID]. The +`kid` is a DID key fragment. The `did` JOSE header parameter carries +the full DID for resolution. + +Implementations SHOULD support at minimum `did:key` [DID-KEY] for +self-contained key distribution without external resolution, and +`did:web` [DID-WEB] for organizations that prefer DNS-anchored +identity. + +DID resolution latency introduces a dependency on external +infrastructure. To preserve the zero-infrastructure baseline, +implementations using Tier 3 MAY cache DID Documents and MUST +specify a maximum cache TTL in their configuration. + +### 5.5. Cross-Tier Interoperability + +A delegation chain MAY include agents operating at different trust +tiers. Each step in the chain is verified using the trust tier of +the signing agent at that step. Verifiers MUST NOT reject a chain +solely because it mixes trust tiers, but MAY apply stricter +policy for chains that include Tier 0 or Tier 1 steps when +exchanging sensitive capabilities. + +--- + +## 6. Delegation Chain + +ACT delegation is peer-to-peer: no Authorization Server is involved. +Delegation is expressed as a cryptographically verifiable chain of +ACT issuances, where each step reduces privileges relative to the +previous step. + +### 6.1. Peer-to-Peer Delegation + +When Agent A authorizes Agent B to perform a sub-task, Agent A: + +1. Creates a new ACT with `sub` set to Agent B's identifier. +2. Sets `cap` to a subset of A's own authorized capabilities, + with constraints at least as restrictive as those in A's mandate. +3. Sets `del.depth` to A's own `del.depth + 1`. +4. Sets `del.max_depth` to no more than the `del.max_depth` value + in A's own mandate. +5. Adds a chain entry containing A's identifier as `delegator`, + the `jti` of A's own mandate, and a `sig` value computed as: + + ``` + sig = Sign(A.private_key, SHA-256(canonical_ACT_phase1_bytes)) + ``` + + where `canonical_ACT_phase1_bytes` is the UTF-8 encoded bytes + of the JWS Compact Serialization of A's Phase 1 ACT. + +6. Signs the new ACT with A's private key. + +### 6.2. Privilege Reduction Requirements + +When issuing a delegated ACT, the issuing agent MUST reduce +privileges by one or more of: + +- Removing capabilities (sub-set of parent capabilities only). +- Adding stricter constraints (lower rate limits, narrower domains, + shorter time windows, lower data classification ceiling). +- Reducing token lifetime (`exp` closer to `iat`). +- Reducing `del.max_depth`. + +The issuing agent MUST NOT grant capabilities not present in its +own mandate. Capability escalation via delegation is prohibited and +MUST be detected and rejected by verifiers. + +For well-known numeric constraints (e.g., `max_records`, +`max_requests_per_hour`), "more restrictive" means a numerically +lower or equal value. For well-known enumerated constraints +(e.g., `data_sensitivity`), "more restrictive" means a value that +is equal or higher in the defined ordering +("public" < "internal" < "confidential" < "restricted"). +For unknown or domain-specific constraint keys, verifiers MUST +treat the constraint as non-comparable and MUST reject the +delegation unless the delegated constraint value is byte-for-byte +identical to the parent constraint value. + +### 6.3. Delegation Verification + +A verifier receiving a delegated ACT MUST: + +1. Verify the ACT's own signature (Section 8.1). +2. For each entry in `del.chain`, in order from index 0 to + `del.depth - 1`: + a. Retrieve the public key for `entry.delegator`. + b. Verify that `entry.sig` is a valid signature over the SHA-256 + hash of the referenced parent ACT (identified by `entry.jti`). + c. Verify that the capabilities in the current ACT are a subset + of the capabilities in the parent ACT, per the constraint + comparison rules in Section 6.2. +3. Verify that `del.depth` does not exceed `del.max_depth`. +4. Verify that `del.chain` length equals `del.depth`. + +If any step fails, the ACT MUST be rejected. + +--- + +## 7. DAG Structure and Causal Ordering + +ACTs in Phase 2 form a DAG over the `pred` (predecessor) claim. The DAG +encodes causal dependencies: a task MAY NOT begin before all its +parent tasks are completed. + +### 7.1. DAG Validation + +When processing a Phase 2 ACT, implementations MUST: + +1. **Uniqueness**: Verify the `jti` is unique within the workflow + (`wid`) or globally if `wid` is absent. + +2. **Predecessor Existence**: Verify every `jti` in `pred` corresponds to + a Phase 2 ACT available in the ACT store or audit ledger. + +3. **Temporal Ordering**: Verify that for each parent: + `parent.exec_ts < child.exec_ts + clock_skew_tolerance` + (RECOMMENDED tolerance: 30 seconds). Causal ordering is + primarily enforced by DAG structure, not timestamps. + +4. **Acyclicity**: Following parent references MUST NOT lead back + to the current ACT's `jti`. Implementations MUST enforce a + maximum ancestor traversal limit (RECOMMENDED: 10,000 nodes). + +5. **Capability Consistency**: Verify that `exec_act` matches one + of the `action` values in the `cap` array from Phase 1. + +### 7.2. Root Tasks and Fan-in + +A root task has `pred = []`. A workflow MAY have multiple root tasks +representing parallel branches with no shared predecessor. + +Fan-in — a task with multiple parents — is expressed naturally: + +```json +{ + "pred": [ + "550e8400-e29b-41d4-a716-446655440001", + "550e8400-e29b-41d4-a716-446655440002" + ] +} +``` + +This indicates the current task depends on the completion of both +referenced parent tasks, which MAY have been executed in parallel +by different agents. + +### 7.3. DAG vs Linear Delegation Chains + +Several concurrent proposals for agent authorization model delegation +as an ordered, linear chain of tokens or principals. Examples include +the `actchain` claim of +[I-D.draft-oauth-transaction-tokens-for-agents-06], the Agentic JWT +construction of [AgenticJWT], the AIP / Interaction-Bound Context Token +(IBCT) model of [AIP-IBCT], and the delegation record defined in +[I-D.draft-helixar-hdp-agentic-delegation-00]. In each of these +designs, the trail from the originator to the final executor is +represented as an ordered array recording one predecessor per hop. + +#### 7.3.1. What Linear Chains Express Well + +Linear chains are a natural fit for simple sequential delegation: +agent A delegates to agent B, which delegates to agent C. The chain +records the history of that single hand-off in order, and verifiers +can walk from the current holder back to the originator without +branching. For interactive user-to-agent-to-service flows, where each +step has exactly one predecessor, a linear chain is both sufficient +and compact. + +#### 7.3.2. Limitations of Linear Chains + +Agentic workflows in practice are rarely purely linear. Planner +agents dispatch parallel sub-tasks; synthesizer agents consume +results from multiple independent branches; tool calls execute +concurrently and their outputs are merged. A linear chain cannot +faithfully represent the following common topologies: + +- **Fork**: A single task spawns multiple independent sub-tasks. A + linear chain cannot express that two concurrent sub-executions + share a common parent authorization but are otherwise + independent; each sub-task would either omit its siblings or + fabricate a false ordering between them. + +- **Join (fan-in)**: A task whose output depends on results from + several predecessors has no single prior hop. Linear chains + cannot express multiple-parent relationships without either + collapsing parallel branches into an arbitrary order or + duplicating records. + +- **Diamond dependencies**: A planner dispatches parallel work and + later synthesizes the results. The synthesis step depends on + every branch, and all branches depend on the same planner. + This diamond shape requires a DAG; a linear chain forces the + verifier to pick one branch and discard the others. + +- **Cross-chain references**: When two independently authorized + chains produce outputs that are later combined (e.g., a shared + cache lookup and a fresh retrieval), linear chains force a + single history and cannot record that the combined result has + two distinct provenances. + +#### 7.3.3. ACT's DAG Approach + +As specified in Section 4.3, the `pred` claim is an array of parent +`jti` values rather than a single scalar. This allows an ACT to +record: + +- Zero parents (a root task, `pred = []`); +- Exactly one parent (a linear chain, equivalent to the + single-predecessor designs referenced above); +- Multiple parents (fan-in from parallel branches); and +- Any acyclic shape that matches the actual execution structure. + +The following example illustrates a diamond workflow. A research +agent (A) dispatches a web-search agent (B) and a code-analysis +agent (C) in parallel; both complete, and their outputs are +combined by a writer agent (D): + +~~~ + +-----+ + | A | pred = [] + +-----+ + / \ + v v + +---+ +---+ + | B | | C | pred = [A.jti] + +---+ +---+ + \ / + v v + +-----+ + | D | pred = [B.jti, C.jti] + +-----+ +~~~ + +A linear `actchain` representation cannot express that D depends on +both B and C. At best, it can record one of the two parents and lose +the other, or serialize B and C into a false sequential order. + +#### 7.3.4. Verifiability Implications + +With a DAG representation, an auditor holding the set of Phase 2 +ACTs for a workflow can reconstruct the full execution graph, not +just one chain per final record. This matters for: + +- **Debugging**: identifying which branch contributed an erroneous + input to a downstream synthesis. +- **Compliance**: demonstrating that every input to a regulated + decision was itself authorized, not only the most recent hop. +- **Tamper-evidence**: detecting that a branch has been omitted, + since the surviving siblings' `pred` arrays name the missing + predecessor by `jti`. + +#### 7.3.5. Interoperability with Linear-Chain Designs + +ACT's DAG reduces to a linear chain in the degenerate case where +every `pred` array has length zero or one. An implementation that +requires linear-chain semantics MAY treat such ACTs as equivalent +to `actchain`-style records and ignore the fork/join capability. +The reverse reduction is not available: a linear-chain-only design +cannot represent ACT DAG topologies without loss of information. + +ACT therefore takes the linear chain as a strict subset of its +model rather than as a competing approach. The DAG generalization +is deliberate and is motivated by the concurrent, branching nature +of real agentic executions rather than by any deficiency in the +linear-chain designs for the sequential cases they target. + +--- + +## 8. Verification Procedure + +### 8.1. Authorization Phase Verification + +A receiving agent MUST verify a Phase 1 ACT as follows: + +1. Parse JWS Compact Serialization per [RFC7515]. +2. Verify `typ` is "act+jwt". +3. Verify `alg` is in the verifier's algorithm allowlist. The + allowlist MUST NOT include "none" or any symmetric algorithm. +4. Retrieve the public key for `kid` per the applicable trust tier + (Section 5). +5. Verify the JWS signature. +6. Verify `exp` has not passed (with clock skew tolerance: + RECOMMENDED maximum 5 minutes). +7. Verify `iat` is not unreasonably in the future (RECOMMENDED: + no more than 30 seconds ahead). +8. Verify `aud` contains the verifier's own identifier. +9. Verify `iss` is a trusted agent identity per local policy. +10. Verify `sub` matches the verifier's own identifier (the agent + is the intended recipient of this mandate). +11. Verify all required claims are present and well-formed. +12. Verify delegation chain (Section 6.3) if `del.chain` is + non-empty. +13. Verify capabilities are within policy limits. + +### 8.2. Execution Phase Verification + +In addition to all Phase 1 verification steps, a verifier processing +a Phase 2 ACT MUST: + +1. Verify `exec_act` is present and matches an `action` in `cap`. +2. Verify `pred` is present and perform DAG validation (Section 7.1). +3. Verify `exec_ts` is present and is greater than or equal to + `iat`. If `exec_ts` is after `exp`, implementations SHOULD log + a warning but MUST NOT reject the record solely on this basis. +4. Verify `status` is present and has a valid value. +5. Verify the re-signature was produced by the `sub` agent (the + executing agent), not the `iss` agent (the mandating agent). + This is verified by checking that the `kid` in the Phase 2 JOSE + header corresponds to the `sub` agent's public key. +6. If `inp_hash` or `out_hash` are present, verify them against + locally available input/output data when possible. + +--- + +## 9. Transport + +### 9.1. HTTP Header Transport + +This specification defines two HTTP header fields for ACT transport: + +**ACT-Mandate**: Carries a Phase 1 ACT issued by an upstream agent +or operator. Value is the JWS Compact Serialization of the ACT. + +``` +GET /api/safety-check HTTP/1.1 +Host: safety-agent.example.com +ACT-Mandate: eyJhbGci...Phase1ACT... +``` + +**ACT-Record**: Carries a Phase 2 ACT from a predecessor agent, +serving as evidence of completed prerequisites. + +``` +POST /api/downstream HTTP/1.1 +Host: downstream-agent.example.com +ACT-Mandate: eyJhbGci...Phase1ACT... +ACT-Record: eyJhbGci...Phase2ACT... +``` + +Multiple `ACT-Record` header lines MAY be included when a task has +multiple completed predecessors (DAG fan-in). If any single ACT-Record +fails verification, the receiver MUST reject the entire request. + +### 9.2. Non-HTTP Transports + +For non-HTTP transports (MCP stdio, A2A message queues, AMQP, etc.), +ACTs SHOULD be carried as a dedicated field in the transport's +metadata envelope. The field name SHOULD be `act_mandate` for Phase 1 +ACTs and `act_record` for Phase 2 ACTs. Implementations MUST use the +JWS Compact Serialization form in all transports. + +--- + +## 10. Audit Ledger Interface + +Phase 2 ACTs SHOULD be submitted to an immutable audit ledger. +A ledger is RECOMMENDED for regulated environments but is not +required for basic ACT operation. This specification does not +mandate a specific storage technology. + +When an audit ledger is deployed, the implementation MUST provide: + +1. **Append-only semantics**: Once an ACT is recorded, it MUST NOT + be modified or deleted. + +2. **Ordering**: A monotonically increasing sequence number per + recorded ACT. + +3. **Lookup**: Efficient retrieval by `jti` value. + +4. **Integrity**: A cryptographic commitment scheme over recorded + ACTs (e.g., hash-chaining, Merkle tree anchoring, or SCITT + registration per [I-D.ietf-scitt-architecture]). + +--- + +## 11. Security Considerations + +### 11.1. Threat Model + +ACT assumes an adversarial environment where: + +- Individual agents may be compromised. +- Network paths may be intercepted (mitigated by transport security). +- Attackers may attempt to replay valid ACTs from prior interactions. +- Colluding agents may attempt to fabricate execution records. +- Agents may attempt privilege escalation via manipulated delegation + chains. + +ACT does NOT assume: + +- A trusted central authority (by design). +- Synchronized clocks beyond the stated skew tolerance. +- Availability of external network services during verification. + +### 11.2. Self-Assertion Limitation + +Phase 2 ACTs are self-asserted: an executing agent signs its own +execution record. A compromised agent with an intact private key can +produce Phase 2 ACTs claiming arbitrary inputs, outputs, and action +types, as long as the claimed `exec_act` matches an authorized +capability. + +This is a fundamental limitation of self-sovereign attestation. It +is the same limitation affecting WIMSE ECT +[I-D.nennemann-wimse-ect] Section 8.2. + +Mitigations: + +- **Cross-agent corroboration**: A receiving agent that processes + an ACT-Record as a prerequisite independently verifies that the + claimed `out_hash` matches the data it actually received. +- **Ledger sequencing**: An append-only ledger with monotonic + sequence numbers prevents retroactive insertion of fabricated + records. +- **SCITT anchoring**: For high-assurance deployments, Phase 2 ACTs + SHOULD be anchored to a SCITT Transparency Service, providing + external witness that the record was submitted at a claimed time. + +### 11.3. Key Compromise + +If an agent's private key is compromised, an attacker can issue +arbitrary Phase 1 mandates (impersonating the agent as an issuer) +and fabricate Phase 2 records (impersonating the agent as an +executor). + +Key compromise response: + +1. The compromised agent's identifier MUST be added to all + verifiers' deny lists. +2. In Tier 2 (PKI) deployments, the certificate MUST be revoked + via CRL or OCSP. +3. In Tier 3 (DID) deployments, the DID Document MUST be updated + to revoke the compromised key. +4. In Tier 1 (pre-shared key) deployments, both parties MUST + perform an out-of-band key rotation. + +ACT chains that include records signed by a compromised key MUST +be treated as potentially tainted from the point of compromise. +Audit systems MUST flag all ACTs signed after the estimated +compromise time. + +### 11.4. Replay Attack Prevention + +`jti` uniqueness within the applicable scope (workflow or global) +provides replay detection. Verifiers MUST reject ACTs whose `jti` +has already been seen and processed. + +`exp` provides a time-bounded replay window. Verifiers MUST reject +expired ACTs. The combination of `jti` and `exp` means that replay +detection state only needs to be maintained for the duration of token +lifetimes. + +### 11.5. Equivocation + +In standalone deployment (no audit ledger, no SCITT anchoring), ACT +does NOT provide non-equivocation guarantees. A compromised agent +can maintain two valid ACT chains — presenting Phase 2 records with +different `out_hash` values to different verifiers — and both will +pass independent verification. + +**Deployments claiming DORA [DORA] Article 10/11 compliance or EU AI +Act [EUAIA] Article 12 compliance MUST use one of:** + +(a) A shared append-only audit ledger visible to all relevant parties, + with cryptographic integrity (hash chaining or Merkle trees). + +(b) SCITT anchoring [I-D.ietf-scitt-architecture] providing external + Transparency Service receipts. + +Standalone ACT provides tamper detection (a verifier can detect +modification of a record it has seen) but not split-view prevention +(a verifier cannot detect a different record shown to another +verifier). + +### 11.6. Privilege Escalation + +Verifiers MUST check that each step in `del.chain` reduces or +maintains (never increases) the capabilities relative to the +preceding step. Implementations MUST reject ACTs where: + +- `del.depth` exceeds `del.max_depth`. +- `cap` contains actions not present in any referenced parent ACT. +- Constraints in `cap` are less restrictive than those in the parent. + +### 11.7. Denial of Service + +ACT verification is more computationally expensive than standard JWT +validation due to delegation chain verification and DAG traversal. + +Mitigations: + +- Reject ACTs larger than 64KB before parsing. +- Enforce maximum `del.chain` length (RECOMMENDED: 10 entries). +- Enforce maximum DAG ancestor traversal depth (RECOMMENDED: 10,000 + nodes, Section 7.1). +- Cache verification results for recently seen `jti` values within + the token lifetime window. + +--- + +## 12. Privacy Considerations + +ACT tokens and audit ledger records may contain information that +identifies agents, organizations, or individuals. Implementations +SHOULD apply data minimization principles: + +- `task.created_by` SHOULD use a pseudonymous identifier rather + than a personal email address or real name. +- `task.purpose` SHOULD use a controlled vocabulary code rather + than free-text descriptions that may contain personal data. +- `del.chain` entries reveal organizational structure. Cross- + organizational delegation chains SHOULD use Tier 3 (DID) + identifiers that do not reveal organizational affiliation. +- `inp_hash` and `out_hash` are hashes of data, not the data + itself, and do not constitute personal data under GDPR + Article 4(1) provided the underlying data is not trivially + reversible (e.g., hashes of very short strings). + +For GDPR Article 17 (right to erasure) compliance, audit ledgers +SHOULD store only ACT tokens (which contain hashes, not raw data) +and SHOULD implement crypto-shredding for any associated encrypted +payloads. + +--- + +## 13. IANA Considerations + +### 13.1. Media Type Registration + +This document requests registration of the following media type: + +- Type name: application +- Subtype name: act+jwt +- Required parameters: none +- Encoding considerations: binary (base64url-encoded JWT) +- Security considerations: See Section 11. +- Interoperability considerations: See Section 8. +- Specification: This document. + +### 13.2. HTTP Header Field Registration + +This document requests registration of the following HTTP header +fields in the "Hypertext Transfer Protocol (HTTP) Field Name +Registry": + +- Header field name: ACT-Mandate +- Applicable protocol: HTTP +- Status: permanent +- Specification: This document, Section 9.1. + +- Header field name: ACT-Record +- Applicable protocol: HTTP +- Status: permanent +- Specification: This document, Section 9.1. + +### 13.3. JWT Claims Registration + +This document requests registration of the following claims in the +IANA "JSON Web Token Claims" registry: + +| Claim Name | Description | Reference | +|------------|------------------------------------|-----------------| +| wid | Workflow identifier | This document | +| task | Task authorization context | This document | +| cap | Capabilities with constraints | This document | +| oversight | Human oversight requirements | This document | +| del | Delegation provenance chain | This document | +| exec_act | Executed action identifier | This document | +| pred | Predecessor task identifiers (DAG) | This document | +| inp_hash | SHA-256 hash of task input | This document | +| out_hash | SHA-256 hash of task output | This document | +| exec_ts | Actual execution timestamp | This document | +| status | Execution status | This document | +| err | Execution error context | This document | + +--- + +## 14. References + +### 14.1. Normative References + +[RFC2119] Bradner, S., "Key words for use in RFCs to Indicate + Requirement Levels", BCP 14, RFC 2119, March 1997. + +[RFC7515] Jones, M., Bradley, J., and N. Sakimura, "JSON Web + Signature (JWS)", RFC 7515, May 2015. + +[RFC7517] Jones, M., "JSON Web Key (JWK)", RFC 7517, May 2015. + +[RFC7518] Jones, M., "JSON Web Algorithms (JWA)", RFC 7518, + May 2015. + +[RFC7519] Jones, M., Bradley, J., and N. Sakimura, "JSON Web + Token (JWT)", RFC 7519, May 2015. + +[RFC8037] Liusvaara, I., "CFRG Elliptic Curves for JOSE", + RFC 8037, January 2017. + +[RFC8174] Leiba, B., "Ambiguity of Uppercase vs Lowercase in RFC + 2119 Key Words", BCP 14, RFC 8174, May 2017. + +[RFC9110] Fielding, R., et al., "HTTP Semantics", RFC 9110, + June 2022. + +[RFC9562] Davis, K., et al., "Universally Unique IDentifiers + (UUIDs)", RFC 9562, May 2024. + +### 14.2. Informative References + +[DORA] European Parliament, "Digital Operational Resilience + Act (DORA)", Regulation (EU) 2022/2554, 2022. + +[EUAIA] European Parliament, "EU Artificial Intelligence Act", + Regulation (EU) 2024/1689, 2024. + +[IEC62304] IEC, "Medical device software — Software life cycle + processes", IEC 62304:2006+AMD1:2015. + +[I-D.aap-oauth-profile] + Cruz, A., "Agent Authorization Profile (AAP) for + OAuth 2.0", draft-aap-oauth-profile-01, February 2026. + +[I-D.nennemann-wimse-ect] + Nennemann, C., "Execution Context Tokens for + Distributed Agentic Workflows", + draft-nennemann-wimse-ect-00, February 2026. + +[I-D.ietf-scitt-architecture] + Birkholz, H., et al., "An Architecture for Trustworthy + and Transparent Digital Supply Chains", + draft-ietf-scitt-architecture-22, October 2025. + Note: This draft is currently in AUTH48 (RFC Editor + queue). To become RFC upon publication. Readers should + use the RFC number once assigned. + +[RFC8693] Jones, M., et al., "OAuth 2.0 Token Exchange", + RFC 8693, January 2020. + +[MCP-SPEC] Model Context Protocol Specification, 2025-11-25, + + +[OPENAI-AGENTS-SDK] + OpenAI, "Agents SDK", + + +[LANGGRAPH] + LangChain, "LangGraph Documentation", + + +[A2A-SPEC] Google, "Agent2Agent (A2A) Protocol", + + +[CREWAI] CrewAI, "CrewAI Documentation", + + +[AUTOGEN] Microsoft, "AutoGen Documentation", + + +[W3C-DID] Sporny, M., et al., "Decentralized Identifiers (DIDs) + v1.0", W3C Recommendation, July 2022. + +[DID-KEY] Longley, D., et al., "The did:key Method v0.7", 2021. + +[I-D.draft-oauth-transaction-tokens-for-agents-06] + Fletcher, G., et al., "OAuth 2.0 Transaction Tokens for + Agents", draft-oauth-transaction-tokens-for-agents-06, + Work in Progress. + +[I-D.draft-helixar-hdp-agentic-delegation-00] + Helixar, "Helixar Delegation Protocol (HDP) for Agentic + Delegation", draft-helixar-hdp-agentic-delegation-00, + Work in Progress. + +[AgenticJWT] + "Agentic JWT: A JSON Web Token Profile for Delegated + Agent Authorization", arXiv:2509.13597, 2025, + . + +[AIP-IBCT] + Prakash, S., "AIP: Agent Interaction Protocol with + Interaction-Bound Context Tokens", arXiv:2603.24775, + March 2026, + . + +[SentinelAgent] + Patil, et al., "SentinelAgent: A Formal Delegation + Chain Calculus for Verifiable Agent Authorization", + arXiv:2604.02767, April 2026, + . + +[I-D.draft-emirdag-scitt-ai-agent-execution-00] + Emirdag, et al. (VERIDIC), "A SCITT Profile for AI + Agent Execution Records", + draft-emirdag-scitt-ai-agent-execution-00, April 2026, + Work in Progress. + +[DID-WEB] Steele, O., et al., "did:web Method Specification", + 2022. + +--- + +## Appendix A: Complete JSON Schema + +The normative JSON Schema for ACT Phase 1 and Phase 2 tokens is +available at [TODO: reference implementation repository]. + +--- + +## Appendix B: Test Vectors + +### B.1. Valid Phase 1 ACT — Root Mandate (Tier 1, Pre-Shared Key) + +``` +[TODO: include encoded test vector with signing key, payload, +and expected JWS Compact Serialization] +``` + +### B.2. Valid Phase 2 ACT — Completed Execution + +``` +[TODO: include encoded test vector demonstrating Phase 1 → Phase 2 +transition with re-signature by target agent] +``` + +### B.3. Valid Phase 2 ACT — Fan-in (Multiple Parents) + +``` +[TODO: demonstrate pred with two predecessor jti values from parallel +workflow branches] +``` + +### B.4. Invalid ACT — Delegation Depth Exceeded + +``` +[TODO: demonstrate del.depth > del.max_depth rejection] +``` + +### B.5. Invalid ACT — Capability Escalation + +``` +[TODO: demonstrate rejection when delegated cap contains action +not present in parent ACT] +``` + +### B.6. Invalid ACT — exec_act Mismatch + +``` +[TODO: demonstrate rejection when exec_act does not match any +cap.action in the Phase 1 claims] +``` + +--- + +## Appendix C: Deployment Scenarios + +### C.1. Minimal Deployment (Zero Infrastructure) + +Two organizations exchange pre-shared public keys via secure email. +Each agent signs Phase 1 mandates and Phase 2 records with its +Ed25519 key. No ledger, no external services. Suitable for +development and low-risk workflows. + +Limitation: No non-equivocation (Section 11.5). + +### C.2. Regulated Deployment with Hash-Chained Ledger + +Phase 2 ACTs are submitted to a shared append-only ledger with +hash-chaining. Each recorded ACT extends a cryptographic chain, +providing tamper evidence for each ACT and the chain as a whole. +The ledger is shared between all regulated parties participating +in the workflow. Suitable for DORA compliance. + +### C.3. High-Assurance Cross-Organizational Deployment + +Phase 2 ACTs are anchored to a SCITT Transparency Service. SCITT +receipts are attached to the audit record as non-equivocation proofs. +DID-based agent identities (Tier 3) enable self-sovereign key +management without shared CA infrastructure. + +### C.4. WIMSE Environment Integration + +In environments where WIMSE is already deployed, ACT-Mandate and +ACT-Record headers are carried alongside the WIMSE Workload-Identity +header. The ECT and ACT serve different purposes: the ECT records +workload-level execution in WIMSE terms; the ACT records the +authorization provenance and capability constraints that governed +the action. + +--- + +## Author's Address + +Christian Nennemann +Independent +Email: [TODO] diff --git a/workspace/packages/act/pyproject.toml b/workspace/packages/act/pyproject.toml new file mode 100644 index 0000000..cf2b416 --- /dev/null +++ b/workspace/packages/act/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "ietf-act" +version = "0.1.0" +description = "Agent Context Token (ACT) — JWT-based authorization and execution accountability for AI agents" +requires-python = ">=3.11" +dependencies = [ + "cryptography>=42.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", +] + +[tool.setuptools.packages.find] +where = ["."] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/workspace/packages/act/tests/test_crypto.py b/workspace/packages/act/tests/test_crypto.py new file mode 100644 index 0000000..f8e0b86 --- /dev/null +++ b/workspace/packages/act/tests/test_crypto.py @@ -0,0 +1,145 @@ +"""Tests for act.crypto module.""" + +import pytest + +from act.crypto import ( + ACTKeyResolver, + KeyRegistry, + X509TrustStore, + b64url_sha256, + compute_sha256, + did_key_from_ed25519, + generate_ed25519_keypair, + generate_p256_keypair, + resolve_did_key, + sign, + verify, +) +from act.errors import ACTKeyResolutionError, ACTSignatureError + + +class TestEd25519: + def test_generate_keypair(self): + priv, pub = generate_ed25519_keypair() + assert priv is not None + assert pub is not None + + def test_sign_verify(self): + priv, pub = generate_ed25519_keypair() + data = b"test data" + sig = sign(priv, data) + verify(pub, sig, data) + + def test_verify_wrong_data(self): + priv, pub = generate_ed25519_keypair() + sig = sign(priv, b"correct data") + with pytest.raises(ACTSignatureError): + verify(pub, sig, b"wrong data") + + def test_verify_wrong_key(self): + priv1, pub1 = generate_ed25519_keypair() + _, pub2 = generate_ed25519_keypair() + sig = sign(priv1, b"data") + with pytest.raises(ACTSignatureError): + verify(pub2, sig, b"data") + + +class TestP256: + def test_generate_keypair(self): + priv, pub = generate_p256_keypair() + assert priv is not None + assert pub is not None + + def test_sign_verify(self): + priv, pub = generate_p256_keypair() + data = b"test data for p256" + sig = sign(priv, data) + assert len(sig) == 64 # r||s, 32 bytes each + verify(pub, sig, data) + + def test_verify_wrong_data(self): + priv, pub = generate_p256_keypair() + sig = sign(priv, b"correct") + with pytest.raises(ACTSignatureError): + verify(pub, sig, b"wrong") + + +class TestSHA256: + def test_compute(self): + h = compute_sha256(b"hello") + assert len(h) == 32 + + def test_b64url(self): + result = b64url_sha256(b"hello world") + assert "=" not in result + assert isinstance(result, str) + + +class TestKeyRegistry: + def test_register_and_get(self): + reg = KeyRegistry() + _, pub = generate_ed25519_keypair() + reg.register("key-1", pub) + assert reg.get("key-1") is pub + assert "key-1" in reg + assert len(reg) == 1 + + def test_missing_key(self): + reg = KeyRegistry() + assert reg.get("missing") is None + assert "missing" not in reg + + +class TestDIDKey: + def test_ed25519_roundtrip(self): + _, pub = generate_ed25519_keypair() + did = did_key_from_ed25519(pub) + assert did.startswith("did:key:z6Mk") + resolved = resolve_did_key(did) + # Verify same key by signing/verifying + from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + original_bytes = pub.public_bytes(Encoding.Raw, PublicFormat.Raw) + resolved_bytes = resolved.public_bytes(Encoding.Raw, PublicFormat.Raw) + assert original_bytes == resolved_bytes + + def test_invalid_prefix(self): + with pytest.raises(ACTKeyResolutionError): + resolve_did_key("did:web:example.com") + + def test_with_fragment(self): + _, pub = generate_ed25519_keypair() + did = did_key_from_ed25519(pub) + did_with_fragment = f"{did}#{did.split(':')[2]}" + resolved = resolve_did_key(did_with_fragment) + assert resolved is not None + + +class TestACTKeyResolver: + def test_tier1_resolution(self): + reg = KeyRegistry() + _, pub = generate_ed25519_keypair() + reg.register("my-key", pub) + resolver = ACTKeyResolver(registry=reg) + assert resolver.resolve("my-key") is pub + + def test_tier3_did_key(self): + _, pub = generate_ed25519_keypair() + did = did_key_from_ed25519(pub) + resolver = ACTKeyResolver() + resolved = resolver.resolve(did) + assert resolved is not None + + def test_unresolvable(self): + resolver = ACTKeyResolver() + with pytest.raises(ACTKeyResolutionError): + resolver.resolve("unknown-kid") + + def test_did_web_resolver_callback(self): + _, pub = generate_ed25519_keypair() + def resolver_cb(did: str): + if did == "did:web:example.com": + return pub + return None + resolver = ACTKeyResolver(did_web_resolver=resolver_cb) + result = resolver.resolve("did:web:example.com") + assert result is pub diff --git a/workspace/packages/act/tests/test_dag.py b/workspace/packages/act/tests/test_dag.py new file mode 100644 index 0000000..586a663 --- /dev/null +++ b/workspace/packages/act/tests/test_dag.py @@ -0,0 +1,103 @@ +"""Tests for act.dag module.""" + +import time + +import pytest + +from act.dag import validate_dag +from act.errors import ACTCapabilityError, ACTDAGError +from act.ledger import ACTLedger +from act.token import ACTRecord, Capability, TaskClaim + + +def make_record(jti, pred=None, exec_act="do.thing", exec_ts=None, cap=None): + """Helper to create a minimal ACTRecord.""" + return ACTRecord( + alg="EdDSA", kid="k", iss="a", sub="b", aud="b", + iat=1772064000, exp=1772064900, + jti=jti, + task=TaskClaim(purpose="t"), + cap=cap or [Capability(action="do.thing")], + exec_act=exec_act, + pred=pred or [], + exec_ts=exec_ts or 1772064100, + status="completed", + ) + + +class TestDAGValidation: + def test_root_task(self): + ledger = ACTLedger() + r = make_record("root-1") + validate_dag(r, ledger) + + def test_child_with_parent(self): + ledger = ACTLedger() + parent = make_record("parent-1", exec_ts=1772064050) + ledger.append(parent) + child = make_record("child-1", pred=["parent-1"], exec_ts=1772064100) + validate_dag(child, ledger) + + def test_fan_in(self): + ledger = ACTLedger() + p1 = make_record("p1", exec_ts=1772064050) + p2 = make_record("p2", exec_ts=1772064060) + ledger.append(p1) + ledger.append(p2) + child = make_record("child", pred=["p1", "p2"], exec_ts=1772064100) + validate_dag(child, ledger) + + def test_duplicate_jti(self): + ledger = ACTLedger() + r = make_record("dup-1") + ledger.append(r) + r2 = make_record("dup-1") + with pytest.raises(ACTDAGError, match="Duplicate"): + validate_dag(r2, ledger) + + def test_missing_parent(self): + ledger = ACTLedger() + r = make_record("orphan", pred=["nonexistent"]) + with pytest.raises(ACTDAGError, match="not found"): + validate_dag(r, ledger) + + def test_self_cycle(self): + ledger = ACTLedger() + r = make_record("cycle", pred=["cycle"]) + with pytest.raises(ACTDAGError, match="cycle"): + validate_dag(r, ledger) + + def test_indirect_cycle(self): + ledger = ACTLedger() + # a -> b -> a would be a cycle + a = make_record("a", pred=["b"], exec_ts=1772064100) + b = make_record("b", pred=["a"], exec_ts=1772064100) + ledger.append(b) + # When validating a, following pred leads to b, + # which has pred=["a"] — cycle! + with pytest.raises(ACTDAGError, match="cycle"): + validate_dag(a, ledger) + + def test_temporal_ordering_violation(self): + ledger = ACTLedger() + parent = make_record("parent", exec_ts=1772064200) + ledger.append(parent) + # Child's exec_ts is way before parent + child = make_record("child", pred=["parent"], exec_ts=1772064100) + with pytest.raises(ACTDAGError, match="Temporal"): + validate_dag(child, ledger) + + def test_temporal_within_tolerance(self): + ledger = ACTLedger() + parent = make_record("parent", exec_ts=1772064120) + ledger.append(parent) + # Child exec_ts is slightly before parent but within 30s tolerance + child = make_record("child", pred=["parent"], exec_ts=1772064100) + validate_dag(child, ledger) + + def test_bad_exec_act(self): + ledger = ACTLedger() + r = make_record("bad", exec_act="not.authorized", + cap=[Capability(action="do.thing")]) + with pytest.raises(ACTCapabilityError): + validate_dag(r, ledger) diff --git a/workspace/packages/act/tests/test_delegation.py b/workspace/packages/act/tests/test_delegation.py new file mode 100644 index 0000000..837ea85 --- /dev/null +++ b/workspace/packages/act/tests/test_delegation.py @@ -0,0 +1,229 @@ +"""Tests for act.delegation module.""" + +import time +import uuid + +import pytest + +from act.crypto import generate_ed25519_keypair, sign, verify, compute_sha256 +from act.delegation import ( + create_delegated_mandate, + verify_capability_subset, + verify_delegation_chain, +) +from act.errors import ( + ACTDelegationError, + ACTPrivilegeEscalationError, +) +from act.token import ( + ACTMandate, + Capability, + Delegation, + DelegationEntry, + TaskClaim, + _b64url_decode, + encode_jws, +) + + +@pytest.fixture +def parent_setup(): + iss_priv, iss_pub = generate_ed25519_keypair() + mandate = ACTMandate( + alg="EdDSA", kid="iss-key", + iss="agent-a", sub="agent-b", aud="agent-b", + iat=1772064000, exp=1772064900, + jti="parent-jti-1", + task=TaskClaim(purpose="parent_task"), + cap=[ + Capability(action="read.data", constraints={"max_records": 10}), + Capability(action="write.result"), + ], + delegation=Delegation(depth=0, max_depth=3, chain=[]), + ) + sig = sign(iss_priv, mandate.signing_input()) + compact = encode_jws(mandate, sig) + return mandate, compact, iss_priv, iss_pub + + +class TestCreateDelegatedMandate: + def test_basic_delegation(self, parent_setup): + mandate, compact, priv, _ = parent_setup + delegated, _ = create_delegated_mandate( + parent_mandate=mandate, parent_compact=compact, + delegator_private_key=priv, + sub="agent-c", kid="key-b", iss="agent-a", aud="agent-c", + iat=1772064010, exp=1772064600, + jti="child-jti-1", + cap=[Capability(action="read.data", constraints={"max_records": 5})], + task=TaskClaim(purpose="child_task"), + ) + assert delegated.delegation.depth == 1 + assert len(delegated.delegation.chain) == 1 + assert delegated.delegation.chain[0].delegator == "agent-a" + + def test_depth_exceeded(self, parent_setup): + mandate, compact, priv, _ = parent_setup + # Set parent to max depth + mandate.delegation = Delegation(depth=3, max_depth=3, chain=[ + DelegationEntry(delegator="x", jti="j", sig="s") + for _ in range(3) + ]) + with pytest.raises(ACTDelegationError, match="exceeds max_depth"): + create_delegated_mandate( + parent_mandate=mandate, parent_compact=compact, + delegator_private_key=priv, + sub="c", kid="k", iss="a", aud="c", + iat=1, exp=2, jti="j", + cap=[Capability(action="read.data", constraints={"max_records": 5})], + task=TaskClaim(purpose="t"), + ) + + def test_no_del_claim(self): + priv, _ = generate_ed25519_keypair() + mandate = ACTMandate( + alg="EdDSA", kid="k", iss="a", sub="b", aud="b", + iat=1, exp=2, + task=TaskClaim(purpose="t"), + cap=[Capability(action="x.y")], + delegation=None, # no del claim + ) + with pytest.raises(ACTDelegationError, match="not permitted"): + create_delegated_mandate( + parent_mandate=mandate, parent_compact="compact", + delegator_private_key=priv, + sub="c", kid="k", iss="a", aud="c", + iat=1, exp=2, jti="j", + cap=[Capability(action="x.y")], + task=TaskClaim(purpose="t"), + ) + + def test_max_depth_reduction(self, parent_setup): + mandate, compact, priv, _ = parent_setup + delegated, _ = create_delegated_mandate( + parent_mandate=mandate, parent_compact=compact, + delegator_private_key=priv, + sub="c", kid="k", iss="a", aud="c", + iat=1, exp=2, jti="j", + cap=[Capability(action="read.data", constraints={"max_records": 5})], + task=TaskClaim(purpose="t"), + max_depth=2, + ) + assert delegated.delegation.max_depth == 2 + + def test_max_depth_escalation(self, parent_setup): + mandate, compact, priv, _ = parent_setup + with pytest.raises(ACTDelegationError, match="exceeds parent max_depth"): + create_delegated_mandate( + parent_mandate=mandate, parent_compact=compact, + delegator_private_key=priv, + sub="c", kid="k", iss="a", aud="c", + iat=1, exp=2, jti="j", + cap=[Capability(action="read.data", constraints={"max_records": 5})], + task=TaskClaim(purpose="t"), + max_depth=10, + ) + + +class TestCapabilitySubset: + def test_valid_subset(self): + parent = [Capability(action="read.data", constraints={"max_records": 10})] + child = [Capability(action="read.data", constraints={"max_records": 5})] + verify_capability_subset(parent, child) + + def test_extra_action(self): + parent = [Capability(action="read.data")] + child = [Capability(action="delete.data")] + with pytest.raises(ACTPrivilegeEscalationError): + verify_capability_subset(parent, child) + + def test_numeric_escalation(self): + parent = [Capability(action="read.data", constraints={"max_records": 10})] + child = [Capability(action="read.data", constraints={"max_records": 100})] + with pytest.raises(ACTPrivilegeEscalationError): + verify_capability_subset(parent, child) + + def test_sensitivity_escalation(self): + parent = [Capability(action="read.data", + constraints={"data_sensitivity": "confidential"})] + child = [Capability(action="read.data", + constraints={"data_sensitivity": "internal"})] + with pytest.raises(ACTPrivilegeEscalationError): + verify_capability_subset(parent, child) + + def test_sensitivity_more_restrictive(self): + parent = [Capability(action="read.data", + constraints={"data_sensitivity": "internal"})] + child = [Capability(action="read.data", + constraints={"data_sensitivity": "restricted"})] + verify_capability_subset(parent, child) # should pass + + def test_missing_constraint(self): + parent = [Capability(action="read.data", + constraints={"max_records": 10, "scope": "local"})] + child = [Capability(action="read.data", + constraints={"max_records": 5})] + with pytest.raises(ACTPrivilegeEscalationError, match="missing"): + verify_capability_subset(parent, child) + + def test_domain_specific_identical(self): + parent = [Capability(action="read.data", + constraints={"custom": "value_a"})] + child = [Capability(action="read.data", + constraints={"custom": "value_a"})] + verify_capability_subset(parent, child) + + def test_domain_specific_different(self): + parent = [Capability(action="read.data", + constraints={"custom": "value_a"})] + child = [Capability(action="read.data", + constraints={"custom": "value_b"})] + with pytest.raises(ACTPrivilegeEscalationError, match="identical"): + verify_capability_subset(parent, child) + + +class TestVerifyDelegationChain: + def test_chain_sig_verification(self, parent_setup): + mandate, compact, priv, pub = parent_setup + delegated, _ = create_delegated_mandate( + parent_mandate=mandate, parent_compact=compact, + delegator_private_key=priv, + sub="c", kid="k", iss="agent-a", aud="c", + iat=1, exp=2, jti="j", + cap=[Capability(action="read.data", constraints={"max_records": 5})], + task=TaskClaim(purpose="t"), + ) + + # Verify the chain + def resolve_key(delegator_id): + return pub + + def resolve_compact(jti): + if jti == "parent-jti-1": + return compact + return None + + verify_delegation_chain(delegated, resolve_key, resolve_compact) + + def test_no_delegation(self): + mandate = ACTMandate( + alg="EdDSA", kid="k", iss="a", sub="b", aud="b", + iat=1, exp=2, + task=TaskClaim(purpose="t"), + cap=[Capability(action="x.y")], + ) + verify_delegation_chain(mandate, lambda x: None) # no-op + + def test_depth_exceeds_max(self): + mandate = ACTMandate( + alg="EdDSA", kid="k", iss="a", sub="b", aud="b", + iat=1, exp=2, + task=TaskClaim(purpose="t"), + cap=[Capability(action="x.y")], + delegation=Delegation(depth=5, max_depth=3, chain=[ + DelegationEntry(delegator="x", jti="j", sig="s") + for _ in range(5) + ]), + ) + with pytest.raises(ACTDelegationError, match="exceeds"): + verify_delegation_chain(mandate, lambda x: None) diff --git a/workspace/packages/act/tests/test_ledger.py b/workspace/packages/act/tests/test_ledger.py new file mode 100644 index 0000000..c38d750 --- /dev/null +++ b/workspace/packages/act/tests/test_ledger.py @@ -0,0 +1,84 @@ +"""Tests for act.ledger module.""" + +import pytest + +from act.errors import ACTLedgerImmutabilityError +from act.ledger import ACTLedger +from act.token import ACTRecord, Capability, TaskClaim + + +def make_record(jti, wid=None): + return ACTRecord( + alg="EdDSA", kid="k", iss="a", sub="b", aud="b", + iat=1772064000, exp=1772064900, + jti=jti, wid=wid, + task=TaskClaim(purpose="t"), + cap=[Capability(action="do.thing")], + exec_act="do.thing", pred=[], exec_ts=1772064100, + status="completed", + ) + + +class TestACTLedger: + def test_append_and_get(self): + ledger = ACTLedger() + r = make_record("jti-1") + seq = ledger.append(r) + assert seq == 0 + assert ledger.get("jti-1") is r + + def test_sequential_ordering(self): + ledger = ACTLedger() + for i in range(5): + seq = ledger.append(make_record(f"jti-{i}")) + assert seq == i + + def test_duplicate_rejected(self): + ledger = ACTLedger() + ledger.append(make_record("jti-1")) + with pytest.raises(ACTLedgerImmutabilityError): + ledger.append(make_record("jti-1")) + + def test_get_missing(self): + ledger = ACTLedger() + assert ledger.get("missing") is None + + def test_list_all(self): + ledger = ACTLedger() + ledger.append(make_record("a")) + ledger.append(make_record("b")) + records = ledger.list() + assert len(records) == 2 + + def test_list_by_wid(self): + ledger = ACTLedger() + ledger.append(make_record("a", wid="w1")) + ledger.append(make_record("b", wid="w2")) + ledger.append(make_record("c", wid="w1")) + assert len(ledger.list("w1")) == 2 + assert len(ledger.list("w2")) == 1 + assert len(ledger.list("w3")) == 0 + + def test_verify_integrity_empty(self): + ledger = ACTLedger() + assert ledger.verify_integrity() is True + + def test_verify_integrity_with_records(self): + ledger = ACTLedger() + for i in range(10): + ledger.append(make_record(f"jti-{i}")) + assert ledger.verify_integrity() is True + + def test_verify_integrity_tampered(self): + ledger = ACTLedger() + ledger.append(make_record("jti-1")) + ledger.append(make_record("jti-2")) + # Tamper with chain hash + ledger._chain_hashes[0] = b"\x00" * 32 + assert ledger.verify_integrity() is False + + def test_len(self): + ledger = ACTLedger() + assert len(ledger) == 0 + ledger.append(make_record("a")) + assert len(ledger) == 1 diff --git a/workspace/packages/act/tests/test_lifecycle.py b/workspace/packages/act/tests/test_lifecycle.py new file mode 100644 index 0000000..7e00852 --- /dev/null +++ b/workspace/packages/act/tests/test_lifecycle.py @@ -0,0 +1,103 @@ +"""Tests for act.lifecycle module.""" + +import time +import uuid + +import pytest + +from act.crypto import generate_ed25519_keypair, sign +from act.errors import ACTCapabilityError, ACTPhaseError +from act.lifecycle import transition_to_record +from act.token import ( + ACTMandate, + ACTRecord, + Capability, + Delegation, + ErrorClaim, + TaskClaim, + decode_jws, + encode_jws, +) +from act.crypto import verify + + +@pytest.fixture +def keys(): + iss_priv, iss_pub = generate_ed25519_keypair() + sub_priv, sub_pub = generate_ed25519_keypair() + return iss_priv, iss_pub, sub_priv, sub_pub + + +@pytest.fixture +def mandate(keys): + iss_priv, _, _, _ = keys + m = ACTMandate( + alg="EdDSA", kid="iss-key", + iss="agent-a", sub="agent-b", aud="agent-b", + iat=1772064000, exp=1772064900, + jti=str(uuid.uuid4()), + task=TaskClaim(purpose="test"), + cap=[Capability(action="read.data"), Capability(action="write.result")], + delegation=Delegation(depth=0, max_depth=2, chain=[]), + ) + return m + + +class TestTransitionToRecord: + def test_basic_transition(self, mandate, keys): + _, _, sub_priv, sub_pub = keys + record, compact = transition_to_record( + mandate, sub_kid="sub-key", sub_private_key=sub_priv, + exec_act="read.data", pred=[], status="completed", + ) + assert isinstance(record, ACTRecord) + assert record.exec_act == "read.data" + assert record.kid == "sub-key" + assert record.iss == mandate.iss # preserved + # Verify signature + _, _, sig, si = decode_jws(compact) + verify(sub_pub, sig, si) + + def test_with_hashes(self, mandate, keys): + _, _, sub_priv, _ = keys + record, _ = transition_to_record( + mandate, sub_kid="k", sub_private_key=sub_priv, + exec_act="write.result", pred=[], status="completed", + inp_hash="abc", out_hash="def", + ) + assert record.inp_hash == "abc" + assert record.out_hash == "def" + + def test_with_error(self, mandate, keys): + _, _, sub_priv, _ = keys + record, _ = transition_to_record( + mandate, sub_kid="k", sub_private_key=sub_priv, + exec_act="read.data", pred=[], status="failed", + err=ErrorClaim(code="timeout", detail="request timed out"), + ) + assert record.status == "failed" + assert record.err is not None + assert record.err.code == "timeout" + + def test_rejects_bad_exec_act(self, mandate, keys): + _, _, sub_priv, _ = keys + with pytest.raises(ACTCapabilityError): + transition_to_record( + mandate, sub_kid="k", sub_private_key=sub_priv, + exec_act="delete.everything", pred=[], + ) + + def test_preserves_phase1_claims(self, mandate, keys): + _, _, sub_priv, _ = keys + record, _ = transition_to_record( + mandate, sub_kid="k", sub_private_key=sub_priv, + exec_act="read.data", pred=[], status="completed", + ) + assert record.iss == mandate.iss + assert record.sub == mandate.sub + assert record.aud == mandate.aud + assert record.iat == mandate.iat + assert record.exp == mandate.exp + assert record.jti == mandate.jti + assert record.task == mandate.task + assert record.cap == mandate.cap diff --git a/workspace/packages/act/tests/test_token.py b/workspace/packages/act/tests/test_token.py new file mode 100644 index 0000000..7b94de9 --- /dev/null +++ b/workspace/packages/act/tests/test_token.py @@ -0,0 +1,244 @@ +"""Tests for act.token module.""" + +import json +import time +import uuid + +import pytest + +from act.token import ( + ACTMandate, + ACTRecord, + Capability, + Delegation, + DelegationEntry, + ErrorClaim, + Oversight, + TaskClaim, + _b64url_decode, + _b64url_encode, + decode_jws, + encode_jws, + parse_token, + validate_action_name, +) +from act.errors import ACTPhaseError, ACTValidationError + + +@pytest.fixture +def base_time(): + return 1772064000 + + +@pytest.fixture +def mandate(base_time): + return ACTMandate( + alg="EdDSA", + kid="test-key", + iss="agent-a", + sub="agent-b", + aud="agent-b", + iat=base_time, + exp=base_time + 900, + jti=str(uuid.uuid4()), + task=TaskClaim(purpose="test_task"), + cap=[Capability(action="read.data")], + ) + + +class TestBase64url: + def test_roundtrip(self): + data = b"hello world" + assert _b64url_decode(_b64url_encode(data)) == data + + def test_no_padding(self): + encoded = _b64url_encode(b"test") + assert "=" not in encoded + + +class TestActionNameValidation: + def test_valid_simple(self): + validate_action_name("read") + + def test_valid_dotted(self): + validate_action_name("read.data") + + def test_valid_with_hyphens(self): + validate_action_name("read-write.data_item") + + def test_invalid_starts_with_digit(self): + with pytest.raises(ACTValidationError): + validate_action_name("1read") + + def test_invalid_empty(self): + with pytest.raises(ACTValidationError): + validate_action_name("") + + def test_invalid_double_dot(self): + with pytest.raises(ACTValidationError): + validate_action_name("read..data") + + +class TestTaskClaim: + def test_roundtrip(self): + t = TaskClaim(purpose="test", data_sensitivity="restricted") + d = t.to_dict() + t2 = TaskClaim.from_dict(d) + assert t == t2 + + def test_missing_purpose(self): + with pytest.raises(ACTValidationError): + TaskClaim.from_dict({}) + + +class TestCapability: + def test_roundtrip(self): + c = Capability(action="read.data", constraints={"max": 10}) + d = c.to_dict() + c2 = Capability.from_dict(d) + assert c == c2 + + def test_validates_action(self): + with pytest.raises(ACTValidationError): + Capability(action="") + + +class TestDelegation: + def test_roundtrip(self): + d = Delegation( + depth=1, + max_depth=3, + chain=[DelegationEntry(delegator="a", jti="j1", sig="sig1")], + ) + as_dict = d.to_dict() + d2 = Delegation.from_dict(as_dict) + assert d.depth == d2.depth + assert len(d2.chain) == 1 + + +class TestACTMandate: + def test_validate_success(self, mandate): + mandate.validate() + + def test_validate_missing_iss(self, base_time): + m = ACTMandate( + alg="EdDSA", kid="k", iss="", sub="b", aud="b", + iat=base_time, exp=base_time + 900, + task=TaskClaim(purpose="t"), cap=[Capability(action="x.y")], + ) + with pytest.raises(ACTValidationError, match="iss"): + m.validate() + + def test_validate_forbidden_alg(self, base_time): + m = ACTMandate( + alg="HS256", kid="k", iss="a", sub="b", aud="b", + iat=base_time, exp=base_time + 900, + task=TaskClaim(purpose="t"), cap=[Capability(action="x.y")], + ) + with pytest.raises(ACTValidationError): + m.validate() + + def test_validate_alg_none(self, base_time): + m = ACTMandate( + alg="none", kid="k", iss="a", sub="b", aud="b", + iat=base_time, exp=base_time + 900, + task=TaskClaim(purpose="t"), cap=[Capability(action="x.y")], + ) + with pytest.raises(ACTValidationError): + m.validate() + + def test_to_claims_includes_optional(self, base_time): + m = ACTMandate( + alg="EdDSA", kid="k", iss="a", sub="b", aud="b", + iat=base_time, exp=base_time + 900, + task=TaskClaim(purpose="t"), cap=[Capability(action="x.y")], + wid="w-1", + oversight=Oversight(requires_approval_for=["x.y"]), + ) + claims = m.to_claims() + assert claims["wid"] == "w-1" + assert "oversight" in claims + + def test_is_phase2(self, mandate): + assert mandate.is_phase2() is False + + def test_from_claims_rejects_phase2(self): + with pytest.raises(ACTPhaseError): + ACTMandate.from_claims( + {"alg": "EdDSA", "typ": "act+jwt", "kid": "k"}, + {"exec_act": "x", "iss": "a", "sub": "b", "aud": "b", + "iat": 1, "exp": 2, "jti": "j", + "task": {"purpose": "t"}, "cap": [{"action": "x"}]}, + ) + + +class TestACTRecord: + def test_from_mandate(self, mandate): + r = ACTRecord.from_mandate( + mandate, kid="sub-key", exec_act="read.data", + pred=[], status="completed", + ) + assert r.iss == mandate.iss + assert r.exec_act == "read.data" + assert r.kid == "sub-key" + + def test_validate_bad_status(self, mandate): + r = ACTRecord.from_mandate( + mandate, kid="k", exec_act="read.data", + pred=[], exec_ts=mandate.iat + 100, status="invalid", + ) + with pytest.raises(ACTValidationError, match="status"): + r.validate() + + def test_is_phase2(self, mandate): + r = ACTRecord.from_mandate( + mandate, kid="k", exec_act="read.data", + pred=[], status="completed", + ) + assert r.is_phase2() is True + + def test_from_claims_rejects_phase1(self): + with pytest.raises(ACTPhaseError): + ACTRecord.from_claims( + {"alg": "EdDSA", "typ": "act+jwt", "kid": "k"}, + {"iss": "a", "sub": "b", "aud": "b", + "iat": 1, "exp": 2, "jti": "j", + "task": {"purpose": "t"}, "cap": [{"action": "x"}]}, + ) + + +class TestJWSSerialization: + def test_decode_invalid_parts(self): + with pytest.raises(ACTValidationError): + decode_jws("only.two") + + def test_decode_invalid_header(self): + with pytest.raises(ACTValidationError): + decode_jws("!!!.cGF5bG9hZA.c2ln") + + def test_decode_wrong_typ(self): + header = _b64url_encode(json.dumps({"alg": "EdDSA", "typ": "jwt", "kid": "k"}).encode()) + payload = _b64url_encode(json.dumps({"iss": "a"}).encode()) + sig = _b64url_encode(b"sig") + with pytest.raises(ACTValidationError, match="typ"): + decode_jws(f"{header}.{payload}.{sig}") + + def test_parse_token_phase1(self, mandate): + from act.crypto import generate_ed25519_keypair, sign + priv, pub = generate_ed25519_keypair() + sig = sign(priv, mandate.signing_input()) + compact = encode_jws(mandate, sig) + parsed = parse_token(compact) + assert isinstance(parsed, ACTMandate) + + def test_parse_token_phase2(self, mandate): + from act.crypto import generate_ed25519_keypair, sign + priv, pub = generate_ed25519_keypair() + record = ACTRecord.from_mandate( + mandate, kid="k", exec_act="read.data", + pred=[], status="completed", + ) + sig = sign(priv, record.signing_input()) + compact = encode_jws(record, sig) + parsed = parse_token(compact) + assert isinstance(parsed, ACTRecord) diff --git a/workspace/packages/act/tests/test_vectors.py b/workspace/packages/act/tests/test_vectors.py new file mode 100644 index 0000000..fd9cfee --- /dev/null +++ b/workspace/packages/act/tests/test_vectors.py @@ -0,0 +1,35 @@ +"""Tests for act.vectors module — Appendix B test vectors.""" + +import pytest + +from act.vectors import generate_vectors, validate_vectors + + +class TestVectorGeneration: + def test_generates_15_vectors(self): + vectors, ctx = generate_vectors() + assert len(vectors) == 15 + + def test_vector_ids(self): + vectors, _ = generate_vectors() + ids = [v.id for v in vectors] + expected = [f"B.{i}" for i in range(1, 16)] + assert ids == expected + + def test_valid_vectors_have_compact(self): + vectors, _ = generate_vectors() + for v in vectors: + if v.valid and v.id != "B.7": + assert v.compact, f"{v.id} should have compact" + + def test_invalid_vectors_have_exception(self): + vectors, _ = generate_vectors() + for v in vectors: + if not v.valid: + assert v.expected_exception is not None, \ + f"{v.id} should have expected_exception" + + +class TestVectorValidation: + def test_all_vectors_pass(self): + assert validate_vectors() is True diff --git a/workspace/packages/act/tests/test_verify.py b/workspace/packages/act/tests/test_verify.py new file mode 100644 index 0000000..9606506 --- /dev/null +++ b/workspace/packages/act/tests/test_verify.py @@ -0,0 +1,191 @@ +"""Tests for act.verify module.""" + +import time +import uuid + +import pytest + +from act.crypto import ( + ACTKeyResolver, + KeyRegistry, + generate_ed25519_keypair, + sign, +) +from act.errors import ( + ACTAudienceMismatchError, + ACTCapabilityError, + ACTExpiredError, + ACTPhaseError, + ACTSignatureError, + ACTValidationError, +) +from act.ledger import ACTLedger +from act.lifecycle import transition_to_record +from act.token import ( + ACTMandate, + ACTRecord, + Capability, + Delegation, + TaskClaim, + encode_jws, +) +from act.verify import ACTVerifier + + +@pytest.fixture +def setup(): + iss_priv, iss_pub = generate_ed25519_keypair() + sub_priv, sub_pub = generate_ed25519_keypair() + registry = KeyRegistry() + registry.register("iss-key", iss_pub) + registry.register("sub-key", sub_pub) + resolver = ACTKeyResolver(registry=registry) + base_time = 1772064000 + return { + "iss_priv": iss_priv, "iss_pub": iss_pub, + "sub_priv": sub_priv, "sub_pub": sub_pub, + "registry": registry, "resolver": resolver, + "base_time": base_time, + } + + +def make_mandate(setup, **overrides): + bt = setup["base_time"] + defaults = dict( + alg="EdDSA", kid="iss-key", + iss="agent-issuer", sub="agent-subject", + aud="agent-subject", + iat=bt, exp=bt + 900, + jti=str(uuid.uuid4()), + task=TaskClaim(purpose="test"), + cap=[Capability(action="read.data")], + ) + defaults.update(overrides) + return ACTMandate(**defaults) + + +def sign_mandate(mandate, priv_key): + sig = sign(priv_key, mandate.signing_input()) + return encode_jws(mandate, sig) + + +class TestVerifyMandate: + def test_valid_mandate(self, setup): + verifier = ACTVerifier( + setup["resolver"], + verifier_id="agent-subject", + trusted_issuers={"agent-issuer"}, + ) + mandate = make_mandate(setup) + compact = sign_mandate(mandate, setup["iss_priv"]) + result = verifier.verify_mandate(compact, now=setup["base_time"] + 100) + assert result.iss == "agent-issuer" + + def test_expired(self, setup): + verifier = ACTVerifier(setup["resolver"], verifier_id="agent-subject") + mandate = make_mandate(setup) + compact = sign_mandate(mandate, setup["iss_priv"]) + with pytest.raises(ACTExpiredError): + verifier.verify_mandate(compact, now=setup["base_time"] + 2000) + + def test_wrong_audience(self, setup): + verifier = ACTVerifier( + setup["resolver"], verifier_id="other-agent", + trusted_issuers={"agent-issuer"}, + ) + mandate = make_mandate(setup) + compact = sign_mandate(mandate, setup["iss_priv"]) + with pytest.raises(ACTAudienceMismatchError): + verifier.verify_mandate( + compact, now=setup["base_time"] + 100, check_sub=False, + ) + + def test_untrusted_issuer(self, setup): + verifier = ACTVerifier( + setup["resolver"], verifier_id="agent-subject", + trusted_issuers={"trusted-only"}, + ) + mandate = make_mandate(setup) + compact = sign_mandate(mandate, setup["iss_priv"]) + with pytest.raises(ACTValidationError, match="not trusted"): + verifier.verify_mandate(compact, now=setup["base_time"] + 100) + + def test_signature_failure(self, setup): + verifier = ACTVerifier(setup["resolver"], verifier_id="agent-subject") + mandate = make_mandate(setup) + compact = sign_mandate(mandate, setup["iss_priv"]) + # Tamper with signature + parts = compact.split(".") + parts[2] = parts[2][:-4] + "XXXX" + tampered = ".".join(parts) + with pytest.raises(ACTSignatureError): + verifier.verify_mandate(tampered, now=setup["base_time"] + 100) + + def test_phase2_as_mandate(self, setup): + verifier = ACTVerifier(setup["resolver"]) + mandate = make_mandate(setup) + record, compact = transition_to_record( + mandate, sub_kid="sub-key", sub_private_key=setup["sub_priv"], + exec_act="read.data", pred=[], status="completed", + exec_ts=setup["base_time"] + 100, + ) + with pytest.raises(ACTPhaseError): + verifier.verify_mandate(compact, now=setup["base_time"] + 100) + + def test_future_iat(self, setup): + verifier = ACTVerifier(setup["resolver"], verifier_id="agent-subject") + bt = setup["base_time"] + mandate = make_mandate(setup, iat=bt + 1000, exp=bt + 2000) + compact = sign_mandate(mandate, setup["iss_priv"]) + with pytest.raises(ACTValidationError, match="future"): + verifier.verify_mandate(compact, now=bt) + + +class TestVerifyRecord: + def test_valid_record(self, setup): + verifier = ACTVerifier( + setup["resolver"], + verifier_id="agent-subject", + trusted_issuers={"agent-issuer"}, + ) + mandate = make_mandate(setup) + record, compact = transition_to_record( + mandate, sub_kid="sub-key", sub_private_key=setup["sub_priv"], + exec_act="read.data", pred=[], + exec_ts=setup["base_time"] + 100, status="completed", + ) + result = verifier.verify_record( + compact, now=setup["base_time"] + 200, check_aud=False, + ) + assert result.exec_act == "read.data" + + def test_wrong_signer(self, setup): + verifier = ACTVerifier(setup["resolver"]) + mandate = make_mandate(setup) + record = ACTRecord.from_mandate( + mandate, kid="sub-key", exec_act="read.data", + pred=[], exec_ts=setup["base_time"] + 100, status="completed", + ) + # Sign with iss key instead of sub key + sig = sign(setup["iss_priv"], record.signing_input()) + compact = encode_jws(record, sig) + with pytest.raises(ACTSignatureError): + verifier.verify_record(compact, now=setup["base_time"] + 200) + + def test_with_dag_validation(self, setup): + verifier = ACTVerifier( + setup["resolver"], verifier_id="agent-subject", + trusted_issuers={"agent-issuer"}, + ) + ledger = ACTLedger() + mandate = make_mandate(setup) + record, compact = transition_to_record( + mandate, sub_kid="sub-key", sub_private_key=setup["sub_priv"], + exec_act="read.data", pred=[], + exec_ts=setup["base_time"] + 100, status="completed", + ) + result = verifier.verify_record( + compact, store=ledger, + now=setup["base_time"] + 200, check_aud=False, + ) + assert result.status == "completed" diff --git a/workspace/packages/ect/.coverage b/workspace/packages/ect/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..e551425648e77dfa30abcf27b80702e772474e11 GIT binary patch literal 53248 zcmeI4-)|h%6~||GW_Na+U0(+`S{Xxq9{|U~_ST}MzaD5>Tu4F%X;Z{Yv&`<^wa3|+ z+04wwF=`tvMFRDuKc1=bCsaI;7V!`y-VhRPd7()C@zjDTMHJeqGLn1lotgcS#3=Gg zv53B--T85UoH^%n&pmhU&dz-A)FW;zcq<4!C+7EQQ<|=8-{4%+G@HI<`eutoJ7#u3 zTYYGMuU%VP{^@OH_PACmJfX2O<=fd(X|43(q326qvA=)FwHEDV%D@5wAOHd&aLW+r zohn(CnHl}*AH`0+E#lB=h%i}~zxCbKW2aa7>0{q~WR)jr{OAOyWod~Y<6*GLJ0j#Q zw=KBqH{FI4yZ#!F*F{#k8;PbIvnY*5Em?3`)t7rUw@I~Pu|^^~q3b!}7C$St7FB`t zY_YfyCkZ4(xNE*F!H=fZ7kDUIA{2f@M2Qwh-R8o?!cQ(vSe5B%{aK-Ga>53E4vknt z2?JA(w@6SU2%A#-dg%C#brCIcr*B~+AU!X{iM3(iRpWrSU0<2$M{eu}J{K26qZ^Cn zbq!pU(6WW#4O>I9o|FbXnbb_rHAH7n`8qYz;%Vg;Ddz_n)C&m@bmKrB*QoJpE3_&-bGT?#=H~Qg&nZWdcxrVwj`}1;e@iI@gOZonjWaPNo7d~ zUT+8WQD0LY6A9`d6brIqt*+mYT_ut>G=lRYbk@Xjnwx}$gLWixofW%KS)JR{+H&IdVkrB|SpMCHc*d7$H*zUEr#L;KtSOIPp5aR(F|9iqGc@ zm9HP!OL3_}ZAGpN&rIc%%Fh{!zDGe{evJ{8Wy(P1lVWS>ywj#=(sA67x-7-uWCY-{ zVAG+(PCe+x%75jda!)z-4D?L|430DQRBAyLr|$h&5JfHxt8&{AucRS^uIP&vX1{I$p_P zIFU-RUv^G;o+OA+MLtiRGNLYemCQ_Nnkyq#CV#m@Or%uLX(ruWHp-C1&2@2&xMVbX z(1}ItdVBj6#`X7KVFR$FU z{G|!9D#zZ@*gNdspGgCuGzfqI2!H?xfB*=900@8p2!H?xfIy!>$+%O`eg?=*7_)Zz zD}cQJZg-ZTOAQe+w}2ltJPfJjcYX$CGY>+Wlv+5 z%P+CJCr_5W$sF@1-=6%%KAjxZKmY_l00ck)1V8`;KmY_l00cDcq}iL%_dop`p8wkq zo4x5VWcBC&#W4l-=l|BK*(;BsQ8NE8jHyn4{+~Z#_9|njGc^A<$JD7m|2G~odxyu+ zDVzW2j+1Tn{vBy({*U+nAuxde2!H?xfB*=900@8p2!H?xfWR$Hz|ak?Ebss8>=TVX zSU>;-KmY_l00ck)1V8`;KmY_l00eG90)|nx)${-CjK==M{?7iuuCN!_uh?^Ji;}Q_ z00@8p2!H?xfB*=900@8p2!H?x+z^2o+tlqZ=C|Mf;lFmHr`j7=Ub_5&b@{PB|Lm9f za*?v`Ft`8o<98pmU;q5HMTbXo+poWHZui$0cYm{csrv)#wgM&IV{IS#=;HmYf9&jD zd3nbu z{`Zt#&QanW#f?i>-kAI7jR%UlY1^NhDwq}Z{6BkDV>|5k?0xncdzJl-y~RFef4Lz8 zQ3wP;00ck)1V8`;KmY_l00ck)1VG^C5Rg9-=y_Xh%%a-lEVZ!;YGdcsreLa#VW>?p zXVPx~>i)krb#oNOSwH{;KmY_l00ck)1V8`;KmY_l00a((fV}^Y`TxPN$5}xD1V8`; zKmY_l00ck)1V8`;K;UK(kk9{P{(rM*<2)b$0w4eaAOHd&00JNY0w4eaAaF1Q{tu`J BU^)N* literal 0 HcmV?d00001 diff --git a/workspace/packages/ect/README.md b/workspace/packages/ect/README.md new file mode 100644 index 0000000..8085692 --- /dev/null +++ b/workspace/packages/ect/README.md @@ -0,0 +1,111 @@ +# WIMSE ECT — Python Reference Implementation + +Python reference implementation of [Execution Context Tokens (ECTs)](../../draft-nennemann-wimse-execution-context-01.txt) for WIMSE. Implements ECT creation (ES256), verification (Section 7), DAG validation (Section 6), and an in-memory audit ledger (Section 9). + +## Layout + +``` +python/ +├── pyproject.toml +├── ect/ # library +│ ├── __init__.py +│ ├── types.py # Payload, constants +│ ├── create.py # create(), generate_key() +│ ├── verify.py # parse(), verify(), VerifyOptions +│ ├── dag.py # validate_dag(), ECTStore, DAGConfig +│ ├── ledger.py # Ledger, MemoryLedger +│ ├── config.py # Config, load_config_from_env() +│ ├── jti_cache.py # JTICache for replay protection +│ └── validate.py # validate_ext, valid_uuid, validate_hash_format +├── tests/ +│ ├── test_create.py +│ └── test_dag.py +├── testdata/ +│ └── valid_root_ect_payload.json +└── demo.py # two-agent workflow demo +``` + +## Install + +```bash +cd refimpl/python && pip install -e . +``` + +## Usage + +```python +from ect import ( + Payload, + create, + generate_key, + CreateOptions, + verify, + VerifyOptions, + MemoryLedger, +) + +cfg = load_config_from_env() +key = generate_key() +payload = Payload( + iss="spiffe://example.com/agent/a", + aud=["spiffe://example.com/agent/b"], + iat=int(time.time()), + exp=int(time.time()) + 600, + jti="550e8400-e29b-41d4-a716-446655440000", + exec_act="review_spec", + pred=[], + ext={ + "pol": "policy_v1", + "pol_decision": "approved", + }, +) +compact = create(payload, key, cfg.create_options("agent-a-key")) + +store = MemoryLedger() +opts = cfg.verify_options() +opts.verifier_id = "spiffe://example.com/agent/b" +opts.resolve_key = lambda kid: key.public_key() if kid == "agent-a-key" else None +opts.store = store +parsed = verify(compact, opts) +store.append(compact, parsed.payload) +``` + +## Demo + +```bash +cd refimpl/python && python3 demo.py +``` + +## Tests + +```bash +cd refimpl/python && python3 -m pytest tests/ -v +``` + +Unit tests require **90% coverage** minimum (`pytest` is configured with `--cov-fail-under=90` in `pyproject.toml`). Install dev deps: `pip install -e ".[dev]"`. Uncovered lines are mainly abstract base methods and a few verify branches that need manually built tokens. + +## draft-01 claim changes + +| -00 (previous) | -01 (current) | Notes | +|----------------|---------------|-------| +| `par` | `pred` | Predecessor task IDs | +| `pol`, `pol_decision` | removed (use `ect_ext`) | Policy claims moved to extension object | +| `sub` | not defined | Standard JWT claim, not part of ECT spec | +| `typ: wimse-exec+jwt` | `typ: exec+jwt` (preferred) | Both accepted for backward compat | +| `max_par_length` | `max_pred_length` | Renamed to match `pred` claim | + +## Production configuration (environment) + +Same env vars as the Go refimpl: `ECT_IAT_MAX_AGE_MINUTES`, `ECT_IAT_MAX_FUTURE_SEC`, `ECT_DEFAULT_EXPIRY_MIN`, `ECT_JTI_REPLAY_CACHE_SIZE`, `ECT_JTI_REPLAY_TTL_MIN`. + +### Replay cache (multi-instance) + +The provided JTI cache is in-memory only. For multiple verifier instances, use a shared store (Redis, DB) and pass a `jti_seen` callable that checks/records JTIs there. See refimpl/README for an overview. + +## Dependencies + +- PyJWT, cryptography (ES256). + +## License + +Same as the Internet-Draft (IETF Trust). Code under Revised BSD per BCP 78/79. diff --git a/workspace/packages/ect/demo.py b/workspace/packages/ect/demo.py new file mode 100644 index 0000000..0d6e37b --- /dev/null +++ b/workspace/packages/ect/demo.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Two-agent ECT workflow demo: Agent A creates root ECT, Agent B verifies and creates child.""" + +import time + +from ect import ( + Payload, + create, + generate_key, + CreateOptions, + verify, + VerifyOptions, + MemoryLedger, +) + +def main(): + ledger = MemoryLedger() + now = int(time.time()) + + key_a = generate_key() + agent_a = "spiffe://example.com/agent/spec-reviewer" + agent_b = "spiffe://example.com/agent/implementer" + kid_a = "agent-a-key" + + # 1) Agent A creates root ECT (task id = jti per spec) + root_jti = "550e8400-e29b-41d4-a716-446655440001" + payload_a = Payload( + iss=agent_a, + aud=[agent_b], + iat=now, + exp=now + 600, + jti=root_jti, + wid="wf-demo-001", + exec_act="review_requirements_spec", + pred=[], + ext={ + "pol": "spec_review_policy_v2", + "pol_decision": "approved", + }, + ) + ect_a = create(payload_a, key_a, CreateOptions(key_id=kid_a)) + print("Agent A created root ECT (jti=550e8400-..., review_requirements_spec)") + + # 2) Agent B verifies + def resolve_key(kid): + if kid == kid_a: + return key_a.public_key() + return None + + opts = VerifyOptions( + verifier_id=agent_b, + resolve_key=resolve_key, + store=ledger, + now=now, + ) + parsed = verify(ect_a, opts) + ledger.append(ect_a, parsed.payload) + print("Agent B verified root ECT and appended to ledger") + + # 3) Agent B creates child ECT (pred contains predecessor jti values per spec) + key_b = generate_key() + kid_b = "agent-b-key" + child_jti = "550e8400-e29b-41d4-a716-446655440002" + payload_b = Payload( + iss=agent_b, + aud=["spiffe://example.com/system/ledger"], + iat=now + 1, + exp=now + 600, + jti=child_jti, + wid="wf-demo-001", + exec_act="implement_module", + pred=[root_jti], + ext={ + "pol": "coding_standards_v3", + "pol_decision": "approved", + }, + ) + ect_b = create(payload_b, key_b, CreateOptions(key_id=kid_b)) + print("Agent B created child ECT (jti=550e8400-...002, implement_module, pred=[predecessor jti])") + + # 4) Verify child ECT with DAG + def resolver_b(kid): + if kid == kid_b: + return key_b.public_key() + if kid == kid_a: + return key_a.public_key() + return None + + opts_b = VerifyOptions( + verifier_id="spiffe://example.com/system/ledger", + resolve_key=resolver_b, + store=ledger, + now=now + 2, + ) + parsed_b = verify(ect_b, opts_b) + ledger.append(ect_b, parsed_b.payload) + print("Verified child ECT with DAG validation and appended to ledger") + print(f"Ledger entries: {parsed.payload.jti} ({parsed.payload.exec_act}), {parsed_b.payload.jti} ({parsed_b.payload.exec_act})") + + +if __name__ == "__main__": + main() diff --git a/workspace/packages/ect/ect/__init__.py b/workspace/packages/ect/ect/__init__.py new file mode 100644 index 0000000..7c25ac6 --- /dev/null +++ b/workspace/packages/ect/ect/__init__.py @@ -0,0 +1,55 @@ +# WIMSE Execution Context Tokens (ECT) — Python reference implementation +# draft-nennemann-wimse-execution-context-01 + +from ect.types import ( + ECT_TYPE, + ECT_TYPE_LEGACY, + Payload, +) +from ect.create import create, generate_key, CreateOptions, default_create_options +from ect.verify import ( + ParsedECT, + parse, + verify, + VerifyOptions, + default_verify_options, + KeyResolver, +) +from ect.dag import ( + ECTStore, + DAGConfig, + default_dag_config, + validate_dag, +) +from ect.ledger import Ledger, MemoryLedger, LedgerEntry, ErrTaskIDExists +from ect.config import Config, default_config, load_config_from_env +from ect.jti_cache import JTICache, new_jti_cache + +__all__ = [ + "ECT_TYPE", + "ECT_TYPE_LEGACY", + "Payload", + "create", + "generate_key", + "CreateOptions", + "default_create_options", + "ParsedECT", + "parse", + "verify", + "VerifyOptions", + "default_verify_options", + "KeyResolver", + "ECTStore", + "DAGConfig", + "default_dag_config", + "validate_dag", + "Ledger", + "MemoryLedger", + "LedgerEntry", + "ErrTaskIDExists", + "Config", + "default_config", + "load_config_from_env", + "JTICache", + "new_jti_cache", +] diff --git a/workspace/packages/ect/ect/config.py b/workspace/packages/ect/ect/config.py new file mode 100644 index 0000000..25ff8bc --- /dev/null +++ b/workspace/packages/ect/ect/config.py @@ -0,0 +1,61 @@ +"""Production config from environment.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + +ENV_IAT_MAX_AGE_MINUTES = "ECT_IAT_MAX_AGE_MINUTES" +ENV_IAT_MAX_FUTURE_SEC = "ECT_IAT_MAX_FUTURE_SEC" +ENV_DEFAULT_EXPIRY_MIN = "ECT_DEFAULT_EXPIRY_MIN" +ENV_JTI_REPLAY_CACHE_SIZE = "ECT_JTI_REPLAY_CACHE_SIZE" +ENV_JTI_REPLAY_TTL_MIN = "ECT_JTI_REPLAY_TTL_MIN" + + +@dataclass +class Config: + iat_max_age_sec: int = 900 + iat_max_future_sec: int = 30 + default_expiry_sec: int = 600 + jti_replay_size: int = 0 + jti_replay_ttl_sec: int = 3600 + + def create_options(self, key_id: str) -> "CreateOptions": + from ect.create import CreateOptions + return CreateOptions( + key_id=key_id, + default_expiry_sec=self.default_expiry_sec, + ) + + def verify_options(self) -> "VerifyOptions": + from ect.verify import VerifyOptions + from ect.dag import default_dag_config + return VerifyOptions( + iat_max_age_sec=self.iat_max_age_sec, + iat_max_future_sec=self.iat_max_future_sec, + dag=default_dag_config(), + ) + + +def default_config() -> Config: + return Config() + + +def _int_env(name: str, default: int) -> int: + v = os.environ.get(name) + if v is None or v == "": + return default + try: + return int(v) + except ValueError: + return default + + +def load_config_from_env() -> Config: + c = default_config() + c.iat_max_age_sec = _int_env(ENV_IAT_MAX_AGE_MINUTES, 15) * 60 + c.iat_max_future_sec = _int_env(ENV_IAT_MAX_FUTURE_SEC, 30) + c.default_expiry_sec = _int_env(ENV_DEFAULT_EXPIRY_MIN, 10) * 60 + c.jti_replay_size = _int_env(ENV_JTI_REPLAY_CACHE_SIZE, 0) + c.jti_replay_ttl_sec = _int_env(ENV_JTI_REPLAY_TTL_MIN, 60) * 60 + return c diff --git a/workspace/packages/ect/ect/create.py b/workspace/packages/ect/ect/create.py new file mode 100644 index 0000000..8af4bf1 --- /dev/null +++ b/workspace/packages/ect/ect/create.py @@ -0,0 +1,104 @@ +"""ECT creation: build and sign JWT with ES256.""" + +from __future__ import annotations + +import copy +import time +from dataclasses import dataclass +from typing import Optional + +import jwt +from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey + +from ect.types import ECT_TYPE, Payload +from ect.validate import ( + DEFAULT_MAX_PRED_LENGTH, + validate_ext, + validate_hash_format, + valid_uuid, +) + + +@dataclass +class CreateOptions: + key_id: str + iat_max_age_sec: int = 900 # 15 min + default_expiry_sec: int = 600 # 10 min + validate_uuids: bool = False + max_pred_length: int = 0 # 0 = no limit; use DEFAULT_MAX_PRED_LENGTH for 100 + + +def default_create_options() -> CreateOptions: + return CreateOptions(key_id="") + + +def _validate_payload(p: Payload, opts: CreateOptions) -> None: + if not p.iss: + raise ValueError("ect: iss required") + if not p.aud: + raise ValueError("ect: aud required") + if not p.jti: + raise ValueError("ect: jti required") + if not p.exec_act: + raise ValueError("ect: exec_act required") + if opts.validate_uuids: + if not valid_uuid(p.jti): + raise ValueError("ect: jti must be UUID format") + if p.wid and not valid_uuid(p.wid): + raise ValueError("ect: wid must be UUID format when set") + max_pred = opts.max_pred_length or 0 + if max_pred > 0 and len(p.pred) > max_pred: + raise ValueError("ect: pred exceeds max length") + if p.inp_hash: + validate_hash_format(p.inp_hash) + if p.out_hash: + validate_hash_format(p.out_hash) + validate_ext(p.ext) + # compensation in ext per spec + if p.ext and p.ext.get("compensation_reason") and not p.ext.get("compensation_required"): + raise ValueError("ect: ext.compensation_reason requires ext.compensation_required true") + + +def create( + payload: Payload, + private_key: EllipticCurvePrivateKey, + opts: CreateOptions, +) -> str: + """Build and sign an ECT. Payload must have required claims; iat/exp can be 0 for defaults. + create() works on a deep copy so the caller's payload is not modified. + """ + if not opts.key_id: + raise ValueError("ect: KeyID required") + + # Work on a copy so we do not mutate the caller's payload. + payload = copy.deepcopy(payload) + + now = int(time.time()) + if payload.iat == 0: + payload.iat = now + if payload.exp == 0: + payload.exp = now + (opts.default_expiry_sec or 600) + if payload.pred is None: + payload.pred = [] + + _validate_payload(payload, opts) + + claims = payload.to_claims() + headers = { + "typ": ECT_TYPE, + "alg": "ES256", + "kid": opts.key_id, + } + return jwt.encode( + claims, + private_key, + algorithm="ES256", + headers=headers, + ) + + +def generate_key() -> EllipticCurvePrivateKey: + """Create an ECDSA P-256 key for ES256 (testing/demo).""" + from cryptography.hazmat.primitives.asymmetric import ec + + return ec.generate_private_key(ec.SECP256R1()) diff --git a/workspace/packages/ect/ect/dag.py b/workspace/packages/ect/ect/dag.py new file mode 100644 index 0000000..b1ca617 --- /dev/null +++ b/workspace/packages/ect/ect/dag.py @@ -0,0 +1,96 @@ +"""DAG validation per Section 6.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ect.types import Payload + +from ect.validate import DEFAULT_MAX_PRED_LENGTH + +DEFAULT_CLOCK_SKEW_TOLERANCE = 30 +DEFAULT_MAX_ANCESTOR_LIMIT = 10000 + + +class ECTStore(ABC): + """Lookup of ECTs by task ID for DAG validation.""" + + @abstractmethod + def get_by_tid(self, tid: str) -> "Payload | None": + pass + + @abstractmethod + def contains(self, tid: str, wid: str) -> bool: + pass + + +class DAGConfig: + def __init__( + self, + clock_skew_tolerance: int = DEFAULT_CLOCK_SKEW_TOLERANCE, + max_ancestor_limit: int = DEFAULT_MAX_ANCESTOR_LIMIT, + max_pred_length: int = 0, + ): + self.clock_skew_tolerance = clock_skew_tolerance or DEFAULT_CLOCK_SKEW_TOLERANCE + self.max_ancestor_limit = max_ancestor_limit or DEFAULT_MAX_ANCESTOR_LIMIT + self.max_pred_length = max_pred_length or 0 + + +def default_dag_config() -> DAGConfig: + return DAGConfig() + + +def _has_cycle( + target_tid: str, + pred_ids: list[str], + store: ECTStore, + visited: set[str], + max_depth: int, +) -> bool: + if len(visited) >= max_depth: + return True + for pred_id in pred_ids: + if pred_id == target_tid: + return True + if pred_id in visited: + continue + visited.add(pred_id) + pred = store.get_by_tid(pred_id) + if pred is not None: + if _has_cycle(target_tid, pred.pred, store, visited, max_depth): + return True + return False + + +def validate_dag( + payload: "Payload", + store: ECTStore, + cfg: DAGConfig, +) -> None: + """Section 6.2: uniqueness (by jti), predecessor existence, temporal ordering, acyclicity, predecessor policy.""" + if cfg.max_pred_length > 0 and len(payload.pred) > cfg.max_pred_length: + raise ValueError("ect: pred exceeds max length") + if store.contains(payload.jti, payload.wid or ""): + raise ValueError(f"ect: task ID (jti) already exists: {payload.jti}") + + for pred_id in payload.pred: + pred = store.get_by_tid(pred_id) + if pred is None: + raise ValueError(f"ect: predecessor task not found: {pred_id}") + if pred.iat >= payload.iat + cfg.clock_skew_tolerance: + raise ValueError(f"ect: predecessor task not earlier than current: {pred_id}") + + visited: set[str] = set() + if _has_cycle(payload.jti, payload.pred, store, visited, cfg.max_ancestor_limit): + raise ValueError("ect: circular dependency or depth limit exceeded") + + # Predecessor policy decision: only when predecessor has policy claims in ext per -01 + for pred_id in payload.pred: + pred = store.get_by_tid(pred_id) + if pred and pred.has_policy_claims() and pred.pol_decision() in ("rejected", "pending_human_review"): + if not payload.compensation_required(): + raise ValueError( + "ect: predecessor has non-approved pol_decision; current ECT must be compensation/remediation or have ext.compensation_required true" + ) diff --git a/workspace/packages/ect/ect/jti_cache.py b/workspace/packages/ect/ect/jti_cache.py new file mode 100644 index 0000000..94c51d7 --- /dev/null +++ b/workspace/packages/ect/ect/jti_cache.py @@ -0,0 +1,52 @@ +"""JTI replay cache for production verification.""" + +from __future__ import annotations + +import threading +import time +from abc import ABC, abstractmethod + + +class JTICache(ABC): + @abstractmethod + def seen(self, jti: str) -> bool: + pass + + @abstractmethod + def add(self, jti: str) -> None: + pass + + +class _MemoryJTICache(JTICache): + def __init__(self, max_size: int, ttl_sec: int) -> None: + self._max_size = max_size + self._ttl_sec = ttl_sec + self._by_jti: dict[str, float] = {} + self._lock = threading.RLock() + + def seen(self, jti: str) -> bool: + with self._lock: + exp = self._by_jti.get(jti) + if exp is None: + return False + if time.time() > exp: + del self._by_jti[jti] + return False + return True + + def add(self, jti: str) -> None: + with self._lock: + now = time.time() + for k, exp in list(self._by_jti.items()): + if now > exp: + del self._by_jti[k] + if self._max_size > 0 and len(self._by_jti) >= self._max_size and jti not in self._by_jti: + # evict one + for k in self._by_jti: + del self._by_jti[k] + break + self._by_jti[jti] = now + self._ttl_sec + + +def new_jti_cache(max_size: int, ttl_sec: int) -> JTICache: + return _MemoryJTICache(max_size, ttl_sec) diff --git a/workspace/packages/ect/ect/ledger.py b/workspace/packages/ect/ect/ledger.py new file mode 100644 index 0000000..025408c --- /dev/null +++ b/workspace/packages/ect/ect/ledger.py @@ -0,0 +1,97 @@ +"""Audit ledger per Section 9.""" + +from __future__ import annotations + +import time +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from ect.types import Payload + +if TYPE_CHECKING: + pass + + +class ErrTaskIDExists(Exception): + """Raised when appending an ECT whose tid already exists.""" + + +@dataclass +class LedgerEntry: + ledger_sequence: int + task_id: str + agent_id: str + action: str + predecessors: list[str] + ect_jws: str + signature_verified: bool + verification_timestamp: float + stored_timestamp: float + + +class Ledger(ABC): + """Append-only audit ledger; lookup by task id (jti).""" + + @abstractmethod + def append(self, ect_jws: str, payload: Payload) -> int: + """Returns new ledger sequence number.""" + pass + + @abstractmethod + def get_by_tid(self, tid: str) -> Payload | None: + pass + + @abstractmethod + def contains(self, tid: str, wid: str) -> bool: + pass + + +class MemoryLedger(Ledger): + """In-memory append-only ECT store implementing Ledger and ECTStore.""" + + def __init__(self) -> None: + self._seq = 0 + self._by_tid: dict[str, "Payload"] = {} + self._entries: list[LedgerEntry] = [] + self._lock = __import__("threading").Lock() + + def append(self, ect_jws: str, payload: Payload) -> int: + if payload is None: + return 0 + with self._lock: + wid = payload.wid or "" + if self._contains_locked(payload.jti, wid): + raise ErrTaskIDExists("ect: task ID (jti) already exists in ledger") + self._seq += 1 + now = time.time() + entry = LedgerEntry( + ledger_sequence=self._seq, + task_id=payload.jti, + agent_id=payload.iss, + action=payload.exec_act, + predecessors=list(payload.pred) if payload.pred else [], + ect_jws=ect_jws, + signature_verified=True, + verification_timestamp=now, + stored_timestamp=now, + ) + self._by_tid[payload.jti] = payload + self._entries.append(entry) + return self._seq + + def get_by_tid(self, tid: str) -> Payload | None: + with self._lock: + return self._by_tid.get(tid) + + def contains(self, tid: str, wid: str) -> bool: + with self._lock: + return self._contains_locked(tid, wid) + + def _contains_locked(self, tid: str, wid: str) -> bool: + p = self._by_tid.get(tid) + if p is None: + return False + if not wid: + return True + return (p.wid or "") == wid diff --git a/workspace/packages/ect/ect/types.py b/workspace/packages/ect/ect/types.py new file mode 100644 index 0000000..b70a9de --- /dev/null +++ b/workspace/packages/ect/ect/types.py @@ -0,0 +1,106 @@ +"""ECT payload and claim types per draft-nennemann-wimse-ect-01 Section 4.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any + +# Preferred typ per -01; legacy accepted for backward compatibility. +ECT_TYPE = "exec+jwt" +ECT_TYPE_LEGACY = "wimse-exec+jwt" + + +def _audience_serialize(aud: list[str]) -> str | list[str]: + if len(aud) == 1: + return aud[0] + return aud + + +def _audience_deserialize(raw: Any) -> list[str]: + if isinstance(raw, list): + return [str(x) for x in raw] + if isinstance(raw, str): + return [raw] + raise ValueError("aud must be string or array of strings") + + +@dataclass +class Payload: + """ECT JWT claims per Section 4. Task identity is jti only; no separate tid per spec.""" + + iss: str + aud: list[str] + iat: int + exp: int + jti: str + exec_act: str + pred: list[str] # predecessor jti values (renamed from par in -01) + wid: str = "" + inp_hash: str = "" + out_hash: str = "" + inp_classification: str = "" + ext: dict[str, Any] = field(default_factory=dict) + + def to_claims(self) -> dict[str, Any]: + """Export as JWT claims. Policy and compensation in ext per -01 spec.""" + out: dict[str, Any] = { + "iss": self.iss, + "aud": _audience_serialize(self.aud), + "iat": self.iat, + "exp": self.exp, + "jti": self.jti, + "exec_act": self.exec_act, + "pred": self.pred, + } + if self.wid: + out["wid"] = self.wid + if self.inp_hash: + out["inp_hash"] = self.inp_hash + if self.out_hash: + out["out_hash"] = self.out_hash + if self.inp_classification: + out["inp_classification"] = self.inp_classification + if self.ext: + out["ect_ext"] = dict(self.ext) + return out + + @classmethod + def from_claims(cls, claims: dict[str, Any]) -> Payload: + """Build Payload from JWT claims. Policy claims read from ext per -01 spec.""" + ext = claims.get("ect_ext") or {} + return cls( + iss=claims["iss"], + aud=_audience_deserialize(claims["aud"]), + iat=int(claims["iat"]), + exp=int(claims["exp"]), + jti=claims["jti"], + exec_act=claims["exec_act"], + pred=claims.get("pred") or [], + wid=claims.get("wid", ""), + inp_hash=claims.get("inp_hash", ""), + out_hash=claims.get("out_hash", ""), + inp_classification=claims.get("inp_classification", ""), + ext=ext, + ) + + def contains_audience(self, verifier_id: str) -> bool: + return verifier_id in self.aud + + def compensation_required(self) -> bool: + """Per spec: compensation_required is in ext.""" + if not self.ext: + return False + return bool(self.ext.get("compensation_required")) + + def has_policy_claims(self) -> bool: + """True if both pol and pol_decision are present in ext (per -01, moved to extension).""" + if not self.ext: + return False + return bool(self.ext.get("pol")) and bool(self.ext.get("pol_decision")) + + def pol_decision(self) -> str: + """Return pol_decision from ext, or empty string.""" + if not self.ext: + return "" + return str(self.ext.get("pol_decision", "")) diff --git a/workspace/packages/ect/ect/validate.py b/workspace/packages/ect/ect/validate.py new file mode 100644 index 0000000..d0c2441 --- /dev/null +++ b/workspace/packages/ect/ect/validate.py @@ -0,0 +1,62 @@ +"""Validation helpers: ext size/depth, UUID, inp_hash/out_hash format.""" + +from __future__ import annotations + +import base64 +import json +import re +from typing import Any + +EXT_MAX_SIZE = 4096 +EXT_MAX_DEPTH = 5 +DEFAULT_MAX_PRED_LENGTH = 100 + +_UUID_RE = re.compile( + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" +) +def _json_depth(obj: Any, depth: int = 0) -> int: + if depth > EXT_MAX_DEPTH: + return depth + if isinstance(obj, dict): + return max((_json_depth(v, depth + 1) for v in obj.values()), default=depth + 1) + if isinstance(obj, list): + return max((_json_depth(x, depth + 1) for x in obj), default=depth + 1) + return depth + + +def validate_ext(ext: dict[str, Any] | None) -> None: + """Raise ValueError if ext exceeds EXT_MAX_SIZE or nesting depth EXT_MAX_DEPTH.""" + if not ext: + return + raw = json.dumps(ext) + if len(raw.encode("utf-8")) > EXT_MAX_SIZE: + raise ValueError("ect: ext exceeds max size (4096 bytes)") + if _json_depth(ext) > EXT_MAX_DEPTH: + raise ValueError("ect: ext exceeds max nesting depth (5)") + + +def valid_uuid(s: str) -> bool: + """Return True if s is a UUID string (RFC 9562).""" + return bool(_UUID_RE.match(s)) + + +def validate_hash_format(s: str) -> None: + """Raise ValueError if s is non-empty and not plain base64url per RFC 9449 / ECT spec. + + The ECT spec (draft-nennemann-wimse-ect-01) and RFC 9449 specify + ``base64url(SHA-256(data))`` — a plain base64url string without any + algorithm prefix. This matches how ACT handles hashes. + """ + if not s: + return + # Reject strings containing non-base64url characters. + # base64url alphabet: A-Z a-z 0-9 - _ (no padding '=' expected) + if not re.fullmatch(r"[A-Za-z0-9_-]+", s): + raise ValueError("ect: inp_hash/out_hash must be plain base64url (no prefix)") + # Verify it actually decodes. + pad = 4 - len(s) % 4 + padded = s + "=" * pad if pad != 4 else s + try: + base64.urlsafe_b64decode(padded) + except Exception: + raise ValueError("ect: inp_hash/out_hash must be plain base64url (no prefix)") from None diff --git a/workspace/packages/ect/ect/verify.py b/workspace/packages/ect/ect/verify.py new file mode 100644 index 0000000..01be36d --- /dev/null +++ b/workspace/packages/ect/ect/verify.py @@ -0,0 +1,154 @@ +"""ECT verification per Section 7.""" + +from __future__ import annotations + +import hmac +import time +from dataclasses import dataclass +from typing import Callable, Optional + +import jwt +from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey + +from ect.types import ECT_TYPE, ECT_TYPE_LEGACY, Payload +from ect.dag import ECTStore, DAGConfig, validate_dag +from ect.validate import validate_ext, validate_hash_format, valid_uuid + + +@dataclass +class ParsedECT: + header: dict + payload: Payload + raw: str + + +KeyResolver = Callable[[str], Optional[EllipticCurvePublicKey]] + + +@dataclass +class VerifyOptions: + verifier_id: str = "" + resolve_key: Optional[KeyResolver] = None + store: Optional[ECTStore] = None + dag: Optional[DAGConfig] = None + now: Optional[int] = None # unix seconds; None = time.time() + iat_max_age_sec: int = 900 + iat_max_future_sec: int = 30 + jti_seen: Optional[Callable[[str], bool]] = None + wit_subject: str = "" + validate_uuids: bool = False + max_pred_length: int = 0 # 0 = no limit + on_verify_attempt: Optional[Callable[[str, Optional[Exception]], None]] = None # (jti, err) for observability + + +def default_verify_options() -> VerifyOptions: + from ect.dag import default_dag_config + return VerifyOptions(dag=default_dag_config()) + + +def parse(compact: str) -> ParsedECT: + """Parse compact JWS and return header + payload without verification.""" + try: + unverified = jwt.decode( + compact, + options={"verify_signature": False, "verify_exp": False}, + ) + except Exception as e: + raise ValueError(f"ect: parse failed: {e}") from e + header = jwt.get_unverified_header(compact) + if header.get("alg") != "ES256": + raise ValueError("ect: expected ES256") + payload = Payload.from_claims(unverified) + return ParsedECT(header=header, payload=payload, raw=compact) + + +def verify(compact: str, opts: VerifyOptions) -> ParsedECT: + """Full Section 7 verification and optional DAG validation.""" + log_jti: list[str] = [""] # use list so callback sees updated jti + + def set_log_jti(jti: str) -> None: + log_jti[0] = jti + + err: Optional[Exception] = None + try: + return _verify_impl(compact, opts, set_log_jti) + except Exception as e: + err = e + raise + finally: + if opts.on_verify_attempt is not None: + opts.on_verify_attempt(log_jti[0], err) + + +def _verify_impl(compact: str, opts: VerifyOptions, set_log_jti: Callable[[str], None]) -> ParsedECT: + header = jwt.get_unverified_header(compact) + typ = header.get("typ") or "" + # Constant-time comparison for typ; accept both preferred and legacy values + if not hmac.compare_digest(typ, ECT_TYPE) and not hmac.compare_digest(typ, ECT_TYPE_LEGACY): + raise ValueError("ect: invalid typ parameter") + alg = header.get("alg") + if alg in ("none", "HS256", "HS384", "HS512"): + raise ValueError("ect: prohibited algorithm") + kid = header.get("kid") + if not kid: + raise ValueError("ect: missing kid") + if not opts.resolve_key: + raise ValueError("ect: ResolveKey required") + pub = opts.resolve_key(kid) + if pub is None: + raise ValueError("ect: unknown key identifier") + + try: + claims = jwt.decode( + compact, + pub, + algorithms=["ES256"], + options={"verify_exp": False, "verify_aud": False, "verify_iat": False}, + ) + except jwt.InvalidSignatureError as e: + raise ValueError(f"ect: invalid signature: {e}") from e + except Exception as e: + raise ValueError(f"ect: verify failed: {e}") from e + + payload = Payload.from_claims(claims) + set_log_jti(payload.jti) + + validate_ext(payload.ext) + if opts.max_pred_length > 0 and len(payload.pred) > opts.max_pred_length: + raise ValueError("ect: pred exceeds max length") + if opts.validate_uuids: + if not valid_uuid(payload.jti): + raise ValueError("ect: jti must be UUID format") + if payload.wid and not valid_uuid(payload.wid): + raise ValueError("ect: wid must be UUID format when set") + if payload.inp_hash: + validate_hash_format(payload.inp_hash) + if payload.out_hash: + validate_hash_format(payload.out_hash) + + if opts.wit_subject and payload.iss != opts.wit_subject: + raise ValueError("ect: issuer does not match WIT subject") + if opts.verifier_id and not payload.contains_audience(opts.verifier_id): + raise ValueError("ect: audience does not include verifier") + + now = opts.now if opts.now is not None else int(time.time()) + if now > payload.exp: + raise ValueError("ect: token expired") + if now - payload.iat > opts.iat_max_age_sec: + raise ValueError("ect: iat too far in the past") + if payload.iat > now + opts.iat_max_future_sec: + raise ValueError("ect: iat in the future") + + # Required claims per spec: jti, exec_act, pred. pred may be set to [] when missing (from_claims already uses []). + if not payload.jti or not payload.exec_act: + raise ValueError("ect: missing required claims (jti, exec_act, pred)") + if payload.pred is None: + payload.pred = [] + + if opts.store is not None and opts.dag is not None: + validate_dag(payload, opts.store, opts.dag) + + if opts.jti_seen is not None and opts.jti_seen(payload.jti): + raise ValueError("ect: jti already seen (replay)") + + return ParsedECT(header=header, payload=payload, raw=compact) diff --git a/workspace/packages/ect/pyproject.toml b/workspace/packages/ect/pyproject.toml new file mode 100644 index 0000000..b0740de --- /dev/null +++ b/workspace/packages/ect/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=61", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "ietf-ect" +version = "0.1.0" +description = "WIMSE Execution Context Tokens (ECT) reference implementation" +requires-python = ">=3.9" +license = "BSD-3-Clause" +dependencies = [ + "PyJWT>=2.8.0", + "cryptography>=42.0.0", +] + +[project.optional-dependencies] +dev = ["pytest>=7.0", "pytest-cov>=4.0"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +addopts = "--cov=ect --cov-report=term-missing --cov-fail-under=90 -v" + +[tool.setuptools.packages.find] +include = ["ect*"] diff --git a/workspace/packages/ect/testdata/valid_root_ect_payload.json b/workspace/packages/ect/testdata/valid_root_ect_payload.json new file mode 100644 index 0000000..46aa833 --- /dev/null +++ b/workspace/packages/ect/testdata/valid_root_ect_payload.json @@ -0,0 +1 @@ +{"iss":"spiffe://example.com/agent/clinical","aud":"spiffe://example.com/agent/safety","iat":1772064150,"exp":1772064750,"jti":"7f3a8b2c-d1e4-4f56-9a0b-c3d4e5f6a7b8","wid":"a0b1c2d3-e4f5-6789-abcd-ef0123456789","exec_act":"recommend_treatment","pred":[],"ect_ext":{"pol":"clinical_reasoning_policy_v2","pol_decision":"approved"}} diff --git a/workspace/packages/ect/tests/__init__.py b/workspace/packages/ect/tests/__init__.py new file mode 100644 index 0000000..d4839a6 --- /dev/null +++ b/workspace/packages/ect/tests/__init__.py @@ -0,0 +1 @@ +# Tests package diff --git a/workspace/packages/ect/tests/test_config.py b/workspace/packages/ect/tests/test_config.py new file mode 100644 index 0000000..f144b1e --- /dev/null +++ b/workspace/packages/ect/tests/test_config.py @@ -0,0 +1,49 @@ +"""Tests for config module.""" + +import os + +import pytest + +from ect import default_config, load_config_from_env +from ect.config import ENV_IAT_MAX_AGE_MINUTES, ENV_JTI_REPLAY_CACHE_SIZE + + +def test_default_config(): + c = default_config() + assert c.iat_max_age_sec == 900 + assert c.jti_replay_size == 0 + + +def test_load_config_from_env(): + os.environ[ENV_IAT_MAX_AGE_MINUTES] = "20" + os.environ[ENV_JTI_REPLAY_CACHE_SIZE] = "500" + try: + c = load_config_from_env() + assert c.iat_max_age_sec == 20 * 60 + assert c.jti_replay_size == 500 + finally: + os.environ.pop(ENV_IAT_MAX_AGE_MINUTES, None) + os.environ.pop(ENV_JTI_REPLAY_CACHE_SIZE, None) + + +def test_config_create_options(): + c = default_config() + opts = c.create_options("my-kid") + assert opts.key_id == "my-kid" + assert opts.default_expiry_sec == c.default_expiry_sec + + +def test_config_verify_options(): + c = default_config() + opts = c.verify_options() + assert opts.iat_max_age_sec == c.iat_max_age_sec + assert opts.dag is not None + + +def test_load_config_invalid_int(): + os.environ[ENV_IAT_MAX_AGE_MINUTES] = "bad" + try: + c = load_config_from_env() + assert c.iat_max_age_sec == 900 + finally: + os.environ.pop(ENV_IAT_MAX_AGE_MINUTES, None) diff --git a/workspace/packages/ect/tests/test_create.py b/workspace/packages/ect/tests/test_create.py new file mode 100644 index 0000000..b4470d0 --- /dev/null +++ b/workspace/packages/ect/tests/test_create.py @@ -0,0 +1,74 @@ +"""Tests for ECT creation and roundtrip.""" + +import json +import os +import time + +import pytest + +from ect import ( + Payload, + create, + generate_key, + CreateOptions, + verify, + VerifyOptions, +) + + +def test_create_roundtrip(): + key = generate_key() + now = int(time.time()) + payload = Payload( + iss="spiffe://example.com/agent/a", + aud=["spiffe://example.com/agent/b"], + iat=now, + exp=now + 600, + jti="e4f5a6b7-c8d9-0123-ef01-234567890abc", + exec_act="review_spec", + pred=[], + ) + compact = create(payload, key, CreateOptions(key_id="agent-a-key-1")) + assert compact + + def resolver(kid): + if kid == "agent-a-key-1": + return key.public_key() + return None + + opts = VerifyOptions( + verifier_id="spiffe://example.com/agent/b", + resolve_key=resolver, + now=now, + ) + parsed = verify(compact, opts) + assert parsed.payload.jti == payload.jti + assert parsed.payload.exec_act == payload.exec_act + + +def test_create_with_test_vector(): + path = os.path.join(os.path.dirname(__file__), "..", "testdata", "valid_root_ect_payload.json") + if not os.path.exists(path): + pytest.skip(f"test vector not found: {path}") + with open(path) as f: + data = json.load(f) + payload = Payload.from_claims(data) + key = generate_key() + now = int(time.time()) + payload.iat = now + payload.exp = now + 600 + + compact = create(payload, key, CreateOptions(key_id="test-kid")) + assert compact + + def resolver(kid): + if kid == "test-kid": + return key.public_key() + return None + + opts = VerifyOptions( + verifier_id=payload.aud[0], + resolve_key=resolver, + now=now, + ) + verify(compact, opts) diff --git a/workspace/packages/ect/tests/test_create_extra.py b/workspace/packages/ect/tests/test_create_extra.py new file mode 100644 index 0000000..0b72d8e --- /dev/null +++ b/workspace/packages/ect/tests/test_create_extra.py @@ -0,0 +1,94 @@ +"""Additional tests for create module.""" + +import time + +import pytest + +from ect import Payload, create, generate_key, CreateOptions, default_create_options + + +def test_default_create_options(): + opts = default_create_options() + assert opts.key_id == "" + + +def test_create_errors(): + key = generate_key() + p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", pred=[]) + with pytest.raises(ValueError, match="KeyID|required"): + create(p, key, CreateOptions(key_id="")) + with pytest.raises((ValueError, TypeError, AttributeError)): + create(None, key, CreateOptions(key_id="k")) + + +def test_create_optional_pol(): + key = generate_key() + now = int(time.time()) + p = Payload( + iss="iss", aud=["a"], iat=now, exp=now + 3600, + jti="jti-nopol", exec_act="act", pred=[], + ) + compact = create(p, key, CreateOptions(key_id="kid")) + assert compact + + +def test_create_validation_errors(): + key = generate_key() + base = dict(iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", pred=[]) + with pytest.raises(ValueError, match="iss"): + create(Payload(**{**base, "iss": ""}), key, CreateOptions(key_id="k")) + with pytest.raises(ValueError, match="aud"): + create(Payload(**{**base, "aud": []}), key, CreateOptions(key_id="k")) + with pytest.raises(ValueError, match="jti"): + create(Payload(**{**base, "jti": ""}), key, CreateOptions(key_id="k")) + with pytest.raises(ValueError, match="exec_act"): + create(Payload(**{**base, "exec_act": ""}), key, CreateOptions(key_id="k")) + + +def test_create_ext_compensation_reason_requires_required(): + key = generate_key() + p = Payload( + iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", pred=[], + ext={"compensation_reason": "rollback", "compensation_required": False}, + ) + with pytest.raises(ValueError, match="compensation_required"): + create(p, key, CreateOptions(key_id="k")) + + +def test_create_zero_expiry_uses_default(): + key = generate_key() + p = Payload(iss="i", aud=["a"], iat=0, exp=0, jti="j", exec_act="e", pred=[]) + compact = create(p, key, CreateOptions(key_id="k", default_expiry_sec=300)) + assert compact + # create() works on a copy; decode the token to verify defaults were applied + import jwt + claims = jwt.decode(compact, options={"verify_signature": False}) + assert claims["exp"] > claims["iat"] + + +def test_create_validate_uuids_rejects_non_uuid_jti(): + key = generate_key() + now = int(time.time()) + p = Payload(iss="i", aud=["a"], iat=now, exp=now + 3600, jti="not-a-uuid", exec_act="e", pred=[]) + with pytest.raises(ValueError, match="jti must be UUID"): + create(p, key, CreateOptions(key_id="k", validate_uuids=True)) + + +def test_create_max_pred_length(): + key = generate_key() + now = int(time.time()) + p = Payload(iss="i", aud=["a"], iat=now, exp=now + 3600, jti="550e8400-e29b-41d4-a716-446655440000", exec_act="e", pred=["p1", "p2"]) + with pytest.raises(ValueError, match="pred exceeds max length"): + create(p, key, CreateOptions(key_id="k", max_pred_length=1)) + + +def test_create_ext_size_rejected(): + from ect.validate import EXT_MAX_SIZE + key = generate_key() + now = int(time.time()) + p = Payload( + iss="i", aud=["a"], iat=now, exp=now + 3600, jti="550e8400-e29b-41d4-a716-446655440000", exec_act="e", pred=[], + ext={"x": "y" * (EXT_MAX_SIZE - 5)}, + ) + with pytest.raises(ValueError, match="ext exceeds max size"): + create(p, key, CreateOptions(key_id="k")) diff --git a/workspace/packages/ect/tests/test_dag.py b/workspace/packages/ect/tests/test_dag.py new file mode 100644 index 0000000..3c228a0 --- /dev/null +++ b/workspace/packages/ect/tests/test_dag.py @@ -0,0 +1,111 @@ +"""Tests for DAG validation.""" + +import time + +import pytest + +from ect import Payload, MemoryLedger, validate_dag, default_dag_config + + +def test_validate_dag_root(): + store = MemoryLedger() + payload = Payload( + iss="", + aud=[], + iat=0, + exp=0, + jti="jti-001", + exec_act="", + pred=[], + wid="wf-1", + ) + validate_dag(payload, store, default_dag_config()) + + +def test_validate_dag_duplicate_jti(): + store = MemoryLedger() + p = Payload( + iss="x", + aud=["y"], + iat=0, + exp=0, + jti="jti-001", + exec_act="a", + pred=[], + wid="wf-1", + ) + store.append("dummy-jws", p) + payload = Payload( + iss="", + aud=[], + iat=0, + exp=0, + jti="jti-001", + exec_act="", + pred=[], + wid="wf-1", + ) + with pytest.raises(ValueError, match="task ID.*already exists"): + validate_dag(payload, store, default_dag_config()) + + +def test_validate_dag_pred_exists(): + store = MemoryLedger() + now = int(time.time()) + p = Payload( + iss="x", + aud=["y"], + iat=now - 60, + exp=now + 600, + jti="jti-001", + exec_act="a", + pred=[], + wid="wf-1", + ) + store.append("jws1", p) + payload = Payload( + iss="", + aud=[], + iat=now, + exp=now + 600, + jti="jti-002", + exec_act="b", + pred=["jti-001"], + wid="wf-1", + ) + validate_dag(payload, store, default_dag_config()) + + +def test_validate_dag_pred_not_found(): + store = MemoryLedger() + now = int(time.time()) + payload = Payload( + iss="", + aud=[], + iat=now, + exp=now + 600, + jti="jti-002", + exec_act="", + pred=["jti-missing"], + ) + with pytest.raises(ValueError, match="predecessor task not found"): + validate_dag(payload, store, default_dag_config()) + + +def test_validate_dag_pred_policy_rejected_requires_compensation(): + store = MemoryLedger() + now = int(time.time()) + p = Payload( + iss="x", aud=["y"], iat=now - 60, exp=now + 600, + jti="jti-rej", exec_act="a", pred=[], wid="wf-1", + ext={"pol": "p", "pol_decision": "rejected"}, + ) + store.append("jws1", p) + payload = Payload( + iss="", aud=[], iat=now, exp=now + 600, + jti="jti-child", exec_act="b", pred=["jti-rej"], wid="wf-1", + ) + with pytest.raises(ValueError, match="compensation"): + validate_dag(payload, store, default_dag_config()) + payload.ext = {"compensation_required": True} + validate_dag(payload, store, default_dag_config()) diff --git a/workspace/packages/ect/tests/test_jti_cache.py b/workspace/packages/ect/tests/test_jti_cache.py new file mode 100644 index 0000000..dca9bf5 --- /dev/null +++ b/workspace/packages/ect/tests/test_jti_cache.py @@ -0,0 +1,40 @@ +"""Tests for JTI replay cache.""" + +import time + +import pytest + +from ect import new_jti_cache + + +def test_jti_cache_seen_and_add(): + cache = new_jti_cache(10, 60) + assert cache.seen("jti-1") is False + cache.add("jti-1") + assert cache.seen("jti-1") is True + assert cache.seen("jti-2") is False + cache.add("jti-2") + assert cache.seen("jti-2") is True + + +def test_jti_cache_expiry(): + cache = new_jti_cache(10, 1) # 1 second TTL + cache.add("jti-1") + assert cache.seen("jti-1") is True + time.sleep(1.1) + assert cache.seen("jti-1") is False + + +def test_jti_cache_max_size_eviction(): + cache = new_jti_cache(2, 60) + cache.add("jti-1") + cache.add("jti-2") + cache.add("jti-3") + assert cache.seen("jti-3") is True + + +def test_jti_cache_add_when_already_present(): + cache = new_jti_cache(2, 60) + cache.add("jti-1") + cache.add("jti-1") + assert cache.seen("jti-1") is True diff --git a/workspace/packages/ect/tests/test_ledger_extra.py b/workspace/packages/ect/tests/test_ledger_extra.py new file mode 100644 index 0000000..b5fcfea --- /dev/null +++ b/workspace/packages/ect/tests/test_ledger_extra.py @@ -0,0 +1,38 @@ +"""Additional tests for ledger module.""" + +import time + +import pytest + +from ect import Payload, MemoryLedger, ErrTaskIDExists + + +def test_ledger_append_and_get(): + m = MemoryLedger() + p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j1", exec_act="act", pred=[]) + seq = m.append("jws1", p) + assert seq == 1 + assert m.get_by_tid("j1").jti == "j1" + + +def test_ledger_err_task_id_exists(): + m = MemoryLedger() + p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j-dup", exec_act="e", pred=[]) + m.append("jws1", p) + with pytest.raises(ErrTaskIDExists): + m.append("jws2", p) + + +def test_ledger_contains_wid(): + m = MemoryLedger() + p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j1", exec_act="e", pred=[], wid="wf1") + m.append("jws", p) + assert m.contains("j1", "") is True + assert m.contains("j1", "wf1") is True + assert m.contains("j1", "wf2") is False + + +def test_ledger_append_none(): + m = MemoryLedger() + seq = m.append("jws", None) + assert seq == 0 diff --git a/workspace/packages/ect/tests/test_types_extra.py b/workspace/packages/ect/tests/test_types_extra.py new file mode 100644 index 0000000..accbc45 --- /dev/null +++ b/workspace/packages/ect/tests/test_types_extra.py @@ -0,0 +1,64 @@ +"""Additional tests for types module.""" + +import pytest + +from ect import Payload + + +def test_payload_contains_audience(): + p = Payload(iss="", aud=["a", "b"], iat=0, exp=0, jti="", exec_act="", pred=[]) + assert p.contains_audience("a") is True + assert p.contains_audience("c") is False + + +def test_payload_compensation_required(): + p = Payload(iss="", aud=[], iat=0, exp=0, jti="", exec_act="", pred=[]) + assert p.compensation_required() is False + p.ext = {"compensation_required": True} + assert p.compensation_required() is True + + +def test_payload_has_policy_claims(): + p = Payload(iss="", aud=[], iat=0, exp=0, jti="", exec_act="", pred=[], + ext={"pol": "p", "pol_decision": "approved"}) + assert p.has_policy_claims() is True + p.ext = {"pol_decision": "approved"} + assert p.has_policy_claims() is False + p.ext = None + assert p.has_policy_claims() is False + + +def test_payload_pol_decision(): + p = Payload(iss="", aud=[], iat=0, exp=0, jti="", exec_act="", pred=[], + ext={"pol_decision": "rejected"}) + assert p.pol_decision() == "rejected" + p.ext = None + assert p.pol_decision() == "" + + +def test_payload_to_claims_optional(): + p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", pred=[], wid="wf") + claims = p.to_claims() + assert claims["wid"] == "wf" + assert "ect_ext" not in claims or not claims.get("ect_ext") + + +def test_payload_from_claims_aud_string(): + claims = {"iss": "i", "aud": "single", "iat": 1, "exp": 2, "jti": "j", "exec_act": "e", "pred": []} + p = Payload.from_claims(claims) + assert p.aud == ["single"] + + +def test_payload_to_claims_all_optional(): + p = Payload( + iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", pred=[], + wid="w", inp_hash="h", out_hash="o", inp_classification="c", + ext={"pol": "p", "pol_decision": "approved"}, + ) + claims = p.to_claims() + assert claims["wid"] == "w" + assert claims["inp_hash"] == "h" + assert claims["out_hash"] == "o" + assert claims["inp_classification"] == "c" + assert claims["ect_ext"]["pol"] == "p" + assert claims["ect_ext"]["pol_decision"] == "approved" diff --git a/workspace/packages/ect/tests/test_validate.py b/workspace/packages/ect/tests/test_validate.py new file mode 100644 index 0000000..a560d5b --- /dev/null +++ b/workspace/packages/ect/tests/test_validate.py @@ -0,0 +1,64 @@ +"""Tests for validate module.""" + +import json +import pytest + +from ect.validate import ( + EXT_MAX_DEPTH, + EXT_MAX_SIZE, + validate_ext, + validate_hash_format, + valid_uuid, +) + + +def test_valid_uuid(): + assert valid_uuid("550e8400-e29b-41d4-a716-446655440000") is True + assert valid_uuid("00000000-0000-0000-0000-000000000000") is True + assert valid_uuid("") is False + assert valid_uuid("not-a-uuid") is False + assert valid_uuid("550e8400e29b41d4a716446655440000") is False # no dashes + + +def test_validate_ext_none(): + validate_ext(None) + validate_ext({}) + + +def test_validate_ext_size(): + # Serialized JSON must exceed EXT_MAX_SIZE (4096) bytes + big = {"x": "y" * (EXT_MAX_SIZE - 2)} # "{\"x\":\"...\"}" + payload + raw = json.dumps(big) + assert len(raw.encode("utf-8")) > EXT_MAX_SIZE + with pytest.raises(ValueError, match="max size"): + validate_ext(big) + + +def test_validate_ext_depth(): + deep = {"a": 1} + for _ in range(EXT_MAX_DEPTH): + deep = {"n": deep} + with pytest.raises(ValueError, match="depth"): + validate_ext(deep) + + +def test_validate_hash_format_empty(): + validate_hash_format("") + + +def test_validate_hash_format_ok(): + # Plain base64url per RFC 9449 / ECT spec (no algorithm prefix) + validate_hash_format("YQ") + validate_hash_format("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") + validate_hash_format("abc123-_XYZ") + + +def test_validate_hash_format_bad(): + # Colon is not valid base64url — rejects old prefixed format + with pytest.raises(ValueError, match="plain base64url"): + validate_hash_format("sha-256:YQ") + with pytest.raises(ValueError, match="plain base64url"): + validate_hash_format("not valid!!") + # Null byte in payload + with pytest.raises(ValueError, match="plain base64url"): + validate_hash_format("YQ\x00") diff --git a/workspace/packages/ect/tests/test_verify.py b/workspace/packages/ect/tests/test_verify.py new file mode 100644 index 0000000..1cb3c83 --- /dev/null +++ b/workspace/packages/ect/tests/test_verify.py @@ -0,0 +1,194 @@ +"""Tests for verify module.""" + +import time + +import pytest + +from ect import ( + Payload, + create, + generate_key, + CreateOptions, + parse, + verify, + VerifyOptions, + default_verify_options, +) + + +def test_parse(): + key = generate_key() + now = int(time.time()) + p = Payload( + iss="iss", aud=["a"], iat=now, exp=now + 3600, + jti="jti-parse", exec_act="act", pred=[], + ) + compact = create(p, key, CreateOptions(key_id="kid")) + parsed = parse(compact) + assert parsed.payload.jti == "jti-parse" + assert parsed.raw == compact + + +def test_default_verify_options(): + opts = default_verify_options() + assert opts.dag is not None + assert opts.iat_max_age_sec == 900 + + +def test_verify_expired(): + key = generate_key() + now = int(time.time()) + p = Payload( + iss="iss", aud=["v"], iat=now - 3600, exp=now - 60, + jti="jti-exp", exec_act="act", pred=[], + ) + compact = create(p, key, CreateOptions(key_id="kid")) + resolver = lambda kid: key.public_key() if kid == "kid" else None + with pytest.raises(ValueError, match="expired"): + verify(compact, VerifyOptions(verifier_id="v", resolve_key=resolver, now=now)) + + +def test_verify_replay(): + key = generate_key() + now = int(time.time()) + p = Payload( + iss="iss", aud=["v"], iat=now, exp=now + 3600, + jti="jti-replay", exec_act="act", pred=[], + ) + compact = create(p, key, CreateOptions(key_id="kid")) + resolver = lambda kid: key.public_key() if kid == "kid" else None + with pytest.raises(ValueError, match="replay"): + verify(compact, VerifyOptions( + verifier_id="v", resolve_key=resolver, now=now, + jti_seen=lambda j: j == "jti-replay", + )) + + +def test_verify_invalid_typ(): + import jwt as jwt_lib + with pytest.raises((ValueError, jwt_lib.exceptions.DecodeError)): + verify("not-a-jws", VerifyOptions()) + + +def test_verify_audience_mismatch(): + key = generate_key() + now = int(time.time()) + p = Payload( + iss="iss", aud=["other"], iat=now, exp=now + 3600, + jti="jti-a", exec_act="act", pred=[], + ) + compact = create(p, key, CreateOptions(key_id="kid")) + resolver = lambda kid: key.public_key() if kid == "kid" else None + with pytest.raises(ValueError, match="audience"): + verify(compact, VerifyOptions(verifier_id="verifier", resolve_key=resolver, now=now)) + + +def test_verify_wit_subject_mismatch(): + key = generate_key() + now = int(time.time()) + p = Payload( + iss="wrong-iss", aud=["v"], iat=now, exp=now + 3600, + jti="jti-w", exec_act="act", pred=[], + ) + compact = create(p, key, CreateOptions(key_id="kid")) + resolver = lambda kid: key.public_key() if kid == "kid" else None + with pytest.raises(ValueError, match="WIT subject"): + verify(compact, VerifyOptions( + verifier_id="v", resolve_key=resolver, now=now, wit_subject="correct-iss", + )) + + +def test_verify_iat_too_old(): + key = generate_key() + now = int(time.time()) + p = Payload( + iss="iss", aud=["v"], iat=now - 2000, exp=now + 3600, + jti="jti-old", exec_act="act", pred=[], + ) + compact = create(p, key, CreateOptions(key_id="kid")) + resolver = lambda kid: key.public_key() if kid == "kid" else None + with pytest.raises(ValueError, match="iat"): + verify(compact, VerifyOptions( + verifier_id="v", resolve_key=resolver, now=now, iat_max_age_sec=900, + )) + + +def test_verify_unknown_key(): + key = generate_key() + now = int(time.time()) + p = Payload( + iss="iss", aud=["v"], iat=now, exp=now + 3600, + jti="jti-k", exec_act="act", pred=[], + ) + compact = create(p, key, CreateOptions(key_id="kid")) + resolver = lambda kid: None # unknown key + with pytest.raises(ValueError, match="unknown key"): + verify(compact, VerifyOptions(verifier_id="v", resolve_key=resolver, now=now)) + + +def test_verify_resolve_key_required(): + key = generate_key() + now = int(time.time()) + p = Payload( + iss="iss", aud=["v"], iat=now, exp=now + 3600, + jti="jti-r", exec_act="act", pred=[], + ) + compact = create(p, key, CreateOptions(key_id="kid")) + with pytest.raises(ValueError, match="ResolveKey"): + verify(compact, VerifyOptions(verifier_id="v", resolve_key=None)) + + +def test_verify_with_dag(): + from ect import MemoryLedger + key = generate_key() + ledger = MemoryLedger() + now = int(time.time()) + root = Payload( + iss="iss", aud=["v"], iat=now, exp=now + 3600, + jti="jti-root", exec_act="act", pred=[], + ) + compact_root = create(root, key, CreateOptions(key_id="kid")) + resolver = lambda kid: key.public_key() if kid == "kid" else None + opts = VerifyOptions(verifier_id="v", resolve_key=resolver, store=ledger, now=now) + parsed = verify(compact_root, opts) + ledger.append(compact_root, parsed.payload) + child = Payload( + iss="iss", aud=["v"], iat=now + 1, exp=now + 3600, + jti="jti-child", exec_act="act2", pred=["jti-root"], + ) + compact_child = create(child, key, CreateOptions(key_id="kid")) + parsed2 = verify(compact_child, opts) + assert parsed2.payload.jti == "jti-child" + + +def test_on_verify_attempt_callback(): + """Observability: on_verify_attempt is called with jti and error (or None).""" + key = generate_key() + now = int(time.time()) + p = Payload(iss="i", aud=["v"], iat=now, exp=now + 3600, jti="jti-obs", exec_act="a", pred=[]) + compact = create(p, key, CreateOptions(key_id="kid")) + resolver = lambda k: key.public_key() if k == "kid" else None + seen = [] + def hook(jti, err): + seen.append((jti, err)) + opts = VerifyOptions(verifier_id="v", resolve_key=resolver, on_verify_attempt=hook) + result = verify(compact, opts) + assert result.payload.jti == "jti-obs" + assert len(seen) == 1 + assert seen[0][0] == "jti-obs" + assert seen[0][1] is None + + +def test_on_verify_attempt_called_on_failure(): + key = generate_key() + now = int(time.time()) + p = Payload(iss="i", aud=["v"], iat=now, exp=now - 1, jti="jti-fail", exec_act="a", pred=[]) + compact = create(p, key, CreateOptions(key_id="kid")) + resolver = lambda k: key.public_key() if k == "kid" else None + seen = [] + opts = VerifyOptions(verifier_id="v", resolve_key=resolver, now=now, on_verify_attempt=lambda jti, err: seen.append((jti, err))) + with pytest.raises(ValueError, match="expired"): + verify(compact, opts) + assert len(seen) == 1 + assert seen[0][0] == "jti-fail" + assert seen[0][1] is not None diff --git a/workspace/packages/pyproject.toml b/workspace/packages/pyproject.toml new file mode 100644 index 0000000..5d19eca --- /dev/null +++ b/workspace/packages/pyproject.toml @@ -0,0 +1,2 @@ +[tool.uv.workspace] +members = ["act", "ect"]