aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-09 05:31:05 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-09 05:31:05 +0200
commit3b2dd318477eb268e6821fb000aeadfe60d85987 (patch)
tree0c46c3ea663490bbee847a0eec5a41c41ad6561b /packages/meshbay-hub/src/meshbay_hub/static/crypto.js
parent6d64da7816aec2cc58bb1a6f0aceb9c9a921129f (diff)
downloadmeshbay-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.js155
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 };