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 --- .../meshbay-hub/tests/test_transport_contracts.py | 50 ++++++++++++++++++---- 1 file changed, 42 insertions(+), 8 deletions(-) (limited to 'packages/meshbay-hub/tests/test_transport_contracts.py') diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py index b462242..879062b 100644 --- a/packages/meshbay-hub/tests/test_transport_contracts.py +++ b/packages/meshbay-hub/tests/test_transport_contracts.py @@ -307,26 +307,60 @@ def test_no_setter_survives_the_state_it_belonged_to(): # ── Parallel uploads ────────────────────────────────────────────────────────── -def test_an_upload_refusal_names_the_file_it_is_about(transport): +def test_an_upload_refusal_names_the_upload_it_is_about(transport): """Reported 2026-08-16: a second upload started in parallel killed both. - An error used to carry no filename, so the client could not tell whose it - was and failed every upload in flight — one name the node disliked took the - other file with it. The node names the file now, and only that upload stops. + An error used to carry nothing identifying, so the client could not tell + whose it was and failed every upload in flight — one name the node disliked + took the other file with it. + + The node named the *file* until MNP 2.0 and names the `upload_id` now: the + filename moved inside the seal, and echoing it in clear so the two sides + could match on it would give back precisely what sealing the upload is for. + The property is unchanged — one refusal, one failed upload. """ body = transport[transport.index("if (msg.type === 'error' && this._uploaders.size)"):] body = body[:body.index("\n if (msg.type === 'chat_msg'")] - assert "this._uploaders.has(msg.filename)" in body, ( + assert "this._uploaders.has(msg.upload_id)" in body, ( "a named refusal must reach one uploader, not all of them") - assert "if (!msg.filename)" in body, ( + assert "if (!msg.upload_id)" in body, ( "an unnamed error from an older node must still stop everything — " "guessing which upload it belongs to would be worse") -def test_uploads_are_tracked_per_file(transport): +def test_uploads_are_tracked_per_upload(transport): """Acks interleave when two files are in flight.""" assert "this._uploaders = new Map()" in transport - assert "this._uploaders.set(file.name" in transport + assert "this._uploaders.set(uploadId" in transport + # And the "already being uploaded" guard still speaks in filenames, because + # that is what the caller passed and what it would recognise in the error. + assert "this._inFlightUploads.has(file.name)" in transport + + +def test_the_upload_itself_is_sealed(transport): + """ + MNP 2.0. The filename, the destination and the bytes go inside the seal + together — sealing the content and announcing the name beside it would be + theatre — and only what the node routes on stays outside. + """ + start = transport.index(" async uploadFile(file,") + body = transport[start:transport.index("\n /** Create a directory", start)] + assert "sealGroup(" in body and "'file_upload'" in body, ( + "the upload must be sealed under the group key") + assert "openGroup(" in body and "'file_upload_ack'" in body, ( + "the ack carries the stored name and must be opened, not read") + # The message the node actually receives: everything between `this._send({` + # and its close. Read on its own, because the same field names appear a few + # lines above inside `msgpack_encode({...})`, which is the sealed half. + sent = body[body.index("this._send({"):] + sent = sent[:sent.index("});")] + assert "filename" not in sent, "the filename is on the message in clear" + assert "data" not in sent, "the bytes are on the message in clear" + assert "dir" not in sent and "root" not in sent, ( + "the destination is on the message in clear") + assert "...sealed," in sent, "the message must carry the sealed pair" + assert "supportsSealedUpload" in body, ( + "an older node must be refused before a chunk is sent, not after") # ── MNP 1.0: the sealed handshake ack ──────────────────────────────────────── -- cgit v1.2.3