summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src/meshbay_common/groupbox.py
diff options
context:
space:
mode:
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