1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
|
"""
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.
**Both, for the upload (MNP 2.0).** `file_upload` carried the filename and the raw
bytes in clear, and `file_upload_ack` carried the name it was stored under. The
download path had been sealed end to end since the beginning — so the same file was
ciphertext coming out of a node and plaintext going in, which is not a threat model,
it is an oversight. The node holds the GEK for its own group, so unlike the index
this direction seals *towards* the node: it opens the payload before it writes
anything to disk, and refuses a chunk that does not open rather than guessing.
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"
PURPOSE_UPLOAD = "upload"
# The chat epoch keys themselves, on their way to a member. The keys are what
# the chat archive is encrypted under; this is only how they travel, which is
# why rotating the group key costs a re-wrap and not a re-encryption.
PURPOSE_CHAT_KEYS = "chat_keys"
# The group's roster of members and their device keys, on its way to a member.
# Sealed for the same reason the index is: it is the group's membership, and a
# peer that has not completed the handshake has no business reading it.
PURPOSE_ROSTER = "roster"
# `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",
PURPOSE_UPLOAD: b"meshbay:upload:v1",
PURPOSE_CHAT_KEYS: b"meshbay:chat_keys:v1",
PURPOSE_ROSTER: b"meshbay:roster:v1",
}
# One subkey per purpose, and `seal` draws a fresh 96-bit nonce per message, so
# the bound that matters is birthday collision under `PURPOSE_UPLOAD` — the only
# purpose with real volume, one message per 48 KiB chunk. 2**32 chunks is 200 TB
# uploaded under a single GEK before the collision probability reaches 2**-32,
# and `gek_rotate` exists. Deriving the nonce from the payload instead would be
# worse, not better: two chunks of identical bytes are ordinary in a file.
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
|