aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
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-hub
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-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/crypto.js75
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js190
-rw-r--r--packages/meshbay-hub/tests/harness/index_seal_probe.mjs98
-rw-r--r--packages/meshbay-hub/tests/test_index_seal_client.py143
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py75
6 files changed, 571 insertions, 20 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
index 69b8c7a..d2e19b7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
@@ -109,6 +109,80 @@ async function decryptChunkBin(gek, fileHashHex, chunkIndex, nonce, ct) {
return new Uint8Array(plaintext);
}
+// ── Sealing a payload under the group key ────────────────────────────────────
+//
+// Mirrors meshbay_common/groupbox.py. `index_sync`, `index_delta` and the
+// `handshake_ack` config payload travel sealed under a GEK-derived subkey; the
+// routing fields (type, v, group_id) and the ack's own authentication (node_pk,
+// proof, sig) stay in clear, because a receiver must route, version-check and
+// *authenticate* before it would trust a decryption.
+//
+// These take and return BYTES, not objects, and that is not an oversight:
+// msgpack here is a minimal hand-written codec private to transport.js, exported
+// to nothing (both files are classic scripts on globals, not ES modules). Making
+// this layer take objects would mean duplicating that codec or reaching across a
+// boundary that does not exist — both worse than one extra line at the call site.
+
+const GROUPBOX_INFO = {
+ index: new TextEncoder().encode('meshbay:index:v1'),
+ ack: new TextEncoder().encode('meshbay:ack:v1'),
+};
+
+/**
+ * Derive the AES-256-GCM subkey for one purpose.
+ * `salt: new Uint8Array(0)` matches Python's `salt=None` — RFC 5869 extracts with
+ * a zero key either way, which is what deriveChunkKey above already relies on.
+ */
+async function groupKey(gek, purpose, usages) {
+ const info = GROUPBOX_INFO[purpose];
+ if (!info) throw new Error(`unknown groupbox purpose: ${purpose}`);
+ const gekKey = gek instanceof CryptoKey
+ ? gek
+ : await crypto.subtle.importKey('raw', gek, 'HKDF', false, ['deriveKey']);
+ return crypto.subtle.deriveKey(
+ { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info },
+ gekKey,
+ { name: 'AES-GCM', length: 256 },
+ false,
+ usages,
+ );
+}
+
+/** What the ciphertext is bound to: this message type, in this group. */
+function groupAad(msgType, groupId) {
+ return new TextEncoder().encode(`${msgType}|${groupId}`);
+}
+
+/**
+ * Open a sealed payload. Throws on anything that does not open — a caller must
+ * never turn that into an empty index or an empty app list (groupbox.py's
+ * `unseal` says why at length).
+ * @returns {Promise<Uint8Array>} the msgpack bytes of the payload
+ */
+async function openGroup(gek, purpose, msgType, groupId, msg) {
+ if (!msg || !msg.nonce || !msg.ct) {
+ throw new Error(`${msgType}: not a sealed message`);
+ }
+ const key = await groupKey(gek, purpose, ['decrypt']);
+ const plain = await crypto.subtle.decrypt(
+ { name: 'AES-GCM', iv: msg.nonce, additionalData: groupAad(msgType, groupId) },
+ key, msg.ct);
+ return new Uint8Array(plain);
+}
+
+/**
+ * Seal payload bytes. Returns the `{nonce, ct}` pair to merge into a message.
+ */
+async function sealGroup(gek, purpose, msgType, groupId, plaintextBytes) {
+ const key = await groupKey(gek, purpose, ['encrypt']);
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
+ const ct = await crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv: nonce, additionalData: groupAad(msgType, groupId) },
+ key, plaintextBytes);
+ return { nonce, ct: new Uint8Array(ct) };
+}
+
+
// ── GEK generation + ECIES wrapping ──────────────────────────────────────────
function generateGEK() {
@@ -372,6 +446,7 @@ async function verifyNodeSignature(nodePkB64, sigB64, transcript) {
// Export for use in app.js
window.MeshBayCrypto = {
importGEK, deriveChunkKey, decryptChunkBin,
+ openGroup, sealGroup,
generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode,
adminTranscript, handshakeTranscript, handshakeProof, webrtcBinding,
joinTranscript, verifyNodeSignature, constantTimeEqual,
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 7d13260..466af53 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -392,6 +392,16 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
if (cancelled) return;
applyIndexDelta(msg);
};
+ // A pushed message that will not open under the group key ends the
+ // session (transport.js _failSession). Nothing is waiting on a push, so
+ // without this the page would keep showing a stale index with nothing
+ // wrong on screen — the worst of the three failure shapes.
+ transport.onSessionFailed = (err) => {
+ if (cancelled) return;
+ setError(err.message);
+ setStatus('error');
+ if (onPresence) onPresence(groupId, 'online');
+ };
// We are in: an invitation to this group has served its purpose.
if (onJoined) onJoined(groupId);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 62ca2d9..329baf1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -178,6 +178,30 @@ window.addEventListener('hashchange', () => {
if (location.hash === '#mb-debug') _showTraceView();
});
+// This build's half of the version range (meshbay_common/handshake.py's
+// MNP_VERSION and MNP_MIN_SUPPORTED). Declared on the handshake — the only
+// message where it is read — so a node we cannot speak to refuses us with a
+// code, instead of the mismatch surfacing as a field that is not there.
+//
+// The `v: '0.1'` on every other message in this file is the historical value
+// and is read by nothing; it is left alone deliberately. The range is
+// negotiated once, at the start, not restated per message.
+const MNP_V = '1.0';
+const MNP_V_MIN = '1.0';
+
+// Codes a NODE sends us, in its own vocabulary (meshbay_common/handshake.py's
+// check_version): `version_too_old` means *we* are too old for it,
+// `version_too_new` that it is too old for what we require. The client's own
+// check of the node names its two conditions separately — see
+// _checkNodeVersion, where reusing this table's wording would read backwards.
+const HANDSHAKE_REFUSALS = {
+ version_too_old: 'This page is older than the node it is talking to. '
+ + 'Reload to pick up the current version.',
+ version_too_new: 'This node is running an older MeshBay than this page needs. '
+ + 'Its operator has to update it.',
+ version_unreadable: 'The node could not read this page\'s protocol version.',
+};
+
const JOIN_REFUSALS = {
code_required: 'This node does not know this browser yet. Ask the node operator '
+ 'for a pairing code (meshbay-node operator pair).',
@@ -278,6 +302,9 @@ class MeshBayTransport {
set onPhotoRoots(fn) { this._onPhotoRoots = fn; }
set onMusicbrainzEnabled(fn) { this._onMusicbrainzEnabled = fn; }
set onIndexProgress(fn) { this._onIndexProgress = fn; }
+ // Fired when a message that must open under the group key does not —
+ // see _failSession. The session is over by the time this runs.
+ set onSessionFailed(fn) { this._onSessionFailed = fn; }
// Fired once an automatic reconnect (see _reconnectLoop) lands a fresh
// handshake, so a consumer with something mid-flight on the old channel —
// today only the video player — can pick back up rather than sit dead.
@@ -523,7 +550,8 @@ class MeshBayTransport {
const reply = await this._sendAndWait({
type: 'handshake',
- v: '0.1',
+ v: MNP_V,
+ v_min: MNP_V_MIN,
token: jwtToken,
group_id: groupId || '',
nonce: window.MeshBayCrypto.b64encode(this._nonceClient),
@@ -531,6 +559,10 @@ class MeshBayTransport {
console.log('[MeshBay] Handshake reply:', reply.type);
if (reply.type === 'handshake_challenge') {
+ // The node's half of the range. Checked before anything else in this
+ // block, because everything below — the join, the proof, the sealed ack
+ // — assumes both sides mean the same thing by each message.
+ _checkNodeVersion(reply);
if (!window.MeshBayCrypto) {
throw new Error('Node requires GEK proof but no crypto available');
}
@@ -708,6 +740,30 @@ class MeshBayTransport {
_checkNodePin(nodeId, ack.node_pk);
this.nodePk = ack.node_pk;
+ // Verify, then decrypt — in that order, and the order is the point. Every
+ // check above decides whether this peer is worth trusting at all; opening
+ // the payload first would mean acting on data from a peer we have not yet
+ // authenticated.
+ //
+ // A payload that does not open aborts the connection. It is emphatically
+ // not an empty config: `enabled_apps` missing reads as "the operator
+ // disabled every app" (the documented client-side fallback is the
+ // opposite — show them all), and either reading is indistinguishable from
+ // a legitimate state, which is what makes a silent fallback worse than a
+ // stop.
+ let config;
+ try {
+ config = msgpack_decode(
+ await C.openGroup(gekRaw, 'ack', 'handshake_ack', gid, ack));
+ } catch (e) {
+ throw new Error(
+ 'handshake_ack did not open under the group key — refusing connection: '
+ + (e && e.message || e));
+ }
+ delete ack.nonce;
+ delete ack.ct;
+ Object.assign(ack, config);
+
return ack;
}
@@ -716,7 +772,8 @@ class MeshBayTransport {
// peer skip proving GEK possession entirely (C3/C6).
console.warn('[MeshBay] Handshake rejected:', reply.detail, 'code:', reply.code);
const rejected = new Error(
- 'MNP handshake rejected: ' + (reply.detail || `unexpected ${reply.type}`));
+ HANDSHAKE_REFUSALS[reply.code]
+ || ('MNP handshake rejected: ' + (reply.detail || `unexpected ${reply.type}`)));
rejected.reason = reply.code || '';
throw rejected;
}
@@ -858,6 +915,12 @@ class MeshBayTransport {
return resp;
}
+ /**
+ * The full index. Resolves with the sealed payload already opened —
+ * `_applyIndexMessage` does that before it hands the message to whoever is
+ * waiting, so both the reply to this call and the node's own unsolicited
+ * pushes go through one decrypt path.
+ */
async fetchIndex() {
const msg = await this._sendAndWait({ type: 'index_sync', v: '0.1' });
if (msg.type === 'error') throw new Error(msg.detail);
@@ -1876,6 +1939,61 @@ class MeshBayTransport {
// ── Internal ──────────────────────────────────────────────────────────────
+ /**
+ * Queue one sealed index message for opening.
+ *
+ * Opening is asynchronous and `_dispatch` is not, so two messages handled
+ * independently would be applied in whichever order their decrypt promises
+ * happened to settle. A delta applied before the sync it is based on — or
+ * before an earlier delta — is a silently wrong view of the group, so they
+ * are opened one at a time, in arrival order.
+ */
+ _queueIndexMessage(msg) {
+ this._indexChain = (this._indexChain || Promise.resolve())
+ .then(() => this._applyIndexMessage(msg))
+ .catch((e) => this._failSession(
+ `${msg.type} did not open under the group key`, e));
+ }
+
+ async _applyIndexMessage(msg) {
+ const groupId = msg.group_id || (this._connectArgs && this._connectArgs.groupId) || '';
+ const payload = msgpack_decode(await window.MeshBayCrypto.openGroup(
+ this._gekRaw, 'index', msg.type, groupId, msg));
+ // The routing fields stay, the envelope's own two go, the payload lands on
+ // top — so every consumer keeps reading the flat message it always read.
+ const opened = { ...msg, ...payload };
+ delete opened.nonce;
+ delete opened.ct;
+
+ if (msg.type === 'index_sync') {
+ if (this._onIndexSync) this._onIndexSync(opened);
+ for (const [, handler] of this._pending) {
+ if (handler._reqType === 'index_sync') {
+ handler.resolve(opened);
+ break;
+ }
+ }
+ return;
+ }
+ if (this._onIndexDelta) this._onIndexDelta(opened);
+ }
+
+ /**
+ * Stop, rather than carry on with a degraded view.
+ *
+ * A payload that does not open is not an empty index and not a config
+ * change — it is a peer we cannot talk to. Reconnecting would only reach the
+ * same peer with the same key, so the session ends and the failure is named.
+ */
+ _failSession(what, cause) {
+ const err = new Error(`${what}: ${(cause && cause.message) || cause}`);
+ console.error('[MeshBay]', err.message);
+ for (const [, handler] of this._pending) handler.reject(err);
+ this._pending.clear();
+ if (this._onSessionFailed) this._onSessionFailed(err);
+ this.close();
+ }
+
async _sendAndWait(obj, timeoutMs = 30000) {
// A reconnect already in flight (see _reconnectLoop) means the channel
// this would send on is the one just declared dead. `_inReconnectAttempt`
@@ -2166,24 +2284,16 @@ class MeshBayTransport {
return;
}
- if (msg.type === 'index_sync' && msg.entries) {
- if (this._onIndexSync) this._onIndexSync(msg);
- for (const [, handler] of this._pending) {
- if (handler._reqType === 'index_sync') {
- handler.resolve(msg);
- break;
- }
- }
- return;
- }
-
- // Incremental update — additions/deletions/updates, never the whole
- // index. Only ever arrives after the full index this browser already
- // has (the node's first push to a newly connected peer is always
- // index_sync, see daemon.py _broadcast_index_change), so there is
- // always a base to apply it to.
- if (msg.type === 'index_delta') {
- if (this._onIndexDelta) this._onIndexDelta(msg);
+ // Both index messages carry their payload sealed under a GEK-derived
+ // subkey (MNP 1.0), so they cannot be acted on from here — _dispatch is
+ // synchronous and opening one is not. `index_delta` is the incremental
+ // form: additions/deletions/updates, never the whole index, and it only
+ // ever arrives after the full index this browser already has (the node's
+ // first push to a newly connected peer is always index_sync, see
+ // daemon.py _broadcast_index_change), so there is always a base to apply
+ // it to.
+ if (msg.type === 'index_sync' || msg.type === 'index_delta') {
+ this._queueIndexMessage(msg);
return;
}
@@ -2604,6 +2714,46 @@ function _extractDtlsFingerprint(sdp) {
const NODE_PIN_PREFIX = 'mb_nodepin_';
+/**
+ * The node's half of the version range, from `handshake_challenge`.
+ *
+ * Mirrors meshbay_common/handshake.py::check_version(). A node that declares no
+ * range at all is a node that predates negotiation — that is every 0.x node, and
+ * none of them can serve a sealed index or a sealed ack — so it is refused here
+ * rather than left to fail later as a message that will not open.
+ */
+function _checkNodeVersion(reply) {
+ const parse = (v) => {
+ const m = /^(\d+)\.(\d+)$/.exec(String(v || ''));
+ return m ? [Number(m[1]), Number(m[2])] : null;
+ };
+ const cmp = (a, b) => (a[0] - b[0]) || (a[1] - b[1]);
+ const fail = (reason, message) => {
+ const e = new Error(message);
+ e.reason = reason;
+ throw e;
+ };
+
+ const theirs = parse(reply.v);
+ if (!theirs) {
+ fail('node_version_unreadable',
+ 'The node did not declare a readable protocol version.');
+ }
+ // No declared minimum means "only what I speak" — the correct reading of a
+ // node from before this field existed.
+ const theirMin = parse(reply.v_min) || theirs;
+ if (cmp(theirs, parse(MNP_V_MIN)) < 0) {
+ fail('node_too_old',
+ 'This node is running an older MeshBay than this page needs. '
+ + 'Its operator has to update it.');
+ }
+ if (cmp(theirMin, parse(MNP_V)) > 0) {
+ fail('client_too_old',
+ 'This page is older than the node it is talking to. '
+ + 'Reload to pick up the current version.');
+ }
+}
+
function _checkNodePin(nodeId, nodePk) {
if (!nodeId || !nodePk) return;
const key = NODE_PIN_PREFIX + nodeId;
diff --git a/packages/meshbay-hub/tests/harness/index_seal_probe.mjs b/packages/meshbay-hub/tests/harness/index_seal_probe.mjs
new file mode 100644
index 0000000..36302bb
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/index_seal_probe.mjs
@@ -0,0 +1,98 @@
+/**
+ * Does the browser open a sealed index — and does it stop when it cannot?
+ *
+ * Drives **the real `MeshBayTransport` over the real `crypto.js`**, fed real
+ * length-prefixed msgpack frames built by Python's `groupbox.seal`. Only the DOM
+ * and the DataChannel are stand-ins; the framing, the msgpack decode, the
+ * dispatch, the HKDF and the AES-GCM are all the shipped code.
+ *
+ * It exists because the two things worth knowing here are invisible to a
+ * source-reading test. The first is §3.4: a payload that does not open must
+ * *raise*, never become an empty index — "this group has no files" is a
+ * legitimate state, so a silent fallback is indistinguishable from the truth.
+ * The second is ordering: opening is asynchronous while `_dispatch` is not, so
+ * two index messages could be applied in whichever order their decrypt promises
+ * happened to settle, and a delta applied before its base is a silently wrong
+ * view of the group.
+ *
+ * node index_seal_probe.mjs <static-dir> <vectors.json>
+ *
+ * Prints JSON: `events` in the order they were delivered, and `fetchIndex`, how
+ * the outstanding request ended.
+ */
+import fs from 'fs';
+
+const STATIC = process.argv[2];
+const input = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
+
+// The transport logs to the console on the paths under test; stdout is this
+// probe's JSON result, so everything it says goes to stderr instead.
+for (const level of ['log', 'warn', 'error', 'info', 'debug']) {
+ console[level] = (...args) => process.stderr.write(args.join(' ') + '\n');
+}
+
+// Just enough DOM for two classic scripts that expect a page.
+globalThis.window = globalThis;
+globalThis.addEventListener = () => {};
+globalThis.removeEventListener = () => {};
+globalThis.location = { hash: '' };
+globalThis.document = {
+ addEventListener() {}, removeEventListener() {}, visibilityState: 'visible',
+};
+
+new Function(fs.readFileSync(`${STATIC}/crypto.js`, 'utf8'))();
+new Function(fs.readFileSync(`${STATIC}/transport.js`, 'utf8'))();
+
+const hex = (s) => Uint8Array.from(s.match(/../g).map((b) => parseInt(b, 16)));
+
+const events = [];
+const tp = new window.MeshBayTransport('', 'token');
+tp._connected = true;
+tp._channel = { readyState: 'open', send() {}, close() {} };
+tp._pc = { close() {} };
+tp._gekRaw = hex(input.gek);
+tp._connectArgs = { groupId: input.group_id };
+
+tp.onIndexSync = (msg) => events.push({
+ event: 'index_sync',
+ entries: (msg.entries || []).map((e) => e.name),
+ dirs: msg.dirs || [],
+ version: msg.version,
+ // Present on the message a consumer sees? The envelope's own fields should
+ // be gone, and the payload's should have taken their place.
+ hasCiphertext: 'ct' in msg || 'nonce' in msg,
+});
+tp.onIndexDelta = (msg) => events.push({
+ event: 'index_delta',
+ additions: (msg.additions || []).map((e) => e.name),
+ base_version: msg.base_version,
+ version: msg.version,
+});
+tp.onSessionFailed = (err) => events.push({ event: 'session_failed', message: err.message });
+
+// One outstanding fetchIndex, so the probe can say what a *waiting caller* is
+// told — which is the half of §3.4 a callback cannot show.
+const fetchOutcome = { state: 'pending' };
+tp._send = () => {};
+tp.fetchIndex()
+ .then((msg) => { fetchOutcome.state = 'resolved';
+ fetchOutcome.entries = (msg.entries || []).map((e) => e.name); })
+ .catch((e) => { fetchOutcome.state = 'rejected'; fetchOutcome.message = e.message; });
+
+const closed = { count: 0 };
+const realClose = tp.close.bind(tp);
+tp.close = () => { closed.count += 1; realClose(); };
+
+(async () => {
+ // Delivered exactly as the DataChannel delivers them: one call per frame, in
+ // order, with no await between.
+ for (const frame of input.frames) tp._onMessage(hex(frame).buffer);
+
+ // Let the opening chain drain. Each message costs two WebCrypto promises, so
+ // a handful of turns is not enough to be sure; a real delay is.
+ await new Promise((r) => setTimeout(r, 200));
+
+ process.stdout.write(JSON.stringify({
+ events, fetchIndex: fetchOutcome, closed: closed.count,
+ }));
+})();
diff --git a/packages/meshbay-hub/tests/test_index_seal_client.py b/packages/meshbay-hub/tests/test_index_seal_client.py
new file mode 100644
index 0000000..ca2c7a2
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_index_seal_client.py
@@ -0,0 +1,143 @@
+"""
+The browser half of MNP 1.0's sealed index, measured rather than read.
+
+`test_index_no_cleartext.py` proves the node sends no filename in the clear. This
+proves the client can still read one — and, the part that matters more, that it
+*stops* when it cannot instead of reporting an empty group.
+
+Driven through `harness/index_seal_probe.mjs`, which runs the shipped
+`transport.js` over the shipped `crypto.js` and is fed real frames built here.
+A source-reading test could show that `openGroup` is called; only this can show
+what a waiting `fetchIndex()` is told when it throws.
+"""
+
+import json
+import shutil
+import struct
+import subprocess
+import tempfile
+from pathlib import Path
+
+import msgpack
+import pytest
+from meshbay_common.crypto import generate_gek
+from meshbay_common.groupbox import PURPOSE_INDEX, seal
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+PROBE = Path(__file__).resolve().parent / "harness" / "index_seal_probe.mjs"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not PROBE.exists(),
+ reason="node unavailable — the client half cannot be measured",
+)
+
+GROUP = "g-probe"
+GEK = generate_gek()
+
+
+def _entry(name: str) -> dict:
+ return {"id": "ab" * 32, "name": name, "path": "library", "size": 10,
+ "type": "file", "added_at": 0, "uploader_id": ""}
+
+
+def _frame(msg: dict) -> str:
+ body = msgpack.packb(msg, use_bin_type=True)
+ return (struct.pack(">I", len(body)) + body).hex()
+
+
+def _sync_frame(gek: bytes, *names: str, version: int = 3) -> str:
+ payload = {"version": version, "entries": [_entry(n) for n in names],
+ "dirs": ["library"], "roots": [{"name": "library"}]}
+ return _frame({"type": "index_sync", "v": "1.0", "group_id": GROUP,
+ **seal(gek, PURPOSE_INDEX, "index_sync", GROUP, payload)})
+
+
+def _delta_frame(gek: bytes, name: str, base: int, version: int) -> str:
+ payload = {"base_version": base, "version": version,
+ "additions": [_entry(name)], "deletions": [], "updates": []}
+ return _frame({"type": "index_delta", "v": "1.0", "group_id": GROUP,
+ **seal(gek, PURPOSE_INDEX, "index_delta", GROUP, payload)})
+
+
+def _run(frames: list[str], gek: bytes = GEK) -> dict:
+ with tempfile.TemporaryDirectory() as tmp:
+ vectors = Path(tmp) / "vectors.json"
+ vectors.write_text(json.dumps(
+ {"gek": gek.hex(), "group_id": GROUP, "frames": frames}))
+ proc = subprocess.run(
+ ["node", str(PROBE), str(STATIC), str(vectors)],
+ capture_output=True, text=True, timeout=120)
+ if proc.returncode != 0:
+ pytest.fail(f"probe failed:\n{proc.stderr}")
+ return json.loads(proc.stdout)
+
+
+def test_a_sealed_index_reaches_the_consumer_intact():
+ out = _run([_sync_frame(GEK, "a-film.mkv", "another.mkv")])
+
+ assert [e["event"] for e in out["events"]] == ["index_sync"]
+ sync = out["events"][0]
+ assert sync["entries"] == ["a-film.mkv", "another.mkv"]
+ assert sync["dirs"] == ["library"]
+ # Moved inside the payload (D4) and still delivered flat, so no consumer had
+ # to change: it reads the same message it always read.
+ assert sync["version"] == 3
+ assert not sync["hasCiphertext"], "the envelope's own fields leaked to consumers"
+
+ # And the waiting caller gets the opened form, not the envelope.
+ assert out["fetchIndex"]["state"] == "resolved"
+ assert out["fetchIndex"]["entries"] == ["a-film.mkv", "another.mkv"]
+
+
+def test_an_index_that_does_not_open_ends_the_session():
+ """
+ §3.4, and the reason it is a rule rather than a preference. An empty
+ `entries` is a legitimate state — a group whose operator has shared nothing
+ yet — so a client that fell back to one would show the same screen for
+ "nothing here" and for "we could not decrypt anything this node sent".
+ """
+ out = _run([_sync_frame(generate_gek(), "a-film.mkv")])
+
+ kinds = [e["event"] for e in out["events"]]
+ assert "index_sync" not in kinds, "a failed decrypt was reported as an index"
+ assert kinds == ["session_failed"]
+ assert "index_sync" in out["events"][0]["message"], (
+ "the failure must name the message type that could not be opened")
+
+ # The caller is told, rather than left to time out 30 s later.
+ assert out["fetchIndex"]["state"] == "rejected"
+ assert "index_sync" in out["fetchIndex"]["message"]
+ assert out["closed"] == 1, "the session carried on after an unopenable message"
+
+
+def test_a_delta_that_does_not_open_ends_the_session_too():
+ """
+ The delta has no caller waiting on it — it is pushed — so a silent failure
+ here would leave a browser showing a stale index with nothing wrong on
+ screen, which is the worst of the three shapes.
+ """
+ out = _run([_sync_frame(GEK, "a-film.mkv"),
+ _delta_frame(generate_gek(), "new.mkv", 3, 4)])
+
+ assert [e["event"] for e in out["events"]] == ["index_sync", "session_failed"]
+ assert "index_delta" in out["events"][1]["message"]
+ assert out["closed"] == 1
+
+
+def test_deltas_are_applied_in_arrival_order():
+ """
+ Opening is asynchronous and `_dispatch` is not. Two messages opened
+ independently settle in whichever order WebCrypto finishes them, and a delta
+ applied before the one it follows is a wrong view of the group that nothing
+ reports. Three deltas in one burst is the cheapest way to force the race.
+ """
+ frames = [_sync_frame(GEK, "a-film.mkv")]
+ frames += [_delta_frame(GEK, f"added-{i}.mkv", 3 + i, 4 + i) for i in range(3)]
+
+ out = _run(frames)
+
+ assert [e["event"] for e in out["events"]] == [
+ "index_sync", "index_delta", "index_delta", "index_delta"]
+ assert [e["additions"][0] for e in out["events"][1:]] == [
+ "added-0.mkv", "added-1.mkv", "added-2.mkv"]
+ assert [e["base_version"] for e in out["events"][1:]] == [3, 4, 5]
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index 6011ddb..fee80bb 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -317,3 +317,78 @@ def test_uploads_are_tracked_per_file(transport):
"""Acks interleave when two files are in flight."""
assert "this._uploaders = new Map()" in transport
assert "this._uploaders.set(file.name" in transport
+
+
+# ── MNP 1.0: the sealed handshake ack ────────────────────────────────────────
+#
+# The index half is measured for real in `test_index_seal_client.py`. The ack is
+# opened inside `connect()`, three messages into a WebRTC negotiation, so these
+# read the source — and the ordering they pin is the whole security argument, not
+# an implementation detail.
+
+def _handshake_block(transport: str) -> str:
+ start = transport.index("if (reply.type === 'handshake_challenge') {")
+ return transport[start:transport.index(" return ack;", start)]
+
+
+def test_the_ack_is_verified_before_it_is_decrypted(transport):
+ """
+ Verify, then decrypt. Opening the payload first would mean acting on data
+ from a peer we have not yet authenticated — which is the exact shape of C3,
+ where `node_pk` was never checked and a peer that had hijacked signaling
+ could serve a forged index and a forged `is_node_admin`.
+ """
+ block = _handshake_block(transport)
+ proof = block.index("Node failed to prove GEK possession")
+ signature = block.index("Node signature invalid")
+ pinned = block.index("_checkNodePin(")
+ opened = block.index("openGroup(")
+ assert proof < opened, "the payload is opened before the GEK proof is checked"
+ assert signature < opened, "the payload is opened before the signature is checked"
+ assert pinned < opened, "the payload is opened before the node is pinned"
+
+
+def test_an_ack_that_does_not_open_refuses_the_connection(transport):
+ """
+ Never a default. An `enabled_apps` that failed to open would otherwise reach
+ the client's documented fallback — show every registered app — which is a
+ confident wrong answer, indistinguishable from an operator's real choice.
+ """
+ block = _handshake_block(transport)
+ opened = block[block.index("let config;"):block.index("return ack;")
+ if "return ack;" in block else len(block)]
+ assert "throw new Error(" in opened, "a failed decrypt is swallowed"
+ assert "handshake_ack" in opened, "the failure does not name the message"
+ for fallback in ("|| {}", "?? {}", "catch { }", "config = {}"):
+ assert fallback not in opened, (
+ f"the ack falls back to {fallback} instead of refusing")
+
+
+def test_the_handshake_declares_a_version_range(transport):
+ """
+ L2: `v` used to be written by everyone and read by nobody, so a mismatch
+ surfaced as a missing field rather than a refusal. Both halves of the range
+ ride the handshake, and the node's half is checked before anything below it
+ in `connect()` runs.
+ """
+ block = transport[transport.index("type: 'handshake',"):]
+ block = block[:block.index("});")]
+ assert "v: MNP_V," in block and "v_min: MNP_V_MIN," in block
+
+ challenge = _handshake_block(transport)
+ assert challenge.index("_checkNodeVersion(") < challenge.index("openGroup("), (
+ "the node's version is checked after its messages are relied on")
+
+
+def test_the_index_is_never_reported_from_a_failed_decrypt(transport):
+ """
+ The consumer callbacks may only be reached from inside the opened path — a
+ `catch` that called `_onIndexSync` with an empty message would show "this
+ group has no files", which is a state a real group can be in.
+ """
+ body = transport[transport.index("async _applyIndexMessage("):]
+ body = body[:body.index("\n /**", 1)]
+ assert "openGroup(" in body
+ assert "catch" not in body, (
+ "_applyIndexMessage swallows its own failure instead of letting "
+ "_queueIndexMessage end the session")