aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
diff options
context:
space:
mode:
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,
};