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 | |
| 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')
3 files changed, 216 insertions, 4 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py index 61502c9..e96c483 100644 --- a/packages/meshbay-common/src/meshbay_common/__init__.py +++ b/packages/meshbay-common/src/meshbay_common/__init__.py @@ -70,5 +70,28 @@ __version__ = "0.10.0" # there: the WebRTC shape — the one every deployed client speaks — is byte for byte # what it was, and no QUIC client ships. Recorded as a MINOR bump for that reason; # a deployed QUIC peer would have made it a MAJOR one. -MNP_VERSION = "0.15" +# 1.0: `index_sync`, `index_delta` and the `handshake_ack` configuration +# fields now travel **sealed under a GEK-derived subkey** +# (`meshbay_common.groupbox`), and the handshake negotiates a supported +# version range instead of writing a `v` nobody reads. +# +# **Breaking, on the wire every deployed client speaks**, and there is no way +# to describe it as additive: an old client sends `index_sync` and gets a +# message with no `entries`; it reads `ack.enabled_apps`, finds nothing, and +# applies its documented fallback — "show every app" — rather than reporting +# an error; a new client against an old node finds `entries` it does not +# expect and no `ct`. 0.15 stayed MINOR because only the QUIC wire changed and +# no QUIC client ships; that argument is not available here, and MAJOR is what +# the project's own rule says. Hub and every node deploy together; the SPA is +# served by the hub, so a browser picks up the new client on reload. +# +# Version negotiation ships in the same flag day rather than after it (phase +# 15.6): the coordinated deployment is already being paid for, and it is what +# makes the *next* breaking change cost a refusal message instead of a second +# flag day. `MNP_MIN_SUPPORTED` in `handshake.py` is the other half. +# +# The index at rest, `index_progress` (counters only, never a path — see +# `groupbox.py` and daemon.py `_push_index_progress`), chat, and file content +# on the operator's disk are all deliberately unchanged. +MNP_VERSION = "1.0" MHP_VERSION = "0.1" diff --git a/packages/meshbay-common/src/meshbay_common/groupbox.py b/packages/meshbay-common/src/meshbay_common/groupbox.py new file mode 100644 index 0000000..f1091c3 --- /dev/null +++ b/packages/meshbay-common/src/meshbay_common/groupbox.py @@ -0,0 +1,123 @@ +""" +Sealing a message payload under the group key. + +`file_chunk` and `stream_data` have always travelled encrypted under a GEK-derived +key; `index_sync`, `index_delta` and the `handshake_ack` config fields travelled in +plain msgpack, authenticated by the DTLS/TLS channel and nothing else. The model in +force was "the channel is the boundary". This module is the other half: a payload +sealed under a key the hub does not hold. + +Two properties, and it is worth being precise about which is which. + +**Integrity, for the ack.** The node signs `handshake_transcript(role, group_id, +nonce_c, nonce_s, binding)`, which contains no ack field at all — so `is_node_admin`, +`enabled_apps`, `video_root` and the rest were authenticated by the channel alone. +An AEAD tag from a GEK-derived key is a stronger statement than any amount of +confidentiality on the index. + +**Confidentiality, for the index.** Defence in depth against our own next bug of a +class already shipped twice: C1 (the node HTTP API served the index and plaintext +files on 0.0.0.0 with no authentication) and C6 (the TCP transport accepted a bare +JWT with no GEK proof) were both "a peer that had not completed the handshake was +served data". Sealed, that bug leaks ciphertext rather than filenames, folder names +and group configuration. It buys nothing against a network observer (DTLS/TLS +already covers that), nothing against the hub (it never sees channel traffic), and +nothing against a member — who holds the GEK. That is the whole claim. + +Purpose separation is deliberate. `GroupIndex.serialize()` reuses +`chunk_key_aes(gek, file_hash, chunk_index)` with a pseudo-file ("the index as chunk +0 of a virtual index file"), which borrows a file's key space for something that is +not a file. Each purpose here derives its own subkey instead. +""" + +from __future__ import annotations + +import os + +import msgpack +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +PURPOSE_INDEX = "index" +PURPOSE_ACK = "ack" + +# `salt=None` here and `salt: new Uint8Array(0)` in crypto.js agree — RFC 5869 +# extracts with a zero key either way. Already proven in production by +# `deriveChunkKey`, and held by the parity test. +_INFO = { + PURPOSE_INDEX: b"meshbay:index:v1", + PURPOSE_ACK: b"meshbay:ack:v1", +} + +NONCE_LEN = 12 # 96-bit, the WebCrypto AES-GCM standard + + +def group_key(gek: bytes, purpose: str) -> bytes: + """Derive the AES-256 subkey for one purpose. Distinct per purpose, by info.""" + try: + info = _INFO[purpose] + except KeyError: + raise ValueError(f"unknown groupbox purpose: {purpose!r}") from None + if not gek: + raise ValueError("no group key") + return HKDF( + algorithm=hashes.SHA256(), length=32, salt=None, info=info, + ).derive(gek) + + +def associated_data(msg_type: str, group_id: str) -> bytes: + """ + What a ciphertext is bound to. + + Binding the message type stops an `index_sync` body being replayed as an + `index_delta`; binding the group stops one being moved between two groups hosted + on the same node. It costs nothing and closes a class of confusion that is + tedious to reason about later. + """ + return f"{msg_type}|{group_id}".encode() + + +def seal(gek: bytes, purpose: str, msg_type: str, group_id: str, + payload: dict) -> dict: + """ + The `{nonce, ct}` pair for the caller to merge into its message. + + Returns only those two fields: the routing fields (`type`, `v`, `group_id`) stay + in clear because the receiver must route and version-check before it can decrypt, + and `group_id` selects the key besides. + """ + key = group_key(gek, purpose) + # 96-bit random nonce per message. The volume here — one message per index + # change — is many orders below the birthday bound. Never derive it from the + # payload: two identical payloads under one subkey would then reuse it. + nonce = os.urandom(NONCE_LEN) + ct = AESGCM(key).encrypt( + nonce, msgpack.packb(payload, use_bin_type=True), + associated_data(msg_type, group_id)) + return {"nonce": nonce, "ct": ct} + + +def unseal(gek: bytes, purpose: str, msg_type: str, group_id: str, + msg: dict) -> dict: + """ + Open a sealed message. Raises on anything that does not open — never a partial + result, and never a default. + + A payload that does not open is not a config change and not an empty index; it is + a peer we cannot talk to. Falling back would make `enabled_apps` read as "the + operator disabled every app" and an index as "the group is empty", both + indistinguishable from a legitimate state — which is what makes a silent fallback + worse than a stop. Same rule already applied to `file_chunk`. + """ + key = group_key(gek, purpose) + nonce = msg.get("nonce") + ct = msg.get("ct") + if not isinstance(nonce, (bytes, bytearray)) or not isinstance(ct, (bytes, bytearray)): + raise ValueError(f"{msg_type}: not a sealed message") + plain = AESGCM(key).decrypt( + bytes(nonce), bytes(ct), associated_data(msg_type, group_id)) + payload = msgpack.unpackb(plain, raw=False) + if not isinstance(payload, dict): + raise ValueError(f"{msg_type}: sealed payload is not a map") + return payload 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, |