diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-03 16:16:55 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-03 16:16:55 +0200 |
| commit | 675beed6ff688733a9598f9d82d41578f48316be (patch) | |
| tree | 78dd4f8dff312f0ad99bd63bc679bf402591c5ed | |
| parent | 15087b0e8fdb872602310119f14680aaa443fd93 (diff) | |
| download | meshbay-675beed6ff688733a9598f9d82d41578f48316be.tar.gz | |
feat!: MNP 1.0 — seal index and handshake_ack under the group key
`index_sync`, `index_delta` and the `handshake_ack` config payload now travel
sealed under a GEK-derived subkey (`meshbay_common/groupbox.py`, mirrored by
`sealGroup`/`openGroup` in `crypto.js`). Only `type`, `v`, `group_id` and the
ack's `node_pk`/`proof`/`sig` stay in clear — a receiver must route and
authenticate before it would trust a decryption. Verify, then decrypt.
The ack line is integrity, not confidentiality: the signed handshake transcript
names no ack field, so `is_node_admin`, `enabled_apps`, `video_root` and the
rest were authenticated by the DTLS channel alone. The index line is defence in
depth against a repeat of C1/C6 — a peer served before the handshake completes
now gets ciphertext, not filenames. Nothing against an observer, the hub, or a
member; that is the whole claim. `index_progress` stays clear (D3, counters
only). Chat is out of scope.
Failure is fatal: a payload that does not open ends the session naming the
message type — never an empty index or an empty `enabled_apps`, both of which
are legitimate states.
Version negotiation ships here too (phase 15.6, brought forward): `v` + `v_min`
on `handshake` and `handshake_challenge`, refused with `version_too_old` /
`version_too_new` / `version_unreadable`. The flag day was already being paid
for; the next breaking change now costs a refusal message.
BREAKING CHANGE: breaks the WebRTC wire every deployed client speaks. Hub and
every node must deploy together; the SPA is served by the hub, so a browser
picks up the new client on reload. See MESHBAY_NODE_PROTOCOL.md §11.1a, §13.1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkzbhmMmK8PqQBtGz5zCvY
23 files changed, 1636 insertions, 88 deletions
@@ -72,7 +72,12 @@ dans le bon `pyproject.toml`. - All three packages share the same version number (released together) ### Protocol versions (independent) -- MNP: `0.4` → bumped independently of package version +- MNP: `1.0` → bumped independently of package version. **`handshake.py`'s + `MNP_MIN_SUPPORTED` is the other half**: both peers declare `v` and `v_min` on + the handshake and refuse each other with a code (`version_too_old` / + `version_too_new` / `version_unreadable`), so a mismatch is a refusal rather + than a field that turns up missing. Shipped with 1.0 because that flag day was + already being paid for; the next breaking change now costs a refusal message - 0.2 added `PING`/`PONG` and backward chat paging (`before` / `has_more`). Additive, so an 0.1 peer still works: it sends no `before` and is answered with the newest page, which is what it wanted @@ -685,7 +690,29 @@ anything that assumes one key per person. `GroupSenderKeyStore.add_sender` currently does `self._states[dist.sender_id] = ...`, so a second device under the same `sender_id` silently overwrites the first. Revoking a device must rotate, like revoking a member. See `docs/devel-phases-next.md` §15.0b -- Chat is plaintext on the wire and at rest; the index is plaintext on the WebRTC path +- ~~Chat is plaintext on the wire and at rest; the index is plaintext on the WebRTC + path.~~ **The index half changed 2026-09-03 (MNP 1.0).** `index_sync`, + `index_delta` and the `handshake_ack` configuration payload are sealed under a + GEK-derived subkey (`meshbay_common/groupbox.py`, mirrored by `sealGroup`/ + `openGroup` in `crypto.js`); only `type`, `v`, `group_id` and the ack's own + `node_pk`/`proof`/`sig` stay in clear, because a receiver must route and + **authenticate** before it would trust a decryption. Chat is unchanged and out of + scope by decision. Read `MESHBAY_NODE_PROTOCOL.md` §11.1a before touching either + message. Three things to keep straight: + - **The ack line is integrity, not confidentiality.** `handshake_transcript` + names no ack field, so `is_node_admin`, `enabled_apps`, `video_root` and the + rest were authenticated by the DTLS channel alone. The AEAD tag comes from a + key the hub does not hold + - **The index line is defence in depth against our own next bug**, of a class + already shipped twice: C1 (node HTTP API served the index on `0.0.0.0` + unauthenticated) and C6 (TCP accepted a bare JWT with no GEK proof). It buys + nothing against an observer, the hub, or a member. That is the whole claim + - **A payload that does not open ends the session**, never a default: an + unopenable `enabled_apps` reads as "the operator disabled every app" and an + unopenable index as "the group is empty", both indistinguishable from + legitimate states. `index_progress` is deliberately *not* sealed (counters + only, every 2 s) — the reason lives next to the code, re-read it before + changing it ## Known calibration TODOs @@ -731,6 +758,8 @@ SFR residential Fedora 44 → meshbay.org OVH VPS: | Need | Module | File | |---|---|---| | Chunk encryption (prod) | `meshbay_common.crypto` | `crypto.py` | +| Sealing a payload under the GEK | `meshbay_common.groupbox` | `groupbox.py` + `sealGroup`/`openGroup` in `static/crypto.js`. `index_sync`, `index_delta`, `handshake_ack` — one envelope, purpose-separated subkeys, AAD = `"<msg_type>\|<group_id>"`. **Never reuse `chunk_key_aes` with a pseudo-file for this** | +| MNP version range | `meshbay_common.handshake` | `MNP_MIN_SUPPORTED`, `check_version` — read by both servers and both clients | | Key derivation from password | `meshbay_common.keyderive` | `keyderive.py` | | Key bundle (web) | `meshbay_common.keyderive` | `keyderive.py` + `static/keyderive.js` | | GEK wrap/unwrap (ECIES) | `meshbay_common.crypto` | `crypto.py` | diff --git a/docs/devel-phases-next.md b/docs/devel-phases-next.md index 48620ca..e842b62 100644 --- a/docs/devel-phases-next.md +++ b/docs/devel-phases-next.md @@ -1109,7 +1109,7 @@ flags. Additions that belong in the user-facing docs: | 15.3 | Node: decrypt chat on receive | Decrypt incoming chat messages, handle out-of-order | | 15.4 | Key rotation on removal | Member removed **or device revoked or unpinned** → all remaining devices rotate | | 15.5 | Chat retention config | Per-group `max_age_days` setting, periodic cleanup in ChatStore | -| 15.6 | MNP version negotiation | Handshake declares a supported version range, not a single `v` field (L2 — today `v` is sent by everyone and checked by no one). **Move this earlier**: it pairs with the minimum-client-version check, and version skew begins the day the desktop client ships (`desktop-client-v1.md` §2.6), not when chat is encrypted | +| 15.6 | MNP version negotiation | ✅ **DONE 2026-09-03**, and not here: it shipped with **MNP 1.0** (the sealed index and ack), which forced a coordinated deployment anyway. `handshake` and `handshake_challenge` each carry `v` and `v_min`; `check_version` refuses with `version_too_old` / `version_too_new` / `version_unreadable`, shaped like `not_a_member`. The flag day was already being paid for, so the next breaking change costs a refusal message instead of a second one. See `MESHBAY_NODE_PROTOCOL.md` §13.1 | | 15.7 | Chat attachments | Attachments are ordinary files on the node and remain plaintext at rest. Either encrypt them under the sender key, or document the asymmetry explicitly. Note they now land in the **operator-designated upload root** (§6.7 of the desktop-client doc) | --- 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) |