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
|
"""
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}
node authorize_token() JWT, scope, denylist, membership, hosting
node → client handshake_challenge {nonce_s}
client → node handshake_response {proof}
node verify client proof HMAC(GEK, client transcript)
node → client handshake_ack {proof, sig, node_pk, is_node_admin}
client verify node proof HMAC(GEK, node transcript) + Ed25519
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.
**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
HANDSHAKE_PREFIX = b"meshbay:mnp:handshake:v1"
ROLE_CLIENT = "client"
ROLE_NODE = "node"
NONCE_LEN = 32
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
pk_user: str
jti: str
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 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"])
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:
raise HandshakeError("Group not hosted on this node")
return AuthorizedPeer(
user_id=user_id,
group_id=group_id,
username=decoded.get("username", ""),
pk_user=decoded.get("pk_user", ""),
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
|