aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js344
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.