aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/transport/quic_client.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-03 16:16:55 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-03 16:16:55 +0200
commit675beed6ff688733a9598f9d82d41578f48316be (patch)
tree78dd4f8dff312f0ad99bd63bc679bf402591c5ed /packages/meshbay-node/src/meshbay_node/transport/quic_client.py
parent15087b0e8fdb872602310119f14680aaa443fd93 (diff)
downloadmeshbay-675beed6ff688733a9598f9d82d41578f48316be.tar.gz
feat!: MNP 1.0 — seal index and handshake_ack under the group key
`index_sync`, `index_delta` and the `handshake_ack` config payload now travel sealed under a GEK-derived subkey (`meshbay_common/groupbox.py`, mirrored by `sealGroup`/`openGroup` in `crypto.js`). Only `type`, `v`, `group_id` and the ack's `node_pk`/`proof`/`sig` stay in clear — a receiver must route and authenticate before it would trust a decryption. Verify, then decrypt. The ack line is integrity, not confidentiality: the signed handshake transcript names no ack field, so `is_node_admin`, `enabled_apps`, `video_root` and the rest were authenticated by the DTLS channel alone. The index line is defence in depth against a repeat of C1/C6 — a peer served before the handshake completes now gets ciphertext, not filenames. Nothing against an observer, the hub, or a member; that is the whole claim. `index_progress` stays clear (D3, counters only). Chat is out of scope. Failure is fatal: a payload that does not open ends the session naming the message type — never an empty index or an empty `enabled_apps`, both of which are legitimate states. Version negotiation ships here too (phase 15.6, brought forward): `v` + `v_min` on `handshake` and `handshake_challenge`, refused with `version_too_old` / `version_too_new` / `version_unreadable`. The flag day was already being paid for; the next breaking change now costs a refusal message. BREAKING CHANGE: breaks the WebRTC wire every deployed client speaks. Hub and every node must deploy together; the SPA is served by the hub, so a browser picks up the new client on reload. See MESHBAY_NODE_PROTOCOL.md §11.1a, §13.1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HkzbhmMmK8PqQBtGz5zCvY
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.py40
1 files changed, 35 insertions, 5 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 4debd27..b22b8df 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/quic_client.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/quic_client.py
@@ -23,11 +23,14 @@ from aioquic.quic.events import QuicEvent, StreamDataReceived
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from meshbay_common import MNP_VERSION
+from meshbay_common.groupbox import PURPOSE_ACK, PURPOSE_INDEX, unseal
from meshbay_common.protocol import MNP, file_chunk_plaintext
from meshbay_common.handshake import (
+ MNP_MIN_SUPPORTED,
NONCE_LEN,
ROLE_CLIENT,
ROLE_NODE,
+ check_version,
handshake_transcript,
make_proof,
quic_binding,
@@ -141,6 +144,8 @@ class QuicChunkClient:
# TLS session the server does not re-send its certificate.
self._peer_cert_der: bytes | None = peer_cert_der
self._session_ticket = session_ticket
+ # The handshake_ack's sealed payload, once connect() has opened it.
+ self._node_config: dict = {}
async def __aenter__(self):
await self.connect()
@@ -178,6 +183,10 @@ class QuicChunkClient:
self._proto._send(self._ctrl_stream, {
"type": MNP.HANDSHAKE,
"v": MNP_VERSION,
+ # The oldest node this build can talk to. Declared in the first
+ # message so a mismatch is a refusal with a code, not a field that
+ # turns up missing three messages later (L2).
+ "v_min": MNP_MIN_SUPPORTED,
"token": self._jwt_token,
"group_id": self._group_id,
"nonce": base64.b64encode(nonce_c).decode(),
@@ -186,6 +195,8 @@ class QuicChunkClient:
reply = await self._proto._recv(self._ctrl_stream)
if reply.get("type") != MNP.HANDSHAKE_CHALLENGE:
raise ConnectionError(f"QUIC handshake rejected: {reply}")
+ # The node's half of the range, checked before we speak to it further.
+ check_version(reply.get("v", ""), reply.get("v_min", ""))
nonce_s = base64.b64decode(reply["nonce"])
@@ -231,6 +242,15 @@ class QuicChunkClient:
except Exception as exc:
raise ConnectionError(f"Node signature invalid: {exc}") from exc
+ # Verify, then decrypt — in that order, and it is not incidental. The
+ # proof and the signature above are what decide whether this peer is worth
+ # trusting at all; opening the payload first would mean acting on data from
+ # someone we have not authenticated. Empty on this transport today (D5),
+ # but it must still open: a payload that does not is a peer we cannot talk
+ # to, not a node with no configuration.
+ self._node_config = unseal(
+ self._gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, self._group_id, ack)
+
log.debug("QUIC connected to %s:%d", self._host, self._port)
@property
@@ -253,14 +273,24 @@ class QuicChunkClient:
"""
Request the Mesh Group Index.
- Returns the message itself — `{group_id, version, entries, dirs, roots}` —
- which is what the WebRTC client has always received. It used to return the
- bytes of a `GroupIndex.serialize()` envelope for the caller to deserialize:
- the same message type carrying a different encoding on this transport alone.
+ Returns `{group_id, version, entries, dirs, roots}` — the sealed payload
+ opened, with `group_id` from the envelope that carried it. It used to return
+ the bytes of a `GroupIndex.serialize()` envelope for the caller to
+ deserialize: the same message type carrying a different encoding on this
+ transport alone.
+
+ A payload that does not open raises. It is never an empty index — that is
+ indistinguishable from a group with no files, which is why a fallback here
+ would be worse than a stop (groupbox.py, §3.4).
"""
sid = self._new_stream()
self._proto._send(sid, {"type": MNP.INDEX_SYNC, "v": MNP_VERSION})
- return await self._proto._recv(sid)
+ msg = await self._proto._recv(sid)
+ if msg.get("type") == "error":
+ raise LookupError(msg.get("detail", "index_sync refused"))
+ payload = unseal(
+ self._gek, PURPOSE_INDEX, MNP.INDEX_SYNC, self._group_id, msg)
+ return {"group_id": msg.get("group_id", self._group_id), **payload}
async def fetch_chunk(self, file_id: str, chunk_index: int) -> bytes:
"""