aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness
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/harness
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/harness')
-rw-r--r--packages/meshbay-hub/tests/harness/upload_seal_probe.mjs102
1 files changed, 102 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/harness/upload_seal_probe.mjs b/packages/meshbay-hub/tests/harness/upload_seal_probe.mjs
new file mode 100644
index 0000000..0b77e42
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/upload_seal_probe.mjs
@@ -0,0 +1,102 @@
+/**
+ * Does the browser's real `uploadFile` produce frames a real node can open —
+ * and does it read back what that node actually answered?
+ *
+ * Drives **the real `MeshBayTransport` over the real `crypto.js`**. Only the DOM
+ * and the DataChannel are stand-ins; the msgpack encode, the HKDF, the AES-GCM
+ * and the whole upload loop are the shipped code.
+ *
+ * It exists because a source-reading test cannot show either half of MNP 2.0's
+ * upload. `test_transport_contracts` can see that `sealGroup` is called; only
+ * this can show that what comes out opens under `meshbay_common.groupbox` — and
+ * that the caller of `uploadFile` is told the name the *node* chose, which now
+ * arrives sealed and would otherwise be `undefined` with nothing to notice it.
+ *
+ * node upload_seal_probe.mjs <static-dir> <input.json>
+ *
+ * Two modes, because the node half runs in Python between them:
+ * "send" — run the upload, print every frame it emits, answer nothing
+ * "receive" — run it again, answering with acks Python built. Their
+ * `upload_id` is retargeted to this run's; it is outside the
+ * seal, so the ciphertext stays exactly the one Python produced.
+ */
+import fs from 'fs';
+
+const STATIC = process.argv[2];
+const input = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
+
+for (const level of ['log', 'warn', 'error', 'info', 'debug']) {
+ console[level] = (...args) => process.stderr.write(args.join(' ') + '\n');
+}
+
+globalThis.window = globalThis;
+globalThis.addEventListener = () => {};
+globalThis.removeEventListener = () => {};
+globalThis.location = { hash: '' };
+globalThis.document = {
+ addEventListener() {}, removeEventListener() {}, visibilityState: 'visible',
+};
+
+new Function(fs.readFileSync(`${STATIC}/crypto.js`, 'utf8'))();
+const transportSrc = fs.readFileSync(`${STATIC}/transport.js`, 'utf8');
+new Function(transportSrc)();
+// The msgpack codec is private to transport.js — pulled out the same way the
+// groupbox parity harness pulls out sealGroup, so this probe encodes and
+// decodes with the codec that is actually shipped rather than a second one.
+const { msgpack_encode, msgpack_decode } =
+ new Function(transportSrc + '\nreturn { msgpack_encode, msgpack_decode };')();
+
+const hex = (s) => Uint8Array.from(s.match(/../g).map((b) => parseInt(b, 16)));
+const toHex = (u8) =>
+ Array.from(u8).map((b) => b.toString(16).padStart(2, '0')).join('');
+
+// Just enough of a File: a name, a size, and slices that yield ArrayBuffers.
+const bytes = hex(input.file.data);
+const file = {
+ name: input.file.name,
+ size: bytes.length,
+ slice(a, b) {
+ const part = bytes.slice(a, b);
+ return { arrayBuffer: async () => part.buffer.slice(
+ part.byteOffset, part.byteOffset + part.byteLength) };
+ },
+};
+
+const tp = new window.MeshBayTransport('', 'token');
+tp._connected = true;
+tp._channel = { readyState: 'open', bufferedAmount: 0, send() {}, close() {} };
+tp._pc = { close() {} };
+tp._gekRaw = hex(input.gek);
+tp._connectArgs = { groupId: input.group_id };
+tp._nodeVersion = input.node_version;
+
+const frames = [];
+let uploadId = null;
+tp._send = (msg) => {
+ frames.push(toHex(msgpack_encode(msg)));
+ if (msg.upload_id) uploadId = msg.upload_id;
+ if (input.mode !== 'receive') return;
+ // Answer as the node did, on the next turn of the loop so the send path
+ // finishes first — which is also how a real ack arrives.
+ const ack = msgpack_decode(hex(input.acks[msg.chunk_index]));
+ ack.upload_id = uploadId;
+ // Through the real `_dispatch`, so the routing under test — matching an
+ // ack to its uploader by `upload_id` — is the shipped one.
+ setImmediate(() => tp._dispatch(ack));
+};
+
+const out = { frames, mode: input.mode };
+const done = tp.uploadFile(file, { chunkSize: input.chunk_size,
+ dir: input.dir, root: input.root });
+
+if (input.mode === 'receive') {
+ done.then((stored) => { out.state = 'resolved'; out.stored = stored; })
+ .catch((e) => { out.state = 'rejected'; out.message = e.message; })
+ .finally(() => { out.upload_id = uploadId;
+ process.stdout.write(JSON.stringify(out)); });
+} else {
+ // Nothing will answer, so let the send loop run itself out and report.
+ done.catch((e) => { out.state = 'rejected'; out.message = e.message; });
+ setTimeout(() => { out.upload_id = uploadId;
+ process.stdout.write(JSON.stringify(out)); }, 250);
+}