aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src/meshbay_common/handshake.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/src/meshbay_common/handshake.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/src/meshbay_common/handshake.py')
-rw-r--r--packages/meshbay-common/src/meshbay_common/handshake.py72
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,