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/src/meshbay_common/handshake.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/src/meshbay_common/handshake.py')
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/handshake.py | 72 |
1 files changed, 69 insertions, 3 deletions
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, |