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/tests/harness/chat_send_probe.py | |
| 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/tests/harness/chat_send_probe.py')
| -rw-r--r-- | packages/meshbay-hub/tests/harness/chat_send_probe.py | 95 |
1 files changed, 89 insertions, 6 deletions
diff --git a/packages/meshbay-hub/tests/harness/chat_send_probe.py b/packages/meshbay-hub/tests/harness/chat_send_probe.py index f1cc191..1f0557b 100644 --- a/packages/meshbay-hub/tests/harness/chat_send_probe.py +++ b/packages/meshbay-hub/tests/harness/chat_send_probe.py @@ -38,7 +38,32 @@ PORT = 8755 RECORDS = [] socketserver.TCPServer.allow_reuse_address = True -PAGE = r"""<!doctype html><html><head><meta charset=utf-8> +GROUP_ID = "g" * 32 +GEK = bytes.fromhex("5a" * 32) +EPOCH_KEY = bytes.fromhex("7c" * 32) + + +def _page() -> str: + """ + The page, with a real sealed `chat_keys_resp` baked in. + + Sealed here, by the shipped Python, rather than assembled in the browser: + msgpack is private to transport.js and exported to nothing, and a payload + the test built itself would prove only that the page agrees with the page. + """ + import msgpack + + from meshbay_common.groupbox import PURPOSE_CHAT_KEYS, seal + + sealed = seal(GEK, PURPOSE_CHAT_KEYS, "chat_keys_resp", GROUP_ID, + {"epochs": [{"epoch": 1, "key": EPOCH_KEY}], "current": 1}) + return (PAGE_TEMPLATE + .replace("__GROUP_ID__", GROUP_ID) + .replace("__GEK_HEX__", GEK.hex()) + .replace("__KEYS_NONCE_HEX__", sealed["nonce"].hex()) + .replace("__KEYS_CT_HEX__", sealed["ct"].hex())) + +PAGE_TEMPLATE = r"""<!doctype html><html><head><meta charset=utf-8> <link rel="stylesheet" href="/style.css"></head> <body> <div class="layout"><div class="main"> @@ -46,6 +71,13 @@ PAGE = r"""<!doctype html><html><head><meta charset=utf-8> <div class="group-tabs"><button class="group-tab active">Chat</button></div> <div id="root"></div> </div></div> +<!-- The two the real page loads and the transport reaches for by global: + `sealChat`/`openGroup` live in crypto.js, `signBytes` in keyderive.js. + Without them a send fails with "cannot read properties of undefined", + which is what this probe reported the first time it exercised the + encrypted path. --> +<script src="/crypto.js"></script> +<script src="/keyderive.js"></script> <script src="/transport.js"></script> <script type="module"> import { html, render, useRef } from '/vendor/htm-preact.js'; @@ -53,6 +85,13 @@ import { ChatPanel } from '/chat-app.js'; const log = []; window.addEventListener('error', e => log.push('error: ' + e.message)); +window.addEventListener('unhandledrejection', + e => log.push('rejected: ' + (e.reason && e.reason.message || e.reason))); +const _warn = console.warn, _err = console.error; +console.warn = (...a) => { log.push('warn: ' + a.join(' ')); _warn(...a); }; +console.error = (...a) => { log.push('console error: ' + a.join(' ')); _err(...a); }; + +const hex = (s) => Uint8Array.from(s.match(/../g) || [], b => parseInt(b, 16)); // The real transport, with only the channel replaced: _send takes the plain // object _sendAndWait built, so the framing and msgpack are the only things @@ -61,6 +100,25 @@ const tp = new window.MeshBayTransport('', 'token'); tp._connected = true; tp._channel = { readyState: 'open', send() {} }; +// Chat is encrypted (MNP 2.0), so a send that is going to come back has to +// seal and sign for real. The device key is generated here rather than stubbed +// — `signBytes` imports a pkcs8 key and WebCrypto will not be fooled — and the +// group key and epoch keys come from Python, which sealed the `chat_keys_resp` +// below exactly as the node does. So this exercises `chatKeys()`, `openGroup`, +// `sealChat` and the real signature, not a model of any of them. +tp._groupId = '__GROUP_ID__'; +tp._gekRaw = hex('__GEK_HEX__'); +tp.chatEpoch = 1; + +const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, + ['sign', 'verify']); +const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))); +tp._sessionKeys = { + skEdB64: b64(await crypto.subtle.exportKey('pkcs8', kp.privateKey)), +}; +// What `device_hello` sets on a live connection. +tp.devicePk = b64(await crypto.subtle.exportKey('raw', kp.publicKey)); + const now = Date.now() / 1000; const history = []; for (let i = 0; i < 5; i++) { @@ -76,18 +134,42 @@ for (let i = 0; i < 5; i++) { tp._send = (obj) => { log.push('sent ' + obj.type); if (obj.type === 'chat_hist') { - setTimeout(() => tp._dispatch( - { type: 'chat_hist_resp', v: '0.2', messages: history, has_more: false }), 10); + setTimeout(() => { + log.push('answering chat_hist'); + tp._dispatch({ type: 'chat_hist_resp', v: '0.2', messages: history, + has_more: false }); + }, 10); + } else if (obj.type === 'chat_keys_req') { + // Sealed under the group key, as `_do_chat_keys_req` sends it. + setTimeout(() => tp._dispatch({ + type: 'chat_keys_resp', v: '2.0', group_id: tp._groupId, + nonce: hex('__KEYS_NONCE_HEX__'), ct: hex('__KEYS_CT_HEX__'), + }), 10); } else if (obj.type === 'chat_msg') { - setTimeout(() => tp._dispatch({ type: 'ack', v: '0.14' }), 10); + // Recorded so the test can assert the message really was sealed and + // signed rather than sent in clear past a composer that let it through. + log.push('chat_msg format=' + obj.format + ' epoch=' + obj.epoch + + ' ct=' + (obj.ct ? obj.ct.length : 0) + + ' sig=' + (obj.sig ? obj.sig.length : 0) + + ' plaintextLeak=' + JSON.stringify(obj).includes('hello')); + setTimeout(() => tp._dispatch({ type: 'ack', v: '2.0' }), 10); } }; +// The panel swallows a send failure into `setInput(text)`, which is right for +// a person and useless for a probe: the symptom is the message not appearing, +// with no reason anywhere. Surfaced here so a failure names itself. +const _sendChat = tp.sendChat.bind(tp); +tp.sendChat = (...a) => _sendChat(...a).catch((e) => { + log.push('sendChat failed: ' + (e && e.message || e)); + throw e; +}); + function Host() { const transportRef = useRef(tp); const gekRef = useRef(null); return html`<${ChatPanel} transportRef=${transportRef} gekRef=${gekRef} - username="me" entries=${[]} status="connected" />`; + username="me" userId="user-me" entries=${[]} status="connected" />`; } render(html`<${Host} />`, document.getElementById('root')); @@ -99,6 +181,7 @@ function snap(label) { out.steps.push({ label, bubbles: document.querySelectorAll('.chat-bubble').length, + msgs: document.querySelectorAll('.chat-msg').length, lastText: [...document.querySelectorAll('.chat-text')].pop()?.textContent ?? null, // What a frozen tab actually is: the composer is disabled for as long as // a send is in flight. @@ -159,7 +242,7 @@ class H(http.server.BaseHTTPRequestHandler): def do_GET(self): if self.path == "/": - body, ctype = PAGE.encode(), "text/html; charset=utf-8" + body, ctype = _page().encode(), "text/html; charset=utf-8" else: path = (STATIC / self.path.lstrip("/")).resolve() if not str(path).startswith(str(STATIC)) or not path.is_file(): |