summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js
blob: d6d733f0d187bbb8130179578a898fd30e018bab (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
// 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' };
    }
  }
});