diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-13 12:24:54 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-13 12:24:54 +0200 |
| commit | 146a6759fa73386e9b59570956aeedd7e1cfd978 (patch) | |
| tree | 3485fa67219297e75abab0a23d2003a67b293ce2 /packages/meshbay-node/src/meshbay_node/transport/quic_client.py | |
| parent | 197f5e25893b845995853379125f607be18fc4e9 (diff) | |
| download | meshbay-146a6759fa73386e9b59570956aeedd7e1cfd978.tar.gz | |
feat(quic): GEK proof and mutual authentication — closes C6
Phase 11.5.4/5/6 — finding C6, the last open critical finding.
QUIC ran a JWT-only handshake: a forged or stolen token reached the node and
could inject chat without ever holding the group key. It now runs the same
challenge/response as WebRTC through meshbay_common.handshake — client nonce,
role-bound length-prefixed transcript, GEK proof, and the node proving itself
with a GEK proof plus an Ed25519 signature over the transcript (C3).
11.5.6 channel binding, resolved by spike and then by two findings the spike
could not predict:
* aioquic 1.3.0 exposes no RFC 5705 exporter, and the peer certificate only
via a private attribute. The server reads its own certificate from disk, so
no internals are touched on that side; the client's access is guarded and
fails loudly if an upgrade moves it.
* A RESUMED TLS session carries no certificate — aioquic does not re-send it,
so there is nothing live to bind to. The anchor therefore travels with the
session ticket, which is sound because the ticket is cryptographically
derived from the handshake where that certificate was presented.
* The anchor had to travel with the ticket rather than live on the client
object: resumption constructs a fresh client, so an instance-level cache
was silently useless. Caught by the resumption test, not by inspection.
Both paths refuse rather than degrade. No certificate and no cached anchor
means the handshake fails; it never falls back to an unbound proof, which would
silently drop MitM detection (L4).
QuicChunkClient gains a peer_cert_der property and constructor argument,
mirroring how session_ticket is already carried by the caller.
Tests: 9 quic/multi-group, full node+common suite green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport/quic_client.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/quic_client.py | 96 |
1 files changed, 89 insertions, 7 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_client.py b/packages/meshbay-node/src/meshbay_node/transport/quic_client.py index 288465f..9102085 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/quic_client.py +++ b/packages/meshbay-node/src/meshbay_node/transport/quic_client.py @@ -11,6 +11,7 @@ TLS cert is self-signed; we use CERT_NONE equivalent in QUIC config. import asyncio import base64 import logging +import os import struct from pathlib import Path @@ -26,6 +27,32 @@ from meshbay_common import MNP_VERSION from meshbay_common.crypto import verify_chunk_signature from meshbay_common.webcrypto import chunk_key_aes as derive_chunk_key, decrypt_chunk_aes as decrypt_chunk from meshbay_common.protocol import MNP +from meshbay_common.handshake import ( + NONCE_LEN, + ROLE_CLIENT, + ROLE_NODE, + handshake_transcript, + make_proof, + quic_binding, + verify_proof, +) + + +def _peer_cert_der(proto) -> bytes | None: + """ + The server certificate as seen by the client — the channel-binding anchor. + + Spike 11.5.6: aioquic 1.3.0 exposes no RFC 5705 exporter, and the peer + certificate only through a private attribute. Returns None when it is absent; + callers decide, because absence is not always an error — see below. + """ + from cryptography.hazmat.primitives import serialization + + tls = getattr(getattr(proto, "_quic", None), "tls", None) + cert = getattr(tls, "_peer_certificate", None) if tls is not None else None + if cert is None: + return None + return cert.public_bytes(serialization.Encoding.DER) log = logging.getLogger(__name__) @@ -99,6 +126,7 @@ class QuicChunkClient: pk_node_b64: str, local_port: int = 0, # 0 = OS picks; set for hole punching (Port-Restricted) group_id: str = "", + peer_cert_der: bytes | None = None, session_ticket: object | None = None, ): self._host = host @@ -112,6 +140,9 @@ class QuicChunkClient: self._proto: _MNPClientProtocol | None = None self._cm = None self._ctrl_stream = 0 + # 11.5.6 binding anchor. Travels with the session ticket: on a resumed + # TLS session the server does not re-send its certificate. + self._peer_cert_der: bytes | None = peer_cert_der self._session_ticket = session_ticket async def __aenter__(self): @@ -146,19 +177,70 @@ class QuicChunkClient: ) self._proto = await self._cm.__aenter__() - handshake_msg = { - "type": MNP.HANDSHAKE, + nonce_c = os.urandom(NONCE_LEN) + self._proto._send(self._ctrl_stream, { + "type": MNP.HANDSHAKE, + "v": MNP_VERSION, + "token": self._jwt_token, + "group_id": self._group_id, + "nonce": base64.b64encode(nonce_c).decode(), + }) + + reply = await self._proto._recv(self._ctrl_stream) + if reply.get("type") != MNP.HANDSHAKE_CHALLENGE: + raise ConnectionError(f"QUIC handshake rejected: {reply}") + + nonce_s = base64.b64decode(reply["nonce"]) + + # On a RESUMED TLS session the server does not re-send its certificate, so + # there is nothing live to bind to. The session ticket is cryptographically + # derived from the original handshake, so binding to the certificate seen + # then is sound — but only if we actually saw one. We never fall back to an + # unbound proof: that would silently drop MitM detection (L4). + cert_der = _peer_cert_der(self._proto) + if cert_der is not None: + self._peer_cert_der = cert_der + elif getattr(self, "_peer_cert_der", None) is None: + raise ConnectionError( + "QUIC peer certificate unavailable and none cached from a prior " + "session — refusing to handshake without channel binding") + binding = quic_binding(self._peer_cert_der) + + self._proto._send(self._ctrl_stream, { + "type": MNP.HANDSHAKE_RESPONSE, "v": MNP_VERSION, - "token": self._jwt_token, - } - if self._group_id: - handshake_msg["group_id"] = self._group_id - self._proto._send(self._ctrl_stream, handshake_msg) + "proof": base64.b64encode(make_proof( + self._gek, ROLE_CLIENT, self._group_id, + nonce_c, nonce_s, binding)).decode(), + }) + ack = await self._proto._recv(self._ctrl_stream) if ack.get("type") != MNP.HANDSHAKE_ACK: raise ConnectionError(f"QUIC handshake rejected: {ack}") + + # Authenticate the node before trusting anything it serves (C3). + if not verify_proof( + self._gek, base64.b64decode(ack.get("proof", "")), ROLE_NODE, + self._group_id, nonce_c, nonce_s, binding, + ): + raise ConnectionError("Node failed to prove GEK possession") + + transcript = handshake_transcript( + ROLE_NODE, self._group_id, nonce_c, nonce_s, binding) + try: + Ed25519PublicKey.from_public_bytes( + base64.b64decode(ack["node_pk"]) + ).verify(base64.b64decode(ack["sig"]), transcript) + except Exception as exc: + raise ConnectionError(f"Node signature invalid: {exc}") from exc + log.debug("QUIC connected to %s:%d", self._host, self._port) + @property + def peer_cert_der(self) -> bytes | None: + """Binding anchor to carry alongside a saved session ticket (11.5.6).""" + return self._peer_cert_der + async def close(self) -> None: if self._cm: await self._cm.__aexit__(None, None, None) |