// The group chat: history, sending, link previews, and the epoch keys that // seal it. // // Methods of MeshBayTransport, copied onto its prototype by extendTransport // (transport.js, which the shell loads first). extendTransport(class { /** * Unfurl a URL pasted in chat. The node fetches it (the browser cannot — * CSP and CORS — and would leak every reader's IP), parses an OpenGraph * card, and caches any image in its thumb store; `image_thumb_hash` then * rides the normal file_req path like a poster. `ok: false` means "no * preview" (blocked, unreachable, not HTML) — the caller just shows the * bare link. Keyed by url: a message with several links fires one each. */ async fetchLinkPreview(url) { const msg = await this._sendAndWait({ type: 'link_preview_req', v: '0.6', url }); if (msg.type === 'error') throw new Error(msg.detail); return msg; } /** * Where chat attachments are written. * * Its own message rather than `setAppDirectories('chat', ...)`: this one is * a destination, and the node refuses a read-only root for it. A caller * reaching for the generic form would get a refusal it has no reason to * expect, so the difference is in the name. */ async setChatDirectory(path, signFn) { const clean = (path || '').replace(/^\/+|\/+$/g, ''); const msg = await this._sendAndWait({ type: 'chat_directory', v: '1.1', path: clean, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp(msg, 'chat_directory', clean, signFn); } return msg; } /** Whether the node unfurls links members post in this group's chat. */ async setChatLinkPreview(enabled, signFn) { const msg = await this._sendAndWait({ type: 'chat_link_preview', v: '1.1', enabled: Boolean(enabled), }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp( msg, 'chat_link_preview', enabled ? 'on' : 'off', signFn); } return msg; } /** * 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; } /** * A page of chat history, newest first by default. * * `before` is a message id, not a timestamp: it pages backwards from the * newest, which is the direction a conversation is read. Asking without it * used to mean `since: 0`, which paged *forwards* from the very first message * — so a busy group opened on its oldest page and never showed the recent * exchange. * * Returns { messages, hasMore } — hasMore says whether anything older exists, * so the "load older" control knows when to stop offering. */ async fetchChatHistory({ before = null, limit = 100 } = {}) { const msg = await this._sendAndWait({ type: 'chat_hist', v: '0.2', before: before, limit: limit, }); if (msg.type === 'error') throw new Error(msg.detail); 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); } /** * 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))); const msg = await this._sendAndWait({ type: 'chat_msg', v: '2.0', format: 1, epoch, ct, nonce, device, sig, thread_id: threadId || null, // Deliberately absent: the display name is inside the envelope now. sender_name: null, }); // Every other request in this file refuses an `error` reply; this one // returned it as though the node had accepted the message. It never // mattered while a refusal reached the wrong caller anyway — now that a // reply finds the request that made it, a message the node rejected would // otherwise appear in the conversation as sent. It rejects more of them // than it used to: a stale epoch, an envelope the node dislikes, or a // device claim that is not this connection's all come back as `error`. if (msg.type === 'error') throw new Error(msg.detail || 'chat send refused'); 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)); // The signature proves *a device* wrote this. Whether that device belongs // to the account the node named is a separate question, and one this // client answers for itself from the roster (Tier 2) rather than taking // `sender_id` on trust. `changed` is the only value worth a notice. const trust = await this.accountDeviceStatus(base.sender_id, deviceB64); 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, trust, }; } catch { return { ...base, payload: '', unreadable: 'decrypt' }; } } });