aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src/meshbay_common/__init__.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-common/src/meshbay_common/__init__.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-common/src/meshbay_common/__init__.py')
-rw-r--r--packages/meshbay-common/src/meshbay_common/__init__.py35
1 files changed, 34 insertions, 1 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py
index b3e24e3..ac69e18 100644
--- a/packages/meshbay-common/src/meshbay_common/__init__.py
+++ b/packages/meshbay-common/src/meshbay_common/__init__.py
@@ -93,5 +93,38 @@ __version__ = "0.11.0"
# The index at rest, `index_progress` (counters only, never a path — see
# `groupbox.py` and daemon.py `_push_index_progress`), chat, and file content
# on the operator's disk are all deliberately unchanged.
-MNP_VERSION = "1.1"
+# 1.1: `index_delta` carries `roots` (additive — a 1.0 client ignores it), and
+# the per-root/per-app operations: `root_update`/`root_eject`/`root_plug`,
+# `app_directories`, `chat_directory`, `chat_link_preview`.
+# 2.0: the **write** path is sealed, and the last unencrypted content message
+# is gone.
+#
+# - `file_upload` and `file_upload_ack` travel sealed under a GEK-derived
+# subkey (`groupbox.PURPOSE_UPLOAD`). The filename, the destination folder
+# and the bytes all ride inside the seal; `upload_id` — a fresh
+# client-chosen correlation id — and `chunk_index` stay in clear because
+# the node routes and orders on them. `filename` used to be the
+# correlation key and cannot be any more, which is what forced `upload_id`.
+# - `stream_seg` is **removed**. It answered with an MPEG-TS segment as
+# base64 with no encryption at all, on both transports, to any
+# authenticated member — the one content-plane message that never went
+# through a GEK-derived key. `stream_data` has done the job properly since
+# Phase 12, and `fetchStreamSegment`, its only browser caller, was defined
+# and never once invoked.
+#
+# **Breaking, on the wire every deployed client speaks**, and MAJOR by the same
+# rule 1.0 was: a 1.x client's upload reaches a 2.0 node with no `filename` and
+# no `data` outside the seal. That is why the break is confined rather than
+# total — `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
+# `upload_not_sealed` and a message saying which side is old. A client refuses
+# symmetrically before sending (`supportsSealedUpload` in transport.js), so
+# neither side discovers this as a timeout.
+#
+# Still deliberately in clear, and none of it is content: the handshake itself,
+# `index_progress` (counters only — see daemon.py `_push_index_progress`), the
+# admin and configuration acks, and the media-metadata replies. Chat is next,
+# under sender keys. The index at rest and file content on the operator's disk
+# are unchanged.
+MNP_VERSION = "2.0"
MHP_VERSION = "0.1"