diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-11 14:55:52 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-11 14:55:52 +0200 |
| commit | cc90dc943fcd0e7bbf52674fb3f95ff097026f4a (patch) | |
| tree | 6315a5e966c027c50b92d59e60c4b506a418143e /packages/meshbay-hub/src/meshbay_hub/static/crypto.js | |
| parent | edde9e441fb6b84e9d56215d6e2a8d9338b8f962 (diff) | |
| download | meshbay-cc90dc943fcd0e7bbf52674fb3f95ff097026f4a.tar.gz | |
feat: Phase 10b — Self-service UI (group create/join, upload, IndexedDB, search)
Six self-service features for the web SPA:
- Group creation UI with GEK auto-generation (AES-256-GCM ECIES)
- Member management + invite by username (GEK wrapping for invitee)
- Open group self-join flow (POST /v1/groups/{id}/join)
- File upload client→node (FILE_UPLOAD MNP type, .uploads/ staging)
- IndexedDB caching of group file indexes (instant display on revisit)
- Cross-group file search (SearchPage, pure client-side on cached indexes)
11 new tests (166 total): 8 group self-service + 3 AES GEK wrap/unwrap.
Co-Authored-By: Claude Opus 4.6 <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 | 79 |
1 files changed, 77 insertions, 2 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js index ee442fb..eb96eef 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js @@ -56,7 +56,7 @@ async function deriveChunkKey(gek, fileHashHex, chunkIndex) { gek, { name: 'AES-GCM', length: 256 }, false, - ['decrypt'], + ['encrypt', 'decrypt'], ); } @@ -158,5 +158,80 @@ async function decryptChunkBin(gek, fileHashHex, chunkIndex, nonce, 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)); +} + // Export for use in app.js -window.MeshBayCrypto = { importGEK, deriveChunkKey, decryptChunk, decryptChunkBin, decryptFile }; +window.MeshBayCrypto = { + importGEK, deriveChunkKey, decryptChunk, decryptChunkBin, decryptFile, + generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode, +}; |