feat: add draft data, gap analysis report, and workspace config
Some checks failed
CI / test (3.11) (push) Failing after 1m37s
CI / test (3.12) (push) Failing after 57s

This commit is contained in:
2026-04-06 18:47:15 +02:00
parent 4f310407b0
commit 2506b6325a
189 changed files with 62649 additions and 0 deletions

View File

@@ -0,0 +1,261 @@
# ACT (Agent Compact Token) — Medium Blog Post Series
## Series Title: "Securing the Agentic Web"
Target audience: Backend/platform engineers, AI practitioners, security-minded architects building multi-agent systems.
---
## Post 1: "Why OAuth Isn't Enough for AI Agents"
**Hook**: OAuth was built for humans clicking "Allow." Autonomous agents don't click buttons — they chain tasks, delegate to sub-agents, and need cryptographic proof of what they did, not just what they were allowed to do.
**Key points**:
- OAuth assumes a human in the loop (consent screens, redirect flows)
- Agents need machine-to-machine auth that works without an Authorization Server
- The missing piece: **accountability** — OAuth proves authorization, but not execution
- Agents operate across organizational boundaries; federated trust is essential
- Existing alternatives and their gaps:
- **SPIFFE/SPIRE**: Workload identity, but no capability scoping or execution records
- **ZCAP-LD**: Capability delegation, but JSON-LD complexity and no execution phase
- **Macaroons**: Contextual caveats, but no standardized structure for agent workflows
- **OPA/Cedar**: Policy engines, but no portable token format
**Diagram idea**: Side-by-side comparison — OAuth flow vs. ACT flow for an agent task
**Call to action**: Introduce ACT as "authorization + accountability in a single token"
---
## Post 2: "One Token, Two Lives — The ACT Lifecycle"
**Hook**: What if your authorization token could *grow up* — transforming from a permission slip into a receipt?
**Key points**:
- **Phase 1 — Authorization Mandate**: Signed by the issuing agent
- "Agent A authorizes Agent B to do X, with these constraints, until time T"
- JOSE header: `typ: "act+jwt"`, `alg: "EdDSA"`, `kid`
- Claims: `iss`, `sub`, `aud`, `exp`, `jti` (UUID v4), `task`, `cap`
- The `task` object: purpose, data sensitivity, created_by
- The `cap` array: fine-grained actions with constraints
- **Phase 2 — Execution Record**: Re-signed by the executing agent
- Same token, new claims added: `exec_act`, `par`, `exec_ts`, `status`
- Critical: header `kid` switches from issuer's key to executor's key
- Optional: `inp_hash` and `out_hash` (SHA-256 of actual I/O data)
- The `err` object for failure cases
**Code example**: Show a Phase 1 JWT payload, then the same payload with Phase 2 claims added
**Why it matters**: One token = complete audit trail. No separate logging system needed. The token *is* the evidence.
**Analogy**: A signed work order (Phase 1) that becomes a signed completion certificate (Phase 2)
---
## Post 3: "Zero to PKI — ACT's Trust Tier Progression"
**Hook**: Most security specs start with "first, set up your PKI infrastructure." ACT starts with "first, share a key over Signal."
**Key points**:
- **Tier 1 — Pre-Shared Keys** (mandatory to implement)
- Ed25519 key pairs shared out-of-band
- `kid` is just an opaque string both parties agree on
- Zero infrastructure. Works on day one. Perfect for prototyping and internal agents
- Key registry: simple dict mapping `kid → public_key_bytes`
- **Tier 2 — PKI / X.509**
- `kid` = SHA-256 thumbprint of DER-encoded certificate
- `x5c` JOSE header carries the certificate chain
- Standard X.509 chain validation against trusted CA store
- For enterprises with existing PKI
- **Tier 3 — Decentralized Identifiers (DIDs)**
- `did:key` — self-contained, no resolution needed (Ed25519 public key in the DID itself)
- `did:web` — HTTP-resolvable, cacheable with TTL
- For cross-organizational federation without shared CA
**Diagram idea**: Three-rung ladder — each tier adds infrastructure but also adds trust guarantees
**Why it matters**: You can start using ACT in 10 minutes with pre-shared keys, then upgrade to PKI or DIDs as your deployment matures. No rip-and-replace.
---
## Post 4: "Delegation Without Escalation — How ACT Prevents Rogue Sub-Agents"
**Hook**: When Agent A delegates to Agent B who delegates to Agent C... how do you ensure C can't do more than A originally allowed?
**Key points**:
- The `del` claim: `{ depth, max_depth, chain[] }`
- `chain` is ordered root → immediate parent
- Each entry: `{ delegator, jti, sig }`
- **Capability attenuation**: child's `cap` MUST be a subset of parent's `cap`
- Constraints can only get MORE restrictive, never less
- Example: parent allows `data.read` with `max_rows: 1000` → child can set `max_rows: 100` but not `max_rows: 5000`
- **Chain verification**:
- `sig = Sign(delegator.private_key, SHA-256(parent_act_compact_bytes))`
- Each chain entry verified against delegator's public key
- **Rejection conditions** (5 ways delegation fails):
1. `depth > max_depth`
2. `chain.length != depth`
3. Any chain signature fails
4. `cap` contains actions not in parent's `cap`
5. Any constraint is less restrictive than parent's
**Code example**: Three-agent delegation chain with progressively narrower capabilities
**Analogy**: Power of attorney — you can give someone authority to act on your behalf, and they can sub-delegate, but each level can only narrow the scope, never widen it
---
## Post 5: "Following the Thread — DAG-Based Execution Tracking"
**Hook**: In a world where agents spawn agents that spawn agents, how do you answer "what happened and in what order?"
**Key points**:
- Every Phase 2 ACT has a `par` (parents) array — JTIs of predecessor tasks
- `[]` for root tasks (no parents)
- Multiple parents = fan-in (joining parallel branches)
- The `jti` itself serves as the task ID
- **DAG validation rules**:
1. `jti` uniqueness within `wid` (workflow) scope
2. Every parent `jti` must exist as a verified Phase 2 ACT in the ledger
3. Temporal ordering: `parent.exec_ts < child.exec_ts + 30s` (clock skew tolerance)
4. Acyclicity: max 10,000-node traversal limit
5. `exec_act` must match one of the `cap[].action` values
- **Workflow grouping**: Optional `wid` (workflow ID) groups related ACTs
**Diagram idea**: A DAG of 6-8 tasks showing fan-out (parallel dispatch) and fan-in (result aggregation)
**Real-world example**: ML pipeline — data fetch → preprocess (fan-out to 3 shards) → train (fan-in) → evaluate → deploy
**Why it matters**: Compliance, debugging, incident response. "Show me every action taken in workflow X, in causal order, with cryptographic proof."
---
## Post 6: "Humans in the Loop — ACT's Oversight Mechanism"
**Hook**: Full autonomy is terrifying. Full control is impractical. ACT's oversight mechanism is the middle ground.
**Key points**:
- The `oversight` claim:
- `requires_approval_for`: array of action strings that need human sign-off
- `approval_ref`: reference to the approval record
- How it works in practice:
- Agent receives mandate with `oversight.requires_approval_for: ["data.delete"]`
- Before executing `data.delete`, agent must obtain approval
- Approval reference stored in `oversight.approval_ref`
- Selective oversight: Only specific high-risk actions require approval, not everything
- Composable with delegation: oversight requirements propagate down the chain
**Why it matters**: Regulatory compliance (GDPR right-to-delete, financial transactions), enterprise risk management, building trust in agent systems incrementally
---
## Post 7: "Threat Model Deep Dive — What ACT Defends Against"
**Hook**: Every security spec claims to be secure. Here's exactly what ACT protects against — and what it doesn't.
**Key points**:
- **Defended threats**:
- Token forgery (Ed25519 signatures, no symmetric algs, no "alg: none")
- Privilege escalation (capability attenuation in delegation chains)
- Replay attacks (jti uniqueness, exp enforcement, clock skew tolerance ≤300s)
- Execution fabrication (Phase 2 re-signature by sub's key, not iss's key)
- Audit trail tampering (hash-chained append-only ledger)
- Man-in-the-middle on delegation (chain signatures bind to parent token bytes)
- **Security constraints in implementation**:
- Algorithm allowlist: EdDSA (Ed25519), ES256 only. No HS*, no "none"
- Key material zeroed on deletion
- iat must not be unreasonably future (≤30s)
- aud verification mandatory
- **Out of scope** (honest about limitations):
- Token revocation (not in v00 — mentioned as future work)
- Confidentiality of token contents (JWS, not JWE)
- Compromised agent keys (standard key management applies)
**Table**: Threat → ACT mitigation → Residual risk
---
## Post 8: "ACT Meets the IETF — Why Standardization Matters for Agent Interop"
**Hook**: Your agents and my agents need to talk. Without a standard, we're back to building custom integrations for every partner.
**Key points**:
- The IETF process: Internet-Draft → RFC pathway
- `draft-nennemann-act-00` is the first submission
- Building on existing standards: RFC 7519 (JWT), RFC 7515 (JWS), RFC 7518 (JWA)
- ABNF notation for action names, formal CDDL-style structures
- Why an IETF RFC vs. a blog post or GitHub repo:
- Interoperability testing with multiple implementations
- Formal security review
- Stable reference for contracts and regulations
- Media type registration: `application/act+jwt`
- **Interoperability requirements**:
- MUST implement Tier 1 (pre-shared keys)
- MUST support EdDSA (Ed25519)
- MUST verify delegation chains
- MUST enforce DAG validation
- Comparison with other agent-related standards efforts
**Why it matters**: As AI agents become infrastructure, we need the same rigor we applied to HTTP, TLS, and OAuth
---
## Post 9: "Building Your First ACT Agent — A Hands-On Tutorial"
**Hook**: Enough theory. Let's build two agents that authorize, execute, and audit a task using ACT.
**Key points** (step-by-step tutorial):
1. Install the `act` Python package
2. Generate Ed25519 key pairs for two agents
3. Create a Phase 1 mandate (Agent A → Agent B: "read customer data")
4. Agent B validates the mandate
5. Agent B executes and creates Phase 2 record (with inp_hash/out_hash)
6. Append to audit ledger
7. Verify the complete chain
8. Add delegation: Agent B delegates subset to Agent C
9. Verify Agent C's execution in the DAG
**Code examples**: Complete working Python code for each step, using the reference implementation
**Real-world scenario**: E-commerce — order service authorizes inventory agent to check stock, inventory agent delegates to warehouse-specific sub-agents
---
## Post 10: "The Road Ahead — ACT's Evolution and the Agentic Future"
**Hook**: ACT v00 is a foundation. Here's what's coming and how you can shape it.
**Key points**:
- **Future work mentioned in the draft**:
- Token revocation mechanisms
- Formal capability algebra (lattice-based constraint reasoning)
- Performance benchmarks across implementations
- Integration patterns with existing auth infrastructure
- Privacy-preserving execution records (selective disclosure)
- **Community and contribution**:
- Reference implementation as the interoperability baseline
- Test vectors (Appendix B) as the conformance suite
- How to write a second implementation (in Rust, Go, etc.)
- **The bigger picture**:
- Agent-to-agent economy needs trust primitives
- ACT as one layer in the agentic stack
- Composability with other standards (OpenID for agents, SCIM, etc.)
---
## Cross-Series Themes
1. **Progressive complexity**: Each post builds on the previous, but each can stand alone
2. **Code-first**: Every concept illustrated with real Python snippets from the reference implementation
3. **Honest trade-offs**: Acknowledge what ACT doesn't solve (yet)
4. **Standards matter**: Thread the IETF standardization story throughout
5. **Real-world grounding**: Each post connects to concrete use cases
## Publication Strategy
- **Cadence**: 1 post per week, 10-week series
- **Length**: 1,500-2,500 words per post (8-12 min read)
- **Tags**: AI, Security, Authentication, Distributed Systems, IETF
- **Series landing page**: Link all posts, provide a "start here" guide
- **Code repo**: Link to the reference implementation throughout
- **Post 9** can be published on dev.to / Hashnode in addition to Medium for developer reach

View File

@@ -0,0 +1,312 @@
# Master Prompt: ACT Reference Implementation
## Context
You are implementing the reference implementation for the Agent Compact
Token (ACT), as defined in `draft-nennemann-act-00`. The full draft is
attached or provided in context. Your implementation must be a clean,
well-documented Python package that serves as the normative reference
for the specification — meaning it is the ground truth for
interoperability testing.
The ACS (Agent Compliance Seal) reference implementation (~1,900 lines,
Python) exists as a prior art reference for code style and structure.
ACT follows the same philosophy: minimal dependencies, sub-millisecond
performance for the hot path, Ed25519 as the primary algorithm.
---
## Deliverables
Produce the following files in a single `act/` package directory:
```
act/
├── __init__.py # Public API exports
├── token.py # ACTMandate and ACTRecord dataclasses + serialization
├── crypto.py # Key management: Tier 1 (pre-shared), Tier 2 (PKI),
│ # Tier 3 (DID:key, DID:web); sign/verify primitives
├── lifecycle.py # Phase 1 → Phase 2 transition logic (re-signing)
├── delegation.py # Delegation chain construction and verification
├── dag.py # DAG validation (uniqueness, parent existence,
│ # temporal ordering, acyclicity, capability
│ # consistency)
├── ledger.py # In-memory append-only audit ledger (for testing;
│ # interface suitable for external backends)
├── verify.py # Unified verification entry point (Phase 1 + Phase 2)
├── errors.py # All ACT-specific exception types
└── vectors.py # Generates and validates all Appendix B test vectors
tests/
├── test_token.py
├── test_crypto.py
├── test_lifecycle.py
├── test_delegation.py
├── test_dag.py
├── test_ledger.py
├── test_verify.py
└── test_vectors.py # Must pass all vectors defined in vectors.py
```
---
## Specification Summary
### Token Structure
An ACT is a JWT (JWS Compact Serialization). It has two phases:
**Phase 1 — Authorization Mandate** (signed by issuing agent):
JOSE Header:
```json
{
"alg": "EdDSA", // Ed25519; also support ES256
"typ": "act+jwt",
"kid": "<key-id>"
// optional: "x5c" (Tier 2), "did" (Tier 3)
}
```
Required JWT claims:
- `iss` — issuer agent identifier (opaque string / X.509 DN / DID)
- `sub` — target agent identifier (same format as iss)
- `aud` — intended recipient(s); string or array
- `iat` — issuance time (NumericDate)
- `exp` — expiration time (NumericDate); SHOULD be iat + ≤900s for automated flows
- `jti` — UUID v4; doubles as task identifier for DAG par references
Optional:
- `wid` — workflow UUID grouping related ACTs
Required ACT-specific claims:
- `task` — object: { purpose (str, REQUIRED), data_sensitivity (str, OPTIONAL),
created_by (str, OPTIONAL), expires_at (NumericDate, OPTIONAL) }
- `cap` — array of { action (str, REQUIRED), constraints (object, OPTIONAL) }
action names conform to ABNF: component *("." component)
component = ALPHA *(ALPHA / DIGIT / "-" / "_")
- `del` — object: { depth (int), max_depth (int), chain (array) }
chain entries: { delegator (str), jti (str), sig (base64url str) }
chain is ordered root → immediate parent (chain[0] = root authority)
If `del` is absent: treat as root mandate, depth=0, delegation forbidden
Optional:
- `oversight` — { requires_approval_for (array of action strings),
approval_ref (str, OPTIONAL) }
**Phase 2 — Execution Record** (re-signed by executing agent, i.e. `sub`):
All Phase 1 claims are preserved unchanged. Additional required claims:
- `exec_act` — string; MUST match one of the cap[].action values
- `par` — array of jti strings (parent task IDs in DAG); [] for root tasks
- `exec_ts` — NumericDate; actual execution time; MUST be >= iat; SHOULD be <= exp
(if exec_ts > exp: log warning, do NOT reject)
- `status` — one of: "completed", "failed", "partial"
Additional optional claims:
- `inp_hash` — base64url(SHA-256(raw input bytes)), no padding
- `out_hash` — base64url(SHA-256(raw output bytes)), no padding
- `err` — { code (str), detail (str) }; present when status != "completed"
**Critical**: In Phase 2, the JOSE header `kid` MUST reference the `sub`
agent's key (not the `iss` agent's key). The re-signature is produced by
the executing agent over the complete Phase 2 payload (all Phase 1 claims
+ execution claims combined).
---
### Trust Tiers
**Tier 1 — Pre-Shared Keys (mandatory-to-implement)**
- Keys: Ed25519 (primary) or P-256
- `kid`: opaque string agreed out-of-band
- Key registry: a dict mapping kid → public key bytes, configured at init time
- No external resolution needed
**Tier 2 — PKI / X.509**
- `kid`: SHA-256 thumbprint of DER-encoded certificate
- `x5c` JOSE header MAY carry the certificate chain
- Verification: standard X.509 chain validation against trusted CA store
**Tier 3 — DID**
- Support `did:key` (self-contained, no resolution needed)
- Support `did:web` (requires HTTP resolution; cache with configurable TTL)
- `kid`: DID key fragment (e.g. `did:key:z6Mk...#key-1`)
- `did` JOSE header MAY carry the full DID for resolution
---
### Delegation Chain
When issuing a delegated ACT (Agent A → Agent B):
1. `del.depth` = parent ACT's `del.depth` + 1
2. `del.max_depth` ≤ parent ACT's `del.max_depth`
3. `cap` must be a subset of parent ACT's `cap` with constraints at least
as restrictive
4. Each chain entry `sig` = Sign(A.private_key, SHA-256(parent_act_compact_bytes))
where `parent_act_compact_bytes` is the raw bytes of the parent ACT's
JWS Compact Serialization (UTF-8 encoded)
Verification of chain entry:
- Retrieve public key for entry.delegator
- Recompute SHA-256(parent_act_compact_bytes)
- Verify entry.sig against that hash using entry.delegator's public key
Rejection conditions:
- `del.depth` > `del.max_depth`
- `del.chain` length != `del.depth`
- Any chain entry sig fails verification
- `cap` contains actions not in parent ACT's `cap`
- Any constraint in `cap` is less restrictive than in parent ACT
---
### DAG Validation (Phase 2)
The ACT ledger (or set of received parent ACTs) is the ECT store.
Required checks on receiving a Phase 2 ACT:
1. `jti` uniqueness within `wid` scope (or globally if `wid` absent)
2. Every `jti` in `par` exists in the ledger/store as a verified Phase 2 ACT
3. For each parent: `parent.exec_ts < child.exec_ts + 30s` (clock skew tolerance)
4. No cycle: following `par` references must not return to current `jti`
— enforce max traversal limit of 10,000 nodes
5. `exec_act` matches one of the `cap[].action` values in the Phase 1 claims
---
### Verification Procedure
**Phase 1 verification** (ACTVerifier.verify_mandate):
1. Parse JWS Compact Serialization
2. Check `typ` == "act+jwt"
3. Check `alg` in allowlist (must include EdDSA/Ed25519, ES256; MUST NOT include
"none" or any HS* algorithm)
4. Resolve public key for `kid` per trust tier
5. Verify JWS signature
6. Check `exp` not passed (clock skew tolerance: ≤300s)
7. Check `iat` not unreasonably future (≤30s ahead)
8. Check `aud` contains verifier's own identifier
9. Check `iss` is trusted per local policy
10. Check `sub` matches verifier's own identifier (when verifier is the target)
11. Check all required claims present and well-formed
12. If `del.chain` non-empty: verify delegation chain
**Phase 2 verification** (ACTVerifier.verify_record):
All Phase 1 steps, plus:
13. Check `exec_act` present and matches a `cap[].action`
14. Check `par` present; perform DAG validation
15. Check `exec_ts` present and >= `iat`; if > `exp` log warning but do NOT reject
16. Check `status` present and valid
17. Check re-signature was produced by `sub` agent's key (kid in Phase 2
header must correspond to sub's public key, not iss's key)
18. Optionally verify `inp_hash`/`out_hash` against provided data
---
### Audit Ledger Interface
`ACTLedger` (in-memory reference implementation):
- `append(act_record: ACTRecord) -> int` — returns sequence number
- `get(jti: str) -> ACTRecord | None`
- `list(wid: str | None) -> list[ACTRecord]`
- `verify_integrity() -> bool` — verifies no records have been tampered with
(hash-chain over sequence-ordered records)
The ledger must enforce append-only semantics: once appended, a record
cannot be modified or deleted. Raise `ACTLedgerImmutabilityError` on
any attempt.
---
### Error Types
Define in `errors.py`:
```python
ACTError # base
ACTValidationError # malformed token structure
ACTSignatureError # signature verification failed
ACTExpiredError # token expired
ACTAudienceMismatchError # aud does not contain verifier identity
ACTCapabilityError # no matching capability / capability escalation
ACTDelegationError # delegation chain invalid
ACTDAGError # DAG validation failed (cycle, missing parent, etc.)
ACTPhaseError # wrong phase for operation (e.g. mandate used as record)
ACTKeyResolutionError # cannot resolve kid to public key
ACTLedgerImmutabilityError # attempt to modify ledger
ACTPrivilegeEscalationError # delegated cap exceeds parent cap
```
---
### Test Vectors (Appendix B)
`vectors.py` must generate and validate all of the following. Each vector
must include: description, input parameters, expected output (encoded token
or expected exception class).
**Valid vectors:**
- B.1: Phase 1 ACT — root mandate, Tier 1 (Ed25519 pre-shared key), no delegation
- B.2: Phase 2 ACT — completed execution, transition from B.1 mandate
- B.3: Phase 2 ACT — fan-in, two parent jti values from parallel branches
- B.4: Phase 1 ACT — delegated mandate (depth=1), chain entry with sig
- B.5: Phase 2 ACT — delegated execution record
**Invalid vectors (must raise specified exception):**
- B.6: `del.depth` > `del.max_depth` → ACTDelegationError
- B.7: `cap` escalation in delegated ACT → ACTPrivilegeEscalationError
- B.8: `exec_act` not in `cap` → ACTCapabilityError
- B.9: DAG cycle (par references own jti) → ACTDAGError
- B.10: Missing parent jti in DAG → ACTDAGError
- B.11: Tampered payload (bit flip in claims) → ACTSignatureError
- B.12: Expired token → ACTExpiredError
- B.13: Wrong audience → ACTAudienceMismatchError
- B.14: Phase 2 re-signed by iss key instead of sub → ACTSignatureError
- B.15: Algorithm "none" → ACTValidationError
---
## Implementation Constraints
**Dependencies**: use only the Python standard library plus:
- `cryptography` (for Ed25519, P-256, X.509)
- `pyjwt` OR manual JWS implementation (prefer manual for spec fidelity)
- `pytest` (test runner only)
**Performance target**: Phase 1 creation ≤ 500µs mean on modern hardware.
Benchmark in a `bench/` directory.
**Code style**:
- Type-annotated throughout (Python 3.11+)
- Dataclasses for token structures
- No global mutable state
- All public API functions documented with docstrings referencing the
relevant draft section (e.g. `# ACT §8.1`)
**Security constraints**:
- MUST NOT use symmetric algorithms (HS256 etc.) anywhere
- MUST NOT implement "alg: none" path
- Ed25519 signing MUST use bound key-pair APIs (private key object that
carries the public key) — never pass raw private key bytes
- All secret key material must be zeroed on deletion where the
cryptography library supports it
**What NOT to implement**:
- DID:web resolution with live HTTP calls in the reference implementation
(stub it with a configurable resolver callback instead)
- Token revocation infrastructure
- Persistence (ledger is in-memory only)
---
## Output Format
Produce each file completely, in order. After all files, produce a
`README.md` for the `act/` package that includes:
- Installation instructions
- Quick-start example (Phase 1 mandate → Phase 2 record → verify)
- Running the test suite
- Running the test vectors
- Performance benchmark instructions
At the end, confirm: "All Appendix B test vectors pass."

View File

@@ -0,0 +1,119 @@
"""Agent Compact 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-00.
"""
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",
]

467
workspace/act/act/crypto.py Normal file
View File

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

136
workspace/act/act/dag.py Normal file
View File

@@ -0,0 +1,136 @@
"""ACT DAG validation for Phase 2 execution records.
Validates the directed acyclic graph formed by par (parent) references
in Phase 2 ACTs, ensuring uniqueness, parent 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. Parent 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 parent 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: Parent existence and temporal ordering
for parent_jti in record.par:
parent = store.get(parent_jti)
if parent is None:
raise ACTDAGError(
f"Parent jti {parent_jti!r} not found in store"
)
# Temporal ordering: parent.exec_ts < child.exec_ts + tolerance
if parent.exec_ts >= record.exec_ts + clock_skew_tolerance:
raise ACTDAGError(
f"Temporal ordering violation: parent {parent_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.par, store)
def _check_acyclicity(
current_jti: str,
parent_jtis: list[str],
store: ACTStore,
) -> None:
"""Check that following par 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(parent_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.par)

View File

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

131
workspace/act/act/errors.py Normal file
View File

@@ -0,0 +1,131 @@
"""ACT-specific exception types.
All exceptions defined in this module correspond to specific failure
modes in the Agent Compact Token lifecycle as defined in
draft-nennemann-act-00.
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-00.
"""
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).
"""

152
workspace/act/act/ledger.py Normal file
View File

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

View File

@@ -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,
par: 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).
par: Parent 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,
par=par if par 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

734
workspace/act/act/token.py Normal file
View File

@@ -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 = ""
par: 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,
"par": self.par,
"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,
par: 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,
par=par if par 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"],
par=claims.get("par", []),
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)}"
)

View File

@@ -0,0 +1,639 @@
"""ACT Appendix B test vectors.
Generates and validates all 15 test vectors from Appendix B of
draft-nennemann-act-00. 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",
par=[],
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", par=[], 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", par=[], 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",
par=[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 parent 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",
par=[],
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",
par=[], 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 (par 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",
par=[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 (par 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",
par=["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",
par=[], 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

323
workspace/act/act/verify.py Normal file
View File

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

View File

@@ -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", par=[], 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", par=[], 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()

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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, par=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,
par=par 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", par=["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", par=["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", par=["nonexistent"])
with pytest.raises(ACTDAGError, match="not found"):
validate_dag(r, ledger)
def test_self_cycle(self):
ledger = ACTLedger()
r = make_record("cycle", par=["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", par=["b"], exec_ts=1772064100)
b = make_record("b", par=["a"], exec_ts=1772064100)
ledger.append(b)
# When validating a, following par leads to b,
# which has par=["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", par=["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", par=["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)

View File

@@ -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)

View File

@@ -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", par=[], 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

View File

@@ -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", par=[], 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", par=[], 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", par=[], 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", par=[],
)
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", par=[], 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

View File

@@ -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",
par=[], 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",
par=[], 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",
par=[], 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",
par=[], status="completed",
)
sig = sign(priv, record.signing_input())
compact = encode_jws(record, sig)
parsed = parse_token(compact)
assert isinstance(parsed, ACTRecord)

View File

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

View File

@@ -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", par=[], 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", par=[],
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",
par=[], 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", par=[],
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"