summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src
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-node/src
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-node/src')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py173
1 files changed, 105 insertions, 68 deletions
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 f8b6c03..5318c25 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -103,7 +103,13 @@ from meshbay_common.join import (
ROLE_OPERATOR,
join_transcript,
)
-from meshbay_common.protocol import MNP, chunk_ciphertext, file_chunk_wire
+from meshbay_common.protocol import (
+ MNP,
+ chunk_ciphertext,
+ file_chunk_wire,
+ file_upload_ack_wire,
+ file_upload_payload,
+)
from meshbay_node.transport.wire import index_sync_message
from meshbay_node.indexer import GroupIndex
from meshbay_node.indexer.indexer import DirectoryIndexer
@@ -3998,27 +4004,88 @@ class WebRTCPeerSession:
self._send(resp)
def _do_file_upload(self, msg: dict) -> None:
+ """
+ One chunk of an upload, sealed under the group key (MNP 2.0).
+
+ Sealing this direction is not symmetry for its own sake. Downloads have
+ been under a GEK-derived key since the beginning; uploads carried the
+ filename and the raw bytes in plain msgpack, so the same file was
+ ciphertext leaving a node and plaintext arriving at one. The node holds
+ the GEK for its own group, so it opens the payload here — before it
+ decides a destination, before it touches the disk — and refuses a chunk
+ that does not open.
+
+ `upload_id` is the correlation key and stays in clear; `filename`, `dir`
+ and `root` moved inside the seal, which is why every refusal below names
+ the upload rather than the file. A `code` says which refusal it is, and
+ the client already knows what it sent.
+ """
ctx = self._group_ctx()
- filename = msg.get("filename", "")
- chunk_index = msg.get("chunk_index", 0)
- total_chunks = msg.get("total_chunks", 1)
- data = msg.get("data")
+ upload_id = str(msg.get("upload_id") or "")[:64]
+
+ gek = ctx.get("gek")
+ if not gek:
+ self._send({"type": "error", "detail": "Group encryption not initialized",
+ "code": "no_group_key", "upload_id": upload_id})
+ return
+
+ try:
+ payload = file_upload_payload(gek, self._group_id or "", msg)
+ except Exception:
+ # Deliberately one answer for "not sealed at all" and "sealed wrong":
+ # distinguishing them tells a peer which of the two it got right.
+ # An MNP 1.x client lands here, which is the whole of the upgrade
+ # story — everything else it does still works.
+ self._audit("upload_refused", "unsealed")
+ self._send({
+ "type": "error",
+ "detail": "This upload did not open under the group key — the "
+ "client may be running an older version",
+ "code": "upload_not_sealed",
+ "upload_id": upload_id,
+ })
+ return
+
+ filename = payload.get("filename") or ""
+ data = payload.get("data")
+ # From the clear part of the message, so peer-controlled and unchecked
+ # by the AEAD. Everything below compares and adds to them.
+ try:
+ chunk_index = int(msg.get("chunk_index", 0))
+ total_chunks = int(msg.get("total_chunks", 1))
+ except (TypeError, ValueError):
+ self._send({"type": "error", "detail": "Invalid chunk index",
+ "code": "bad_chunk_index", "upload_id": upload_id})
+ return
+
+ def _refuse(detail: str, code: str = "") -> None:
+ """A refusal names the upload, never the file: the name is sealed."""
+ out = {"type": "error", "detail": detail, "upload_id": upload_id}
+ if code:
+ out["code"] = code
+ self._send(out)
- if not filename or data is None:
- self._send({"type": "error", "detail": "Missing filename or data",
- "filename": filename})
+ # Types first, and before any state is created. What comes out of a
+ # sealed payload is authenticated, not validated: it is msgpack a
+ # member wrote, and `SAFE_UPLOAD_NAME.match(123)` raises where a
+ # refusal was meant.
+ if not isinstance(filename, str) or not filename:
+ _refuse("Missing filename or data", "upload_incomplete")
return
+ # Bytes, always: base64 was the shape of the old plaintext `data` field
+ # and there is no sealed message that can carry a string here.
+ if not isinstance(data, (bytes, bytearray)):
+ _refuse("Invalid chunk encoding", "bad_chunk_encoding")
+ return
+ chunk_bytes = bytes(data)
if not SAFE_UPLOAD_NAME.match(filename):
- self._send({"type": "error", "detail": "Invalid filename",
- "filename": filename})
+ _refuse("Invalid filename", "invalid_filename")
return
roots: RootSet | None = ctx.get("roots")
if not roots:
- self._send({"type": "error",
- "detail": "No directories configured for this group",
- "filename": filename})
+ _refuse("No directories configured for this group", "no_roots")
return
# The client names the root it is uploading into — it is browsing one,
@@ -4029,47 +4096,32 @@ class WebRTCPeerSession:
# An unknown name is refused rather than falling back to a writable
# root, because "the file went somewhere else" is discovered weeks
# later — the same reason the old single upload root was never guessed.
- # A client that names nothing is an MNP 1.0 one, and there was exactly
- # one destination in its world: the first writable root.
# `dir` is the folder being browsed, as a virtual path
# (`Media/Films/1999`); `root` is the older, coarser form and is what
- # its first segment means on its own.
- target_rel = str(msg.get("dir") or "").strip().strip("/")
+ # its first segment means on its own. Both are sealed now, so a refusal
+ # below can no longer quote them back.
+ target_rel = str(payload.get("dir") or "").strip().strip("/")
target_root_name = (target_rel.split("/")[0] if target_rel
- else str(msg.get("root") or "").strip())
+ else str(payload.get("root") or "").strip())
upload_root = None
if target_root_name:
upload_root = roots.by_name(target_root_name)
if upload_root is None:
- self._send({"type": "error",
- "detail": f"No directory named "
- f"{target_root_name!r} in this group",
- "code": "no_such_root",
- "filename": filename})
+ _refuse("No such directory in this group", "no_such_root")
return
else:
writable = roots.writable_roots
upload_root = writable[0] if writable else None
if upload_root is None:
- self._send({"type": "error",
- "detail": "No writable directory in this group",
- "code": "no_writable_root",
- "filename": filename})
+ _refuse("No writable directory in this group", "no_writable_root")
return
if not upload_root.writable:
- self._send({"type": "error",
- "detail": f"Directory '{upload_root.name}' is read-only",
- "code": "root_read_only",
- "filename": filename})
+ _refuse("That directory is read-only", "root_read_only")
self._audit("upload_refused", filename[:64])
return
if not upload_root.available:
- self._send({"type": "error",
- "detail": f"Directory '{upload_root.name}' is "
- f"currently unavailable",
- "code": "root_unavailable",
- "filename": filename})
+ _refuse("That directory is currently unavailable", "root_unavailable")
return
# The folder the sender is looking at, and no subdirectory of the node's
@@ -4093,23 +4145,17 @@ class WebRTCPeerSession:
if target_rel:
target_dir = roots.resolve(target_rel)
if target_dir is None or not target_dir.is_dir():
- self._send({"type": "error",
- "detail": "Not a directory in this group",
- "code": "no_such_directory",
- "filename": filename})
+ _refuse("Not a directory in this group", "no_such_directory")
return
rel_dir = target_rel
else:
- # An MNP 1.0 client names nothing; the root itself is where its one
- # destination now is.
+ # A client that names nothing: the first writable root is where its
+ # one destination is.
target_dir = upload_root.path
rel_dir = upload_root.name
if not target_dir.is_dir():
- self._send({"type": "error",
- "detail": f"Directory '{upload_root.name}' is "
- f"currently unavailable",
- "code": "root_unavailable",
- "filename": filename})
+ _refuse("That directory is currently unavailable",
+ "root_unavailable")
return
upload_key = f"{rel_dir}/{filename}"
@@ -4125,33 +4171,24 @@ class WebRTCPeerSession:
# Backstop: _free_name already guarantees this, and it stays because
# it asserts the invariant where the write happens.
if final_path.exists():
- self._send({"type": "error", "detail": "File already exists",
- "filename": filename})
+ _refuse("File already exists", "already_exists")
return
state = {"next_index": 0, "bytes": 0, "stored_name": stored_name}
self._uploads[upload_key] = state
elif state is None:
- self._send({"type": "error", "detail": "Upload not started",
- "filename": filename})
+ _refuse("Upload not started", "not_started")
return
# Reject out-of-order or replayed chunks — otherwise chunk_index>0 appends
# blindly to whatever .part file is already on disk.
if chunk_index != state["next_index"]:
- self._send({"type": "error", "detail": "Unexpected chunk index",
- "filename": filename})
+ _refuse("Unexpected chunk index", "bad_chunk_index")
return
- if isinstance(data, str):
- chunk_bytes = base64.b64decode(data)
- else:
- chunk_bytes = bytes(data)
-
if state["bytes"] + len(chunk_bytes) > MAX_UPLOAD_BYTES:
self._uploads.pop(upload_key, None)
tmp_path.unlink(missing_ok=True)
- self._send({"type": "error", "detail": "Upload exceeds size limit",
- "filename": filename})
+ _refuse("Upload exceeds size limit", "too_large")
return
with open(tmp_path, "wb" if chunk_index == 0 else "ab") as f:
@@ -4159,16 +4196,16 @@ class WebRTCPeerSession:
state["next_index"] = chunk_index + 1
state["bytes"] += len(chunk_bytes)
- self._send({
- "type": MNP.FILE_UPLOAD_ACK,
- "v": MNP_VERSION,
- "chunk_index": chunk_index,
- "filename": filename,
+ self._send(file_upload_ack_wire(
+ gek, self._group_id or "",
+ upload_id=upload_id,
+ chunk_index=chunk_index,
+ filename=filename,
# What it is actually called on disk, which a chat attachment has to
# reference and the uploader deserves to be told.
- "stored_as": stored_name,
- "dir": rel_dir,
- })
+ stored_as=stored_name,
+ dir=rel_dir,
+ ))
if chunk_index + 1 >= total_chunks:
self._uploads.pop(upload_key, None)