diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 344 |
1 files changed, 334 insertions, 10 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 179292e..927956d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -86,6 +86,7 @@ const BROADCAST_ACK_TYPES = new Set([ 'root_update_ack', 'root_eject_ack', 'root_plug_ack', 'root_add_ack', 'root_remove_ack', 'app_directories_ack', 'chat_directory_ack', 'chat_link_preview_ack', + 'chat_epoch_ack', ]); /** Hand a broadcast ack to the callback that would have had it from a peer. */ @@ -96,6 +97,8 @@ function _replayBroadcast(transport, msg) { transport._onChatDirectory(msg.path || ''); } else if (msg.type === 'chat_link_preview_ack' && transport._onChatLinkPreview) { transport._onChatLinkPreview(Boolean(msg.enabled)); + } else if (msg.type === 'chat_epoch_ack') { + transport._applyChatEpoch(msg); } else if (transport._onRootsChanged) { transport._onRootsChanged(msg); } @@ -107,7 +110,7 @@ const ADMIN_OP_TYPES = new Set([ 'musicbrainz_enabled', 'file_delete', 'dir_delete', 'apps_enabled', 'set_scan_settings', 'member_revoke', 'root_add', 'root_remove', 'root_update', 'root_eject', 'root_plug', - 'app_directories', 'chat_directory', 'chat_link_preview', + 'app_directories', 'chat_directory', 'chat_link_preview', 'chat_epoch', 'member_unpin', 'gek_rotate', 'group_attach', 'group_detach', 'invite_create', ]); @@ -380,6 +383,7 @@ class MeshBayTransport { set onAppDirectories(fn) { this._onAppDirectories = fn; } set onChatDirectory(fn) { this._onChatDirectory = fn; } set onChatLinkPreview(fn) { this._onChatLinkPreview = fn; } + set onChatEpoch(fn) { this._onChatEpoch = fn; } set onTmdbConfig(fn) { this._onTmdbConfig = fn; } set onTmdbEnabled(fn) { this._onTmdbEnabled = fn; } set onVideoRoot(fn) { this._onVideoRoot = fn; } @@ -437,6 +441,10 @@ class MeshBayTransport { this._recoveryKey = recoveryKey || null; this._username = username || null; this._userId = userId || null; + // The group this connection is for. Kept on the instance because the + // handshake is not the only thing that needs it any more: device_hello and + // the chat envelope both bind to it, and both run outside connect()'s scope. + this._groupId = groupId || ''; this._newNodeBundle = null; this._newNodeBundleRecovery = null; this._joinError = null; @@ -848,6 +856,29 @@ class MeshBayTransport { delete ack.ct; Object.assign(ack, config); + // Tell the node which of this account's devices is on this connection. + // Deliberately after the ack, and gated on the node's own version rather + // than sent hopefully: a node that does not know the message answers + // nothing at all, which would leave a `device_hello` sitting in + // `_pending` for the full 30s — and the arrival-order fallback hands an + // unrouted reply to the *oldest* pending request, which right after a + // handshake is exactly this one. That is the routed-by-luck bug the chat + // ack comment above was written for; not repeating it. + this._nodeMnp = String(ack.v || ''); + // From the *sealed* part of the ack: a forged epoch would have this + // client sealing under a key the group has retired. + this.chatEpoch = ack.chat_epoch || 0; + // Per connection: a reconnect may land on a node whose epoch has moved, + // and keeping a stale set would silently seal under a retired key. + this._chatKeys = null; + this._chatKeysInFlight = null; + // Not gated on a version any more: a node that reached this point speaks + // MNP 2.0, where identifying the device is what makes chat possible at + // all. `check_version` refused anything older before we got here. + await this._announceDevice().catch((e) => { + console.warn('[MeshBay] device_hello failed — chat will not work:', e); + }); + return ack; } @@ -863,6 +894,37 @@ class MeshBayTransport { } /** + * "This connection is device X of account Y", signed with the device key. + * + * Best effort by construction: a browser that has not recovered its identity + * keys has nothing to sign with, and a node older than MNP 1.2 does not know + * the message. Neither is an error — the node simply keeps the weaker + * attribution it had before, which is what every client did until now. + */ + async _announceDevice() { + if (!this._sessionKeys || !this._sessionKeys.skEdB64) return; + if (!this._nonceNode || !this.nodePk || !this._userId) return; + + const C = window.MeshBayCrypto; + // Derived from our own secret key, never read back from anywhere — the same + // rule as pairOperator: signing a public key someone handed us is the + // substitution this mechanism exists to close. + const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64); + const ts = Math.floor(Date.now() / 1000); + const transcript = C.deviceHelloTranscript( + this.nodePk, this._groupId || '', this._userId, pkEdB64, + this._nonceNode, ts); + const sig = await window.MeshBayKeys.signBytes( + this._sessionKeys.skEdB64, transcript); + + const resp = await this._sendAndWait({ + type: 'device_hello', v: '2.0', pk_ed25519: pkEdB64, ts, sig, + }); + this.devicePk = (resp && resp.type === 'device_hello_ack') ? pkEdB64 : ''; + return this.devicePk; + } + + /** * Kick off (or join, if one is already running) the automatic reconnect * after the WebRTC connection is declared unrecoverable. Idempotent: every * caller racing to reconnect at once — the connectionstatechange handler, @@ -1319,6 +1381,29 @@ class MeshBayTransport { } /** + * Open a new chat epoch by hand. Operator only, and signed. + * + * Not a switch — there is nothing to turn on. The removals that matter open + * an epoch by themselves; this is the operator saying "move the key anyway", + * the same instruction as `rotateGek` and signed for the same reason. + */ + async rotateChatEpoch(signFn) { + // This connection's own group, not a parameter. Every settings pane takes + // the same props by design (`test_app_settings_plugin.py`), so reaching for + // a `groupId` here would make the loop that renders them conditional — and + // the transport already knows which group it is connected to. + const groupId = this._groupId || ''; + const msg = await this._sendAndWait({ + type: 'chat_epoch', v: '2.0', group_id: groupId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'chat_epoch', groupId, signFn); + } + return msg; + } + + /** * MusicBrainz metadata for one track (Music app, docs/musicbay.md §4.3) * — same shape as fetchMediaMeta, minus a season/episode concept: * album-level (release), resolved from the track's own artist/album @@ -1403,7 +1488,42 @@ class MeshBayTransport { limit: limit, }); if (msg.type === 'error') throw new Error(msg.detail); - return { messages: msg.messages || [], hasMore: !!msg.has_more }; + const rows = msg.messages || []; + const messages = []; + for (const row of rows) messages.push(await this._openChatMessage(row)); + return { messages, hasMore: !!msg.has_more }; + } + + /** + * Turn one stored or relayed chat message into what the panel renders. + * + * **The one place that decides how a message is read.** Live messages and + * history arrive by different routes and used to be shaped at each of them; + * with a `format` column and more than one way to read a payload, two copies + * of that decision is two places to get it wrong, and the disagreement would + * show up only in history. + * + * `payload` is bytes on the wire now — the node stopped decoding it as UTF-8, + * which mangled anything that was not text. A message that cannot be read + * comes back marked rather than thrown away: a gap in a conversation the + * reader can see is honest, and silently dropping messages is not. + */ + async _openChatMessage(row) { + const base = { + id: row.id, + sender_id: row.sender_id, + sender_name: row.sender_name || '', + timestamp: row.timestamp, + thread_id: row.thread_id, + }; + const format = row.format || 0; + if (format === 0) { + return { ...base, payload: _asText(row.payload) }; + } + if (format !== 1) { + return { ...base, payload: '', unreadable: 'format' }; + } + return this._openSealedChat(base, row); } /** @@ -1420,16 +1540,163 @@ class MeshBayTransport { return Math.round(performance.now() - started); } - async sendChat(payload, iteration, threadId, senderName) { - const msg = await this._sendAndWait({ + /** + * Send one message, sealed under this group's current chat epoch key and + * signed with this device's key. + * + * There is no plaintext path. MNP 2.0 has no unencrypted chat and the node + * refuses one, so a fallback here could only ever produce a refusal the user + * cannot act on — and a client that quietly posted in clear into a group + * whose members believe their chat is encrypted is the downgrade the whole + * design is about not having. + * + * `sender_name` goes **inside** the envelope. On the wire it is a field any + * peer can set to anything, and the node caches it to render history — so + * display-name spoofing is free while chat is plaintext. Sealed and signed, + * it is as authenticated as the message it names. + * + * Refuses rather than falls back. A client that cannot seal must not quietly + * post in clear into a group whose members believe their chat is encrypted; + * the node refuses it too, and the two refusals agreeing is the point. + */ + async sendChat(text, iteration, threadId, senderName) { + const keys = await this.chatKeys(); + const epoch = this.chatEpoch || keys.current; + const epochKey = keys.byEpoch.get(epoch); + if (!epochKey) throw new Error('No chat key for this group — reconnect'); + if (!this.devicePk || !this._sessionKeys || !this._sessionKeys.skEdB64) { + throw new Error('This device is not identified to the node — reconnect'); + } + + const C = window.MeshBayCrypto; + const gid = this._groupId || ''; + const plaintext = msgpack_encode({ + text: String(text), + thread_id: threadId || null, + sender_name: senderName || '', + sent_at: Math.floor(Date.now() / 1000), + }); + const { nonce, ct } = await C.sealChat( + epochKey, gid, epoch, this.devicePk, plaintext); + const device = C.b64decode(this.devicePk); + const sig = C.b64decode(await window.MeshBayKeys.signBytes( + this._sessionKeys.skEdB64, + C.chatSigningTranscript(gid, epoch, device, nonce, ct))); + + return this._sendAndWait({ type: 'chat_msg', - v: '0.1', - payload: payload, - iteration: iteration || 0, + v: '2.0', + format: 1, + epoch, + ct, + nonce, + device, + sig, thread_id: threadId || null, - sender_name: senderName || null, + // Deliberately absent: the display name is inside the envelope now. + sender_name: null, }); - return msg; + } + + /** + * This group's chat epoch moved. + * + * An epoch opens when somebody is removed, and a client that kept sealing + * under the retired key would be writing messages the group can still read + * but that the removed member could read too. Dropping the cached keys is + * what makes the next send fetch the new one. + */ + _applyChatEpoch(msg) { + if (msg.epoch) this.chatEpoch = msg.epoch; + this._chatKeys = null; + this._chatKeysInFlight = null; + if (this._onChatEpoch) this._onChatEpoch(this.chatEpoch); + } + + /** + * Every chat epoch key for this group, fetched once per connection. + * + * Every epoch, not just the current one — that is what lets a device linked + * this morning read a conversation from last year, and a member who joined + * yesterday read the history the group already had. The node decides which + * epochs a member is entitled to; this asks for what it is given. + */ + async chatKeys() { + if (this._chatKeys) return this._chatKeys; + if (this._chatKeysInFlight) return this._chatKeysInFlight; + + this._chatKeysInFlight = (async () => { + const resp = await this._sendAndWait({ + type: 'chat_keys_req', v: '2.0', group_id: this._groupId || '', + }); + if (resp.type === 'error') throw new Error(resp.detail); + // Sealed under a group-derived subkey. A payload that does not open is + // not "no keys" — it is a peer we cannot talk to, and treating it as an + // empty set would present an encrypted group as one with no history. + const payload = msgpack_decode(await window.MeshBayCrypto.openGroup( + this._gekRaw, 'chat_keys', 'chat_keys_resp', this._groupId || '', resp)); + const byEpoch = new Map(); + for (const e of payload.epochs || []) byEpoch.set(e.epoch, e.key); + this._chatKeys = { byEpoch, current: payload.current || 0 }; + return this._chatKeys; + })(); + try { + return await this._chatKeysInFlight; + } finally { + this._chatKeysInFlight = null; + } + } + + /** + * Open one sealed message, or mark it unreadable and say why. + * + * Authorship is established **before** decryption: the signature is over the + * ciphertext, so a message that does not verify is never rendered as having + * been written by the account it claims — which is the whole point of signing + * rather than trusting the node's `sender_id`. + * + * An unreadable message is kept and marked, never dropped. A gap the reader + * can see is honest; a conversation quietly missing messages is not. + */ + async _openSealedChat(base, row) { + const C = window.MeshBayCrypto; + const gid = this._groupId || ''; + const epoch = row.epoch || 0; + const device = row.device; + const nonce = row.nonce; + const ct = row.ct; + if (!device || !nonce || !ct || !row.sig) { + return { ...base, payload: '', unreadable: 'envelope' }; + } + + if (!await C.verifyChatSignature(device, gid, epoch, nonce, ct, row.sig)) { + return { ...base, payload: '', unreadable: 'signature' }; + } + + let keys; + try { + keys = await this.chatKeys(); + } catch { + return { ...base, payload: '', unreadable: 'keys' }; + } + const epochKey = keys.byEpoch.get(epoch); + if (!epochKey) return { ...base, payload: '', unreadable: 'epoch' }; + + const deviceB64 = C.b64encode(device); + try { + const plain = msgpack_decode( + await C.openChat(epochKey, gid, epoch, deviceB64, nonce, ct)); + return { + ...base, + payload: String(plain.text || ''), + sender_name: plain.sender_name || base.sender_name, + thread_id: plain.thread_id ?? base.thread_id, + device: deviceB64, + verified: true, + }; + } catch { + return { ...base, payload: '', unreadable: 'decrypt' }; + } } /** @@ -2402,7 +2669,12 @@ class MeshBayTransport { return; } if (msg.type === 'chat_msg' && this._onChat) { - this._onChat(msg); + // Shaped through the same reader as history, and asynchronously — a live + // message and a stored one are the same message, and a second way of + // reading one is a second thing to get wrong. + this._openChatMessage(msg) + .then(m => { if (this._onChat) this._onChat(m); }) + .catch(e => console.warn('[MeshBay] chat message unreadable', e)); return; } if (msg.type === 'stream_init') { @@ -2447,6 +2719,10 @@ class MeshBayTransport { if (msg.type === 'chat_link_preview_ack' && this._onChatLinkPreview) { this._onChatLinkPreview(Boolean(msg.enabled)); } + if (msg.type === 'chat_epoch_ack') { + this._applyChatEpoch(msg); + return; + } // Node-wide (not per-group) — the operator supplied/cleared a custom // token, or changed the query language. `token_customized` only says @@ -2679,6 +2955,35 @@ class MeshBayTransport { return; } + // device_hello_ack ends in `_ack` but is not an admin op, so the branch + // above looks it up under `admin:device_hello`, finds nothing, and drops it + // through to the arrival-order guess. Routed by request type instead: a + // request type deserves a key, and a reply deserves something to key it by. + if (msg.type === 'device_hello_ack') { + for (const [, handler] of this._pending) { + if (handler._reqType === 'device_hello') { handler.resolve(msg); return; } + } + console.warn('[MeshBay] device_hello_ack with no matching request'); + return; + } + + // Same shape as chat_hist_resp above, and found the same way — by driving + // the panel rather than by reading this file. `chat_keys_resp` answers a + // `chat_keys_req` under a different type string, so without this it fell + // to the arrival-order guess at the end and was handed to whatever was + // oldest in `_pending`. `chat_send_probe.py` caught it on its first run: + // the Videos tab's unanswered `media_meta_req` swallowed the chat keys, + // and the send then waited out its own 30s timeout with the composer + // disabled — which is a frozen Chat tab, the exact defect that harness + // exists for, reappearing one feature later. + if (msg.type === 'chat_keys_resp') { + for (const [, handler] of this._pending) { + if (handler._reqType === 'chat_keys_req') { handler.resolve(msg); return; } + } + console.warn('[MeshBay] chat_keys_resp with no matching request'); + return; + } + // A bare `ack` answers three requests: sending a chat message, and storing // or withdrawing a keypair bundle. The bundle acks name themselves in // `detail`; the chat one carries nothing at all, so it was left to the @@ -2734,6 +3039,25 @@ class MeshBayTransport { } } +/** + * A wire payload as text. + * + * A plaintext message arrives as a string from the node; msgpack `bin` arrives + * as a Uint8Array. Both have to render. + * + * This function was deleted once, with an unrelated helper that sat next to it, + * and nothing complained: its only caller is inside `_openChatMessage`, whose + * rejection the chat panel swallows in a `.catch()` that just marks the page + * unloaded. The visible result was a conversation that rendered completely + * empty, with no error in the console and the node answering perfectly — found + * by `chat_send_probe.py`, not by reading this file. + */ +function _asText(payload) { + if (payload instanceof Uint8Array) return new TextDecoder().decode(payload); + if (payload == null) return ''; + return String(payload); +} + // ── Minimal msgpack encode/decode ──────────────────────────────────────────── // Covers the subset used by MNP: maps, strings, integers, binary, arrays, null. |