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/crypto.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/crypto.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/crypto.js | 117 |
1 files changed, 116 insertions, 1 deletions
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, }; |