aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-common/src/meshbay_common/__init__.py25
-rw-r--r--packages/meshbay-common/src/meshbay_common/groupbox.py123
-rw-r--r--packages/meshbay-common/src/meshbay_common/handshake.py72
-rw-r--r--packages/meshbay-common/tests/test_groupbox.py120
-rw-r--r--packages/meshbay-common/tests/test_js_python_parity.py157
-rw-r--r--packages/meshbay-common/tests/test_version_negotiation.py78
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/crypto.js75
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js190
-rw-r--r--packages/meshbay-hub/tests/harness/index_seal_probe.mjs98
-rw-r--r--packages/meshbay-hub/tests/test_index_seal_client.py143
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py75
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py49
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/quic_client.py40
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/quic_server.py24
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py45
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/wire.py54
-rw-r--r--packages/meshbay-node/tests/test_daemon.py18
-rw-r--r--packages/meshbay-node/tests/test_index_no_cleartext.py159
-rw-r--r--packages/meshbay-node/tests/test_transport_wire_parity.py63
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py71
21 files changed, 1604 insertions, 85 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py
index 61502c9..e96c483 100644
--- a/packages/meshbay-common/src/meshbay_common/__init__.py
+++ b/packages/meshbay-common/src/meshbay_common/__init__.py
@@ -70,5 +70,28 @@ __version__ = "0.10.0"
# there: the WebRTC shape — the one every deployed client speaks — is byte for byte
# what it was, and no QUIC client ships. Recorded as a MINOR bump for that reason;
# a deployed QUIC peer would have made it a MAJOR one.
-MNP_VERSION = "0.15"
+# 1.0: `index_sync`, `index_delta` and the `handshake_ack` configuration
+# fields now travel **sealed under a GEK-derived subkey**
+# (`meshbay_common.groupbox`), and the handshake negotiates a supported
+# version range instead of writing a `v` nobody reads.
+#
+# **Breaking, on the wire every deployed client speaks**, and there is no way
+# to describe it as additive: an old client sends `index_sync` and gets a
+# message with no `entries`; it reads `ack.enabled_apps`, finds nothing, and
+# applies its documented fallback — "show every app" — rather than reporting
+# an error; a new client against an old node finds `entries` it does not
+# expect and no `ct`. 0.15 stayed MINOR because only the QUIC wire changed and
+# no QUIC client ships; that argument is not available here, and MAJOR is what
+# the project's own rule says. Hub and every node deploy together; the SPA is
+# served by the hub, so a browser picks up the new client on reload.
+#
+# Version negotiation ships in the same flag day rather than after it (phase
+# 15.6): the coordinated deployment is already being paid for, and it is what
+# makes the *next* breaking change cost a refusal message instead of a second
+# flag day. `MNP_MIN_SUPPORTED` in `handshake.py` is the other half.
+#
+# 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.0"
MHP_VERSION = "0.1"
diff --git a/packages/meshbay-common/src/meshbay_common/groupbox.py b/packages/meshbay-common/src/meshbay_common/groupbox.py
new file mode 100644
index 0000000..f1091c3
--- /dev/null
+++ b/packages/meshbay-common/src/meshbay_common/groupbox.py
@@ -0,0 +1,123 @@
+"""
+Sealing a message payload under the group key.
+
+`file_chunk` and `stream_data` have always travelled encrypted under a GEK-derived
+key; `index_sync`, `index_delta` and the `handshake_ack` config fields travelled in
+plain msgpack, authenticated by the DTLS/TLS channel and nothing else. The model in
+force was "the channel is the boundary". This module is the other half: a payload
+sealed under a key the hub does not hold.
+
+Two properties, and it is worth being precise about which is which.
+
+**Integrity, for the ack.** The node signs `handshake_transcript(role, group_id,
+nonce_c, nonce_s, binding)`, which contains no ack field at all — so `is_node_admin`,
+`enabled_apps`, `video_root` and the rest were authenticated by the channel alone.
+An AEAD tag from a GEK-derived key is a stronger statement than any amount of
+confidentiality on the index.
+
+**Confidentiality, for the index.** Defence in depth against our own next bug of a
+class already shipped twice: C1 (the node HTTP API served the index and plaintext
+files on 0.0.0.0 with no authentication) and C6 (the TCP transport accepted a bare
+JWT with no GEK proof) were both "a peer that had not completed the handshake was
+served data". Sealed, that bug leaks ciphertext rather than filenames, folder names
+and group configuration. It buys nothing against a network observer (DTLS/TLS
+already covers that), nothing against the hub (it never sees channel traffic), and
+nothing against a member — who holds the GEK. That is the whole claim.
+
+Purpose separation is deliberate. `GroupIndex.serialize()` reuses
+`chunk_key_aes(gek, file_hash, chunk_index)` with a pseudo-file ("the index as chunk
+0 of a virtual index file"), which borrows a file's key space for something that is
+not a file. Each purpose here derives its own subkey instead.
+"""
+
+from __future__ import annotations
+
+import os
+
+import msgpack
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+
+PURPOSE_INDEX = "index"
+PURPOSE_ACK = "ack"
+
+# `salt=None` here and `salt: new Uint8Array(0)` in crypto.js agree — RFC 5869
+# extracts with a zero key either way. Already proven in production by
+# `deriveChunkKey`, and held by the parity test.
+_INFO = {
+ PURPOSE_INDEX: b"meshbay:index:v1",
+ PURPOSE_ACK: b"meshbay:ack:v1",
+}
+
+NONCE_LEN = 12 # 96-bit, the WebCrypto AES-GCM standard
+
+
+def group_key(gek: bytes, purpose: str) -> bytes:
+ """Derive the AES-256 subkey for one purpose. Distinct per purpose, by info."""
+ try:
+ info = _INFO[purpose]
+ except KeyError:
+ raise ValueError(f"unknown groupbox purpose: {purpose!r}") from None
+ if not gek:
+ raise ValueError("no group key")
+ return HKDF(
+ algorithm=hashes.SHA256(), length=32, salt=None, info=info,
+ ).derive(gek)
+
+
+def associated_data(msg_type: str, group_id: str) -> bytes:
+ """
+ What a ciphertext is bound to.
+
+ Binding the message type stops an `index_sync` body being replayed as an
+ `index_delta`; binding the group stops one being moved between two groups hosted
+ on the same node. It costs nothing and closes a class of confusion that is
+ tedious to reason about later.
+ """
+ return f"{msg_type}|{group_id}".encode()
+
+
+def seal(gek: bytes, purpose: str, msg_type: str, group_id: str,
+ payload: dict) -> dict:
+ """
+ The `{nonce, ct}` pair for the caller to merge into its message.
+
+ Returns only those two fields: the routing fields (`type`, `v`, `group_id`) stay
+ in clear because the receiver must route and version-check before it can decrypt,
+ and `group_id` selects the key besides.
+ """
+ key = group_key(gek, purpose)
+ # 96-bit random nonce per message. The volume here — one message per index
+ # change — is many orders below the birthday bound. Never derive it from the
+ # payload: two identical payloads under one subkey would then reuse it.
+ nonce = os.urandom(NONCE_LEN)
+ ct = AESGCM(key).encrypt(
+ nonce, msgpack.packb(payload, use_bin_type=True),
+ associated_data(msg_type, group_id))
+ return {"nonce": nonce, "ct": ct}
+
+
+def unseal(gek: bytes, purpose: str, msg_type: str, group_id: str,
+ msg: dict) -> dict:
+ """
+ Open a sealed message. Raises on anything that does not open — never a partial
+ result, and never a default.
+
+ A payload that does not open is not a config change and not an empty index; it is
+ a peer we cannot talk to. Falling back would make `enabled_apps` read as "the
+ operator disabled every app" and an index as "the group is empty", both
+ indistinguishable from a legitimate state — which is what makes a silent fallback
+ worse than a stop. Same rule already applied to `file_chunk`.
+ """
+ key = group_key(gek, purpose)
+ nonce = msg.get("nonce")
+ ct = msg.get("ct")
+ if not isinstance(nonce, (bytes, bytearray)) or not isinstance(ct, (bytes, bytearray)):
+ raise ValueError(f"{msg_type}: not a sealed message")
+ plain = AESGCM(key).decrypt(
+ bytes(nonce), bytes(ct), associated_data(msg_type, group_id))
+ payload = msgpack.unpackb(plain, raw=False)
+ if not isinstance(payload, dict):
+ raise ValueError(f"{msg_type}: sealed payload is not a map")
+ return payload
diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py
index fca218f..2f3d641 100644
--- a/packages/meshbay-common/src/meshbay_common/handshake.py
+++ b/packages/meshbay-common/src/meshbay_common/handshake.py
@@ -9,13 +9,15 @@ module, and a parity test fails if either skips a step.
The sequence:
- client → node handshake {token, group_id, nonce_c}
+ client → node handshake {token, group_id, nonce_c, v, v_min}
+ node check_version() supported range, both ways
node authorize_token() JWT, scope, denylist, membership, hosting
- node → client handshake_challenge {nonce_s}
+ node → client handshake_challenge {nonce_s, v, v_min}
client → node handshake_response {proof}
node verify client proof HMAC(GEK, client transcript)
- node → client handshake_ack {proof, sig, node_pk, is_node_admin}
+ node → client handshake_ack {proof, sig, node_pk, nonce, ct}
client verify node proof HMAC(GEK, node transcript) + Ed25519
+ client THEN open ct the session config, sealed (groupbox.py)
Two properties this adds over the previous design:
@@ -28,6 +30,22 @@ forged `is_node_admin` flag. The node now proves GEK possession over a
client-chosen nonce *and* signs the transcript with its long-term key, so the
client can pin it.
+**A version that is checked (L2).** `v` used to be written by everyone and read by
+nobody, so a version mismatch surfaced as a missing field — an old client reading a
+1.0 ack found no `enabled_apps` and applied its documented fallback, "show every
+app", which is a wrong answer rather than an error. Both sides now declare the range
+they speak, in the first message each sends, and a peer outside it is refused with a
+code rather than served a message it will misread. Without this the *next* breaking
+change costs another coordinated deployment; with it, it costs a refusal.
+
+**A payload the hub cannot forge.** The transcript above names `role`, `group_id`,
+both nonces and the binding — and **no ack field**. So `is_node_admin`,
+`enabled_apps`, `video_root` and the rest were authenticated by the channel alone.
+Since MNP 1.0 they travel sealed under a GEK-derived subkey (`groupbox.py`), which
+gives them an AEAD tag from a key the hub does not hold. Verify first, then decrypt:
+opening the payload before the proof and the signature would mean acting on data
+from a peer not yet authenticated.
+
**Unambiguous transcripts (L4).** The old proof was `nonce ‖ offer_fp ‖ answer_fp`
— bare concatenation, and a missing fingerprint silently degraded it to nonce-only.
Every field is now length-prefixed and domain-separated, the role is bound so a
@@ -44,8 +62,16 @@ from typing import Any, Protocol
import jwt
+from meshbay_common import MNP_VERSION
+
HANDSHAKE_PREFIX = b"meshbay:mnp:handshake:v1"
+# The oldest peer this build will talk to. MNP 1.0 sealed `index_sync`,
+# `index_delta` and the `handshake_ack` payload under the group key, which no
+# 0.x peer can open and which a 0.x peer's own messages do not carry — there is
+# nothing to be compatible with, which is what makes it a MAJOR bump.
+MNP_MIN_SUPPORTED = "1.0"
+
ROLE_CLIENT = "client"
ROLE_NODE = "node"
@@ -147,6 +173,46 @@ def verify_proof(
return hmac.compare_digest(proof, expected)
+def parse_version(v: str) -> tuple[int, int]:
+ """`"1.0"` → `(1, 0)`. Raises ValueError on anything else."""
+ major, _, minor = str(v).partition(".")
+ return int(major), int(minor)
+
+
+def check_version(peer_v: str, peer_min: str = "") -> None:
+ """
+ Refuse a peer outside the range this build speaks, before anything else.
+
+ `peer_min` is the oldest version the peer accepts *from us*; a peer that
+ declares none is treated as accepting only what it speaks, which is the right
+ reading of every 0.x peer — none of them declared a range because none of them
+ checked one.
+
+ Raises HandshakeError with a code the other side can act on, rather than
+ letting the mismatch surface later as a field that is missing.
+ """
+ ours = parse_version(MNP_VERSION)
+ our_min = parse_version(MNP_MIN_SUPPORTED)
+ try:
+ theirs = parse_version(peer_v)
+ their_min = parse_version(peer_min) if peer_min else theirs
+ except (ValueError, AttributeError):
+ raise HandshakeError(
+ f"Unreadable protocol version {peer_v!r}", code="version_unreadable"
+ ) from None
+
+ if theirs < our_min:
+ raise HandshakeError(
+ f"Protocol {peer_v} is too old for this peer, which needs "
+ f"{MNP_MIN_SUPPORTED} or later",
+ code="version_too_old")
+ if their_min > ours:
+ raise HandshakeError(
+ f"This peer speaks protocol {MNP_VERSION}, older than the "
+ f"{peer_min} the other side requires",
+ code="version_too_new")
+
+
def authorize_token(
token: str,
hub_pk_pem: bytes,
diff --git a/packages/meshbay-common/tests/test_groupbox.py b/packages/meshbay-common/tests/test_groupbox.py
new file mode 100644
index 0000000..65d8ce6
--- /dev/null
+++ b/packages/meshbay-common/tests/test_groupbox.py
@@ -0,0 +1,120 @@
+"""
+The group-key envelope: round trip, and every refusal it owes.
+
+`groupbox.seal`/`unseal` is what puts `index_sync`, `index_delta` and the
+`handshake_ack` configuration under a key the hub does not hold. The structural
+tests here matter less than `test_index_no_cleartext.py`, which asserts the
+property on a real frame; these pin the primitive.
+"""
+
+import msgpack
+import pytest
+from cryptography.exceptions import InvalidTag
+from meshbay_common.crypto import generate_gek
+from meshbay_common.groupbox import (
+ PURPOSE_ACK,
+ PURPOSE_INDEX,
+ group_key,
+ seal,
+ unseal,
+)
+
+PAYLOAD = {"version": 7, "entries": [{"name": "a.mkv", "size": 12}], "dirs": ["root"]}
+
+
+@pytest.fixture
+def gek():
+ return generate_gek()
+
+
+def test_round_trip(gek):
+ for purpose in (PURPOSE_INDEX, PURPOSE_ACK):
+ sealed = seal(gek, purpose, "index_sync", "g1", PAYLOAD)
+ assert set(sealed) == {"nonce", "ct"}
+ assert unseal(gek, purpose, "index_sync", "g1", sealed) == PAYLOAD
+
+
+def test_a_wrong_key_does_not_open(gek):
+ sealed = seal(gek, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD)
+ with pytest.raises(InvalidTag):
+ unseal(generate_gek(), PURPOSE_INDEX, "index_sync", "g1", sealed)
+
+
+def test_purposes_are_separate_key_spaces(gek):
+ """
+ The reason there are two info strings rather than one key reused.
+
+ An ack sealed under the index subkey would otherwise be openable by anything
+ holding the index subkey, which is the confusion `GroupIndex.serialize()`'s
+ "the index as chunk 0 of a virtual index file" creates for chunk keys.
+ """
+ assert group_key(gek, PURPOSE_INDEX) != group_key(gek, PURPOSE_ACK)
+ sealed = seal(gek, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD)
+ with pytest.raises(InvalidTag):
+ unseal(gek, PURPOSE_ACK, "index_sync", "g1", sealed)
+
+
+def test_a_body_cannot_be_replayed_as_another_message_type(gek):
+ """The AAD's first half: an index_sync body is not an index_delta."""
+ sealed = seal(gek, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD)
+ with pytest.raises(InvalidTag):
+ unseal(gek, PURPOSE_INDEX, "index_delta", "g1", sealed)
+
+
+def test_a_body_cannot_be_moved_between_groups(gek):
+ """
+ The AAD's second half. Two groups on one node share a GEK-holding process but
+ not a GEK; this closes the case where they do share one (a rotation in flight,
+ a test fixture, an operator reusing a key) as well.
+ """
+ sealed = seal(gek, PURPOSE_INDEX, "index_sync", "group-a", PAYLOAD)
+ with pytest.raises(InvalidTag):
+ unseal(gek, PURPOSE_INDEX, "index_sync", "group-b", sealed)
+
+
+def test_a_tampered_ciphertext_does_not_open(gek):
+ sealed = seal(gek, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD)
+ sealed["ct"] = bytes([sealed["ct"][0] ^ 1]) + sealed["ct"][1:]
+ with pytest.raises(InvalidTag):
+ unseal(gek, PURPOSE_INDEX, "index_sync", "g1", sealed)
+
+
+def test_a_message_that_is_not_sealed_is_refused_as_such(gek):
+ """
+ Not an empty payload, and not a crash on a missing key — the two shapes a
+ caller might otherwise paper over.
+ """
+ with pytest.raises(ValueError):
+ unseal(gek, PURPOSE_INDEX, "index_sync", "g1", {"entries": []})
+
+
+def test_a_fresh_nonce_per_message(gek):
+ """
+ Never derived from the payload: two identical payloads under one long-lived
+ subkey would then reuse a nonce, which for GCM is a total break.
+ """
+ nonces = {seal(gek, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD)["nonce"]
+ for _ in range(50)}
+ assert len(nonces) == 50
+
+
+def test_no_key_is_an_error_not_a_plaintext_fallback(gek):
+ with pytest.raises(ValueError):
+ seal(None, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD)
+
+
+def test_an_unknown_purpose_is_refused(gek):
+ with pytest.raises(ValueError):
+ group_key(gek, "chat")
+
+
+def test_the_envelope_carries_no_readable_payload(gek):
+ """
+ The property, at the level of the primitive: what `seal` returns holds nothing
+ of what went in. `test_index_no_cleartext.py` asserts the same thing on the
+ real frames.
+ """
+ payload = {"video_root": "holidays-2019-invoices", "entries": ["ledger.pdf"]}
+ frame = msgpack.packb(seal(gek, PURPOSE_ACK, "handshake_ack", "g1", payload))
+ for word in (b"holidays", b"invoices", b"ledger", b"video_root"):
+ assert word not in frame
diff --git a/packages/meshbay-common/tests/test_js_python_parity.py b/packages/meshbay-common/tests/test_js_python_parity.py
index 340ea3e..6f7437f 100644
--- a/packages/meshbay-common/tests/test_js_python_parity.py
+++ b/packages/meshbay-common/tests/test_js_python_parity.py
@@ -16,6 +16,7 @@ Skipped when node is unavailable; that is a coverage gap, not a pass.
import json
import shutil
import subprocess
+import tempfile
from pathlib import Path
import pytest
@@ -234,3 +235,159 @@ def test_length_prefixing_actually_disambiguates(js_output):
a = js_output["handshake"][2] # group_id "g"
b = js_output["handshake"][3] # group_id ""
assert a != b, "JS transcripts collide across different group ids"
+
+
+# ── groupbox: the sealed payload, both directions ────────────────────────────
+#
+# Unlike the transcripts above, this one has a wire format to disagree about as
+# well as a derivation: the HKDF salt (Python's `salt=None` against WebCrypto's
+# `salt: new Uint8Array(0)`) and the AAD's UTF-8 encoding are both invisible to
+# every other test, and a disagreement in either means no browser can open an
+# index or a handshake ack from any node — with the AEAD reporting only "it did
+# not open", which is the same thing a wrong key reports.
+
+# (purpose, msg_type, group_id)
+GROUPBOX_VECTORS = [
+ ("index", "index_sync", "g" * 32),
+ ("index", "index_delta", "g" * 32),
+ ("ack", "handshake_ack", "g" * 32),
+ # Empty group id — the operator-pairing shape, and the one a naive
+ # concatenation would let collide with a short id.
+ ("ack", "handshake_ack", ""),
+ # Non-ASCII: TextEncoder and Python's .encode() must agree on the AAD.
+ ("index", "index_sync", "groupe-café-日本"),
+ # A '|' inside the group id, which is the AAD's own separator.
+ ("index", "index_sync", "a|b"),
+]
+
+GROUPBOX_GEK = bytes.fromhex("5a" * 32)
+
+_GROUPBOX_HARNESS = r"""
+const fs = require('fs');
+
+globalThis.window = {};
+const src = fs.readFileSync(process.argv[2], 'utf8');
+const M = new Function(src + '\nreturn { sealGroup, openGroup };')();
+
+const hex = (s) => {
+ const out = new Uint8Array(s.length / 2);
+ for (let i = 0; i < s.length; i += 2) out[i / 2] = parseInt(s.substr(i, 2), 16);
+ return out;
+};
+const toHex = (u8) =>
+ Array.from(u8).map(b => b.toString(16).padStart(2, '0')).join('');
+
+(async () => {
+ const input = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
+ const gek = hex(input.gek);
+ const out = { opened: [], sealed: [] };
+
+ for (const v of input.vectors) {
+ // Python sealed it; open it here.
+ out.opened.push(toHex(await M.openGroup(
+ gek, v.purpose, v.msg_type, v.group_id,
+ { nonce: hex(v.nonce), ct: hex(v.ct) })));
+ // Seal the same plaintext here, for Python to open.
+ const sealed = await M.sealGroup(
+ gek, v.purpose, v.msg_type, v.group_id, hex(v.plaintext));
+ out.sealed.push({ nonce: toHex(sealed.nonce), ct: toHex(sealed.ct) });
+ }
+
+ process.stdout.write(JSON.stringify(out));
+})().catch((e) => { console.error(e); process.exit(1); });
+"""
+
+
+def _groupbox_payload(idx: int) -> dict:
+ """A distinct payload per vector, so a crossed result cannot pass."""
+ return {"n": idx, "name": f"entry-{idx}.bin", "flags": [True, None, idx * 7]}
+
+
+@pytest.fixture(scope="module")
+def groupbox_js(tmp_path_factory):
+ import msgpack
+
+ from meshbay_common.groupbox import seal
+
+ d = tmp_path_factory.mktemp("groupbox-parity")
+ harness = d / "harness.js"
+ harness.write_text(_GROUPBOX_HARNESS)
+
+ vectors = []
+ for i, (purpose, msg_type, group_id) in enumerate(GROUPBOX_VECTORS):
+ payload = _groupbox_payload(i)
+ sealed = seal(GROUPBOX_GEK, purpose, msg_type, group_id, payload)
+ vectors.append({
+ "purpose": purpose, "msg_type": msg_type, "group_id": group_id,
+ "nonce": sealed["nonce"].hex(), "ct": sealed["ct"].hex(),
+ "plaintext": msgpack.packb(payload, use_bin_type=True).hex(),
+ })
+
+ payload_file = d / "vectors.json"
+ payload_file.write_text(json.dumps({"gek": GROUPBOX_GEK.hex(),
+ "vectors": vectors}))
+
+ proc = subprocess.run(
+ ["node", str(harness), str(CRYPTO_JS), str(payload_file)],
+ capture_output=True, text=True, timeout=60,
+ )
+ if proc.returncode != 0:
+ pytest.fail(f"node groupbox harness failed:\n{proc.stderr}")
+ return json.loads(proc.stdout)
+
+
+@pytest.mark.parametrize("idx,vector", list(enumerate(GROUPBOX_VECTORS)))
+def test_browser_opens_what_python_sealed(idx, vector, groupbox_js):
+ """A mismatch means no browser can read an index or a handshake ack."""
+ import msgpack
+
+ opened = bytes.fromhex(groupbox_js["opened"][idx])
+ assert msgpack.unpackb(opened, raw=False) == _groupbox_payload(idx), (
+ f"crypto.js and groupbox.py disagree for {vector!r}")
+
+
+@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.
+ """
+ from meshbay_common.groupbox import unseal
+
+ purpose, msg_type, group_id = vector
+ sealed = groupbox_js["sealed"][idx]
+ msg = {"nonce": bytes.fromhex(sealed["nonce"]),
+ "ct": bytes.fromhex(sealed["ct"])}
+ assert unseal(GROUPBOX_GEK, purpose, msg_type, group_id, msg) == \
+ _groupbox_payload(idx)
+
+
+def test_the_browser_refuses_a_payload_sealed_for_another_message(groupbox_js):
+ """
+ The AAD, checked across the boundary rather than only within Python: a JS
+ `openGroup` that dropped `additionalData` would still round-trip against
+ itself and against Python, and would pass every other test here.
+ """
+ from meshbay_common.groupbox import seal
+
+ sealed = seal(GROUPBOX_GEK, "index", "index_sync", "g1", {"x": 1})
+ script = (
+ "globalThis.window = {};\n"
+ "const fs = require('fs');\n"
+ "const M = new Function(fs.readFileSync(process.argv[2], 'utf8')\n"
+ " + '\\nreturn { openGroup };')();\n"
+ "const hex = (s) => Uint8Array.from(s.match(/../g).map(b => parseInt(b, 16)));\n"
+ "M.openGroup(hex(process.argv[3]), 'index', 'index_delta', 'g1',\n"
+ " { nonce: hex(process.argv[4]), ct: hex(process.argv[5]) })\n"
+ " .then(() => { console.log('OPENED'); })\n"
+ " .catch(() => { console.log('REFUSED'); });\n"
+ )
+ with tempfile.TemporaryDirectory() as tmp:
+ h = Path(tmp) / "aad.js"
+ h.write_text(script)
+ proc = subprocess.run(
+ ["node", str(h), str(CRYPTO_JS), GROUPBOX_GEK.hex(),
+ sealed["nonce"].hex(), sealed["ct"].hex()],
+ capture_output=True, text=True, timeout=60)
+ assert proc.stdout.strip() == "REFUSED", proc.stdout + proc.stderr
diff --git a/packages/meshbay-common/tests/test_version_negotiation.py b/packages/meshbay-common/tests/test_version_negotiation.py
new file mode 100644
index 0000000..50bdf54
--- /dev/null
+++ b/packages/meshbay-common/tests/test_version_negotiation.py
@@ -0,0 +1,78 @@
+"""
+The version range, checked rather than merely written.
+
+Before MNP 1.0 every message carried a `v` that no one read, so a mismatch
+surfaced as a *missing field*: an 0.x client reading a 1.0 handshake ack finds no
+`enabled_apps` and applies its documented fallback — show every app — which is a
+wrong answer rather than an error. The three failure modes 1.0's flag day was
+called for all present that way, which is why negotiation ships in the same
+deployment rather than after it (phase 15.6, decision D2).
+"""
+
+import pytest
+from meshbay_common import MNP_VERSION
+from meshbay_common.handshake import (
+ MNP_MIN_SUPPORTED,
+ HandshakeError,
+ check_version,
+ parse_version,
+)
+
+
+def test_this_build_accepts_itself():
+ check_version(MNP_VERSION, MNP_MIN_SUPPORTED)
+
+
+def test_a_peer_that_declares_no_minimum_is_read_as_speaking_only_its_own():
+ """
+ Which is the right reading of every 0.x peer: none of them declared a range,
+ because none of them checked one.
+ """
+ check_version(MNP_VERSION)
+
+
+def test_an_older_peer_is_refused_with_a_code():
+ with pytest.raises(HandshakeError) as caught:
+ check_version("0.15")
+ assert caught.value.code == "version_too_old"
+ # The text is for a human and may be reworded; the client matches the code.
+ assert "0.15" in str(caught.value)
+
+
+def test_a_peer_requiring_more_than_we_speak_is_refused_with_a_code():
+ ours = parse_version(MNP_VERSION)
+ future = f"{ours[0] + 1}.0"
+ with pytest.raises(HandshakeError) as caught:
+ check_version(future, future)
+ assert caught.value.code == "version_too_new"
+
+
+def test_a_newer_peer_that_still_accepts_us_is_allowed():
+ """
+ The point of a range rather than an equality: a 1.4 node that still speaks to
+ 1.0 clients must not refuse one.
+ """
+ ours = parse_version(MNP_VERSION)
+ check_version(f"{ours[0]}.{ours[1] + 4}", MNP_MIN_SUPPORTED)
+
+
+@pytest.mark.parametrize("bad", ["", "one.two", "1", None, "1.0.0", "v1.0"])
+def test_an_unreadable_version_is_refused_not_guessed(bad):
+ with pytest.raises(HandshakeError) as caught:
+ check_version(bad)
+ assert caught.value.code == "version_unreadable"
+
+
+def test_versions_order_numerically_not_lexically():
+ """`"0.9" < "0.15"` as strings, and the opposite as versions."""
+ assert parse_version("0.9") < parse_version("0.15") < parse_version("1.0")
+
+
+def test_mnp_1_0_is_a_major_bump():
+ """
+ Recorded as a test because the number is the only thing that says "this one is
+ different". 1.0 seals the index and the ack under the group key: no 0.x peer
+ can open either, and there is nothing to be compatible with.
+ """
+ assert parse_version(MNP_VERSION) >= (1, 0)
+ assert parse_version(MNP_MIN_SUPPORTED) >= (1, 0)
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
index 69b8c7a..d2e19b7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
@@ -109,6 +109,80 @@ async function decryptChunkBin(gek, fileHashHex, chunkIndex, nonce, ct) {
return new Uint8Array(plaintext);
}
+// ── Sealing a payload under the group key ────────────────────────────────────
+//
+// Mirrors meshbay_common/groupbox.py. `index_sync`, `index_delta` and the
+// `handshake_ack` config payload travel sealed under a GEK-derived subkey; the
+// routing fields (type, v, group_id) and the ack's own authentication (node_pk,
+// proof, sig) stay in clear, because a receiver must route, version-check and
+// *authenticate* before it would trust a decryption.
+//
+// These take and return BYTES, not objects, and that is not an oversight:
+// msgpack here is a minimal hand-written codec private to transport.js, exported
+// to nothing (both files are classic scripts on globals, not ES modules). Making
+// this layer take objects would mean duplicating that codec or reaching across a
+// boundary that does not exist — both worse than one extra line at the call site.
+
+const GROUPBOX_INFO = {
+ index: new TextEncoder().encode('meshbay:index:v1'),
+ ack: new TextEncoder().encode('meshbay:ack:v1'),
+};
+
+/**
+ * Derive the AES-256-GCM subkey for one purpose.
+ * `salt: new Uint8Array(0)` matches Python's `salt=None` — RFC 5869 extracts with
+ * a zero key either way, which is what deriveChunkKey above already relies on.
+ */
+async function groupKey(gek, purpose, usages) {
+ const info = GROUPBOX_INFO[purpose];
+ if (!info) throw new Error(`unknown groupbox purpose: ${purpose}`);
+ const gekKey = gek instanceof CryptoKey
+ ? gek
+ : await crypto.subtle.importKey('raw', gek, 'HKDF', false, ['deriveKey']);
+ return crypto.subtle.deriveKey(
+ { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info },
+ gekKey,
+ { name: 'AES-GCM', length: 256 },
+ false,
+ usages,
+ );
+}
+
+/** What the ciphertext is bound to: this message type, in this group. */
+function groupAad(msgType, groupId) {
+ return new TextEncoder().encode(`${msgType}|${groupId}`);
+}
+
+/**
+ * Open a sealed payload. Throws on anything that does not open — a caller must
+ * never turn that into an empty index or an empty app list (groupbox.py's
+ * `unseal` says why at length).
+ * @returns {Promise<Uint8Array>} the msgpack bytes of the payload
+ */
+async function openGroup(gek, purpose, msgType, groupId, msg) {
+ if (!msg || !msg.nonce || !msg.ct) {
+ throw new Error(`${msgType}: not a sealed message`);
+ }
+ const key = await groupKey(gek, purpose, ['decrypt']);
+ const plain = await crypto.subtle.decrypt(
+ { name: 'AES-GCM', iv: msg.nonce, additionalData: groupAad(msgType, groupId) },
+ key, msg.ct);
+ return new Uint8Array(plain);
+}
+
+/**
+ * Seal payload bytes. Returns the `{nonce, ct}` pair to merge into a message.
+ */
+async function sealGroup(gek, purpose, msgType, groupId, plaintextBytes) {
+ const key = await groupKey(gek, purpose, ['encrypt']);
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
+ const ct = await crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv: nonce, additionalData: groupAad(msgType, groupId) },
+ key, plaintextBytes);
+ return { nonce, ct: new Uint8Array(ct) };
+}
+
+
// ── GEK generation + ECIES wrapping ──────────────────────────────────────────
function generateGEK() {
@@ -372,6 +446,7 @@ async function verifyNodeSignature(nodePkB64, sigB64, transcript) {
// Export for use in app.js
window.MeshBayCrypto = {
importGEK, deriveChunkKey, decryptChunkBin,
+ openGroup, sealGroup,
generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode,
adminTranscript, handshakeTranscript, handshakeProof, webrtcBinding,
joinTranscript, verifyNodeSignature, constantTimeEqual,
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 7d13260..466af53 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -392,6 +392,16 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
if (cancelled) return;
applyIndexDelta(msg);
};
+ // A pushed message that will not open under the group key ends the
+ // session (transport.js _failSession). Nothing is waiting on a push, so
+ // without this the page would keep showing a stale index with nothing
+ // wrong on screen — the worst of the three failure shapes.
+ transport.onSessionFailed = (err) => {
+ if (cancelled) return;
+ setError(err.message);
+ setStatus('error');
+ if (onPresence) onPresence(groupId, 'online');
+ };
// We are in: an invitation to this group has served its purpose.
if (onJoined) onJoined(groupId);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 62ca2d9..329baf1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -178,6 +178,30 @@ window.addEventListener('hashchange', () => {
if (location.hash === '#mb-debug') _showTraceView();
});
+// This build's half of the version range (meshbay_common/handshake.py's
+// MNP_VERSION and MNP_MIN_SUPPORTED). Declared on the handshake — the only
+// message where it is read — so a node we cannot speak to refuses us with a
+// code, instead of the mismatch surfacing as a field that is not there.
+//
+// The `v: '0.1'` on every other message in this file is the historical value
+// and is read by nothing; it is left alone deliberately. The range is
+// negotiated once, at the start, not restated per message.
+const MNP_V = '1.0';
+const MNP_V_MIN = '1.0';
+
+// Codes a NODE sends us, in its own vocabulary (meshbay_common/handshake.py's
+// check_version): `version_too_old` means *we* are too old for it,
+// `version_too_new` that it is too old for what we require. The client's own
+// check of the node names its two conditions separately — see
+// _checkNodeVersion, where reusing this table's wording would read backwards.
+const HANDSHAKE_REFUSALS = {
+ version_too_old: 'This page is older than the node it is talking to. '
+ + 'Reload to pick up the current version.',
+ version_too_new: 'This node is running an older MeshBay than this page needs. '
+ + 'Its operator has to update it.',
+ version_unreadable: 'The node could not read this page\'s protocol version.',
+};
+
const JOIN_REFUSALS = {
code_required: 'This node does not know this browser yet. Ask the node operator '
+ 'for a pairing code (meshbay-node operator pair).',
@@ -278,6 +302,9 @@ class MeshBayTransport {
set onPhotoRoots(fn) { this._onPhotoRoots = fn; }
set onMusicbrainzEnabled(fn) { this._onMusicbrainzEnabled = fn; }
set onIndexProgress(fn) { this._onIndexProgress = fn; }
+ // Fired when a message that must open under the group key does not —
+ // see _failSession. The session is over by the time this runs.
+ set onSessionFailed(fn) { this._onSessionFailed = fn; }
// Fired once an automatic reconnect (see _reconnectLoop) lands a fresh
// handshake, so a consumer with something mid-flight on the old channel —
// today only the video player — can pick back up rather than sit dead.
@@ -523,7 +550,8 @@ class MeshBayTransport {
const reply = await this._sendAndWait({
type: 'handshake',
- v: '0.1',
+ v: MNP_V,
+ v_min: MNP_V_MIN,
token: jwtToken,
group_id: groupId || '',
nonce: window.MeshBayCrypto.b64encode(this._nonceClient),
@@ -531,6 +559,10 @@ class MeshBayTransport {
console.log('[MeshBay] Handshake reply:', reply.type);
if (reply.type === 'handshake_challenge') {
+ // The node's half of the range. Checked before anything else in this
+ // block, because everything below — the join, the proof, the sealed ack
+ // — assumes both sides mean the same thing by each message.
+ _checkNodeVersion(reply);
if (!window.MeshBayCrypto) {
throw new Error('Node requires GEK proof but no crypto available');
}
@@ -708,6 +740,30 @@ class MeshBayTransport {
_checkNodePin(nodeId, ack.node_pk);
this.nodePk = ack.node_pk;
+ // Verify, then decrypt — in that order, and the order is the point. Every
+ // check above decides whether this peer is worth trusting at all; opening
+ // the payload first would mean acting on data from a peer we have not yet
+ // authenticated.
+ //
+ // A payload that does not open aborts the connection. It is emphatically
+ // not an empty config: `enabled_apps` missing reads as "the operator
+ // disabled every app" (the documented client-side fallback is the
+ // opposite — show them all), and either reading is indistinguishable from
+ // a legitimate state, which is what makes a silent fallback worse than a
+ // stop.
+ let config;
+ try {
+ config = msgpack_decode(
+ await C.openGroup(gekRaw, 'ack', 'handshake_ack', gid, ack));
+ } catch (e) {
+ throw new Error(
+ 'handshake_ack did not open under the group key — refusing connection: '
+ + (e && e.message || e));
+ }
+ delete ack.nonce;
+ delete ack.ct;
+ Object.assign(ack, config);
+
return ack;
}
@@ -716,7 +772,8 @@ class MeshBayTransport {
// peer skip proving GEK possession entirely (C3/C6).
console.warn('[MeshBay] Handshake rejected:', reply.detail, 'code:', reply.code);
const rejected = new Error(
- 'MNP handshake rejected: ' + (reply.detail || `unexpected ${reply.type}`));
+ HANDSHAKE_REFUSALS[reply.code]
+ || ('MNP handshake rejected: ' + (reply.detail || `unexpected ${reply.type}`)));
rejected.reason = reply.code || '';
throw rejected;
}
@@ -858,6 +915,12 @@ class MeshBayTransport {
return resp;
}
+ /**
+ * The full index. Resolves with the sealed payload already opened —
+ * `_applyIndexMessage` does that before it hands the message to whoever is
+ * waiting, so both the reply to this call and the node's own unsolicited
+ * pushes go through one decrypt path.
+ */
async fetchIndex() {
const msg = await this._sendAndWait({ type: 'index_sync', v: '0.1' });
if (msg.type === 'error') throw new Error(msg.detail);
@@ -1876,6 +1939,61 @@ class MeshBayTransport {
// ── Internal ──────────────────────────────────────────────────────────────
+ /**
+ * Queue one sealed index message for opening.
+ *
+ * Opening is asynchronous and `_dispatch` is not, so two messages handled
+ * independently would be applied in whichever order their decrypt promises
+ * happened to settle. A delta applied before the sync it is based on — or
+ * before an earlier delta — is a silently wrong view of the group, so they
+ * are opened one at a time, in arrival order.
+ */
+ _queueIndexMessage(msg) {
+ this._indexChain = (this._indexChain || Promise.resolve())
+ .then(() => this._applyIndexMessage(msg))
+ .catch((e) => this._failSession(
+ `${msg.type} did not open under the group key`, e));
+ }
+
+ async _applyIndexMessage(msg) {
+ const groupId = msg.group_id || (this._connectArgs && this._connectArgs.groupId) || '';
+ const payload = msgpack_decode(await window.MeshBayCrypto.openGroup(
+ this._gekRaw, 'index', msg.type, groupId, msg));
+ // The routing fields stay, the envelope's own two go, the payload lands on
+ // top — so every consumer keeps reading the flat message it always read.
+ const opened = { ...msg, ...payload };
+ delete opened.nonce;
+ delete opened.ct;
+
+ if (msg.type === 'index_sync') {
+ if (this._onIndexSync) this._onIndexSync(opened);
+ for (const [, handler] of this._pending) {
+ if (handler._reqType === 'index_sync') {
+ handler.resolve(opened);
+ break;
+ }
+ }
+ return;
+ }
+ if (this._onIndexDelta) this._onIndexDelta(opened);
+ }
+
+ /**
+ * Stop, rather than carry on with a degraded view.
+ *
+ * A payload that does not open is not an empty index and not a config
+ * change — it is a peer we cannot talk to. Reconnecting would only reach the
+ * same peer with the same key, so the session ends and the failure is named.
+ */
+ _failSession(what, cause) {
+ const err = new Error(`${what}: ${(cause && cause.message) || cause}`);
+ console.error('[MeshBay]', err.message);
+ for (const [, handler] of this._pending) handler.reject(err);
+ this._pending.clear();
+ if (this._onSessionFailed) this._onSessionFailed(err);
+ this.close();
+ }
+
async _sendAndWait(obj, timeoutMs = 30000) {
// A reconnect already in flight (see _reconnectLoop) means the channel
// this would send on is the one just declared dead. `_inReconnectAttempt`
@@ -2166,24 +2284,16 @@ class MeshBayTransport {
return;
}
- if (msg.type === 'index_sync' && msg.entries) {
- if (this._onIndexSync) this._onIndexSync(msg);
- for (const [, handler] of this._pending) {
- if (handler._reqType === 'index_sync') {
- handler.resolve(msg);
- break;
- }
- }
- return;
- }
-
- // Incremental update — additions/deletions/updates, never the whole
- // index. Only ever arrives after the full index this browser already
- // has (the node's first push to a newly connected peer is always
- // index_sync, see daemon.py _broadcast_index_change), so there is
- // always a base to apply it to.
- if (msg.type === 'index_delta') {
- if (this._onIndexDelta) this._onIndexDelta(msg);
+ // Both index messages carry their payload sealed under a GEK-derived
+ // subkey (MNP 1.0), so they cannot be acted on from here — _dispatch is
+ // synchronous and opening one is not. `index_delta` is the incremental
+ // form: additions/deletions/updates, never the whole index, and it only
+ // ever arrives after the full index this browser already has (the node's
+ // first push to a newly connected peer is always index_sync, see
+ // daemon.py _broadcast_index_change), so there is always a base to apply
+ // it to.
+ if (msg.type === 'index_sync' || msg.type === 'index_delta') {
+ this._queueIndexMessage(msg);
return;
}
@@ -2604,6 +2714,46 @@ function _extractDtlsFingerprint(sdp) {
const NODE_PIN_PREFIX = 'mb_nodepin_';
+/**
+ * The node's half of the version range, from `handshake_challenge`.
+ *
+ * Mirrors meshbay_common/handshake.py::check_version(). A node that declares no
+ * range at all is a node that predates negotiation — that is every 0.x node, and
+ * none of them can serve a sealed index or a sealed ack — so it is refused here
+ * rather than left to fail later as a message that will not open.
+ */
+function _checkNodeVersion(reply) {
+ const parse = (v) => {
+ const m = /^(\d+)\.(\d+)$/.exec(String(v || ''));
+ return m ? [Number(m[1]), Number(m[2])] : null;
+ };
+ const cmp = (a, b) => (a[0] - b[0]) || (a[1] - b[1]);
+ const fail = (reason, message) => {
+ const e = new Error(message);
+ e.reason = reason;
+ throw e;
+ };
+
+ const theirs = parse(reply.v);
+ if (!theirs) {
+ fail('node_version_unreadable',
+ 'The node did not declare a readable protocol version.');
+ }
+ // No declared minimum means "only what I speak" — the correct reading of a
+ // node from before this field existed.
+ const theirMin = parse(reply.v_min) || theirs;
+ if (cmp(theirs, parse(MNP_V_MIN)) < 0) {
+ fail('node_too_old',
+ 'This node is running an older MeshBay than this page needs. '
+ + 'Its operator has to update it.');
+ }
+ if (cmp(theirMin, parse(MNP_V)) > 0) {
+ fail('client_too_old',
+ 'This page is older than the node it is talking to. '
+ + 'Reload to pick up the current version.');
+ }
+}
+
function _checkNodePin(nodeId, nodePk) {
if (!nodeId || !nodePk) return;
const key = NODE_PIN_PREFIX + nodeId;
diff --git a/packages/meshbay-hub/tests/harness/index_seal_probe.mjs b/packages/meshbay-hub/tests/harness/index_seal_probe.mjs
new file mode 100644
index 0000000..36302bb
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/index_seal_probe.mjs
@@ -0,0 +1,98 @@
+/**
+ * Does the browser open a sealed index — and does it stop when it cannot?
+ *
+ * Drives **the real `MeshBayTransport` over the real `crypto.js`**, fed real
+ * length-prefixed msgpack frames built by Python's `groupbox.seal`. Only the DOM
+ * and the DataChannel are stand-ins; the framing, the msgpack decode, the
+ * dispatch, the HKDF and the AES-GCM are all the shipped code.
+ *
+ * It exists because the two things worth knowing here are invisible to a
+ * source-reading test. The first is §3.4: a payload that does not open must
+ * *raise*, never become an empty index — "this group has no files" is a
+ * legitimate state, so a silent fallback is indistinguishable from the truth.
+ * The second is ordering: opening is asynchronous while `_dispatch` is not, so
+ * two index messages could be applied in whichever order their decrypt promises
+ * happened to settle, and a delta applied before its base is a silently wrong
+ * view of the group.
+ *
+ * node index_seal_probe.mjs <static-dir> <vectors.json>
+ *
+ * Prints JSON: `events` in the order they were delivered, and `fetchIndex`, how
+ * the outstanding request ended.
+ */
+import fs from 'fs';
+
+const STATIC = process.argv[2];
+const input = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
+
+// The transport logs to the console on the paths under test; stdout is this
+// probe's JSON result, so everything it says goes to stderr instead.
+for (const level of ['log', 'warn', 'error', 'info', 'debug']) {
+ console[level] = (...args) => process.stderr.write(args.join(' ') + '\n');
+}
+
+// Just enough DOM for two classic scripts that expect a page.
+globalThis.window = globalThis;
+globalThis.addEventListener = () => {};
+globalThis.removeEventListener = () => {};
+globalThis.location = { hash: '' };
+globalThis.document = {
+ addEventListener() {}, removeEventListener() {}, visibilityState: 'visible',
+};
+
+new Function(fs.readFileSync(`${STATIC}/crypto.js`, 'utf8'))();
+new Function(fs.readFileSync(`${STATIC}/transport.js`, 'utf8'))();
+
+const hex = (s) => Uint8Array.from(s.match(/../g).map((b) => parseInt(b, 16)));
+
+const events = [];
+const tp = new window.MeshBayTransport('', 'token');
+tp._connected = true;
+tp._channel = { readyState: 'open', send() {}, close() {} };
+tp._pc = { close() {} };
+tp._gekRaw = hex(input.gek);
+tp._connectArgs = { groupId: input.group_id };
+
+tp.onIndexSync = (msg) => events.push({
+ event: 'index_sync',
+ entries: (msg.entries || []).map((e) => e.name),
+ dirs: msg.dirs || [],
+ version: msg.version,
+ // Present on the message a consumer sees? The envelope's own fields should
+ // be gone, and the payload's should have taken their place.
+ hasCiphertext: 'ct' in msg || 'nonce' in msg,
+});
+tp.onIndexDelta = (msg) => events.push({
+ event: 'index_delta',
+ additions: (msg.additions || []).map((e) => e.name),
+ base_version: msg.base_version,
+ version: msg.version,
+});
+tp.onSessionFailed = (err) => events.push({ event: 'session_failed', message: err.message });
+
+// One outstanding fetchIndex, so the probe can say what a *waiting caller* is
+// told — which is the half of §3.4 a callback cannot show.
+const fetchOutcome = { state: 'pending' };
+tp._send = () => {};
+tp.fetchIndex()
+ .then((msg) => { fetchOutcome.state = 'resolved';
+ fetchOutcome.entries = (msg.entries || []).map((e) => e.name); })
+ .catch((e) => { fetchOutcome.state = 'rejected'; fetchOutcome.message = e.message; });
+
+const closed = { count: 0 };
+const realClose = tp.close.bind(tp);
+tp.close = () => { closed.count += 1; realClose(); };
+
+(async () => {
+ // Delivered exactly as the DataChannel delivers them: one call per frame, in
+ // order, with no await between.
+ for (const frame of input.frames) tp._onMessage(hex(frame).buffer);
+
+ // Let the opening chain drain. Each message costs two WebCrypto promises, so
+ // a handful of turns is not enough to be sure; a real delay is.
+ await new Promise((r) => setTimeout(r, 200));
+
+ process.stdout.write(JSON.stringify({
+ events, fetchIndex: fetchOutcome, closed: closed.count,
+ }));
+})();
diff --git a/packages/meshbay-hub/tests/test_index_seal_client.py b/packages/meshbay-hub/tests/test_index_seal_client.py
new file mode 100644
index 0000000..ca2c7a2
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_index_seal_client.py
@@ -0,0 +1,143 @@
+"""
+The browser half of MNP 1.0's sealed index, measured rather than read.
+
+`test_index_no_cleartext.py` proves the node sends no filename in the clear. This
+proves the client can still read one — and, the part that matters more, that it
+*stops* when it cannot instead of reporting an empty group.
+
+Driven through `harness/index_seal_probe.mjs`, which runs the shipped
+`transport.js` over the shipped `crypto.js` and is fed real frames built here.
+A source-reading test could show that `openGroup` is called; only this can show
+what a waiting `fetchIndex()` is told when it throws.
+"""
+
+import json
+import shutil
+import struct
+import subprocess
+import tempfile
+from pathlib import Path
+
+import msgpack
+import pytest
+from meshbay_common.crypto import generate_gek
+from meshbay_common.groupbox import PURPOSE_INDEX, seal
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+PROBE = Path(__file__).resolve().parent / "harness" / "index_seal_probe.mjs"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not PROBE.exists(),
+ reason="node unavailable — the client half cannot be measured",
+)
+
+GROUP = "g-probe"
+GEK = generate_gek()
+
+
+def _entry(name: str) -> dict:
+ return {"id": "ab" * 32, "name": name, "path": "library", "size": 10,
+ "type": "file", "added_at": 0, "uploader_id": ""}
+
+
+def _frame(msg: dict) -> str:
+ body = msgpack.packb(msg, use_bin_type=True)
+ return (struct.pack(">I", len(body)) + body).hex()
+
+
+def _sync_frame(gek: bytes, *names: str, version: int = 3) -> str:
+ payload = {"version": version, "entries": [_entry(n) for n in names],
+ "dirs": ["library"], "roots": [{"name": "library"}]}
+ return _frame({"type": "index_sync", "v": "1.0", "group_id": GROUP,
+ **seal(gek, PURPOSE_INDEX, "index_sync", GROUP, payload)})
+
+
+def _delta_frame(gek: bytes, name: str, base: int, version: int) -> str:
+ payload = {"base_version": base, "version": version,
+ "additions": [_entry(name)], "deletions": [], "updates": []}
+ return _frame({"type": "index_delta", "v": "1.0", "group_id": GROUP,
+ **seal(gek, PURPOSE_INDEX, "index_delta", GROUP, payload)})
+
+
+def _run(frames: list[str], gek: bytes = GEK) -> dict:
+ with tempfile.TemporaryDirectory() as tmp:
+ vectors = Path(tmp) / "vectors.json"
+ vectors.write_text(json.dumps(
+ {"gek": gek.hex(), "group_id": GROUP, "frames": frames}))
+ proc = subprocess.run(
+ ["node", str(PROBE), str(STATIC), str(vectors)],
+ capture_output=True, text=True, timeout=120)
+ if proc.returncode != 0:
+ pytest.fail(f"probe failed:\n{proc.stderr}")
+ return json.loads(proc.stdout)
+
+
+def test_a_sealed_index_reaches_the_consumer_intact():
+ out = _run([_sync_frame(GEK, "a-film.mkv", "another.mkv")])
+
+ assert [e["event"] for e in out["events"]] == ["index_sync"]
+ sync = out["events"][0]
+ assert sync["entries"] == ["a-film.mkv", "another.mkv"]
+ assert sync["dirs"] == ["library"]
+ # Moved inside the payload (D4) and still delivered flat, so no consumer had
+ # to change: it reads the same message it always read.
+ assert sync["version"] == 3
+ assert not sync["hasCiphertext"], "the envelope's own fields leaked to consumers"
+
+ # And the waiting caller gets the opened form, not the envelope.
+ assert out["fetchIndex"]["state"] == "resolved"
+ assert out["fetchIndex"]["entries"] == ["a-film.mkv", "another.mkv"]
+
+
+def test_an_index_that_does_not_open_ends_the_session():
+ """
+ §3.4, and the reason it is a rule rather than a preference. An empty
+ `entries` is a legitimate state — a group whose operator has shared nothing
+ yet — so a client that fell back to one would show the same screen for
+ "nothing here" and for "we could not decrypt anything this node sent".
+ """
+ out = _run([_sync_frame(generate_gek(), "a-film.mkv")])
+
+ kinds = [e["event"] for e in out["events"]]
+ assert "index_sync" not in kinds, "a failed decrypt was reported as an index"
+ assert kinds == ["session_failed"]
+ assert "index_sync" in out["events"][0]["message"], (
+ "the failure must name the message type that could not be opened")
+
+ # The caller is told, rather than left to time out 30 s later.
+ assert out["fetchIndex"]["state"] == "rejected"
+ assert "index_sync" in out["fetchIndex"]["message"]
+ assert out["closed"] == 1, "the session carried on after an unopenable message"
+
+
+def test_a_delta_that_does_not_open_ends_the_session_too():
+ """
+ The delta has no caller waiting on it — it is pushed — so a silent failure
+ here would leave a browser showing a stale index with nothing wrong on
+ screen, which is the worst of the three shapes.
+ """
+ out = _run([_sync_frame(GEK, "a-film.mkv"),
+ _delta_frame(generate_gek(), "new.mkv", 3, 4)])
+
+ assert [e["event"] for e in out["events"]] == ["index_sync", "session_failed"]
+ assert "index_delta" in out["events"][1]["message"]
+ assert out["closed"] == 1
+
+
+def test_deltas_are_applied_in_arrival_order():
+ """
+ Opening is asynchronous and `_dispatch` is not. Two messages opened
+ independently settle in whichever order WebCrypto finishes them, and a delta
+ applied before the one it follows is a wrong view of the group that nothing
+ reports. Three deltas in one burst is the cheapest way to force the race.
+ """
+ frames = [_sync_frame(GEK, "a-film.mkv")]
+ frames += [_delta_frame(GEK, f"added-{i}.mkv", 3 + i, 4 + i) for i in range(3)]
+
+ out = _run(frames)
+
+ assert [e["event"] for e in out["events"]] == [
+ "index_sync", "index_delta", "index_delta", "index_delta"]
+ assert [e["additions"][0] for e in out["events"][1:]] == [
+ "added-0.mkv", "added-1.mkv", "added-2.mkv"]
+ assert [e["base_version"] for e in out["events"][1:]] == [3, 4, 5]
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")
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 3a024f0..7341423 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -37,7 +37,7 @@ from pathlib import Path
import uvicorn
from meshbay_common import MNP_VERSION
-from meshbay_common.protocol import MNP, index_entry_wire
+from meshbay_common.protocol import MNP
from meshbay_node.audit import AuditStore
from meshbay_node.bundle_store import BundleStore
from meshbay_node.chat.store import ChatStore
@@ -58,6 +58,7 @@ from meshbay_node.transport import (
QUIC_AVAILABLE,
WEBRTC_AVAILABLE,
)
+from meshbay_node.transport.wire import index_delta_message, index_sync_message
if QUIC_AVAILABLE:
from meshbay_node.transport import QuicChunkServer
@@ -1003,6 +1004,13 @@ class NodeDaemon:
def _push_index_progress(self, group_id: str, progress) -> None:
if not self._webrtc:
return
+ # Deliberately NOT sealed, unlike index_sync/index_delta (decision D3).
+ # Counters only — never a path, never a filename, see IndexProgress in
+ # indexer.py — pushed every couple of seconds for the whole length of a
+ # scan. Sealing it would buy an attacker's rough estimate of a library's
+ # size and cost a key derivation and a decrypt per push. If a field that
+ # names anything is ever added here, that trade is void and this message
+ # joins the other two.
msg = {
"type": MNP.INDEX_PROGRESS,
"v": MNP_VERSION,
@@ -1135,36 +1143,25 @@ class NodeDaemon:
# 11.5 — Push to connected WebRTC peers in this group
if self._webrtc:
- if delta is not None:
- msg = {
- "type": MNP.INDEX_DELTA,
- "v": MNP_VERSION,
- "group_id": idx.group_id,
- "base_version": delta.base_version,
- "version": delta.version,
- "additions": [index_entry_wire(e) for e in delta.additions],
- "deletions": delta.deletions,
- "updates": [index_entry_wire(e) for e in delta.updates],
- }
- else:
- msg = {
- "type": MNP.INDEX_SYNC,
- "v": MNP_VERSION,
- "group_id": idx.group_id,
- "version": idx.version,
- "entries": [index_entry_wire(e) for e in idx.entries],
- }
- pushed = 0
- for session in list(self._webrtc._sessions.values()):
- if session._group_id == group_id:
+ peers = [s for s in list(self._webrtc._sessions.values())
+ if s._group_id == group_id]
+ # Both messages are sealed under a GEK-derived subkey, so building one
+ # needs a key. A group without one has no peers to push to either — the
+ # node refuses every handshake while the GEK is None (NS8) — so this is
+ # "nobody is listening", not a case to send in clear for.
+ if peers and idx.gek:
+ msg = (index_delta_message(idx, delta) if delta is not None
+ else index_sync_message(idx, indexer.roots))
+ pushed = 0
+ for session in peers:
try:
session._send(msg)
pushed += 1
except Exception:
pass
- if pushed:
- log.info("Index %s pushed to %d WebRTC peers",
- "delta" if delta is not None else "sync", pushed)
+ if pushed:
+ log.info("Index %s pushed to %d WebRTC peers",
+ "delta" if delta is not None else "sync", pushed)
# 11.9 — Register file hashes with hub swarm table (public groups only, H7)
group_cfg = next(
diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_client.py b/packages/meshbay-node/src/meshbay_node/transport/quic_client.py
index 4debd27..b22b8df 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/quic_client.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/quic_client.py
@@ -23,11 +23,14 @@ from aioquic.quic.events import QuicEvent, StreamDataReceived
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from meshbay_common import MNP_VERSION
+from meshbay_common.groupbox import PURPOSE_ACK, PURPOSE_INDEX, unseal
from meshbay_common.protocol import MNP, file_chunk_plaintext
from meshbay_common.handshake import (
+ MNP_MIN_SUPPORTED,
NONCE_LEN,
ROLE_CLIENT,
ROLE_NODE,
+ check_version,
handshake_transcript,
make_proof,
quic_binding,
@@ -141,6 +144,8 @@ class QuicChunkClient:
# TLS session the server does not re-send its certificate.
self._peer_cert_der: bytes | None = peer_cert_der
self._session_ticket = session_ticket
+ # The handshake_ack's sealed payload, once connect() has opened it.
+ self._node_config: dict = {}
async def __aenter__(self):
await self.connect()
@@ -178,6 +183,10 @@ class QuicChunkClient:
self._proto._send(self._ctrl_stream, {
"type": MNP.HANDSHAKE,
"v": MNP_VERSION,
+ # The oldest node this build can talk to. Declared in the first
+ # message so a mismatch is a refusal with a code, not a field that
+ # turns up missing three messages later (L2).
+ "v_min": MNP_MIN_SUPPORTED,
"token": self._jwt_token,
"group_id": self._group_id,
"nonce": base64.b64encode(nonce_c).decode(),
@@ -186,6 +195,8 @@ class QuicChunkClient:
reply = await self._proto._recv(self._ctrl_stream)
if reply.get("type") != MNP.HANDSHAKE_CHALLENGE:
raise ConnectionError(f"QUIC handshake rejected: {reply}")
+ # The node's half of the range, checked before we speak to it further.
+ check_version(reply.get("v", ""), reply.get("v_min", ""))
nonce_s = base64.b64decode(reply["nonce"])
@@ -231,6 +242,15 @@ class QuicChunkClient:
except Exception as exc:
raise ConnectionError(f"Node signature invalid: {exc}") from exc
+ # Verify, then decrypt — in that order, and it is not incidental. The
+ # proof and the signature above are what decide whether this peer is worth
+ # trusting at all; opening the payload first would mean acting on data from
+ # someone we have not authenticated. Empty on this transport today (D5),
+ # but it must still open: a payload that does not is a peer we cannot talk
+ # to, not a node with no configuration.
+ self._node_config = unseal(
+ self._gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, self._group_id, ack)
+
log.debug("QUIC connected to %s:%d", self._host, self._port)
@property
@@ -253,14 +273,24 @@ class QuicChunkClient:
"""
Request the Mesh Group Index.
- Returns the message itself — `{group_id, version, entries, dirs, roots}` —
- which is what the WebRTC client has always received. It used to return the
- bytes of a `GroupIndex.serialize()` envelope for the caller to deserialize:
- the same message type carrying a different encoding on this transport alone.
+ Returns `{group_id, version, entries, dirs, roots}` — the sealed payload
+ opened, with `group_id` from the envelope that carried it. It used to return
+ the bytes of a `GroupIndex.serialize()` envelope for the caller to
+ deserialize: the same message type carrying a different encoding on this
+ transport alone.
+
+ A payload that does not open raises. It is never an empty index — that is
+ indistinguishable from a group with no files, which is why a fallback here
+ would be worse than a stop (groupbox.py, §3.4).
"""
sid = self._new_stream()
self._proto._send(sid, {"type": MNP.INDEX_SYNC, "v": MNP_VERSION})
- return await self._proto._recv(sid)
+ msg = await self._proto._recv(sid)
+ if msg.get("type") == "error":
+ raise LookupError(msg.get("detail", "index_sync refused"))
+ payload = unseal(
+ self._gek, PURPOSE_INDEX, MNP.INDEX_SYNC, self._group_id, msg)
+ return {"group_id": msg.get("group_id", self._group_id), **payload}
async def fetch_chunk(self, file_id: str, chunk_index: int) -> bytes:
"""
diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
index 8a32538..6dde3ff 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
@@ -36,17 +36,20 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from meshbay_common import MNP_VERSION
from meshbay_node.roots import RootSet, entry_abs_path
from meshbay_common.handshake import (
+ MNP_MIN_SUPPORTED,
NONCE_LEN,
ROLE_CLIENT,
ROLE_NODE,
HandshakeError,
authorize_token,
+ check_version,
handshake_transcript,
make_proof,
quic_binding,
verify_proof,
)
from meshbay_common.crypto import pk_to_b64
+from meshbay_common.groupbox import PURPOSE_ACK, seal
from meshbay_common.protocol import MNP, file_chunk_wire
from meshbay_node.indexer import GroupIndex
from meshbay_node.transport.wire import index_sync_message
@@ -269,6 +272,16 @@ class _MNPServerProtocol(QuicConnectionProtocol):
module, bound to the QUIC certificate hash. Finding C6 is closed on this
transport.
"""
+ # Same order as WebRTC: the range first, so a peer we cannot speak to is
+ # told so, rather than served messages it will misread (L2).
+ try:
+ check_version(msg.get("v", ""), msg.get("v_min", ""))
+ except HandshakeError as refusal:
+ self._send(stream_id, {"type": "error", "detail": str(refusal),
+ "code": refusal.code})
+ self._quic.close()
+ return
+
try:
peer = authorize_token(
msg.get("token", ""),
@@ -278,7 +291,8 @@ class _MNPServerProtocol(QuicConnectionProtocol):
denylist=self._ctx.get("denylist"),
)
except HandshakeError as refusal:
- self._send(stream_id, {"type": "error", "detail": str(refusal)})
+ self._send(stream_id, {"type": "error", "detail": str(refusal),
+ "code": refusal.code})
self._quic.close()
return
@@ -306,6 +320,7 @@ class _MNPServerProtocol(QuicConnectionProtocol):
self._send(stream_id, {
"type": MNP.HANDSHAKE_CHALLENGE,
"v": MNP_VERSION,
+ "v_min": MNP_MIN_SUPPORTED,
"nonce": base64.b64encode(self._gek_challenge).decode(),
})
@@ -355,12 +370,19 @@ class _MNPServerProtocol(QuicConnectionProtocol):
log.info("QUIC handshake OK — user=%s group=%s",
self._user_id[:8], self._group_id[:8])
+ # The config payload is empty here — QUIC serves no browser, so none of
+ # the fields WebRTC carries has a consumer on this transport. It is sealed
+ # anyway (decision D5): one shape per message on every transport, which is
+ # the lesson of the two `file_chunk` encoders and the two `index_sync`
+ # encodings. A field added later then has somewhere to go that is already
+ # authenticated, instead of arriving in clear beside a sealed one.
self._send(stream_id, {
"type": MNP.HANDSHAKE_ACK,
"v": MNP_VERSION,
"node_pk": pk_to_b64(self._ctx["sk_node"].public_key()),
"proof": base64.b64encode(node_proof).decode(),
"sig": base64.b64encode(self._ctx["sk_node"].sign(transcript)).decode(),
+ **seal(gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, peer.group_id, {}),
})
self._gek_challenge = None
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 370a8b4..0154319 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -45,11 +45,13 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
from meshbay_common import MNP_VERSION
from meshbay_common.handshake import (
+ MNP_MIN_SUPPORTED,
NONCE_LEN,
ROLE_CLIENT,
ROLE_NODE,
HandshakeError,
authorize_token,
+ check_version,
handshake_transcript,
make_proof,
verify_proof,
@@ -87,6 +89,7 @@ from meshbay_common.device import (
device_code_hash,
device_request_transcript,
)
+from meshbay_common.groupbox import PURPOSE_ACK, seal
from meshbay_common.join import (
JOIN_TTL,
ROLE_MEMBER,
@@ -609,6 +612,15 @@ class WebRTCPeerSession:
group_id = msg.get("group_id", "")
log.info("WebRTC handshake request: group=%s (peer=%s)",
group_id[:8] if group_id else "none", self._peer_id)
+ # Before the token, and before anything is decided from it: a peer we
+ # cannot speak to is refused with a code it can act on, rather than
+ # served messages it will misread as missing fields (L2).
+ try:
+ check_version(msg.get("v", ""), msg.get("v_min", ""))
+ except HandshakeError as refusal:
+ self._send({"type": "error", "detail": str(refusal),
+ "code": refusal.code})
+ return
try:
peer = authorize_token(
msg.get("token", ""),
@@ -655,6 +667,9 @@ class WebRTCPeerSession:
self._send({
"type": MNP.HANDSHAKE_CHALLENGE,
"v": MNP_VERSION,
+ # Our half of the range. The client refuses us on this rather than
+ # discovering the mismatch when a field it expected is not there.
+ "v_min": MNP_MIN_SUPPORTED,
"nonce": base64.b64encode(self._gek_challenge).decode(),
# Announced here because a first-time joiner needs it *before* the
# ack: join_request signs a transcript naming this node, and someone
@@ -729,13 +744,15 @@ class WebRTCPeerSession:
gek, ROLE_NODE, self._group_id or "", self._nonce_client,
self._gek_challenge or b"", binding)
- ack = {
- "type": MNP.HANDSHAKE_ACK,
- "v": MNP_VERSION,
- "node_pk": pk_to_b64(self._ctx["sk_node"].public_key()),
- "proof": base64.b64encode(node_proof).decode(),
- "sig": base64.b64encode(
- self._ctx["sk_node"].sign(node_transcript)).decode(),
+ # Everything the client needs in order to *authenticate* us stays in clear —
+ # node_pk, proof and sig are what it checks before it would trust a
+ # decryption, so they cannot themselves be behind one. The configuration
+ # below is sealed under a GEK-derived subkey, which gives it an
+ # authentication tag from a key the hub does not hold. Until MNP 1.0 the
+ # signed transcript named no ack field at all, so is_node_admin,
+ # enabled_apps, video_root and the rest were authenticated by the DTLS
+ # channel and nothing else.
+ config = {
"is_node_admin": self._is_node_admin(),
# So the interface knows whether to offer uploading at all. Not a
# permission — the node refuses regardless — but without it the
@@ -790,10 +807,20 @@ class WebRTCPeerSession:
},
}
if node_user_id:
- ack["node_user_id"] = node_user_id
+ config["node_user_id"] = node_user_id
pk_x_b64 = self._ctx.get("pk_x25519_b64")
if pk_x_b64:
- ack["node_pk_x25519"] = pk_x_b64
+ config["node_pk_x25519"] = pk_x_b64
+
+ ack = {
+ "type": MNP.HANDSHAKE_ACK,
+ "v": MNP_VERSION,
+ "node_pk": pk_to_b64(self._ctx["sk_node"].public_key()),
+ "proof": base64.b64encode(node_proof).decode(),
+ "sig": base64.b64encode(
+ self._ctx["sk_node"].sign(node_transcript)).decode(),
+ **seal(gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, self._group_id or "", config),
+ }
self._send(ack)
self._audit("handshake")
diff --git a/packages/meshbay-node/src/meshbay_node/transport/wire.py b/packages/meshbay-node/src/meshbay_node/transport/wire.py
index 4e09167..c683204 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/wire.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/wire.py
@@ -14,12 +14,22 @@ consumer each and nothing asserting they matched. Same failure mode as the two
`GroupIndex.serialize()`/`deserialize()` are unchanged and still tested — they remain
a correct signed index envelope — but they no longer describe any MNP message. Read
-them as an at-rest/interchange format, not as a wire contract.
+them as an at-rest/interchange format, not as a wire contract. It is also not a
+candidate for reuse below: it compresses with zstd, which no browser can decompress
+(`DecompressionStream` offers gzip and deflate only).
+
+Since MNP 1.0 both messages carry their payload **sealed under a GEK-derived subkey**
+(`meshbay_common.groupbox`). Only the routing fields — `type`, `v`, `group_id` — stay
+in clear: a receiver must route and version-check before it can decrypt, and
+`group_id` is the AAD and selects the key besides. `version`/`base_version` moved
+*inside* the payload; there is no reason to act on a version number carried by a
+message we have not yet authenticated.
"""
from __future__ import annotations
from meshbay_common import MNP_VERSION
+from meshbay_common.groupbox import PURPOSE_INDEX, seal
from meshbay_common.protocol import MNP, index_entry_wire
from meshbay_node.roots import RootSet
@@ -60,17 +70,43 @@ def index_sync_message(index, roots: RootSet | None) -> dict:
"""
The full `index_sync` message for one group.
- `dirs` and `roots` are here because directories are not index entries: without
- them a folder someone just created, or one they emptied, does not exist as far as
- a client is concerned, and a member cannot tell "the drive is unplugged" from "it
- is all still there".
+ `dirs` and `roots` are in the payload because directories are not index entries:
+ without them a folder someone just created, or one they emptied, does not exist as
+ far as a client is concerned, and a member cannot tell "the drive is unplugged"
+ from "it is all still there".
"""
- return {
- "type": MNP.INDEX_SYNC,
- "v": MNP_VERSION,
- "group_id": index.group_id,
+ payload = {
"version": index.version,
"entries": [index_entry_wire(e) for e in index.entries],
"dirs": list_dirs(roots),
"roots": roots.describe() if roots else [],
}
+ return {
+ "type": MNP.INDEX_SYNC,
+ "v": MNP_VERSION,
+ "group_id": index.group_id,
+ **seal(index.gek, PURPOSE_INDEX, MNP.INDEX_SYNC, index.group_id, payload),
+ }
+
+
+def index_delta_message(index, delta) -> dict:
+ """
+ One `index_delta` — what changed since the last thing this node broadcast.
+
+ Built here rather than inline in the daemon, which is where it lived and which
+ made it the third place an index message was constructed: precisely the drift
+ that produced two `index_sync` encodings and two `file_chunk` encodings before it.
+ """
+ payload = {
+ "base_version": delta.base_version,
+ "version": delta.version,
+ "additions": [index_entry_wire(e) for e in delta.additions],
+ "deletions": list(delta.deletions),
+ "updates": [index_entry_wire(e) for e in delta.updates],
+ }
+ return {
+ "type": MNP.INDEX_DELTA,
+ "v": MNP_VERSION,
+ "group_id": index.group_id,
+ **seal(index.gek, PURPOSE_INDEX, MNP.INDEX_DELTA, index.group_id, payload),
+ }
diff --git a/packages/meshbay-node/tests/test_daemon.py b/packages/meshbay-node/tests/test_daemon.py
index 71aae78..8bd169d 100644
--- a/packages/meshbay-node/tests/test_daemon.py
+++ b/packages/meshbay-node/tests/test_daemon.py
@@ -17,6 +17,7 @@ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from unittest.mock import AsyncMock, MagicMock, patch
from meshbay_common.crypto import generate_gek
+from meshbay_common.groupbox import PURPOSE_INDEX, unseal
from meshbay_node.config import Config, HubConfig, NodeConfig, GroupConfig, KeystoreConfig
from conftest import one_root
from meshbay_node.daemon import NodeDaemon
@@ -263,7 +264,8 @@ async def test_daemon_index_change_pushes_to_peers(tmp_path, shared_dir, gek, hu
msg = mock_session._send.call_args[0][0]
assert msg["type"] == "index_sync"
assert msg["group_id"] == "a" * 32
- assert len(msg["entries"]) == indexer.index.count
+ payload = unseal(gek, PURPOSE_INDEX, "index_sync", "a" * 32, msg)
+ assert len(payload["entries"]) == indexer.index.count
# Finding H7: this group is private, so its content hashes must NOT be
# registered with the hub. The test previously asserted the opposite —
@@ -391,7 +393,9 @@ async def test_first_broadcast_is_full_sync_second_is_delta(tmp_path, shared_dir
await asyncio.sleep(0.05)
first = session._send.call_args_list[0].args[0]
assert first["type"] == "index_sync"
- assert len(first["entries"]) == indexer.index.count
+ # Sealed since MNP 1.0 — the entries are inside, not on the envelope.
+ first_payload = unseal(gek, PURPOSE_INDEX, "index_sync", "a" * 32, first)
+ assert len(first_payload["entries"]) == indexer.index.count
# Nothing actually changed in the index between the two calls, but
# _on_index_change does not know or care why it was called — the
@@ -401,8 +405,9 @@ async def test_first_broadcast_is_full_sync_second_is_delta(tmp_path, shared_dir
await asyncio.sleep(0.05)
second = session._send.call_args_list[1].args[0]
assert second["type"] == "index_delta"
- assert second["additions"] == []
- assert second["deletions"] == []
+ second_payload = unseal(gek, PURPOSE_INDEX, "index_delta", "a" * 32, second)
+ assert second_payload["additions"] == []
+ assert second_payload["deletions"] == []
@pytest.mark.asyncio
@@ -435,8 +440,9 @@ async def test_delta_reflects_additions_and_deletions(tmp_path, shared_dir, gek)
delta_msg = session._send.call_args_list[1].args[0]
assert delta_msg["type"] == "index_delta"
- assert delta_msg["deletions"] == [removed_id]
- assert [a["id"] for a in delta_msg["additions"]] == ["new-file-id"]
+ payload = unseal(gek, PURPOSE_INDEX, "index_delta", "a" * 32, delta_msg)
+ assert payload["deletions"] == [removed_id]
+ assert [a["id"] for a in payload["additions"]] == ["new-file-id"]
@pytest.mark.asyncio
diff --git a/packages/meshbay-node/tests/test_index_no_cleartext.py b/packages/meshbay-node/tests/test_index_no_cleartext.py
new file mode 100644
index 0000000..f510884
--- /dev/null
+++ b/packages/meshbay-node/tests/test_index_no_cleartext.py
@@ -0,0 +1,159 @@
+"""
+The test that asserts the property, rather than the mechanism.
+
+Worth more than checking that a `ct` field is present: this fails for any future
+change that puts a name back in the clear, including one nobody thought of as an
+index message. Findings C1 (the node HTTP API served the index and plaintext files
+on 0.0.0.0 with no authentication) and C6 (the TCP transport accepted a bare JWT
+with no GEK proof) were both "a peer that had not completed the handshake was served
+data"; sealed, that bug leaks ciphertext instead of a library's filenames.
+
+The distinctive strings below are invented and could not occur by chance in msgpack
+framing or in a field name.
+"""
+
+import msgpack
+import pytest
+from conftest import one_root
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from meshbay_common.crypto import generate_gek
+from meshbay_common.groupbox import PURPOSE_ACK, PURPOSE_INDEX, seal, unseal
+from meshbay_common.protocol import MNP
+from meshbay_node.indexer import DirectoryIndexer, GroupIndex
+from meshbay_node.transport.wire import index_delta_message, index_sync_message
+
+# A filename and a folder name that appear nowhere else in the tree.
+SECRET_FILE = "quixotry-ledger-2019.pdf"
+SECRET_DIR = "zarfwidget-archive"
+SECRET_ROOT = "/srv/vasculum-private/library"
+
+
+@pytest.fixture
+def gek():
+ return generate_gek()
+
+
+@pytest.fixture
+async def indexer(tmp_path, gek):
+ shared = tmp_path / "shared"
+ (shared / SECRET_DIR).mkdir(parents=True)
+ (shared / SECRET_DIR / SECRET_FILE).write_bytes(b"x" * 64)
+ roots = one_root(shared, name="library")
+ idx = DirectoryIndexer(
+ roots=roots, group_id="g-1", sk_node=Ed25519PrivateKey.generate(), gek=gek)
+ await idx.initial_scan()
+ return idx
+
+
+def _assert_absent(frame: bytes, *words: str) -> None:
+ for word in words:
+ assert word.encode() not in frame, f"{word!r} travels in the clear"
+
+
+@pytest.mark.asyncio
+async def test_index_sync_frame_carries_no_filename(indexer):
+ frame = msgpack.packb(index_sync_message(indexer.index, indexer.roots),
+ use_bin_type=True)
+ # Not only the whole name — a fragment of it would be just as much of a leak.
+ _assert_absent(frame, SECRET_FILE, "quixotry", SECRET_DIR, "zarfwidget",
+ "entries", "dirs")
+ # And the routing fields are still readable, or nothing could be dispatched.
+ msg = msgpack.unpackb(frame, raw=False)
+ assert msg["type"] == MNP.INDEX_SYNC
+ assert msg["group_id"] == "g-1"
+ assert set(msg) == {"type", "v", "group_id", "nonce", "ct"}
+
+
+@pytest.mark.asyncio
+async def test_index_sync_payload_still_says_everything(indexer, gek):
+ """Sealed, not lost: every field a client reads is inside."""
+ msg = index_sync_message(indexer.index, indexer.roots)
+ payload = unseal(gek, PURPOSE_INDEX, MNP.INDEX_SYNC, "g-1", msg)
+ assert [e["name"] for e in payload["entries"]] == [SECRET_FILE]
+ assert any(d.endswith(SECRET_DIR) for d in payload["dirs"])
+ assert payload["roots"]
+ # Moved inside deliberately (D4): there is no reason to act on a version
+ # carried by a message we have not authenticated.
+ assert payload["version"] == indexer.index.version
+ assert "version" not in msg
+
+
+@pytest.mark.asyncio
+async def test_index_delta_frame_carries_no_filename(indexer, gek):
+ """
+ The delta is where a cleartext path would most easily survive: it used to be
+ hand-built in the daemon, a third construction site for an index message.
+ """
+ before = GroupIndex._snapshot(
+ "g-1", indexer.index.sk_node, gek, indexer.index.version - 1, {})
+ delta = indexer.index.diff(before)
+ assert delta.additions
+
+ frame = msgpack.packb(index_delta_message(indexer.index, delta),
+ use_bin_type=True)
+ _assert_absent(frame, SECRET_FILE, "quixotry", "additions", "deletions")
+
+ payload = unseal(gek, PURPOSE_INDEX, MNP.INDEX_DELTA, "g-1",
+ msgpack.unpackb(frame, raw=False))
+ assert [e["name"] for e in payload["additions"]] == [SECRET_FILE]
+ assert payload["base_version"] == delta.base_version
+
+
+def test_handshake_ack_frame_carries_no_configuration(gek):
+ """
+ The ack is the line that matters most, and it is an integrity gap as much as a
+ confidentiality one: the node signs `handshake_transcript(...)`, which names no
+ ack field, so every value below was authenticated by the DTLS channel alone.
+ """
+ config = {
+ "is_node_admin": True,
+ "video_root": SECRET_ROOT,
+ "enabled_apps": ["files", "videos"],
+ }
+ ack = {
+ "type": MNP.HANDSHAKE_ACK,
+ "v": "1.0",
+ "node_pk": "Tk9ERVBL",
+ "proof": "cHJvb2Y=",
+ "sig": "c2ln",
+ **seal(gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, "g-1", config),
+ }
+ frame = msgpack.packb(ack, use_bin_type=True)
+ _assert_absent(frame, SECRET_ROOT, "vasculum", "video_root",
+ "enabled_apps", "is_node_admin")
+
+ # What a client needs in order to authenticate the node is still in clear —
+ # it verifies those *before* it would trust a decryption.
+ msg = msgpack.unpackb(frame, raw=False)
+ assert msg["node_pk"] and msg["proof"] and msg["sig"]
+ assert unseal(gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, "g-1", msg) == config
+
+
+def test_index_progress_stays_clear_and_stays_counters():
+ """
+ Decision D3: `index_progress` is deliberately *not* sealed — counters only,
+ pushed every couple of seconds for the whole length of a scan, so sealing it
+ would buy a rough library size and cost a decrypt per push.
+
+ The field list is re-derived from the daemon's own source rather than restated
+ here, so this fails the day the message grows something that names anything —
+ `IndexProgress` already carries a `current_dir` the push deliberately omits,
+ and adding it would be one line. That is the moment the trade above is void.
+ """
+ import ast
+ import inspect
+ import textwrap
+
+ from meshbay_node.daemon import NodeDaemon
+
+ source = inspect.getsource(NodeDaemon._push_index_progress)
+ tree = ast.parse(textwrap.dedent(source))
+ dicts = [n for n in ast.walk(tree) if isinstance(n, ast.Dict)]
+ assert len(dicts) == 1, "more than one message built here — re-read this test"
+ keys = {k.value for k in dicts[0].keys}
+ assert keys == {"type", "v", "group_id",
+ "scanning", "scanned_bytes", "total_bytes"}, (
+ f"index_progress now carries {keys} — re-read decision D3 before shipping it")
+
+ assert "seal(" not in source
+ assert "D3" in source, "the reason it is not sealed must stay next to the code"
diff --git a/packages/meshbay-node/tests/test_transport_wire_parity.py b/packages/meshbay-node/tests/test_transport_wire_parity.py
index 5e04525..bc6b134 100644
--- a/packages/meshbay-node/tests/test_transport_wire_parity.py
+++ b/packages/meshbay-node/tests/test_transport_wire_parity.py
@@ -19,6 +19,7 @@ import pytest
from conftest import one_root
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from meshbay_common.crypto import generate_gek
+from meshbay_common.groupbox import PURPOSE_INDEX, unseal
from meshbay_common.protocol import MNP, file_chunk_plaintext, file_chunk_wire
from meshbay_node.indexer import DirectoryIndexer
from meshbay_node.transport import quic_server, webrtc_server
@@ -60,6 +61,37 @@ def test_both_transports_use_the_one_index_builder():
"QUIC is serializing the index again — that was the fork")
+def test_neither_transport_seals_by_hand():
+ """
+ `groupbox` is the only sealer, the same rule `file_chunk_wire` already has.
+ A server reaching for AESGCM or HKDF directly is a second envelope waiting to
+ disagree with the first about a nonce length, an info string or an AAD.
+ """
+ for module in (webrtc_server, quic_server):
+ source = inspect.getsource(module)
+ assert "seal(" in source, f"{module.__name__} sends an unsealed ack"
+ assert "AESGCM(" not in source, (
+ f"{module.__name__} builds its own AEAD instead of using groupbox")
+ assert "HKDF(" not in source, (
+ f"{module.__name__} derives its own subkey instead of using groupbox")
+
+
+def test_the_daemon_does_not_build_an_index_message_itself():
+ """
+ The delta was hand-built in `_broadcast_index_change` — the third construction
+ site for an index message, and the one that would have kept sending cleartext
+ while the other two were sealed.
+ """
+ from meshbay_node import daemon
+
+ source = inspect.getsource(daemon)
+ assert "index_delta_message" in source and "index_sync_message" in source
+ assert '"type": MNP.INDEX_DELTA' not in source, (
+ "the daemon builds index_delta by hand again")
+ assert '"type": MNP.INDEX_SYNC' not in source, (
+ "the daemon builds index_sync by hand again")
+
+
def test_chunk_wire_shape_is_identical_across_transports(gek, shared_dir):
"""The two servers' read-and-encrypt helpers agree on every field but the nonce."""
path = shared_dir / "film.mkv"
@@ -116,10 +148,35 @@ async def test_index_sync_shape(gek, shared_dir):
msg = index_sync_message(indexer.index, roots)
+ # In clear: what a receiver needs to route and version-check before it can
+ # decrypt, and nothing else.
assert msg["type"] == MNP.INDEX_SYNC
assert msg["group_id"] == "g"
- assert [e["name"] for e in msg["entries"]] == ["film.mkv"]
+ assert set(msg) == {"type", "v", "group_id", "nonce", "ct"}
+
+ payload = unseal(gek, PURPOSE_INDEX, MNP.INDEX_SYNC, "g", msg)
+ assert [e["name"] for e in payload["entries"]] == ["film.mkv"]
# Directories are not index entries, so they travel separately — including the
# empty one, which no entry's path would have revealed.
- assert any(d.endswith("sub") for d in msg["dirs"])
- assert msg["roots"]
+ assert any(d.endswith("sub") for d in payload["dirs"])
+ assert payload["roots"]
+
+
+@pytest.mark.asyncio
+async def test_a_wrong_key_raises_rather_than_reporting_an_empty_group(gek, shared_dir):
+ """
+ §3.4, at the level a client would hit it. An index that fails to open must not
+ become an empty index: "the group has no files" is a legitimate state, so a
+ fallback there is indistinguishable from the truth — which is exactly what
+ makes it worse than a stop.
+ """
+ sk_node = Ed25519PrivateKey.generate()
+ roots = one_root(shared_dir)
+ indexer = DirectoryIndexer(roots=roots, group_id="g", sk_node=sk_node, gek=gek)
+ await indexer.initial_scan()
+
+ msg = index_sync_message(indexer.index, roots)
+ with pytest.raises(Exception) as caught:
+ unseal(generate_gek(), PURPOSE_INDEX, MNP.INDEX_SYNC, "g", msg)
+ # Assert on the refusal, not on a degraded result.
+ assert not isinstance(caught.value, dict)
diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py
index a4c7f61..dc74752 100644
--- a/packages/meshbay-node/tests/test_webrtc_transport.py
+++ b/packages/meshbay-node/tests/test_webrtc_transport.py
@@ -33,6 +33,7 @@ from meshbay_common.crypto import (
unwrap_gek,
unwrap_gek_aes,
)
+from meshbay_common.groupbox import PURPOSE_ACK, PURPOSE_INDEX, unseal
from meshbay_common.webcrypto import chunk_key_aes, decrypt_chunk_aes
from meshbay_common.protocol import MNP
TEST_GROUP = "g"
@@ -358,13 +359,20 @@ async def test_webrtc_datachannel_file_transfer(sk_node, sk_hub, gek, shared_dir
ack = await _handshake_with_gek_proof(channel, received, sk_hub, gek,
browser_pc=browser_pc)
assert ack["type"] == MNP.HANDSHAKE_ACK
+ # 1b) The ack's configuration is sealed under the group key (MNP 1.0), and the
+ # signed handshake transcript names no ack field — so this envelope is the only
+ # thing authenticating `is_node_admin` and the rest.
+ config = unseal(gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, TEST_GROUP, ack)
+ assert "is_node_admin" in config
+ assert "is_node_admin" not in ack
# 2) Request index
channel.send(_pack({"type": MNP.INDEX_SYNC, "v": MNP_VERSION}))
idx_msg = await asyncio.wait_for(received.get(), timeout=5.0)
assert idx_msg["type"] == MNP.INDEX_SYNC
- assert "entries" in idx_msg
- assert len(idx_msg["entries"]) > 0
+ assert "entries" not in idx_msg, "the index travels in the clear"
+ payload = unseal(gek, PURPOSE_INDEX, MNP.INDEX_SYNC, TEST_GROUP, idx_msg)
+ assert len(payload["entries"]) > 0
# 3) Request file chunk
entry = next(e for e in indexer.index.entries if e.name == "test.bin")
@@ -442,6 +450,65 @@ async def test_webrtc_invalid_jwt_rejected(sk_node, sk_hub, gek, shared_dir):
@pytest.mark.asyncio
+async def test_webrtc_old_client_is_refused_with_a_code(sk_node, sk_hub, gek, shared_dir):
+ """
+ A version mismatch must present as a refusal, not as a missing field.
+
+ An 0.x client reaching a 1.0 node would otherwise get a `handshake_ack` with
+ no `enabled_apps` and apply its documented fallback — show every app — and an
+ `index_sync` with no `entries` it would read as an empty group. Both are
+ confident wrong answers. The check runs *before* the token, so it costs
+ nothing and reports the real reason (L2).
+ """
+ hub_pk_pem = _hub_pk_pem(sk_hub)
+ indexer = DirectoryIndexer(roots=one_root(shared_dir), group_id=TEST_GROUP,
+ sk_node=sk_node, gek=gek)
+ await indexer.initial_scan()
+
+ transport = WebRTCTransport(
+ sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek,
+ roots=one_root(shared_dir), index=indexer.index, stun_servers=[],
+ )
+
+ browser_pc = RTCPeerConnection()
+ received = asyncio.Queue()
+ channel = browser_pc.createDataChannel("mnp")
+
+ @channel.on("message")
+ def on_msg(message):
+ if isinstance(message, str):
+ message = message.encode()
+ received.put_nowait(_unpack(message))
+
+ offer = await browser_pc.createOffer()
+ await browser_pc.setLocalDescription(offer)
+ answer_sdp, _ = await transport.handle_offer(
+ browser_pc.localDescription.sdp, "peer-old")
+ await browser_pc.setRemoteDescription(
+ RTCSessionDescription(sdp=answer_sdp, type="answer"))
+ await asyncio.sleep(0.5)
+
+ # A perfectly valid token — the refusal must not depend on it, and must not
+ # be reported as an authorization problem either.
+ channel.send(_pack({
+ "type": MNP.HANDSHAKE,
+ "v": "0.15",
+ "token": _make_jwt(sk_hub, groups=[TEST_GROUP]),
+ "group_id": TEST_GROUP,
+ "nonce": base64.b64encode(os.urandom(32)).decode(),
+ }))
+
+ msg = await asyncio.wait_for(received.get(), timeout=5.0)
+ assert msg["type"] == "error"
+ # The client matches on the code; the text may be reworded.
+ assert msg["code"] == "version_too_old"
+ assert "0.15" in msg["detail"]
+
+ await browser_pc.close()
+ await transport.close_all()
+
+
+@pytest.mark.asyncio
async def test_webrtc_request_before_handshake_rejected(sk_node, sk_hub, gek, shared_dir):
"""WebRTC DataChannel: request without handshake is rejected."""
hub_pk_pem = _hub_pk_pem(sk_hub)