Skip to content

Add single-round-trip mutual attestation - #7

Draft
atulpatildbz wants to merge 1 commit into
google:mainfrom
atulpatildbz:mutual-verification-single-rtt
Draft

Add single-round-trip mutual attestation#7
atulpatildbz wants to merge 1 commit into
google:mainfrom
atulpatildbz:mutual-verification-single-rtt

Conversation

@atulpatildbz

@atulpatildbz atulpatildbz commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Simplifies the mutual attestation design from #6 down to a single round trip.

Both endpoints verify each other over one /_attest-connection exchange 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.

Client (TEE)                                        Server (TEE)
|                                                              |
| [1] Standard TLS 1.3 handshake                               |
|<============================================================>|
+--[2] EKM = export_keying_material(label, 32)   # no context
+--[3] Proof_client = Sign(K_client, {SHA256(EKM), SHA256(T_client),
|                                     role=CLIENT, mode=MUTUAL})
| [4] POST /_attest-connection { mode=MUTUAL, Proof_client }   |
|------------------------------------------------------------>|
|                                 +--[5] EKM derived independently
|                                 +--[6] Verify Proof_client, else reject
|                                 +--[7] Proof_server = Sign(K_server, {...
|                                 |         role=SERVER, mode=MUTUAL})
| [8] { Proof_server, mode=MUTUAL, mutual_attestation_complete }|
|<------------------------------------------------------------|
+--[9] Verify the echoed mode, then verify Proof_server        |
| [10] Verified E2E encrypted application data                 |
|=============================================================>|

What this removes vs #6

Both nonces, handshake_id, MutualAttestationTranscript, the CLIENT_FINISH phase, and the entire WeakKeyDictionary of 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_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 (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:

  • Clientrevalidate_session() raises, the lazy revalidation timer is disabled, and revalidation_timeout together with mutual_attestation is a ValueError.
  • Server — a repeat /_attest-connection on an attested session gets 403 + 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.md documents it explicitly.

Testing

tests/mutual_attestation_tls_integration_test.py drives 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.py adds adversarial cases with real keys, all rejected:

  • server proof reflected as a client proof
  • client proof replayed as a server proof
  • a server-only proof offered for a mutual check
  • a proof bound to a different TLS session
  • a key not covered by the token's eat_nonce
  • tampered ECDSA and tampered ML-DSA signatures

Plus unit coverage across the shared protocol module, AttestedTLS, the client connection, both middlewares, and the client facade.

205 passed. Rebased onto 793e619, which fixes the compare helper and the uvicorn "auto" interface baseline failures; this branch no longer carries its own fixes for either. I separately confirmed h11 accepts the ASGI Connection: close and transitions to MUST_CLOSE, since that path has no live-server test.

Wire compatibility

Every new proto field is added, none renumbered. AttestationMode's zero value is SERVER_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 unless require_mutual_attestation is set.

Known issue left alone

_process_attestation_response catches its own AttestationHandshakeError and re-raises it as "Connection error reading response", hiding the server's status code — so the new 403 drop 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

@atulpatildbz
atulpatildbz force-pushed the mutual-verification-single-rtt branch 3 times, most recently from 2a1da8e to 84f33ae Compare August 25, 2026 14:37
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
atulpatildbz force-pushed the mutual-verification-single-rtt branch from 84f33ae to c0e41f4 Compare August 25, 2026 14:40
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