aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-03 16:16:55 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-03 16:16:55 +0200
commit675beed6ff688733a9598f9d82d41578f48316be (patch)
tree78dd4f8dff312f0ad99bd63bc679bf402591c5ed /packages/meshbay-node
parent15087b0e8fdb872602310119f14680aaa443fd93 (diff)
downloadmeshbay-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')
-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
-rw-r--r--packages/meshbay-node/tests/test_daemon.py18
-rw-r--r--packages/meshbay-node/tests/test_index_no_cleartext.py159
-rw-r--r--packages/meshbay-node/tests/test_transport_wire_parity.py63
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py71
9 files changed, 462 insertions, 61 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),
+ }
diff --git a/packages/meshbay-node/tests/test_daemon.py b/packages/meshbay-node/tests/test_daemon.py
index 71aae78..8bd169d 100644
--- a/packages/meshbay-node/tests/test_daemon.py
+++ b/packages/meshbay-node/tests/test_daemon.py
@@ -17,6 +17,7 @@ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from unittest.mock import AsyncMock, MagicMock, patch
from meshbay_common.crypto import generate_gek
+from meshbay_common.groupbox import PURPOSE_INDEX, unseal
from meshbay_node.config import Config, HubConfig, NodeConfig, GroupConfig, KeystoreConfig
from conftest import one_root
from meshbay_node.daemon import NodeDaemon
@@ -263,7 +264,8 @@ async def test_daemon_index_change_pushes_to_peers(tmp_path, shared_dir, gek, hu
msg = mock_session._send.call_args[0][0]
assert msg["type"] == "index_sync"
assert msg["group_id"] == "a" * 32
- assert len(msg["entries"]) == indexer.index.count
+ payload = unseal(gek, PURPOSE_INDEX, "index_sync", "a" * 32, msg)
+ assert len(payload["entries"]) == indexer.index.count
# Finding H7: this group is private, so its content hashes must NOT be
# registered with the hub. The test previously asserted the opposite —
@@ -391,7 +393,9 @@ async def test_first_broadcast_is_full_sync_second_is_delta(tmp_path, shared_dir
await asyncio.sleep(0.05)
first = session._send.call_args_list[0].args[0]
assert first["type"] == "index_sync"
- assert len(first["entries"]) == indexer.index.count
+ # Sealed since MNP 1.0 — the entries are inside, not on the envelope.
+ first_payload = unseal(gek, PURPOSE_INDEX, "index_sync", "a" * 32, first)
+ assert len(first_payload["entries"]) == indexer.index.count
# Nothing actually changed in the index between the two calls, but
# _on_index_change does not know or care why it was called — the
@@ -401,8 +405,9 @@ async def test_first_broadcast_is_full_sync_second_is_delta(tmp_path, shared_dir
await asyncio.sleep(0.05)
second = session._send.call_args_list[1].args[0]
assert second["type"] == "index_delta"
- assert second["additions"] == []
- assert second["deletions"] == []
+ second_payload = unseal(gek, PURPOSE_INDEX, "index_delta", "a" * 32, second)
+ assert second_payload["additions"] == []
+ assert second_payload["deletions"] == []
@pytest.mark.asyncio
@@ -435,8 +440,9 @@ async def test_delta_reflects_additions_and_deletions(tmp_path, shared_dir, gek)
delta_msg = session._send.call_args_list[1].args[0]
assert delta_msg["type"] == "index_delta"
- assert delta_msg["deletions"] == [removed_id]
- assert [a["id"] for a in delta_msg["additions"]] == ["new-file-id"]
+ payload = unseal(gek, PURPOSE_INDEX, "index_delta", "a" * 32, delta_msg)
+ assert payload["deletions"] == [removed_id]
+ assert [a["id"] for a in payload["additions"]] == ["new-file-id"]
@pytest.mark.asyncio
diff --git a/packages/meshbay-node/tests/test_index_no_cleartext.py b/packages/meshbay-node/tests/test_index_no_cleartext.py
new file mode 100644
index 0000000..f510884
--- /dev/null
+++ b/packages/meshbay-node/tests/test_index_no_cleartext.py
@@ -0,0 +1,159 @@
+"""
+The test that asserts the property, rather than the mechanism.
+
+Worth more than checking that a `ct` field is present: this fails for any future
+change that puts a name back in the clear, including one nobody thought of as an
+index message. Findings C1 (the node HTTP API served the index and plaintext files
+on 0.0.0.0 with no authentication) and C6 (the TCP transport accepted a bare JWT
+with no GEK proof) were both "a peer that had not completed the handshake was served
+data"; sealed, that bug leaks ciphertext instead of a library's filenames.
+
+The distinctive strings below are invented and could not occur by chance in msgpack
+framing or in a field name.
+"""
+
+import msgpack
+import pytest
+from conftest import one_root
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from meshbay_common.crypto import generate_gek
+from meshbay_common.groupbox import PURPOSE_ACK, PURPOSE_INDEX, seal, unseal
+from meshbay_common.protocol import MNP
+from meshbay_node.indexer import DirectoryIndexer, GroupIndex
+from meshbay_node.transport.wire import index_delta_message, index_sync_message
+
+# A filename and a folder name that appear nowhere else in the tree.
+SECRET_FILE = "quixotry-ledger-2019.pdf"
+SECRET_DIR = "zarfwidget-archive"
+SECRET_ROOT = "/srv/vasculum-private/library"
+
+
+@pytest.fixture
+def gek():
+ return generate_gek()
+
+
+@pytest.fixture
+async def indexer(tmp_path, gek):
+ shared = tmp_path / "shared"
+ (shared / SECRET_DIR).mkdir(parents=True)
+ (shared / SECRET_DIR / SECRET_FILE).write_bytes(b"x" * 64)
+ roots = one_root(shared, name="library")
+ idx = DirectoryIndexer(
+ roots=roots, group_id="g-1", sk_node=Ed25519PrivateKey.generate(), gek=gek)
+ await idx.initial_scan()
+ return idx
+
+
+def _assert_absent(frame: bytes, *words: str) -> None:
+ for word in words:
+ assert word.encode() not in frame, f"{word!r} travels in the clear"
+
+
+@pytest.mark.asyncio
+async def test_index_sync_frame_carries_no_filename(indexer):
+ frame = msgpack.packb(index_sync_message(indexer.index, indexer.roots),
+ use_bin_type=True)
+ # Not only the whole name — a fragment of it would be just as much of a leak.
+ _assert_absent(frame, SECRET_FILE, "quixotry", SECRET_DIR, "zarfwidget",
+ "entries", "dirs")
+ # And the routing fields are still readable, or nothing could be dispatched.
+ msg = msgpack.unpackb(frame, raw=False)
+ assert msg["type"] == MNP.INDEX_SYNC
+ assert msg["group_id"] == "g-1"
+ assert set(msg) == {"type", "v", "group_id", "nonce", "ct"}
+
+
+@pytest.mark.asyncio
+async def test_index_sync_payload_still_says_everything(indexer, gek):
+ """Sealed, not lost: every field a client reads is inside."""
+ msg = index_sync_message(indexer.index, indexer.roots)
+ payload = unseal(gek, PURPOSE_INDEX, MNP.INDEX_SYNC, "g-1", msg)
+ assert [e["name"] for e in payload["entries"]] == [SECRET_FILE]
+ assert any(d.endswith(SECRET_DIR) for d in payload["dirs"])
+ assert payload["roots"]
+ # Moved inside deliberately (D4): there is no reason to act on a version
+ # carried by a message we have not authenticated.
+ assert payload["version"] == indexer.index.version
+ assert "version" not in msg
+
+
+@pytest.mark.asyncio
+async def test_index_delta_frame_carries_no_filename(indexer, gek):
+ """
+ The delta is where a cleartext path would most easily survive: it used to be
+ hand-built in the daemon, a third construction site for an index message.
+ """
+ before = GroupIndex._snapshot(
+ "g-1", indexer.index.sk_node, gek, indexer.index.version - 1, {})
+ delta = indexer.index.diff(before)
+ assert delta.additions
+
+ frame = msgpack.packb(index_delta_message(indexer.index, delta),
+ use_bin_type=True)
+ _assert_absent(frame, SECRET_FILE, "quixotry", "additions", "deletions")
+
+ payload = unseal(gek, PURPOSE_INDEX, MNP.INDEX_DELTA, "g-1",
+ msgpack.unpackb(frame, raw=False))
+ assert [e["name"] for e in payload["additions"]] == [SECRET_FILE]
+ assert payload["base_version"] == delta.base_version
+
+
+def test_handshake_ack_frame_carries_no_configuration(gek):
+ """
+ The ack is the line that matters most, and it is an integrity gap as much as a
+ confidentiality one: the node signs `handshake_transcript(...)`, which names no
+ ack field, so every value below was authenticated by the DTLS channel alone.
+ """
+ config = {
+ "is_node_admin": True,
+ "video_root": SECRET_ROOT,
+ "enabled_apps": ["files", "videos"],
+ }
+ ack = {
+ "type": MNP.HANDSHAKE_ACK,
+ "v": "1.0",
+ "node_pk": "Tk9ERVBL",
+ "proof": "cHJvb2Y=",
+ "sig": "c2ln",
+ **seal(gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, "g-1", config),
+ }
+ frame = msgpack.packb(ack, use_bin_type=True)
+ _assert_absent(frame, SECRET_ROOT, "vasculum", "video_root",
+ "enabled_apps", "is_node_admin")
+
+ # What a client needs in order to authenticate the node is still in clear —
+ # it verifies those *before* it would trust a decryption.
+ msg = msgpack.unpackb(frame, raw=False)
+ assert msg["node_pk"] and msg["proof"] and msg["sig"]
+ assert unseal(gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, "g-1", msg) == config
+
+
+def test_index_progress_stays_clear_and_stays_counters():
+ """
+ Decision D3: `index_progress` is deliberately *not* sealed — counters only,
+ pushed every couple of seconds for the whole length of a scan, so sealing it
+ would buy a rough library size and cost a decrypt per push.
+
+ The field list is re-derived from the daemon's own source rather than restated
+ here, so this fails the day the message grows something that names anything —
+ `IndexProgress` already carries a `current_dir` the push deliberately omits,
+ and adding it would be one line. That is the moment the trade above is void.
+ """
+ import ast
+ import inspect
+ import textwrap
+
+ from meshbay_node.daemon import NodeDaemon
+
+ source = inspect.getsource(NodeDaemon._push_index_progress)
+ tree = ast.parse(textwrap.dedent(source))
+ dicts = [n for n in ast.walk(tree) if isinstance(n, ast.Dict)]
+ assert len(dicts) == 1, "more than one message built here — re-read this test"
+ keys = {k.value for k in dicts[0].keys}
+ assert keys == {"type", "v", "group_id",
+ "scanning", "scanned_bytes", "total_bytes"}, (
+ f"index_progress now carries {keys} — re-read decision D3 before shipping it")
+
+ assert "seal(" not in source
+ assert "D3" in source, "the reason it is not sealed must stay next to the code"
diff --git a/packages/meshbay-node/tests/test_transport_wire_parity.py b/packages/meshbay-node/tests/test_transport_wire_parity.py
index 5e04525..bc6b134 100644
--- a/packages/meshbay-node/tests/test_transport_wire_parity.py
+++ b/packages/meshbay-node/tests/test_transport_wire_parity.py
@@ -19,6 +19,7 @@ import pytest
from conftest import one_root
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from meshbay_common.crypto import generate_gek
+from meshbay_common.groupbox import PURPOSE_INDEX, unseal
from meshbay_common.protocol import MNP, file_chunk_plaintext, file_chunk_wire
from meshbay_node.indexer import DirectoryIndexer
from meshbay_node.transport import quic_server, webrtc_server
@@ -60,6 +61,37 @@ def test_both_transports_use_the_one_index_builder():
"QUIC is serializing the index again — that was the fork")
+def test_neither_transport_seals_by_hand():
+ """
+ `groupbox` is the only sealer, the same rule `file_chunk_wire` already has.
+ A server reaching for AESGCM or HKDF directly is a second envelope waiting to
+ disagree with the first about a nonce length, an info string or an AAD.
+ """
+ for module in (webrtc_server, quic_server):
+ source = inspect.getsource(module)
+ assert "seal(" in source, f"{module.__name__} sends an unsealed ack"
+ assert "AESGCM(" not in source, (
+ f"{module.__name__} builds its own AEAD instead of using groupbox")
+ assert "HKDF(" not in source, (
+ f"{module.__name__} derives its own subkey instead of using groupbox")
+
+
+def test_the_daemon_does_not_build_an_index_message_itself():
+ """
+ The delta was hand-built in `_broadcast_index_change` — the third construction
+ site for an index message, and the one that would have kept sending cleartext
+ while the other two were sealed.
+ """
+ from meshbay_node import daemon
+
+ source = inspect.getsource(daemon)
+ assert "index_delta_message" in source and "index_sync_message" in source
+ assert '"type": MNP.INDEX_DELTA' not in source, (
+ "the daemon builds index_delta by hand again")
+ assert '"type": MNP.INDEX_SYNC' not in source, (
+ "the daemon builds index_sync by hand again")
+
+
def test_chunk_wire_shape_is_identical_across_transports(gek, shared_dir):
"""The two servers' read-and-encrypt helpers agree on every field but the nonce."""
path = shared_dir / "film.mkv"
@@ -116,10 +148,35 @@ async def test_index_sync_shape(gek, shared_dir):
msg = index_sync_message(indexer.index, roots)
+ # In clear: what a receiver needs to route and version-check before it can
+ # decrypt, and nothing else.
assert msg["type"] == MNP.INDEX_SYNC
assert msg["group_id"] == "g"
- assert [e["name"] for e in msg["entries"]] == ["film.mkv"]
+ assert set(msg) == {"type", "v", "group_id", "nonce", "ct"}
+
+ payload = unseal(gek, PURPOSE_INDEX, MNP.INDEX_SYNC, "g", msg)
+ assert [e["name"] for e in payload["entries"]] == ["film.mkv"]
# Directories are not index entries, so they travel separately — including the
# empty one, which no entry's path would have revealed.
- assert any(d.endswith("sub") for d in msg["dirs"])
- assert msg["roots"]
+ assert any(d.endswith("sub") for d in payload["dirs"])
+ assert payload["roots"]
+
+
+@pytest.mark.asyncio
+async def test_a_wrong_key_raises_rather_than_reporting_an_empty_group(gek, shared_dir):
+ """
+ §3.4, at the level a client would hit it. An index that fails to open must not
+ become an empty index: "the group has no files" is a legitimate state, so a
+ fallback there is indistinguishable from the truth — which is exactly what
+ makes it worse than a stop.
+ """
+ sk_node = Ed25519PrivateKey.generate()
+ roots = one_root(shared_dir)
+ indexer = DirectoryIndexer(roots=roots, group_id="g", sk_node=sk_node, gek=gek)
+ await indexer.initial_scan()
+
+ msg = index_sync_message(indexer.index, roots)
+ with pytest.raises(Exception) as caught:
+ unseal(generate_gek(), PURPOSE_INDEX, MNP.INDEX_SYNC, "g", msg)
+ # Assert on the refusal, not on a degraded result.
+ assert not isinstance(caught.value, dict)
diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py
index a4c7f61..dc74752 100644
--- a/packages/meshbay-node/tests/test_webrtc_transport.py
+++ b/packages/meshbay-node/tests/test_webrtc_transport.py
@@ -33,6 +33,7 @@ from meshbay_common.crypto import (
unwrap_gek,
unwrap_gek_aes,
)
+from meshbay_common.groupbox import PURPOSE_ACK, PURPOSE_INDEX, unseal
from meshbay_common.webcrypto import chunk_key_aes, decrypt_chunk_aes
from meshbay_common.protocol import MNP
TEST_GROUP = "g"
@@ -358,13 +359,20 @@ async def test_webrtc_datachannel_file_transfer(sk_node, sk_hub, gek, shared_dir
ack = await _handshake_with_gek_proof(channel, received, sk_hub, gek,
browser_pc=browser_pc)
assert ack["type"] == MNP.HANDSHAKE_ACK
+ # 1b) The ack's configuration is sealed under the group key (MNP 1.0), and the
+ # signed handshake transcript names no ack field — so this envelope is the only
+ # thing authenticating `is_node_admin` and the rest.
+ config = unseal(gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, TEST_GROUP, ack)
+ assert "is_node_admin" in config
+ assert "is_node_admin" not in ack
# 2) Request index
channel.send(_pack({"type": MNP.INDEX_SYNC, "v": MNP_VERSION}))
idx_msg = await asyncio.wait_for(received.get(), timeout=5.0)
assert idx_msg["type"] == MNP.INDEX_SYNC
- assert "entries" in idx_msg
- assert len(idx_msg["entries"]) > 0
+ assert "entries" not in idx_msg, "the index travels in the clear"
+ payload = unseal(gek, PURPOSE_INDEX, MNP.INDEX_SYNC, TEST_GROUP, idx_msg)
+ assert len(payload["entries"]) > 0
# 3) Request file chunk
entry = next(e for e in indexer.index.entries if e.name == "test.bin")
@@ -442,6 +450,65 @@ async def test_webrtc_invalid_jwt_rejected(sk_node, sk_hub, gek, shared_dir):
@pytest.mark.asyncio
+async def test_webrtc_old_client_is_refused_with_a_code(sk_node, sk_hub, gek, shared_dir):
+ """
+ A version mismatch must present as a refusal, not as a missing field.
+
+ An 0.x client reaching a 1.0 node would otherwise get a `handshake_ack` with
+ no `enabled_apps` and apply its documented fallback — show every app — and an
+ `index_sync` with no `entries` it would read as an empty group. Both are
+ confident wrong answers. The check runs *before* the token, so it costs
+ nothing and reports the real reason (L2).
+ """
+ hub_pk_pem = _hub_pk_pem(sk_hub)
+ indexer = DirectoryIndexer(roots=one_root(shared_dir), group_id=TEST_GROUP,
+ sk_node=sk_node, gek=gek)
+ await indexer.initial_scan()
+
+ transport = WebRTCTransport(
+ sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek,
+ roots=one_root(shared_dir), index=indexer.index, stun_servers=[],
+ )
+
+ browser_pc = RTCPeerConnection()
+ received = asyncio.Queue()
+ channel = browser_pc.createDataChannel("mnp")
+
+ @channel.on("message")
+ def on_msg(message):
+ if isinstance(message, str):
+ message = message.encode()
+ received.put_nowait(_unpack(message))
+
+ offer = await browser_pc.createOffer()
+ await browser_pc.setLocalDescription(offer)
+ answer_sdp, _ = await transport.handle_offer(
+ browser_pc.localDescription.sdp, "peer-old")
+ await browser_pc.setRemoteDescription(
+ RTCSessionDescription(sdp=answer_sdp, type="answer"))
+ await asyncio.sleep(0.5)
+
+ # A perfectly valid token — the refusal must not depend on it, and must not
+ # be reported as an authorization problem either.
+ channel.send(_pack({
+ "type": MNP.HANDSHAKE,
+ "v": "0.15",
+ "token": _make_jwt(sk_hub, groups=[TEST_GROUP]),
+ "group_id": TEST_GROUP,
+ "nonce": base64.b64encode(os.urandom(32)).decode(),
+ }))
+
+ msg = await asyncio.wait_for(received.get(), timeout=5.0)
+ assert msg["type"] == "error"
+ # The client matches on the code; the text may be reworded.
+ assert msg["code"] == "version_too_old"
+ assert "0.15" in msg["detail"]
+
+ await browser_pc.close()
+ await transport.close_all()
+
+
+@pytest.mark.asyncio
async def test_webrtc_request_before_handshake_rejected(sk_node, sk_hub, gek, shared_dir):
"""WebRTC DataChannel: request without handshake is rejected."""
hub_pk_pem = _hub_pk_pem(sk_hub)