Add single-round-trip mutual attestation - #7
Draft
atulpatildbz wants to merge 1 commit into
Draft
Conversation
atulpatildbz
force-pushed
the
mutual-verification-single-rtt
branch
3 times, most recently
from
August 25, 2026 14:37
2a1da8e to
84f33ae
Compare
This CL lets both endpoints verify each other over one /_attest-connection exchange, rather than the two flights the earlier mutual-attestation design required. The client exports the TLS session EKM, signs its own proof over it, and carries that proof in the request; the server verifies it before producing anything, then answers with its own proof, which the client verifies against the same EKM. Neither peer contributes a nonce. The EKM already derives from both peers' handshake randoms, so it is unique and unforgeable per session and serves as the freshness challenge directly. Dropping the nonces is what collapses the exchange to one round trip, and it removes the per-connection pending-handshake state the two-flight design had to keep between flights. Because both sides now sign the same value, peer_role and mode (plus protocol_version) inside SessionSignaturePayload provide the domain separation the separate nonces used to: a proof cannot be reflected back and accepted as the other side's, and a server-only proof cannot satisfy a mutual check. All three fields sit at their defaults for server-only proofs, so proto3 omits them and the existing signed payload is byte-for-byte unchanged. The binding is constant for the lifetime of the session, so re-attesting it would prove nothing new. Mutual sessions are therefore attested exactly once: the client refuses to revalidate, and the server answers a repeat attestation with 403 plus Connection: close and revokes the session's authorization. A failed exchange does not consume the session, so a rejected client may retry. Re-attestation means opening a new connection. The client shows its evidence before it can verify the server, which is the cost of a single round trip; MUTUAL_ATTESTATION.md documents the trade-off.
atulpatildbz
force-pushed
the
mutual-verification-single-rtt
branch
from
August 25, 2026 14:40
84f33ae to
c0e41f4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Simplifies the mutual attestation design from #6 down to a single round trip.
Both endpoints verify each other over one
/_attest-connectionexchange instead of two flights. The client exports the TLS session EKM, signs its own proof over it, and carries that proof in the request; the server verifies it before producing anything, then answers with its own proof, which the client verifies against the same EKM.What this removes vs #6
Both nonces,
handshake_id,MutualAttestationTranscript, theCLIENT_FINISHphase, and the entireWeakKeyDictionaryof pending verifications with its handshake timeout.EKM as the nonce
Neither peer contributes a nonce. The EKM already derives from both peers' handshake randoms, so it is unique and unforgeable per session and serves as the freshness challenge directly. Dropping the nonces is what collapses the exchange to one round trip, and it removes the per-connection state the two-flight design had to keep between flights.
Because both sides now sign the same value,
peer_role+mode+protocol_versioninsideSessionSignaturePayloadprovide the domain separation the separate nonces used to: a proof cannot be reflected back and accepted as the other side's, and a server-only proof cannot satisfy a mutual check.All three fields sit at their defaults for server-only proofs, so proto3 omits them and the existing signed payload is byte-for-byte unchanged (pinned by a test).
No revalidation
The binding is constant for the lifetime of the TLS session, so re-attesting it would re-sign an identical payload and prove nothing new. Mutual sessions are attested exactly once:
revalidate_session()raises, the lazy revalidation timer is disabled, andrevalidation_timeouttogether withmutual_attestationis aValueError./_attest-connectionon an attested session gets403+Connection: close, and the session's authorization is revoked so no further application traffic is served on it.A failed exchange does not consume the session, so a client rejected on policy may retry. Re-attestation means opening a new connection, which brings a new EKM and a genuinely fresh challenge.
Trade-off worth reviewing
Collapsing to one round trip means the client cannot verify the server before sending its own proof, so whoever terminates the TLS session sees the client's GCA token. It travels inside the tunnel (after normal cert validation), is bound to this session's EKM, and is role-bound — but this is a real ordering change from #6, and
MUTUAL_ATTESTATION.mddocuments it explicitly.Testing
tests/mutual_attestation_tls_integration_test.pydrives the real client connection class against the real WSGI middleware over a genuine TLS socket, so the EKM both peers bind to comes from OpenSSL rather than a stub, and every ECDSA / ML-DSA signature is really produced and really verified. Only GCA token issuance and its OIDC verification are faked. It covers the happy path, policy rejection, server-only rejection when mutual is required, the replay drop (confirming the server actually closes the connection), and reconnect-to-re-attest.tests/mutual_attestation_test.pyadds adversarial cases with real keys, all rejected:eat_noncePlus unit coverage across the shared protocol module,
AttestedTLS, the client connection, both middlewares, and the client facade.205 passed. Rebased onto 793e619, which fixes thecomparehelper and the uvicorn"auto"interface baseline failures; this branch no longer carries its own fixes for either. I separately confirmed h11 accepts the ASGIConnection: closeand transitions toMUST_CLOSE, since that path has no live-server test.Wire compatibility
Every new proto field is added, none renumbered.
AttestationMode's zero value isSERVER_ONLY, so an existing client's request parses as before and its signed payload is unchanged. A server built from this SDK keeps serving server-only clients unlessrequire_mutual_attestationis set.Known issue left alone
_process_attestation_responsecatches its ownAttestationHandshakeErrorand re-raises it as"Connection error reading response", hiding the server's status code — so the new403drop surfaces with a misleading message. An existing test pins that behavior, so it is out of scope here; happy to fix separately.🤖 Generated with Claude Code
https://claude.ai/code/session_019jTWyALhkXbjNNibu82R6j