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/groupbox.py | |
| 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/groupbox.py')
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/groupbox.py | 123 |
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 |