diff --git a/CHANGELOG.md b/CHANGELOG.md index 77b2330..493f182 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,13 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `experiments/`: reproducible claim suite C1-C6. C1 (attenuation), C2 (cross-chain replay), and C5 (provenance DAG) are fully reproducible; C3, C4, C6 SKIP until their Tier 2/3 dependency lands. Each claim has a CI test. - SEV-SNP attestation backend (Tier 3): `ca2a_runtime.tee.sev_snp` (report parsing, `SevSnpProvider`) and `ca2a_verify.sev_snp` (VCEK chain verification, ECDSA-P384 report-signature verification, measurement/report-data binding), all fail-closed. Chain path validated against the real AMD Milan root; report-signature path validated with synthetic vectors. Report generation requires a real SEV-SNP guest. - Peer-call enforcement decision core (Tier 2): `ca2a_runtime.policy.LocalPolicy` and `ca2a_runtime.peer` (`effective_scope`, `enforce_peer_call`). Effective permission is the delegated leaf scope intersected with the callee's local policy; a granted call emits a linked provenance record. New error `SCOPE_NOT_PERMITTED`. Claim C3 (scope-policy intersection) is now a validated experiment. Cedar-engine binding of the local policy and live A2A transport wiring remain open. +- Sealed peer channel (Tier 2): `ca2a_runtime.channel` (`SealedChannel`, `generate_channel_keypair`, `open_sealed`). HPKE-style X25519 -> HKDF-SHA256 -> ChaCha20-Poly1305 sealing a payload to the peer's attested key; only the peer's private key opens it, and a wrong key or tampered ciphertext fails closed. Claim C4 (sealed-payload confidentiality) is now a validated experiment at the cryptographic layer. The enclave-binding of the private key (a hardware property) and live-path wiring remain open. - Repository scaffold: governance, CI/CD, docs framework, and packaging at parity with the agentrust-io house standard ### Not yet implemented -- Live A2A transport wiring for the peer-enforcement decision core (Tier 2) +- Live A2A transport wiring for the peer-enforcement decision core, including binding the seal to a verified attestation report (Tier 2) - Cedar policy engine binding for the local policy (Tier 2) -- Sealed peer channel (Tier 2) - Intel TDX and TPM attestation backends (Tier 3); end-to-end SEV-SNP validation against real hardware vectors [Unreleased]: https://github.com/agentrust-io/ca2a/commits/main diff --git a/LIMITATIONS.md b/LIMITATIONS.md index f70aa1f..be98b82 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -1,25 +1,25 @@ -# Limitations - -cA2A is a pre-release profile in active design. This document states plainly what is built, what is stubbed, and what is out of scope, so no claim in the documentation runs ahead of the code. This is a deliberate discipline: proof, not promises. - -## 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. -- Configuration, error registry, and the CLI surface. - -## What is stubbed or not yet implemented - -- **Runtime peer-delegation enforcement.** The runtime does not yet accept a delegation credential on a live inbound peer call, verify it in the request path, and intersect the delegated scope with a local Cedar policy. This is Tier 2 on the roadmap. -- **Sealed peer channel.** Payloads are not yet sealed to a peer's attested measurement. Until this lands, do not send confidential task payloads across a trust boundary and assume they are protected. -- **Real hardware attestation.** The **SEV-SNP verifier is implemented**: report parsing, VCEK certificate chain verification, ECDSA-P384 report-signature verification, and measurement/report-data binding, all fail-closed. The chain path is validated against the genuine AMD Milan root chain; the report-signature path is validated with synthetic vectors, since a real report plus VCEK pair needs SEV-SNP hardware. Report generation (`SevSnpProvider.attest`) still requires a real SEV-SNP guest. **Intel TDX and TPM backends are not yet implemented (Tier 3).** Until a backend verifies a real quote end to end against a golden measurement on hardware, cA2A must not be described as fully attested across trust domains. - -## Out of scope - -- The A2A transport itself. cA2A is a profile on A2A, not a replacement for it. -- Agent identity issuance beyond delegation. -- AI model governance beyond delegation and provenance. -- Hardware TEE platform SDKs and firmware. - -## Dependencies on sibling projects - -cA2A composes primitives from [agent-manifest](https://github.com/agentrust-io/agent-manifest), [cmcp](https://github.com/agentrust-io/cmcp), and [trace-spec](https://github.com/agentrust-io/trace-spec). Version skew across those repos can change cA2A behavior; pin compatible versions before relying on cross-repo guarantees. +# Limitations + +cA2A is a pre-release profile in active design. This document states plainly what is built, what is stubbed, and what is out of scope, so no claim in the documentation runs ahead of the code. This is a deliberate discipline: proof, not promises. + +## 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. +- Configuration, error registry, and the CLI surface. + +## What is stubbed or not yet implemented + +- **Runtime peer-delegation enforcement.** The runtime does not yet accept a delegation credential on a live inbound peer call, verify it in the request path, and intersect the delegated scope with a local Cedar policy. This is Tier 2 on the roadmap. +- **Sealed peer channel.** The channel is implemented: a payload is sealed to the peer's attested X25519 key (X25519 ECDH, HKDF-SHA256, ChaCha20-Poly1305), and only the holder of the peer's private key can open it. The remaining gap is the hardware property that the private key never leaves the peer's enclave (established by attestation), and wiring the seal to a verified report on a live inbound call. Until that end-to-end binding lands on hardware, do not assume a payload is confined to a specific attested measurement. +- **Real hardware attestation.** The **SEV-SNP verifier is implemented**: report parsing, VCEK certificate chain verification, ECDSA-P384 report-signature verification, and measurement/report-data binding, all fail-closed. The chain path is validated against the genuine AMD Milan root chain; the report-signature path is validated with synthetic vectors, since a real report plus VCEK pair needs SEV-SNP hardware. Report generation (`SevSnpProvider.attest`) still requires a real SEV-SNP guest. **Intel TDX and TPM backends are not yet implemented (Tier 3).** Until a backend verifies a real quote end to end against a golden measurement on hardware, cA2A must not be described as fully attested across trust domains. + +## Out of scope + +- The A2A transport itself. cA2A is a profile on A2A, not a replacement for it. +- Agent identity issuance beyond delegation. +- AI model governance beyond delegation and provenance. +- Hardware TEE platform SDKs and firmware. + +## Dependencies on sibling projects + +cA2A composes primitives from [agent-manifest](https://github.com/agentrust-io/agent-manifest), [cmcp](https://github.com/agentrust-io/cmcp), and [trace-spec](https://github.com/agentrust-io/trace-spec). Version skew across those repos can change cA2A behavior; pin compatible versions before relying on cross-repo guarantees. diff --git a/ROADMAP.md b/ROADMAP.md index 7643c8e..068c142 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,7 +23,7 @@ Already implemented and tested elsewhere; cA2A depends on it rather than reimple ## v0.2: Runtime enforcement and sealed channel - Runtime peer-delegation enforcement: **decision core landed** (`ca2a_runtime.peer.enforce_peer_call`: verify chain, intersect delegated scope with local policy, enforce, emit provenance record; claim C3 validated). Remaining: bind a Cedar policy engine as the local policy, and wire the decision core to a live A2A transport (Tier 2) -- Sealed peer channel: extend the attestation-gated key pattern to peer-to-peer, so the task payload decrypts only inside the peer's verified enclave (Tier 2) +- Sealed peer channel: **landed** (`ca2a_runtime.channel`: HPKE-style X25519 -> HKDF-SHA256 -> ChaCha20-Poly1305 sealing to the peer's attested key; claim C4 validated). Remaining: bind the seal to a verified attestation report on a live call, and rely on the enclave to hold the private key (hardware property) - Linked runtime evidence: each hop's TRACE record references the parent record hash and delegation credential id, producing a verifiable delegation DAG (Tier 2) ## Critical path, sequenced first (Tier 3) diff --git a/docs/spec/call-graph.md b/docs/spec/call-graph.md index 794c065..f60a0e0 100644 --- a/docs/spec/call-graph.md +++ b/docs/spec/call-graph.md @@ -19,8 +19,8 @@ inbound A2A task 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.seal(...) [PENDING, Tier 2] - | payload readable only inside the peer's enclave +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] @@ -37,7 +37,7 @@ If any step raises, the call is denied. Absence of evidence is denial, not a war | 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 measurement | `SealedChannel.seal` | Pending, Tier 2 (fails closed) | +| 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) | | Live A2A transport wiring | The decision core runs off an actual inbound A2A request | (design) | Pending, Tier 2 | @@ -75,9 +75,9 @@ enforce_peer_call(chain, "read", policy=policy, record_id="rec-c") # raises SCO 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; binding a full Cedar policy engine (as cMCP does) is tracked separately (#10). What is not yet wired is the live inbound transport: `enforce_peer_call` is the decision the runtime makes, not yet driven off an actual A2A request. See [cedar-policy.md](cedar-policy.md). -## Step 4: seal the payload to the peer measurement (pending, Tier 2) +## Step 4: seal the payload to the peer's attested key (crypto implemented) -Once the peer measurement is verified (step 2), the task payload is intended to be sealed to that measurement so it decrypts only inside the peer's verified enclave. Today `SealedChannel.seal` and `SealedChannel.open` raise `SEALED_CHANNEL_ERROR` rather than send plaintext. Do not send confidential payloads across a trust boundary and assume they are protected. See [sealed-channel.md](sealed-channel.md). +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. The property that the payload decrypts *only inside the attested measurement* rests on the private key being enclave-bound (a hardware property from attestation), and binding the seal to a verified report on the live path is still to be wired. See [sealed-channel.md](sealed-channel.md). ## Step 5: emit a linked provenance record (implemented) diff --git a/docs/spec/error-codes.md b/docs/spec/error-codes.md index 2575406..89006c4 100644 --- a/docs/spec/error-codes.md +++ b/docs/spec/error-codes.md @@ -1,56 +1,55 @@ -# Error Codes - -Every failure the cA2A runtime and verifier raise is a subclass of `CA2AError`. Each subclass carries a stable `code` string and an `http_status`. The code is what you match on in tests and callers. The HTTP status is what a service should return when the error crosses an A2A boundary. Both are defined in `ca2a_runtime/errors.py` and are the authoritative values below. - -An error also carries a human-readable message and an optional `detail`. The message and detail are not stable and are for diagnostics only. Match on `code`, never on message text. - -## Registry - -| Class | `code` | HTTP | When raised | -|---|---|---|---| -| `CA2AError` | `CA2A_ERROR` | 500 | Base class for all cA2A runtime and verifier errors. Not raised directly; caught to handle any cA2A failure generically. | -| `ConfigError` | `CONFIG_ERROR` | 500 | `Ca2aConfig` construction or `verify_chain_file` config load failed: unknown field, `max_delegation_depth` not a positive integer, missing config file, invalid YAML, or a non-mapping config root. | -| `InvalidCredential` | `INVALID_CREDENTIAL` | 400 | A `DelegationCredential` is malformed or its Ed25519 signature does not verify: unsigned credential, bad signature, malformed fields, or a chain document that is not a list or `{"chain": [...]}`, a missing chain file, or invalid JSON. | -| `ScopeEscalation` | `SCOPE_ESCALATION` | 403 | A child grant claims authority its parent did not hold. Raised by `verify_chain` when a hop's scope is not a subset of its parent's scope. | -| `BrokenDelegationLink` | `BROKEN_DELEGATION_LINK` | 409 | A hop does not chain to its stated parent, or continuity is broken: empty chain, a root credential that names a parent or has nonzero depth, a hop whose parent link or subject does not match the previous hop, or a hop depth that is not previous + 1. | -| `DelegationDepthExceeded` | `DELEGATION_DEPTH_EXCEEDED` | 403 | A chain is longer than the configured `max_delegation_depth`. Raised by `verify_chain`. | -| `CredentialReplay` | `CREDENTIAL_REPLAY` | 409 | A `credential_id` appears more than once in a single chain. Raised by `verify_chain`. | -| `AttestationUnsupported` | `ATTESTATION_UNSUPPORTED` | 500 | An attestation provider was requested that the host cannot supply. Reserved for the Tier 3 hardware backends; see [Peer Attestation](attestation.md). | -| `AttestationFailed` | `ATTESTATION_FAILED` | 412 | Attestation evidence was present but did not verify against the expected measurement. Reserved for the Tier 3 hardware backends; see [Peer Attestation](attestation.md). | -| `SealedChannelError` | `SEALED_CHANNEL_ERROR` | 500 | The measurement-bound peer channel could not seal or open a payload. Today the sealed channel is a Tier 2 placeholder that fails closed: `seal` and `open` always raise this. See [Sealed Channel](sealed-channel.md). | -| `ProvenanceLinkBroken` | `PROVENANCE_LINK_BROKEN` | 409 | A `DelegationRecord` does not chain to its stated parent record, or a record was tampered with so its hash no longer matches a child's link: empty provenance chain, duplicate `record_id`, a root record that references a parent, a broken parent hash link, or a record whose `credential_id` or subject does not match the chain. Raised by `verify_dag` and `cross_check_chain`. | - -## Which errors are live today - -`ConfigError`, `InvalidCredential`, `ScopeEscalation`, `BrokenDelegationLink`, `DelegationDepthExceeded`, `CredentialReplay`, and `ProvenanceLinkBroken` are raised by shipping code paths: attenuated delegation, offline chain verification, and the provenance DAG. - -`SealedChannelError` exists and is raised, but only because the sealed channel is a fail-closed placeholder. Any attempt to `seal` or `open` raises it. This is deliberate: cA2A must not carry confidential payloads across a trust boundary until Tier 2 lands. See [LIMITATIONS.md](../../LIMITATIONS.md). - -`AttestationUnsupported` and `AttestationFailed` are defined for the attestation path but are not exercised by a real hardware backend yet. The `software-only` provider is for development and CI and does not verify a hardware quote. Real verification is Tier 3. See [Peer Attestation](attestation.md) and [ROADMAP.md](../../ROADMAP.md). - -## Handling errors - -Catch the base class to handle any cA2A failure, or a specific subclass to react to one condition. The `code` attribute gives you the stable identifier and `http_status` the status to surface. - -```python -from ca2a_runtime.errors import CA2AError, ScopeEscalation -from ca2a_verify.verify import verify_chain_file - -try: - verify_chain_file("chain.json") -except ScopeEscalation as exc: - # A hop claimed more than its parent granted. - print(exc.code, exc.http_status) # SCOPE_ESCALATION 403 -except CA2AError as exc: - # Any other cA2A failure. - print(exc.code, exc.http_status, exc.detail) -``` - -Verification fails closed. `verify_chain`, `verify_dag`, and `cross_check_chain` raise the first error they find rather than returning a partial result, so a caught `CA2AError` means the chain or DAG was rejected. - -## See also - -- [Delegation Chain](delegation-chain.md) for the checks behind `ScopeEscalation`, `BrokenDelegationLink`, `DelegationDepthExceeded`, and `CredentialReplay`. -- [Provenance DAG](provenance-dag.md) for the checks behind `ProvenanceLinkBroken`. -- [Verification Library](verification-library.md) for `verify_chain`, `verify_chain_file`, `verify_dag`, and `cross_check_chain`. -- [Failure Modes](failure-modes.md) for how these errors map to observable runtime behavior. +# Error Codes + +Every failure the cA2A runtime and verifier raise is a subclass of `CA2AError`. Each subclass carries a stable `code` string and an `http_status`. The code is what you match on in tests and callers. The HTTP status is what a service should return when the error crosses an A2A boundary. Both are defined in `ca2a_runtime/errors.py` and are the authoritative values below. + +An error also carries a human-readable message and an optional `detail`. The message and detail are not stable and are for diagnostics only. Match on `code`, never on message text. + +## Registry + +| Class | `code` | HTTP | When raised | +|---|---|---|---| +| `CA2AError` | `CA2A_ERROR` | 500 | Base class for all cA2A runtime and verifier errors. Not raised directly; caught to handle any cA2A failure generically. | +| `ConfigError` | `CONFIG_ERROR` | 500 | `Ca2aConfig` construction or `verify_chain_file` config load failed: unknown field, `max_delegation_depth` not a positive integer, missing config file, invalid YAML, or a non-mapping config root. | +| `InvalidCredential` | `INVALID_CREDENTIAL` | 400 | A `DelegationCredential` is malformed or its Ed25519 signature does not verify: unsigned credential, bad signature, malformed fields, or a chain document that is not a list or `{"chain": [...]}`, a missing chain file, or invalid JSON. | +| `ScopeEscalation` | `SCOPE_ESCALATION` | 403 | A child grant claims authority its parent did not hold. Raised by `verify_chain` when a hop's scope is not a subset of its parent's scope. | +| `BrokenDelegationLink` | `BROKEN_DELEGATION_LINK` | 409 | A hop does not chain to its stated parent, or continuity is broken: empty chain, a root credential that names a parent or has nonzero depth, a hop whose parent link or subject does not match the previous hop, or a hop depth that is not previous + 1. | +| `DelegationDepthExceeded` | `DELEGATION_DEPTH_EXCEEDED` | 403 | A chain is longer than the configured `max_delegation_depth`. Raised by `verify_chain`. | +| `CredentialReplay` | `CREDENTIAL_REPLAY` | 409 | A `credential_id` appears more than once in a single chain. Raised by `verify_chain`. | +| `AttestationUnsupported` | `ATTESTATION_UNSUPPORTED` | 500 | An attestation provider was requested that the host cannot supply. Raised by `SevSnpProvider.attest` off SEV-SNP hardware; also reserved for the TDX/TPM backends. See [Peer Attestation](attestation.md). | +| `AttestationFailed` | `ATTESTATION_FAILED` | 412 | Attestation evidence was present but did not verify. Raised by the SEV-SNP verifier on a malformed report, an untrusted or broken certificate chain, a bad report signature, or a measurement / report-data mismatch. See [Peer Attestation](attestation.md). | +| `SealedChannelError` | `SEALED_CHANNEL_ERROR` | 500 | The sealed peer channel could not construct or open a payload: an invalid peer public key, a malformed or unsupported sealed blob, a wrong key, or a tampered ciphertext (AEAD authentication failure). Fails closed; never returns unauthenticated plaintext. See [Sealed Channel](sealed-channel.md). | +| `ProvenanceLinkBroken` | `PROVENANCE_LINK_BROKEN` | 409 | A `DelegationRecord` does not chain to its stated parent record, or a record was tampered with so its hash no longer matches a child's link: empty provenance chain, duplicate `record_id`, a root record that references a parent, a broken parent hash link, or a record whose `credential_id` or subject does not match the chain. Raised by `verify_dag` and `cross_check_chain`. | +| `ScopeNotPermitted` | `SCOPE_NOT_PERMITTED` | 403 | A requested capability is not in the effective scope (the delegated leaf scope intersected with the callee's local policy). Raised by `enforce_peer_call`. | + +## Which errors are live today + +`ConfigError`, `InvalidCredential`, `ScopeEscalation`, `BrokenDelegationLink`, `DelegationDepthExceeded`, `CredentialReplay`, and `ProvenanceLinkBroken` are raised by shipping code paths: attenuated delegation, offline chain verification, and the provenance DAG. `ScopeNotPermitted` is raised by the peer-call enforcement decision core (`enforce_peer_call`), and `SealedChannelError` by the sealed channel (`SealedChannel.seal`, `open_sealed`), both of which are implemented. + +`AttestationFailed` is raised by the SEV-SNP verifier (chain, report signature, and measurement binding), and `AttestationUnsupported` by `SevSnpProvider.attest` off hardware. Producing a real report requires a SEV-SNP guest, and the TDX/TPM backends are not yet implemented. See [Peer Attestation](attestation.md) and [ROADMAP.md](../../ROADMAP.md). + +## Handling errors + +Catch the base class to handle any cA2A failure, or a specific subclass to react to one condition. The `code` attribute gives you the stable identifier and `http_status` the status to surface. + +```python +from ca2a_runtime.errors import CA2AError, ScopeEscalation +from ca2a_verify.verify import verify_chain_file + +try: + verify_chain_file("chain.json") +except ScopeEscalation as exc: + # A hop claimed more than its parent granted. + print(exc.code, exc.http_status) # SCOPE_ESCALATION 403 +except CA2AError as exc: + # Any other cA2A failure. + print(exc.code, exc.http_status, exc.detail) +``` + +Verification fails closed. `verify_chain`, `verify_dag`, and `cross_check_chain` raise the first error they find rather than returning a partial result, so a caught `CA2AError` means the chain or DAG was rejected. + +## See also + +- [Delegation Chain](delegation-chain.md) for the checks behind `ScopeEscalation`, `BrokenDelegationLink`, `DelegationDepthExceeded`, and `CredentialReplay`. +- [Provenance DAG](provenance-dag.md) for the checks behind `ProvenanceLinkBroken`. +- [Verification Library](verification-library.md) for `verify_chain`, `verify_chain_file`, `verify_dag`, and `cross_check_chain`. +- [Failure Modes](failure-modes.md) for how these errors map to observable runtime behavior. diff --git a/docs/spec/failure-modes.md b/docs/spec/failure-modes.md index ef7d035..54f6511 100644 --- a/docs/spec/failure-modes.md +++ b/docs/spec/failure-modes.md @@ -1,147 +1,149 @@ -# 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 `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. - -Real hardware providers (`tpm`, `sev-snp`, `tdx`, `opaque`) return `False` from `detect()` in this release, so they are never auto-selected, and verification against an absent backend fails closed rather than assuming a peer is trustworthy. The `software-only` provider is for development and CI and never reports a hardware platform string. Until at least one real hardware backend verifies a quote, cA2A must not be described as attested across a trust boundary. This 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 (not yet implemented; fails closed) - -The sealed peer channel binds a payload key to a peer's attested measurement so the task decrypts only inside the peer's verified enclave. It is Tier 2 and **not yet implemented**. Critically, the placeholder does not degrade to plaintext. `SealedChannel.seal` and `SealedChannel.open` raise `SealedChannelError` rather than pass a payload in the clear: - -```python -from ca2a_runtime.channel.sealed import SealedChannel -from ca2a_runtime.errors import SealedChannelError - -channel = SealedChannel(peer_measurement="...") -try: - wire = channel.seal(payload) -except SealedChannelError as exc: - # code == "SEALED_CHANNEL_ERROR" - # No plaintext was emitted. Do not send the payload. - abort(exc) -``` - -Instantiation is allowed so the runtime can be wired against the interface, but the sealing operations fail closed. Do not send confidential task payloads across a trust boundary and assume they are protected until this lands. 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 | -| Runtime peer-delegation enforcement in the live request path | `SCOPE_ESCALATION` / others | Pending Tier 2 | -| Cedar scope intersection at the peer | (design) | Pending Tier 2 | -| Sealed channel (fails closed, never emits plaintext) | `SEALED_CHANNEL_ERROR` | Interface only, Tier 2 | -| Missing or failed attestation | `ATTESTATION_UNSUPPORTED` / `ATTESTATION_FAILED` | Fails closed; hardware backend Tier 3 | - -The failures marked "enforced today" are exercised by the offline verifiers (`verify_chain`, `verify_chain_file`, `verify_dag`, `cross_check_chain`). The Tier 2/3 rows fail closed by design: the sealed channel raises instead of sending plaintext, and hardware attestation denies instead of trusting an unmeasured peer. What is not present is the live inbound request path that would apply chain verification, Cedar intersection, and sealing to a real peer call. That is Tier 2. See [LIMITATIONS.md](../../LIMITATIONS.md) and the [roadmap](../../ROADMAP.md) for sequencing. +# 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 `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. + +Real hardware providers (`tpm`, `sev-snp`, `tdx`, `opaque`) return `False` from `detect()` in this release, so they are never auto-selected, and verification against an absent backend fails closed rather than assuming a peer is trustworthy. The `software-only` provider is for development and CI and never reports a hardware platform string. Until at least one real hardware backend verifies a quote, cA2A must not be described as attested across a trust boundary. This 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. diff --git a/docs/spec/sealed-channel.md b/docs/spec/sealed-channel.md index 27c7307..727bd39 100644 --- a/docs/spec/sealed-channel.md +++ b/docs/spec/sealed-channel.md @@ -1,23 +1,31 @@ # Sealed Peer Channel -The sealed channel binds a task payload to a peer's attested measurement so it decrypts only inside the peer's verified enclave. This is Tier 2 on the roadmap and is not yet implemented; the interface is defined so the runtime can be written against it. +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 B's measurement means only a B that booted the expected, measured code can open it. +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. + +## Scheme + +HPKE-style, using only primitives from the `cryptography` library: + +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. ## Interface ```python -from ca2a_runtime.channel import SealedChannel +from ca2a_runtime.channel import SealedChannel, generate_channel_keypair, open_sealed -ch = SealedChannel(peer_measurement="sha256:...") -sealed = ch.seal(payload) # to the peer's measurement -opened = ch.open(sealed) # only inside the attested peer +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 ``` -Until the enclave-sealing backend lands, `seal` and `open` fail closed with `SEALED_CHANNEL_ERROR` rather than silently transmitting plaintext. Do not send confidential task payloads across a trust boundary and assume they are protected. See [LIMITATIONS.md](../../LIMITATIONS.md). +`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. -## Construction +## What rests on hardware -The channel key is bound to the measurement in the attestation report (see [attestation](attestation.md)), extending the attestation-gated key pattern that cmcp uses for agent-to-gateway to the peer-to-peer case. The connectivity path sees ciphertext; the only thing that leaves the enclave in the clear is the signed TRACE record. +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. diff --git a/experiments/README.md b/experiments/README.md index 18a8d77..11c7b2d 100644 --- a/experiments/README.md +++ b/experiments/README.md @@ -1,34 +1,34 @@ -# cA2A Experiments - -Reproducible experiments backing the technical claims in the cA2A profile and paper. - -Each experiment imports directly from `ca2a_runtime`. Run from the repo root after `pip install -e ".[dev]"`. - -## Experiments - -| Dir | Claim | Status | Key result | -|-----|-------|--------|-----------| -| [claim1-attenuation-soundness](claim1-attenuation-soundness/) | C1: A child grant can never exceed its parent | working | 200/200 narrowing chains accepted; 200/200 escalation attempts rejected (`ScopeEscalation`) | -| [claim2-cross-chain-replay](claim2-cross-chain-replay/) | C2: Replayed or spliced credentials are rejected | working | Duplicate `credential_id` -> `CredentialReplay`; cross-chain splice -> `BrokenDelegationLink` | -| [claim3-scope-policy-intersection](claim3-scope-policy-intersection/) | C3: Delegated scope intersected with local policy | working | Effective scope = delegated INTERSECT local policy; capability granted only when delegated AND locally allowed (1/1 allowed, 3/3 denied) | -| [claim4-sealed-payload-confidentiality](claim4-sealed-payload-confidentiality/) | C4: Payload decrypts only inside the attested peer | gated (Tier 2) | Proves fail-closed today (`SealedChannelError`, no silent plaintext); confidentiality property pending | -| [claim5-provenance-dag-integrity](claim5-provenance-dag-integrity/) | C5: Linked records are tamper-evident, bound to authority | working | Tamper flips ~50% of hash bits (128/256), `ProvenanceLinkBroken` raised; reparent detected; provenance bound to authority | -| [claim6-cross-operator-attestation](claim6-cross-operator-attestation/) | C6: Two domains, independent keys, mutual attestation, binary-swap detection | gated (Tier 3) | SKIPs until a real hardware attestation backend verifies a quote | - -Working experiments are fully reproducible on any host with no TEE. Gated experiments SKIP (exit 0) until the implementation they depend on lands, mirroring how cmcp's `claim-hw-attestation` SKIPs without a confidential VM. Each gated dependency is on the [roadmap](../ROADMAP.md). - -## Running - -```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 today -python experiments/claim5-provenance-dag-integrity/run.py -python experiments/claim6-cross-operator-attestation/run.py # SKIP (Tier 3) -``` - -## CI - -Each claim has a unit test under `tests/unit/test_claim*.py`. Working claims assert their property; gated claims register a `pytest.mark.skip` so CI records them as skipped, not failed, until the dependency lands. The suite runs in the `test` job of [.github/workflows/ci.yml](../.github/workflows/ci.yml). +# cA2A Experiments + +Reproducible experiments backing the technical claims in the cA2A profile and paper. + +Each experiment imports directly from `ca2a_runtime`. Run from the repo root after `pip install -e ".[dev]"`. + +## Experiments + +| Dir | Claim | Status | Key result | +|-----|-------|--------|-----------| +| [claim1-attenuation-soundness](claim1-attenuation-soundness/) | C1: A child grant can never exceed its parent | working | 200/200 narrowing chains accepted; 200/200 escalation attempts rejected (`ScopeEscalation`) | +| [claim2-cross-chain-replay](claim2-cross-chain-replay/) | C2: Replayed or spliced credentials are rejected | working | Duplicate `credential_id` -> `CredentialReplay`; cross-chain splice -> `BrokenDelegationLink` | +| [claim3-scope-policy-intersection](claim3-scope-policy-intersection/) | C3: Delegated scope intersected with local policy | working | Effective scope = delegated INTERSECT local policy; capability granted only when delegated AND locally allowed (1/1 allowed, 3/3 denied) | +| [claim4-sealed-payload-confidentiality](claim4-sealed-payload-confidentiality/) | C4: Payload decrypts only with the peer's enclave-bound key | working | Sealed to the attested key (X25519 -> HKDF -> ChaCha20-Poly1305); only the peer's private key opens it; path sees ciphertext; tamper fails closed | +| [claim5-provenance-dag-integrity](claim5-provenance-dag-integrity/) | C5: Linked records are tamper-evident, bound to authority | working | Tamper flips ~50% of hash bits (128/256), `ProvenanceLinkBroken` raised; reparent detected; provenance bound to authority | +| [claim6-cross-operator-attestation](claim6-cross-operator-attestation/) | C6: Two domains, independent keys, mutual attestation, binary-swap detection | gated (Tier 3) | SKIPs until a real hardware attestation backend verifies a quote | + +Working experiments are fully reproducible on any host with no TEE. Gated experiments SKIP (exit 0) until the implementation they depend on lands, mirroring how cmcp's `claim-hw-attestation` SKIPs without a confidential VM. Each gated dependency is on the [roadmap](../ROADMAP.md). + +## Running + +```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 today +python experiments/claim5-provenance-dag-integrity/run.py +python experiments/claim6-cross-operator-attestation/run.py # SKIP (Tier 3) +``` + +## CI + +Each claim has a unit test under `tests/unit/test_claim*.py`. Working claims assert their property; gated claims register a `pytest.mark.skip` so CI records them as skipped, not failed, until the dependency lands. The suite runs in the `test` job of [.github/workflows/ci.yml](../.github/workflows/ci.yml). diff --git a/experiments/claim4-sealed-payload-confidentiality/README.md b/experiments/claim4-sealed-payload-confidentiality/README.md index b57d8d7..5222f8c 100644 --- a/experiments/claim4-sealed-payload-confidentiality/README.md +++ b/experiments/claim4-sealed-payload-confidentiality/README.md @@ -1,57 +1,33 @@ -# Experiment: Sealed-Payload Confidentiality (Fail-Closed) +# Experiment: Sealed-Payload Confidentiality -**Claim:** A task payload sealed to a peer's attested measurement decrypts only inside that peer's verified enclave (cA2A Claim 4). +**Claim:** A task payload sealed to a peer's attested key decrypts only with the private key bound to that peer's enclave (cA2A Claim 4). -**Status:** GATED on Tier 2. `SealedChannel` is a fail-closed placeholder. The enclave-sealing backend that would make the confidentiality property demonstrable is not implemented in this release (see `ROADMAP.md` / `LIMITATIONS.md`). This experiment does **not** claim confidentiality is proven. It proves the honest fallback: absent the backend, the runtime refuses to seal rather than emitting plaintext. +**Status: validated (cryptographic layer).** -**What this experiment proves today:** +`ca2a_runtime.channel` implements an HPKE-style sealed channel: an ephemeral X25519 ECDH to the peer's public key, HKDF-SHA256 to derive a key, and ChaCha20-Poly1305 AEAD over the payload. The peer generates its channel keypair (inside the enclave, on real hardware) and vouches for the public key through its attestation report; a sender seals to that key with `SealedChannel(peer_pub).seal(payload)`, and only the holder of the private key opens it with `open_sealed(blob, private_key)`. -1. `SealedChannel(peer_measurement).seal(payload)` raises `SealedChannelError`. It does not return the payload, an empty blob, or any silent plaintext. -2. `SealedChannel(...).open(sealed)` raises `SealedChannelError` for the same reason. -3. The error carries a `SEALED_CHANNEL_ERROR` code and a detail string that names Tier 2, so a caller can distinguish "not implemented yet" from a runtime encryption fault. +**What it proves:** -**What this experiment does NOT prove (pending Tier 2):** +1. The sealed blob does not contain the plaintext; the transport sees ciphertext. +2. Only the peer's private key opens the payload; another party with a different key cannot. +3. Any tampering with the sealed blob fails closed (AEAD authentication), so a modified payload never decrypts. -- That a sealed payload is confidential against the transport or the host. -- That the payload decrypts only under the attested peer measurement and nowhere else. - -Those are the actual confidentiality properties. They require the measurement-bound sealing backend and a real attested peer, and are marked as a skipped placeholder in the CI test. - -**Why fail-closed matters for governance:** - -The dangerous failure mode for a confidentiality primitive is silent degradation: an unfinished channel that quietly forwards plaintext while callers believe it is sealed. cA2A's placeholder is wired so the confidentiality-dependent path cannot run at all until the backend lands. A caller that forgets to check the Tier gate gets an exception, not a leak. +**What rests on hardware (not proven here):** the guarantee that the private key never leaves the peer's enclave, so the payload decrypts *only inside the attested measurement*. That is what attestation establishes (see the SEV-SNP verifier and [attestation.md](../../docs/spec/attestation.md)). Driving the seal off a verified report on a live inbound call is runtime wiring, tracked on the roadmap. The cryptographic confidentiality of the payload to the attested key is what this experiment validates. ## Running ```bash -# From repo root, with the package installed editable (pip install -e .) -.venv/Scripts/python.exe experiments/claim4-sealed-payload-confidentiality/run.py +# From repo root, with the package installed editable (pip install -e ".[dev]") +python experiments/claim4-sealed-payload-confidentiality/run.py ``` ## Expected output ``` -============================================================ -Experiment: Sealed-Payload Confidentiality (Fail-Closed) -Claim 4: payload decrypts only inside the attested peer -Status: GATED on Tier 2 (SealedChannel is a placeholder) -============================================================ - -[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 - -[2. open() fails closed] - open(sealed) raised: SealedChannelError 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. +Claim 4: sealed-payload confidentiality + [1] plaintext hidden in sealed blob: YES (... bytes) OK + [2] peer opens with its private key: OK + [3] other party cannot open: OK + [4] tampered payload fails closed: OK +KEY RESULT: 4/4 sealed to the attested key; only the enclave-bound private key opens it; path sees ciphertext; tamper fails closed ``` - -Exit code is 0: fail-closed behavior confirmed is a success, not a failure. diff --git a/experiments/claim4-sealed-payload-confidentiality/run.py b/experiments/claim4-sealed-payload-confidentiality/run.py index 18a5da8..7ab0159 100644 --- a/experiments/claim4-sealed-payload-confidentiality/run.py +++ b/experiments/claim4-sealed-payload-confidentiality/run.py @@ -1,109 +1,80 @@ +#!/usr/bin/env python3 +"""Claim 4: a task payload sealed to a peer's attested key decrypts only with +the private key bound to that peer's enclave. + +Validated experiment (no hardware) at the cryptographic layer: the sealed blob +hides the plaintext, only the peer's private key opens it, another party cannot, +and tampering fails closed. The guarantee that the private key never leaves the +enclave is what attestation establishes on real hardware; that end-to-end +binding on a live call is runtime wiring (see ROADMAP.md). """ -Experiment: Sealed-Payload Confidentiality (Fail-Closed) -Claim 4: a task payload sealed to a peer's attested measurement decrypts only -inside that peer's verified enclave. - -Status: GATED on Tier 2. SealedChannel is a fail-closed placeholder. The -enclave-sealing backend that would let us demonstrate confidentiality is not -implemented in this release. This script demonstrates the CURRENT honest -behavior: seal()/open() raise SealedChannelError rather than silently emitting -plaintext. The confidentiality property itself is pending Tier 2. - -Run from repo root (package installed editable): - .venv/Scripts/python.exe experiments/claim4-sealed-payload-confidentiality/run.py -""" - +# ruff: noqa: T201 from __future__ import annotations import sys from pathlib import Path -# Allow running from repo root without install. sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) -from ca2a_runtime.channel import SealedChannel -from ca2a_runtime.errors import SealedChannelError - -PEER_MEASUREMENT = "sha256:" + "ab" * 32 -SECRET = b"confidential task payload: transfer $5000 to account 12345" +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey # noqa: E402 +from ca2a_runtime.channel import SealedChannel, generate_channel_keypair, open_sealed # noqa: E402 +from ca2a_runtime.errors import SealedChannelError # noqa: E402 -def result(label: str, value: str, ok: bool | None = None) -> None: - if ok is None: - print(f" {label}: {value}") - elif ok: - print(f" {label}: {value} OK") - else: - print(f" {label}: {value} FAIL") +SECRET = b"confidential task payload: transfer 5000 to account 12345" def main() -> int: - print("=" * 60) - print("Experiment: Sealed-Payload Confidentiality (Fail-Closed)") - print("Claim 4: payload decrypts only inside the attested peer") - print("Status: GATED on Tier 2 (SealedChannel is a placeholder)") - print("=" * 60) - - failures = 0 - channel = SealedChannel(peer_measurement=PEER_MEASUREMENT) - - # ------------------------------------------------------------------ - # Property 1: seal() fails closed. No silent plaintext. - # ------------------------------------------------------------------ - print("\n[1. seal() fails closed]") - seal_output: bytes | None = None + # The peer generates its channel keypair (inside the enclave, on hardware). + peer_priv, peer_pub = generate_channel_keypair() + print("Claim 4: sealed-payload confidentiality") + + sealed = SealedChannel(peer_pub).seal(SECRET) + checks = 0 + passed = 0 + + # 1. The transport sees ciphertext, not the payload. + checks += 1 + hidden = SECRET not in sealed + passed += hidden + print(f" [1] plaintext hidden in sealed blob: {'YES' if hidden else 'NO'} " + f"({len(sealed)} bytes) {'OK' if hidden else 'FAIL'}") + + # 2. The peer (holder of the enclave-bound private key) recovers it. + checks += 1 + recovered = open_sealed(sealed, peer_priv) == SECRET + passed += recovered + print(f" [2] peer opens with its private key: {'OK' if recovered else 'FAIL'}") + + # 3. Another party (different key) cannot open it. + checks += 1 try: - seal_output = channel.seal(SECRET) - except SealedChannelError as exc: - result("seal(payload) raised", "SealedChannelError", True) - result("error code", exc.code, exc.code == "SEALED_CHANNEL_ERROR") - if exc.code != "SEALED_CHANNEL_ERROR": - failures += 1 - detail = exc.detail or "" - names_tier2 = "Tier 2" in detail - result("detail names Tier 2", "YES" if names_tier2 else "NO", names_tier2) - if not names_tier2: - failures += 1 - # The exception path is exactly what "no silent plaintext" means: - # control never reached a return, so seal_output is still None. - no_plaintext = seal_output is None - result("plaintext emitted", "NO" if no_plaintext else "YES", no_plaintext) - if not no_plaintext: - failures += 1 - else: - # A returned value here would be the dangerous silent-degradation case. - result("seal(payload) raised", "nothing: returned a value", False) - leaked = seal_output == SECRET - result("plaintext emitted", "YES" if leaked else "unknown blob", False) - failures += 1 - - # ------------------------------------------------------------------ - # Property 2: open() fails closed too. - # ------------------------------------------------------------------ - print("\n[2. open() fails closed]") + open_sealed(sealed, X25519PrivateKey.generate()) + blocked = False + except SealedChannelError: + blocked = True + passed += blocked + print(f" [3] other party cannot open: {'OK' if blocked else 'FAIL'}") + + # 4. Tampering fails closed (AEAD authentication). + checks += 1 + tampered = bytearray(sealed) + tampered[-1] ^= 0xFF try: - channel.open(b"sealed blob that we never actually produced") + open_sealed(bytes(tampered), peer_priv) + tamper_caught = False except SealedChannelError: - result("open(sealed) raised", "SealedChannelError", True) - else: - result("open(sealed) raised", "nothing: returned a value", False) - failures += 1 - - # ------------------------------------------------------------------ - # Summary - # ------------------------------------------------------------------ - print("\n" + "=" * 60) - if failures == 0: - print("KEY RESULT: SealedChannel fails closed. seal()/open() raise") - print("SEALED_CHANNEL_ERROR instead of emitting plaintext. This is") - print("the honest current behavior. The confidentiality claim itself") - print("(payload decrypts only under the attested peer measurement) is") - print("PENDING Tier 2 and is not demonstrated here.") + tamper_caught = True + passed += tamper_caught + 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") return 0 - print(f"KEY RESULT: {failures} check(s) FAILED. Fail-closed behavior not") - print("confirmed: see output above.") + print(f"KEY RESULT: FAIL ({passed}/{checks} passed)") return 1 if __name__ == "__main__": - sys.exit(main()) + raise SystemExit(main()) diff --git a/src/ca2a_runtime/channel/__init__.py b/src/ca2a_runtime/channel/__init__.py index 8c923a6..d442843 100644 --- a/src/ca2a_runtime/channel/__init__.py +++ b/src/ca2a_runtime/channel/__init__.py @@ -1,10 +1,14 @@ -"""Sealed peer channel: seal a task payload to a peer's attested measurement. +"""Sealed peer channel: seal a task payload to a peer's attested key. -Not yet implemented (Tier 2, see ROADMAP.md). The interface is defined so the -runtime peer path can be written against it while the enclave-sealing backend -is built on the cmcp TEE primitives. +HPKE-style X25519 -> HKDF-SHA256 -> ChaCha20-Poly1305. Only the holder of the +private key bound to the peer's attested measurement can open a sealed payload. +See docs/spec/sealed-channel.md. """ -from ca2a_runtime.channel.sealed import SealedChannel +from ca2a_runtime.channel.sealed import ( + SealedChannel, + generate_channel_keypair, + open_sealed, +) -__all__ = ["SealedChannel"] +__all__ = ["SealedChannel", "generate_channel_keypair", "open_sealed"] diff --git a/src/ca2a_runtime/channel/sealed.py b/src/ca2a_runtime/channel/sealed.py index 6b47d8d..bddae18 100644 --- a/src/ca2a_runtime/channel/sealed.py +++ b/src/ca2a_runtime/channel/sealed.py @@ -1,34 +1,97 @@ -"""Sealed peer channel interface (Tier 2, not yet implemented). +"""Sealed peer channel: seal a task payload to a peer's attested key. -The sealed channel binds a symmetric payload key to a peer's attested -measurement so the task payload decrypts only inside the peer's verified -enclave. Until the enclave-sealing backend lands, these operations fail closed -rather than silently sending plaintext. +The channel binds a payload to the X25519 public key that a peer's attestation +report vouches for (see docs/spec/attestation.md). The scheme is HPKE-style: +an ephemeral X25519 ECDH to the peer key, HKDF-SHA256 to derive a symmetric +key, and ChaCha20-Poly1305 AEAD over the payload. Only the holder of the +private key can open the result. + +Confidentiality of the payload rests on that private key. On real hardware the +key is generated and held inside the peer's enclave and never leaves it, so the +payload decrypts only inside the attested measurement. This module implements +the cryptography; 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 tracked as runtime wiring (see ROADMAP.md). """ from __future__ import annotations +import os + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.asymmetric.x25519 import ( + X25519PrivateKey, + X25519PublicKey, +) +from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 +from cryptography.hazmat.primitives.hashes import SHA256 +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + from ca2a_runtime.errors import SealedChannelError +_VERSION = 1 +_HKDF_INFO = b"ca2a/sealed-channel/v1" +_EPH_LEN = 32 +_NONCE_LEN = 12 +_HEADER_LEN = 1 + _EPH_LEN + _NONCE_LEN -class SealedChannel: - """Placeholder for the measurement-bound peer channel. - Instantiation is allowed so the runtime can be wired against the interface; - the sealing operations fail closed until Tier 2 lands (see LIMITATIONS.md). +def generate_channel_keypair() -> tuple[X25519PrivateKey, str]: + """Return an enclave channel keypair and its public key as raw hex. + + On hardware this runs inside the peer's enclave and the public key is bound + into the attestation report; the private key never leaves the enclave. """ + priv = X25519PrivateKey.generate() + pub_hex = priv.public_key().public_bytes_raw().hex() + return priv, pub_hex + - def __init__(self, peer_measurement: str) -> None: - self.peer_measurement = peer_measurement +def _derive_key(shared: bytes, eph_pub: bytes, peer_pub: bytes) -> bytes: + return HKDF(algorithm=SHA256(), length=32, salt=None, info=_HKDF_INFO).derive( + eph_pub + peer_pub + shared + ) - def seal(self, payload: bytes) -> bytes: - raise SealedChannelError( - "sealed peer channel not implemented", - detail="Tier 2 on the roadmap; do not send confidential payloads yet", - ) - def open(self, sealed: bytes) -> bytes: - raise SealedChannelError( - "sealed peer channel not implemented", - detail="Tier 2 on the roadmap", - ) +class SealedChannel: + """Sender side: seals payloads to a peer's attested X25519 public key.""" + + def __init__(self, peer_public_key: str) -> None: + try: + self._peer_pub = X25519PublicKey.from_public_bytes(bytes.fromhex(peer_public_key)) + except ValueError as exc: + raise SealedChannelError("invalid peer public key", detail=str(exc)) from exc + self.peer_public_key = peer_public_key + + def seal(self, payload: bytes, *, aad: bytes = b"") -> bytes: + """Seal ``payload`` to the peer key. The result is opaque to anyone + without the peer's private key; ``aad`` is authenticated, not encrypted.""" + eph_priv = X25519PrivateKey.generate() + eph_pub = eph_priv.public_key().public_bytes_raw() + peer_pub_raw = self._peer_pub.public_bytes_raw() + shared = eph_priv.exchange(self._peer_pub) + key = _derive_key(shared, eph_pub, peer_pub_raw) + nonce = os.urandom(_NONCE_LEN) + ct = ChaCha20Poly1305(key).encrypt(nonce, payload, aad) + return bytes([_VERSION]) + eph_pub + nonce + ct + + +def open_sealed(blob: bytes, private_key: X25519PrivateKey, *, aad: bytes = b"") -> bytes: + """Open a sealed payload with the enclave-bound private key. + + Fails closed with SealedChannelError on a malformed blob, a wrong key, or a + tampered ciphertext (AEAD authentication failure); it never returns + unauthenticated plaintext. + """ + if len(blob) < _HEADER_LEN or blob[0] != _VERSION: + raise SealedChannelError("malformed or unsupported sealed payload") + eph_pub = blob[1 : 1 + _EPH_LEN] + nonce = blob[1 + _EPH_LEN : _HEADER_LEN] + ct = blob[_HEADER_LEN:] + try: + peer_pub_raw = private_key.public_key().public_bytes_raw() + shared = private_key.exchange(X25519PublicKey.from_public_bytes(eph_pub)) + key = _derive_key(shared, eph_pub, peer_pub_raw) + return ChaCha20Poly1305(key).decrypt(nonce, ct, aad) + except (InvalidTag, ValueError) as exc: + raise SealedChannelError("sealed payload failed to open", detail=str(exc)) from exc diff --git a/tests/unit/test_channel_and_tee.py b/tests/unit/test_channel_and_tee.py index fec5f4f..8198dd4 100644 --- a/tests/unit/test_channel_and_tee.py +++ b/tests/unit/test_channel_and_tee.py @@ -1,23 +1,12 @@ -"""Tests for the fail-closed placeholders: sealed channel and TEE base.""" +"""Tests for the TEE base types.""" from __future__ import annotations import pytest -from ca2a_runtime.channel import SealedChannel -from ca2a_runtime.errors import SealedChannelError from ca2a_runtime.tee import AttestationReport, BaseProvider -def test_sealed_channel_fails_closed() -> None: - ch = SealedChannel(peer_measurement="sha256:abc") - assert ch.peer_measurement == "sha256:abc" - with pytest.raises(SealedChannelError): - ch.seal(b"secret task") - with pytest.raises(SealedChannelError): - ch.open(b"sealed blob") - - def test_attestation_report_is_frozen() -> None: report = AttestationReport("software-only", "m0", "deadbeef", "nonce") assert report.platform == "software-only" diff --git a/tests/unit/test_claim4_sealed.py b/tests/unit/test_claim4_sealed.py index e090e0a..dfdc297 100644 --- a/tests/unit/test_claim4_sealed.py +++ b/tests/unit/test_claim4_sealed.py @@ -1,55 +1,41 @@ """Claim 4: sealed-payload confidentiality. -The confidentiality property (payload decrypts only inside the attested peer -measurement) is GATED on Tier 2 and is not implemented in this release. What we -can and do test now is the fail-closed contract of the placeholder: seal() and -open() raise SealedChannelError instead of silently emitting plaintext. The -confidentiality property itself is a skipped placeholder pending Tier 2. +A payload sealed to a peer's attested key decrypts only with the private key +bound to that peer's enclave. Validated here at the cryptographic layer: the +sealed blob hides the plaintext, only the peer's private key opens it, and any +tampering fails closed. The guarantee that the private key never leaves the +enclave is what attestation establishes on real hardware; that end-to-end +binding on a live call remains runtime wiring (see ROADMAP.md). """ from __future__ import annotations import pytest +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey -from ca2a_runtime.channel import SealedChannel +from ca2a_runtime.channel import SealedChannel, generate_channel_keypair, open_sealed from ca2a_runtime.errors import SealedChannelError -PEER_MEASUREMENT = "sha256:" + "ab" * 32 +def test_payload_decrypts_only_with_peer_private_key() -> None: + peer_priv, peer_pub = generate_channel_keypair() + payload = b"confidential task payload" -def test_seal_fails_closed() -> None: - channel = SealedChannel(peer_measurement=PEER_MEASUREMENT) - with pytest.raises(SealedChannelError): - channel.seal(b"confidential task payload") + sealed = SealedChannel(peer_pub).seal(payload) + assert payload not in sealed # the transport sees ciphertext + # The peer (holder of the enclave-bound private key) recovers it. + assert open_sealed(sealed, peer_priv) == payload -def test_open_fails_closed() -> None: - channel = SealedChannel(peer_measurement=PEER_MEASUREMENT) + # Anyone else, including another attested peer, cannot. + other_priv = X25519PrivateKey.generate() with pytest.raises(SealedChannelError): - channel.open(b"sealed blob") - - -def test_seal_error_carries_tier2_signal() -> None: - channel = SealedChannel(peer_measurement=PEER_MEASUREMENT) - with pytest.raises(SealedChannelError) as excinfo: - channel.seal(b"confidential task payload") - exc = excinfo.value - assert exc.code == "SEALED_CHANNEL_ERROR" - assert exc.detail is not None - assert "Tier 2" in exc.detail - - -@pytest.mark.skip( - reason="Confidentiality property gated on Tier 2: the enclave-sealing " - "backend (payload decrypts only under the attested peer measurement) is " - "not implemented in this release. See ROADMAP.md." -) -def test_payload_decrypts_only_under_attested_peer_measurement() -> None: - channel = SealedChannel(peer_measurement=PEER_MEASUREMENT) - payload = b"confidential task payload" - sealed = channel.seal(payload) - assert sealed != payload - assert channel.open(sealed) == payload - wrong_peer = SealedChannel(peer_measurement="sha256:" + "cd" * 32) + open_sealed(sealed, other_priv) + + +def test_tampered_sealed_payload_fails_closed() -> None: + peer_priv, peer_pub = generate_channel_keypair() + sealed = bytearray(SealedChannel(peer_pub).seal(b"confidential task payload")) + sealed[-1] ^= 0xFF with pytest.raises(SealedChannelError): - wrong_peer.open(sealed) + open_sealed(bytes(sealed), peer_priv) diff --git a/tests/unit/test_sealed_channel.py b/tests/unit/test_sealed_channel.py new file mode 100644 index 0000000..d7d18f3 --- /dev/null +++ b/tests/unit/test_sealed_channel.py @@ -0,0 +1,60 @@ +"""Tests for the sealed peer channel (X25519 -> HKDF -> ChaCha20-Poly1305).""" + +from __future__ import annotations + +import pytest +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey + +from ca2a_runtime.channel import SealedChannel, generate_channel_keypair, open_sealed +from ca2a_runtime.errors import SealedChannelError + + +def test_seal_open_roundtrip() -> None: + priv, pub = generate_channel_keypair() + payload = b"transfer 250000 to account 12345" + sealed = SealedChannel(pub).seal(payload) + assert open_sealed(sealed, priv) == payload + + +def test_sealed_blob_hides_plaintext() -> None: + priv, pub = generate_channel_keypair() + payload = b"MNPI: acquisition closes Friday" + sealed = SealedChannel(pub).seal(payload) + assert payload not in sealed # the path sees ciphertext, not the payload + + +def test_wrong_key_cannot_open() -> None: + _, pub = generate_channel_keypair() + sealed = SealedChannel(pub).seal(b"secret") + attacker_key = X25519PrivateKey.generate() + with pytest.raises(SealedChannelError): + open_sealed(sealed, attacker_key) + + +def test_tampered_ciphertext_fails_closed() -> None: + priv, pub = generate_channel_keypair() + sealed = bytearray(SealedChannel(pub).seal(b"secret")) + sealed[-1] ^= 0xFF # flip a ciphertext/tag byte + with pytest.raises(SealedChannelError): + open_sealed(bytes(sealed), priv) + + +def test_aad_must_match() -> None: + priv, pub = generate_channel_keypair() + sealed = SealedChannel(pub).seal(b"secret", aad=b"session-A") + assert open_sealed(sealed, priv, aad=b"session-A") == b"secret" + with pytest.raises(SealedChannelError): + open_sealed(sealed, priv, aad=b"session-B") + + +def test_malformed_blob_rejected() -> None: + priv, _ = generate_channel_keypair() + with pytest.raises(SealedChannelError): + open_sealed(b"too short", priv) + with pytest.raises(SealedChannelError): + open_sealed(b"\x02" + b"\x00" * 60, priv) # unsupported version + + +def test_invalid_peer_key_rejected() -> None: + with pytest.raises(SealedChannelError): + SealedChannel("not-hex-zz")