""" 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