diff options
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, |