diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-03 16:16:55 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-03 16:16:55 +0200 |
| commit | 675beed6ff688733a9598f9d82d41578f48316be (patch) | |
| tree | 78dd4f8dff312f0ad99bd63bc679bf402591c5ed /packages/meshbay-hub/src | |
| parent | 15087b0e8fdb872602310119f14680aaa443fd93 (diff) | |
| download | meshbay-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/src')
3 files changed, 255 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; |