summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py49
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/quic_client.py40
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/quic_server.py24
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py45
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/wire.py54
5 files changed, 162 insertions, 50 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 3a024f0..7341423 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -37,7 +37,7 @@ from pathlib import Path
import uvicorn
from meshbay_common import MNP_VERSION
-from meshbay_common.protocol import MNP, index_entry_wire
+from meshbay_common.protocol import MNP
from meshbay_node.audit import AuditStore
from meshbay_node.bundle_store import BundleStore
from meshbay_node.chat.store import ChatStore
@@ -58,6 +58,7 @@ from meshbay_node.transport import (
QUIC_AVAILABLE,
WEBRTC_AVAILABLE,
)
+from meshbay_node.transport.wire import index_delta_message, index_sync_message
if QUIC_AVAILABLE:
from meshbay_node.transport import QuicChunkServer
@@ -1003,6 +1004,13 @@ class NodeDaemon:
def _push_index_progress(self, group_id: str, progress) -> None:
if not self._webrtc:
return
+ # Deliberately NOT sealed, unlike index_sync/index_delta (decision D3).
+ # Counters only — never a path, never a filename, see IndexProgress in
+ # indexer.py — pushed every couple of seconds for the whole length of a
+ # scan. Sealing it would buy an attacker's rough estimate of a library's
+ # size and cost a key derivation and a decrypt per push. If a field that
+ # names anything is ever added here, that trade is void and this message
+ # joins the other two.
msg = {
"type": MNP.INDEX_PROGRESS,
"v": MNP_VERSION,
@@ -1135,36 +1143,25 @@ class NodeDaemon:
# 11.5 — Push to connected WebRTC peers in this group
if self._webrtc:
- if delta is not None:
- msg = {
- "type": MNP.INDEX_DELTA,
- "v": MNP_VERSION,
- "group_id": idx.group_id,
- "base_version": delta.base_version,
- "version": delta.version,
- "additions": [index_entry_wire(e) for e in delta.additions],
- "deletions": delta.deletions,
- "updates": [index_entry_wire(e) for e in delta.updates],
- }
- else:
- msg = {
- "type": MNP.INDEX_SYNC,
- "v": MNP_VERSION,
- "group_id": idx.group_id,
- "version": idx.version,
- "entries": [index_entry_wire(e) for e in idx.entries],
- }
- pushed = 0
- for session in list(self._webrtc._sessions.values()):
- if session._group_id == group_id:
+ peers = [s for s in list(self._webrtc._sessions.values())
+ if s._group_id == group_id]
+ # Both messages are sealed under a GEK-derived subkey, so building one
+ # needs a key. A group without one has no peers to push to either — the
+ # node refuses every handshake while the GEK is None (NS8) — so this is
+ # "nobody is listening", not a case to send in clear for.
+ if peers and idx.gek:
+ msg = (index_delta_message(idx, delta) if delta is not None
+ else index_sync_message(idx, indexer.roots))
+ pushed = 0
+ for session in peers:
try:
session._send(msg)
pushed += 1
except Exception:
pass
- if pushed:
- log.info("Index %s pushed to %d WebRTC peers",
- "delta" if delta is not None else "sync", pushed)
+ if pushed:
+ log.info("Index %s pushed to %d WebRTC peers",
+ "delta" if delta is not None else "sync", pushed)
# 11.9 — Register file hashes with hub swarm table (public groups only, H7)
group_cfg = next(
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:
"""
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
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 370a8b4..0154319 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -45,11 +45,13 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
from meshbay_common import MNP_VERSION
from meshbay_common.handshake import (
+ MNP_MIN_SUPPORTED,
NONCE_LEN,
ROLE_CLIENT,
ROLE_NODE,
HandshakeError,
authorize_token,
+ check_version,
handshake_transcript,
make_proof,
verify_proof,
@@ -87,6 +89,7 @@ from meshbay_common.device import (
device_code_hash,
device_request_transcript,
)
+from meshbay_common.groupbox import PURPOSE_ACK, seal
from meshbay_common.join import (
JOIN_TTL,
ROLE_MEMBER,
@@ -609,6 +612,15 @@ class WebRTCPeerSession:
group_id = msg.get("group_id", "")
log.info("WebRTC handshake request: group=%s (peer=%s)",
group_id[:8] if group_id else "none", self._peer_id)
+ # Before the token, and before anything is decided from it: a peer we
+ # cannot speak to is refused with a code it can act on, rather than
+ # served messages it will misread as missing fields (L2).
+ try:
+ check_version(msg.get("v", ""), msg.get("v_min", ""))
+ except HandshakeError as refusal:
+ self._send({"type": "error", "detail": str(refusal),
+ "code": refusal.code})
+ return
try:
peer = authorize_token(
msg.get("token", ""),
@@ -655,6 +667,9 @@ class WebRTCPeerSession:
self._send({
"type": MNP.HANDSHAKE_CHALLENGE,
"v": MNP_VERSION,
+ # Our half of the range. The client refuses us on this rather than
+ # discovering the mismatch when a field it expected is not there.
+ "v_min": MNP_MIN_SUPPORTED,
"nonce": base64.b64encode(self._gek_challenge).decode(),
# Announced here because a first-time joiner needs it *before* the
# ack: join_request signs a transcript naming this node, and someone
@@ -729,13 +744,15 @@ class WebRTCPeerSession:
gek, ROLE_NODE, self._group_id or "", self._nonce_client,
self._gek_challenge or b"", binding)
- ack = {
- "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(node_transcript)).decode(),
+ # Everything the client needs in order to *authenticate* us stays in clear —
+ # node_pk, proof and sig are what it checks before it would trust a
+ # decryption, so they cannot themselves be behind one. The configuration
+ # below is sealed under a GEK-derived subkey, which gives it an
+ # authentication tag from a key the hub does not hold. Until MNP 1.0 the
+ # signed transcript named no ack field at all, so is_node_admin,
+ # enabled_apps, video_root and the rest were authenticated by the DTLS
+ # channel and nothing else.
+ config = {
"is_node_admin": self._is_node_admin(),
# So the interface knows whether to offer uploading at all. Not a
# permission — the node refuses regardless — but without it the
@@ -790,10 +807,20 @@ class WebRTCPeerSession:
},
}
if node_user_id:
- ack["node_user_id"] = node_user_id
+ config["node_user_id"] = node_user_id
pk_x_b64 = self._ctx.get("pk_x25519_b64")
if pk_x_b64:
- ack["node_pk_x25519"] = pk_x_b64
+ config["node_pk_x25519"] = pk_x_b64
+
+ ack = {
+ "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(node_transcript)).decode(),
+ **seal(gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, self._group_id or "", config),
+ }
self._send(ack)
self._audit("handshake")
diff --git a/packages/meshbay-node/src/meshbay_node/transport/wire.py b/packages/meshbay-node/src/meshbay_node/transport/wire.py
index 4e09167..c683204 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/wire.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/wire.py
@@ -14,12 +14,22 @@ consumer each and nothing asserting they matched. Same failure mode as the two
`GroupIndex.serialize()`/`deserialize()` are unchanged and still tested — they remain
a correct signed index envelope — but they no longer describe any MNP message. Read
-them as an at-rest/interchange format, not as a wire contract.
+them as an at-rest/interchange format, not as a wire contract. It is also not a
+candidate for reuse below: it compresses with zstd, which no browser can decompress
+(`DecompressionStream` offers gzip and deflate only).
+
+Since MNP 1.0 both messages carry their payload **sealed under a GEK-derived subkey**
+(`meshbay_common.groupbox`). Only the routing fields — `type`, `v`, `group_id` — stay
+in clear: a receiver must route and version-check before it can decrypt, and
+`group_id` is the AAD and selects the key besides. `version`/`base_version` moved
+*inside* the payload; there is no reason to act on a version number carried by a
+message we have not yet authenticated.
"""
from __future__ import annotations
from meshbay_common import MNP_VERSION
+from meshbay_common.groupbox import PURPOSE_INDEX, seal
from meshbay_common.protocol import MNP, index_entry_wire
from meshbay_node.roots import RootSet
@@ -60,17 +70,43 @@ def index_sync_message(index, roots: RootSet | None) -> dict:
"""
The full `index_sync` message for one group.
- `dirs` and `roots` are here because directories are not index entries: without
- them a folder someone just created, or one they emptied, does not exist as far as
- a client is concerned, and a member cannot tell "the drive is unplugged" from "it
- is all still there".
+ `dirs` and `roots` are in the payload because directories are not index entries:
+ without them a folder someone just created, or one they emptied, does not exist as
+ far as a client is concerned, and a member cannot tell "the drive is unplugged"
+ from "it is all still there".
"""
- return {
- "type": MNP.INDEX_SYNC,
- "v": MNP_VERSION,
- "group_id": index.group_id,
+ payload = {
"version": index.version,
"entries": [index_entry_wire(e) for e in index.entries],
"dirs": list_dirs(roots),
"roots": roots.describe() if roots else [],
}
+ return {
+ "type": MNP.INDEX_SYNC,
+ "v": MNP_VERSION,
+ "group_id": index.group_id,
+ **seal(index.gek, PURPOSE_INDEX, MNP.INDEX_SYNC, index.group_id, payload),
+ }
+
+
+def index_delta_message(index, delta) -> dict:
+ """
+ One `index_delta` — what changed since the last thing this node broadcast.
+
+ Built here rather than inline in the daemon, which is where it lived and which
+ made it the third place an index message was constructed: precisely the drift
+ that produced two `index_sync` encodings and two `file_chunk` encodings before it.
+ """
+ payload = {
+ "base_version": delta.base_version,
+ "version": delta.version,
+ "additions": [index_entry_wire(e) for e in delta.additions],
+ "deletions": list(delta.deletions),
+ "updates": [index_entry_wire(e) for e in delta.updates],
+ }
+ return {
+ "type": MNP.INDEX_DELTA,
+ "v": MNP_VERSION,
+ "group_id": index.group_id,
+ **seal(index.gek, PURPOSE_INDEX, MNP.INDEX_DELTA, index.group_id, payload),
+ }