aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-13 11:56:04 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-13 11:56:04 +0200
commitbe1ff89465c9878f2fbd641fb0d4eba729439ac8 (patch)
tree79e67a4de1262b5593b9023600de1ed37934eefc
parente13659f8f3166b5a9a4155314941bc149fec2721 (diff)
downloadmeshbay-be1ff89465c9878f2fbd641fb0d4eba729439ac8.tar.gz
refactor(quic): share the unified handshake authorization
Phase 11.5.4 — QUIC half. Findings M1, M9 on this transport. quic_server._do_handshake_sync was a second, weaker copy of the WebRTC logic: group_id was optional, so omitting it skipped the membership check entirely and fell back to the node's first group (M1); node-scoped daemon tokens were accepted as client tokens (M9); and the checks could drift from the WebRTC path independently, which is how they diverged in the first place. Authorization now comes from meshbay_common.handshake, shared with WebRTC. C6 IS STILL OPEN ON THIS TRANSPORT. There is no GEK proof here yet: a forged or stolen token still reaches the node over QUIC and can inject chat without holding the group key. What remains is the challenge/response and the mutual node proof — quic_binding() is written and unit-tested for exactly this, and 11.5.6 (whether a certificate hash is the right anchor, or an RFC 5705 exporter is reachable from aioquic) is still unproven. This commit narrows the gap to the proof itself; it does not close the finding. QUIC tests updated: default tokens are members of the test group, and clients pass group_id, since it is mandatory now. Tests: 9 quic/multi-group, full node+common suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/quic_server.py52
-rw-r--r--packages/meshbay-node/tests/test_quic_transport.py9
2 files changed, 36 insertions, 25 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
index 7bb6ce5..ba2d5c5 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
@@ -34,6 +34,7 @@ from aioquic.quic.events import QuicEvent, StreamDataReceived, StreamReset
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from meshbay_common import MNP_VERSION
+from meshbay_common.handshake import HandshakeError, authorize_token
from meshbay_common.crypto import (
sign_chunk,
pk_to_b64,
@@ -193,40 +194,43 @@ class _MNPServerProtocol(QuicConnectionProtocol):
self._send(stream_id, {"type": "error", "detail": str(e)})
def _do_handshake_sync(self, stream_id: int, msg: dict) -> None:
- token = msg.get("token", "")
- group_id = msg.get("group_id", "")
- try:
- decoded = jwt.decode(token, self._ctx["hub_pk_pem"], algorithms=["EdDSA"])
- except Exception as e:
- self._send(stream_id, {"type": "error", "detail": f"Invalid JWT: {e}"})
- self._quic.close()
- return
-
- denylist = self._ctx.get("denylist")
- if denylist and denylist.is_denied(
- decoded.get("sub", ""), decoded.get("jti", ""), group_id):
- self._send(stream_id, {"type": "error", "detail": "Token revoked"})
- self._quic.close()
- return
+ """
+ Authorization half of the unified handshake (11.5.4).
- if group_id and group_id not in decoded.get("groups", []):
- self._send(stream_id, {"type": "error", "detail": "Not a member of this group"})
- self._quic.close()
- return
+ This used to be a second, weaker copy of the WebRTC logic: group_id was
+ optional (so omitting it skipped the membership check entirely — M1),
+ node-scoped daemon tokens were accepted as client tokens (M9), and the
+ checks could drift from the WebRTC path independently. All of that now
+ comes from meshbay_common.handshake, shared with WebRTC.
- if group_id and "groups" in self._ctx and group_id not in self._ctx["groups"]:
- self._send(stream_id, {"type": "error", "detail": "Group not hosted on this node"})
+ NOT YET DONE — finding C6 remains open on this transport: there is still no
+ GEK proof here, so a forged or stolen token reaches the node and can inject
+ chat without holding the group key. The challenge/response and mutual node
+ proof (quic_binding() is written and unit-tested for exactly this) are the
+ remaining work in 11.5.4/5/6.
+ """
+ try:
+ peer = authorize_token(
+ msg.get("token", ""),
+ self._ctx["hub_pk_pem"],
+ group_id=msg.get("group_id", ""),
+ hosted_groups=self._ctx.get("groups"),
+ denylist=self._ctx.get("denylist"),
+ )
+ except HandshakeError as refusal:
+ self._send(stream_id, {"type": "error", "detail": str(refusal)})
self._quic.close()
return
- self._user_id = decoded["sub"]
- self._group_id = group_id
+ self._user_id = peer.user_id
+ self._group_id = peer.group_id
peers = self._ctx.get("_peers")
if peers is not None:
peers[self._user_id] = self
- log.info("QUIC handshake OK — user=%s group=%s", self._user_id[:8], group_id[:8] if group_id else "none")
+ log.info("QUIC handshake OK — user=%s group=%s",
+ self._user_id[:8], self._group_id[:8])
self._send(stream_id, {
"type": MNP.HANDSHAKE_ACK,
"v": MNP_VERSION,
diff --git a/packages/meshbay-node/tests/test_quic_transport.py b/packages/meshbay-node/tests/test_quic_transport.py
index 0c1a1cd..c2c2b70 100644
--- a/packages/meshbay-node/tests/test_quic_transport.py
+++ b/packages/meshbay-node/tests/test_quic_transport.py
@@ -49,7 +49,8 @@ def make_jwt(sk_hub, pk_node_b64, ttl=3600, groups=None):
"iss": "test-hub", "sub": "user-001",
"pk_user": pk_node_b64, "hub_id": "test-hub",
"jti": "test-jti", "iat": now, "exp": now + ttl,
- "groups": groups or [],
+ # group_id is mandatory (M1), so default tokens are members of "g".
+ "groups": groups if groups is not None else ["g"],
}, sk_pem, algorithm="EdDSA")
@@ -80,6 +81,7 @@ async def test_quic_chunk_roundtrip(sk_node, sk_hub, gek, shared_dir, tmp_path):
host="127.0.0.1", port=19100,
jwt_token=token, gek=gek,
pk_node_b64=pk_to_b64(sk_node.public_key()),
+ group_id="g",
) as client:
chunk0 = await client.fetch_chunk(entry.id, chunk_index=0)
chunk1 = await client.fetch_chunk(entry.id, chunk_index=1)
@@ -116,6 +118,7 @@ async def test_quic_fetch_index(sk_node, sk_hub, gek, shared_dir, tmp_path):
host="127.0.0.1", port=19101,
jwt_token=token, gek=gek,
pk_node_b64=pk_to_b64(sk_node.public_key()),
+ group_id="g",
) as client:
wire = await client.fetch_index()
recovered = GroupIndex.deserialize(wire, sk_node=sk_node, gek=gek)
@@ -220,6 +223,7 @@ async def test_quic_session_resumption(sk_node, sk_hub, gek, shared_dir, tmp_pat
async with QuicChunkClient(
host="127.0.0.1", port=19104,
jwt_token=token, gek=gek, pk_node_b64=pk_b64,
+ group_id="g",
) as client:
wire = await client.fetch_index()
assert GroupIndex.deserialize(wire, sk_node=sk_node, gek=gek).count == 2
@@ -233,6 +237,7 @@ async def test_quic_session_resumption(sk_node, sk_hub, gek, shared_dir, tmp_pat
host="127.0.0.1", port=19104,
jwt_token=token, gek=gek, pk_node_b64=pk_b64,
session_ticket=saved_ticket,
+ group_id="g",
) as client:
wire = await client.fetch_index()
assert GroupIndex.deserialize(wire, sk_node=sk_node, gek=gek).count == 2
@@ -269,6 +274,7 @@ async def test_quic_denylist_blocks_user(sk_node, sk_hub, gek, shared_dir, tmp_p
async with QuicChunkClient(
host="127.0.0.1", port=19105,
jwt_token=token, gek=gek, pk_node_b64=pk_b64,
+ group_id="g",
) as client:
wire = await client.fetch_index()
assert GroupIndex.deserialize(wire, sk_node=sk_node, gek=gek).count == 2
@@ -281,6 +287,7 @@ async def test_quic_denylist_blocks_user(sk_node, sk_hub, gek, shared_dir, tmp_p
async with QuicChunkClient(
host="127.0.0.1", port=19105,
jwt_token=token, gek=gek, pk_node_b64=pk_b64,
+ group_id="g",
) as client:
await client.fetch_index()