diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/crypto.js | 155 |
1 files changed, 155 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 new file mode 100644 index 0000000..7862283 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js @@ -0,0 +1,155 @@ +/** + * 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<CryptoKey>} + */ +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<CryptoKey>} + */ +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, + ['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<Uint8Array>} 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<Blob>} 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, 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; +} + +// Export for use in app.js +window.MeshBayCrypto = { importGEK, deriveChunkKey, decryptChunk, decryptFile }; |