/** * MeshBay Browser Crypto — AES-256-GCM private group decryption. * Uses WebCrypto SubtleCrypto API (available in all modern browsers). * * Handles groups with cipher="aes-256-gcm" (browser-accessible groups). * ChaCha20-Poly1305 groups (cipher="chacha20-poly1305") require the * native client (node) for decryption — not supported in browser. * * Usage: * const gek = await importGEK(gekB64); * const plaintext = await decryptChunkBin(gek, fileHashHex, chunkIndex, nonce, ct); */ const CIPHER_INFO_PREFIX = new TextEncoder().encode('file:'); const CIPHER_INFO_SUFFIX_AES = new TextEncoder().encode(':aes'); // ── Key derivation ──────────────────────────────────────────────────────────── /** * Import a raw GEK (base64) as a WebCrypto key for HKDF. * @param {string} gekB64 - base64-encoded GEK (32 bytes) * @returns {Promise} */ async function importGEK(gekB64) { const raw = b64decode(gekB64); return crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey', 'deriveBits']); } /** * Derive a per-chunk AES-256-GCM key from the GEK. * Mirrors meshbay_common/webcrypto.py::chunk_key_aes(). * * @param {CryptoKey} gek - HKDF key from importGEK() * @param {string} fileHashHex - blake3 hash of file (hex, 64 chars) * @param {number} chunkIndex * @returns {Promise} */ async function deriveChunkKey(gek, fileHashHex, chunkIndex) { // Build HKDF info: "file:" + file_hash_bytes + ":chunk:" + uint32be + ":aes" const fileHashBytes = hexToBytes(fileHashHex); const chunkIdxBytes = new Uint8Array(4); new DataView(chunkIdxBytes.buffer).setUint32(0, chunkIndex, false); // big-endian const infoParts = [ new TextEncoder().encode('file:'), fileHashBytes, new TextEncoder().encode(':chunk:'), chunkIdxBytes, new TextEncoder().encode(':aes'), ]; const info = concatBuffers(infoParts); return crypto.subtle.deriveKey( { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info }, gek, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'], ); } // ── Decryption ──────────────────────────────────────────────────────────────── // `decryptChunk` (base64) and `decryptFile` were removed on 2026-09-03. // // `decryptChunk` was the real path until Phase 9.15 moved chunks to a binary wire // format; `decryptChunkBin` below replaced it, and MNP 0.15 removed the last node // that could still emit the base64 shape (the QUIC encoder, left behind by 9.15). // // `decryptFile` was never called by anything, in any commit: it fetched // `${nodeUrl}/file/${id}/${chunk}?token=` in a loop — the node's unauthenticated // HTTP file API, which is finding C1 and was deleted in Phase 11.5. A client for an // endpoint that no longer exists, kept alive only by being exported. // ── Helpers ─────────────────────────────────────────────────────────────────── function b64decode(b64) { const binary = atob(b64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return bytes; } function hexToBytes(hex) { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); return bytes; } function concatBuffers(arrays) { const total = arrays.reduce((s, a) => s + a.byteLength, 0); const result = new Uint8Array(total); let offset = 0; for (const arr of arrays) { result.set(new Uint8Array(arr.buffer || arr), offset); offset += arr.byteLength; } return result; } async function decryptChunkBin(gek, fileHashHex, chunkIndex, nonce, ct) { const chunkKey = await deriveChunkKey(gek, fileHashHex, chunkIndex); const plaintext = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: nonce }, chunkKey, 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} 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() { return crypto.getRandomValues(new Uint8Array(32)); } async function wrapGEK(gek, pkXRaw) { const skEph = await crypto.subtle.generateKey({ name: 'X25519' }, true, ['deriveBits']); const pkEphRaw = new Uint8Array(await crypto.subtle.exportKey('raw', skEph.publicKey)); const pkRecip = await crypto.subtle.importKey('raw', pkXRaw, { name: 'X25519' }, false, []); const sharedBits = await crypto.subtle.deriveBits( { name: 'X25519', public: pkRecip }, skEph.privateKey, 256); const sharedKey = await crypto.subtle.importKey( 'raw', sharedBits, 'HKDF', false, ['deriveKey']); const wrapKey = await crypto.subtle.deriveKey( { name: 'HKDF', hash: 'SHA-256', salt: pkEphRaw, info: new TextEncoder().encode('meshbay:gek_wrap:v1:aes') }, sharedKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt']); const nonce = crypto.getRandomValues(new Uint8Array(12)); const ct = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: nonce, additionalData: pkXRaw }, wrapKey, gek); return { pk_eph_b64: btoa(String.fromCharCode(...pkEphRaw)), nonce_b64: btoa(String.fromCharCode(...nonce)), wrapped_b64: btoa(String.fromCharCode(...new Uint8Array(ct))), }; } async function unwrapGEK(bundle, skXPkcs8, pkXRaw) { const pkEphRaw = b64decode(bundle.pk_eph_b64); const nonce = b64decode(bundle.nonce_b64); const wrapped = b64decode(bundle.wrapped_b64); const skX = await crypto.subtle.importKey( 'pkcs8', skXPkcs8, { name: 'X25519' }, false, ['deriveBits']); const pkEph = await crypto.subtle.importKey( 'raw', pkEphRaw, { name: 'X25519' }, false, []); const sharedBits = await crypto.subtle.deriveBits( { name: 'X25519', public: pkEph }, skX, 256); const sharedKey = await crypto.subtle.importKey( 'raw', sharedBits, 'HKDF', false, ['deriveKey']); const wrapKey = await crypto.subtle.deriveKey( { name: 'HKDF', hash: 'SHA-256', salt: pkEphRaw, info: new TextEncoder().encode('meshbay:gek_wrap:v1:aes') }, sharedKey, { name: 'AES-GCM', length: 256 }, false, ['decrypt']); const plain = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: nonce, additionalData: pkXRaw }, wrapKey, wrapped); return new Uint8Array(plain); } // ── Chunk encryption (for upload) ──────────────────────────────────────────── async function encryptChunk(gek, fileHashHex, chunkIndex, plaintext) { const chunkKey = await deriveChunkKey(gek, fileHashHex, chunkIndex); const nonce = crypto.getRandomValues(new Uint8Array(12)); const ct = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: nonce }, chunkKey, plaintext); return { nonce, ct: new Uint8Array(ct) }; } function b64encode(bytes) { return btoa(String.fromCharCode(...bytes)); } // ── Admin operation transcript ─────────────────────────────────────────────── // Mirrors meshbay_common/adminop.py::admin_transcript(). Both sides build these // bytes independently; they are never taken off the wire. // // Finding H5: the client used to sign 32 raw random bytes chosen by the node — a // blind signing oracle. It now reconstructs a domain-separated, length-prefixed // transcript naming the operation, subject, node and group, so the UI can show the // user what they are authorizing and a signature cannot be reused elsewhere. const ADMIN_TRANSCRIPT_PREFIX = new TextEncoder().encode('meshbay:admin:v1'); function adminTranscript(op, nodePkB64, groupId, subject, nonceB64, ts) { const enc = new TextEncoder(); const fields = [ enc.encode(op), enc.encode(nodePkB64), enc.encode(groupId), enc.encode(subject), b64decode(nonceB64), enc.encode(String(ts)), ]; let total = ADMIN_TRANSCRIPT_PREFIX.length; for (const f of fields) total += 4 + f.length; const out = new Uint8Array(total); out.set(ADMIN_TRANSCRIPT_PREFIX, 0); let off = ADMIN_TRANSCRIPT_PREFIX.length; for (const f of fields) { new DataView(out.buffer).setUint32(off, f.length, false); off += 4; out.set(f, off); off += f.length; } return out; } // ── GEK proof (HMAC-SHA256 for handshake challenge) ───────────────────────── // Mirrors meshbay_common/handshake.py. Every field length-prefixed and the role // bound in, so a client proof can never be replayed as a node proof and a missing // fingerprint cannot silently degrade the proof to nonce-only (L4). const HANDSHAKE_PREFIX = new TextEncoder().encode('meshbay:mnp:handshake:v1'); function _lenPrefixed(parts) { let total = 0; for (const p of parts) total += 4 + p.length; const out = new Uint8Array(total); const view = new DataView(out.buffer); let off = 0; for (const p of parts) { view.setUint32(off, p.length, false); off += 4; out.set(p, off); off += p.length; } return out; } function webrtcBinding(offerFp, answerFp) { if (!offerFp || !offerFp.length || !answerFp || !answerFp.length) { throw new Error('Channel binding unavailable — refusing to handshake'); } return _lenPrefixed([offerFp, answerFp]); } function handshakeTranscript(role, groupId, nonceClient, nonceNode, binding) { const enc = new TextEncoder(); const body = _lenPrefixed([ enc.encode(role), enc.encode(groupId), nonceClient, nonceNode, binding, ]); const out = new Uint8Array(HANDSHAKE_PREFIX.length + body.length); out.set(HANDSHAKE_PREFIX, 0); out.set(body, HANDSHAKE_PREFIX.length); return out; } async function handshakeProof(gekRaw, role, groupId, nonceClient, nonceNode, binding) { const transcript = handshakeTranscript(role, groupId, nonceClient, nonceNode, binding); const key = await crypto.subtle.importKey( 'raw', gekRaw, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); const sig = await crypto.subtle.sign('HMAC', key, transcript); return new Uint8Array(sig); } // ── Join / pairing transcript ─────────────────────────────────────────────── // Mirrors meshbay_common/join.py. Signing both of our public keys together binds // the X25519 key to the Ed25519 identity the node pins, so the node can safely // wrap the group key for a key that came over the wire instead of one fetched // from the hub's directory (H3). nonce_node ties it to this connection. 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'); function joinTranscript(nodePkB64, groupId, userId, pkEdB64, pkXB64, nonceNode, ts) { const enc = new TextEncoder(); const body = _lenPrefixed([ enc.encode(nodePkB64), enc.encode(groupId), enc.encode(userId), enc.encode(pkEdB64), enc.encode(pkXB64), nonceNode, enc.encode(String(ts)), ]); const out = new Uint8Array(JOIN_PREFIX.length + body.length); out.set(JOIN_PREFIX, 0); out.set(body, JOIN_PREFIX.length); return out; } /** * Device linking transcripts, mirroring meshbay_common/device.py. * * Two signatures admit a device: the new one proves it holds the keys it is * presenting, and a key the node already pinned countersigns them. The hub can * produce neither — it has stored no user keys since 2026-08-14 — which is what * makes this safe to do without an operator. */ function deviceRequestTranscript(nodePkB64, userId, pkEdB64, pkXB64, codeHash, nonceNode, ts) { const enc = new TextEncoder(); const body = _lenPrefixed([ enc.encode(nodePkB64), enc.encode(userId), enc.encode(pkEdB64), enc.encode(pkXB64), enc.encode(codeHash), nonceNode, enc.encode(String(ts)), ]); const out = new Uint8Array(DEVICE_REQ_PREFIX.length + body.length); out.set(DEVICE_REQ_PREFIX, 0); out.set(body, DEVICE_REQ_PREFIX.length); return out; } function deviceAddTranscript(nodePkB64, userId, pkEdB64, pkXB64, nonceNode, ts) { const enc = new TextEncoder(); const body = _lenPrefixed([ enc.encode(nodePkB64), enc.encode(userId), enc.encode(pkEdB64), enc.encode(pkXB64), nonceNode, enc.encode(String(ts)), ]); const out = new Uint8Array(DEVICE_ADD_PREFIX.length + body.length); out.set(DEVICE_ADD_PREFIX, 0); out.set(body, DEVICE_ADD_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 * this code* rather than *this code*. That is what stops the node answering an * approver with a substituted key: the approver recomputes this from what they * typed and what they were handed, and a substitution finds nothing. Nothing * here rests on a human comparing digits. */ async function deviceCodeHash(code, pkEdB64, pkXB64) { const enc = new TextEncoder(); const payload = enc.encode([code, pkEdB64, pkXB64].join('\x1f')); const digest = await crypto.subtle.digest('SHA-256', payload); return Array.from(new Uint8Array(digest)) .map(b => b.toString(16).padStart(2, '0')).join(''); } /** Crockford folding, mirroring roster.normalize_code. */ function normalizeCode(code) { let out = ''; for (const ch of code.toUpperCase()) { if (ch === '-' || ch === ' ' || ch === '\t') continue; if (ch === 'I' || ch === 'L') out += '1'; else if (ch === 'O') out += '0'; else if (ch === 'U') out += 'V'; else out += ch; } return out; } function constantTimeEqual(a, b) { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; return diff === 0; } /** Verify the node's Ed25519 signature over the handshake transcript (C3). */ async function verifyNodeSignature(nodePkB64, sigB64, transcript) { const raw = b64decode(nodePkB64); const key = await crypto.subtle.importKey('raw', raw, { name: 'Ed25519' }, false, ['verify']); return crypto.subtle.verify('Ed25519', key, b64decode(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, deviceRequestTranscript, deviceAddTranscript, deviceCodeHash, normalizeCode, };