aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src/meshbay_common/handshake.py
blob: 4d2cac25fc0592ac7b60658fa24808b0b7040e78 (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
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
"""
Unified MNP handshake — one implementation, every transport.

Finding C6: the handshake existed three times over (WebRTC, QUIC, TCP), and only
the newest copy enforced the GEK proof. QUIC and TCP accepted a bare JWT, so a
forged or stolen token reached the node and could inject chat messages without
ever holding the group key. TCP is gone (11.5.2); QUIC and WebRTC now share this
module, and a parity test fails if either skips a step.

The sequence:

    client → node   handshake            {token, group_id, nonce_c, v, v_min}
    node            check_version()      supported range, both ways
    node            authorize_token()    JWT, scope, denylist, membership, hosting
    node → client   handshake_challenge  {nonce_s, v, v_min}
    client → node   handshake_response   {proof}
    node            verify client proof  HMAC(GEK, client transcript)
    node → client   handshake_ack        {proof, sig, node_pk, nonce, ct}
    client          verify node proof    HMAC(GEK, node transcript) + Ed25519
    client          THEN open ct         the session config, sealed (groupbox.py)

Two properties this adds over the previous design:

**Mutual authentication (C3).** Authentication used to run one way: the client
proved itself, the node proved nothing. `handshake_ack.node_pk` was never verified
against anything, and per-chunk signatures had been dropped in Phase 9.15, so a
peer that had hijacked signaling (C2) or been substituted by the hub could accept
the client's proof, ignore it, and serve a forged index, forged chat history and a
forged `is_node_admin` flag. The node now proves GEK possession over a
client-chosen nonce *and* signs the transcript with its long-term key, so the
client can pin it.

**A version that is checked (L2).** `v` used to be written by everyone and read by
nobody, so a version mismatch surfaced as a missing field — an old client reading a
1.0 ack found no `enabled_apps` and applied its documented fallback, "show every
app", which is a wrong answer rather than an error. Both sides now declare the range
they speak, in the first message each sends, and a peer outside it is refused with a
code rather than served a message it will misread. Without this the *next* breaking
change costs another coordinated deployment; with it, it costs a refusal.

**A payload the hub cannot forge.** The transcript above names `role`, `group_id`,
both nonces and the binding — and **no ack field**. So `is_node_admin`,
`enabled_apps`, `video_root` and the rest were authenticated by the channel alone.
Since MNP 1.0 they travel sealed under a GEK-derived subkey (`groupbox.py`), which
gives them an AEAD tag from a key the hub does not hold. Verify first, then decrypt:
opening the payload before the proof and the signature would mean acting on data
from a peer not yet authenticated.

**Unambiguous transcripts (L4).** The old proof was `nonce ‖ offer_fp ‖ answer_fp`
— bare concatenation, and a missing fingerprint silently degraded it to nonce-only.
Every field is now length-prefixed and domain-separated, the role is bound so a
client proof can never be replayed as a node proof, and an empty channel binding is
refused rather than tolerated.
"""

from __future__ import annotations

import hashlib
import hmac
from dataclasses import dataclass
from typing import Any, Protocol

import jwt

from meshbay_common import MNP_VERSION

HANDSHAKE_PREFIX = b"meshbay:mnp:handshake:v1"

# The oldest peer this build will talk to.
#
# 2.0 (2026-09-07): chat messages are sealed under a per-device subkey of the
# group's chat epoch key, and the node refuses a plaintext one. A 1.x peer can
# neither produce nor read that, so there is nothing to be compatible with —
# the same reasoning that made 1.0 a MAJOR bump for the sealed index.
#
# Moving the floor with the version is the point: a 1.x client is refused here,
# with `version_too_old` and a sentence saying so, instead of completing a
# handshake and then discovering that every message it sends is rejected and
# every message it receives is unreadable. A stated refusal is a bug report; a
# chat that quietly does not work is a support case.
# 3.0 (2026-09-09): a transfer runs under a lease, and a 2.x peer cannot ask for
# one. Admitting it would mean either refusing it later, per file, in a way it
# has no vocabulary to understand — or serving it outside every cap the operator
# set, which makes the caps decoration. Neither is honest, so it is refused
# here, with a code and a sentence.
MNP_MIN_SUPPORTED = "3.0"

ROLE_CLIENT = "client"
ROLE_NODE = "node"

NONCE_LEN = 32

# Clock-skew tolerance for JWT `iat`/`exp`/`nbf`. The token is issued by the
# hub and verified by a node, on two machines whose clocks are only as close
# as their NTP — and a VM guest that has just resumed can be tens of seconds
# out. Without this a slightly-fast client cannot connect at all
# ("token is not yet valid (iat)").
JWT_LEEWAY_SECONDS = 60


class HandshakeError(Exception):
    """
    Refusal, with a message safe to hand to the peer.

    `code` is the same refusal in a form a client can act on. The text is for a
    human and may be reworded; matching on it from the client would be a string
    comparison that breaks silently the day someone improves the wording.
    """

    def __init__(self, message: str, code: str = ""):
        super().__init__(message)
        self.code = code


class DenylistLike(Protocol):
    def is_denied(self, user_id: str, jti: str, group_id: str = "") -> bool: ...


@dataclass
class AuthorizedPeer:
    user_id: str
    group_id: str
    username: str
    jti: str

    # No `pk_user`. The hub used to put a user key in the token and the node
    # recorded it as the uploader's identity, which let whoever issued tokens
    # decide who could delete a file. Identity keys are pinned by the node
    # (see roster.py); the hub certifies accounts, not keys.


def handshake_transcript(
    role: str,
    group_id: str,
    nonce_client: bytes,
    nonce_node: bytes,
    binding: bytes,
) -> bytes:
    """
    Bytes covered by a handshake proof.

    `binding` ties the proof to the concrete connection: the two DTLS fingerprints
    for WebRTC, the TLS certificate hashes for QUIC. Without it a proof captured on
    one connection is replayable on another (NS5).
    """
    fields = [
        role.encode(),
        group_id.encode(),
        nonce_client,
        nonce_node,
        binding,
    ]
    out = bytearray(HANDSHAKE_PREFIX)
    for field in fields:
        out += len(field).to_bytes(4, "big")
        out += field
    return bytes(out)


def make_proof(
    gek: bytes,
    role: str,
    group_id: str,
    nonce_client: bytes,
    nonce_node: bytes,
    binding: bytes,
) -> bytes:
    if not binding:
        # An empty binding means the transport could not identify the channel.
        # Proceeding would silently drop MitM detection (L4).
        raise HandshakeError("Channel binding unavailable")
    if not gek:
        raise HandshakeError("Group encryption not initialized")
    transcript = handshake_transcript(
        role, group_id, nonce_client, nonce_node, binding)
    return hmac.new(gek, transcript, hashlib.sha256).digest()


def verify_proof(
    gek: bytes,
    proof: bytes,
    role: str,
    group_id: str,
    nonce_client: bytes,
    nonce_node: bytes,
    binding: bytes,
) -> bool:
    try:
        expected = make_proof(
            gek, role, group_id, nonce_client, nonce_node, binding)
    except HandshakeError:
        return False
    return hmac.compare_digest(proof, expected)


def parse_version(v: str) -> tuple[int, int]:
    """`"1.0"` → `(1, 0)`. Raises ValueError on anything else."""
    major, _, minor = str(v).partition(".")
    return int(major), int(minor)


def check_version(peer_v: str, peer_min: str = "") -> None:
    """
    Refuse a peer outside the range this build speaks, before anything else.

    `peer_min` is the oldest version the peer accepts *from us*; a peer that
    declares none is treated as accepting only what it speaks, which is the right
    reading of every 0.x peer — none of them declared a range because none of them
    checked one.

    Raises HandshakeError with a code the other side can act on, rather than
    letting the mismatch surface later as a field that is missing.
    """
    ours = parse_version(MNP_VERSION)
    our_min = parse_version(MNP_MIN_SUPPORTED)
    try:
        theirs = parse_version(peer_v)
        their_min = parse_version(peer_min) if peer_min else theirs
    except (ValueError, AttributeError):
        raise HandshakeError(
            f"Unreadable protocol version {peer_v!r}", code="version_unreadable"
        ) from None

    if theirs < our_min:
        raise HandshakeError(
            f"Protocol {peer_v} is too old for this peer, which needs "
            f"{MNP_MIN_SUPPORTED} or later",
            code="version_too_old")
    if their_min > ours:
        raise HandshakeError(
            f"This peer speaks protocol {MNP_VERSION}, older than the "
            f"{peer_min} the other side requires",
            code="version_too_new")


def authorize_token(
    token: str,
    hub_pk_pem: bytes,
    *,
    group_id: str,
    hosted_groups: Any | None = None,
    denylist: DenylistLike | None = None,
    require_scope: str | None = "user",
) -> AuthorizedPeer:
    """
    Everything decided from the JWT, before any proof is exchanged.

    Raises HandshakeError with a peer-safe message. Deliberately strict about
    `group_id`: it used to be optional, and omitting it skipped the membership
    check entirely and fell back to the node's first group (M1).
    """
    try:
        decoded = jwt.decode(token, hub_pk_pem, algorithms=["EdDSA"],
                             leeway=JWT_LEEWAY_SECONDS)
    except Exception as exc:
        raise HandshakeError(f"Invalid JWT: {exc}") from exc

    # A node-scoped daemon token must not be usable as a client token (M9).
    if require_scope is not None and decoded.get("scope", "user") != require_scope:
        raise HandshakeError("Wrong token scope")

    user_id = decoded.get("sub", "")
    jti = decoded.get("jti", "")
    if not user_id:
        raise HandshakeError("Token has no subject")

    if not group_id:
        raise HandshakeError("group_id is required")

    if denylist is not None and denylist.is_denied(user_id, jti, group_id):
        raise HandshakeError("Token revoked")

    if group_id not in decoded.get("groups", []):
        # Almost always a token issued before the person was added to the group:
        # `groups` is baked in at login and the hub does not push updates. The
        # client refreshes and retries on this code rather than telling someone
        # who *is* a member that they are not one.
        raise HandshakeError("Not a member of this group", code="not_a_member")

    if hosted_groups is not None and group_id not in hosted_groups:
        # Coded, because a client handed several nodes for one group has to tell
        # "this node cannot serve it, try the next one" apart from "you, in this
        # browser, must do something first". Uncoded it was neither, and a node
        # wrongly registered for a group took that group down for everyone.
        raise HandshakeError("Group not hosted on this node", code="not_hosted")

    return AuthorizedPeer(
        user_id=user_id,
        group_id=group_id,
        username=decoded.get("username", ""),
        jti=jti,
    )


def webrtc_binding(offer_fp: bytes, answer_fp: bytes) -> bytes:
    """Channel binding for WebRTC: both DTLS certificate fingerprints."""
    return (len(offer_fp).to_bytes(4, "big") + offer_fp
            + len(answer_fp).to_bytes(4, "big") + answer_fp)


def quic_binding(server_cert_der: bytes) -> bytes:
    """
    Channel binding for QUIC.

    QUIC has no DTLS fingerprint to reuse, so the anchor is a hash of the server's
    self-signed certificate — the same value a client pins as the node identity.
    An RFC 5705 exporter would be stronger; aioquic does not currently expose one
    (11.5.6).
    """
    digest = hashlib.sha256(server_cert_der).digest()
    return len(digest).to_bytes(4, "big") + digest