diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-03 16:16:55 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-03 16:16:55 +0200 |
| commit | 675beed6ff688733a9598f9d82d41578f48316be (patch) | |
| tree | 78dd4f8dff312f0ad99bd63bc679bf402591c5ed /packages/meshbay-node/src/meshbay_node/transport/quic_client.py | |
| parent | 15087b0e8fdb872602310119f14680aaa443fd93 (diff) | |
| download | meshbay-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.py | 40 |
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: """ |