/** * 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 decryptChunk(gek, fileHashHex, chunkIndex, nonceB64, ctB64); */ 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 ──────────────────────────────────────────────────────────────── /** * Decrypt one chunk of a private group file. * @param {CryptoKey} gek - from importGEK() * @param {string} fileHashHex * @param {number} chunkIndex * @param {string} nonceB64 - 12-byte nonce, base64 * @param {string} ctB64 - ciphertext + GCM tag, base64 * @returns {Promise} plaintext */ async function decryptChunk(gek, fileHashHex, chunkIndex, nonceB64, ctB64) { const chunkKey = await deriveChunkKey(gek, fileHashHex, chunkIndex); const nonce = b64decode(nonceB64); const ct = b64decode(ctB64); const plaintext = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: nonce }, chunkKey, ct, ); return new Uint8Array(plaintext); } /** * Decrypt a full file by fetching and decrypting all chunks in order. * @param {CryptoKey} gek * @param {string} nodeUrl - base URL of node HTTP API * @param {string} fileId - blake3 hex hash (= file_id in index) * @param {string} fileHashHex - same as fileId (blake3 of file content) * @param {string} jwtToken * @returns {Promise} decrypted file as Blob */ async function decryptFile(gek, nodeUrl, fileId, fileHashHex, jwtToken) { const chunks = []; let chunkIdx = 0; while (true) { const sep = nodeUrl.includes('?') ? '&' : '?'; const url = `${nodeUrl}/file/${fileId}/${chunkIdx}${sep}token=${jwtToken}`; const resp = await fetch(url); if (!resp.ok) break; const data = await resp.json(); if (data.encrypted === false) { // Public group: data_b64 is plaintext chunks.push(b64decode(data.data_b64)); } else { // Private group with AES-256-GCM const plain = await decryptChunk( gek, fileHashHex, chunkIdx, data.nonce_b64, data.ct_b64, ); chunks.push(plain); } if (data.plaintext_size < 1024 * 1024) break; // last chunk (< 1MB) chunkIdx++; } return new Blob(chunks); } // ── 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); } // ── 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'); 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; } 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, decryptChunk, decryptChunkBin, decryptFile, generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode, adminTranscript, handshakeTranscript, handshakeProof, webrtcBinding, joinTranscript, verifyNodeSignature, constantTimeEqual, };