From 8980a8e42d94ab7c0bc9739283d39f938f8402b0 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 7 Sep 2026 17:46:33 +0200 Subject: feat(mnp)!: seal the upload under the group key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downloads have been encrypted under a GEK-derived key since the beginning: `file_chunk` and `stream_data` both go through `chunk_ciphertext`. Uploads never were. `file_upload` carried the filename and the raw bytes in plain msgpack, and `file_upload_ack` carried the name the node stored them under — so the same file was ciphertext leaving a node and plaintext arriving at one. There was no threat model behind that asymmetry. Both halves now travel sealed under a third groupbox purpose, HKDF(GEK, info="meshbay:upload:v1"). The filename, the destination folder and the bytes are all inside the seal; only `upload_id` and `chunk_index` stay in clear, because the node routes and orders on them before it can decrypt. This direction seals *towards* the node — it holds the GEK for its own group — and it opens the payload before it picks a destination or touches the disk. What that forced, and why none of it is optional: - `filename` was the correlation key on both sides. It cannot be: matching an ack to its request by name would hand back exactly what the seal hides. `upload_id` replaces it — client-drawn, opaque to the node, unique within a connection, never an authorization input. The property it guarded (one refusal fails one upload, not every upload in flight) is unchanged. - Refusals can no longer quote what they refused. `No directory named 'X'` becomes `No such directory in this group` plus the `code` that was already there; the client knows what it sent. - No plaintext fallback. A path that still accepts plaintext is not a sealed path, so an unsealed `file_upload` is refused with `upload_not_sealed`. Hardened while here, because what comes out of a seal is authenticated but not validated — a member can seal anything: `filename` and `data` have their types checked before any upload state is created, and `chunk_index`/`total_chunks`, which are outside the seal by necessity, can no longer raise where a refusal was meant. Tests. `test_upload_sealed.py` pins the node half: nothing identifying on the wire, tamper/wrong-key/wrong-group all refused with nothing written, and multi-chunk reassembly unchanged. `test_upload_seal_client.py` drives the shipped `uploadFile` over the shipped `crypto.js` under node and feeds its real frames to the real `_do_file_upload` — the file lands intact, and the ack the node actually produced comes back with the name it chose for a collision, which is the half a source-reading test cannot see. Both upload purposes join the JS/Python groupbox parity vectors. BREAKING CHANGE: MNP 2.0. `file_upload`/`file_upload_ack` change shape on the wire every deployed client speaks, which is MAJOR by the same rule 1.0 was — but the break is confined to uploads. `MNP_MIN_SUPPORTED` stays at "1.0", so a 1.x peer still connects, browses, downloads, streams and chats; only its uploads are refused, with a message saying which side is old. The client checks the node's version before sending a chunk, so neither side meets this as a timeout. This is the version negotiation shipped in 1.0 earning its keep: 1.0 cost a flag day, 2.0 costs a refusal code. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3 --- .../tests/test_security_regressions.py | 89 +++++++++------------- 1 file changed, 37 insertions(+), 52 deletions(-) (limited to 'packages/meshbay-node/tests/test_security_regressions.py') diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py index fa821d9..988aa46 100644 --- a/packages/meshbay-node/tests/test_security_regressions.py +++ b/packages/meshbay-node/tests/test_security_regressions.py @@ -16,10 +16,11 @@ from pathlib import Path import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_common.crypto import generate_gek from meshbay_common.protocol import IndexEntry from meshbay_node.indexer.group_index import GroupIndex from meshbay_node.roots import RootSet -from conftest import one_root +from conftest import one_root, opened_ack, sealed_upload from meshbay_node.transport.webrtc_server import WebRTCPeerSession @@ -156,7 +157,10 @@ def _session(tmp_path: Path, user_id: str) -> WebRTCPeerSession: shared_root = tmp_path / "shared" shared_root.mkdir(exist_ok=True) index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) - ctx = {"roots": one_root(shared_root), "index": index, "sk_node": index.sk_node} + # A group key, because uploads are sealed under it since MNP 2.0 — the + # handler opens the payload before it has a filename to refuse. + ctx = {"roots": one_root(shared_root), "index": index, + "sk_node": index.sk_node, "gek": generate_gek()} session = WebRTCPeerSession.__new__(WebRTCPeerSession) session._ctx = ctx @@ -188,12 +192,8 @@ def test_upload_cannot_overwrite_another_members_file(tmp_path): original.write_bytes(b"operator's original content") attacker = _session(tmp_path, "attacker-user") - attacker._do_file_upload({ - "filename": "important.mp4", - "chunk_index": 0, - "total_chunks": 1, - "data": base64.b64encode(b"attacker content").decode(), - }) + attacker._do_file_upload(sealed_upload( + attacker, filename="important.mp4", data=b"attacker content")) assert original.read_bytes() == b"operator's original content", ( "an upload replaced an existing file (C5a)") @@ -203,12 +203,15 @@ def test_upload_cannot_overwrite_another_members_file(tmp_path): def test_upload_second_attempt_cannot_replace_own_completed_file(tmp_path): """C5a: even the original uploader does not get to overwrite.""" session = _session(tmp_path, "user-1") - payload = {"filename": "movie.mp4", "chunk_index": 0, "total_chunks": 1, - "data": base64.b64encode(b"first").decode()} - session._do_file_upload(dict(payload)) - session.sent.clear() + def _send_it(): + # Sealed afresh each time: a nonce is drawn per message, so re-sending + # the same dict would be a replay rather than a second upload. + session._do_file_upload(sealed_upload( + session, filename="movie.mp4", data=b"first")) - session._do_file_upload(dict(payload)) + _send_it() + session.sent.clear() + _send_it() uploads = _uploads_dir(session) assert (uploads / "movie.mp4").read_bytes() == b"first", ( "the first upload was replaced") @@ -252,11 +255,8 @@ def test_the_client_names_a_folder_and_never_a_filesystem_path(tmp_path): for bad in ("../../etc", "/etc", "shared/../..", "shared/../../etc", "nope", "shared/missing"): session.sent.clear() - session._do_file_upload({ - "filename": "note.txt", "dir": bad, - "chunk_index": 0, "total_chunks": 1, - "data": base64.b64encode(b"x").decode(), - }) + session._do_file_upload(sealed_upload( + session, filename="note.txt", data=b"x", dir=bad)) refusal = [m for m in session.sent if m.get("type") == "error"] assert refusal, f"{bad!r} was accepted" assert refusal[0].get("code") in ("no_such_root", "no_such_directory"), bad @@ -274,11 +274,8 @@ def test_an_upload_lands_in_the_folder_it_names(tmp_path): root = session._ctx["roots"].roots[0] (root.path / "Albums").mkdir() - session._do_file_upload({ - "filename": "note.txt", "dir": f"{root.name}/Albums", - "chunk_index": 0, "total_chunks": 1, - "data": base64.b64encode(b"x").decode(), - }) + session._do_file_upload(sealed_upload( + session, filename="note.txt", data=b"x", dir=f"{root.name}/Albums")) assert (root.path / "Albums" / "note.txt").read_bytes() == b"x" assert not (root.path / "Albums" / "uploads").exists(), ( @@ -304,11 +301,8 @@ def test_an_upload_goes_to_the_root_it_names(tmp_path): {"path": str(incoming), "writable": True}, ]) - session._do_file_upload({ - "filename": "note.txt", "dir": "Incoming", - "chunk_index": 0, "total_chunks": 1, - "data": base64.b64encode(b"x").decode(), - }) + session._do_file_upload(sealed_upload( + session, filename="note.txt", data=b"x", dir="Incoming")) assert (incoming / "note.txt").read_bytes() == b"x" assert not (media / "note.txt").exists(), "it went to the first root instead" @@ -327,11 +321,8 @@ def test_a_read_only_root_refuses_an_upload(tmp_path): session._ctx["roots"] = RootSet.build([{"path": str(published)}]) session._is_node_admin = lambda: True - session._do_file_upload({ - "filename": "note.txt", "dir": "Published", - "chunk_index": 0, "total_chunks": 1, - "data": base64.b64encode(b"x").decode(), - }) + session._do_file_upload(sealed_upload( + session, filename="note.txt", data=b"x", dir="Published")) refusal = [m for m in session.sent if m.get("type") == "error"] assert refusal and refusal[0].get("code") == "root_read_only" @@ -349,11 +340,8 @@ def test_a_fully_read_only_group_refuses_an_unaddressed_upload(tmp_path): session = _session(tmp_path, "user-1") session._ctx["roots"] = RootSet.build([{"path": str(published)}]) - session._do_file_upload({ - "filename": "note.txt", - "chunk_index": 0, "total_chunks": 1, - "data": base64.b64encode(b"x").decode(), - }) + session._do_file_upload(sealed_upload( + session, filename="note.txt", data=b"x")) refusal = [m for m in session.sent if m.get("type") == "error"] assert refusal and refusal[0].get("code") == "no_writable_root" @@ -375,11 +363,8 @@ def test_an_ejected_root_refuses_an_upload(tmp_path): roots.roots[0].available = False session._ctx["roots"] = roots - session._do_file_upload({ - "filename": "note.txt", "dir": "USB", - "chunk_index": 0, "total_chunks": 1, - "data": base64.b64encode(b"x").decode(), - }) + session._do_file_upload(sealed_upload( + session, filename="note.txt", data=b"x", dir="USB")) refusal = [m for m in session.sent if m.get("type") == "error"] assert refusal and refusal[0].get("code") == "root_unavailable" @@ -392,22 +377,22 @@ def test_two_members_can_send_the_same_filename(tmp_path): IMG_1234.jpg. The second gets a free name; neither replaces the other. """ first = _session(tmp_path, "user-1") - first._do_file_upload({ - "filename": "IMG_1234.jpg", "chunk_index": 0, "total_chunks": 1, - "data": base64.b64encode(b"first").decode(), - }) + first._do_file_upload(sealed_upload( + first, filename="IMG_1234.jpg", data=b"first")) second = _session(tmp_path, "user-2") - second._do_file_upload({ - "filename": "IMG_1234.jpg", "chunk_index": 0, "total_chunks": 1, - "data": base64.b64encode(b"second").decode(), - }) + # Same group, so the same key: `_session` builds one per call, and two + # members of one group do not have two. + second._ctx["gek"] = first._ctx["gek"] + second._do_file_upload(sealed_upload( + second, filename="IMG_1234.jpg", data=b"second")) uploads = _uploads_dir(first) assert (uploads / "IMG_1234.jpg").read_bytes() == b"first" assert (uploads / "IMG_1234 (2).jpg").read_bytes() == b"second" ack = [m for m in second.sent if m.get("type") == "file_upload_ack"][-1] - assert ack["stored_as"] == "IMG_1234 (2).jpg", ( + assert "stored_as" not in ack, "the name the node chose must be sealed" + assert opened_ack(second, ack)["stored_as"] == "IMG_1234 (2).jpg", ( "the sender must be told the name that was used, or a chat attachment " "points at someone else's file") -- cgit v1.2.3