aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src/meshbay_common/groupbox.py
blob: eb2f6302ced72824295b43a11fbae4b6bdeb43eb (plain) (blame)
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
"""
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"

# `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",
}

# 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