diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-07 17:46:33 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-07 17:46:33 +0200 |
| commit | 8980a8e42d94ab7c0bc9739283d39f938f8402b0 (patch) | |
| tree | bbb830b48162ebfdcf443f300c82495f452ad1e0 /packages/meshbay-common/tests | |
| parent | 77dd077491aea50e71e21e0d17555a2f91cf818b (diff) | |
| download | meshbay-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/tests')
| -rw-r--r-- | packages/meshbay-common/tests/test_js_python_parity.py | 13 |
1 files changed, 10 insertions, 3 deletions
diff --git a/packages/meshbay-common/tests/test_js_python_parity.py b/packages/meshbay-common/tests/test_js_python_parity.py index 6f7437f..1abae75 100644 --- a/packages/meshbay-common/tests/test_js_python_parity.py +++ b/packages/meshbay-common/tests/test_js_python_parity.py @@ -258,6 +258,11 @@ GROUPBOX_VECTORS = [ ("index", "index_sync", "groupe-café-日本"), # A '|' inside the group id, which is the AAD's own separator. ("index", "index_sync", "a|b"), + # MNP 2.0 — the upload, and the only purpose the *browser* seals in + # production. A disagreement here means no file can be uploaded from any + # browser to any node, and the node reports only "it did not open". + ("upload", "file_upload", "g" * 32), + ("upload", "file_upload_ack", "g" * 32), ] GROUPBOX_GEK = bytes.fromhex("5a" * 32) @@ -349,9 +354,11 @@ def test_browser_opens_what_python_sealed(idx, vector, groupbox_js): @pytest.mark.parametrize("idx,vector", list(enumerate(GROUPBOX_VECTORS))) def test_python_opens_what_the_browser_sealed(idx, vector, groupbox_js): """ - The other direction. Nothing in the SPA seals today — `sealGroup` exists for - the chat plan, which needs the same primitive — but a codec that only ever - runs one way is a codec whose encoder is untested. + The other direction, and since MNP 2.0 it is a shipping path rather than a + precaution: `uploadFile` seals every chunk under `upload`, and the node + opens it with `unseal`. The index vectors above still only ever run one way + in production, and are kept because a codec whose encoder is untested is a + codec with half a test. """ from meshbay_common.groupbox import unseal |