diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-09 05:31:05 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-09 05:31:05 +0200 |
| commit | 3b2dd318477eb268e6821fb000aeadfe60d85987 (patch) | |
| tree | 0c46c3ea663490bbee847a0eec5a41c41ad6561b /packages/meshbay-hub/src/meshbay_hub/static/crypto.js | |
| parent | 6d64da7816aec2cc58bb1a6f0aceb9c9a921129f (diff) | |
| download | meshbay-3b2dd318477eb268e6821fb000aeadfe60d85987.tar.gz | |
feat: Phase 6 complete — chat, multi-group, federation, replication, webcrypto
6.1 Double Ratchet (meshbay_common/ratchet.py):
Forward secrecy, break-in recovery, out-of-order delivery.
Signal-spec KDF_RK/KDF_CK via HKDF-SHA256. 11/11 tests.
6.2 Multi-group node (config.py):
[[groups]] TOML array, per-group ports, back-compat [group].
6.3 MHP federation persistence (db/models.py FederatedGroup + SwarmSource):
receive_directory() now persists to federated_groups table.
list_public_groups() includes federated results with source attribution.
6.4 Content replication (node/replication.py + hub SwarmSource):
ContentReplicator: fetch-index, download, hash-verify, register-swarm.
Hub: POST /v1/swarm/register, GET /v1/swarm/{hash} for multi-source.
6.5 Browser private group (webcrypto.py + static/crypto.js):
AES-256-GCM variant of GEK for WebCrypto-compatible groups.
crypto.js: SubtleCrypto importGEK + deriveChunkKey + decryptChunk.
Keys distinct from ChaCha20 via :aes HKDF info suffix. 4/4 tests.
74/74 tests total.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/crypto.js')
| -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 }; |