aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_transport_contracts.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-03 16:16:55 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-03 16:16:55 +0200
commit675beed6ff688733a9598f9d82d41578f48316be (patch)
tree78dd4f8dff312f0ad99bd63bc679bf402591c5ed /packages/meshbay-hub/tests/test_transport_contracts.py
parent15087b0e8fdb872602310119f14680aaa443fd93 (diff)
downloadmeshbay-675beed6ff688733a9598f9d82d41578f48316be.tar.gz
feat!: MNP 1.0 — seal index and handshake_ack under the group key
`index_sync`, `index_delta` and the `handshake_ack` config payload now travel sealed under a GEK-derived subkey (`meshbay_common/groupbox.py`, mirrored by `sealGroup`/`openGroup` in `crypto.js`). Only `type`, `v`, `group_id` and the ack's `node_pk`/`proof`/`sig` stay in clear — a receiver must route and authenticate before it would trust a decryption. Verify, then decrypt. The ack line is integrity, not confidentiality: the signed handshake transcript names no ack field, so `is_node_admin`, `enabled_apps`, `video_root` and the rest were authenticated by the DTLS channel alone. The index line is defence in depth against a repeat of C1/C6 — a peer served before the handshake completes now gets ciphertext, not filenames. Nothing against an observer, the hub, or a member; that is the whole claim. `index_progress` stays clear (D3, counters only). Chat is out of scope. Failure is fatal: a payload that does not open ends the session naming the message type — never an empty index or an empty `enabled_apps`, both of which are legitimate states. Version negotiation ships here too (phase 15.6, brought forward): `v` + `v_min` on `handshake` and `handshake_challenge`, refused with `version_too_old` / `version_too_new` / `version_unreadable`. The flag day was already being paid for; the next breaking change now costs a refusal message. BREAKING CHANGE: breaks the WebRTC wire every deployed client speaks. Hub and every node must deploy together; the SPA is served by the hub, so a browser picks up the new client on reload. See MESHBAY_NODE_PROTOCOL.md §11.1a, §13.1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HkzbhmMmK8PqQBtGz5zCvY
Diffstat (limited to 'packages/meshbay-hub/tests/test_transport_contracts.py')
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py75
1 files changed, 75 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index 6011ddb..fee80bb 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -317,3 +317,78 @@ def test_uploads_are_tracked_per_file(transport):
"""Acks interleave when two files are in flight."""
assert "this._uploaders = new Map()" in transport
assert "this._uploaders.set(file.name" in transport
+
+
+# ── MNP 1.0: the sealed handshake ack ────────────────────────────────────────
+#
+# The index half is measured for real in `test_index_seal_client.py`. The ack is
+# opened inside `connect()`, three messages into a WebRTC negotiation, so these
+# read the source — and the ordering they pin is the whole security argument, not
+# an implementation detail.
+
+def _handshake_block(transport: str) -> str:
+ start = transport.index("if (reply.type === 'handshake_challenge') {")
+ return transport[start:transport.index(" return ack;", start)]
+
+
+def test_the_ack_is_verified_before_it_is_decrypted(transport):
+ """
+ Verify, then decrypt. Opening the payload first would mean acting on data
+ from a peer we have not yet authenticated — which is the exact shape of C3,
+ where `node_pk` was never checked and a peer that had hijacked signaling
+ could serve a forged index and a forged `is_node_admin`.
+ """
+ block = _handshake_block(transport)
+ proof = block.index("Node failed to prove GEK possession")
+ signature = block.index("Node signature invalid")
+ pinned = block.index("_checkNodePin(")
+ opened = block.index("openGroup(")
+ assert proof < opened, "the payload is opened before the GEK proof is checked"
+ assert signature < opened, "the payload is opened before the signature is checked"
+ assert pinned < opened, "the payload is opened before the node is pinned"
+
+
+def test_an_ack_that_does_not_open_refuses_the_connection(transport):
+ """
+ Never a default. An `enabled_apps` that failed to open would otherwise reach
+ the client's documented fallback — show every registered app — which is a
+ confident wrong answer, indistinguishable from an operator's real choice.
+ """
+ block = _handshake_block(transport)
+ opened = block[block.index("let config;"):block.index("return ack;")
+ if "return ack;" in block else len(block)]
+ assert "throw new Error(" in opened, "a failed decrypt is swallowed"
+ assert "handshake_ack" in opened, "the failure does not name the message"
+ for fallback in ("|| {}", "?? {}", "catch { }", "config = {}"):
+ assert fallback not in opened, (
+ f"the ack falls back to {fallback} instead of refusing")
+
+
+def test_the_handshake_declares_a_version_range(transport):
+ """
+ L2: `v` used to be written by everyone and read by nobody, so a mismatch
+ surfaced as a missing field rather than a refusal. Both halves of the range
+ ride the handshake, and the node's half is checked before anything below it
+ in `connect()` runs.
+ """
+ block = transport[transport.index("type: 'handshake',"):]
+ block = block[:block.index("});")]
+ assert "v: MNP_V," in block and "v_min: MNP_V_MIN," in block
+
+ challenge = _handshake_block(transport)
+ assert challenge.index("_checkNodeVersion(") < challenge.index("openGroup("), (
+ "the node's version is checked after its messages are relied on")
+
+
+def test_the_index_is_never_reported_from_a_failed_decrypt(transport):
+ """
+ The consumer callbacks may only be reached from inside the opened path — a
+ `catch` that called `_onIndexSync` with an empty message would show "this
+ group has no files", which is a state a real group can be in.
+ """
+ body = transport[transport.index("async _applyIndexMessage("):]
+ body = body[:body.index("\n /**", 1)]
+ assert "openGroup(" in body
+ assert "catch" not in body, (
+ "_applyIndexMessage swallows its own failure instead of letting "
+ "_queueIndexMessage end the session")