aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/transport/quic_client.py
diff options
context:
space:
mode:
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.py96
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)