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