summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/quic_client.py96
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/quic_server.py94
2 files changed, 182 insertions, 8 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)
diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
index ba2d5c5..ed3925d 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
@@ -20,6 +20,7 @@ The transport is the only change — all crypto, auth, and message types stay th
import asyncio
import base64
import logging
+import os
import struct
import subprocess
from pathlib import Path
@@ -34,7 +35,17 @@ from aioquic.quic.events import QuicEvent, StreamDataReceived, StreamReset
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from meshbay_common import MNP_VERSION
-from meshbay_common.handshake import HandshakeError, authorize_token
+from meshbay_common.handshake import (
+ NONCE_LEN,
+ ROLE_CLIENT,
+ ROLE_NODE,
+ HandshakeError,
+ authorize_token,
+ handshake_transcript,
+ make_proof,
+ quic_binding,
+ verify_proof,
+)
from meshbay_common.crypto import (
sign_chunk,
pk_to_b64,
@@ -158,6 +169,9 @@ class _MNPServerProtocol(QuicConnectionProtocol):
self._user_id: str | None = None
self._group_id: str | None = None
self._buffers: dict[int, _StreamBuffer] = {}
+ self._nonce_client: bytes = b""
+ self._gek_challenge: bytes | None = None
+ self._pending = None
def quic_event_received(self, event: QuicEvent) -> None:
if isinstance(event, StreamDataReceived):
@@ -177,6 +191,8 @@ class _MNPServerProtocol(QuicConnectionProtocol):
try:
if mtype == MNP.HANDSHAKE:
self._do_handshake_sync(stream_id, msg)
+ elif mtype == MNP.HANDSHAKE_RESPONSE:
+ self._do_handshake_response_sync(stream_id, msg)
elif self._user_id is None:
self._send(stream_id, {"type": "error", "detail": "Handshake required"})
elif mtype == MNP.INDEX_SYNC:
@@ -222,6 +238,67 @@ class _MNPServerProtocol(QuicConnectionProtocol):
self._quic.close()
return
+ try:
+ self._nonce_client = base64.b64decode(msg.get("nonce", ""))
+ except Exception:
+ self._nonce_client = b""
+ if len(self._nonce_client) < NONCE_LEN:
+ self._send(stream_id, {"type": "error", "detail": "Client nonce required"})
+ self._quic.close()
+ return
+
+ gctx = self._ctx["groups"][peer.group_id] if "groups" in self._ctx else self._ctx
+ if not gctx.get("gek"):
+ self._send(stream_id, {
+ "type": "error",
+ "detail": "Group encryption not initialized — contact node operator",
+ })
+ self._quic.close()
+ return
+
+ # Decoded but NOT authenticated: authentication is the GEK proof below.
+ self._pending = peer
+ self._gek_challenge = os.urandom(NONCE_LEN)
+ self._send(stream_id, {
+ "type": MNP.HANDSHAKE_CHALLENGE,
+ "v": MNP_VERSION,
+ "nonce": base64.b64encode(self._gek_challenge).decode(),
+ })
+
+ def _do_handshake_response_sync(self, stream_id: int, msg: dict) -> None:
+ """Verify the client's GEK proof, then prove the node in return (C6, C3)."""
+ if not self._gek_challenge or self._pending is None:
+ self._send(stream_id, {"type": "error", "detail": "No pending handshake challenge"})
+ return
+
+ peer = self._pending
+ gctx = self._ctx["groups"][peer.group_id] if "groups" in self._ctx else self._ctx
+ gek = gctx.get("gek")
+ if not gek:
+ self._send(stream_id, {"type": "error", "detail": "Group encryption not initialized"})
+ self._quic.close()
+ return
+
+ binding = self._ctx.get("server_cert_der")
+ if not binding:
+ # Refuse rather than fall back to an unbound proof (L4).
+ self._send(stream_id, {"type": "error", "detail": "Channel binding unavailable"})
+ self._quic.close()
+ return
+ binding = quic_binding(binding)
+
+ try:
+ proof = base64.b64decode(msg.get("proof", ""))
+ except Exception:
+ self._send(stream_id, {"type": "error", "detail": "Invalid proof encoding"})
+ return
+
+ if not verify_proof(gek, proof, ROLE_CLIENT, peer.group_id,
+ self._nonce_client, self._gek_challenge, binding):
+ self._send(stream_id, {"type": "error", "detail": "GEK proof failed"})
+ self._quic.close()
+ return
+
self._user_id = peer.user_id
self._group_id = peer.group_id
@@ -229,13 +306,21 @@ class _MNPServerProtocol(QuicConnectionProtocol):
if peers is not None:
peers[self._user_id] = self
+ transcript = handshake_transcript(
+ ROLE_NODE, peer.group_id, self._nonce_client, self._gek_challenge, binding)
+ node_proof = make_proof(
+ gek, ROLE_NODE, peer.group_id, self._nonce_client, self._gek_challenge, binding)
+
log.info("QUIC handshake OK — user=%s group=%s",
self._user_id[:8], self._group_id[:8])
self._send(stream_id, {
"type": MNP.HANDSHAKE_ACK,
"v": MNP_VERSION,
"node_pk": pk_to_b64(self._ctx["sk_node"].public_key()),
+ "proof": base64.b64encode(node_proof).decode(),
+ "sig": base64.b64encode(self._ctx["sk_node"].sign(transcript)).decode(),
})
+ self._gek_challenge = None
def _group_ctx(self) -> dict:
"""Resolve the active group context (multi-group or legacy single-group)."""
@@ -459,6 +544,13 @@ class QuicChunkServer:
generate_self_signed_cert(self._cert_path, self._key_path)
config = QuicConfiguration(is_client=False, alpn_protocols=ALPN)
config.load_cert_chain(str(self._cert_path), str(self._key_path))
+
+ # Channel-binding anchor for the handshake proof (11.5.6). Read from our own
+ # cert file — no aioquic internals needed on this side.
+ from cryptography import x509
+ from cryptography.hazmat.primitives import serialization as _ser
+ self._ctx["server_cert_der"] = x509.load_pem_x509_certificate(
+ self._cert_path.read_bytes()).public_bytes(_ser.Encoding.DER)
return config
def _store_ticket(self, ticket: Any) -> None: