aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-07 17:50:28 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-07 17:50:28 +0200
commit36cebf25d0e0f24cf63be4380ccb5d03da726a74 (patch)
tree8509ec4cf68a058f7383299e11bdea97ab06cadf /packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
parent8883d60d0afa2ed9dd1ef68bc21fe1b9a65a59ff (diff)
downloadmeshbay-36cebf25d0e0f24cf63be4380ccb5d03da726a74.tar.gz
feat(chat): encrypt group chat under per-device epoch keys (MNP 2.0)
Chat messages are sealed with AES-256-GCM under a key derived per group, per epoch, per *device*, and signed over the ciphertext with the device key the node pinned. The node relays and archives; it cannot read a message. There is no switch. MNP goes to 2.0 and MNP_MIN_SUPPORTED moves with it, so a 1.x peer is refused at the handshake with `version_too_old` rather than admitted and then unable to speak. An opt-in flag was designed and rejected: every node is a test node, so it would have bought nothing and left a plaintext branch reachable — C6's lesson one feature later. A test reads the source and refuses any code that consults a `chat_encrypted` setting. Not Sender Keys, and `senderkeys.py` is now documented as unused. With distribution under the group key and a node that serves history to devices which were not present, the node must retain each chain's earliest key, and a chain key at iteration i yields every message key from i on by pure HKDF — forward secrecy is zero either way. What the ratchet was left buying was stateful client code with silent failure modes, three of them reproduced: any member could sign as any other, a second device dropped the first's chain, and the skipped-key cache grew without bound. The reasoning is in docs/chat-sender-keys.md, which is the specification and the decision record. Epochs, not rotation: the epoch key is wrapped under the group key at delivery and never stored under it, so `gek_rotate` is a re-wrap. A group-key-derived archive key would have made every message ever sent unreadable on the first `member unpin`, which is the documented step after removing a member. A new epoch opens on member revoke/unpin, device revoke and `gek_rotate`; old epochs are kept and still delivered, so history stays readable to everyone who could already read it, and nothing anywhere deletes one. Three prerequisites this needed, each a live defect on its own: * The peer registry was keyed by user_id, so one account's second device evicted the first and the broadcast skipped recipients by account — a person's phone never saw what they typed on their laptop. * The handshake authenticated an account, never a device. `device_hello` (additive, signed, refused unless the key is a live device of this account in the node's own roster) is what lets the node refuse a member claiming somebody else's key. * `_admin_exec_file_delete` authorized against the exact uploading key, so device linking had already broken deleting your own file from your other device. It now authorizes against any non-revoked device of `uploader_id`. Found by driving the real panel over the real transport, not by reading source: `chat_keys_resp` was routed by arrival order and handed to an unanswered `media_meta_req` — the original frozen-tab defect in a message type that did not exist when that probe was written. And `_asText` had been deleted with an unrelated helper beside it; its only caller sits inside a promise the panel catches, so every conversation rendered empty with nothing in the console. Existing node data is migrated by QE/migration/migrate_chat_encryption.py (not versioned, per the QE rule), run with the node stopped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/chat-app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/chat-app.js67
1 files changed, 53 insertions, 14 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
index 0a7ef26..a7b8fd9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
@@ -203,8 +203,8 @@ function ChatImage({ filename, entries, transportRef, gekRef }) {
// read one answer. Empty means the group has no writable root right now — every
// root is read-only, or the one drive that was writable is unplugged — and the
// paperclip says so rather than producing a refusal from the node.
-function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
- onPreview, attachRoot = '', attachDir = '',
+function ChatPanel({ transportRef, username, userId, entries, gekRef,
+ onRefreshIndex, onPreview, attachRoot = '', attachDir = '',
onActivity, status }) {
const [messages, setMessages] = useState([]);
const [hasMore, setHasMore] = useState(false);
@@ -257,13 +257,14 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
transport.onChat = (msg) => {
const id = msg.id
|| `live-${Date.now()}-${Math.random().toString(36).slice(2)}`;
+ // Spread rather than rebuilt field by field: the transport is the one
+ // place that decides how a message is read, and copying a subset of its
+ // result here is how the live path and the history path come to disagree
+ // — which would show up only for messages that cannot be opened.
setMessages(prev => [...prev, {
+ ...msg,
id,
- sender_id: msg.sender_id,
- sender_name: msg.sender_name || '',
- payload: msg.payload,
timestamp: msg.timestamp || Date.now() / 1000,
- thread_id: msg.thread_id,
}]);
if (!atBottomRef.current) setUnreadFrom(prev => prev ?? id);
};
@@ -459,7 +460,8 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
await transport.sendChat(text, 0, null, username);
setMessages(prev => [...prev, {
id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`,
- sender_id: username,
+ own: true,
+ sender_id: userId || username,
sender_name: username,
payload: text,
timestamp: Date.now() / 1000,
@@ -473,7 +475,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
setSending(false);
setTimeout(() => { if (inputRef.current) inputRef.current.focus(); });
}
- }, [input, username, jumpToBottom]);
+ }, [input, username, userId, jumpToBottom]);
const attachFile = useCallback(async (e) => {
const file = e.target.files?.[0];
@@ -501,7 +503,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
await transport.sendChat(structured, 0, null, username);
setMessages(prev => [...prev, {
id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`,
- sender_id: username, sender_name: username,
+ own: true, sender_id: userId || username, sender_name: username,
payload: structured, timestamp: Date.now() / 1000, thread_id: null,
}]);
jumpToBottom();
@@ -510,7 +512,16 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
} finally {
setAttaching(false);
}
- }, [username, onRefreshIndex, jumpToBottom, attachRoot, attachDir]);
+ }, [username, userId, onRefreshIndex, jumpToBottom, attachRoot, attachDir]);
+
+ // Chat is always encrypted, and sealing needs this device to have identified
+ // itself to the node (`device_hello`) — which is also what lets the node
+ // refuse a member claiming somebody else's key. Without it there is nothing
+ // to send with, so the composer says so before anything is typed rather than
+ // producing a refusal the reader cannot act on.
+ const transportNow = transportRef.current;
+ const cannotSend = !!(transportNow && transportNow.connected
+ && !transportNow.devicePk);
const onKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -543,7 +554,14 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
</div>
`}
${messages.map((m, i) => {
- const isOwn = m.sender_name === username || m.sender_id === username;
+ // By account, and by an explicit flag on our own optimistic echo.
+ // Comparing a display name against a sender id happened to work
+ // while the echo invented `sender_id: username`, and would have
+ // started rendering other people's messages as the reader's own the
+ // moment two members shared a display name.
+ const isOwn = m.own === true
+ || (!!userId && m.sender_id === userId)
+ || (!userId && m.sender_name === username);
const displayName = m.sender_name || '?';
const prev = messages[i - 1];
const showSender = !isOwn && (i === 0 ||
@@ -551,6 +569,26 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
// A conversation read over several days is unreadable without them.
const daySep = i === 0 || !_sameDay(prev.timestamp, m.timestamp)
? _dayLabel(m.timestamp) : null;
+ // A message the transport could not open is shown as a gap, with
+ // what went wrong. Dropping it would leave a conversation quietly
+ // missing messages, which is worse than a visible hole: nobody can
+ // notice what they were never shown.
+ if (m.unreadable) {
+ return html`
+ ${daySep && html`
+ <div class="chat-day" key=${'d' + m.id}><span>${daySep}</span></div>
+ `}
+ <div key=${m.id} class="chat-msg ${isOwn ? 'chat-msg-own' : ''}">
+ ${showSender && html`<div class="chat-sender">${displayName}</div>`}
+ <div class="chat-bubble chat-bubble-unreadable">
+ <span class="chat-unreadable">
+ ${t('chat.unreadable_' + m.unreadable) || t('chat.unreadable')}
+ </span>
+ <span class="chat-time">${formatTime(m.timestamp)}</span>
+ </div>
+ </div>
+ `;
+ }
const parsed = _parsePayload(m.payload);
const att = parsed && parsed.attachment;
const msgText = parsed && typeof parsed.text === 'string' ? parsed.text : m.payload;
@@ -613,13 +651,14 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
</span>
`}
<textarea class="chat-input" rows="1" ref=${inputRef}
- placeholder="${t('chat.placeholder')}"
+ placeholder="${cannotSend ? t('chat.encrypted_cannot_send')
+ : t('chat.placeholder')}"
value=${input}
onInput=${e => setInput(e.target.value)}
onKeyDown=${onKeyDown}
- disabled=${sending} />
+ disabled=${sending || cannotSend} />
<button class="chat-send" onClick=${sendMessage}
- disabled=${sending || !input.trim()}>
+ disabled=${sending || cannotSend || !input.trim()}>
${t('chat.send')}
</button>
</div>