aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-03 16:16:55 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-03 16:16:55 +0200
commit675beed6ff688733a9598f9d82d41578f48316be (patch)
tree78dd4f8dff312f0ad99bd63bc679bf402591c5ed /packages/meshbay-hub/src/meshbay_hub/static/crypto.js
parent15087b0e8fdb872602310119f14680aaa443fd93 (diff)
downloadmeshbay-675beed6ff688733a9598f9d82d41578f48316be.tar.gz
feat!: MNP 1.0 — seal index and handshake_ack under the group key
`index_sync`, `index_delta` and the `handshake_ack` config payload now travel sealed under a GEK-derived subkey (`meshbay_common/groupbox.py`, mirrored by `sealGroup`/`openGroup` in `crypto.js`). Only `type`, `v`, `group_id` and the ack's `node_pk`/`proof`/`sig` stay in clear — a receiver must route and authenticate before it would trust a decryption. Verify, then decrypt. The ack line is integrity, not confidentiality: the signed handshake transcript names no ack field, so `is_node_admin`, `enabled_apps`, `video_root` and the rest were authenticated by the DTLS channel alone. The index line is defence in depth against a repeat of C1/C6 — a peer served before the handshake completes now gets ciphertext, not filenames. Nothing against an observer, the hub, or a member; that is the whole claim. `index_progress` stays clear (D3, counters only). Chat is out of scope. Failure is fatal: a payload that does not open ends the session naming the message type — never an empty index or an empty `enabled_apps`, both of which are legitimate states. Version negotiation ships here too (phase 15.6, brought forward): `v` + `v_min` on `handshake` and `handshake_challenge`, refused with `version_too_old` / `version_too_new` / `version_unreadable`. The flag day was already being paid for; the next breaking change now costs a refusal message. BREAKING CHANGE: breaks the WebRTC wire every deployed client speaks. Hub and every node must deploy together; the SPA is served by the hub, so a browser picks up the new client on reload. See MESHBAY_NODE_PROTOCOL.md §11.1a, §13.1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HkzbhmMmK8PqQBtGz5zCvY
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/crypto.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/crypto.js75
1 files changed, 75 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
index 69b8c7a..d2e19b7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
@@ -109,6 +109,80 @@ async function decryptChunkBin(gek, fileHashHex, chunkIndex, nonce, ct) {
return new Uint8Array(plaintext);
}
+// ── Sealing a payload under the group key ────────────────────────────────────
+//
+// Mirrors meshbay_common/groupbox.py. `index_sync`, `index_delta` and the
+// `handshake_ack` config payload travel sealed under a GEK-derived subkey; the
+// routing fields (type, v, group_id) and the ack's own authentication (node_pk,
+// proof, sig) stay in clear, because a receiver must route, version-check and
+// *authenticate* before it would trust a decryption.
+//
+// These take and return BYTES, not objects, and that is not an oversight:
+// msgpack here is a minimal hand-written codec private to transport.js, exported
+// to nothing (both files are classic scripts on globals, not ES modules). Making
+// this layer take objects would mean duplicating that codec or reaching across a
+// boundary that does not exist — both worse than one extra line at the call site.
+
+const GROUPBOX_INFO = {
+ index: new TextEncoder().encode('meshbay:index:v1'),
+ ack: new TextEncoder().encode('meshbay:ack:v1'),
+};
+
+/**
+ * Derive the AES-256-GCM subkey for one purpose.
+ * `salt: new Uint8Array(0)` matches Python's `salt=None` — RFC 5869 extracts with
+ * a zero key either way, which is what deriveChunkKey above already relies on.
+ */
+async function groupKey(gek, purpose, usages) {
+ const info = GROUPBOX_INFO[purpose];
+ if (!info) throw new Error(`unknown groupbox purpose: ${purpose}`);
+ const gekKey = gek instanceof CryptoKey
+ ? gek
+ : await crypto.subtle.importKey('raw', gek, 'HKDF', false, ['deriveKey']);
+ return crypto.subtle.deriveKey(
+ { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info },
+ gekKey,
+ { name: 'AES-GCM', length: 256 },
+ false,
+ usages,
+ );
+}
+
+/** What the ciphertext is bound to: this message type, in this group. */
+function groupAad(msgType, groupId) {
+ return new TextEncoder().encode(`${msgType}|${groupId}`);
+}
+
+/**
+ * Open a sealed payload. Throws on anything that does not open — a caller must
+ * never turn that into an empty index or an empty app list (groupbox.py's
+ * `unseal` says why at length).
+ * @returns {Promise<Uint8Array>} the msgpack bytes of the payload
+ */
+async function openGroup(gek, purpose, msgType, groupId, msg) {
+ if (!msg || !msg.nonce || !msg.ct) {
+ throw new Error(`${msgType}: not a sealed message`);
+ }
+ const key = await groupKey(gek, purpose, ['decrypt']);
+ const plain = await crypto.subtle.decrypt(
+ { name: 'AES-GCM', iv: msg.nonce, additionalData: groupAad(msgType, groupId) },
+ key, msg.ct);
+ return new Uint8Array(plain);
+}
+
+/**
+ * Seal payload bytes. Returns the `{nonce, ct}` pair to merge into a message.
+ */
+async function sealGroup(gek, purpose, msgType, groupId, plaintextBytes) {
+ const key = await groupKey(gek, purpose, ['encrypt']);
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
+ const ct = await crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv: nonce, additionalData: groupAad(msgType, groupId) },
+ key, plaintextBytes);
+ return { nonce, ct: new Uint8Array(ct) };
+}
+
+
// ── GEK generation + ECIES wrapping ──────────────────────────────────────────
function generateGEK() {
@@ -372,6 +446,7 @@ async function verifyNodeSignature(nodePkB64, sigB64, transcript) {
// Export for use in app.js
window.MeshBayCrypto = {
importGEK, deriveChunkKey, decryptChunkBin,
+ openGroup, sealGroup,
generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode,
adminTranscript, handshakeTranscript, handshakeProof, webrtcBinding,
joinTranscript, verifyNodeSignature, constantTimeEqual,