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 | |
| 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')
15 files changed, 656 insertions, 26 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js index 96fc52f..1eba59a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js @@ -17,7 +17,8 @@ import { FolderPickerField } from './folder-tree.js'; * has to be on a read-write root. The picker greys out the rest rather than * letting the node's refusal arrive after the fact. */ -function ChatSettings({ roots, dirs, settings, saveDirectories, transport, signFn }) { +function ChatSettings({ roots, dirs, settings, saveDirectories, transport, + signFn }) { const { busy, msg, run } = useSaver(); const [directory, setDirectory] = useState(settings.chatDirectory || ''); const [linkPreview, setLinkPreview] = useState(settings.chatLinkPreview !== false); @@ -59,6 +60,19 @@ function ChatSettings({ roots, dirs, settings, saveDirectories, transport, signF label=${t('settings_app.chat_link_preview_label')} /> <p class="settings-hint">${t('settings_app.chat_link_preview_hint')}</p> </div> + ${/* Not a toggle: chat is always encrypted (MNP 2.0), so there is + nothing here to turn on. What an operator may want is to move the + key on deliberately — the removals that matter already do it by + themselves. Stated rather than left invisible, because "is my chat + encrypted?" is a question people ask of a settings pane. */ ''} + <div class="settings-row" style="margin-top:12px"> + <p class="settings-hint">${t('settings_app.chat_encrypted_always')}</p> + <button class="app-save" disabled=${busy} + onClick=${() => run(() => transport.rotateChatEpoch(signFn))}> + ${busy ? t('settings_app.saving') : t('settings_app.chat_rotate_epoch')} + </button> + <p class="settings-hint">${t('settings_app.chat_rotate_epoch_hint')}</p> + </div> ${msg && html`<p class="settings-hint">${msg}</p>`} </div> `; 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> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js index d2e19b7..f9cff08 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js @@ -126,6 +126,7 @@ async function decryptChunkBin(gek, fileHashHex, chunkIndex, nonce, ct) { const GROUPBOX_INFO = { index: new TextEncoder().encode('meshbay:index:v1'), ack: new TextEncoder().encode('meshbay:ack:v1'), + chat_keys: new TextEncoder().encode('meshbay:chat_keys:v1'), }; /** @@ -183,6 +184,96 @@ async function sealGroup(gek, purpose, msgType, groupId, plaintextBytes) { } +// ── Chat: per-device keys under a group chat epoch key ────────────────────── +// +// Mirrors meshbay_common/chatbox.py; held to it by test_js_python_parity. +// +// One key per group, per epoch, per *device*. The node generates the epoch key +// and hands it over wrapped under the group key; every member derives every +// device's subkey from it by name, so nothing is distributed per device and +// there is no per-device state to keep. Two devices therefore never share an +// AES key — the property per-device ratchet chains were wanted for, obtained by +// derivation rather than by mutable state that both of them advance. +// +// Signing is separate from encryption and is what establishes who spoke: over +// the *ciphertext*, so authorship can be checked before decryption and by +// anyone holding the roster, and with the device key the node pinned rather +// than a fresh key the sender invented. + +const CHAT_DEV_INFO = 'meshbay:chat:dev:v1'; +const CHAT_SIG_PREFIX = new TextEncoder().encode('meshbay:chat:v1'); + +async function chatDeviceKey(epochKey, groupId, deviceB64, usages) { + const info = new TextEncoder().encode( + `${CHAT_DEV_INFO}|${groupId}|${deviceB64}`); + const base = await crypto.subtle.importKey( + 'raw', epochKey, 'HKDF', false, ['deriveKey']); + return crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info }, + base, { name: 'AES-GCM', length: 256 }, false, usages); +} + +/** The group and the epoch a ciphertext is bound to. */ +function chatAad(groupId, epoch) { + return new TextEncoder().encode(`chat_msg|${groupId}|${epoch}`); +} + +/** + * What the sending device signs. Length-prefixed and domain-separated (L4): + * without the lengths a message could be re-cut into a different one with the + * same bytes. + * + * No connection nonce, unlike every other transcript here — a receiver reading + * history has no access to the connection a message arrived on. Replay is + * refused by the node's storage instead, on a unique (device, nonce). + */ +function chatSigningTranscript(groupId, epoch, device, nonce, ct) { + const enc = new TextEncoder(); + const body = _lenPrefixed([ + enc.encode(groupId), enc.encode(String(epoch)), device, nonce, ct, + ]); + const out = new Uint8Array(CHAT_SIG_PREFIX.length + body.length); + out.set(CHAT_SIG_PREFIX, 0); + out.set(body, CHAT_SIG_PREFIX.length); + return out; +} + +/** `{nonce, ct}` for one message. The caller signs and merges. */ +async function sealChat(epochKey, groupId, epoch, deviceB64, plaintextBytes) { + const key = await chatDeviceKey(epochKey, groupId, deviceB64, ['encrypt']); + // Random per message, never derived from the payload: two identical messages + // under one device's key would reuse it, and AES-GCM under nonce reuse does + // not fail gracefully. + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const ct = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: nonce, additionalData: chatAad(groupId, epoch) }, + key, plaintextBytes); + return { nonce, ct: new Uint8Array(ct) }; +} + +/** The msgpack bytes of one message. Throws if it does not open. */ +async function openChat(epochKey, groupId, epoch, deviceB64, nonce, ct) { + const key = await chatDeviceKey(epochKey, groupId, deviceB64, ['decrypt']); + const plain = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: nonce, additionalData: chatAad(groupId, epoch) }, + key, ct); + return new Uint8Array(plain); +} + +/** Whether this device signed this ciphertext. */ +async function verifyChatSignature(deviceRaw, groupId, epoch, nonce, ct, sig) { + try { + const key = await crypto.subtle.importKey( + 'raw', deviceRaw, { name: 'Ed25519' }, false, ['verify']); + return await crypto.subtle.verify( + 'Ed25519', key, sig, + chatSigningTranscript(groupId, epoch, deviceRaw, nonce, ct)); + } catch { + return false; + } +} + + // ── GEK generation + ECIES wrapping ────────────────────────────────────────── function generateGEK() { @@ -348,6 +439,7 @@ async function handshakeProof(gekRaw, role, groupId, nonceClient, nonceNode, bin const JOIN_PREFIX = new TextEncoder().encode('meshbay:join:v1'); const DEVICE_REQ_PREFIX = new TextEncoder().encode('meshbay:device_req:v1'); const DEVICE_ADD_PREFIX = new TextEncoder().encode('meshbay:device_add:v1'); +const DEVICE_HELLO_PREFIX = new TextEncoder().encode('meshbay:device_hello:v1'); function joinTranscript(nodePkB64, groupId, userId, pkEdB64, pkXB64, nonceNode, ts) { const enc = new TextEncoder(); @@ -400,6 +492,27 @@ function deviceAddTranscript(nodePkB64, userId, pkEdB64, pkXB64, nonceNode, ts) } /** + * "Which of this account's devices am I?", mirroring + * `meshbay_common/device.py:device_hello_transcript`. + * + * The handshake proves membership of a group and carries an account from the + * hub's token; it proves nothing about which device is talking. Sent once, after + * the handshake, so the node stops resolving "the account's oldest key" and + * attributing this device's uploads to another one. + */ +function deviceHelloTranscript(nodePkB64, groupId, userId, pkEdB64, nonceNode, ts) { + const enc = new TextEncoder(); + const body = _lenPrefixed([ + enc.encode(nodePkB64), enc.encode(groupId), enc.encode(userId), + enc.encode(pkEdB64), nonceNode, enc.encode(String(ts)), + ]); + const out = new Uint8Array(DEVICE_HELLO_PREFIX.length + body.length); + out.set(DEVICE_HELLO_PREFIX, 0); + out.set(body, DEVICE_HELLO_PREFIX.length); + return out; +} + +/** * sha256(code ‖ pk_ed ‖ pk_x), hex — the lookup key for a pending request. * * The keys go in with the code, so the hash identifies *this device asking with @@ -450,6 +563,8 @@ window.MeshBayCrypto = { generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode, adminTranscript, handshakeTranscript, handshakeProof, webrtcBinding, joinTranscript, verifyNodeSignature, constantTimeEqual, - deviceRequestTranscript, deviceAddTranscript, deviceCodeHash, + deviceRequestTranscript, deviceAddTranscript, deviceHelloTranscript, + deviceCodeHash, + sealChat, openChat, chatSigningTranscript, verifyChatSignature, normalizeCode, }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index d05d3a3..180c762 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -652,6 +652,15 @@ export default { 'chat.unread': 'Neue Nachrichten', 'chat.jump_latest': 'Zu den neuesten springen', 'chat.jump_new': 'Neue Nachrichten', + 'chat.unreadable': 'Diese Nachricht konnte nicht gelesen werden', + 'chat.unreadable_signature': 'Nicht verifizierte Nachricht — die Signatur passt nicht zum Absender', + 'chat.unreadable_epoch': 'Geschrieben, bevor dieses Gerät diese Unterhaltung lesen konnte', + 'chat.unreadable_keys': 'Chat-Schlüssel nicht verfügbar — neu verbinden, um dies zu lesen', + 'chat.unreadable_decrypt': 'Diese Nachricht konnte nicht entschlüsselt werden', + 'chat.unreadable_envelope': 'Diese Nachricht kam unvollständig an', + 'chat.unreadable_format': 'Diese Nachricht erfordert eine neuere Version von MeshBay', + 'chat.encrypted_needs_newer': 'Diese Unterhaltung ist verschlüsselt und dieser Client kann sie nicht lesen — MeshBay aktualisieren', + 'chat.encrypted_cannot_send': 'Diese Unterhaltung ist verschlüsselt und dieses Gerät kann noch nicht darin schreiben', 'group.leave': 'Gruppe verlassen', 'group.leave_confirm': '„{name}“ verlassen? Sie verlieren den Zugang zu ihren Dateien und zum Chat. Hochgeladene Dateien bleiben auf dem Node, und der Node behält die für Sie gemerkte Identität, bis sein Betreiber sie entfernt.', 'group.mute': 'Mute notifications', @@ -852,6 +861,9 @@ export default { 'settings_app.chat_no_writable_root': 'Diese Gruppe hat kein beschreibbares Verzeichnis, daher sind Anhänge aus.', 'settings_app.chat_link_preview_label': 'Link-Vorschauen', 'settings_app.chat_link_preview_hint': 'Postet ein Mitglied einen Link, holt der Node Titel und Bild der Seite. Das ist eine Anfrage von Ihrem Rechner an eine Website, die jemand anderes gewählt hat.', + 'settings_app.chat_encrypted_always': 'Der Chat dieser Gruppe ist immer verschlüsselt. Das lässt sich nicht abschalten.', + 'settings_app.chat_rotate_epoch': 'Chat-Schlüssel weiterdrehen', + 'settings_app.chat_rotate_epoch_hint': 'Mitglieder lesen weiterhin den gesamten Verlauf. Wer aus der Gruppe entfernt wurde, kann nicht mehr lesen, was ab jetzt geschrieben wird. Ein Mitglied oder Gerät zu entfernen tut das bereits von selbst.', 'settings_app.tmdb_token_prompt': 'Registrieren Sie sich bei TMDB, um einen eigenen API-Schlüssel zu erzeugen.', 'settings_app.tmdb_token_link': 'Schlüssel holen', 'settings_node.roots_offline_hint': 'Nicht mit dem Node verbunden — Änderungen laufen über den lokalen Node und greifen beim nächsten Neuladen.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 6dbb70f..996d5b6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -640,6 +640,9 @@ export default { 'settings_app.chat_no_writable_root': 'This group has no read-write directory, so attachments are off.', 'settings_app.chat_link_preview_label': 'Link previews', 'settings_app.chat_link_preview_hint': 'When a member posts a link, the node fetches the page\'s title and image. That is a request from your machine to a site somebody else chose.', + 'settings_app.chat_encrypted_always': 'Chat in this group is always encrypted. It cannot be turned off.', + 'settings_app.chat_rotate_epoch': 'Move the chat key on', + 'settings_app.chat_rotate_epoch_hint': 'Members keep reading the whole history. Anyone removed from the group cannot read what is written from now on. Removing a member or a device already does this by itself.', 'settings_app.tmdb_token_prompt': 'Sign up on TMDB to generate your own API key.', 'settings_app.tmdb_token_link': 'Get a key', 'settings_node.roots_offline_hint': 'Not connected to the node — changes go through the local node instead, and take effect on its next reload.', @@ -760,6 +763,20 @@ export default { 'chat.unread': 'New messages', 'chat.jump_latest': 'Jump to latest', 'chat.jump_new': 'New messages', + // A message this client could not open. Each one names what actually went + // wrong, because "could not read this message" sends an operator nowhere: + // a signature failure is somebody impersonating a member, a missing epoch key + // is a device that joined after the message was written, and a decryption + // failure is neither. + 'chat.unreadable': 'This message could not be read', + 'chat.unreadable_signature': 'Unverified message — the signature does not match the sender', + 'chat.unreadable_epoch': 'Written before this device could read this conversation', + 'chat.unreadable_keys': 'Chat keys unavailable — reconnect to read this', + 'chat.unreadable_decrypt': 'This message could not be decrypted', + 'chat.unreadable_envelope': 'This message arrived incomplete', + 'chat.unreadable_format': 'This message needs a newer version of MeshBay', + 'chat.encrypted_needs_newer': 'This conversation is encrypted and this client cannot read it — update MeshBay', + 'chat.encrypted_cannot_send': 'This conversation is encrypted and this device cannot post to it yet', 'group.leave': 'Leave group', 'group.leave_confirm': 'Leave “{name}”? You will lose access to its files and chat. Files you uploaded stay on the node, and the node keeps the identity it pinned for you until its operator removes it.', 'group.mute': 'Mute notifications', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index aa950cc..10fac89 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -648,6 +648,15 @@ export default { 'chat.unread': 'Mensajes nuevos', 'chat.jump_latest': 'Ir a lo más reciente', 'chat.jump_new': 'Mensajes nuevos', + 'chat.unreadable': 'No se pudo leer este mensaje', + 'chat.unreadable_signature': 'Mensaje no verificado: la firma no coincide con el remitente', + 'chat.unreadable_epoch': 'Escrito antes de que este dispositivo pudiera leer esta conversación', + 'chat.unreadable_keys': 'Claves de chat no disponibles: vuelve a conectarte para leerlo', + 'chat.unreadable_decrypt': 'No se pudo descifrar este mensaje', + 'chat.unreadable_envelope': 'Este mensaje llegó incompleto', + 'chat.unreadable_format': 'Este mensaje necesita una versión más reciente de MeshBay', + 'chat.encrypted_needs_newer': 'Esta conversación está cifrada y este cliente no puede leerla: actualiza MeshBay', + 'chat.encrypted_cannot_send': 'Esta conversación está cifrada y este dispositivo aún no puede escribir en ella', 'group.leave': 'Salir del grupo', 'group.leave_confirm': '¿Salir de «{name}»? Perderá el acceso a sus archivos y a su chat. Los archivos que subió permanecen en el node, y este conserva la identidad que fijó para usted hasta que su operador la retire.', 'group.mute': 'Mute notifications', @@ -848,6 +857,9 @@ export default { 'settings_app.chat_no_writable_root': 'Este grupo no tiene directorio de escritura, así que los adjuntos están desactivados.', 'settings_app.chat_link_preview_label': 'Vistas previas de enlaces', 'settings_app.chat_link_preview_hint': 'Cuando un miembro publica un enlace, el nodo obtiene el título y la imagen de la página. Es una petición desde tu máquina a un sitio que eligió otra persona.', + 'settings_app.chat_encrypted_always': 'El chat de este grupo siempre está cifrado. No se puede desactivar.', + 'settings_app.chat_rotate_epoch': 'Renovar la clave del chat', + 'settings_app.chat_rotate_epoch_hint': 'Los miembros siguen leyendo todo el historial. Quien haya sido expulsado del grupo no podrá leer lo que se escriba a partir de ahora. Quitar a un miembro o un dispositivo ya lo hace por sí solo.', 'settings_app.tmdb_token_prompt': 'Regístrate en TMDB para generar tu propia clave de API.', 'settings_app.tmdb_token_link': 'Obtener una clave', 'settings_node.roots_offline_hint': 'Sin conexión con el nodo: los cambios pasan por el nodo local y se aplican en su próxima recarga.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index daa9f94..f470bf1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -651,6 +651,15 @@ export default { 'chat.unread': 'Nouveaux messages', 'chat.jump_latest': 'Aller au plus récent', 'chat.jump_new': 'Nouveaux messages', + 'chat.unreadable': 'Ce message n\'a pas pu être lu', + 'chat.unreadable_signature': 'Message non vérifié — la signature ne correspond pas à l\'expéditeur', + 'chat.unreadable_epoch': 'Écrit avant que cet appareil puisse lire cette conversation', + 'chat.unreadable_keys': 'Clés de discussion indisponibles — reconnectez-vous pour lire ceci', + 'chat.unreadable_decrypt': 'Ce message n\'a pas pu être déchiffré', + 'chat.unreadable_envelope': 'Ce message est arrivé incomplet', + 'chat.unreadable_format': 'Ce message nécessite une version plus récente de MeshBay', + 'chat.encrypted_needs_newer': 'Cette conversation est chiffrée et ce client ne peut pas la lire — mettez MeshBay à jour', + 'chat.encrypted_cannot_send': 'Cette conversation est chiffrée et cet appareil ne peut pas encore y écrire', 'group.leave': 'Quitter le groupe', 'group.leave_confirm': 'Quitter « {name} » ? Vous perdrez l’accès à ses fichiers et à sa discussion. Les fichiers que vous avez envoyés restent sur le node, et celui-ci conserve l’identité qu’il a épinglée pour vous jusqu’à ce que son opérateur la retire.', 'group.mute': 'Couper les notifications', @@ -866,6 +875,9 @@ export default { 'settings_app.chat_no_writable_root': 'Ce groupe n\'a aucun répertoire en écriture : les pièces jointes sont désactivées.', 'settings_app.chat_link_preview_label': 'Aperçus des liens', 'settings_app.chat_link_preview_hint': 'Quand un membre poste un lien, le nœud récupère le titre et l\'image de la page. C\'est une requête depuis votre machine vers un site choisi par quelqu\'un d\'autre.', + 'settings_app.chat_encrypted_always': 'La discussion de ce groupe est toujours chiffrée. Cela ne peut pas être désactivé.', + 'settings_app.chat_rotate_epoch': 'Faire tourner la clé de discussion', + 'settings_app.chat_rotate_epoch_hint': 'Les membres continuent de lire tout l\'historique. Quiconque a été retiré du groupe ne peut plus lire ce qui sera écrit. Retirer un membre ou un appareil le fait déjà tout seul.', 'settings_app.tmdb_token_prompt': 'Créez un compte TMDB pour générer votre propre clé d\'API.', 'settings_app.tmdb_token_link': 'Obtenir une clé', 'settings_node.roots_offline_hint': 'Non connecté au nœud — les changements passent par le nœud local et prennent effet à son prochain rechargement.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 364c26c..f971320 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -650,6 +650,15 @@ export default { 'chat.unread': 'Nuovi messaggi', 'chat.jump_latest': 'Vai ai più recenti', 'chat.jump_new': 'Nuovi messaggi', + 'chat.unreadable': 'Impossibile leggere questo messaggio', + 'chat.unreadable_signature': 'Messaggio non verificato: la firma non corrisponde al mittente', + 'chat.unreadable_epoch': 'Scritto prima che questo dispositivo potesse leggere questa conversazione', + 'chat.unreadable_keys': 'Chiavi della chat non disponibili: riconnettiti per leggerlo', + 'chat.unreadable_decrypt': 'Impossibile decifrare questo messaggio', + 'chat.unreadable_envelope': 'Questo messaggio è arrivato incompleto', + 'chat.unreadable_format': 'Questo messaggio richiede una versione più recente di MeshBay', + 'chat.encrypted_needs_newer': 'Questa conversazione è cifrata e questo client non può leggerla: aggiorna MeshBay', + 'chat.encrypted_cannot_send': 'Questa conversazione è cifrata e questo dispositivo non può ancora scriverci', 'group.leave': 'Esci dal gruppo', 'group.leave_confirm': 'Uscire da «{name}»? Perderà l’accesso ai suoi file e alla chat. I file che ha caricato restano sul node, e il node mantiene l’identità che ha fissato per lei finché il suo operatore non la rimuove.', 'group.mute': 'Mute notifications', @@ -862,6 +871,9 @@ export default { 'settings_app.chat_no_writable_root': 'Questo gruppo non ha directory scrivibili, quindi gli allegati sono disattivati.', 'settings_app.chat_link_preview_label': 'Anteprime dei link', 'settings_app.chat_link_preview_hint': 'Quando un membro pubblica un link, il nodo recupera titolo e immagine della pagina. È una richiesta dalla tua macchina a un sito scelto da qualcun altro.', + 'settings_app.chat_encrypted_always': 'La chat di questo gruppo è sempre cifrata. Non può essere disattivata.', + 'settings_app.chat_rotate_epoch': 'Ruota la chiave della chat', + 'settings_app.chat_rotate_epoch_hint': 'I membri continuano a leggere tutta la cronologia. Chi è stato rimosso dal gruppo non potrà leggere ciò che verrà scritto d\'ora in poi. Rimuovere un membro o un dispositivo lo fa già da solo.', 'settings_app.tmdb_token_prompt': 'Registrati su TMDB per generare la tua chiave API.', 'settings_app.tmdb_token_link': 'Ottieni una chiave', 'settings_node.roots_offline_hint': 'Non connesso al nodo: le modifiche passano dal nodo locale e hanno effetto al successivo ricaricamento.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index f778fc4..0817608 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -638,6 +638,15 @@ export default { 'chat.unread': '新しいメッセージ', 'chat.jump_latest': '最新へ移動', 'chat.jump_new': '新しいメッセージ', + 'chat.unreadable': 'このメッセージを読み取れませんでした', + 'chat.unreadable_signature': '未検証のメッセージ — 署名が送信者と一致しません', + 'chat.unreadable_epoch': 'この端末がこの会話を読めるようになる前に書かれました', + 'chat.unreadable_keys': 'チャットの鍵を取得できません — 再接続してください', + 'chat.unreadable_decrypt': 'このメッセージを復号できませんでした', + 'chat.unreadable_envelope': 'このメッセージは不完全な状態で届きました', + 'chat.unreadable_format': 'このメッセージには新しいバージョンの MeshBay が必要です', + 'chat.encrypted_needs_newer': 'この会話は暗号化されており、このクライアントでは読めません — MeshBay を更新してください', + 'chat.encrypted_cannot_send': 'この会話は暗号化されており、この端末はまだ投稿できません', 'group.leave': 'グループを退出', 'group.leave_confirm': '「{name}」を退出しますか?ファイルとチャットへのアクセスがなくなります。アップロードしたファイルは node に残り、node は固定した識別情報を運営者が削除するまで保持します。', 'group.mute': 'Mute notifications', @@ -846,6 +855,9 @@ export default { 'settings_app.chat_no_writable_root': 'このグループには書き込み可能なディレクトリがないため、添付は無効です。', 'settings_app.chat_link_preview_label': 'リンクのプレビュー', 'settings_app.chat_link_preview_hint': 'メンバーがリンクを投稿すると、ノードがページのタイトルと画像を取得します。これは他人が選んだサイトへの、あなたのマシンからのリクエストです。', + 'settings_app.chat_encrypted_always': 'このグループのチャットは常に暗号化されています。無効にはできません。', + 'settings_app.chat_rotate_epoch': 'チャット鍵を更新する', + 'settings_app.chat_rotate_epoch_hint': 'メンバーは履歴全体を引き続き読めます。グループから外された相手は、これ以降に書かれた内容を読めません。メンバーや端末を削除すると、これは自動的に行われます。', 'settings_app.tmdb_token_prompt': 'TMDB に登録して、自分の API キーを発行してください。', 'settings_app.tmdb_token_link': 'キーを取得', 'settings_node.roots_offline_hint': 'ノードに接続していません — 変更はローカルノード経由で行われ、次回の再読み込みで反映されます。', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 6e6842e..25bf89d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -652,6 +652,15 @@ export default { 'chat.unread': 'Nieuwe berichten', 'chat.jump_latest': 'Naar de nieuwste', 'chat.jump_new': 'Nieuwe berichten', + 'chat.unreadable': 'Dit bericht kon niet worden gelezen', + 'chat.unreadable_signature': 'Niet-geverifieerd bericht — de handtekening past niet bij de afzender', + 'chat.unreadable_epoch': 'Geschreven voordat dit apparaat dit gesprek kon lezen', + 'chat.unreadable_keys': 'Chatsleutels niet beschikbaar — maak opnieuw verbinding om dit te lezen', + 'chat.unreadable_decrypt': 'Dit bericht kon niet worden ontsleuteld', + 'chat.unreadable_envelope': 'Dit bericht kwam onvolledig aan', + 'chat.unreadable_format': 'Dit bericht vereist een nieuwere versie van MeshBay', + 'chat.encrypted_needs_newer': 'Dit gesprek is versleuteld en deze client kan het niet lezen — werk MeshBay bij', + 'chat.encrypted_cannot_send': 'Dit gesprek is versleuteld en dit apparaat kan er nog niet in schrijven', 'group.leave': 'Groep verlaten', 'group.leave_confirm': '„{name}” verlaten? U verliest de toegang tot de bestanden en de chat ervan. Bestanden die u hebt geüpload blijven op de node, en de node houdt de voor u vastgezette identiteit tot zijn beheerder die weghaalt.', 'group.mute': 'Mute notifications', @@ -864,6 +873,9 @@ export default { 'settings_app.chat_no_writable_root': 'Deze groep heeft geen beschrijfbare map, dus bijlagen staan uit.', 'settings_app.chat_link_preview_label': 'Linkvoorbeelden', 'settings_app.chat_link_preview_hint': 'Als een lid een link plaatst, haalt de node de titel en afbeelding van de pagina op. Dat is een verzoek vanaf uw machine naar een site die iemand anders koos.', + 'settings_app.chat_encrypted_always': 'De chat van deze groep is altijd versleuteld. Dit kan niet worden uitgezet.', + 'settings_app.chat_rotate_epoch': 'Chatsleutel doordraaien', + 'settings_app.chat_rotate_epoch_hint': 'Leden blijven de hele geschiedenis lezen. Wie uit de groep is verwijderd, kan niet lezen wat er vanaf nu wordt geschreven. Een lid of apparaat verwijderen doet dit al vanzelf.', 'settings_app.tmdb_token_prompt': 'Meld u aan bij TMDB om uw eigen API-sleutel te maken.', 'settings_app.tmdb_token_link': 'Sleutel ophalen', 'settings_node.roots_offline_hint': 'Niet verbonden met de node — wijzigingen gaan via de lokale node en worden bij de volgende herlaadbeurt actief.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index c8cbd16..d63d4ae 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -670,6 +670,15 @@ export default { 'chat.unread': 'Nowe wiadomości', 'chat.jump_latest': 'Przejdź do najnowszych', 'chat.jump_new': 'Nowe wiadomości', + 'chat.unreadable': 'Nie udało się odczytać tej wiadomości', + 'chat.unreadable_signature': 'Niezweryfikowana wiadomość — podpis nie zgadza się z nadawcą', + 'chat.unreadable_epoch': 'Napisana, zanim to urządzenie mogło czytać tę rozmowę', + 'chat.unreadable_keys': 'Klucze czatu niedostępne — połącz się ponownie, aby to odczytać', + 'chat.unreadable_decrypt': 'Nie udało się odszyfrować tej wiadomości', + 'chat.unreadable_envelope': 'Ta wiadomość dotarła niekompletna', + 'chat.unreadable_format': 'Ta wiadomość wymaga nowszej wersji MeshBay', + 'chat.encrypted_needs_newer': 'Ta rozmowa jest zaszyfrowana i ten klient nie może jej odczytać — zaktualizuj MeshBay', + 'chat.encrypted_cannot_send': 'Ta rozmowa jest zaszyfrowana i to urządzenie nie może jeszcze w niej pisać', 'group.leave': 'Opuść grupę', 'group.leave_confirm': 'Opuścić grupę „{name}”? Utraci Pan(i) dostęp do jej plików i czatu. Wysłane pliki pozostaną na node, a node zachowa przypiętą dla Pana/Pani tożsamość do czasu, aż jego operator ją usunie.', 'group.mute': 'Mute notifications', @@ -890,6 +899,9 @@ export default { 'settings_app.chat_no_writable_root': 'Ta grupa nie ma katalogu do zapisu, więc załączniki są wyłączone.', 'settings_app.chat_link_preview_label': 'Podglądy linków', 'settings_app.chat_link_preview_hint': 'Gdy członek wysyła link, węzeł pobiera tytuł i obraz strony. To żądanie z Twojego komputera do witryny wybranej przez kogoś innego.', + 'settings_app.chat_encrypted_always': 'Czat w tej grupie jest zawsze szyfrowany. Nie da się tego wyłączyć.', + 'settings_app.chat_rotate_epoch': 'Zmień klucz czatu', + 'settings_app.chat_rotate_epoch_hint': 'Członkowie nadal czytają całą historię. Osoba usunięta z grupy nie odczyta tego, co zostanie napisane od teraz. Usunięcie członka lub urządzenia robi to już samo.', 'settings_app.tmdb_token_prompt': 'Zarejestruj się w TMDB, aby wygenerować własny klucz API.', 'settings_app.tmdb_token_link': 'Pobierz klucz', 'settings_node.roots_offline_hint': 'Brak połączenia z węzłem — zmiany przechodzą przez węzeł lokalny i zaczną działać po jego następnym przeładowaniu.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 6eb2242..f23f237 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -649,6 +649,15 @@ export default { 'chat.unread': 'Mensagens novas', 'chat.jump_latest': 'Ir para a mais recente', 'chat.jump_new': 'Mensagens novas', + 'chat.unreadable': 'Não foi possível ler esta mensagem', + 'chat.unreadable_signature': 'Mensagem não verificada — a assinatura não corresponde ao remetente', + 'chat.unreadable_epoch': 'Escrita antes de este dispositivo poder ler esta conversa', + 'chat.unreadable_keys': 'Chaves do chat indisponíveis — reconecte-se para ler isto', + 'chat.unreadable_decrypt': 'Não foi possível descriptografar esta mensagem', + 'chat.unreadable_envelope': 'Esta mensagem chegou incompleta', + 'chat.unreadable_format': 'Esta mensagem exige uma versão mais recente do MeshBay', + 'chat.encrypted_needs_newer': 'Esta conversa é criptografada e este cliente não consegue lê-la — atualize o MeshBay', + 'chat.encrypted_cannot_send': 'Esta conversa é criptografada e este dispositivo ainda não pode escrever nela', 'group.leave': 'Sair do grupo', 'group.leave_confirm': 'Sair de “{name}”? Você perderá o acesso aos arquivos e à conversa. Os arquivos que você enviou permanecem no node, e ele mantém a identidade que fixou para você até que o operador dele a remova.', 'group.mute': 'Mute notifications', @@ -849,6 +858,9 @@ export default { 'settings_app.chat_no_writable_root': 'Este grupo não tem diretório gravável, então os anexos estão desativados.', 'settings_app.chat_link_preview_label': 'Prévias de links', 'settings_app.chat_link_preview_hint': 'Quando alguém publica um link, o nó busca o título e a imagem da página. É uma requisição da sua máquina para um site escolhido por outra pessoa.', + 'settings_app.chat_encrypted_always': 'O chat deste grupo é sempre criptografado. Não é possível desativar.', + 'settings_app.chat_rotate_epoch': 'Girar a chave do chat', + 'settings_app.chat_rotate_epoch_hint': 'Os membros continuam lendo todo o histórico. Quem foi removido do grupo não conseguirá ler o que for escrito daqui em diante. Remover um membro ou dispositivo já faz isso sozinho.', 'settings_app.tmdb_token_prompt': 'Cadastre-se no TMDB para gerar sua própria chave de API.', 'settings_app.tmdb_token_link': 'Obter uma chave', 'settings_node.roots_offline_hint': 'Sem conexão com o nó — as alterações passam pelo nó local e entram em vigor no próximo recarregamento.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 044611e..cd0332c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -625,6 +625,15 @@ export default { 'chat.unread': '新消息', 'chat.jump_latest': '跳到最新', 'chat.jump_new': '新消息', + 'chat.unreadable': '无法读取此消息', + 'chat.unreadable_signature': '未验证的消息 — 签名与发送者不符', + 'chat.unreadable_epoch': '在此设备能够读取该对话之前写入', + 'chat.unreadable_keys': '聊天密钥不可用 — 请重新连接以读取', + 'chat.unreadable_decrypt': '无法解密此消息', + 'chat.unreadable_envelope': '此消息传输不完整', + 'chat.unreadable_format': '此消息需要更新版本的 MeshBay', + 'chat.encrypted_needs_newer': '该对话已加密,此客户端无法读取 — 请更新 MeshBay', + 'chat.encrypted_cannot_send': '该对话已加密,此设备尚无法在其中发言', 'group.leave': '退出群组', 'group.leave_confirm': '退出“{name}”?您将失去其文件和聊天的访问权。您上传的文件仍留在 node 上,node 也会保留它为您固定的身份,直到其运营者将其移除。', 'group.mute': 'Mute notifications', @@ -833,6 +842,9 @@ export default { 'settings_app.chat_no_writable_root': '该群组没有可写目录,因此附件已停用。', 'settings_app.chat_link_preview_label': '链接预览', 'settings_app.chat_link_preview_hint': '当成员发布链接时,节点会抓取该页面的标题和图片。这是从你的机器发往他人所选站点的请求。', + 'settings_app.chat_encrypted_always': '本群组的聊天始终加密,无法关闭。', + 'settings_app.chat_rotate_epoch': '更换聊天密钥', + 'settings_app.chat_rotate_epoch_hint': '成员仍可阅读全部历史记录。已被移出群组的人无法阅读此后写入的内容。移除成员或设备时已自动执行此操作。', 'settings_app.tmdb_token_prompt': '在 TMDB 注册以生成你自己的 API 密钥。', 'settings_app.tmdb_token_link': '获取密钥', 'settings_node.roots_offline_hint': '未连接到节点 — 变更将通过本地节点进行,并在其下次重新加载时生效。', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index d1d5732..a6a6e7c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -4216,3 +4216,16 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } .hw-item .icon { width: 15px; height: 15px; flex-shrink: 0; } .hw-name { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .hw-size { flex: 0 0 auto; color: var(--text-dim); font-size: 0.85em; } + +/* A chat message this client could not open. Deliberately visible rather than + hidden: a conversation quietly missing messages is worse than a gap, because + nobody can notice what they were never shown. */ +.chat-bubble-unreadable { + background: var(--bg-raised); + border: 1px dashed var(--border); + color: var(--text-secondary); +} +.chat-unreadable { + font-style: italic; + opacity: 0.75; +} 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. |