diff options
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 |