From 6abb68ae95f6c4da4a66453398006183a73db9d9 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 9 Aug 2026 04:11:00 +0200 Subject: feat(node): add TCP+TLS chunk server and client (MNP v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-signed TLS cert (RSA-2048, TLS 1.3 min). Server: JWT offline verify, index sync, file_request → encrypt+sign chunk pipeline. Client: handshake, fetch_index, fetch_chunk with Ed25519 verify + blake3 hash check + GEK decrypt. Integration test: 2MB file served in 2 chunks, reassembled == original. 3/3 tests. Full suite: 29/29. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../src/meshbay_node/transport/client.py | 144 +++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 packages/meshbay-node/src/meshbay_node/transport/client.py (limited to 'packages/meshbay-node/src/meshbay_node/transport/client.py') diff --git a/packages/meshbay-node/src/meshbay_node/transport/client.py b/packages/meshbay-node/src/meshbay_node/transport/client.py new file mode 100644 index 0000000..3430365 --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/transport/client.py @@ -0,0 +1,144 @@ +""" +MeshBay — TCP+TLS chunk client (MNP v1). + +Used by the web client (or other nodes) to fetch files from a Mesh Node. +Verifies Ed25519 chunk signatures using the node's public key from the hub. +""" + +import asyncio +import base64 +import logging +import struct +from pathlib import Path + +import blake3 +import msgpack +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from meshbay_common import MNP_VERSION +from meshbay_common.crypto import ( + chunk_key as derive_chunk_key, + decrypt_chunk, + verify_chunk_signature, +) +from meshbay_common.protocol import MNP +from meshbay_node.transport.tls_cert import client_ssl_context + +log = logging.getLogger(__name__) + +MAX_MSG = 64 * 1024 * 1024 + + +async def _send(writer, obj): + data = msgpack.packb(obj, use_bin_type=True) + writer.write(struct.pack(">I", len(data)) + data) + await writer.drain() + +async def _recv(reader): + header = await reader.readexactly(4) + length = struct.unpack(">I", header)[0] + if length > MAX_MSG: + raise ValueError(f"Message too large: {length}") + return msgpack.unpackb(await reader.readexactly(length), raw=False) + + +class ChunkClient: + """ + Async client for fetching encrypted chunks from a ChunkServer. + + Usage: + async with ChunkClient(host, port, jwt_token, gek, pk_node_b64) as client: + data = await client.fetch_chunk(file_id, chunk_index=0) + """ + + def __init__( + self, + host: str, + port: int, + jwt_token: str, + gek: bytes, + pk_node_b64: str, # node's Ed25519 PK from hub — used for sig verification + ): + self._host = host + self._port = port + self._jwt_token = jwt_token + self._gek = gek + self._pk_node = Ed25519PublicKey.from_public_bytes( + base64.b64decode(pk_node_b64)) + self._reader: asyncio.StreamReader | None = None + self._writer: asyncio.StreamWriter | None = None + + async def __aenter__(self): + await self.connect() + return self + + async def __aexit__(self, *_): + await self.close() + + async def connect(self) -> None: + ssl_ctx = client_ssl_context() + self._reader, self._writer = await asyncio.open_connection( + self._host, self._port, ssl=ssl_ctx) + + # MNP handshake + await _send(self._writer, { + "type": MNP.HANDSHAKE, + "v": MNP_VERSION, + "token": self._jwt_token, + }) + ack = await _recv(self._reader) + if ack.get("type") != MNP.HANDSHAKE_ACK: + raise ConnectionError(f"Handshake rejected: {ack}") + log.debug("Connected to node %s:%d", self._host, self._port) + + async def close(self) -> None: + if self._writer: + self._writer.close() + await self._writer.wait_closed() + + async def fetch_index(self) -> bytes: + """Request the Mesh Group Index. Returns raw wire bytes (encrypted).""" + await _send(self._writer, {"type": MNP.INDEX_SYNC, "v": MNP_VERSION}) + msg = await _recv(self._reader) + return base64.b64decode(msg["index_b64"]) + + async def fetch_chunk(self, file_id: str, chunk_index: int) -> bytes: + """ + Fetch, verify, and decrypt one chunk. + Returns plaintext bytes. + """ + await _send(self._writer, { + "type": MNP.FILE_REQUEST, + "v": MNP_VERSION, + "file_id": file_id, + "chunk_index": chunk_index, + }) + msg = await _recv(self._reader) + + if msg.get("type") == "error": + raise LookupError(msg.get("detail", "Unknown error")) + + ct = base64.b64decode(msg["ct_b64"]) + nonce = base64.b64decode(msg["nonce_b64"]) + ct_hash = base64.b64decode(msg["ct_hash_b64"]) + pt_hash = base64.b64decode(msg["pt_hash_b64"]) + sig = base64.b64decode(msg["sig_b64"]) + file_hash = base64.b64decode(msg["file_hash_b64"]) + ci = msg["chunk_index"] + + # 1. Verify Ed25519 signature + verify_chunk_signature(self._pk_node, ci, nonce, ct_hash, sig) + + # 2. Verify ciphertext hash + if blake3.blake3(ct).digest() != ct_hash: + raise ValueError("Ciphertext hash mismatch") + + # 3. Decrypt + ckey = derive_chunk_key(self._gek, file_hash, ci) + plaintext = decrypt_chunk(ckey, nonce, ct) + + # 4. Verify plaintext hash + if blake3.blake3(plaintext).digest() != pt_hash: + raise ValueError("Plaintext hash mismatch after decryption") + + return plaintext -- cgit v1.2.3