diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-03 16:16:55 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-03 16:16:55 +0200 |
| commit | 675beed6ff688733a9598f9d82d41578f48316be (patch) | |
| tree | 78dd4f8dff312f0ad99bd63bc679bf402591c5ed /packages/meshbay-node/src/meshbay_node/transport/quic_server.py | |
| parent | 15087b0e8fdb872602310119f14680aaa443fd93 (diff) | |
| download | meshbay-675beed6ff688733a9598f9d82d41578f48316be.tar.gz | |
feat!: MNP 1.0 — seal index and handshake_ack under the group key
`index_sync`, `index_delta` and the `handshake_ack` config payload now travel
sealed under a GEK-derived subkey (`meshbay_common/groupbox.py`, mirrored by
`sealGroup`/`openGroup` in `crypto.js`). Only `type`, `v`, `group_id` and the
ack's `node_pk`/`proof`/`sig` stay in clear — a receiver must route and
authenticate before it would trust a decryption. Verify, then decrypt.
The ack line is integrity, not confidentiality: the signed handshake transcript
names no ack field, so `is_node_admin`, `enabled_apps`, `video_root` and the
rest were authenticated by the DTLS channel alone. The index line is defence in
depth against a repeat of C1/C6 — a peer served before the handshake completes
now gets ciphertext, not filenames. Nothing against an observer, the hub, or a
member; that is the whole claim. `index_progress` stays clear (D3, counters
only). Chat is out of scope.
Failure is fatal: a payload that does not open ends the session naming the
message type — never an empty index or an empty `enabled_apps`, both of which
are legitimate states.
Version negotiation ships here too (phase 15.6, brought forward): `v` + `v_min`
on `handshake` and `handshake_challenge`, refused with `version_too_old` /
`version_too_new` / `version_unreadable`. The flag day was already being paid
for; the next breaking change now costs a refusal message.
BREAKING CHANGE: breaks the WebRTC wire every deployed client speaks. Hub and
every node must deploy together; the SPA is served by the hub, so a browser
picks up the new client on reload. See MESHBAY_NODE_PROTOCOL.md §11.1a, §13.1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkzbhmMmK8PqQBtGz5zCvY
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport/quic_server.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/quic_server.py | 24 |
1 files changed, 23 insertions, 1 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 8a32538..6dde3ff 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py @@ -36,17 +36,20 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from meshbay_common import MNP_VERSION from meshbay_node.roots import RootSet, entry_abs_path from meshbay_common.handshake import ( + MNP_MIN_SUPPORTED, NONCE_LEN, ROLE_CLIENT, ROLE_NODE, HandshakeError, authorize_token, + check_version, handshake_transcript, make_proof, quic_binding, verify_proof, ) from meshbay_common.crypto import pk_to_b64 +from meshbay_common.groupbox import PURPOSE_ACK, seal from meshbay_common.protocol import MNP, file_chunk_wire from meshbay_node.indexer import GroupIndex from meshbay_node.transport.wire import index_sync_message @@ -269,6 +272,16 @@ class _MNPServerProtocol(QuicConnectionProtocol): module, bound to the QUIC certificate hash. Finding C6 is closed on this transport. """ + # Same order as WebRTC: the range first, so a peer we cannot speak to is + # told so, rather than served messages it will misread (L2). + try: + check_version(msg.get("v", ""), msg.get("v_min", "")) + except HandshakeError as refusal: + self._send(stream_id, {"type": "error", "detail": str(refusal), + "code": refusal.code}) + self._quic.close() + return + try: peer = authorize_token( msg.get("token", ""), @@ -278,7 +291,8 @@ class _MNPServerProtocol(QuicConnectionProtocol): denylist=self._ctx.get("denylist"), ) except HandshakeError as refusal: - self._send(stream_id, {"type": "error", "detail": str(refusal)}) + self._send(stream_id, {"type": "error", "detail": str(refusal), + "code": refusal.code}) self._quic.close() return @@ -306,6 +320,7 @@ class _MNPServerProtocol(QuicConnectionProtocol): self._send(stream_id, { "type": MNP.HANDSHAKE_CHALLENGE, "v": MNP_VERSION, + "v_min": MNP_MIN_SUPPORTED, "nonce": base64.b64encode(self._gek_challenge).decode(), }) @@ -355,12 +370,19 @@ class _MNPServerProtocol(QuicConnectionProtocol): log.info("QUIC handshake OK — user=%s group=%s", self._user_id[:8], self._group_id[:8]) + # The config payload is empty here — QUIC serves no browser, so none of + # the fields WebRTC carries has a consumer on this transport. It is sealed + # anyway (decision D5): one shape per message on every transport, which is + # the lesson of the two `file_chunk` encoders and the two `index_sync` + # encodings. A field added later then has somewhere to go that is already + # authenticated, instead of arriving in clear beside a sealed one. self._send(stream_id, { "type": MNP.HANDSHAKE_ACK, "v": MNP_VERSION, "node_pk": pk_to_b64(self._ctx["sk_node"].public_key()), "proof": base64.b64encode(node_proof).decode(), "sig": base64.b64encode(self._ctx["sk_node"].sign(transcript)).decode(), + **seal(gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, peer.group_id, {}), }) self._gek_challenge = None |