aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src/meshbay_common/groupbox.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/groupbox.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/groupbox.py')
-rw-r--r--packages/meshbay-common/src/meshbay_common/groupbox.py123
1 files changed, 123 insertions, 0 deletions
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