aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/tests/test_version_negotiation.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-03 16:16:55 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-03 16:16:55 +0200
commit675beed6ff688733a9598f9d82d41578f48316be (patch)
tree78dd4f8dff312f0ad99bd63bc679bf402591c5ed /packages/meshbay-common/tests/test_version_negotiation.py
parent15087b0e8fdb872602310119f14680aaa443fd93 (diff)
downloadmeshbay-675beed6ff688733a9598f9d82d41578f48316be.tar.gz
feat!: MNP 1.0 — seal index and handshake_ack under the group key
`index_sync`, `index_delta` and the `handshake_ack` config payload now travel sealed under a GEK-derived subkey (`meshbay_common/groupbox.py`, mirrored by `sealGroup`/`openGroup` in `crypto.js`). Only `type`, `v`, `group_id` and the ack's `node_pk`/`proof`/`sig` stay in clear — a receiver must route and authenticate before it would trust a decryption. Verify, then decrypt. The ack line is integrity, not confidentiality: the signed handshake transcript names no ack field, so `is_node_admin`, `enabled_apps`, `video_root` and the rest were authenticated by the DTLS channel alone. The index line is defence in depth against a repeat of C1/C6 — a peer served before the handshake completes now gets ciphertext, not filenames. Nothing against an observer, the hub, or a member; that is the whole claim. `index_progress` stays clear (D3, counters only). Chat is out of scope. Failure is fatal: a payload that does not open ends the session naming the message type — never an empty index or an empty `enabled_apps`, both of which are legitimate states. Version negotiation ships here too (phase 15.6, brought forward): `v` + `v_min` on `handshake` and `handshake_challenge`, refused with `version_too_old` / `version_too_new` / `version_unreadable`. The flag day was already being paid for; the next breaking change now costs a refusal message. BREAKING CHANGE: breaks the WebRTC wire every deployed client speaks. Hub and every node must deploy together; the SPA is served by the hub, so a browser picks up the new client on reload. See MESHBAY_NODE_PROTOCOL.md §11.1a, §13.1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HkzbhmMmK8PqQBtGz5zCvY
Diffstat (limited to 'packages/meshbay-common/tests/test_version_negotiation.py')
-rw-r--r--packages/meshbay-common/tests/test_version_negotiation.py78
1 files changed, 78 insertions, 0 deletions
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)