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 /packages/meshbay-common/tests/test_version_negotiation.py | |
| 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
Diffstat (limited to 'packages/meshbay-common/tests/test_version_negotiation.py')
| -rw-r--r-- | packages/meshbay-common/tests/test_version_negotiation.py | 78 |
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) |