diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-07 17:50:28 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-07 17:50:28 +0200 |
| commit | 36cebf25d0e0f24cf63be4380ccb5d03da726a74 (patch) | |
| tree | 8509ec4cf68a058f7383299e11bdea97ab06cadf /packages/meshbay-hub/src/meshbay_hub/static/chat-app.js | |
| parent | 8883d60d0afa2ed9dd1ef68bc21fe1b9a65a59ff (diff) | |
| download | meshbay-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.js | 67 |
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> |