diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport/quic_client.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/quic_client.py | 40 |
1 files changed, 35 insertions, 5 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_client.py b/packages/meshbay-node/src/meshbay_node/transport/quic_client.py index 4debd27..b22b8df 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/quic_client.py +++ b/packages/meshbay-node/src/meshbay_node/transport/quic_client.py @@ -23,11 +23,14 @@ from aioquic.quic.events import QuicEvent, StreamDataReceived from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from meshbay_common import MNP_VERSION +from meshbay_common.groupbox import PURPOSE_ACK, PURPOSE_INDEX, unseal from meshbay_common.protocol import MNP, file_chunk_plaintext from meshbay_common.handshake import ( + MNP_MIN_SUPPORTED, NONCE_LEN, ROLE_CLIENT, ROLE_NODE, + check_version, handshake_transcript, make_proof, quic_binding, @@ -141,6 +144,8 @@ class QuicChunkClient: # TLS session the server does not re-send its certificate. self._peer_cert_der: bytes | None = peer_cert_der self._session_ticket = session_ticket + # The handshake_ack's sealed payload, once connect() has opened it. + self._node_config: dict = {} async def __aenter__(self): await self.connect() @@ -178,6 +183,10 @@ class QuicChunkClient: self._proto._send(self._ctrl_stream, { "type": MNP.HANDSHAKE, "v": MNP_VERSION, + # The oldest node this build can talk to. Declared in the first + # message so a mismatch is a refusal with a code, not a field that + # turns up missing three messages later (L2). + "v_min": MNP_MIN_SUPPORTED, "token": self._jwt_token, "group_id": self._group_id, "nonce": base64.b64encode(nonce_c).decode(), @@ -186,6 +195,8 @@ class QuicChunkClient: reply = await self._proto._recv(self._ctrl_stream) if reply.get("type") != MNP.HANDSHAKE_CHALLENGE: raise ConnectionError(f"QUIC handshake rejected: {reply}") + # The node's half of the range, checked before we speak to it further. + check_version(reply.get("v", ""), reply.get("v_min", "")) nonce_s = base64.b64decode(reply["nonce"]) @@ -231,6 +242,15 @@ class QuicChunkClient: except Exception as exc: raise ConnectionError(f"Node signature invalid: {exc}") from exc + # Verify, then decrypt — in that order, and it is not incidental. The + # proof and the signature above are what decide whether this peer is worth + # trusting at all; opening the payload first would mean acting on data from + # someone we have not authenticated. Empty on this transport today (D5), + # but it must still open: a payload that does not is a peer we cannot talk + # to, not a node with no configuration. + self._node_config = unseal( + self._gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, self._group_id, ack) + log.debug("QUIC connected to %s:%d", self._host, self._port) @property @@ -253,14 +273,24 @@ class QuicChunkClient: """ Request the Mesh Group Index. - Returns the message itself — `{group_id, version, entries, dirs, roots}` — - which is what the WebRTC client has always received. It used to return the - bytes of a `GroupIndex.serialize()` envelope for the caller to deserialize: - the same message type carrying a different encoding on this transport alone. + Returns `{group_id, version, entries, dirs, roots}` — the sealed payload + opened, with `group_id` from the envelope that carried it. It used to return + the bytes of a `GroupIndex.serialize()` envelope for the caller to + deserialize: the same message type carrying a different encoding on this + transport alone. + + A payload that does not open raises. It is never an empty index — that is + indistinguishable from a group with no files, which is why a fallback here + would be worse than a stop (groupbox.py, §3.4). """ sid = self._new_stream() self._proto._send(sid, {"type": MNP.INDEX_SYNC, "v": MNP_VERSION}) - return await self._proto._recv(sid) + msg = await self._proto._recv(sid) + if msg.get("type") == "error": + raise LookupError(msg.get("detail", "index_sync refused")) + payload = unseal( + self._gek, PURPOSE_INDEX, MNP.INDEX_SYNC, self._group_id, msg) + return {"group_id": msg.get("group_id", self._group_id), **payload} async def fetch_chunk(self, file_id: str, chunk_index: int) -> bytes: """ |