|
1 | 1 | # Mutual Attestation |
2 | 2 |
|
3 | 3 | --- |
4 | | -Status: Proposal |
| 4 | +Status: Implemented in the reference transport |
5 | 5 | Written: 2026-08-09 |
6 | 6 | Stability: Unstable |
7 | 7 | --- |
8 | 8 |
|
| 9 | +> **State of this document.** The design below is built. The callee issues a |
| 10 | +> challenge on the handshake endpoint, the caller binds its own channel key into a |
| 11 | +> report under it, and the callee appraises that before it opens the sealed |
| 12 | +> payload. What is *not* done is a hardware run in both directions: see |
| 13 | +> [What this still does not give you](#what-this-still-does-not-give-you), which |
| 14 | +> has not moved. |
| 15 | +
|
9 | 16 | ## What is one-directional, and what is not |
10 | 17 |
|
11 | 18 | The primitives are symmetric. Either peer can bind a channel key into a report |
@@ -40,8 +47,8 @@ caller callee |
40 | 47 | │ appraise callee, seal payload |
41 | 48 | │ attest own channel key under C |
42 | 49 | │ POST /task + ChannelOffer(C) ──────▶ |
43 | | - │ appraise caller BEFORE acting |
44 | | - │ ◀────────────── response sealed to caller key |
| 50 | + │ appraise caller BEFORE opening payload |
| 51 | + │ ◀───── provenance record (readable, so it can be chained) |
45 | 52 | ``` |
46 | 53 |
|
47 | 54 | Three properties fall out, and each is a requirement rather than a consequence: |
@@ -71,6 +78,26 @@ that was wrong in this protocol. |
71 | 78 | exactly-once. That weaker property is stated here rather than left implied. |
72 | 79 | 2. **Record the outcome, requirement configurable.** A callee does not demand |
73 | 80 | attestation by default and can be configured to. |
| 81 | + - The knob is a three-rung ladder, `require_caller_attestation`: |
| 82 | + `"none"` (the default, demands nothing), `"any"` (an offer that appraises, |
| 83 | + software assurance is enough), `"hardware"` (the assurance must be |
| 84 | + hardware-backed). `"hardware"` without a `caller_verifier` is refused at |
| 85 | + construction rather than on every call, because it could never succeed. |
| 86 | + - The outcome is one of four values, not three: `not_offered`, `failed`, |
| 87 | + `software-only`, `hardware`. A peer that offered nothing and a peer whose |
| 88 | + offer did not appraise are different facts and neither may be readable as |
| 89 | + the software-only case. |
| 90 | + - **An offer that is present and does not appraise is refused at every rung, |
| 91 | + including `"none"`.** Demanding nothing means accepting a caller that proves |
| 92 | + nothing; it does not mean accepting a broken proof. Without this a |
| 93 | + misconfigured attestation path is indistinguishable from a caller that never |
| 94 | + had one, which is how a control ends up switched off without anyone deciding |
| 95 | + to switch it off. |
| 96 | + - The outcome is **in the hashed record body**, always, including |
| 97 | + `not_offered`. Omitting it when nothing was appraised would leave an auditor |
| 98 | + unable to tell a peer that checked and found nothing from a peer that never |
| 99 | + checked. The cost is real and was accepted: it changes the hash of every |
| 100 | + record ever emitted, and the example DAGs were regenerated for it. |
74 | 101 | 3. ~~The response is sealed to the caller's attested key.~~ **Withdrawn on |
75 | 102 | 2026-08-09, before implementation.** The argument for it was that an appraised |
76 | 103 | key which is never used is ceremony. That was wrong on both halves. |
@@ -120,6 +147,49 @@ make the requirement configurable, and never let absence look like success. A |
120 | 147 | callee should be able to say "hardware or nothing" and should not say it by |
121 | 148 | default. |
122 | 149 |
|
| 150 | +## What was built |
| 151 | + |
| 152 | +```python |
| 153 | +from ca2a_runtime.node import PeerNode |
| 154 | +from ca2a_runtime.policy import LocalPolicy |
| 155 | +from ca2a_runtime.transport import client |
| 156 | +from ca2a_runtime.tee.software import SoftwareProvider |
| 157 | + |
| 158 | +# Callee: demands nothing, records everything (the default). |
| 159 | +node = PeerNode(LocalPolicy.of(["read"])) |
| 160 | + |
| 161 | +# Callee: opts in to strictness. |
| 162 | +node = PeerNode(LocalPolicy.of(["read"]), require_caller_attestation="any") |
| 163 | + |
| 164 | +# Caller: opts in to being appraised. |
| 165 | +client.send_task(base_url, chain, "read", "r0", |
| 166 | + payload=b"...", caller_provider=SoftwareProvider()) |
| 167 | +``` |
| 168 | + |
| 169 | +| Piece | Where | |
| 170 | +|---|---| |
| 171 | +| Stateless challenge (`v1.<expiry>.<random>.<mac>`) | `ca2a_runtime.challenge` | |
| 172 | +| Callee-side appraisal, challenge then offer | `attestation.appraise_caller` | |
| 173 | +| Requirement ladder and the refusal records | `peer.appraise_caller_runtime` | |
| 174 | +| Ordering: appraise before `open_sealed` | `peer.handle_peer_request` | |
| 175 | +| `caller_offer` on the wire | `transport.constants`, `transport.a2a_adapter` | |
| 176 | +| Challenge on the handshake response | `transport.server`, `transport.wire` | |
| 177 | +| Caller-side opt-in | `transport.client.send_task(caller_provider=...)` | |
| 178 | + |
| 179 | +Two consequences worth stating plainly, because neither is free: |
| 180 | + |
| 181 | +**Every record hash changed.** `caller_attestation` is in the hashed body of every |
| 182 | +`DelegationRecord`, so records emitted before this change do not hash to what they |
| 183 | +used to. The example DAGs under `examples/` were regenerated, and a record loaded |
| 184 | +without the field is read as `not_offered` -- the only thing its emitter could |
| 185 | +honestly have claimed. |
| 186 | + |
| 187 | +**A challenge does not cross instances.** The secret is per-process, so a callee |
| 188 | +behind a load balancer must pin the handshake and the task to one instance or |
| 189 | +share a secret between them. This is the stateless scheme's cost, chosen with |
| 190 | +open eyes over a challenge store; `test_a_challenge_from_another_instance_does_not_verify` |
| 191 | +holds the line. |
| 192 | + |
123 | 193 | ## What this still does not give you |
124 | 194 |
|
125 | 195 | **It is not simultaneous.** The caller appraises the callee, then the callee |
|
0 commit comments