diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 6f25a18..576c99f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -61,7 +61,7 @@ jobs: if [ -d docs ]; then cp -r docs $BUILD/docs; fi - for fname in index.md CHANGELOG.md CONTRIBUTING.md GOVERNANCE.md CHARTER.md CODE_OF_CONDUCT.md SECURITY.md PRIVACY.md ROADMAP.md LIMITATIONS.md CNAME; do + for fname in index.md CHANGELOG.md CONTRIBUTING.md GOVERNANCE.md CHARTER.md CODE_OF_CONDUCT.md SECURITY.md PRIVACY.md ROADMAP.md LIMITATIONS.md SPONSORS.md CNAME; do if [ -f "$fname" ]; then cp "$fname" "$BUILD/$fname"; fi done diff --git a/CHARTER.md b/CHARTER.md index 7fda880..e492c27 100644 --- a/CHARTER.md +++ b/CHARTER.md @@ -44,7 +44,7 @@ Upon host organization acceptance, governance transitions from the current Proje ## 4. Intellectual Property Policy -All contributions must be made under the terms of [LICENSE](LICENSE). Contributors must sign commits with the Developer Certificate of Origin (DCO). No contribution may incorporate material covered by a patent the contributor is unwilling to license royalty-free to conforming implementations. +All contributions must be made under the terms of [LICENSE](https://github.com/agentrust-io/ca2a/blob/main/LICENSE). Contributors must sign commits with the Developer Certificate of Origin (DCO). No contribution may incorporate material covered by a patent the contributor is unwilling to license royalty-free to conforming implementations. ## 5. Trademark Policy diff --git a/LIMITATIONS.md b/LIMITATIONS.md index 6326cb7..c85d82e 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -4,7 +4,7 @@ cA2A 0.2 is a Developer Preview with a runnable, tested profile and runtime. Thi ## What is built -- The delegation credential model and the offline chain verifier skeleton: signature checks, scope attenuation (a child grant must be a provable subset of its parent), depth limits, and cross-chain replay rejection. The hardest of these semantics is reused from [agent-manifest](https://github.com/agentrust-io/agent-manifest), where it is implemented and tested. +- The delegation credential model and offline chain verifier: trusted-root checks, signatures, scope attenuation, depth and validity bounds, duplicate credential IDs within a chain, and cross-chain splice rejection. These checks do not maintain a global history of used credentials. - Configuration, error registry, and the CLI surface, including `ca2a start`, which builds a `PeerNode` from a config file and serves it over the reference transport. - A reference HTTP transport and the attestation handshake, in software mode. `ca2a_runtime.transport.server` and `ca2a_runtime.transport.client` (standard library only) run a live inbound A2A-profile call end to end: the caller fetches the callee's attested channel key, seals a payload to it, and sends a delegated task; the callee parses the A2A metadata with the adapter, runs verify + policy + enforce + open-sealed + provenance, and replies. `ca2a_runtime.attestation` gates the seal on a verified channel key. This is a **reference** transport, not part of the profile: the profile mandates no wire protocol (see Out of scope), and in software mode the peer key is accepted at `assurance="none"`. @@ -18,7 +18,7 @@ cA2A 0.2 is a Developer Preview with a runnable, tested profile and runtime. Thi What **failed**, and it is the important half: `verify_tpm_report` could not chain to a pinned root, reporting "AK chain root is not among the supplied trusted TPM roots". On that host the AK certificate at NV `0x01C101D0` is 994 bytes, is issued by `CN=Global Virtual TPM CA - 03`, and carries **no AIA extension at all**, so there are no intermediates to fetch and none stored elsewhere in NV. A different Azure host (`Standard_D2s_v5`, eastus, 2026-07-31) presented a 1596-byte certificate under `Azure Cloud Virtual TPM CA - 11` with a walkable AIA chain reaching the root pinned in `ca2a_verify/tpm_roots.py`. Both observations are real: Azure runs more than one vTPM CA generation, so **the shipped Azure root is not sufficient fleet-wide** and a deployment must pin the hierarchy its own hosts actually present. Until then, treat the TPM tier as: evidence is genuine and its signature and binding are verifiable, but key provenance is host-dependent. - **TPM evidence proves key provenance only where a chain reaches a pinnable root.** A quote signed by the transient fallback key is a verifiable signature but carries no certificate chain, so it proves nothing about *where* the key lives, and `verify_tpm_report` rejects it. As above, a platform-provisioned certified attestation key is necessary but not sufficient: the chain must also be assemblable, which fails when the AK certificate carries no AIA. A GCP Shielded VM is weaker still (probed 2026-07-31: no EK certificate, no persistent handles, and `get-shielded-identity` returns a bare `ekPub` with no certificate). Client firmware TPM vendor roots are not yet published in a pinnable form. Separately, the TCG event log is 0 bytes on both Azure and GCP, so PCR values cannot be attributed to specific boot events on either cloud. -## Platform state is not appraised +## Platform-state appraisal is opt-in {#platform-state-is-not-appraised} chain + subgraph callee[Callee cA2A runtime] + chain[Verify chain against trusted roots] --> holder[Verify caller holds leaf key] + holder --> scope[Intersect grant with local policy] + scope --> appraisal[Appraise caller if required or offered] + appraisal --> decision[Enforce capability and create decision record] + decision --> payload[Open sealed payload if present] + decision --> record[Linked decision record] + end + roots[Approved root issuers] --> chain + policy[Local capability policy] --> scope + evidence[Appraisal requirements and evidence] --> appraisal + record --> result[Payload and decision returned to integration] + payload --> result ``` -Agent A --(delegation cred, scope S_A)--> Agent B --(scope S_B ⊆ S_A)--> Agent C -``` -1. A issues B a child credential with `S_B ⊆ S_A`, signed over the canonical form of the grant. -2. Before B accepts, the cA2A runtime verifies the chain, verifies B's attestation measurement, and intersects `S_B` with B's local Cedar policy. -3. The payload is sealed to B's measurement. -4. B emits a TRACE record linking to A's record. + + +Before sending a sealed payload, the caller must obtain and appraise the callee's channel offer. That outbound step and the callee's appraisal of the caller are distinct directions. The runtime box is a process boundary in software mode; verified confidential-computing deployments can add hardware isolation. The surrounding agents and their tools do not automatically move inside it. + +The effective authority is the intersection of the delegated scope and local policy. Delegation cannot grant a capability that local policy refuses. Invalid chains or holder proofs are rejected before policy decisions and do not receive signed denial records; authenticated policy and appraisal denials have their own evidence behavior. See the [peer implementation](https://github.com/agentrust-io/ca2a/blob/main/src/ca2a_runtime/peer.py) for the exact ordering and failure paths. ## Profile, not protocol -cA2A binds to A2A the way TRACE binds to IETF RATS, EAT, and SCITT: it is an overlay, not a competitor. This keeps it neutral across org, cloud, and TEE-vendor boundaries, which is the claim a vendor-anchored verifier cannot make. +The profile defines trust fields and checks. The reference HTTP transport makes the peer path runnable, and an A2A SDK bridge is also available. Transport choice does not establish hardware assurance or replace the relying party's trust policy. -The profile mandates no wire protocol. A reference HTTP transport ships (`ca2a_runtime.transport.server`/`client`) so the peer path is runnable off hardware, but it is a convenience, not part of the profile: any A2A server can carry the extension fields instead. +Continue with [configuration](configuration.md), the [profile](spec/profile.md), or the [limitations](../LIMITATIONS.md). diff --git a/docs/quickstart.md b/docs/quickstart.md index 9a66d62..31bcf6e 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,78 +1,125 @@ -# Quick Start +# Verify your first delegation chain -This walkthrough builds a delegation chain and verifies it offline. It needs no hardware TEE and no network. It exercises the part of cA2A that is built today: attenuated delegation and offline chain verification. +Build a two-hop grant: a root gives an intermediary `read` and `write`, and the intermediary gives the leaf only `read`. Verify it, then reject an untrusted root and a correctly signed grant that exceeds its parent's authority. After installation, this example needs no network, hardware, or running peer. ## Install -```bash -pip install ca2a-runtime -``` - -Or run the published rootless container with a read-only configuration mount: +Use Python 3.11+, Git, and Bash on Linux, macOS, or Windows with WSL. Install from the current source checkout so the example uses the verification behavior documented here. ```bash -docker run --rm -p 8443:8443 \ - --read-only --tmpfs /tmp:rw,noexec,nosuid,size=16m \ - -v "$PWD/ca2a-config.yaml:/etc/ca2a/config.yaml:ro" \ - ghcr.io/agentrust-io/ca2a-runtime:v0.2.0 \ - start --config /etc/ca2a/config.yaml +git clone https://github.com/agentrust-io/ca2a.git ca2a-quickstart +cd ca2a-quickstart +python3 -m venv .venv +source .venv/bin/activate +python -m pip install -e . ``` -The image runs as UID/GID 10001. Hardware-backed providers additionally need -the relevant device passed through with permissions for that identity; do not -run the whole container as root to obtain device access. +## Build an example chain -cA2A 0.2 is published as a normal release. Contributors working from a checkout can instead install from source: `pip install -e ".[dev]"`. +Save this complete block as `first_chain.py`, then run `python first_chain.py`: -## Build an example chain +```python +import json +import time +from pathlib import Path -The repo ships a generator that produces a valid three-hop chain (`admin` narrows to `read+write` narrows to `read`): +from ca2a_runtime.delegation import DelegationCredential, new_keypair, verify_chain +from ca2a_runtime.errors import CA2AError -```bash -python scripts/gen_example_chain.py -# wrote examples/minimal/chain.json +root_priv, root_pub = new_keypair() +mid_priv, mid_pub = new_keypair() +_, leaf_pub = new_keypair() +now = int(time.time()) +root = DelegationCredential( + "demo-root", root_pub, mid_pub, frozenset({"cap:read", "cap:write"}), 0, + not_before=now - 5, not_after=now + 3600, +).sign(root_priv) +child = DelegationCredential( + "demo-child", mid_pub, leaf_pub, frozenset({"cap:read"}), 1, + parent_id="demo-root", not_before=now - 5, not_after=now + 1800, +).sign(mid_priv) + +# Retain the approved issuer separately from the chain being inspected. +trusted_roots = {root_pub} +verify_chain([root, child], trusted_root_issuers=trusted_roots) +print("PASS: two-hop chain verified; leaf scope is cap:read") + +try: + verify_chain([root, child], trusted_root_issuers=set()) +except CA2AError as exc: + assert exc.code == "UNTRUSTED_DELEGATION_ROOT", exc.code + print("PASS: untrusted root rejected") +else: + raise AssertionError("An untrusted root was accepted") + +# Sign the invalid grant so this tests authorization, not edited signature bytes. +escalated = DelegationCredential( + "demo-escalated", mid_pub, leaf_pub, frozenset({"cap:admin"}), 1, + parent_id="demo-root", not_before=now - 5, not_after=now + 1800, +).sign(mid_priv) +try: + verify_chain([root, escalated], trusted_root_issuers=trusted_roots) +except CA2AError as exc: + assert exc.code == "SCOPE_ESCALATION", exc.code + print("PASS: signed scope escalation rejected") +else: + raise AssertionError("An escalated grant was accepted") + +Path("demo-chain.json").write_text(json.dumps({ + "chain": [item.body() | {"signature": item.signature} for item in [root, child]] +}, indent=2)) +Path("trusted-root.txt").write_text(root_pub) +print("Saved demo-chain.json and trusted-root.txt; private keys were not saved") ``` -Each hop is a signed `DelegationCredential`. The scope of each hop is a subset of its parent, continuity is preserved (each issuer is the previous subject), and each hop links to its parent by `credential_id`. +Expected output: + +```text +PASS: two-hop chain verified; leaf scope is cap:read +PASS: untrusted root rejected +PASS: signed scope escalation rejected +Saved demo-chain.json and trusted-root.txt; private keys were not saved +``` ## Verify it +Use the separately retained issuer key: + ```bash -ca2a verify-chain --chain examples/minimal/chain.json --trusted-root-issuer -# {"verified": true, "hops": 3, "leaf_scope": ["cap:read"]} +ca2a verify-chain --chain demo-chain.json --trusted-root-issuer "$(cat trusted-root.txt)" ``` -Verification checks four invariants and fails on the first violation: +Expected exit code: `0`. -1. **Signature** on every hop against the issuer's Ed25519 public key. -2. **Continuity**: each hop's issuer is the previous hop's subject. -3. **Attenuation**: each hop's scope is a subset of its parent's scope. -4. **Anti-replay**: `parent_id` links to the previous `credential_id` and every `credential_id` is unique. +```json +{"verified": true, "hops": 2, "leaf_scope": ["cap:read"]} +``` + +In production, the relying party obtains trusted roots through its own approval process. Copying the issuer from an arbitrary incoming chain into the trust list would let that chain choose its own authority. ## Try to break it -Edit `examples/minimal/chain.json` so a child hop adds a capability its parent did not hold, then re-run: +The script signs an overbroad child credential and expects `SCOPE_ESCALATION`. Editing a signed JSON field instead should fail the signature check first. These are different checks; an invalid signature does not demonstrate scope attenuation. -```bash -ca2a verify-chain --chain examples/minimal/chain.json --trusted-root-issuer -# {"verified": false, "code": "SCOPE_ESCALATION", "error": "hop 1 scope exceeds parent grant"} -``` +The verifier also checks issuer-to-subject continuity, parent links, unique credential IDs, depth, and validity windows. These offline checks do not keep a global ledger of previously accepted requests. Live request replay handling is a separate runtime concern. ## Build a chain in code -```python -from ca2a_runtime.delegation import DelegationCredential, new_keypair, verify_chain +The complete script above uses the same `DelegationCredential` and `verify_chain` APIs as the runtime. `trusted_root_issuers` is required for a successful verification. Keep the root grant and each child grant bounded by your own authorization policy. -root_priv, root_pub = new_keypair() -mid_priv, mid_pub = new_keypair() -_, leaf_pub = new_keypair() +## What is not in this walkthrough -root = DelegationCredential("c0", root_pub, mid_pub, frozenset({"cap:a", "cap:b"}), 0).sign(root_priv) -child = DelegationCredential("c1", mid_pub, leaf_pub, frozenset({"cap:a"}), 1, parent_id="c0").sign(mid_priv) +This example verifies signed grants. It does not execute tasks, appraise hardware, seal a network payload, or verify a TRACE provenance DAG. Follow [How It Works](concepts.md) for the live peer path and [Limitations](../LIMITATIONS.md) for the evidence that has been demonstrated. -verify_chain([root, child]) # raises on any violation -``` +## Troubleshooting -## What is not in this walkthrough +- **Module not found:** activate `.venv` in the terminal running the example. +- **File not found:** run `first_chain.py` from the same directory as the CLI command. +- **Untrusted root:** use this demo's retained key. Do not trust keys solely because an incoming chain supplies them. +- **Expired credential:** the child lasts 30 minutes. Rerun the script to generate a fresh demo chain. + +## Next steps -The runtime peer path accepts a delegation credential on a live inbound A2A call, appraises the peer, seals the payload, enforces local policy, and emits signed provenance. The walkthrough defaults to software assurance; see [ROADMAP.md](../ROADMAP.md) and [LIMITATIONS.md](../LIMITATIONS.md) before making hardware-backed claims. +- [Architecture](concepts.md): trust checks and runtime boundaries. +- [Delegation specification](spec/delegation-chain.md): validation rules. +- [Limitations](../LIMITATIONS.md): software and hardware assurance boundaries. diff --git a/docs/spec/call-graph.md b/docs/spec/call-graph.md index 00d36e7..1fc079c 100644 --- a/docs/spec/call-graph.md +++ b/docs/spec/call-graph.md @@ -1,109 +1,50 @@ -# Inbound Peer-Call Decision - -When a cA2A peer receives an inbound A2A task, it runs a fixed sequence of checks before it acts on the task and after it acts. The order is not arbitrary: cheap, offline, deterministic checks run first, and each step fails closed so a later step never runs against unverified input. This page states the full intended enforcement order and marks, for each step, what the code does today versus what is design. - -Steps 1 (chain verification), 3 (scope intersection), 4 (opening a sealed payload), and 5 (provenance emission) are composed into one transport-agnostic handler, `ca2a_runtime.peer.handle_peer_request`, which takes a parsed `PeerRequest` and runs the pipeline fail-closed. The `ca2a_runtime.transport` adapter parses actual A2A extension metadata into that `PeerRequest`, and a reference HTTP server/client (`ca2a_runtime.transport.server`/`client`) now drive the whole path live in software mode. Step 2 is what remains for a cross-trust-domain deployment: a SEV-SNP verifier exists (`ca2a_verify.sev_snp`, used counterparty-side to seal to a peer before sending), but producing a report needs real hardware, and the `verifier` seam in `ca2a_runtime.attestation` is not yet driven off a live hardware quote. The profile itself still mandates no wire protocol (the reference transport is a convenience, not part of it). See [LIMITATIONS.md](../../LIMITATIONS.md) and [ROADMAP.md](../../ROADMAP.md). - -## Decision flow - -``` -inbound A2A task - | - v -1. verify delegation chain verify_chain(chain, max_depth) [IMPLEMENTED] - | signature, continuity, attenuation, depth, replay - v -2. verify peer attestation provider.attest / expected measurement [PENDING, Tier 3] - | measurement must match an expected value - v -3. intersect scope with policy effective_scope(chain, local_policy) [IMPLEMENTED (decision core)] - | the effective grant is delegated scope AND local policy - v -4. seal payload to measurement SealedChannel(peer_pub).seal(...) [IMPLEMENTED (crypto); attestation binding pending] - | payload readable only with the peer's enclave-bound key - v -5. emit linked provenance enforce_peer_call(...) -> DelegationRecord - | DelegationRecord chained to the parent record [IMPLEMENTED] - v - accept and act on the task -``` - -If any step raises, the call is denied. Absence of evidence is denial, not a warning (see the fail-closed tenet in [SPEC.md](../SPEC.md)). - -## Status - -| Step | What it enforces | Function / type | Status | -|---|---|---|---| -| 1. Chain verification | Signature, continuity, attenuation, depth bound, anti-replay | `verify_chain` | Implemented | -| 2. Peer attestation | Peer measurement matches an expected value | `ca2a_verify.sev_snp.verify_sev_snp_report` | SEV-SNP verifier implemented; not yet wired into the call path; report needs hardware | -| 3. Scope intersection | Delegated scope intersected with local policy | `ca2a_runtime.peer.effective_scope`, `enforce_peer_call` | Implemented (decision core); Cedar engine binding pending (#10) | -| 4. Payload sealing | Payload sealed to the peer's attested key | `SealedChannel.seal`, `open_sealed` | Implemented (crypto); binding to a verified report on the live path pending | -| 5. Provenance record | A `DelegationRecord` emitted and linked to its parent | `enforce_peer_call`, `record_for`, `verify_dag` | Implemented (emitted by the decision core) | -| Inbound pipeline handler | Verify, enforce, open sealed payload, emit record off a parsed request | `handle_peer_request`, `PeerRequest` | Implemented (transport-agnostic) | -| A2A wire parsing into a `PeerRequest` | Parse actual A2A extension fields into the handler's input | `ca2a_runtime.transport.a2a_adapter`, and the reference `transport.server`/`client` and `PeerNode` | Implemented (software mode); the profile still mandates no specific transport | - -## Step 1: verify the delegation chain (implemented) - -The inbound credential is verified as the leaf of a root-to-leaf chain. `verify_chain` raises the specific error for the first invariant that fails: signature (`INVALID_CREDENTIAL`), continuity and depth-step (`BROKEN_DELEGATION_LINK`), depth bound (`DELEGATION_DEPTH_EXCEEDED`), attenuation (`SCOPE_ESCALATION`), and anti-replay (`CREDENTIAL_REPLAY`). See [delegation-chain.md](delegation-chain.md) and [error-codes.md](error-codes.md). - -```python -from ca2a_runtime.delegation import DelegationCredential, verify_chain - -# chain is the root-to-leaf list carried with the inbound task. -verify_chain(chain, max_depth=8) # raises on the first violation -leaf = chain[-1] -delegated_scope = leaf.scope # frozenset[str], the authority to enforce below -``` - -`max_depth` comes from `Ca2aConfig.max_delegation_depth` (default 8). This step is deterministic and offline: it contacts no operator and depends only on the signed bytes. It is the only step that gates an inbound call in this release. - -## Step 2: verify peer attestation (pending, Tier 3) - -The runtime confirms the peer is running attested, measured code before it is trusted with the task, by checking the peer's report measurement against an expected value under a fresh nonce. A SEV-SNP verifier exists (`ca2a_verify.sev_snp.verify_sev_snp_report`: VCEK chain, report-signature, measurement binding), but it is not yet wired into this call path, producing a report requires real SEV-SNP hardware, and TDX/TPM backends are not implemented. Verification fails closed when evidence is absent. Do not treat this step as active on the live path yet. See [attestation.md](attestation.md). - -## Step 3: intersect scope with local policy (implemented as a decision core) - -The effective authority for the task is the intersection of the delegated leaf scope (from step 1) with what the peer's own local policy permits. Delegation can only narrow authority, never widen it, so the peer's policy is an independent second bound on top of attenuation. This is implemented in `ca2a_runtime.peer`: - -```python -from ca2a_runtime.peer import effective_scope, enforce_peer_call -from ca2a_runtime.policy import LocalPolicy - -policy = LocalPolicy.of(["read", "audit"]) -effective_scope(chain, policy) # delegated leaf scope AND policy allow -enforce_peer_call(chain, "read", policy=policy, record_id="rec-c") # raises SCOPE_NOT_PERMITTED if not in the effective scope -``` - -A capability is granted only when it is both delegated down the chain and allowed by the local policy; a capability in one but not the other is denied with `SCOPE_NOT_PERMITTED`. The `LocalPolicy` here is a capability allow set; a full Cedar policy engine (as cMCP uses) is also available via `ca2a_runtime.cedar.CedarPolicy`. `enforce_peer_call` is the decision the runtime makes; the reference `PeerNode` and HTTP server now drive it off an actual A2A request in software mode. See [cedar-policy.md](cedar-policy.md). - -## Step 4: seal the payload to the peer's attested key (crypto implemented) - -Once the peer's attested public key is known (from its report, step 2), the task payload is sealed to it so only the holder of the peer's private key can open it. The channel is implemented (`SealedChannel(peer_pub).seal(...)` and `open_sealed(...)`, an HPKE-style X25519 -> HKDF-SHA256 -> ChaCha20-Poly1305 scheme). `open_sealed` fails closed with `SEALED_CHANNEL_ERROR` on a wrong key or tampered ciphertext. On a live call the handshake (`ca2a_runtime.attestation.verify_offer`) gates the seal on a channel key the caller has appraised under a fresh nonce. As of 2026-07-27 that appraisal has run at `assurance="hardware"` off a live SEV-SNP quote, so the property that the payload decrypts *only inside the attested measurement* now rests on a hardware-verified measurement rather than on a software-mode stand-in. It holds one-directionally: the caller appraised the callee, and mutual simultaneous attestation is still outstanding. In software mode the appraisal remains `assurance="none"`, which is what the committed examples exercise. See [sealed-channel.md](sealed-channel.md). - -## Step 5: emit a linked provenance record (implemented) - -After the task is accepted, `enforce_peer_call` emits a `DelegationRecord` that references its parent record by hash and names the credential it acted under. The record model, the offline DAG verifier, and the binding to the delegation chain exist today: - -```python -from ca2a_runtime.provenance import record_for, verify_dag, cross_check_chain - -# Build this hop's record, linked to the parent record's hash. -record = record_for(leaf, record_id="rec-c", parent_record_hash=parent_hash) - -# Offline: parent links match recomputed hashes, no record_id repeats. -verify_dag(records) # raises PROVENANCE_LINK_BROKEN on tampering -cross_check_chain(records, chain) # record i must match credential i (id + subject) -``` - -`enforce_peer_call` produces this record for the accepted hop, linked to the parent by hash. `verify_dag` raises `PROVENANCE_LINK_BROKEN` if a record was tampered with or reparented, because the stored `parent_record_hash` no longer matches the recomputed hash of the preceding record, and `cross_check_chain` ties provenance to authority. The reference `PeerNode`/HTTP server now drive this emission off a live inbound A2A request in software mode. The record format and DAG semantics are the runtime-evidence side of the profile; see [trace-a2a-profile.md](trace-a2a-profile.md) and [provenance-dag.md](provenance-dag.md). - -## Why this order - -Each step is a precondition for the next, so ordering is a safety property: - -- Chain verification (1) runs first because it is offline and deterministic and establishes the delegated scope every later step reasons about. -- Attestation (2) precedes sealing (4) because the payload is sealed to the measurement attestation produces; sealing to an unverified measurement would be meaningless. -- Scope intersection (3) precedes acting on the task because the effective grant, not the raw delegated scope, is what the peer is allowed to exercise. -- Provenance emission (5) runs after the task is accepted so the record reflects what was actually done, and it links to the parent record so the workflow forms a verifiable DAG. - -This release enforces bounded authority and local-policy intersection (steps 1 and 3) and emits and verifies linked provenance records (step 5) through the `enforce_peer_call` decision core, and the reference transport drives the whole path off a live A2A request in software mode. What it does not yet do is enforce hardware peer integrity or hardware-measured payload confidentiality (steps 2 and 4): those need a real attestation quote through the `verifier` seam. See the residual-risks section of the [threat-model.md](threat-model.md). +# Inbound Peer-Call Decision + +The inbound handler checks a caller's authority and identity before returning a task payload to the application. The reference HTTP transport and `PeerNode` use this handler; the profile does not mandate that transport. + +## Decision flow + +For a parsed `PeerRequest`, `ca2a_runtime.peer.handle_peer_request` runs these steps: + +1. Verify the chain against locally trusted root issuers. +2. Verify the caller holds the leaf subject key (required by default). +3. Intersect delegated scope with local policy. +4. Appraise the caller's runtime under the configured requirement. +5. Decide the capability and construct an allow or denial record. +6. Open any sealed payload with the callee's private key. +7. Return `PeerResult` to the application. + +An exception stops the pipeline. A denial never returns the payload. An allow record describes an authorization decision; it does not prove the application completed the task. The decision record is constructed **before** payload opening, which can itself fail. + +## Checks and trust inputs + +| Step | Implementation | Required context | +|---|---|---| +| Chain | `verify_chain` | Locally approved `trusted_root_issuers`, depth limit, credential validity windows | +| Holder proof | `verify_caller_holds_leaf` | Callee audience and challenge secret; proof commits to the requested operation | +| Policy | `policy.intersect` | `LocalPolicy` allow set or `CedarPolicy` | +| Caller appraisal | `appraise_caller_runtime` | Challenge context, verifier, and `require_caller_attestation` | +| Capability | `decide_capability` | Requested capability must be in the effective scope | +| Payload | `open_sealed` | Callee's channel private key; authenticated ciphertext | + +Chain verification checks signatures, continuity, attenuation, depth, validity, and duplicate credential IDs within the supplied chain. It does not maintain a global history that makes each credential single-use. Root keys must come from local policy, not from an untrusted incoming chain. + +Holder proof establishes that this caller controls the key named as the leaf subject. Attestation establishes a separate claim about its runtime. `require_holder_proof=False` exists for offline replay and must not be used on a live peer path. Chain and holder failures occur before policy evaluation and emit no decision record. + +## Caller appraisal + +The default `require_caller_attestation="none"` accepts a caller that offers no attestation and records `not_offered`. `"any"` requires an appraisable offer, including software assurance. `"hardware"` requires hardware assurance. A present but invalid offer is rejected at every setting. + +The record carries the appraisal outcome: `not_offered`, `software-only`, `hardware`, or `failed`. Appraisal refusal and capability denial can carry linked denial records. See [mutual attestation](mutual-attestation.md) for the challenge protocol and assurance requirements. + +## Sending is a separate path + +Before sending, the **caller** appraises the **callee's** channel offer and seals the payload to its verified channel key. On receipt, the **callee** checks the caller and opens that payload. These are separate directions of trust; a successful outbound appraisal does not establish inbound caller authority. + +The encrypted channel works in software mode. Protection from a host operator additionally depends on verified hardware evidence, expected measurements, and private-key custody in the measured environment. See [sealed channel](sealed-channel.md) and [hardware validation](../hardware-validation.md) for the evidence and remaining deployment gaps. + +## Decision core versus full handler + +`effective_scope` and `enforce_peer_call` are lower-level helpers for chain verification and policy decisions. They do not perform the full handler's holder proof, caller appraisal, or payload opening. Integrations should use the full peer path described in [transport binding](transport.md). + +For a runnable local example, start with the [quick start](../quickstart.md). For signed evidence verification, see the [verification library](verification-library.md). diff --git a/docs/spec/cedar-policy.md b/docs/spec/cedar-policy.md index 7f60208..f9cf75c 100644 --- a/docs/spec/cedar-policy.md +++ b/docs/spec/cedar-policy.md @@ -1,98 +1,44 @@ -# Scope-Policy Intersection (Cedar) - -When a peer accepts a delegated task, two independent trust decisions meet. The delegator says what the caller was authorized to ask for; the callee's own policy says what it will honor regardless of who asks. The effective permission the peer exercises is the intersection of the two: - -``` -effective = delegated_scope ∩ local_policy_allow -``` - -Status: the intersection semantics are implemented as an enforcement decision core in `ca2a_runtime.peer` (`effective_scope`, `enforce_peer_call`), and the local policy can be either a capability allow set (`ca2a_runtime.policy.LocalPolicy`) or a real Cedar policy engine (`ca2a_runtime.cedar.CedarPolicy`, backed by `cedarpy`, the same engine cMCP runs). Both satisfy the `ca2a_runtime.policy.Policy` protocol, so they are interchangeable in the peer path. Validated by experiment C3 and the Cedar unit tests. What remains is wiring the decision core to a live A2A transport rather than a direct call. See [call-graph.md](call-graph.md) and [ROADMAP.md](../../ROADMAP.md). - -## Cedar policy - -`CedarPolicy` evaluates each capability as a Cedar authorization request whose action id is the capability name; a capability is permitted iff Cedar returns `Allow`. The effective scope is the delegated leaf scope intersected with the capabilities Cedar permits. - -```python -from ca2a_runtime.cedar import CedarPolicy -from ca2a_runtime.peer import effective_scope - -policy = CedarPolicy('permit(principal, action == Action::"read", resource);') -effective_scope(chain, policy) # delegated leaf scope AND what Cedar allows -``` - -## Why an intersection - -Each side owns half of the decision, and neither may escalate the other. - -- Delegation is authoritative on the caller's side: a capability that is allowed by the callee's local policy but absent from the verified delegated scope is dropped. The caller was never granted it upstream, so local policy cannot manufacture it. -- Local policy is authoritative on the callee's side: a capability present in the delegated scope but denied by local policy is dropped. The delegator cannot force the callee to honor something the callee's own policy forbids. - -The safe combination is therefore the set intersection, not a union and not an override. The effective set is never larger than either input. This is defense in depth: an error or compromise on one side cannot widen what the other side permits. - -Attenuation across the chain already guarantees the delegated scope is a provable subset of the root grant (see [delegation chain](delegation-chain.md)). The Cedar intersection adds the callee's local constraint on top of that verified scope. - -## Where it attaches - -The intersection runs at the runtime peer-delegation enforcement point: the moment a peer accepts an inbound A2A task carrying a delegation credential. That enforcement point does not exist yet. The sequence it will slot into is described in [call-graph.md](call-graph.md). At a high level, on an inbound peer call the runtime will: - -1. Verify the delegation chain (signature, continuity, attenuation, anti-replay) with `verify_chain`. -2. Verify the peer's attestation measurement (see [attestation](attestation.md)). -3. Compute `effective = delegated_scope ∩ local_policy_allow` and enforce it before any capability is exercised. - -The verified leaf scope from step 1 is the `delegated_scope` input to step 3. Nothing between the two is exercised until the intersection is computed. - -## Cedar engine reuse - -cA2A does not ship its own policy engine. The local-policy half of the intersection reuses the Cedar policy engine already built and used in [cmcp](https://github.com/agentrust-io/cmcp), listed as a Tier 0 reused primitive on the roadmap. Cedar answers, for the calling principal and a requested action on a resource, whether local policy permits it. The runtime maps each capability string in the delegated scope to a Cedar authorization query and keeps only the capabilities Cedar allows. - -The `policy_bundle_path` field on `Ca2aConfig` names the Cedar policy bundle the runtime will load. It is validated and carried through configuration today, but the runtime does not yet consume it because the enforcement point that would evaluate it is not built. - -```python -from ca2a_runtime.config import Ca2aConfig - -cfg = Ca2aConfig.load("ca2a.yaml") -cfg.policy_bundle_path # path to the Cedar bundle; parsed but not yet enforced -cfg.enforcement_mode # "enforcing" | "advisory" | "silent" (see below) -``` - -## Enforcement mode - -`Ca2aConfig.enforcement_mode` selects what the runtime does with the computed effective set once the enforcement point lands: - -| Mode | Intended behavior | -|---|---| -| `enforcing` | Deny any capability outside the effective set. This is the default and the fail-closed posture. | -| `advisory` | Compute and record the effective set, emit a warning on a capability outside it, but do not block. For rollout and observation. | -| `silent` | Compute and record only, no warning and no block. | - -Today the field is validated by `Ca2aConfig.from_dict` but has no runtime effect, since there is no call-time gate to apply it at. - -## Illustrative shape - -The following shows the intended computation. It is illustrative only and is not the Cedar engine: it stands in a plain set intersection for what the runtime will do by mapping each capability to a Cedar authorization query. The `experiments/claim3-scope-policy-intersection/run.py` harness prints this same shape behind a SKIP banner, because the real path is gated on Tier 2. - -```python -# Illustrative only. NOT the Cedar engine and NOT the runtime path. -delegated_scope = {"cap:read", "cap:write", "cap:admin"} # verified leaf scope -local_policy_allow = {"cap:read", "cap:write", "cap:audit"} # what Cedar would allow here - -effective = delegated_scope & local_policy_allow -# -> {"cap:read", "cap:write"} - -dropped_by_policy = delegated_scope - local_policy_allow -# -> {"cap:admin"} delegated, but local policy denies it - -dropped_by_delegation = local_policy_allow - delegated_scope -# -> {"cap:audit"} locally allowed, but never delegated -``` - -In the wired implementation `local_policy_allow` is not a precomputed set. Each capability in `delegated_scope` becomes a Cedar authorization query against the loaded bundle, and only the ones Cedar permits survive into `effective`. - -## What lands in Tier 2 - -The scope-policy intersection depends on two Tier 2 runtime pieces, neither of which is built: - -- Runtime peer-delegation enforcement: a call-time gate that accepts a delegation credential on a live inbound peer call and runs verification in the request path. `verify_chain` already checks the chain in isolation; there is no point at which it gates an actual peer-to-peer call. -- Cedar binding: the wiring from a verified delegated scope to the cmcp Cedar engine, and the effective-permission computation above. - -Until both land, do not describe cA2A as enforcing local policy on delegated calls. See [ROADMAP.md](../../ROADMAP.md). +# Scope-Policy Intersection (Cedar) + +A delegated capability is usable only when the callee's local policy also allows it. The runtime implements this intersection with either `LocalPolicy`, a capability allow set, or `CedarPolicy`, backed by `cedarpy`. Both implement the `Policy` protocol and run in the live peer path. + +```text +effective scope = delegated leaf scope intersected with local policy +``` + +For example, a leaf delegated `read` and `write` meeting a policy that permits `read` and `audit` receives only `read`. Local policy cannot add undelegated authority, and delegation cannot override a local denial. + +## Cedar policy + +This reference fragment assumes `chain` is a signed chain and `trusted_roots` contains independently approved root issuer keys. Use the [quick start](../quickstart.md) to create a local chain first. + +```python +from ca2a_runtime.cedar import CedarPolicy +from ca2a_runtime.peer import effective_scope + +policy = CedarPolicy('permit(principal, action == Action::"read", resource);') +allowed = effective_scope(chain, policy, trusted_root_issuers=trusted_roots) +``` + +`CedarPolicy` maps each delegated capability to a Cedar request whose action ID is that capability. Only Cedar `Allow` results survive. See `ca2a_runtime.cedar` for the principal, resource, context, and entity inputs supported by the API. + +## Runtime configuration + +`ca2a start --config ca2a.yaml` uses `bootstrap.load_policy` to load `policy_bundle_path` when set, or otherwise the `local_policy` allow set. A relative bundle path resolves against the configuration file's directory. A missing or empty bundle is a configuration error. Startup requires one of these policy sources. + +`Ca2aConfig.enforcement_mode` accepts `enforcing`, `advisory`, and `silent`, but the peer path currently **always blocks a denied capability**. The latter two values do not enable an observation-only rollout. See [configuration](../configuration.md). + +## Where it runs + +The full inbound handler verifies the chain and holder proof before evaluating local policy. It then appraises the caller, decides the requested capability, and opens any sealed payload only after acceptance. `SCOPE_NOT_PERMITTED` carries a denial record when the capability falls outside the effective scope. The exact sequence is in [inbound peer-call decision](call-graph.md). + +## Reproduce the behavior + +From an installed source checkout: + +```bash +python experiments/claim3-scope-policy-intersection/run.py +pytest tests/unit/test_cedar.py tests/unit/test_peer.py tests/unit/test_bootstrap.py +``` + +C3 checks the intersection using `LocalPolicy`; the Cedar tests exercise the actual Cedar engine. Passing the allow-set experiment alone does not validate a Cedar policy bundle. diff --git a/docs/spec/component-model.md b/docs/spec/component-model.md index d68e419..8491432 100644 --- a/docs/spec/component-model.md +++ b/docs/spec/component-model.md @@ -1,63 +1,39 @@ # Component Model -The cA2A runtime is a set of small, composable modules under `src/`. Each maps to one primitive in [How It Works](../concepts.md). This page describes what each component is, what it exposes, and whether it is implemented today or a placeholder for pending Tier 2/Tier 3 work. Nothing here describes behavior that is not in the source. +cA2A composes delegation verification, local policy, caller authentication, attestation, encrypted payloads, and evidence records. The reference runtime and HTTP transport implement this path; hardware assurance depends on the provider and evidence available in the deployment. ## Components -### delegation +| Component | Module | Responsibility | +|---|---|---| +| Delegation | `ca2a_runtime.delegation.credential` | Signed credentials; root trust, continuity, scope attenuation, depth and validity checks | +| Holder proof | `ca2a_runtime.delegation.holder` | Bind the caller to the leaf subject key and challenged request | +| Policy | `ca2a_runtime.policy`, `ca2a_runtime.cedar` | Allow-set or Cedar policy evaluation | +| Inbound handler | `ca2a_runtime.peer` | Verify, authenticate, appraise, authorize, and open the payload | +| Channel | `ca2a_runtime.channel` | X25519 key agreement and authenticated encryption; reject wrong keys and altered ciphertext | +| Attestation | `ca2a_runtime.attestation` | Challenge-bound channel offers and peer appraisal | +| Providers | `ca2a_runtime.tee` | Software evidence or hardware collection for TPM, SEV-SNP, and TDX; OPAQUE has no collector | +| Provenance | `ca2a_runtime.provenance` | Hash-linked decision records and consistency checks | +| TRACE binding | `ca2a_runtime.trace_binding` | Signed TRACE records carrying delegation links | +| Offline verification | `ca2a_verify.verify`, `ca2a_verify.dag` | Verify credentials and signed TRACE record paths against caller-supplied trust anchors | +| Configuration | `ca2a_runtime.config`, `ca2a_runtime.bootstrap` | Validate configuration and build a node with policy, provider, and trusted roots | +| Node and transport | `ca2a_runtime.node`, `ca2a_runtime.transport` | Compose the handler with reference HTTP and A2A adapters | +| CLI | `ca2a_runtime.cli` | Offline validation commands and `ca2a start` | -`ca2a_runtime.delegation.credential` holds the credential model and the offline chain verifier. `DelegationCredential` is a frozen dataclass with a signed `body()` (everything but the signature) and a detached Ed25519 `signature`. `new_keypair()` returns a fresh `Ed25519PrivateKey` and its raw-hex public key. `verify_chain(chain, *, max_depth=8)` walks a root-to-leaf list and raises the specific error for the first failed invariant: signature, continuity, attenuation, depth, and anti-replay. This is the implemented core. See [delegation chain](delegation-chain.md). +## How they compose -### provenance +The caller appraises a callee's channel offer and seals the task to that key. The callee receives a parsed `PeerRequest`, checks its delegation and holder proof, evaluates policy and caller appraisal, and decides whether to open the payload. [Inbound peer-call decision](call-graph.md) gives the exact order and failure behavior. -`ca2a_runtime.provenance` is the runtime-evidence side. `DelegationRecord` is a frozen dataclass whose `record_hash()` is a SHA-256 over its canonical body, so any field change breaks a child's link. `record_for(credential, record_id, parent_record_hash)` builds the record a hop emits. `verify_dag(records)` confirms a root-to-leaf provenance chain (root has no parent link, each later record's `parent_record_hash` equals the recomputed hash of the previous record, no repeated `record_id`). `cross_check_chain(records, chain)` ties provenance to authority: record `i` must reference credential `i` and carry the same subject. Implemented. The full TRACE binding lands with Tier 2. See [TRACE A2A profile](trace-a2a-profile.md) and [provenance DAG](provenance-dag.md). +`enforce_peer_call` is a lower-level authorization helper. Calling it directly does not perform the full handler's holder proof or caller appraisal. -### verify +## Evidence has two forms -`ca2a_verify.verify` is a thin offline wrapper over the delegation verifier. `verify_delegation_chain(chain, *, trusted_root_issuers, max_depth=8)` returns a `ChainResult` (`hops`, `root_issuer`, `leaf_subject`, `leaf_scope`); `verify_chain_file(path, *, trusted_root_issuers, max_depth=8)` loads a chain from JSON (a list, or `{"chain": [...]}`) and verifies it. The explicit local root trust set is mandatory: signatures alone establish consistency, not authorization. `VerificationError` is re-exported as `CA2AError` so callers catch one type. Implemented. See [verification library](verification-library.md). +`DelegationRecord` is an unsigned, hash-linked decision record. `verify_dag` checks one ordered root-to-leaf path; `cross_check_chain` checks its credential IDs and subjects. These consistency checks do not authenticate the record producer or prove task completion. -### channel +The separate TRACE binding and `verify_trace_dag` add signed-record verification against trusted keys. The current APIs check ordered paths, not an arbitrary branching graph. See [provenance DAG](provenance-dag.md) and [verification library](verification-library.md). -`ca2a_runtime.channel.sealed` defines `SealedChannel`, the measurement-bound peer channel. Instantiation is allowed so the runtime can be wired against the interface, but `seal()` and `open()` fail closed with `SEALED_CHANNEL_ERROR` today. This is Tier 2 and not yet implemented; do not send confidential payloads across a trust boundary and assume they are protected. See [sealed channel](sealed-channel.md) and [LIMITATIONS.md](../../LIMITATIONS.md). +## Deployment boundaries -### tee +Software mode exercises delegation, policy, encryption, and signed evidence without hardware isolation. A hardware platform name alone is insufficient: the verifier needs valid evidence, an accepted trust chain, and the expected measurement. Provider collection, verifier support, and end-to-end deployment validation are separate facts; consult [hardware validation](../hardware-validation.md). -`ca2a_runtime.tee.base` defines the provider interface and evidence model. `AttestationReport` is a frozen dataclass binding a `public_key` to a `measurement` under a `nonce` on a named `platform`, plus four optional evidence fields (`raw_evidence`, `quote_signature`, `attestation_key_pem`, `attestation_key_chain_pem`) that make those claims checkable. `BaseProvider` is an ABC with `detect()` and `attest(public_key, nonce)`, and the two must agree: `detect()` is True only where `attest()` works. TPM, SEV-SNP and TDX all have collectors, the latter two through the kernel configfs-TSM interface; OPAQUE has a verifier but no collector, so its `attest()` raises and verification fails closed. See [attestation](attestation.md). - -### config - -`ca2a_runtime.config` holds `Ca2aConfig`, a frozen dataclass validated by `from_dict()` / `load()`: `provider` (from `VALID_PROVIDERS`), `enforcement_mode` (from `VALID_ENFORCEMENT`), `max_delegation_depth`, `policy_bundle_path`, `local_policy`, and `listen_addr`. Invalid values raise `CONFIG_ERROR`. `ca2a_runtime.bootstrap` turns a validated config into a running `PeerNode`: it resolves the policy from `local_policy` or `policy_bundle_path` and the provider from `provider`, both fail-closed. `enforcement_mode` is still only recorded; the peer path always fails closed on a denial. - -### errors - -`ca2a_runtime.errors` is the central registry. Every error is a `CA2AError` subclass carrying a stable `code` and an `http_status`: `CONFIG_ERROR`, `INVALID_CREDENTIAL`, `SCOPE_ESCALATION`, `BROKEN_DELEGATION_LINK`, `DELEGATION_DEPTH_EXCEEDED`, `CREDENTIAL_REPLAY`, `ATTESTATION_UNSUPPORTED`, `ATTESTATION_FAILED`, `SEALED_CHANNEL_ERROR`, `PROVENANCE_LINK_BROKEN`. See [error codes](error-codes.md). - -### cli - -`ca2a_runtime.cli` exposes the `ca2a` command. `validate-config --config` loads and validates a `Ca2aConfig`, `verify-chain --chain --trusted-root-issuer [--max-depth]` calls `verify_chain_file`, and `verify-dag --dag [--chain --trusted-root-issuer]` verifies a provenance DAG; all three operate offline. `start --config` is the one online command: it builds a `PeerNode` through `ca2a_runtime.bootstrap` and serves it with `ca2a_runtime.transport.server`. - -## Component map - -| Component | Module | Key API | Status | -|---|---|---|---| -| delegation | `ca2a_runtime.delegation.credential` | `DelegationCredential`, `new_keypair`, `verify_chain` | Implemented | -| provenance | `ca2a_runtime.provenance` | `DelegationRecord`, `record_for`, `verify_dag`, `cross_check_chain` | Implemented | -| verify | `ca2a_verify.verify` | `verify_delegation_chain`, `verify_chain_file`, `ChainResult` | Implemented | -| config | `ca2a_runtime.config` | `Ca2aConfig` | Implemented | -| bootstrap | `ca2a_runtime.bootstrap` | `load_policy`, `select_provider`, `build_peer_node` | Implemented | -| errors | `ca2a_runtime.errors` | `CA2AError` and subclasses | Implemented | -| cli | `ca2a_runtime.cli` | `ca2a validate-config`, `ca2a verify-chain`, `ca2a verify-dag`, `ca2a start` | Implemented | -| channel | `ca2a_runtime.channel.sealed` | `SealedChannel` | Placeholder, fails closed (Tier 2) | -| tee | `ca2a_runtime.tee.base` | `BaseProvider`, `AttestationReport` | Interface only; hardware backends pending (Tier 3) | - -## How they compose on an inbound peer call - -The intended peer path threads these components together. Steps 2 through 5 below are the target composition; the implemented parts today are the chain and provenance verification an offline verifier can run over signed evidence. - -1. A hands B a child credential with `scope ⊆` A's scope. This is the [delegation](delegation-chain.md) model, implemented. -2. Before B accepts, the runtime verifies the chain with `verify_chain` and intersects the delegated scope with a local Cedar policy under B's `enforcement_mode`. Chain verification is implemented; runtime enforcement and Cedar scope intersection are Tier 2 and not yet built. See [Cedar policy](cedar-policy.md). -3. B's `tee` provider produces an `AttestationReport`; the runtime checks the measurement. The interface exists, but no hardware backend verifies a quote yet (Tier 3), so this fails closed. See [attestation](attestation.md). -4. The task payload is sealed to B's measurement through `SealedChannel`. Tier 2, fails closed today. See [sealed channel](sealed-channel.md). -5. B emits a `DelegationRecord` linking to A's record via `record_for`, and any verifier can later run `verify_dag` and `cross_check_chain` offline. Implemented. See [TRACE A2A profile](trace-a2a-profile.md). - -What ships today is the offline path: given signed credentials and records, `ca2a_verify` and `provenance` reconstruct and check the delegation tree without trusting the operators that produced it. The runtime peer enforcement, sealed channel, Cedar intersection, and hardware attestation that would gate a live call are pending. See [failure modes](failure-modes.md), [ROADMAP.md](../../ROADMAP.md), and [LIMITATIONS.md](../../LIMITATIONS.md). +The configuration accepts three enforcement-mode names, but all currently enforce denial on the peer path. The reference transport is an implementation option; the [profile](profile.md) remains transport-independent. diff --git a/docs/spec/failure-modes.md b/docs/spec/failure-modes.md index ed35664..dde802f 100644 --- a/docs/spec/failure-modes.md +++ b/docs/spec/failure-modes.md @@ -1,151 +1,37 @@ -# Failure Modes - -cA2A fails closed. Every check on the delegation path either produces positive, verifiable evidence that a request is authorized, or it denies. There is no third state where a request proceeds with a warning attached. The guiding principle is this: - -Absence of evidence is denial, not a warning. - -An unsigned credential, a measurement that cannot be checked, a provenance record whose parent link does not resolve: each of these is treated as a failure, not a soft signal. This page enumerates the failure paths, the error each one raises, and which paths are enforced today versus which fail closed pending Tier 2/3 work. - -Each failure below names its error code. The codes and their HTTP mappings are defined in [error-codes.md](error-codes.md); the adversary these defenses assume is in [threat-model.md](threat-model.md). - -## Delegation chain failures (enforced today) - -`verify_chain` walks a root-to-leaf list of credentials and raises the specific error for the first invariant that fails. It never returns a partial or "mostly valid" result. See [delegation-chain.md](delegation-chain.md) for the full invariant table. - -### Unsigned or invalid credential - -A credential with an empty `signature`, or one whose signature does not verify against the `issuer` public key, is rejected. - -```python -from ca2a_runtime.delegation import DelegationCredential, verify_chain -from ca2a_runtime.errors import InvalidCredential - -try: - verify_chain(chain) -except InvalidCredential as exc: - # code == "INVALID_CREDENTIAL" - deny(exc) -``` - -`verify_signature` raises `InvalidCredential` on an unsigned credential ("credential is unsigned") and on a signature that fails Ed25519 verification. `DelegationCredential.from_dict` raises the same error on a malformed body (missing or wrong-typed fields). A credential the verifier cannot authenticate is not trusted, full stop. - -### Scope escalation - -A child hop whose `scope` is not a subset of its parent's `scope` is rejected with `SCOPE_ESCALATION`. This is the confused-deputy defense: a delegate cannot act with authority its delegator never held. - -```python -from ca2a_runtime.errors import ScopeEscalation - -try: - verify_chain(chain) -except ScopeEscalation as exc: - # exc.detail names the capabilities that were added, e.g. "added: ['payments:write']" - deny(exc) -``` - -The subset check runs at every hop. Adding even one capability the parent did not grant fails the whole chain. - -### Broken link - -`BROKEN_DELEGATION_LINK` covers every break in chain continuity: - -- the root names a parent, or the root's `depth` is not 0; -- a hop's `parent_id` does not equal the previous hop's `credential_id`; -- a hop's `issuer` is not the previous hop's `subject`; -- a hop's `depth` is not the previous hop's `depth` plus 1; -- the chain is empty. - -Any one of these means the chain is not a single unbroken delegation from root issuer to leaf subject, so it is denied. - -### Depth exceeded - -A hop whose `depth` exceeds `max_depth` (default 8) raises `DELEGATION_DEPTH_EXCEEDED`. This bounds the length of any accepted delegation and caps the blast radius of a runaway re-delegation. - -```python -verify_chain(chain, max_depth=8) # raises DelegationDepthExceeded past the limit -``` - -### Replay - -If any `credential_id` appears more than once in a chain, verification raises `CREDENTIAL_REPLAY`. A credential minted for one hop cannot be spliced back into the same chain or reused to fabricate a loop. Cross-chain replay protection is the sibling guarantee carried over from [agent-manifest](https://github.com/agentrust-io/agent-manifest); this module enforces the within-chain uniqueness half. - -## Provenance failures (enforced today) - -The provenance DAG is verifiable offline. `verify_dag` recomputes each record's hash and confirms the stored parent link matches. See [provenance-dag.md](provenance-dag.md) for the record model. - -### Tamper or reparent - -Each `DelegationRecord` links to its parent by the SHA-256 hash of the parent record's canonical body. Changing any field of a record (its `scope`, `subject`, `record_id`, or its own parent link) changes that record's hash, which breaks the link the child stored. `verify_dag` raises `PROVENANCE_LINK_BROKEN`: - -```python -from ca2a_runtime.provenance import verify_dag -from ca2a_runtime.errors import ProvenanceLinkBroken - -try: - verify_dag(records) -except ProvenanceLinkBroken as exc: - # code == "PROVENANCE_LINK_BROKEN" - deny(exc) -``` - -`verify_dag` denies when: - -- the record list is empty; -- the first record carries a parent link (a root must not reference a parent); -- any later record's `parent_record_hash` does not equal the recomputed hash of the immediately preceding record (this catches both tampering and reparenting). A link naming a digest this verifier does not compute never reaches that comparison: it raises `TRACE_DIGEST_UNSUPPORTED` instead, because a hash that was never recomputed cannot be evidence of tampering; -- a `record_id` repeats. - -`cross_check_chain` ties provenance back to authority: record `i` must reference credential `i` and carry the same `subject`. A mismatch in length, `credential_id`, or `subject` raises `PROVENANCE_LINK_BROKEN`. A verified DAG that does not line up with the delegation chain it claims to describe is not accepted as evidence. - -## Attestation failures (fails closed; hardware pending Tier 3) - -Peer attestation proves a peer runs measured code before a task is trusted to it. Two error codes govern the failure paths, defined in [attestation.md](attestation.md): - -- `ATTESTATION_UNSUPPORTED` (`AttestationUnsupported`): no attestation backend is available for the requested platform. -- `ATTESTATION_FAILED` (`AttestationFailed`): a backend ran but the measurement or quote did not verify. - -A provider returns `True` from `detect()` only where `attest()` can actually produce evidence on that host, so a provider is never selected and then found broken. All three hardware providers have collectors: `tpm` on a Linux host with a TPM and tpm2-pytss, `sev-snp` and `tdx` on a guest exposing the kernel configfs-TSM interface with the matching guest device. Where a host cannot collect, `AttestationUnsupported` names the actual missing piece rather than claiming the platform is absent, including on Azure confidential VMs, where SEV-SNP runs behind a paravisor and the channel key is rooted through the vTPM instead. `opaque` has a verifier but no collector, so its `attest()` raises. The `software-only` provider returns `False` from `detect()`, so a no-guarantee posture is always an explicit choice, and it never reports a hardware platform string. - -Verification fails closed on absent or invalid evidence. A report claiming a hardware platform with no `raw_evidence` or `quote_signature`, or with no certificate chain for the key that signed it, is rejected rather than trusted. The remaining gap is a full collect-then-verify pass in one process on hardware, which is Tier 3 on the [roadmap](../../ROADMAP.md) and a shared critical path with cmcp. - -## Missing or failed attestation is denial - -The same principle applies with force here: a peer that cannot produce a verifiable measurement is denied the task, not granted it "unless proven bad." A valid A2A Signed Agent Card is not a substitute for attestation. A card says the domain owner issued it; it says nothing about whether the code behind the card is the code that was measured. - -## Sealed channel (implemented; fails closed) - -The sealed peer channel seals a payload to the key a peer's attestation vouches for, so only the holder of that private key can open it. `open_sealed` never returns unauthenticated plaintext: a malformed blob, a wrong key, or a tampered ciphertext raises `SealedChannelError`. - -```python -from ca2a_runtime.channel import SealedChannel, generate_channel_keypair, open_sealed -from ca2a_runtime.errors import SealedChannelError - -peer_priv, peer_pub = generate_channel_keypair() # peer side, in the enclave on hardware -sealed = SealedChannel(peer_pub).seal(payload) # sender side -try: - opened = open_sealed(sealed, peer_priv) # only the peer's key opens it -except SealedChannelError as exc: - # code == "SEALED_CHANNEL_ERROR": wrong key or tampered payload. No plaintext returned. - abort(exc) -``` - -The property that the payload decrypts *only inside the attested measurement* rests on the private key being enclave-bound (a hardware property from attestation); binding the seal to a verified report on the live path is still to be wired. See [sealed-channel.md](sealed-channel.md) and [LIMITATIONS.md](../../LIMITATIONS.md). - -## What is enforced today versus pending - -| Failure mode | Error | Status | -|---|---|---| -| Unsigned or invalid credential | `INVALID_CREDENTIAL` | Enforced today | -| Scope escalation | `SCOPE_ESCALATION` | Enforced today | -| Broken delegation link | `BROKEN_DELEGATION_LINK` | Enforced today | -| Depth exceeded | `DELEGATION_DEPTH_EXCEEDED` | Enforced today | -| Credential replay (within chain) | `CREDENTIAL_REPLAY` | Enforced today | -| Provenance tamper or reparent | `PROVENANCE_LINK_BROKEN` | Enforced today | -| Capability not in effective scope (delegated ∩ local policy) | `SCOPE_NOT_PERMITTED` | Enforced today (decision core) | -| Sealed payload: wrong key or tampered ciphertext | `SEALED_CHANNEL_ERROR` | Enforced today (fails closed) | -| SEV-SNP attestation: bad chain, signature, or measurement | `ATTESTATION_FAILED` | Enforced today (verifier); report needs hardware | -| TDX / TPM attestation | `ATTESTATION_UNSUPPORTED` / `ATTESTATION_FAILED` | Pending Tier 3 | -| Live A2A transport wiring of the decision core | `SCOPE_NOT_PERMITTED` / others | Pending Tier 2 | -| Cedar policy engine binding for the local policy | (allow-set stands in) | Pending Tier 2 | - -The failures marked "enforced today" are exercised by the offline verifiers (`verify_chain`, `verify_chain_file`, `verify_dag`, `cross_check_chain`), the peer-call decision core (`enforce_peer_call`), the sealed channel (`open_sealed`), and the SEV-SNP verifier. What is not yet present is the live inbound A2A request path that would drive chain verification, scope intersection, attestation, and sealing off a real peer call, and the binding of the seal to a verified report on that path. That is Tier 2. See [LIMITATIONS.md](../../LIMITATIONS.md) and the [roadmap](../../ROADMAP.md) for sequencing. +# Failure Modes + +The full peer handler raises a `CA2AError` subtype when a required check fails and returns no payload. The configured caller-attestation requirement determines whether an absent offer is acceptable. The default permits absence and records `not_offered`; it never accepts a present but invalid offer. + +## Inbound failures + +| Failure | Error code | Result | +|---|---|---| +| Root issuer outside local trust set | `UNTRUSTED_DELEGATION_ROOT` | Stop before holder proof and policy | +| Invalid credential signature or validity | See [error codes](error-codes.md) | Stop before holder proof and policy | +| Scope widens, link breaks, depth exceeds limit, or credential ID repeats | `SCOPE_ESCALATION`, `BROKEN_DELEGATION_LINK`, `DELEGATION_DEPTH_EXCEEDED`, `CREDENTIAL_REPLAY` | Reject the chain | +| Missing or invalid required holder proof | `HOLDER_PROOF_INVALID` | Stop before policy; emit no decision record | +| Caller offer fails appraisal or required assurance is absent | `ATTESTATION_FAILED` | Stop before payload opening; refusal can carry a denial record | +| Requested capability outside effective scope | `SCOPE_NOT_PERMITTED` | Stop before payload opening; carry a denial record | +| No channel key, wrong key, malformed or altered ciphertext | `SEALED_CHANNEL_ERROR` | Return no plaintext | + +See [inbound peer-call decision](call-graph.md) for ordering. The lower-level `enforce_peer_call` helper does not perform holder proof or caller appraisal. + +## Attestation requirements + +`require_caller_attestation="none"` permits an absent offer. `"any"` requires an appraisable offer, including software assurance. `"hardware"` requires hardware assurance. A present but unappraisable offer is refused at every setting. An Agent Card or valid credential does not replace runtime appraisal. + +Hardware provider selection and evidence collection can fail with `ATTESTATION_UNSUPPORTED`; evidence appraisal can fail with `ATTESTATION_FAILED`. A detected device does not guarantee a later collection operation succeeds. TPM, SEV-SNP, and TDX have collectors and verifiers, but their deployment evidence and remaining gaps differ. See [hardware validation](../hardware-validation.md). + +The `auto` provider setting does not silently fall back to software when hardware is unavailable. Software mode must be selected explicitly. The `advisory` and `silent` enforcement-mode names do not currently disable peer-path denial. + +## Offline evidence failures + +Unsigned `verify_dag` checks path consistency: root shape, parent hashes, and record-ID uniqueness. `cross_check_chain` checks credential IDs and subjects against an independently verified chain. These checks do not authenticate an unsigned record producer or detect every possible rewrite; see [provenance DAG](provenance-dag.md). + +Signed `verify_trace_dag` additionally checks trusted signing keys and signatures. Invalid records raise `TRACE_RECORD_INVALID`; broken links raise `PROVENANCE_LINK_BROKEN`. A link naming an unsupported digest raises `TRACE_DIGEST_UNSUPPORTED`, meaning the verifier could not establish the link rather than that it established tampering. + +## Replay and completion boundaries + +Duplicate credential IDs within a chain and cross-chain splices are rejected. The offline verifier has no global used-credential database, so those checks do not establish single-use credentials across requests. + +A successful authorization record does not establish that payload opening or application execution succeeded. Applications need evidence of their own completion behavior. See [limitations](../../LIMITATIONS.md) and the [threat model](threat-model.md). diff --git a/docs/spec/provenance-dag.md b/docs/spec/provenance-dag.md index 7f936b0..68c671e 100644 --- a/docs/spec/provenance-dag.md +++ b/docs/spec/provenance-dag.md @@ -1,112 +1,45 @@ -# Provenance DAG - -A delegation chain proves who was *allowed* to act. The provenance DAG records what *actually happened*: one signed-in-substance record per delegation hop, each linked to its parent by hash. A chain of records forms a tamper-evident, hash-linked structure that a verifier reconstructs and checks offline, without trusting the operators that produced the records. - -This module (`ca2a_runtime.provenance`) is implemented and validated. Claim C5 exercises all of its properties (see [reproducing the claims](../tutorials/reproducing-the-claims.md) and the experiment at `experiments/claim5-provenance-dag-integrity/`). It is the runtime-evidence side of the [TRACE A2A profile](trace-a2a-profile.md); the full TRACE record binding lands with the Tier 2 provenance work. - -## DelegationRecord - -A `DelegationRecord` is the provenance record a hop emits for the delegation credential it acted under. It is a frozen dataclass: - -| Field | Type | Meaning | -|---|---|---| -| `record_id` | string | Unique id of this record | -| `credential_id` | string | The `credential_id` of the delegation credential this hop acted under | -| `subject` | hex | The delegate for this hop, copied from the credential's `subject` | -| `scope` | set of strings | Capabilities exercised at this hop, copied from the credential's `scope` | -| `parent_record_hash` | string or null | `record_hash()` of the parent record; null at the root | - -The hashed portion of the record is its `body()`: `record_id`, `credential_id`, `subject`, `scope` as a sorted array, and `parent_record_hash`. There is no separate signature field on the record. Integrity comes from the hash link, and authority comes from binding the record back to its signed credential with `cross_check_chain()`. - -## record_hash() - -`record_hash()` is the SHA-256 hex digest over the canonical bytes of `body()`: - -```python -def record_hash(self) -> str: - return hashlib.sha256(canonical_bytes(self.body())).hexdigest() -``` - -`canonical_bytes` is the same RFC 8785 (JCS) encoding used to sign delegation credentials (see [delegation chain](delegation-chain.md)). Because the parent link is the hash of the parent's canonical body, and every other field of a record feeds that hash, any change to a record changes its `record_hash()` and therefore breaks the link in its child. - -## record_for() - -`record_for()` builds the record a hop emits for a given credential. It copies `credential_id`, `subject`, and `scope` straight off the `DelegationCredential`, so a record cannot silently claim a credential id, subject, or scope different from the one it was minted from: - -```python -from ca2a_runtime.delegation.credential import DelegationCredential, new_keypair -from ca2a_runtime.provenance import record_for - -# `cred` is a signed DelegationCredential for this hop. -record = record_for(cred, record_id="rec-0", parent_record_hash=None) -``` - -The caller supplies the `record_id` and the `parent_record_hash` (the previous record's `record_hash()`, or `None` for the root). Chaining a workflow is a fold over the hops: - -```python -from ca2a_runtime.provenance import record_for - -records = [] -parent_hash = None -for i, cred in enumerate(chain): - rec = record_for(cred, record_id=f"rec-{i}", parent_record_hash=parent_hash) - records.append(rec) - parent_hash = rec.record_hash() -``` - -## verify_dag() - -`verify_dag()` takes a root-to-leaf list of records, checks the linking invariants, and returns the list unchanged on success. It raises `ProvenanceLinkBroken` (error code `PROVENANCE_LINK_BROKEN`, HTTP 409) on the first violation: - -```python -from ca2a_runtime.provenance import verify_dag -from ca2a_runtime.errors import ProvenanceLinkBroken - -try: - verify_dag(records) -except ProvenanceLinkBroken as exc: - ... # reject the workflow -``` - -The invariants are: - -| Invariant | Violation | -|---|---| -| The list is non-empty | `PROVENANCE_LINK_BROKEN` (`empty provenance chain`) | -| The first record is a root: `parent_record_hash` is `None` | `PROVENANCE_LINK_BROKEN` (`root record must not reference a parent`) | -| Every later record's `parent_record_hash` equals the recomputed `record_hash()` of the immediately preceding record | `PROVENANCE_LINK_BROKEN` (`record i parent link does not match the previous record's hash`) | -| No `record_id` repeats | `PROVENANCE_LINK_BROKEN` (`duplicate record_id at position i`) | - -The parent hash is *recomputed* from the previous record on every check rather than trusted. That is what makes the structure tamper-evident: the stored link and the recomputed hash have to agree. - -## cross_check_chain() - -`verify_dag()` proves the records are internally consistent, but on its own it says nothing about *authority*. `cross_check_chain()` ties provenance to the verified [delegation chain](delegation-chain.md): record `i` must reference credential `i` and carry the same subject. - -```python -from ca2a_runtime.provenance import cross_check_chain - -# `chain` has passed verify_chain; `records` has passed verify_dag. -cross_check_chain(records, chain) # raises ProvenanceLinkBroken on any mismatch -``` - -It raises `ProvenanceLinkBroken` if the two lists differ in length, if any `record.credential_id` does not equal the corresponding `credential.credential_id`, or if any `record.subject` does not equal the corresponding `credential.subject`. Run `verify_chain` on the credentials and `verify_dag` on the records first, then `cross_check_chain` to bind the two. A forged `credential_id` on a record, for example, is caught here even though the record chain itself hashes cleanly. - -## Tamper-evidence - -SHA-256 exhibits the avalanche property: a one-field change to a record produces a digest that differs from the original in roughly half of its 256 bits. The C5 experiment measures this directly by adding a capability to a record's scope and counting differing bits between the old and new `record_hash()`. It observes about 128 of 256 bits flipped. - -Because the child record stores the parent's *old* hash in `parent_record_hash`, a tampered record no longer hashes to that stored value, and `verify_dag()` raises `ProvenanceLinkBroken` at the child. An attacker cannot fix this by editing only one record: repairing the child's `parent_record_hash` to match the tampered parent changes the child's own hash, which breaks *its* child, and so on to the leaf. Correcting the whole tail requires recomputing every downstream link, which `cross_check_chain()` then rejects if the underlying credentials no longer line up. - -## Reparenting - -Reparenting is the attack of pointing a record at a different, legitimately-hashed parent to hide a hop or re-order the chain. It is caught by the same recompute-and-compare check. `verify_dag()` walks the list in order and requires each record's `parent_record_hash` to equal the hash of the record *immediately before it in the list*. A record whose `parent_record_hash` points at some other record's hash (for example the root's, skipping an intermediate hop) fails that equality and raises `ProvenanceLinkBroken`. The C5 experiment demonstrates this by repointing the leaf's `parent_record_hash` at the root's hash instead of its true parent and confirming detection. - -## Status and scope - -The provenance DAG in this module is implemented and reproducible under claim C5. What it does *not* yet do: - -- The records are hash-linked, not independently signed. Authority binding is via `cross_check_chain()` against signed credentials, not a signature on each record. -- The full TRACE record binding described in the [TRACE A2A profile](trace-a2a-profile.md), emitting these links as `delegation.parent_record_hash` / `delegation.credential_id` fields inside a TRACE record, lands with the Tier 2 provenance work. See [ROADMAP.md](../../ROADMAP.md) and [LIMITATIONS.md](../../LIMITATIONS.md). - -For the offline chain verifier that this module pairs with, see the [verification library](verification-library.md). +# Provenance DAG + +A delegation credential describes authority. A provenance record describes a decision made under that authority. cA2A provides both unsigned `DelegationRecord` helpers and a signed TRACE binding. Choose the signed form when a recipient needs to authenticate who produced the evidence. + +## DelegationRecord + +`ca2a_runtime.provenance.DelegationRecord` contains a record ID, credential ID, subject, delegated scope, parent-record hash, and caller-attestation outcome. Denial records also carry the decision, requested capability, effective scope, and reason. + +The `scope` field copies the credential's grant. It is not a list of actions the application completed. An allow record is constructed at the authorization decision, before the full handler opens any sealed payload. + +`record_for(credential, record_id, parent_record_hash)` constructs an allow record. `denial_record_for` constructs a refusal. `record_hash()` computes SHA-256 over the RFC 8785 canonical body, including the caller-attestation outcome and any populated denial fields. These records have no independent signature. + +## Build and check a path + +This reference fragment assumes `chain` has already passed credential verification against independently trusted roots. For a complete walkthrough, see [emit and verify provenance](../tutorials/emit-and-verify-provenance.md). + +```python +from ca2a_runtime.provenance import record_for, verify_dag, cross_check_chain + +records = [] +parent_hash = None +for i, credential in enumerate(chain): + record = record_for(credential, record_id=f"rec-{i}", parent_record_hash=parent_hash) + records.append(record) + parent_hash = record.record_hash() + +verify_dag(records) +cross_check_chain(records, chain) +``` + +`verify_dag` accepts one ordered root-to-leaf path. It rejects an empty path, a root with a parent, repeated record IDs, or a child whose parent hash differs from the recomputed hash of the immediately preceding record. Despite its name, it does not accept an arbitrary branching graph. + +`cross_check_chain` checks path length, credential IDs, and subjects against a separately verified credential chain. It does not check the record's scope or authenticate its producer. + +## What hash links establish + +Changing a parent without updating its child's link is detected. Repointing a child away from the preceding record is also detected. C5 exercises those cases. + +Hash links alone do not prevent an attacker from rewriting the records and recomputing every downstream hash. A leaf edit has no child link to break. Credential cross-checking does not fix this: an attacker can retain the credential IDs and subjects while changing other unsigned fields. An authenticated external commitment or signed records are needed to establish authenticity. Neither form alone proves task completion or that the supplied path includes every event. + +## Signed TRACE binding + +`ca2a_runtime.trace_binding` emits signed TRACE records carrying `delegation.parent_record_hash` and `delegation.credential_id`. `ca2a_verify.verify_trace_dag` checks signatures against the recipient's trusted keys, record structure, and the ordered parent links. `cross_check_trace_dag` aligns non-root credential IDs with the verified credential chain. + +This binding is implemented. See the [verification library](verification-library.md) for trust and freshness inputs and the [TRACE A2A profile](trace-a2a-profile.md) for the wire fields. Software signing authenticates a key's statement; hardware assurance requires separate evidence and appraisal. diff --git a/docs/spec/sealed-channel.md b/docs/spec/sealed-channel.md index 727bd39..d5bfb7d 100644 --- a/docs/spec/sealed-channel.md +++ b/docs/spec/sealed-channel.md @@ -1,31 +1,39 @@ # Sealed Peer Channel -The sealed channel binds a task payload to the key a peer's attestation vouches for, so it decrypts only with the private key held inside that peer's verified enclave. The channel is implemented; the guarantee that the private key is enclave-bound is what attestation establishes, and driving the seal off a verified report on a live call is runtime wiring still to come. - -## Threat it addresses - -When A sends B a task payload, that payload crosses a network and lands in B's memory. If B is in another trust domain, mTLS protects the pipe but not the endpoint: the operator hosting B, or a connectivity provider between them, can read plaintext. Sealing the payload to the key bound to B's measurement means only a B that booted the expected, measured code holds the private key that can open it. +The sealed channel encrypts a task to a peer's X25519 public key. Only the matching private key can open it. The reference runtime connects this encryption to channel-offer appraisal; software mode exercises the same encryption without proving hardware isolation. ## Scheme -HPKE-style, using only primitives from the `cryptography` library: +The module uses X25519 key agreement, HKDF-SHA256, and ChaCha20-Poly1305 from `cryptography`. The sealed blob contains a version, ephemeral public key, nonce, and authenticated ciphertext. This is an HPKE-style construction, not a claim of RFC 9180 interoperability. -1. The peer generates an X25519 channel keypair (inside its enclave on hardware) and vouches for the public key through its attestation report. -2. The sender does an ephemeral X25519 ECDH to that public key, derives a 32-byte key with HKDF-SHA256, and encrypts the payload with ChaCha20-Poly1305. -3. The sealed blob is `version || ephemeral_public_key || nonce || ciphertext`. Only the holder of the peer's private key can reconstruct the shared secret and decrypt. +## Try the cryptographic layer -## Interface +Run this after installing the source checkout as shown in the [quick start](../quickstart.md): ```python from ca2a_runtime.channel import SealedChannel, generate_channel_keypair, open_sealed - -peer_priv, peer_pub = generate_channel_keypair() # peer side, in the enclave on hardware -sealed = SealedChannel(peer_pub).seal(payload, aad=b"session-id") # sender side -opened = open_sealed(sealed, peer_priv, aad=b"session-id") # only the peer's key opens it +from ca2a_runtime.errors import SealedChannelError + +peer_private, peer_public = generate_channel_keypair() +payload = b"reconcile this ledger" +sealed = SealedChannel(peer_public).seal(payload, aad=b"session-1") +assert open_sealed(sealed, peer_private, aad=b"session-1") == payload + +wrong_private, _ = generate_channel_keypair() +try: + open_sealed(sealed, wrong_private, aad=b"session-1") +except SealedChannelError: + print("wrong key rejected") +else: + raise AssertionError("wrong key accepted") ``` -`open_sealed` fails closed with `SEALED_CHANNEL_ERROR` on a malformed blob, a wrong key, or a tampered ciphertext (AEAD authentication failure); it never returns unauthenticated plaintext. `aad` binds context (for example a session id) into the authentication tag. +Malformed blobs, changed ciphertext, wrong keys, or mismatched additional authenticated data raise `SEALED_CHANNEL_ERROR`; no unauthenticated plaintext is returned. Both sides must supply the same `aad` when using that low-level option. + +## Attestation and key custody + +In a live call, the sender appraises the recipient's channel offer before sealing to it. The recipient performs its inbound checks before opening the payload. See [inbound peer-call decision](call-graph.md). -## What rests on hardware +Encryption to a key does not by itself prove where the private key lives. Protection against the recipient's host operator depends on accepted hardware evidence binding the key to the expected measured environment, and on that environment retaining the private key. This example generates its keys in ordinary process memory and demonstrates no such protection. -The cryptographic confidentiality of the payload to the attested key is implemented and tested here. The stronger property, that the payload decrypts *only inside the attested measurement*, holds because the private key is generated in and never leaves the peer's enclave; that is a hardware property established by [attestation](attestation.md), not by this module. Binding the seal to a verified report on a live inbound call is tracked on the [roadmap](../../ROADMAP.md). The connectivity path sees ciphertext; the only thing that leaves the enclave in the clear is the signed TRACE record. +The encrypted payload does not hide all transport metadata, offers, or evidence records. See [attestation](attestation.md), [hardware validation](../hardware-validation.md), and [limitations](../../LIMITATIONS.md) for the deployment-specific assurance boundary. diff --git a/docs/spec/verification-library.md b/docs/spec/verification-library.md index b3ff244..1bb582b 100644 --- a/docs/spec/verification-library.md +++ b/docs/spec/verification-library.md @@ -1,6 +1,6 @@ # Verification Library -`ca2a-verify` verifies a delegation chain, and eventually the delegation DAG, offline. It does not require trusting any operator: a chain is checked against the issuers' public keys and the attenuation invariants alone. +`ca2a-verify` checks delegation credentials and signed TRACE record paths offline. The caller supplies trusted credential issuers and record-signing keys; cryptographic consistency alone does not establish authorization. ## API @@ -26,8 +26,12 @@ All verification failures are subtypes of `CA2AError`, re-exported as `Verificat ## Offline by design -The verifier reads only the chain document. It contacts no server, trusts no operator signature over the transport, and produces the same verdict anywhere. This is what makes a delegation chain usable as evidence in an audit or a procurement review, not just at runtime. +Chain verification contacts no server. For reproducible historical checks, preserve the document, trust set, verifier version, and evaluation time. The default current-time validity check can change its verdict as credentials expire. -## Not yet implemented +## Signed TRACE record paths -The delegation DAG verifier, which links each hop's TRACE record to its parent and checks the whole tree, lands with the Tier 2 provenance work. See [ROADMAP.md](../../ROADMAP.md). +`verify_trace_dag(records, trusted_keys=..., max_age_seconds=None)` verifies signatures from trusted keys, record structure, and parent hashes along one ordered root-to-leaf path. It returns a `TraceDagResult`. The default omits a record-age limit for historical audits; supply `max_age_seconds` when freshness is required. + +`cross_check_trace_dag(records, chain)` then checks path length and non-root credential IDs. Run credential and signed-record verification first. This cross-check does not independently bind TRACE subjects to credential subjects or prove task completion. + +The API name uses DAG terminology, but the input is one ordered path, not an arbitrary branching tree. The unsigned `DelegationRecord` helper has a separate `verify_dag` consistency check; it is not a substitute for signature verification. See [provenance DAG](provenance-dag.md) and [TRACE A2A profile](trace-a2a-profile.md). diff --git a/docs/tutorials/authoring-a-delegation-credential.md b/docs/tutorials/authoring-a-delegation-credential.md index 4df9275..9b49f4d 100644 --- a/docs/tutorials/authoring-a-delegation-credential.md +++ b/docs/tutorials/authoring-a-delegation-credential.md @@ -4,6 +4,12 @@ The [verify-a-delegation-chain](verify-a-delegation-chain.md) tutorial takes an Everything here uses `ca2a_runtime.delegation`. For the field semantics and the full invariant table, see [the delegation chain spec](../spec/delegation-chain.md). +## Setup + +Use Python 3.11+. Clone `https://github.com/agentrust-io/ca2a.git`, enter the checkout, create and activate a virtual environment, then run `python -m pip install -e .`. Run the Python blocks below in order in one script or interactive session. The negative examples catch their expected errors so execution can continue. + +The example credentials omit validity bounds to keep the field walkthrough short. Use bounded grants for deployed systems; the [quick start](../quickstart.md) shows those fields. + ## 1. Generate keypairs Each hop is signed by its issuer and names a subject. Both are Ed25519 public keys, encoded as raw hex. `new_keypair()` returns the private key object and its public key hex. @@ -12,6 +18,7 @@ Each hop is signed by its issuer and names a subject. Both are Ed25519 public ke from ca2a_runtime.delegation import new_keypair a_priv, a_pub = new_keypair() # root agent A +trusted_roots = {a_pub} # retain our local authority independently of the chain b_priv, b_pub = new_keypair() # agent B c_priv, c_pub = new_keypair() # agent C ``` @@ -40,13 +47,17 @@ The root credential must have `depth=0` and `parent_id=None`; `verify_chain` rej `sign()` checks that the signing key matches the `issuer` field. Signing with the wrong key raises `INVALID_CREDENTIAL`: ```python -root_wrong = DelegationCredential( - credential_id="cred-0", - issuer=a_pub, - subject=b_pub, - scope=frozenset({"cap:read"}), - depth=0, -).sign(b_priv) # raises InvalidCredential: signing key does not match credential issuer +from ca2a_runtime.errors import InvalidCredential + +try: + DelegationCredential( + credential_id="cred-0", issuer=a_pub, subject=b_pub, + scope=frozenset({"cap:read"}), depth=0, + ).sign(b_priv) +except InvalidCredential: + print("wrong signing key rejected") +else: + raise AssertionError("wrong signing key accepted") ``` ## 3. Extend the chain with narrowing scope @@ -85,14 +96,21 @@ Order the credentials root to leaf and call `verify_chain`. It returns `None` on from ca2a_runtime.delegation import verify_chain chain = [root, mid, leaf] -verify_chain(chain) # returns None: all invariants hold +verify_chain(chain, trusted_root_issuers=trusted_roots) print("verified", len(chain), "hops; leaf scope", sorted(leaf.scope)) ``` `verify_chain` takes an optional `max_depth` keyword (default `8`). A hop whose `depth` exceeds it raises `DELEGATION_DEPTH_EXCEEDED`. ```python -verify_chain(chain, max_depth=1) # raises DelegationDepthExceeded at hop 2 +from ca2a_runtime.errors import DelegationDepthExceeded + +try: + verify_chain(chain, max_depth=1, trusted_root_issuers=trusted_roots) +except DelegationDepthExceeded: + print("depth limit enforced") +else: + raise AssertionError("depth limit bypassed") ``` ## 5. Watch a child over-scope @@ -112,10 +130,12 @@ over = DelegationCredential( from ca2a_runtime.errors import ScopeEscalation try: - verify_chain([root, mid, over]) + verify_chain([root, mid, over], trusted_root_issuers=trusted_roots) except ScopeEscalation as exc: print(exc.code, "-", exc, "|", exc.detail) # SCOPE_ESCALATION - hop 2 scope exceeds parent grant | added: ['cap:admin'] +else: + raise AssertionError("scope escalation accepted") ``` The signature on `over` is valid; C really did sign it. That is the point. A well-formed signature proves only that C authored the grant, not that C was entitled to make it. The subset check on `scope` is what forecloses the confused-deputy move where a delegate quietly widens its own authority. See [the delegation chain spec](../spec/delegation-chain.md#attenuation-is-the-whole-point). @@ -138,6 +158,6 @@ A malformed dict (missing field, wrong type) raises `INVALID_CREDENTIAL` from `f ## What you built -You produced a three-hop chain of delegated authority whose scope provably narrows at each hop, and you confirmed that a child cannot silently widen its grant. This chain verifies offline, with no trust in whoever produced it, using only the issuers' public keys embedded in the credentials. +You produced and verified a narrowing three-hop chain against a separately retained root authority. A correctly signed child that exceeded its parent's scope was rejected. A production verifier must obtain trusted roots through its own approval process; an incoming chain cannot choose them. -The cA2A runtime calls this same verifier on the inbound peer path. Runtime peer enforcement, intersecting a verified scope against the peer's local Cedar policy, is Tier 2 and not yet implemented; see [LIMITATIONS](../../LIMITATIONS.md) and the [Cedar policy](../spec/cedar-policy.md) design. To carry these credentials into a checkable provenance DAG, continue to [emit and verify provenance](emit-and-verify-provenance.md). +The runtime uses the same verifier before holder proof and local-policy enforcement. See [Cedar policy](../spec/cedar-policy.md) for the implemented policy path. Continue to [verify a saved chain](verify-a-delegation-chain.md) or [emit and verify provenance](emit-and-verify-provenance.md). diff --git a/docs/tutorials/emit-and-verify-provenance.md b/docs/tutorials/emit-and-verify-provenance.md index d998dd8..8a0b599 100644 --- a/docs/tutorials/emit-and-verify-provenance.md +++ b/docs/tutorials/emit-and-verify-provenance.md @@ -1,203 +1,72 @@ -# Emit and Verify Provenance - -A verified delegation chain tells you who was allowed to act. A provenance DAG is the runtime evidence that the delegation actually happened, in order, and was not edited after the fact. This tutorial takes a signed chain, emits one `DelegationRecord` per hop, verifies the linked records offline, tampers with one record to watch the link break, and binds the provenance back to the delegation credentials it claims to act under. - -Everything here runs offline with no hardware. It mirrors `experiments/claim5-provenance-dag-integrity`. For the model behind these records, see [provenance-dag.md](../spec/provenance-dag.md); for the credential model see [delegation-chain.md](../spec/delegation-chain.md). - -## What a record is - -Each delegation hop emits a `DelegationRecord`. The record names the credential it acted under, repeats that credential's `subject` and `scope`, and carries `parent_record_hash`: the SHA-256 of the previous record's canonical body. The hash link is what makes the DAG tamper-evident. Change any field of a record and its `record_hash()` changes, so the child that pointed at the old hash no longer lines up. - -```python -from dataclasses import dataclass - -@dataclass(frozen=True) -class DelegationRecord: - record_id: str - credential_id: str - subject: str - scope: frozenset[str] - parent_record_hash: str | None = None -``` - -`record_hash()` is SHA-256 over the canonical body (`record_id`, `credential_id`, `subject`, sorted `scope`, `parent_record_hash`). The canonicalization is the same RFC 8785 (JCS) encoding used to sign credentials, so an auditor recomputes the exact bytes. - -## 1. Build a signed chain - -Start from a correctly signed root-to-leaf delegation chain. This is the same setup used in [verify-a-delegation-chain.md](verify-a-delegation-chain.md); here we build it in code so we have the credentials in hand to emit records from. - -```python -from ca2a_runtime.delegation.credential import DelegationCredential, new_keypair - - -def build_chain(scopes: list[frozenset[str]]) -> list[DelegationCredential]: - chain: list[DelegationCredential] = [] - priv, pub = new_keypair() - parent_id: str | None = None - for depth, scope in enumerate(scopes): - next_priv, next_pub = new_keypair() - cred = DelegationCredential( - credential_id=f"cred-{depth}", - issuer=pub, - subject=next_pub, - scope=scope, - depth=depth, - parent_id=parent_id, - ).sign(priv) - chain.append(cred) - parent_id = cred.credential_id - priv, pub = next_priv, next_pub - return chain - - -chain = build_chain( - [ - frozenset({"cap:a", "cap:b", "cap:c"}), - frozenset({"cap:a", "cap:b"}), - frozenset({"cap:a"}), - ] -) -``` - -Each hop's `issuer` is the previous hop's `subject`, and scope narrows at every step. `new_keypair()` returns an `Ed25519PrivateKey` and its public key as raw hex. - -## 2. Emit one record per hop - -Walk the chain and call `record_for()` for each credential, threading the running `parent_record_hash`. The root record has no parent, so it starts at `None`. - -```python -from ca2a_runtime.provenance import DelegationRecord, record_for - - -def records_from_chain(chain: list[DelegationCredential]) -> list[DelegationRecord]: - records: list[DelegationRecord] = [] - parent_hash: str | None = None - for i, cred in enumerate(chain): - rec = record_for(cred, record_id=f"rec-{i}", parent_record_hash=parent_hash) - records.append(rec) - parent_hash = rec.record_hash() - return records - - -records = records_from_chain(chain) -``` - -`record_for(credential, record_id, parent_record_hash)` copies `credential_id`, `subject`, and `scope` off the credential and stamps in the parent link you pass. After appending a record you recompute `record_hash()` and feed it forward as the next record's parent. - -## 3. Verify the DAG - -`verify_dag()` walks the records root to leaf and returns them in order on success. It enforces three things: the first record must be a root (no parent link), every later record's `parent_record_hash` must equal the recomputed hash of the immediately preceding record, and no `record_id` may repeat. - -```python -from ca2a_runtime.provenance import verify_dag - -verified = verify_dag(records) -print(f"verified {len(verified)} records") -# verified 3 records -``` - -If it returns without raising, the linked hash chain is intact. - -## 4. Tamper with a record - -Now edit one field of a record without touching anything else. Because `record_hash()` covers `scope`, adding a capability flips roughly half of the 256 hash bits (the SHA-256 avalanche), so record 1's new hash no longer matches the `parent_record_hash` that record 2 still stores. - -```python -from ca2a_runtime.errors import ProvenanceLinkBroken - -original = records[1] -tampered = DelegationRecord( - record_id=original.record_id, - credential_id=original.credential_id, - subject=original.subject, - scope=frozenset(original.scope | {"cap:injected"}), - parent_record_hash=original.parent_record_hash, -) - -tampered_records = list(records) -tampered_records[1] = tampered - -try: - verify_dag(tampered_records) -except ProvenanceLinkBroken as exc: - print(f"{exc.code}: {exc}") -# PROVENANCE_LINK_BROKEN: record 2 parent link does not match the previous record's hash -``` - -`ProvenanceLinkBroken` carries code `PROVENANCE_LINK_BROKEN` and HTTP status 409. The message names the position where the link failed, and `exc.detail` reads `a tampered or reparented record was detected`. Note that the tampered record is at position 1, but the break is detected at position 2: the verifier catches the edit at the first child whose stored link no longer matches. - -## 5. Reparent a record - -The same mechanism catches a record repointed at a different parent, even when the record's own fields are untouched. Here the leaf is made to claim the root's hash as its parent instead of record 1's. - -```python -leaf = records[2] -reparented = DelegationRecord( - record_id=leaf.record_id, - credential_id=leaf.credential_id, - subject=leaf.subject, - scope=leaf.scope, - parent_record_hash=records[0].record_hash(), # should be records[1]'s hash -) - -reparented_records = list(records) -reparented_records[2] = reparented - -try: - verify_dag(reparented_records) -except ProvenanceLinkBroken as exc: - print(f"{exc.code}: {exc}") -# PROVENANCE_LINK_BROKEN: record 2 parent link does not match the previous record's hash -``` - -You cannot splice a record into a different position in the DAG without breaking the link, because the stored `parent_record_hash` must equal the hash of the record that actually precedes it. - -## 6. Bind provenance to authority - -`verify_dag()` proves the records are internally consistent, but on its own it does not prove they describe the delegation you think they do. Records could be internally valid yet name credentials that never existed. `cross_check_chain()` closes that gap: record `i` must reference credential `i` and carry the same subject. - -```python -from ca2a_runtime.provenance import cross_check_chain - -cross_check_chain(records, chain) # returns None on success -print("provenance bound to the delegation chain") -``` - -Forge a `credential_id` on any record and the cross-check rejects it: - -```python -mismatch = list(records) -mismatch[0] = DelegationRecord( - record_id=records[0].record_id, - credential_id="FORGED-CRED-ID", - subject=records[0].subject, - scope=records[0].scope, - parent_record_hash=None, -) - -try: - cross_check_chain(mismatch, chain) -except ProvenanceLinkBroken as exc: - print(f"{exc.code}: {exc}") -# PROVENANCE_LINK_BROKEN: record 0 credential_id does not match the chain -``` - -`cross_check_chain()` also raises `ProvenanceLinkBroken` if the record list and the chain are different lengths, or if any record's `subject` does not match its credential's `subject`. Run both checks together and a valid provenance DAG cannot be fabricated independently of the signed authority it claims. - -## What you proved - -You emitted a linked provenance record per delegation hop, verified the DAG offline, and watched a single-field edit and a reparent both surface as `ProvenanceLinkBroken`. `cross_check_chain()` ties every record back to the credential it acted under, so the evidence trail is bound to the signed delegation chain, not free-floating. An auditor replays `verify_dag()` and `cross_check_chain()` against the recorded records and credentials without trusting the runtime that emitted them. - -## Scope and limits - -This is the runtime-evidence side of the cA2A profile and it works today. What it is not: - -- These records are a hash-linked evidence trail. They are not yet the full TRACE binding; that lands with the Tier 2 provenance work. See [trace-a2a-profile.md](../spec/trace-a2a-profile.md). -- The DAG is verified after the fact from recorded records. cA2A does not yet enforce peer behavior at runtime (Tier 2), so a peer must still emit honest records for the trail to mean anything. See [threat-model.md](../spec/threat-model.md) and [LIMITATIONS.md](../../LIMITATIONS.md). -- The verifier walks a single root-to-leaf sequence. Branching DAGs and the wire format for transmitting records are on the [roadmap](../../ROADMAP.md). - -## Next steps - -- Reproduce the numbers behind this page: [reproducing-the-claims.md](reproducing-the-claims.md). -- Author the credentials the records point at: [authoring-a-delegation-credential.md](authoring-a-delegation-credential.md). -- The full provenance model and record schema: [provenance-dag.md](../spec/provenance-dag.md). +# Emit and Verify Provenance + +Build an unsigned record path, detect a broken link, and see why consistent hashes alone do not authenticate evidence. First run the Python blocks in [authoring a delegation credential](authoring-a-delegation-credential.md), which install the package and define a verified `chain` and independently retained `trusted_roots`. Continue in the same script or session. + +## Emit one record per credential + +```python +from ca2a_runtime.provenance import record_for, verify_dag, cross_check_chain + +records = [] +parent_hash = None +for i, credential in enumerate(chain): + record = record_for(credential, record_id=f"rec-{i}", parent_record_hash=parent_hash) + records.append(record) + parent_hash = record.record_hash() + +verify_dag(records) +cross_check_chain(records, chain) +print("3 records: links and credential references match") +``` + +Each record copies its credential's ID, subject, and delegated scope. The scope is the grant, not proof of completed actions. The canonical hash also covers caller-appraisal and any populated denial fields. These locally constructed records carry no independent signatures and do not show that a task ran. + +## Detect an unrepaired change + +```python +from dataclasses import replace +from ca2a_runtime.errors import ProvenanceLinkBroken + +tampered = list(records) +tampered[1] = replace(records[1], scope=records[1].scope | {"cap:injected"}) +try: + verify_dag(tampered) +except ProvenanceLinkBroken: + print("parent edit detected at its child") +else: + raise AssertionError("broken parent link accepted") +``` + +Record 1's hash changed while record 2 still holds its old hash. Reparenting record 2 to the root instead of the preceding record is likewise rejected: + +```python +reparented = list(records) +reparented[2] = replace(records[2], parent_record_hash=records[0].record_hash()) +try: + verify_dag(reparented) +except ProvenanceLinkBroken: + print("reparenting detected") +else: + raise AssertionError("wrong parent accepted") +``` + +## See the boundary + +An attacker who can rewrite the entire unsigned path can recompute downstream links. The credential cross-check compares IDs and subjects, not every record field. This counterexample deliberately changes scope while retaining those references: + +```python +repaired = list(tampered) +repaired[2] = replace(repaired[2], parent_record_hash=repaired[1].record_hash()) +verify_dag(repaired) +cross_check_chain(repaired, chain) +assert repaired[1].scope != chain[1].scope +print("rewritten unsigned path passes consistency checks; authenticity is not established") +``` + +This does not change the signed delegation or grant additional runtime authority. It shows that hash consistency and credential references do not authenticate the record producer. A leaf edit has no child link to break at all. + +## Use signed evidence for authenticity + +The implemented TRACE binding signs records carrying delegation links. `verify_trace_dag` requires the recipient's trusted signing keys and checks signatures, structure, and parent links. `cross_check_trace_dag` then aligns non-root credential IDs with a separately verified chain. See the [verification library](../spec/verification-library.md). + +Both path verifiers accept one ordered root-to-leaf sequence, not an arbitrary branching graph. Even signed authorization evidence does not establish task completion or completeness of the submitted history. For runtime checks, see [inbound peer-call decision](../spec/call-graph.md). diff --git a/docs/tutorials/reproducing-the-claims.md b/docs/tutorials/reproducing-the-claims.md index 4ba016e..5f38e64 100644 --- a/docs/tutorials/reproducing-the-claims.md +++ b/docs/tutorials/reproducing-the-claims.md @@ -1,188 +1,56 @@ # Reproducing the Claims -Every technical claim in the cA2A profile is backed by a script under `experiments/`. This tutorial runs all six. Three are validated today (C1, C2, C5) and print a `KEY RESULT` line you can check. Three are gated on unbuilt tiers (C3, C4, C6) and SKIP with exit 0, matching what [LIMITATIONS.md](../../LIMITATIONS.md) says is not yet built. Nothing here needs a TEE or any hardware. +Run six software experiments from the source checkout. They exercise delegation, policy intersection, encrypted payloads, hash links, and synthetic attestation. None requires a TEE. Their results establish the specific cases below, not hardware protection or complete profile conformance. -Each experiment imports directly from `ca2a_runtime`, so it exercises the real API described in [The Verification Library](../spec/verification-library.md), not a mock. +## Install and run -## 1. Install - -Run from the repo root: +Use Python 3.11 or later: ```bash -pip install -e ".[dev]" +git clone https://github.com/agentrust-io/ca2a.git +cd ca2a +python -m venv .venv ``` -The `[dev]` extra pulls in `pytest`, so the same install runs both the standalone experiment scripts and the CI unit tests. Two of the scripts (`claim3` and `claim6`) import `ca2a_runtime` with no `sys.path` fallback, so the editable install is required for the full suite. - -## 2. The map - -| Dir | Claim | Status | What it proves | -|-----|-------|--------|----------------| -| `claim1-attenuation-soundness` | C1 | validated | A child grant can never exceed its parent | -| `claim2-cross-chain-replay` | C2 | validated | Replayed or spliced credentials are rejected | -| `claim3-scope-policy-intersection` | C3 | gated (Tier 2) | Delegated scope intersected with local Cedar policy | -| `claim4-sealed-payload-confidentiality` | C4 | gated (Tier 2) | Payload decrypts only inside the attested peer | -| `claim5-provenance-dag-integrity` | C5 | validated | Linked records are tamper-evident, bound to authority | -| `claim6-cross-operator-attestation` | C6 | gated (Tier 3) | Mutual attestation, binary-swap detection | - -Gated experiments SKIP (exit 0) until the implementation they depend on lands. Each gated dependency is a line item on [ROADMAP.md](../../ROADMAP.md): Tier 2 is runtime peer-delegation enforcement and the sealed channel; Tier 3 is a real hardware attestation backend. - -## 3. C1: attenuation soundness (validated) +Activate with `source .venv/bin/activate` on macOS/Linux or `.venv\Scripts\Activate.ps1` in PowerShell. Then: ```bash +python -m pip install -e ".[dev]" python experiments/claim1-attenuation-soundness/run.py -``` - -The script builds 200 strictly narrowing chains with `DelegationCredential`, `new_keypair`, and `sign`, then verifies each with `verify_chain`. It then builds 200 escalating variants in which one hop adds a capability no ancestor held, and confirms each is rejected with `ScopeEscalation`. - -```text -[1] Narrowing chains accepted - trials: 200 - accepted: 200/200 - -[2] Escalation attempts rejected - trials: 200 - rejected with ScopeEscalation: 200/200 - example: hop 3 scope exceeds parent grant (added: ['cap:escalate-0']) - -============================================================ -KEY RESULT: 200/200 narrowing chains accepted; 200/200 escalation attempts rejected (ScopeEscalation) -``` - -Exit code 0. See [Delegation Chain](../spec/delegation-chain.md) for the attenuation rule and [Error Codes](../spec/error-codes.md) for `SCOPE_ESCALATION`. - -## 4. C2: cross-chain replay (validated) - -```bash python experiments/claim2-cross-chain-replay/run.py -``` - -Two independent chains verify as a control. Then a credential is duplicated inside one chain (`CredentialReplay`), and a credential minted for chain A is spliced into chain B, breaking continuity (`BrokenDelegationLink`). - -```text - spliced credential_id: a-1 (from chain A) - verify_chain raised: BrokenDelegationLink OK - error detail: hop 1 parent_id does not match previous credential_id - -============================================================ -KEY RESULT: 2/2 replay attacks rejected (1 CredentialReplay, 1 BrokenDelegationLink); 2/2 control chains valid -``` - -Exit code 0. These are the anti-replay invariants in the [Threat Model](../spec/threat-model.md). - -## 5. C5: provenance DAG integrity (validated) - -```bash -python experiments/claim5-provenance-dag-integrity/run.py -``` - -The script emits one `DelegationRecord` per hop with `record_for`, chaining `parent_record_hash`, and verifies the DAG with `verify_dag`. It then tampers one record's scope, measures the SHA-256 avalanche across `record_hash()`, and confirms the tamper and a reparent both raise `ProvenanceLinkBroken`. Finally `cross_check_chain` binds each record to its credential and rejects a forged `credential_id`. - -```text -[4. cross_check_chain ties record i to credential i] - aligned records: cross_check_chain passes OK - credential_id mismatch: ProvenanceLinkBroken raised OK - -============================================================ -KEY RESULT: tamper flips ~50% of hash bits (120/256), ProvenanceLinkBroken raised; reparent detected; provenance bound to authority -``` - -Exit code 0. The bit count varies run to run around 128/256 because the key material is freshly generated; the property is that it is close to half, not an exact value. See the [Provenance DAG](../spec/provenance-dag.md) page. - -## 6. C3: scope-policy intersection (gated on Tier 2) - -```bash python experiments/claim3-scope-policy-intersection/run.py -``` - -This SKIPs. Two runtime pieces do not exist yet: there is no request-time gate on an inbound peer call, and there is no wiring from a verified delegated scope to a Cedar policy engine. Both are Tier 2 on [ROADMAP.md](../../ROADMAP.md). The script prints a small hard-coded set intersection labeled as illustrative only; it is not the Cedar engine. - -```text -SKIP: Tier 2 runtime scope-policy intersection is not implemented. - - runtime peer-delegation enforcement not built (no call-time gate) - - Cedar intersection not wired (no delegated-scope AND local-policy path) -See ROADMAP.md. Exiting 0 so CI and dev hosts pass. -... -KEY RESULT: SKIP (gated on Tier 2). Effective permission = delegated scope - intersected with local Cedar policy; enforcement path not built. -``` - -Exit code 0. See [Cedar Policy](../spec/cedar-policy.md) for the intended intersection. - -## 7. C4: sealed-payload confidentiality (gated on Tier 2) - -```bash python experiments/claim4-sealed-payload-confidentiality/run.py -``` - -The sealed channel is a fail-closed placeholder. This script does not demonstrate confidentiality; it demonstrates the honest current behavior, that `SealedChannel.seal()` and `open()` raise `SealedChannelError` (code `SEALED_CHANNEL_ERROR`) rather than silently emitting plaintext. The confidentiality property itself is pending Tier 2. - -```text -[1. seal() fails closed] - seal(payload) raised: SealedChannelError OK - error code: SEALED_CHANNEL_ERROR OK - detail names Tier 2: YES OK - plaintext emitted: NO OK -... -KEY RESULT: SealedChannel fails closed. seal()/open() raise -SEALED_CHANNEL_ERROR instead of emitting plaintext. This is -the honest current behavior. The confidentiality claim itself -(payload decrypts only under the attested peer measurement) is -PENDING Tier 2 and is not demonstrated here. -``` - -Exit code 0. See the [Sealed Channel](../spec/sealed-channel.md) page. - -## 8. C6: cross-operator attestation (gated on Tier 3) - -```bash +python experiments/claim5-provenance-dag-integrity/run.py python experiments/claim6-cross-operator-attestation/run.py ``` -This SKIPs. The SEV-SNP and TDX collectors are Tier 3 and not implemented, so on the SEV-SNP path this experiment exercises no provider that can produce a quote. (`TpmProvider.attest` does now produce a real quote on a Linux host with a TPM, but this experiment is written against SEV-SNP.) The script probes for a provider, finds none, prints a software-only illustration of the `AttestationReport` shape clearly marked as carrying no assurance, then SKIPs. +Each script should exit 0 and print `KEY RESULT`. A traceback, nonzero exit, or `SKIP` is not a successful reproduction. The delegation experiments retain the locally generated root authority separately and pass it explicitly to verification. Do not infer production trust from an incoming chain's own root. -```text -KEY RESULT: SKIP: cross-operator attestation is gated on Tier 3 (real -hardware attestation backend). No provider can produce a verifiable quote -yet, so mutual attestation and binary-swap detection cannot be demonstrated. -The reports above are software-only and carry no assurance. See ROADMAP.md. -Exiting 0 so CI and dev hosts pass. -``` +## Read the results -Exit code 0. See [Attestation](../spec/attestation.md). +| Experiment | Expected result | Boundary | +|---|---|---| +| C1: attenuation soundness | 200 narrowing chains accepted; 200 correctly signed escalations rejected with `ScopeEscalation` | Exercises scope attenuation on generated chains | +| C2: cross-chain replay | Two control chains accepted; duplicate ID and splice rejected | No global single-use credential database | +| C3: scope-policy intersection | `read` allowed; `write`, `audit`, and `admin` denied | Uses `LocalPolicy`; separate tests cover Cedar | +| C4: sealed-payload confidentiality | Four encryption checks pass, including wrong-key and tamper rejection | Keys live in software memory; no enclave custody demonstrated | +| C5: provenance integrity | Unrepaired parent changes, reparenting, and credential-ID mismatch detected | Unsigned hash links do not authenticate the producer or prevent a fully recomputed rewrite | +| C6: cross-operator attestation | Four checks pass with synthetic reports and certificates | Locally generated test root; no genuine hardware quote or live cross-operator deployment | -## 9. Run them all +C5's changed-bit count varies because the keys are random. The exact count is not a security acceptance threshold. C6's measurement mismatch is simulated by changing a synthetic report, not by swapping a binary on real hardware. -```bash -pip install -e ".[dev]" -python experiments/claim1-attenuation-soundness/run.py -python experiments/claim2-cross-chain-replay/run.py -python experiments/claim3-scope-policy-intersection/run.py # SKIP (Tier 2) -python experiments/claim4-sealed-payload-confidentiality/run.py # fail-closed (Tier 2) -python experiments/claim5-provenance-dag-integrity/run.py -python experiments/claim6-cross-operator-attestation/run.py # SKIP (Tier 3) -``` - -Every script exits 0. The validated three print a `KEY RESULT` asserting their property; the gated three print a SKIP banner and exit 0 so they never break CI or a laptop with no TEE. +## CI coverage -## 10. The CI tests +`tests/unit/test_documented_experiments.py` runs these exact script entry points and checks their positive-control results. To reproduce that check: -Each claim also has a unit test under `tests/unit/test_claim*.py`, run by the `test` job in `.github/workflows/ci.yml`. The validated claims assert their property directly. For example, `test_claim1_attenuation.py` verifies a known narrowing chain and asserts a re-broadening child raises `ScopeEscalation` at the offending hop. - -The gated claims register a `pytest.mark.skip` with a reason that names the tier and points at the roadmap, so CI records them as skipped rather than failed until the dependency lands: - -```python -@pytest.mark.skip( - reason="Tier 2: runtime scope-policy intersection not implemented; see ROADMAP.md" -) -def test_effective_scope_is_delegation_intersect_local_policy() -> None: - ... +```bash +python -m pytest tests/unit/test_documented_experiments.py ``` -Run the suite: +For the actual Cedar engine and the runtime's authentication/appraisal ordering: ```bash -pytest tests/unit -v +python -m pytest tests/unit/test_cedar.py tests/unit/test_holder_binding.py tests/unit/test_mutual_attestation.py ``` -You will see the C1, C2, and C5 tests pass and the C3, C4, and C6 tests reported as skipped. That skip count is the honest ledger of what cA2A has not yet built. As Tier 2 and Tier 3 land on [ROADMAP.md](../../ROADMAP.md), those skips flip to real assertions. +Continue to [hardware validation](../hardware-validation.md) for recorded hardware evidence, or [integrating with A2A](integrating-with-a2a.md) for a local transport walkthrough. diff --git a/docs/tutorials/verify-a-delegation-chain.md b/docs/tutorials/verify-a-delegation-chain.md index 6dfec85..4923e4a 100644 --- a/docs/tutorials/verify-a-delegation-chain.md +++ b/docs/tutorials/verify-a-delegation-chain.md @@ -1,64 +1,62 @@ -# Tutorial: Verify a Delegation Chain +# Verify a Saved Delegation Chain -This tutorial verifies a chain, then deliberately breaks each invariant to see the verifier reject it. No hardware needed. +Save a locally generated chain, verify it using a separately retained root key, then detect an edited signature. This walkthrough builds on [authoring a delegation credential](authoring-a-delegation-credential.md): install that checkout and run its Python blocks first, in the same session. They define `chain`, `a_pub`, and `trusted_roots`, and already test a correctly signed scope escalation. -## 1. Generate a valid chain +## Save and verify -```bash -python scripts/gen_example_chain.py -ca2a verify-chain --chain examples/minimal/chain.json --trusted-root-issuer -# {"verified": true, "hops": 3, "leaf_scope": ["cap:read"]} -``` - -The chain grants `admin` at the root, narrows to `read+write`, then to `read`. - -## 2. Break attenuation +```python +import json +from pathlib import Path +from ca2a_verify import verify_chain_file -Open `examples/minimal/chain.json` and add `"cap:admin"` to the `scope` of the last hop (the leaf held only `cap:read`). Re-run: +Path("chain.json").write_text(json.dumps({ + "chain": [credential.body() | {"signature": credential.signature} for credential in chain] +}), encoding="utf-8") +Path("trusted-root.txt").write_text(a_pub, encoding="utf-8") -```bash -ca2a verify-chain --chain examples/minimal/chain.json --trusted-root-issuer -# {"verified": false, "code": "SCOPE_ESCALATION", ...} +result = verify_chain_file("chain.json", trusted_root_issuers=trusted_roots) +assert result.hops == 3 +assert result.leaf_scope == ["cap:read"] +print("verified 3 hops; leaf scope cap:read") ``` -The leaf claimed authority its parent did not hold. Regenerate to restore. +The trusted root was retained when you created the local authority. For an incoming production chain, obtain the approved root through your own trust configuration. Reading its issuer field and trusting it would let the chain authorize itself. -## 3. Break the link +## Change a signed field -Regenerate, then change the `parent_id` of the middle hop to `"nope"`: +```python +from ca2a_runtime.errors import InvalidCredential -```bash -ca2a verify-chain --chain examples/minimal/chain.json --trusted-root-issuer -# {"verified": false, "code": "BROKEN_DELEGATION_LINK", ...} +document = json.loads(Path("chain.json").read_text(encoding="utf-8")) +document["chain"][-1]["scope"].append("cap:admin") +Path("tampered-chain.json").write_text(json.dumps(document), encoding="utf-8") + +try: + verify_chain_file("tampered-chain.json", trusted_root_issuers=trusted_roots) +except InvalidCredential: + print("edited signature rejected: INVALID_CREDENTIAL") +else: + raise AssertionError("edited signed field accepted") ``` -## 4. Tamper with a signed field +This checks signature integrity. To exercise attenuation, the overbroad grant must have a valid signature; the authoring walkthrough does that. Editing a signed `parent_id` also fails signature verification before it can demonstrate a continuity error. + +## Use the CLI -Regenerate, then change any signed field (for example a `scope` entry) without re-signing: +In Bash: ```bash -ca2a verify-chain --chain examples/minimal/chain.json --trusted-root-issuer -# {"verified": false, "code": "INVALID_CREDENTIAL", ...} +ca2a verify-chain --chain chain.json --trusted-root-issuer "$(cat trusted-root.txt)" ``` -The signature no longer matches the canonical body. +In PowerShell: -## 5. Verify in code - -```python -from ca2a_verify import verify_chain_file -from ca2a_runtime.errors import CA2AError - -try: - result = verify_chain_file( - "examples/minimal/chain.json", - trusted_root_issuers={""}, - ) - print(f"verified {result.hops} hops, leaf scope {result.leaf_scope}") -except CA2AError as exc: - print(f"rejected: {exc.code}: {exc}") +```powershell +ca2a verify-chain --chain chain.json --trusted-root-issuer (Get-Content trusted-root.txt -Raw) ``` -## What you proved +Expect exit 0 and `{"verified": true, "hops": 3, "leaf_scope": ["cap:read"]}`. Substituting `tampered-chain.json` should exit nonzero with `INVALID_CREDENTIAL`. + +## What this establishes -You verified a chain of delegated authority, offline, without trusting whoever produced it. That is the property cA2A carries into the runtime peer path once attestation and sealing land. +You checked signed grants against a trusted issuer and rejected an edited credential. This does not execute a task, establish caller possession of the leaf key, or appraise hardware. Continue to [inbound peer-call decision](../spec/call-graph.md) for those runtime checks. diff --git a/experiments/claim1-attenuation-soundness/run.py b/experiments/claim1-attenuation-soundness/run.py index 90245ca..9ddb649 100644 --- a/experiments/claim1-attenuation-soundness/run.py +++ b/experiments/claim1-attenuation-soundness/run.py @@ -36,7 +36,7 @@ def build_signed_chain( scopes: list[frozenset[str]], -) -> list[DelegationCredential]: +) -> tuple[list[DelegationCredential], str]: """Build a correctly signed root-to-leaf chain granting scopes[i] at hop i. Continuity holds: each hop's issuer is the previous hop's subject, depth @@ -44,6 +44,7 @@ def build_signed_chain( """ chain: list[DelegationCredential] = [] priv, pub = new_keypair() + trusted_root = pub # Retain our generated authority before constructing input. parent_id: str | None = None for depth, scope in enumerate(scopes): next_priv, next_pub = new_keypair() @@ -58,7 +59,7 @@ def build_signed_chain( chain.append(cred) parent_id = cred.credential_id priv, pub = next_priv, next_pub - return chain + return chain, trusted_root def narrowing_scopes(trial: int) -> list[frozenset[str]]: @@ -101,9 +102,9 @@ def main() -> int: section("[1] Narrowing chains accepted") accepted = 0 for trial in range(TRIALS): - chain = build_signed_chain(narrowing_scopes(trial)) + chain, trusted_root = build_signed_chain(narrowing_scopes(trial)) try: - verify_chain(chain) + verify_chain(chain, trusted_root_issuers={trusted_root}) accepted += 1 except Exception as exc: # noqa: BLE001 print(f" trial {trial} UNEXPECTED rejection: {type(exc).__name__}: {exc}") @@ -118,9 +119,9 @@ def main() -> int: other = 0 example = "" for trial in range(TRIALS): - chain = build_signed_chain(escalating_scopes(trial)) + chain, trusted_root = build_signed_chain(escalating_scopes(trial)) try: - verify_chain(chain) + verify_chain(chain, trusted_root_issuers={trusted_root}) print(f" trial {trial} ESCALATION NOT CAUGHT: chain verified") except ScopeEscalation as exc: rejected += 1 diff --git a/experiments/claim2-cross-chain-replay/run.py b/experiments/claim2-cross-chain-replay/run.py index 1543c41..c91da5b 100644 --- a/experiments/claim2-cross-chain-replay/run.py +++ b/experiments/claim2-cross-chain-replay/run.py @@ -36,7 +36,7 @@ def build_chain( scopes: list[frozenset[str]], *, prefix: str -) -> list[DelegationCredential]: +) -> tuple[list[DelegationCredential], str]: """Build a correctly signed root-to-leaf chain with unique credential ids. Each hop's issuer is the previous hop's subject, depth increments from 0, @@ -44,6 +44,7 @@ def build_chain( """ chain: list[DelegationCredential] = [] priv, pub = new_keypair() + trusted_root = pub # Local fixture authority, not learned from incoming data. parent_id: str | None = None for depth, scope in enumerate(scopes): next_priv, next_pub = new_keypair() @@ -58,7 +59,7 @@ def build_chain( chain.append(cred) parent_id = cred.credential_id priv, pub = next_priv, next_pub - return chain + return chain, trusted_root def section(title: str) -> None: @@ -84,7 +85,7 @@ def main() -> int: rejected = 0 # Two independent chains, each with its own key material and id namespace. - chain_a = build_chain( + chain_a, root_a = build_chain( [ frozenset({"cap:a", "cap:b", "cap:c"}), frozenset({"cap:a", "cap:b"}), @@ -92,7 +93,7 @@ def main() -> int: ], prefix="a", ) - chain_b = build_chain( + chain_b, root_b = build_chain( [ frozenset({"cap:x", "cap:y", "cap:z"}), frozenset({"cap:x", "cap:y"}), @@ -105,9 +106,9 @@ def main() -> int: # Property 1: control, both chains verify # ------------------------------------------------------------------ section("1. Control: two independent valid chains verify") - for name, chain in (("A", chain_a), ("B", chain_b)): + for name, chain, root in (("A", chain_a, root_a), ("B", chain_b, root_b)): try: - verify_chain(chain) + verify_chain(chain, trusted_root_issuers={root}) result(f"chain {name} ({len(chain)} hops)", "VALID", True) except CA2AError as exc: result(f"chain {name} ({len(chain)} hops)", f"rejected: {exc}", False) @@ -122,7 +123,7 @@ def main() -> int: duped = chain_a[1].credential_id result("duplicated credential_id", duped) try: - verify_chain(replay_chain) + verify_chain(replay_chain, trusted_root_issuers={root_a}) result("verify_chain raised", "nothing: replay NOT caught", False) failures += 1 except CredentialReplay as exc: @@ -145,7 +146,7 @@ def main() -> int: splice_chain = [chain_b[0], spliced, chain_b[2]] result("spliced credential_id", f"{spliced.credential_id} (from chain A)") try: - verify_chain(splice_chain) + verify_chain(splice_chain, trusted_root_issuers={root_b}) result("verify_chain raised", "nothing: splice NOT caught", False) failures += 1 except (BrokenDelegationLink, CredentialReplay) as exc: diff --git a/experiments/claim3-scope-policy-intersection/run.py b/experiments/claim3-scope-policy-intersection/run.py index a1cc7fd..6e074c2 100644 --- a/experiments/claim3-scope-policy-intersection/run.py +++ b/experiments/claim3-scope-policy-intersection/run.py @@ -3,10 +3,8 @@ callee's local policy. A peer can exercise a capability only if BOTH its delegation chain granted it AND the callee's local policy allows it. -Validated experiment (no hardware). Uses the enforcement decision core in -ca2a_runtime.peer. Binding a full Cedar policy engine as the local policy is -tracked separately (issue #10); the intersection semantics are what this claim -establishes. +Software experiment using the enforcement decision core in ca2a_runtime.peer. +This uses LocalPolicy; tests/unit/test_cedar.py exercises the Cedar engine. """ # ruff: noqa: T201 from __future__ import annotations @@ -22,9 +20,10 @@ from ca2a_runtime.policy import LocalPolicy # noqa: E402 -def build_chain(scopes: list[frozenset[str]]) -> list[DelegationCredential]: +def build_chain(scopes: list[frozenset[str]]) -> tuple[list[DelegationCredential], str]: chain: list[DelegationCredential] = [] priv, pub = new_keypair() + trusted_root = pub # Retain the local fixture authority separately. parent_id: str | None = None for depth, scope in enumerate(scopes): next_priv, next_pub = new_keypair() @@ -35,17 +34,17 @@ def build_chain(scopes: list[frozenset[str]]) -> list[DelegationCredential]: chain.append(cred) parent_id = cred.credential_id priv, pub = next_priv, next_pub - return chain + return chain, trusted_root def main() -> int: # Delegated down to a leaf scope of {read, write}; the root held admin too. - chain = build_chain([frozenset({"read", "write", "admin"}), frozenset({"read", "write"})]) + chain, trusted_root = build_chain([frozenset({"read", "write", "admin"}), frozenset({"read", "write"})]) # The callee's local policy allows {read, audit}. Note write is delegated but # not locally allowed, and audit is locally allowed but never delegated. policy = LocalPolicy.of(["read", "audit"]) - eff = effective_scope(chain, policy) + eff = effective_scope(chain, policy, trusted_root_issuers={trusted_root}) print("Claim 3: effective permission = delegated scope INTERSECT local policy") print(f" leaf delegated scope: {sorted(chain[-1].scope)}") print(f" local policy allows: {sorted(policy.allow)}") @@ -60,7 +59,8 @@ def main() -> int: allowed = denied = 0 for cap, expect_allowed in cases.items(): try: - enforce_peer_call(chain, cap, policy=policy, record_id="rec-0") + enforce_peer_call(chain, cap, policy=policy, record_id="rec-0", + trusted_root_issuers={trusted_root}) got = True except ScopeNotPermitted: got = False diff --git a/experiments/claim4-sealed-payload-confidentiality/run.py b/experiments/claim4-sealed-payload-confidentiality/run.py index 7ab0159..0facef5 100644 --- a/experiments/claim4-sealed-payload-confidentiality/run.py +++ b/experiments/claim4-sealed-payload-confidentiality/run.py @@ -69,8 +69,8 @@ def main() -> int: print(f" [4] tampered payload fails closed: {'OK' if tamper_caught else 'FAIL'}") if passed == checks: - print(f"KEY RESULT: {passed}/{checks} sealed to the attested key; only the " - "enclave-bound private key opens it; path sees ciphertext; tamper fails closed") + print(f"KEY RESULT: {passed}/{checks} encryption checks passed; matching key opens; " + "wrong key and tamper rejected (software keys; no hardware assurance)") return 0 print(f"KEY RESULT: FAIL ({passed}/{checks} passed)") return 1 diff --git a/index.md b/index.md index f83ccf4..b2a6d10 100644 --- a/index.md +++ b/index.md @@ -1,44 +1,31 @@ --- -title: Secure, confidential agent-to-agent delegation -description: cA2A is a trust profile on top of the Agent2Agent protocol. It adds attested, attenuated delegation, a sealed peer channel, and an offline-verifiable provenance record, without replacing the transport. +title: Verify who delegated what to each agent +description: cA2A adds delegation and peer trust checks to A2A. Start with an offline chain that accepts a narrowed grant and rejects scope escalation. --- -# cA2A +# Verify who delegated what to each agent -cA2A (Confidential A2A) is the secure, confidential way to do agent-to-agent delegation on the [Agent2Agent (A2A)](https://a2a-protocol.org/) protocol. It layers attested, attenuated delegation, a sealed peer channel, and an offline-verifiable provenance record on top of A2A, without replacing the transport. +cA2A adds signed delegation credentials, peer appraisal, sealed channels, and linked provenance to agent-to-agent communication. A verifier checks who issued a grant and whether each child stays within its parent's authority. -**Agent A delegates to B. B delegates part of it to C. Who authorized what, did B stay inside the authority A actually held, and can you prove it for every hop?** +[Verify your first delegation chain](docs/quickstart.md){ .md-button .md-button--primary } +[See the runtime boundaries](docs/concepts.md){ .md-button } -!!! tip "TL;DR" - - A2A's Signed Agent Card answers one question: did the domain owner issue this card. It does not cover integrity, authority, confidentiality, or provenance. - - cA2A is a profile on top of A2A, not a competing transport, the way TRACE profiles RATS and EAT rather than reinventing them. - - Install with `pip install ca2a-runtime`. The same package does offline chain verification and runs the live peer runtime. - - Developer Preview. One bound worth reading before you rely on it: peer appraisal has been demonstrated one-directional, so mutual simultaneous attestation is still outstanding. See [Limitations](LIMITATIONS.md). - -```bash -pip install ca2a-runtime -ca2a verify-chain --chain ./examples/minimal/chain.json -``` +The first example runs locally with Python 3.11+. It verifies a narrowed grant and rejects both an untrusted issuer and signed scope escalation. No hardware or running peer is needed. ## The gap it closes -A2A won the agent-to-agent transport war. Its trust model stops at the front door. - -The Signed Agent Card proves the domain owner issued the card. It does not answer: - -- **Integrity.** Is the peer running attested, unmodified, governed code, or a tampered agent wearing a valid card. -- **Authority.** When A delegates to B, does A actually hold the authority it is passing, and is B's grant a provable subset of it. -- **Confidentiality.** The task payload A sends B crosses a network and lands in B's memory. If B is in another trust domain, nothing seals that payload to B's attested measurement. -- **Provenance.** Across A to B to C, there is no unbroken, offline-verifiable chain of who delegated what to whom under which policy. - -A2A leaves the runtime credential layer to implementers. The common answers, mTLS and OAuth scopes, secure the pipe and assert an identity. They do not attenuate authority, attest runtime integrity, or seal payloads to a measurement. +An agent identity does not by itself establish delegation authority. When A delegates to B and B delegates to C, the relying party needs to authenticate the root and check each grant. Live calls also need caller authentication, local policy, and any required runtime appraisal. ## The four primitives -1. **Attenuated delegation.** Each hop carries a signed delegation credential whose scope is a provable subset of its parent. Child scope cannot exceed parent, depth is bounded, and replay across chains is rejected. -2. **Runtime attestation.** A peer proves it is running attested, measured code before it is trusted with a delegated task. -3. **Sealed peer channel.** The task payload is sealed to the peer's attested measurement, so it decrypts only inside the verified enclave. -4. **Provenance record.** Each hop emits a TRACE record referencing the parent record hash and delegation credential id, producing an offline-verifiable delegation DAG. +| Mechanism | What to check | Start here | +|---|---|---| +| Delegation credentials | Trusted root, signatures, continuity, bounded scope and depth | [Offline quickstart](docs/quickstart.md) | +| Runtime appraisal | Evidence and measurements required by the relying party | [Attestation](docs/spec/attestation.md) | +| Sealed peer channel | Payload encryption to the appraised peer key | [Sealed channel](docs/spec/sealed-channel.md) | +| Linked provenance | Record signatures and parent links | [TRACE A2A profile](docs/spec/trace-a2a-profile.md) | + +Software and hardware modes provide different assurance. Peer appraisal has been demonstrated one-directionally; mutual simultaneous hardware attestation remains outstanding. Read [Limitations](LIMITATIONS.md) before relying on a hardware claim. ## Where to start diff --git a/mkdocs.yml b/mkdocs.yml index 2a08dd9..6e01fdb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ site_name: cA2A -site_description: The secure, confidential profile for agent-to-agent (A2A) delegation. Attested, attenuated delegation with a sealed peer channel and offline-verifiable provenance, on top of the Agent2Agent (A2A) protocol. +site_description: Verify signed delegation chains and peer trust evidence for A2A. Start with a local grant and scope-escalation check. site_url: https://ca2a.agentrust-io.com repo_url: https://github.com/agentrust-io/ca2a repo_name: agentrust-io/ca2a @@ -62,16 +62,10 @@ plugins: - llmstxt: full_output: llms-full.txt markdown_description: >- - cA2A (Confidential A2A) is the secure, confidential way to do - agent-to-agent delegation on the Agent2Agent (A2A) protocol. It is a - trust profile, not a competing transport: it adds attested, attenuated - delegation (each hop's authority is a provable subset of its parent's), - runtime attestation of the peer, a sealed peer channel that binds the - task payload to the peer's attested measurement, and an - offline-verifiable provenance record per hop. If you are looking for a - secure version of A2A for multi-agent systems, this is the AgenTrust - profile for it. Reuses the delegation semantics from agent-manifest and - the TEE and policy primitives from cMCP. Developer preview. + cA2A adds signed delegation, peer appraisal, sealed channels, and linked + provenance to A2A communication. Begin with offline grant verification + against a separately trusted root. Live runtime checks and hardware + assurance require additional evidence and explicit relying-party policy. sections: Getting started: - index.md diff --git a/tests/unit/test_docs_quickstart.py b/tests/unit/test_docs_quickstart.py new file mode 100644 index 0000000..c883b85 --- /dev/null +++ b/tests/unit/test_docs_quickstart.py @@ -0,0 +1,38 @@ +"""Run the documented grant and rejection examples, then verify its saved chain.""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +from ca2a_runtime.cli import main + + +def test_first_chain(tmp_path, capsys): + page = Path(__file__).resolve().parents[2] / "docs/quickstart.md" + blocks = re.findall(r"^```python\n(.*?)^```", page.read_text(encoding="utf-8"), re.M | re.S) + assert len(blocks) == 1 + run = subprocess.run( + [sys.executable, "-c", blocks[0]], cwd=tmp_path, capture_output=True, text=True + ) + assert run.returncode == 0, run.stdout + run.stderr + assert run.stdout.count("PASS:") == 3 + root = (tmp_path / "trusted-root.txt").read_text() + assert ( + main( + [ + "verify-chain", + "--chain", + str(tmp_path / "demo-chain.json"), + "--trusted-root-issuer", + root, + ] + ) + == 0 + ) + assert json.loads(capsys.readouterr().out) == { + "verified": True, + "hops": 2, + "leaf_scope": ["cap:read"], + } diff --git a/tests/unit/test_documented_experiments.py b/tests/unit/test_documented_experiments.py new file mode 100644 index 0000000..4337fd4 --- /dev/null +++ b/tests/unit/test_documented_experiments.py @@ -0,0 +1,59 @@ +"""Run the public tutorial entry points, including their positive controls.""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] + + +@pytest.mark.parametrize( + ("directory", "expected"), + [ + ("claim1-attenuation-soundness", "200/200 narrowing chains accepted; 200/200"), + ("claim2-cross-chain-replay", "2/2 control chains valid"), + ("claim3-scope-policy-intersection", "1/1 allowed, 3/3 denied"), + ("claim4-sealed-payload-confidentiality", "4/4 encryption checks passed"), + ("claim5-provenance-dag-integrity", "reparent detected"), + ("claim6-cross-operator-attestation", "4/4 two operators"), + ], +) +def test_documented_experiment(directory: str, expected: str) -> None: + result = subprocess.run( + [sys.executable, str(ROOT / "experiments" / directory / "run.py")], + cwd=ROOT, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert expected in result.stdout, result.stdout + assert "SKIP" not in result.stdout + + +def test_sealed_channel_documented_example() -> None: + page = (ROOT / "docs/spec/sealed-channel.md").read_text(encoding="utf-8-sig") + blocks = re.findall(r"```python\n(.*?)```", page, flags=re.DOTALL) + assert len(blocks) == 1 + exec(compile(blocks[0], "docs/spec/sealed-channel.md", "exec"), {}) + + +def test_delegation_tutorial_sequence(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + namespace: dict[str, object] = {} + for name in ( + "authoring-a-delegation-credential", + "verify-a-delegation-chain", + "emit-and-verify-provenance", + ): + path = ROOT / "docs/tutorials" / f"{name}.md" + blocks = re.findall(r"```python\n(.*?)```", path.read_text(encoding="utf-8-sig"), re.DOTALL) + assert blocks, path + for block in blocks: + exec(compile(block, str(path), "exec"), namespace)