Skip to content

feat(ca2a): appraise the caller before opening its payload - #90

Merged
imran-siddique merged 1 commit into
mainfrom
feat/mutual-attestation-transport
Aug 10, 2026
Merged

feat(ca2a): appraise the caller before opening its payload#90
imran-siddique merged 1 commit into
mainfrom
feat/mutual-attestation-transport

Conversation

@imran-siddique

Copy link
Copy Markdown
Member

Finishes the mutual attestation work designed in #89. That PR landed the design and the stateless challenge primitive; this threads it through the transport so it actually runs.

What changed

The callee verified the caller's delegation chain (authorization: what the peer is allowed to ask for) and never appraised what the caller was running. A callee had no way to know whether the peer sending it a task was an enclave or a laptop.

Now: the handshake response carries a callee-issued challenge, the caller binds its own channel key into a report under it (caller_offer in the A2A metadata), and the callee appraises that before open_sealed.

caller                                   callee
  │  GET /channel?nonce=N  ─────────────▶
  │  ◀── ChannelOffer(N) + challenge C
  │  appraise callee, seal payload, attest own key under C
  │  POST /task + caller_offer  ────────▶
  │                                       appraise caller BEFORE opening payload
  │  ◀─── provenance record (readable, so it can be chained)

The ordering is the property

The payload is sealed to the callee's own channel key, so the callee can read it the instant it arrives. Appraising afterwards means an unattested caller has already had its work done, and every guarantee becomes a report on something that already happened.

test_sealed_payload_is_never_opened_when_appraisal_refuses asserts open_sealed is never called when appraisal refuses. I verified the test by making the swap: moving the open-payload block above the appraisal (a valid, plausible refactor) fails exactly those 3 cases with the callee opened the payload before appraising the caller, and nothing else. A first attempt at the mutation failed with an UnboundLocalError instead, which would have proven nothing, so it was redone properly.

Decisions taken (Imran, 2026-08-09)

Decision Choice
Requirement knob Three-rung ladder require_caller_attestation: "none" (default) → "any""hardware"
Offer present but invalid, at require="none" Fail closed always
Outcome in the hashed record body Always, including not_offered

Default demands nothing, because cA2A is alpha, almost no caller can attest, and a control that breaks the common case gets switched off and never switched back on. "hardware" without a caller_verifier is refused at construction rather than on every call.

A present and unappraisable offer is refused at every rung including "none". Demanding nothing means accepting a caller that proves nothing; it does not mean accepting a broken proof, or a misconfigured attestation path becomes indistinguishable from a caller that never had one.

⚠️ Breaking: every record hash changed

caller_attestation is in the hashed DelegationRecord body, always, as one of not_offered / failed / software-only / hardware. Four values, not three: a peer that offered nothing and a peer whose offer did not appraise are different facts, and neither may read as the software-only case.

Records predating the field are read as not_offered. The example DAGs are regenerated.

Two gaps found on the way

  • The 1 MiB body bound had no test above it. Now declares an oversized Content-Length over a raw socket, which is what the guard actually inspects (it refuses before reading, so the body is never buffered). Sending a real 1 MiB body races the early refusal and the client sees a connection abort, which is why it went through a socket.
  • The committed example DAGs were never verified as committed. The demos regenerate chain.json/dag.json on run, so the existing tests were checking the demo's own fresh output. Not hypothetical: this change left examples/cross-operator-delegation/dag.json broken on disk with the whole suite green and the README still quoting ca2a verify-dag as working. test_committed_examples_verify.py reads the blobs out of git instead.

What this still does not give you

Unchanged, and stated in the spec and LIMITATIONS.md: it is not simultaneous (the caller commits a sealed payload before the callee has appraised it — that needs a commitment step, a larger protocol), it does not make either peer trustworthy, and it is software mode only. Making the protocol mutual does not make the recorded hardware run mutual.

Also: a challenge does not cross instances (per-process secret, the stateless scheme's accepted cost). test_a_challenge_from_another_instance_does_not_verify holds that line.

Tests

367 passed, 3 skipped. 35 new in test_mutual_attestation.py, plus the two guards above. ruff check and mypy clean.

🤖 Generated with Claude Code

The reference transport was one-directional. The callee verified the
caller's delegation chain, which says what a peer is allowed to ask for,
and had no way to know whether the peer sending it a task was an enclave
or a laptop.

The handshake response now carries a callee-issued challenge, the caller
binds its own channel key into a report under it (caller_offer in the A2A
metadata), and the callee appraises that report before it opens the
sealed payload. The ordering is the property, not an implementation
detail: the payload is sealed to the callee's own key, so appraising
afterwards would mean an unattested caller had already had its work done.
The test for it was verified by making the swap and watching it fail.

Off by default, opt-in one rung at a time (require_caller_attestation:
none -> any -> hardware), because almost no caller can attest yet and a
callee that refused them out of the box is a callee nobody can talk to.
An offer that is present and does not appraise is refused at every rung
including "none": demanding nothing means accepting a caller that proves
nothing, not accepting a broken proof.

BREAKING: caller_attestation is in the hashed record body, always, so
every record hash changes. Absence would leave an auditor unable to tell
a peer that checked and found nothing from a peer that never checked.
The example DAGs are regenerated.

Also closes two gaps found on the way: the 1 MiB request-body bound had
no test above it, and the committed example DAGs were never verified as
committed (the demos regenerate them, so the suite was checking its own
output -- which is how the record-body change left cross-operator/dag.json
broken on disk with everything still green).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant