diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-07 18:03:52 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-07 18:03:52 +0200 |
| commit | e1383e1d545b994f4ad61694f868339defb0bdef (patch) | |
| tree | 67a3b1933f2a9c5caf107d01d9ff91c44a975df6 /packages/meshbay-hub/tests/harness/upload_seal_probe.mjs | |
| parent | 36cebf25d0e0f24cf63be4380ccb5d03da726a74 (diff) | |
| parent | 8980a8e42d94ab7c0bc9739283d39f938f8402b0 (diff) | |
| download | meshbay-e1383e1d545b994f4ad61694f868339defb0bdef.tar.gz | |
Merge origin/main into the chat encryption work
Both sides landed a breaking MNP change and both called it 2.0, which is right:
the sealed upload, the removal of `stream_seg` and mandatory chat encryption
share one flag day. They are recorded as one version in `__init__.py` rather
than as a race between two.
The resolutions that were decisions rather than mechanics:
* **`MNP_MIN_SUPPORTED` moves to "2.0".** The sealed upload alone was a
*confined* break — a 1.x peer could still connect, browse, download, stream
and chat, with only its uploads refused by `upload_not_sealed` — so the floor
deliberately stayed at "1.0". Mandatory chat encryption ends that
confinement: a 1.x peer can neither produce a sealed chat message nor read
one, so it would connect, look fine, and be unable to say anything. Refusing
it at the handshake is the honest form. The per-message `upload_not_sealed`
path is untouched and still right if the floor is ever lowered.
* **`sendChat` throws on an `error` reply**, from origin, applied to the sealed
send. It matters more after this change, not less: the node now refuses a
stale epoch, a malformed envelope and a device claim that is not the
connection's own, so there are three new ways for a message to be rejected
and none of them may look like a message that was sent.
* **`req_id` supersedes the per-type routing** this branch added for
`chat_keys_resp` and `device_hello_ack`. Both blocks are kept beside the
existing `chat_hist_resp` one, for the same stated reason — a node too old to
stamp — and their comments no longer claim to be the mechanism that closes
the class. `req_id` is.
* **`chat_send_probe.py` is rebuilt on origin's structure**, not beside it: two
scenarios, a stub that stamps `req_id`, `music_meta_req` as the older pending
request. The encrypted path is layered on — a real Ed25519 device key
generated in the page, and a `chat_keys_resp` sealed by the shipped Python,
because a payload the page built itself would prove only that the page agrees
with the page.
* **`test_reply_correlation.py` now sends a sealed message.** Its subject is
which of the two messages leaving that handler carries the id; plaintext chat
was only the fixture, and the node refuses one now.
* `groupbox` keeps both new purposes (`upload`, `chat_keys`); `protocol.py`
keeps origin's removal of `STREAM_SEGMENT` and this branch's correction of
the "Double Ratchet message" comment on `CHAT_MESSAGE`, which was wrong when
it was written and is wrong differently now.
Full suite on the merged tree: 1993 passed, 11 failed — the same 11 that fail
on a pristine checkout (2 Windows service tests, 1 apps-enabled policy, 7
transcode tests that pass in isolation, and the WebRTC invite test that hangs
on its own).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
Diffstat (limited to 'packages/meshbay-hub/tests/harness/upload_seal_probe.mjs')
| -rw-r--r-- | packages/meshbay-hub/tests/harness/upload_seal_probe.mjs | 102 |
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); +} |