summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/transport/wire.py
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/src/meshbay_node/transport/wire.py
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/src/meshbay_node/transport/wire.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/wire.py54
1 files changed, 45 insertions, 9 deletions
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),
+ }