aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-07 17:50:28 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-07 17:50:28 +0200
commit36cebf25d0e0f24cf63be4380ccb5d03da726a74 (patch)
tree8509ec4cf68a058f7383299e11bdea97ab06cadf /packages/meshbay-hub/src/meshbay_hub/static/crypto.js
parent8883d60d0afa2ed9dd1ef68bc21fe1b9a65a59ff (diff)
downloadmeshbay-36cebf25d0e0f24cf63be4380ccb5d03da726a74.tar.gz
feat(chat): encrypt group chat under per-device epoch keys (MNP 2.0)
Chat messages are sealed with AES-256-GCM under a key derived per group, per epoch, per *device*, and signed over the ciphertext with the device key the node pinned. The node relays and archives; it cannot read a message. There is no switch. MNP goes to 2.0 and MNP_MIN_SUPPORTED moves with it, so a 1.x peer is refused at the handshake with `version_too_old` rather than admitted and then unable to speak. An opt-in flag was designed and rejected: every node is a test node, so it would have bought nothing and left a plaintext branch reachable — C6's lesson one feature later. A test reads the source and refuses any code that consults a `chat_encrypted` setting. Not Sender Keys, and `senderkeys.py` is now documented as unused. With distribution under the group key and a node that serves history to devices which were not present, the node must retain each chain's earliest key, and a chain key at iteration i yields every message key from i on by pure HKDF — forward secrecy is zero either way. What the ratchet was left buying was stateful client code with silent failure modes, three of them reproduced: any member could sign as any other, a second device dropped the first's chain, and the skipped-key cache grew without bound. The reasoning is in docs/chat-sender-keys.md, which is the specification and the decision record. Epochs, not rotation: the epoch key is wrapped under the group key at delivery and never stored under it, so `gek_rotate` is a re-wrap. A group-key-derived archive key would have made every message ever sent unreadable on the first `member unpin`, which is the documented step after removing a member. A new epoch opens on member revoke/unpin, device revoke and `gek_rotate`; old epochs are kept and still delivered, so history stays readable to everyone who could already read it, and nothing anywhere deletes one. Three prerequisites this needed, each a live defect on its own: * The peer registry was keyed by user_id, so one account's second device evicted the first and the broadcast skipped recipients by account — a person's phone never saw what they typed on their laptop. * The handshake authenticated an account, never a device. `device_hello` (additive, signed, refused unless the key is a live device of this account in the node's own roster) is what lets the node refuse a member claiming somebody else's key. * `_admin_exec_file_delete` authorized against the exact uploading key, so device linking had already broken deleting your own file from your other device. It now authorizes against any non-revoked device of `uploader_id`. Found by driving the real panel over the real transport, not by reading source: `chat_keys_resp` was routed by arrival order and handed to an unanswered `media_meta_req` — the original frozen-tab defect in a message type that did not exist when that probe was written. And `_asText` had been deleted with an unrelated helper beside it; its only caller sits inside a promise the panel catches, so every conversation rendered empty with nothing in the console. Existing node data is migrated by QE/migration/migrate_chat_encryption.py (not versioned, per the QE rule), run with the node stopped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/crypto.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/crypto.js117
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,
};