aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_transport_contracts.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-07 17:46:33 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-07 17:46:33 +0200
commit8980a8e42d94ab7c0bc9739283d39f938f8402b0 (patch)
treebbb830b48162ebfdcf443f300c82495f452ad1e0 /packages/meshbay-hub/tests/test_transport_contracts.py
parent77dd077491aea50e71e21e0d17555a2f91cf818b (diff)
downloadmeshbay-8980a8e42d94ab7c0bc9739283d39f938f8402b0.tar.gz
feat(mnp)!: seal the upload under the group key
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
Diffstat (limited to 'packages/meshbay-hub/tests/test_transport_contracts.py')
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py50
1 files changed, 42 insertions, 8 deletions
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 ────────────────────────────────────────