diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node')
5 files changed, 162 insertions, 50 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 3a024f0..7341423 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -37,7 +37,7 @@ from pathlib import Path import uvicorn from meshbay_common import MNP_VERSION -from meshbay_common.protocol import MNP, index_entry_wire +from meshbay_common.protocol import MNP from meshbay_node.audit import AuditStore from meshbay_node.bundle_store import BundleStore from meshbay_node.chat.store import ChatStore @@ -58,6 +58,7 @@ from meshbay_node.transport import ( QUIC_AVAILABLE, WEBRTC_AVAILABLE, ) +from meshbay_node.transport.wire import index_delta_message, index_sync_message if QUIC_AVAILABLE: from meshbay_node.transport import QuicChunkServer @@ -1003,6 +1004,13 @@ class NodeDaemon: def _push_index_progress(self, group_id: str, progress) -> None: if not self._webrtc: return + # Deliberately NOT sealed, unlike index_sync/index_delta (decision D3). + # Counters only — never a path, never a filename, see IndexProgress in + # indexer.py — pushed every couple of seconds for the whole length of a + # scan. Sealing it would buy an attacker's rough estimate of a library's + # size and cost a key derivation and a decrypt per push. If a field that + # names anything is ever added here, that trade is void and this message + # joins the other two. msg = { "type": MNP.INDEX_PROGRESS, "v": MNP_VERSION, @@ -1135,36 +1143,25 @@ class NodeDaemon: # 11.5 — Push to connected WebRTC peers in this group if self._webrtc: - if delta is not None: - msg = { - "type": MNP.INDEX_DELTA, - "v": MNP_VERSION, - "group_id": idx.group_id, - "base_version": delta.base_version, - "version": delta.version, - "additions": [index_entry_wire(e) for e in delta.additions], - "deletions": delta.deletions, - "updates": [index_entry_wire(e) for e in delta.updates], - } - else: - msg = { - "type": MNP.INDEX_SYNC, - "v": MNP_VERSION, - "group_id": idx.group_id, - "version": idx.version, - "entries": [index_entry_wire(e) for e in idx.entries], - } - pushed = 0 - for session in list(self._webrtc._sessions.values()): - if session._group_id == group_id: + peers = [s for s in list(self._webrtc._sessions.values()) + if s._group_id == group_id] + # Both messages are sealed under a GEK-derived subkey, so building one + # needs a key. A group without one has no peers to push to either — the + # node refuses every handshake while the GEK is None (NS8) — so this is + # "nobody is listening", not a case to send in clear for. + if peers and idx.gek: + msg = (index_delta_message(idx, delta) if delta is not None + else index_sync_message(idx, indexer.roots)) + pushed = 0 + for session in peers: try: session._send(msg) pushed += 1 except Exception: pass - if pushed: - log.info("Index %s pushed to %d WebRTC peers", - "delta" if delta is not None else "sync", pushed) + if pushed: + log.info("Index %s pushed to %d WebRTC peers", + "delta" if delta is not None else "sync", pushed) # 11.9 — Register file hashes with hub swarm table (public groups only, H7) group_cfg = next( 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: """ 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 8a32538..6dde3ff 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py @@ -36,17 +36,20 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from meshbay_common import MNP_VERSION from meshbay_node.roots import RootSet, entry_abs_path from meshbay_common.handshake import ( + MNP_MIN_SUPPORTED, NONCE_LEN, ROLE_CLIENT, ROLE_NODE, HandshakeError, authorize_token, + check_version, handshake_transcript, make_proof, quic_binding, verify_proof, ) from meshbay_common.crypto import pk_to_b64 +from meshbay_common.groupbox import PURPOSE_ACK, seal from meshbay_common.protocol import MNP, file_chunk_wire from meshbay_node.indexer import GroupIndex from meshbay_node.transport.wire import index_sync_message @@ -269,6 +272,16 @@ class _MNPServerProtocol(QuicConnectionProtocol): module, bound to the QUIC certificate hash. Finding C6 is closed on this transport. """ + # Same order as WebRTC: the range first, so a peer we cannot speak to is + # told so, rather than served messages it will misread (L2). + try: + check_version(msg.get("v", ""), msg.get("v_min", "")) + except HandshakeError as refusal: + self._send(stream_id, {"type": "error", "detail": str(refusal), + "code": refusal.code}) + self._quic.close() + return + try: peer = authorize_token( msg.get("token", ""), @@ -278,7 +291,8 @@ class _MNPServerProtocol(QuicConnectionProtocol): denylist=self._ctx.get("denylist"), ) except HandshakeError as refusal: - self._send(stream_id, {"type": "error", "detail": str(refusal)}) + self._send(stream_id, {"type": "error", "detail": str(refusal), + "code": refusal.code}) self._quic.close() return @@ -306,6 +320,7 @@ class _MNPServerProtocol(QuicConnectionProtocol): self._send(stream_id, { "type": MNP.HANDSHAKE_CHALLENGE, "v": MNP_VERSION, + "v_min": MNP_MIN_SUPPORTED, "nonce": base64.b64encode(self._gek_challenge).decode(), }) @@ -355,12 +370,19 @@ class _MNPServerProtocol(QuicConnectionProtocol): log.info("QUIC handshake OK — user=%s group=%s", self._user_id[:8], self._group_id[:8]) + # The config payload is empty here — QUIC serves no browser, so none of + # the fields WebRTC carries has a consumer on this transport. It is sealed + # anyway (decision D5): one shape per message on every transport, which is + # the lesson of the two `file_chunk` encoders and the two `index_sync` + # encodings. A field added later then has somewhere to go that is already + # authenticated, instead of arriving in clear beside a sealed one. 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(), + **seal(gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, peer.group_id, {}), }) self._gek_challenge = None diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 370a8b4..0154319 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -45,11 +45,13 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import ( from meshbay_common import MNP_VERSION from meshbay_common.handshake import ( + MNP_MIN_SUPPORTED, NONCE_LEN, ROLE_CLIENT, ROLE_NODE, HandshakeError, authorize_token, + check_version, handshake_transcript, make_proof, verify_proof, @@ -87,6 +89,7 @@ from meshbay_common.device import ( device_code_hash, device_request_transcript, ) +from meshbay_common.groupbox import PURPOSE_ACK, seal from meshbay_common.join import ( JOIN_TTL, ROLE_MEMBER, @@ -609,6 +612,15 @@ class WebRTCPeerSession: group_id = msg.get("group_id", "") log.info("WebRTC handshake request: group=%s (peer=%s)", group_id[:8] if group_id else "none", self._peer_id) + # Before the token, and before anything is decided from it: a peer we + # cannot speak to is refused with a code it can act on, rather than + # served messages it will misread as missing fields (L2). + try: + check_version(msg.get("v", ""), msg.get("v_min", "")) + except HandshakeError as refusal: + self._send({"type": "error", "detail": str(refusal), + "code": refusal.code}) + return try: peer = authorize_token( msg.get("token", ""), @@ -655,6 +667,9 @@ class WebRTCPeerSession: self._send({ "type": MNP.HANDSHAKE_CHALLENGE, "v": MNP_VERSION, + # Our half of the range. The client refuses us on this rather than + # discovering the mismatch when a field it expected is not there. + "v_min": MNP_MIN_SUPPORTED, "nonce": base64.b64encode(self._gek_challenge).decode(), # Announced here because a first-time joiner needs it *before* the # ack: join_request signs a transcript naming this node, and someone @@ -729,13 +744,15 @@ class WebRTCPeerSession: gek, ROLE_NODE, self._group_id or "", self._nonce_client, self._gek_challenge or b"", binding) - ack = { - "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(node_transcript)).decode(), + # Everything the client needs in order to *authenticate* us stays in clear — + # node_pk, proof and sig are what it checks before it would trust a + # decryption, so they cannot themselves be behind one. The configuration + # below is sealed under a GEK-derived subkey, which gives it an + # authentication tag from a key the hub does not hold. Until MNP 1.0 the + # signed transcript named no ack field at all, so is_node_admin, + # enabled_apps, video_root and the rest were authenticated by the DTLS + # channel and nothing else. + config = { "is_node_admin": self._is_node_admin(), # So the interface knows whether to offer uploading at all. Not a # permission — the node refuses regardless — but without it the @@ -790,10 +807,20 @@ class WebRTCPeerSession: }, } if node_user_id: - ack["node_user_id"] = node_user_id + config["node_user_id"] = node_user_id pk_x_b64 = self._ctx.get("pk_x25519_b64") if pk_x_b64: - ack["node_pk_x25519"] = pk_x_b64 + config["node_pk_x25519"] = pk_x_b64 + + ack = { + "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(node_transcript)).decode(), + **seal(gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, self._group_id or "", config), + } self._send(ack) self._audit("handshake") diff --git a/packages/meshbay-node/src/meshbay_node/transport/wire.py b/packages/meshbay-node/src/meshbay_node/transport/wire.py index 4e09167..c683204 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/wire.py +++ b/packages/meshbay-node/src/meshbay_node/transport/wire.py @@ -14,12 +14,22 @@ consumer each and nothing asserting they matched. Same failure mode as the two `GroupIndex.serialize()`/`deserialize()` are unchanged and still tested — they remain a correct signed index envelope — but they no longer describe any MNP message. Read -them as an at-rest/interchange format, not as a wire contract. +them as an at-rest/interchange format, not as a wire contract. It is also not a +candidate for reuse below: it compresses with zstd, which no browser can decompress +(`DecompressionStream` offers gzip and deflate only). + +Since MNP 1.0 both messages carry their payload **sealed under a GEK-derived subkey** +(`meshbay_common.groupbox`). Only the routing fields — `type`, `v`, `group_id` — stay +in clear: a receiver must route and version-check before it can decrypt, and +`group_id` is the AAD and selects the key besides. `version`/`base_version` moved +*inside* the payload; there is no reason to act on a version number carried by a +message we have not yet authenticated. """ from __future__ import annotations from meshbay_common import MNP_VERSION +from meshbay_common.groupbox import PURPOSE_INDEX, seal from meshbay_common.protocol import MNP, index_entry_wire from meshbay_node.roots import RootSet @@ -60,17 +70,43 @@ def index_sync_message(index, roots: RootSet | None) -> dict: """ The full `index_sync` message for one group. - `dirs` and `roots` are here because directories are not index entries: without - them a folder someone just created, or one they emptied, does not exist as far as - a client is concerned, and a member cannot tell "the drive is unplugged" from "it - is all still there". + `dirs` and `roots` are in the payload because directories are not index entries: + without them a folder someone just created, or one they emptied, does not exist as + far as a client is concerned, and a member cannot tell "the drive is unplugged" + from "it is all still there". """ - return { - "type": MNP.INDEX_SYNC, - "v": MNP_VERSION, - "group_id": index.group_id, + payload = { "version": index.version, "entries": [index_entry_wire(e) for e in index.entries], "dirs": list_dirs(roots), "roots": roots.describe() if roots else [], } + return { + "type": MNP.INDEX_SYNC, + "v": MNP_VERSION, + "group_id": index.group_id, + **seal(index.gek, PURPOSE_INDEX, MNP.INDEX_SYNC, index.group_id, payload), + } + + +def index_delta_message(index, delta) -> dict: + """ + One `index_delta` — what changed since the last thing this node broadcast. + + Built here rather than inline in the daemon, which is where it lived and which + made it the third place an index message was constructed: precisely the drift + that produced two `index_sync` encodings and two `file_chunk` encodings before it. + """ + payload = { + "base_version": delta.base_version, + "version": delta.version, + "additions": [index_entry_wire(e) for e in delta.additions], + "deletions": list(delta.deletions), + "updates": [index_entry_wire(e) for e in delta.updates], + } + return { + "type": MNP.INDEX_DELTA, + "v": MNP_VERSION, + "group_id": index.group_id, + **seal(index.gek, PURPOSE_INDEX, MNP.INDEX_DELTA, index.group_id, payload), + } |