aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-common')
-rw-r--r--packages/meshbay-common/src/meshbay_common/__init__.py25
-rw-r--r--packages/meshbay-common/src/meshbay_common/groupbox.py123
-rw-r--r--packages/meshbay-common/src/meshbay_common/handshake.py72
-rw-r--r--packages/meshbay-common/tests/test_groupbox.py120
-rw-r--r--packages/meshbay-common/tests/test_js_python_parity.py157
-rw-r--r--packages/meshbay-common/tests/test_version_negotiation.py78
6 files changed, 571 insertions, 4 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py
index 61502c9..e96c483 100644
--- a/packages/meshbay-common/src/meshbay_common/__init__.py
+++ b/packages/meshbay-common/src/meshbay_common/__init__.py
@@ -70,5 +70,28 @@ __version__ = "0.10.0"
# there: the WebRTC shape — the one every deployed client speaks — is byte for byte
# what it was, and no QUIC client ships. Recorded as a MINOR bump for that reason;
# a deployed QUIC peer would have made it a MAJOR one.
-MNP_VERSION = "0.15"
+# 1.0: `index_sync`, `index_delta` and the `handshake_ack` configuration
+# fields now travel **sealed under a GEK-derived subkey**
+# (`meshbay_common.groupbox`), and the handshake negotiates a supported
+# version range instead of writing a `v` nobody reads.
+#
+# **Breaking, on the wire every deployed client speaks**, and there is no way
+# to describe it as additive: an old client sends `index_sync` and gets a
+# message with no `entries`; it reads `ack.enabled_apps`, finds nothing, and
+# applies its documented fallback — "show every app" — rather than reporting
+# an error; a new client against an old node finds `entries` it does not
+# expect and no `ct`. 0.15 stayed MINOR because only the QUIC wire changed and
+# no QUIC client ships; that argument is not available here, and MAJOR is what
+# the project's own rule says. Hub and every node deploy together; the SPA is
+# served by the hub, so a browser picks up the new client on reload.
+#
+# Version negotiation ships in the same flag day rather than after it (phase
+# 15.6): the coordinated deployment is already being paid for, and it is what
+# makes the *next* breaking change cost a refusal message instead of a second
+# flag day. `MNP_MIN_SUPPORTED` in `handshake.py` is the other half.
+#
+# The index at rest, `index_progress` (counters only, never a path — see
+# `groupbox.py` and daemon.py `_push_index_progress`), chat, and file content
+# on the operator's disk are all deliberately unchanged.
+MNP_VERSION = "1.0"
MHP_VERSION = "0.1"
diff --git a/packages/meshbay-common/src/meshbay_common/groupbox.py b/packages/meshbay-common/src/meshbay_common/groupbox.py
new file mode 100644
index 0000000..f1091c3
--- /dev/null
+++ b/packages/meshbay-common/src/meshbay_common/groupbox.py
@@ -0,0 +1,123 @@
+"""
+Sealing a message payload under the group key.
+
+`file_chunk` and `stream_data` have always travelled encrypted under a GEK-derived
+key; `index_sync`, `index_delta` and the `handshake_ack` config fields travelled in
+plain msgpack, authenticated by the DTLS/TLS channel and nothing else. The model in
+force was "the channel is the boundary". This module is the other half: a payload
+sealed under a key the hub does not hold.
+
+Two properties, and it is worth being precise about which is which.
+
+**Integrity, for the ack.** The node signs `handshake_transcript(role, group_id,
+nonce_c, nonce_s, binding)`, which contains no ack field at all — so `is_node_admin`,
+`enabled_apps`, `video_root` and the rest were authenticated by the channel alone.
+An AEAD tag from a GEK-derived key is a stronger statement than any amount of
+confidentiality on the index.
+
+**Confidentiality, for the index.** Defence in depth against our own next bug of a
+class already shipped twice: C1 (the node HTTP API served the index and plaintext
+files on 0.0.0.0 with no authentication) and C6 (the TCP transport accepted a bare
+JWT with no GEK proof) were both "a peer that had not completed the handshake was
+served data". Sealed, that bug leaks ciphertext rather than filenames, folder names
+and group configuration. It buys nothing against a network observer (DTLS/TLS
+already covers that), nothing against the hub (it never sees channel traffic), and
+nothing against a member — who holds the GEK. That is the whole claim.
+
+Purpose separation is deliberate. `GroupIndex.serialize()` reuses
+`chunk_key_aes(gek, file_hash, chunk_index)` with a pseudo-file ("the index as chunk
+0 of a virtual index file"), which borrows a file's key space for something that is
+not a file. Each purpose here derives its own subkey instead.
+"""
+
+from __future__ import annotations
+
+import os
+
+import msgpack
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+
+PURPOSE_INDEX = "index"
+PURPOSE_ACK = "ack"
+
+# `salt=None` here and `salt: new Uint8Array(0)` in crypto.js agree — RFC 5869
+# extracts with a zero key either way. Already proven in production by
+# `deriveChunkKey`, and held by the parity test.
+_INFO = {
+ PURPOSE_INDEX: b"meshbay:index:v1",
+ PURPOSE_ACK: b"meshbay:ack:v1",
+}
+
+NONCE_LEN = 12 # 96-bit, the WebCrypto AES-GCM standard
+
+
+def group_key(gek: bytes, purpose: str) -> bytes:
+ """Derive the AES-256 subkey for one purpose. Distinct per purpose, by info."""
+ try:
+ info = _INFO[purpose]
+ except KeyError:
+ raise ValueError(f"unknown groupbox purpose: {purpose!r}") from None
+ if not gek:
+ raise ValueError("no group key")
+ return HKDF(
+ algorithm=hashes.SHA256(), length=32, salt=None, info=info,
+ ).derive(gek)
+
+
+def associated_data(msg_type: str, group_id: str) -> bytes:
+ """
+ What a ciphertext is bound to.
+
+ Binding the message type stops an `index_sync` body being replayed as an
+ `index_delta`; binding the group stops one being moved between two groups hosted
+ on the same node. It costs nothing and closes a class of confusion that is
+ tedious to reason about later.
+ """
+ return f"{msg_type}|{group_id}".encode()
+
+
+def seal(gek: bytes, purpose: str, msg_type: str, group_id: str,
+ payload: dict) -> dict:
+ """
+ The `{nonce, ct}` pair for the caller to merge into its message.
+
+ Returns only those two fields: the routing fields (`type`, `v`, `group_id`) stay
+ in clear because the receiver must route and version-check before it can decrypt,
+ and `group_id` selects the key besides.
+ """
+ key = group_key(gek, purpose)
+ # 96-bit random nonce per message. The volume here — one message per index
+ # change — is many orders below the birthday bound. Never derive it from the
+ # payload: two identical payloads under one subkey would then reuse it.
+ nonce = os.urandom(NONCE_LEN)
+ ct = AESGCM(key).encrypt(
+ nonce, msgpack.packb(payload, use_bin_type=True),
+ associated_data(msg_type, group_id))
+ return {"nonce": nonce, "ct": ct}
+
+
+def unseal(gek: bytes, purpose: str, msg_type: str, group_id: str,
+ msg: dict) -> dict:
+ """
+ Open a sealed message. Raises on anything that does not open — never a partial
+ result, and never a default.
+
+ A payload that does not open is not a config change and not an empty index; it is
+ a peer we cannot talk to. Falling back would make `enabled_apps` read as "the
+ operator disabled every app" and an index as "the group is empty", both
+ indistinguishable from a legitimate state — which is what makes a silent fallback
+ worse than a stop. Same rule already applied to `file_chunk`.
+ """
+ key = group_key(gek, purpose)
+ nonce = msg.get("nonce")
+ ct = msg.get("ct")
+ if not isinstance(nonce, (bytes, bytearray)) or not isinstance(ct, (bytes, bytearray)):
+ raise ValueError(f"{msg_type}: not a sealed message")
+ plain = AESGCM(key).decrypt(
+ bytes(nonce), bytes(ct), associated_data(msg_type, group_id))
+ payload = msgpack.unpackb(plain, raw=False)
+ if not isinstance(payload, dict):
+ raise ValueError(f"{msg_type}: sealed payload is not a map")
+ return payload
diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py
index fca218f..2f3d641 100644
--- a/packages/meshbay-common/src/meshbay_common/handshake.py
+++ b/packages/meshbay-common/src/meshbay_common/handshake.py
@@ -9,13 +9,15 @@ module, and a parity test fails if either skips a step.
The sequence:
- client → node handshake {token, group_id, nonce_c}
+ client → node handshake {token, group_id, nonce_c, v, v_min}
+ node check_version() supported range, both ways
node authorize_token() JWT, scope, denylist, membership, hosting
- node → client handshake_challenge {nonce_s}
+ node → client handshake_challenge {nonce_s, v, v_min}
client → node handshake_response {proof}
node verify client proof HMAC(GEK, client transcript)
- node → client handshake_ack {proof, sig, node_pk, is_node_admin}
+ node → client handshake_ack {proof, sig, node_pk, nonce, ct}
client verify node proof HMAC(GEK, node transcript) + Ed25519
+ client THEN open ct the session config, sealed (groupbox.py)
Two properties this adds over the previous design:
@@ -28,6 +30,22 @@ forged `is_node_admin` flag. The node now proves GEK possession over a
client-chosen nonce *and* signs the transcript with its long-term key, so the
client can pin it.
+**A version that is checked (L2).** `v` used to be written by everyone and read by
+nobody, so a version mismatch surfaced as a missing field — an old client reading a
+1.0 ack found no `enabled_apps` and applied its documented fallback, "show every
+app", which is a wrong answer rather than an error. Both sides now declare the range
+they speak, in the first message each sends, and a peer outside it is refused with a
+code rather than served a message it will misread. Without this the *next* breaking
+change costs another coordinated deployment; with it, it costs a refusal.
+
+**A payload the hub cannot forge.** The transcript above names `role`, `group_id`,
+both nonces and the binding — and **no ack field**. So `is_node_admin`,
+`enabled_apps`, `video_root` and the rest were authenticated by the channel alone.
+Since MNP 1.0 they travel sealed under a GEK-derived subkey (`groupbox.py`), which
+gives them an AEAD tag from a key the hub does not hold. Verify first, then decrypt:
+opening the payload before the proof and the signature would mean acting on data
+from a peer not yet authenticated.
+
**Unambiguous transcripts (L4).** The old proof was `nonce ‖ offer_fp ‖ answer_fp`
— bare concatenation, and a missing fingerprint silently degraded it to nonce-only.
Every field is now length-prefixed and domain-separated, the role is bound so a
@@ -44,8 +62,16 @@ from typing import Any, Protocol
import jwt
+from meshbay_common import MNP_VERSION
+
HANDSHAKE_PREFIX = b"meshbay:mnp:handshake:v1"
+# The oldest peer this build will talk to. MNP 1.0 sealed `index_sync`,
+# `index_delta` and the `handshake_ack` payload under the group key, which no
+# 0.x peer can open and which a 0.x peer's own messages do not carry — there is
+# nothing to be compatible with, which is what makes it a MAJOR bump.
+MNP_MIN_SUPPORTED = "1.0"
+
ROLE_CLIENT = "client"
ROLE_NODE = "node"
@@ -147,6 +173,46 @@ def verify_proof(
return hmac.compare_digest(proof, expected)
+def parse_version(v: str) -> tuple[int, int]:
+ """`"1.0"` → `(1, 0)`. Raises ValueError on anything else."""
+ major, _, minor = str(v).partition(".")
+ return int(major), int(minor)
+
+
+def check_version(peer_v: str, peer_min: str = "") -> None:
+ """
+ Refuse a peer outside the range this build speaks, before anything else.
+
+ `peer_min` is the oldest version the peer accepts *from us*; a peer that
+ declares none is treated as accepting only what it speaks, which is the right
+ reading of every 0.x peer — none of them declared a range because none of them
+ checked one.
+
+ Raises HandshakeError with a code the other side can act on, rather than
+ letting the mismatch surface later as a field that is missing.
+ """
+ ours = parse_version(MNP_VERSION)
+ our_min = parse_version(MNP_MIN_SUPPORTED)
+ try:
+ theirs = parse_version(peer_v)
+ their_min = parse_version(peer_min) if peer_min else theirs
+ except (ValueError, AttributeError):
+ raise HandshakeError(
+ f"Unreadable protocol version {peer_v!r}", code="version_unreadable"
+ ) from None
+
+ if theirs < our_min:
+ raise HandshakeError(
+ f"Protocol {peer_v} is too old for this peer, which needs "
+ f"{MNP_MIN_SUPPORTED} or later",
+ code="version_too_old")
+ if their_min > ours:
+ raise HandshakeError(
+ f"This peer speaks protocol {MNP_VERSION}, older than the "
+ f"{peer_min} the other side requires",
+ code="version_too_new")
+
+
def authorize_token(
token: str,
hub_pk_pem: bytes,
diff --git a/packages/meshbay-common/tests/test_groupbox.py b/packages/meshbay-common/tests/test_groupbox.py
new file mode 100644
index 0000000..65d8ce6
--- /dev/null
+++ b/packages/meshbay-common/tests/test_groupbox.py
@@ -0,0 +1,120 @@
+"""
+The group-key envelope: round trip, and every refusal it owes.
+
+`groupbox.seal`/`unseal` is what puts `index_sync`, `index_delta` and the
+`handshake_ack` configuration under a key the hub does not hold. The structural
+tests here matter less than `test_index_no_cleartext.py`, which asserts the
+property on a real frame; these pin the primitive.
+"""
+
+import msgpack
+import pytest
+from cryptography.exceptions import InvalidTag
+from meshbay_common.crypto import generate_gek
+from meshbay_common.groupbox import (
+ PURPOSE_ACK,
+ PURPOSE_INDEX,
+ group_key,
+ seal,
+ unseal,
+)
+
+PAYLOAD = {"version": 7, "entries": [{"name": "a.mkv", "size": 12}], "dirs": ["root"]}
+
+
+@pytest.fixture
+def gek():
+ return generate_gek()
+
+
+def test_round_trip(gek):
+ for purpose in (PURPOSE_INDEX, PURPOSE_ACK):
+ sealed = seal(gek, purpose, "index_sync", "g1", PAYLOAD)
+ assert set(sealed) == {"nonce", "ct"}
+ assert unseal(gek, purpose, "index_sync", "g1", sealed) == PAYLOAD
+
+
+def test_a_wrong_key_does_not_open(gek):
+ sealed = seal(gek, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD)
+ with pytest.raises(InvalidTag):
+ unseal(generate_gek(), PURPOSE_INDEX, "index_sync", "g1", sealed)
+
+
+def test_purposes_are_separate_key_spaces(gek):
+ """
+ The reason there are two info strings rather than one key reused.
+
+ An ack sealed under the index subkey would otherwise be openable by anything
+ holding the index subkey, which is the confusion `GroupIndex.serialize()`'s
+ "the index as chunk 0 of a virtual index file" creates for chunk keys.
+ """
+ assert group_key(gek, PURPOSE_INDEX) != group_key(gek, PURPOSE_ACK)
+ sealed = seal(gek, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD)
+ with pytest.raises(InvalidTag):
+ unseal(gek, PURPOSE_ACK, "index_sync", "g1", sealed)
+
+
+def test_a_body_cannot_be_replayed_as_another_message_type(gek):
+ """The AAD's first half: an index_sync body is not an index_delta."""
+ sealed = seal(gek, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD)
+ with pytest.raises(InvalidTag):
+ unseal(gek, PURPOSE_INDEX, "index_delta", "g1", sealed)
+
+
+def test_a_body_cannot_be_moved_between_groups(gek):
+ """
+ The AAD's second half. Two groups on one node share a GEK-holding process but
+ not a GEK; this closes the case where they do share one (a rotation in flight,
+ a test fixture, an operator reusing a key) as well.
+ """
+ sealed = seal(gek, PURPOSE_INDEX, "index_sync", "group-a", PAYLOAD)
+ with pytest.raises(InvalidTag):
+ unseal(gek, PURPOSE_INDEX, "index_sync", "group-b", sealed)
+
+
+def test_a_tampered_ciphertext_does_not_open(gek):
+ sealed = seal(gek, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD)
+ sealed["ct"] = bytes([sealed["ct"][0] ^ 1]) + sealed["ct"][1:]
+ with pytest.raises(InvalidTag):
+ unseal(gek, PURPOSE_INDEX, "index_sync", "g1", sealed)
+
+
+def test_a_message_that_is_not_sealed_is_refused_as_such(gek):
+ """
+ Not an empty payload, and not a crash on a missing key — the two shapes a
+ caller might otherwise paper over.
+ """
+ with pytest.raises(ValueError):
+ unseal(gek, PURPOSE_INDEX, "index_sync", "g1", {"entries": []})
+
+
+def test_a_fresh_nonce_per_message(gek):
+ """
+ Never derived from the payload: two identical payloads under one long-lived
+ subkey would then reuse a nonce, which for GCM is a total break.
+ """
+ nonces = {seal(gek, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD)["nonce"]
+ for _ in range(50)}
+ assert len(nonces) == 50
+
+
+def test_no_key_is_an_error_not_a_plaintext_fallback(gek):
+ with pytest.raises(ValueError):
+ seal(None, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD)
+
+
+def test_an_unknown_purpose_is_refused(gek):
+ with pytest.raises(ValueError):
+ group_key(gek, "chat")
+
+
+def test_the_envelope_carries_no_readable_payload(gek):
+ """
+ The property, at the level of the primitive: what `seal` returns holds nothing
+ of what went in. `test_index_no_cleartext.py` asserts the same thing on the
+ real frames.
+ """
+ payload = {"video_root": "holidays-2019-invoices", "entries": ["ledger.pdf"]}
+ frame = msgpack.packb(seal(gek, PURPOSE_ACK, "handshake_ack", "g1", payload))
+ for word in (b"holidays", b"invoices", b"ledger", b"video_root"):
+ assert word not in frame
diff --git a/packages/meshbay-common/tests/test_js_python_parity.py b/packages/meshbay-common/tests/test_js_python_parity.py
index 340ea3e..6f7437f 100644
--- a/packages/meshbay-common/tests/test_js_python_parity.py
+++ b/packages/meshbay-common/tests/test_js_python_parity.py
@@ -16,6 +16,7 @@ Skipped when node is unavailable; that is a coverage gap, not a pass.
import json
import shutil
import subprocess
+import tempfile
from pathlib import Path
import pytest
@@ -234,3 +235,159 @@ def test_length_prefixing_actually_disambiguates(js_output):
a = js_output["handshake"][2] # group_id "g"
b = js_output["handshake"][3] # group_id ""
assert a != b, "JS transcripts collide across different group ids"
+
+
+# ── groupbox: the sealed payload, both directions ────────────────────────────
+#
+# Unlike the transcripts above, this one has a wire format to disagree about as
+# well as a derivation: the HKDF salt (Python's `salt=None` against WebCrypto's
+# `salt: new Uint8Array(0)`) and the AAD's UTF-8 encoding are both invisible to
+# every other test, and a disagreement in either means no browser can open an
+# index or a handshake ack from any node — with the AEAD reporting only "it did
+# not open", which is the same thing a wrong key reports.
+
+# (purpose, msg_type, group_id)
+GROUPBOX_VECTORS = [
+ ("index", "index_sync", "g" * 32),
+ ("index", "index_delta", "g" * 32),
+ ("ack", "handshake_ack", "g" * 32),
+ # Empty group id — the operator-pairing shape, and the one a naive
+ # concatenation would let collide with a short id.
+ ("ack", "handshake_ack", ""),
+ # Non-ASCII: TextEncoder and Python's .encode() must agree on the AAD.
+ ("index", "index_sync", "groupe-café-日本"),
+ # A '|' inside the group id, which is the AAD's own separator.
+ ("index", "index_sync", "a|b"),
+]
+
+GROUPBOX_GEK = bytes.fromhex("5a" * 32)
+
+_GROUPBOX_HARNESS = r"""
+const fs = require('fs');
+
+globalThis.window = {};
+const src = fs.readFileSync(process.argv[2], 'utf8');
+const M = new Function(src + '\nreturn { sealGroup, openGroup };')();
+
+const hex = (s) => {
+ const out = new Uint8Array(s.length / 2);
+ for (let i = 0; i < s.length; i += 2) out[i / 2] = parseInt(s.substr(i, 2), 16);
+ return out;
+};
+const toHex = (u8) =>
+ Array.from(u8).map(b => b.toString(16).padStart(2, '0')).join('');
+
+(async () => {
+ const input = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
+ const gek = hex(input.gek);
+ const out = { opened: [], sealed: [] };
+
+ for (const v of input.vectors) {
+ // Python sealed it; open it here.
+ out.opened.push(toHex(await M.openGroup(
+ gek, v.purpose, v.msg_type, v.group_id,
+ { nonce: hex(v.nonce), ct: hex(v.ct) })));
+ // Seal the same plaintext here, for Python to open.
+ const sealed = await M.sealGroup(
+ gek, v.purpose, v.msg_type, v.group_id, hex(v.plaintext));
+ out.sealed.push({ nonce: toHex(sealed.nonce), ct: toHex(sealed.ct) });
+ }
+
+ process.stdout.write(JSON.stringify(out));
+})().catch((e) => { console.error(e); process.exit(1); });
+"""
+
+
+def _groupbox_payload(idx: int) -> dict:
+ """A distinct payload per vector, so a crossed result cannot pass."""
+ return {"n": idx, "name": f"entry-{idx}.bin", "flags": [True, None, idx * 7]}
+
+
+@pytest.fixture(scope="module")
+def groupbox_js(tmp_path_factory):
+ import msgpack
+
+ from meshbay_common.groupbox import seal
+
+ d = tmp_path_factory.mktemp("groupbox-parity")
+ harness = d / "harness.js"
+ harness.write_text(_GROUPBOX_HARNESS)
+
+ vectors = []
+ for i, (purpose, msg_type, group_id) in enumerate(GROUPBOX_VECTORS):
+ payload = _groupbox_payload(i)
+ sealed = seal(GROUPBOX_GEK, purpose, msg_type, group_id, payload)
+ vectors.append({
+ "purpose": purpose, "msg_type": msg_type, "group_id": group_id,
+ "nonce": sealed["nonce"].hex(), "ct": sealed["ct"].hex(),
+ "plaintext": msgpack.packb(payload, use_bin_type=True).hex(),
+ })
+
+ payload_file = d / "vectors.json"
+ payload_file.write_text(json.dumps({"gek": GROUPBOX_GEK.hex(),
+ "vectors": vectors}))
+
+ proc = subprocess.run(
+ ["node", str(harness), str(CRYPTO_JS), str(payload_file)],
+ capture_output=True, text=True, timeout=60,
+ )
+ if proc.returncode != 0:
+ pytest.fail(f"node groupbox harness failed:\n{proc.stderr}")
+ return json.loads(proc.stdout)
+
+
+@pytest.mark.parametrize("idx,vector", list(enumerate(GROUPBOX_VECTORS)))
+def test_browser_opens_what_python_sealed(idx, vector, groupbox_js):
+ """A mismatch means no browser can read an index or a handshake ack."""
+ import msgpack
+
+ opened = bytes.fromhex(groupbox_js["opened"][idx])
+ assert msgpack.unpackb(opened, raw=False) == _groupbox_payload(idx), (
+ f"crypto.js and groupbox.py disagree for {vector!r}")
+
+
+@pytest.mark.parametrize("idx,vector", list(enumerate(GROUPBOX_VECTORS)))
+def test_python_opens_what_the_browser_sealed(idx, vector, groupbox_js):
+ """
+ The other direction. Nothing in the SPA seals today — `sealGroup` exists for
+ the chat plan, which needs the same primitive — but a codec that only ever
+ runs one way is a codec whose encoder is untested.
+ """
+ from meshbay_common.groupbox import unseal
+
+ purpose, msg_type, group_id = vector
+ sealed = groupbox_js["sealed"][idx]
+ msg = {"nonce": bytes.fromhex(sealed["nonce"]),
+ "ct": bytes.fromhex(sealed["ct"])}
+ assert unseal(GROUPBOX_GEK, purpose, msg_type, group_id, msg) == \
+ _groupbox_payload(idx)
+
+
+def test_the_browser_refuses_a_payload_sealed_for_another_message(groupbox_js):
+ """
+ The AAD, checked across the boundary rather than only within Python: a JS
+ `openGroup` that dropped `additionalData` would still round-trip against
+ itself and against Python, and would pass every other test here.
+ """
+ from meshbay_common.groupbox import seal
+
+ sealed = seal(GROUPBOX_GEK, "index", "index_sync", "g1", {"x": 1})
+ script = (
+ "globalThis.window = {};\n"
+ "const fs = require('fs');\n"
+ "const M = new Function(fs.readFileSync(process.argv[2], 'utf8')\n"
+ " + '\\nreturn { openGroup };')();\n"
+ "const hex = (s) => Uint8Array.from(s.match(/../g).map(b => parseInt(b, 16)));\n"
+ "M.openGroup(hex(process.argv[3]), 'index', 'index_delta', 'g1',\n"
+ " { nonce: hex(process.argv[4]), ct: hex(process.argv[5]) })\n"
+ " .then(() => { console.log('OPENED'); })\n"
+ " .catch(() => { console.log('REFUSED'); });\n"
+ )
+ with tempfile.TemporaryDirectory() as tmp:
+ h = Path(tmp) / "aad.js"
+ h.write_text(script)
+ proc = subprocess.run(
+ ["node", str(h), str(CRYPTO_JS), GROUPBOX_GEK.hex(),
+ sealed["nonce"].hex(), sealed["ct"].hex()],
+ capture_output=True, text=True, timeout=60)
+ assert proc.stdout.strip() == "REFUSED", proc.stdout + proc.stderr
diff --git a/packages/meshbay-common/tests/test_version_negotiation.py b/packages/meshbay-common/tests/test_version_negotiation.py
new file mode 100644
index 0000000..50bdf54
--- /dev/null
+++ b/packages/meshbay-common/tests/test_version_negotiation.py
@@ -0,0 +1,78 @@
+"""
+The version range, checked rather than merely written.
+
+Before MNP 1.0 every message carried a `v` that no one read, so a mismatch
+surfaced as a *missing field*: an 0.x client reading a 1.0 handshake ack finds no
+`enabled_apps` and applies its documented fallback — show every app — which is a
+wrong answer rather than an error. The three failure modes 1.0's flag day was
+called for all present that way, which is why negotiation ships in the same
+deployment rather than after it (phase 15.6, decision D2).
+"""
+
+import pytest
+from meshbay_common import MNP_VERSION
+from meshbay_common.handshake import (
+ MNP_MIN_SUPPORTED,
+ HandshakeError,
+ check_version,
+ parse_version,
+)
+
+
+def test_this_build_accepts_itself():
+ check_version(MNP_VERSION, MNP_MIN_SUPPORTED)
+
+
+def test_a_peer_that_declares_no_minimum_is_read_as_speaking_only_its_own():
+ """
+ Which is the right reading of every 0.x peer: none of them declared a range,
+ because none of them checked one.
+ """
+ check_version(MNP_VERSION)
+
+
+def test_an_older_peer_is_refused_with_a_code():
+ with pytest.raises(HandshakeError) as caught:
+ check_version("0.15")
+ assert caught.value.code == "version_too_old"
+ # The text is for a human and may be reworded; the client matches the code.
+ assert "0.15" in str(caught.value)
+
+
+def test_a_peer_requiring_more_than_we_speak_is_refused_with_a_code():
+ ours = parse_version(MNP_VERSION)
+ future = f"{ours[0] + 1}.0"
+ with pytest.raises(HandshakeError) as caught:
+ check_version(future, future)
+ assert caught.value.code == "version_too_new"
+
+
+def test_a_newer_peer_that_still_accepts_us_is_allowed():
+ """
+ The point of a range rather than an equality: a 1.4 node that still speaks to
+ 1.0 clients must not refuse one.
+ """
+ ours = parse_version(MNP_VERSION)
+ check_version(f"{ours[0]}.{ours[1] + 4}", MNP_MIN_SUPPORTED)
+
+
+@pytest.mark.parametrize("bad", ["", "one.two", "1", None, "1.0.0", "v1.0"])
+def test_an_unreadable_version_is_refused_not_guessed(bad):
+ with pytest.raises(HandshakeError) as caught:
+ check_version(bad)
+ assert caught.value.code == "version_unreadable"
+
+
+def test_versions_order_numerically_not_lexically():
+ """`"0.9" < "0.15"` as strings, and the opposite as versions."""
+ assert parse_version("0.9") < parse_version("0.15") < parse_version("1.0")
+
+
+def test_mnp_1_0_is_a_major_bump():
+ """
+ Recorded as a test because the number is the only thing that says "this one is
+ different". 1.0 seals the index and the ack under the group key: no 0.x peer
+ can open either, and there is nothing to be compatible with.
+ """
+ assert parse_version(MNP_VERSION) >= (1, 0)
+ assert parse_version(MNP_MIN_SUPPORTED) >= (1, 0)