diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-13 03:56:30 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-13 03:56:30 +0200 |
| commit | f0248975908ad670fa8a820f865bf22ea8d0172d (patch) | |
| tree | f4af64d36cacaccb4f6d13436e001aeb57e861e3 /packages/meshbay-hub/src/meshbay_hub/static | |
| parent | 35130e5528a52161630fd1c93572e1b2b7cd911b (diff) | |
| download | meshbay-f0248975908ad670fa8a820f865bf22ea8d0172d.tar.gz | |
feat: Phase 12 — P2P crypto material, password split, node Ed25519 auth
Baseline commit capturing in-progress Phase 12 work that was already present
in the working tree (uncommitted) before the Phase 11.5 security remediation
begins. Committed as-is, without review or modification, so that remediation
changes arrive as a separable diff.
Contents: BundleStore (P2P GEK + keypair bundles), password split
(auth_key / bundle_key), node Ed25519 auth (POST /v1/nodes/auth, node-scoped
JWT), GEK-HMAC handshake proof with DTLS channel binding, Ed25519 admin
challenge-response, node local admin UI rewrite, browser key persistence.
Not authored in this session — captured to establish a baseline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 549 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/crypto.js | 16 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/i18n.js | 19 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/keyderive.js | 127 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/style.css | 192 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 178 |
6 files changed, 906 insertions, 175 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index d8f9df5..ddff928 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -66,6 +66,58 @@ async function getAllCachedIndexes() { // ── Auth persistence ───────────────────────────────────────────────────────── let _sessionKeys = null; +let _bundleKey = null; +let _pendingBundlePush = null; + +function _openKeyDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open('meshbay_keys', 1); + req.onupgradeneeded = () => req.result.createObjectStore('k'); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} +async function _storeBundleKey(key) { + try { + const db = await _openKeyDB(); + const tx = db.transaction('k', 'readwrite'); + tx.objectStore('k').put(key, 'bk'); + await new Promise(r => { tx.oncomplete = r; }); + db.close(); + } catch {} +} +async function _loadBundleKey() { + try { + const db = await _openKeyDB(); + const tx = db.transaction('k', 'readonly'); + const g = tx.objectStore('k').get('bk'); + const val = await new Promise(r => { g.onsuccess = () => r(g.result); }); + db.close(); + return val || null; + } catch { return null; } +} +async function _clearKeyDB() { + try { + const db = await _openKeyDB(); + const tx = db.transaction('k', 'readwrite'); + tx.objectStore('k').clear(); + await new Promise(r => { tx.oncomplete = r; }); + db.close(); + } catch {} +} +function _saveSessionKeys() { + try { + if (_sessionKeys) sessionStorage.setItem('meshbay_sk', JSON.stringify(_sessionKeys)); + } catch {} +} +function _restoreSessionKeys() { + try { + if (!_sessionKeys) { + const sk = sessionStorage.getItem('meshbay_sk'); + if (sk) _sessionKeys = JSON.parse(sk); + } + } catch {} +} function loadAuth() { try { @@ -81,6 +133,10 @@ function saveAuth(auth) { } else { localStorage.removeItem(AUTH_KEY); _sessionKeys = null; + _bundleKey = null; + _pendingBundlePush = null; + _clearKeyDB(); + try { sessionStorage.removeItem('meshbay_sk'); } catch {} } } @@ -139,9 +195,74 @@ function navigate(path) { const AuthContext = createContext(null); function useAuth() { return useContext(AuthContext); } +// ── User Menu ──────────────────────────────────────────────────────────────── + +function UserMenu({ user, theme, onThemeChange, onLogout }) { + const [open, setOpen] = useState(false); + const [langOpen, setLangOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const close = (e) => { + if (ref.current && !ref.current.contains(e.target)) setOpen(false); + }; + document.addEventListener('click', close); + return () => document.removeEventListener('click', close); + }, [open]); + + const resolved = resolveTheme(theme); + + return html` + <div class="user-menu-wrap" ref=${ref}> + <button class="user-menu-trigger" onClick=${() => setOpen(o => !o)}> + <span class="user-menu-avatar">${user.username[0].toUpperCase()}</span> + <span class="user-menu-name">${user.username}</span> + <span class="user-menu-caret">${open ? '▴' : '▾'}</span> + </button> + ${open && html` + <div class="user-menu-dropdown"> + <div class="user-menu-header"> + <span class="user-menu-avatar lg">${user.username[0].toUpperCase()}</span> + <div> + <div class="user-menu-uname">${user.username}</div> + <div class="user-menu-role">${user.role || 'user'}</div> + </div> + </div> + <div class="user-menu-divider"></div> + <button class="user-menu-item" onClick=${(e) => { e.stopPropagation(); setLangOpen(o => !o); }}> + <span class="umi-icon">${'\u{1F310}'}</span> ${t('usermenu.language')} + <span class="umi-arrow">${langOpen ? '▴' : '▾'}</span> + </button> + ${langOpen && LOCALES.map(l => html` + <button key=${l.code} class="user-menu-item user-menu-sub" + onClick=${() => { setLocale(l.code); window.location.reload(); }}> + <span class="umi-icon">${l.flag}</span> ${l.name} + ${getLocale() === l.code && html`<span class="umi-check">${'✓'}</span>`} + </button> + `)} + <button class="user-menu-item" onClick=${() => { setOpen(false); navigate('/settings'); }}> + <span class="umi-icon">${'⚙'}</span> ${t('usermenu.settings')} + </button> + <button class="user-menu-item" onClick=${() => { + onThemeChange(resolved === 'dark' ? 'light' : 'dark'); + }}> + <span class="umi-icon">${resolved === 'dark' ? '☀' : '☾'}</span> + ${' '}${resolved === 'dark' ? t('usermenu.theme_light') : t('usermenu.theme_dark')} + </button> + <div class="user-menu-divider"></div> + <button class="user-menu-item user-menu-logout" onClick=${onLogout}> + <span class="umi-icon">${'⏻'}</span> ${t('usermenu.logout')} + </button> + </div> + `} + </div> + `; +} + // ── Nav ────────────────────────────────────────────────────────────────────── -function Nav({ user, theme, onThemeToggle, onLogout, onMenuToggle, unreadCount }) { +function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount }) { return html` <nav class="nav"> <div class="nav-left"> @@ -157,13 +278,9 @@ function Nav({ user, theme, onThemeToggle, onLogout, onMenuToggle, unreadCount } ${'\u{1F514}'}${unreadCount > 0 && html`<span class="notif-badge">${unreadCount}</span>`} </a> `} - <button class="nav-theme" onClick=${onThemeToggle} - aria-label="${t('nav.toggle_menu')}" title=${theme === 'dark' ? t('nav.light_mode') : t('nav.dark_mode')}> - ${theme === 'dark' ? '☀' : '☾'} - </button> ${user ? html` - <span class="nav-user">${user.username}</span> - <button class="nav-btn" onClick=${onLogout}>${t('nav.logout')}</button> + <${UserMenu} user=${user} theme=${theme} + onThemeChange=${onThemeChange} onLogout=${onLogout} /> ` : html` <a class="nav-btn" href="#/login">${t('nav.login')}</a> `} @@ -178,6 +295,22 @@ function Sidebar({ groups, route, menuOpen, role }) { const isStaff = role === 'moderator' || role === 'admin'; return html` <aside class="sidebar ${menuOpen ? 'open' : ''}"> + ${isStaff && html` + <div class="sidebar-section"> + <div class="sidebar-heading">${t('sidebar.admin')}</div> + <a class="sidebar-item ${route === '/admin' ? 'active' : ''}" + href="#/admin">${'\u{1F6E1}'} ${t('admin.title')}</a> + </div> + `} + <div class="sidebar-section"> + <div class="sidebar-heading">${t('sidebar.discover')}</div> + <a class="sidebar-item ${route === '/explore' ? 'active' : ''}" + href="#/explore">${t('sidebar.public_groups')}</a> + <a class="sidebar-item ${route === '/search' ? 'active' : ''}" + href="#/search">${t('sidebar.search')}</a> + <a class="sidebar-item ${route === '/create-group' ? 'active' : ''}" + href="#/create-group">${t('sidebar.create_group')}</a> + </div> <div class="sidebar-section"> <div class="sidebar-heading">${t('sidebar.my_groups')}</div> ${groups.length === 0 @@ -191,21 +324,6 @@ function Sidebar({ groups, route, menuOpen, role }) { `) } </div> - <div class="sidebar-section"> - <div class="sidebar-heading">${t('sidebar.discover')}</div> - <a class="sidebar-item ${route === '/explore' ? 'active' : ''}" - href="#/explore">${t('sidebar.public_groups')}</a> - <a class="sidebar-item ${route === '/search' ? 'active' : ''}" - href="#/search">${t('sidebar.search')}</a> - <a class="sidebar-item ${route === '/create-group' ? 'active' : ''}" - href="#/create-group">${t('sidebar.create_group')}</a> - <a class="sidebar-item ${route === '/settings' ? 'active' : ''}" - href="#/settings">${t('sidebar.settings')}</a> - ${isStaff && html` - <a class="sidebar-item ${route === '/admin' ? 'active' : ''}" - href="#/admin">${t('sidebar.admin')}</a> - `} - </div> </aside> `; } @@ -455,6 +573,7 @@ function ExplorePage({ token, myGroupIds }) { <div key=${g.id} class="group-card"> <a href="#/group/${g.id}" style="text-decoration:none;color:inherit"> <h3>${g.name}</h3> + ${g.description && html`<p class="group-card-desc">${g.description}</p>`} </a> <span class="badge">${g.join_policy}</span> ${g.source && g.source !== 'local' && html` @@ -484,6 +603,7 @@ function ExplorePage({ token, myGroupIds }) { function CreateGroupPage({ token, onCreated }) { const [name, setName] = useState(''); + const [description, setDescription] = useState(''); const [visibility, setVisibility] = useState('private'); const [joinPolicy, setJoinPolicy] = useState('invite'); const [error, setError] = useState(''); @@ -495,26 +615,12 @@ function CreateGroupPage({ token, onCreated }) { setLoading(true); setError(''); try { + const body = { name: name.trim(), visibility, join_policy: joinPolicy }; + if (description.trim()) body.description = description.trim().slice(0, 512); const data = await hubFetch('/v1/groups', { - method: 'POST', token, - body: { name: name.trim(), visibility, join_policy: joinPolicy }, + method: 'POST', token, body, }); - if (window.MeshBayCrypto) { - try { - const gek = window.MeshBayCrypto.generateGEK(); - const meResp = await hubFetch('/v1/users/me', { token }); - const pubkeys = await hubFetch(`/v1/users/${meResp.username}/pubkeys`); - const pkXBytes = Uint8Array.from(atob(pubkeys.pk_x25519), c => c.charCodeAt(0)); - const bundle = await window.MeshBayCrypto.wrapGEK(gek, pkXBytes); - await hubFetch(`/v1/groups/${data.group_id}/members/${meResp.username}/gek`, { - method: 'POST', token, body: bundle, - }); - } catch (e) { - console.warn('GEK wrap skipped:', e); - } - } - if (onCreated) onCreated(); navigate('/'); } catch (err) { @@ -525,31 +631,70 @@ function CreateGroupPage({ token, onCreated }) { }; return html` - <div class="page-center"> - <h2>${t('create_group.title')}</h2> - <p class="page-message" style="max-width:380px;text-align:center;margin-bottom:12px"> - ${t('create_group.hint')} - </p> - ${error && html`<p class="error-msg">${error}</p>`} - <form class="login-form" onSubmit=${onSubmit}> - <input type="text" placeholder="${t('create_group.name')}" - value=${name} onInput=${e => setName(e.target.value)} required /> - <label class="settings-label">${t('create_group.visibility')}</label> - <select class="settings-select" value=${visibility} - onChange=${e => setVisibility(e.target.value)}> - <option value="private">${t('create_group.private')}</option> - <option value="public">${t('create_group.public')}</option> - </select> - <label class="settings-label">${t('create_group.join_policy')}</label> - <select class="settings-select" value=${joinPolicy} - onChange=${e => setJoinPolicy(e.target.value)}> - <option value="invite">${t('create_group.invite')}</option> - <option value="open">${t('create_group.open')}</option> - </select> - <button type="submit" disabled=${loading}> - ${loading ? t('create_group.creating') : t('create_group.submit')} - </button> - </form> + <div class="create-group-page"> + <div class="create-group-card"> + <div class="create-group-header"> + <h2>${t('create_group.title')}</h2> + <p class="create-group-hint">${t('create_group.hint')}</p> + </div> + ${error && html`<div class="error-msg" style="margin-bottom:16px">${error}</div>`} + <form onSubmit=${onSubmit}> + <div class="form-field"> + <label class="form-label">${t('create_group.name')}</label> + <input type="text" placeholder="e.g. Family Photos, Project Files..." + value=${name} onInput=${e => setName(e.target.value)} required autofocus /> + </div> + + <div class="form-field"> + <label class="form-label">${t('create_group.description')}</label> + <textarea class="form-textarea" rows="3" maxlength="512" + placeholder="${t('create_group.description_hint')}" + value=${description} + onInput=${e => setDescription(e.target.value)} /> + <div class="form-char-count">${description.length}/512</div> + </div> + + <div class="form-field"> + <label class="form-label">${t('create_group.visibility')}</label> + <div class="option-cards"> + <button type="button" class="option-card ${visibility === 'private' ? 'selected' : ''}" + onClick=${() => setVisibility('private')}> + <span class="option-icon">🔒</span> + <span class="option-title">${t('create_group.private')}</span> + <span class="option-desc">Only invited members can see and access files</span> + </button> + <button type="button" class="option-card ${visibility === 'public' ? 'selected' : ''}" + onClick=${() => setVisibility('public')}> + <span class="option-icon">🌐</span> + <span class="option-title">${t('create_group.public')}</span> + <span class="option-desc">Anyone can discover and browse this group</span> + </button> + </div> + </div> + + <div class="form-field"> + <label class="form-label">${t('create_group.join_policy')}</label> + <div class="option-cards"> + <button type="button" class="option-card ${joinPolicy === 'invite' ? 'selected' : ''}" + onClick=${() => setJoinPolicy('invite')}> + <span class="option-icon">✉</span> + <span class="option-title">${t('create_group.invite')}</span> + <span class="option-desc">Members must be invited by an admin</span> + </button> + <button type="button" class="option-card ${joinPolicy === 'open' ? 'selected' : ''}" + onClick=${() => setJoinPolicy('open')}> + <span class="option-icon">🚪</span> + <span class="option-title">${t('create_group.open')}</span> + <span class="option-desc">Anyone can join without approval</span> + </button> + </div> + </div> + + <button class="create-group-submit" type="submit" disabled=${loading}> + ${loading ? t('create_group.creating') : t('create_group.submit')} + </button> + </form> + </div> </div> `; } @@ -617,7 +762,7 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk return results; } -function GroupPage({ groupId, group, token, username }) { +function GroupPage({ groupId, group, token, username, userId }) { const [status, setStatus] = useState('idle'); const [entries, setEntries] = useState([]); const [cached, setCached] = useState(false); @@ -632,10 +777,18 @@ function GroupPage({ groupId, group, token, username }) { const [tab, setTab] = useState('files'); const [uploading, setUploading] = useState(false); const [menuOpen, setMenuOpen] = useState(null); + const [isNodeAdmin, setIsNodeAdmin] = useState(false); const transportRef = useRef(null); const gekRef = useRef(null); useEffect(() => { + if (menuOpen === null) return; + const close = () => setMenuOpen(null); + document.addEventListener('click', close); + return () => document.removeEventListener('click', close); + }, [menuOpen]); + + useEffect(() => { let cancelled = false; getCachedGroupIndex(groupId).then(hit => { @@ -649,6 +802,8 @@ function GroupPage({ groupId, group, token, username }) { setStatus('discovering'); setError(''); gekRef.current = null; + if (!_bundleKey) _bundleKey = await _loadBundleKey(); + _restoreSessionKeys(); try { const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); if (cancelled) return; @@ -657,15 +812,65 @@ function GroupPage({ groupId, group, token, username }) { return; } + // Session keys for P2P GEK bundle fetch (node delivers wrapped GEK) + const sessionKeys = _sessionKeys ? { + skXB64: _sessionKeys.skXB64, + pkXB64: _sessionKeys.pkXB64, + } : null; + setStatus('connecting'); const nodeId = nodesData.nodes[0].node_id; const transport = new window.MeshBayTransport('', token); transportRef.current = transport; - await transport.connect(nodeId, token, groupId); + const ack = await transport.connect( + nodeId, token, groupId, null, sessionKeys, _bundleKey, username); if (cancelled) return; + setIsNodeAdmin(!!ack.is_node_admin); + + // If transport recovered different session keys from node during handshake + if (transport.sessionKeys) { + const recovered = transport.sessionKeys; + if (!_sessionKeys || recovered.skXB64 !== _sessionKeys.skXB64) { + _sessionKeys = recovered; + if (!_sessionKeys.pkXB64) { + const pubkeys = await hubFetch( + `/v1/users/${username}/pubkeys`, { token }); + _sessionKeys.pkXB64 = pubkeys.pk_x25519; + } + _pendingBundlePush = null; + try { localStorage.removeItem(`meshbay_kp_${username}`); } catch {} + _saveSessionKeys(); + } + } + + // Push keypair bundle to node (new registration, localStorage → node) + if (_pendingBundlePush && transport.connected) { + try { + await transport.storeKeypairBundle(_pendingBundlePush); + try { localStorage.removeItem(`meshbay_kp_${username}`); } catch {} + _pendingBundlePush = null; + } catch (e) { + console.warn('[MeshBay] Bundle push to node deferred:', e.message); + } + } + + // Import GEK from transport (fetched from node during handshake) + if (transport.gekRaw && window.MeshBayCrypto) { + gekRef.current = await window.MeshBayCrypto.importGEK( + window.MeshBayCrypto.b64encode(transport.gekRaw)); + } + setStatus('fetching'); + transport.onIndexSync = (msg) => { + if (cancelled) return; + const synced = msg.entries || []; + setEntries(synced); + setCached(false); + cacheGroupIndex(groupId, group ? group.name : groupId, synced); + }; + const indexMsg = await transport.fetchIndex(); if (cancelled) return; const freshEntries = indexMsg.entries || []; @@ -692,6 +897,7 @@ function GroupPage({ groupId, group, token, username }) { return () => { cancelled = true; if (transportRef.current) { + transportRef.current.onIndexSync = null; transportRef.current.close(); transportRef.current = null; } @@ -705,11 +911,6 @@ function GroupPage({ groupId, group, token, username }) { setDlState({ fileId: entry.id, name: entry.name, progress: 0, total: entry.size }); try { - if (!gekRef.current && window.MeshBayCrypto) { - const gekB64 = await transport.fetchGEK(); - gekRef.current = await window.MeshBayCrypto.importGEK(gekB64); - } - const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); let downloaded = 0; const onProgress = (bytes) => { @@ -764,8 +965,9 @@ function GroupPage({ groupId, group, token, username }) { const buf = new Uint8Array(await slice.arrayBuffer()); await transport.uploadChunk(file.name, i, totalChunks, buf); } + await new Promise(r => setTimeout(r, 2500)); const indexMsg = await transport.fetchIndex(); - setEntries(indexMsg.entries || []); + if (indexMsg.entries) setEntries(indexMsg.entries); } catch (err) { setError(err.message); } finally { @@ -777,7 +979,10 @@ function GroupPage({ groupId, group, token, username }) { const transport = transportRef.current; if (!transport || !transport.connected) return; try { - await transport.deleteFile(entry.id); + const signFn = (_sessionKeys && window.MeshBayKeys) + ? (challenge) => window.MeshBayKeys.signChallenge(_sessionKeys.skEdB64, challenge) + : null; + await transport.deleteFile(entry.id, signFn); const indexMsg = await transport.fetchIndex(); setEntries(indexMsg.entries || []); } catch (err) { @@ -846,12 +1051,30 @@ function GroupPage({ groupId, group, token, username }) { return html` <div> <div class="group-header"> - <h2>${group ? group.name : t('group.default_name')}</h2> + <div> + <h2 style="margin-bottom:${group && group.description ? '4px' : '0'}"> + ${group ? group.name : t('group.default_name')} + </h2> + ${group && group.description && html` + <p class="group-desc">${group.description}</p> + `} + </div> <span class="status-badge ${statusClass}"> ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html`<span class="spinner"></span>${' '}`} ${statusLabel} </span> + ${group && group.is_admin && html` + <button class="admin-btn danger" style="margin-left:auto" + onClick=${async () => { + if (!confirm(t('group.delete_group_confirm', { name: group.name }))) return; + try { + await hubFetch('/v1/groups/' + groupId, { method: 'DELETE', token }); + navigate('/'); + window.location.reload(); + } catch (err) { setError(err.message); } + }}>${t('group.delete_group')}</button> + `} </div> ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`} ${dlState && html` @@ -954,21 +1177,21 @@ function GroupPage({ groupId, group, token, username }) { setMenuOpen(null); if (e.type === 'video') setVideoEntry(e); else setPreviewEntry(e); - }}>${t('group.view')}</button> + }}><span class="fmi">${'\u{1F441}'}</span> ${t('group.view')}</button> `} <button onClick=${() => { setMenuOpen(null); downloadFile(e); }}> - ${t('group.download')} + <span class="fmi">${'\u{2B07}'}</span> ${t('group.download')} </button> ${e.type === 'video' && html` <button onClick=${() => { setMenuOpen(null); setVideoEntry(e); }}> - ${t('group.play')} + <span class="fmi">${'\u{25B6}'}</span> ${t('group.play')} </button> `} - ${group && group.is_admin && status === 'connected' && html` + ${(isNodeAdmin || (userId && e.uploader_id === userId)) && status === 'connected' && html` <button class="danger" onClick=${() => { setMenuOpen(null); if (confirm(t('group.delete_confirm', { name: e.name }))) deleteFile(e); - }}>${t('group.delete')}</button> + }}><span class="fmi">${'\u{1F5D1}'}</span> ${t('group.delete')}</button> `} </div> `} @@ -987,14 +1210,19 @@ function GroupPage({ groupId, group, token, username }) { ${tab === 'chat' && status === 'connected' && html` <${ChatPanel} transportRef=${transportRef} username=${username} - entries=${entries} gekRef=${gekRef} onRefreshIndex=${refreshIndex} /> + entries=${entries} gekRef=${gekRef} onRefreshIndex=${refreshIndex} + onPreview=${(entry) => { + if (entry.type === 'video') setVideoEntry(entry); + else setPreviewEntry(entry); + }} /> `} ${tab === 'chat' && status !== 'connected' && html` <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting')}</p> `} ${tab === 'members' && html` - <${MembersPanel} groupId=${groupId} group=${group} token=${token} /> + <${MembersPanel} groupId=${groupId} group=${group} token=${token} + transportRef=${transportRef} gekRef=${gekRef} /> `} `} ${status === 'offline' && html` @@ -1046,10 +1274,6 @@ function FilePreview({ entry, transportRef, gekRef, onClose }) { return; } try { - if (!gekRef.current && window.MeshBayCrypto) { - const gekB64 = await transport.fetchGEK(); - gekRef.current = await window.MeshBayCrypto.importGEK(gekB64); - } const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); let downloaded = 0; const chunks = await pipelinedDownload( @@ -1139,7 +1363,7 @@ function _b64ToU8(b64) { // ── Members Panel ──────────────────────────────────────────────────────── -function MembersPanel({ groupId, group, token }) { +function MembersPanel({ groupId, group, token, transportRef, gekRef }) { const [members, setMembers] = useState([]); const [adminId, setAdminId] = useState(''); const [loading, setLoading] = useState(true); @@ -1168,30 +1392,28 @@ function MembersPanel({ groupId, group, token }) { setInviting(true); setError(''); try { - const pubkeys = await hubFetch(`/v1/users/${inviteUser.trim()}/pubkeys`); + const transport = transportRef && transportRef.current; + const username = inviteUser.trim(); + + // Fetch invitee's public keys (hub = public key directory) + const pubkeys = await hubFetch(`/v1/users/${username}/pubkeys`, { token }); const pkXBytes = Uint8Array.from(atob(pubkeys.pk_x25519), c => c.charCodeAt(0)); - const gekB64 = await (async () => { - const transport = window._activeTransport; - if (transport && transport.connected) { - return await transport.fetchGEK(); - } - const bundleResp = await hubFetch(`/v1/groups/${groupId}/gek`, { token }); - if (!_sessionKeys) throw new Error('No session keys — log in via browser registration'); - const skXB64 = _sessionKeys.skXB64; - const skXRaw = Uint8Array.from(atob(skXB64), c => c.charCodeAt(0)); - const meResp = await hubFetch('/v1/users/me', { token }); - const myPubkeys = await hubFetch(`/v1/users/${meResp.username}/pubkeys`); - const myPkX = Uint8Array.from(atob(myPubkeys.pk_x25519), c => c.charCodeAt(0)); - const rawGek = await window.MeshBayCrypto.unwrapGEK(bundleResp, skXRaw, myPkX); - return btoa(String.fromCharCode(...rawGek)); - })(); + // Get raw GEK from the active transport connection + if (!transport || !transport.connected || !transport.gekRaw) { + throw new Error('Not connected to node or no GEK available'); + } + const gekBytes = transport.gekRaw; - const gekBytes = Uint8Array.from(atob(gekB64), c => c.charCodeAt(0)); + // Wrap GEK for invitee and store on node via P2P const bundle = await window.MeshBayCrypto.wrapGEK(gekBytes, pkXBytes); - await hubFetch(`/v1/groups/${groupId}/members/${inviteUser.trim()}/gek`, { - method: 'POST', token, body: bundle, + await transport.storeGekBundle(pubkeys.user_id, groupId, bundle); + + // Add member on hub (membership management only) + await hubFetch(`/v1/groups/${groupId}/members/${username}`, { + method: 'POST', token, body: {}, }); + setInviteUser(''); loadMembers(); } catch (err) { @@ -1263,19 +1485,17 @@ function _parsePayload(raw) { function ChatImage({ filename, entries, transportRef, gekRef }) { const [blobUrl, setBlobUrl] = useState(null); const [loading, setLoading] = useState(true); + const loadedRef = useRef(false); useEffect(() => { + if (loadedRef.current) return; let cancelled = false; const load = async () => { const transport = transportRef.current; - if (!transport || !transport.connected) { setLoading(false); return; } + if (!transport || !transport.connected) { setLoading(true); return; } const entry = entries.find(e => e.name === filename); - if (!entry) { setLoading(false); return; } + if (!entry) { setLoading(true); return; } try { - if (!gekRef.current && window.MeshBayCrypto) { - const gekB64 = await transport.fetchGEK(); - gekRef.current = await window.MeshBayCrypto.importGEK(gekB64); - } const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); const chunks = await pipelinedDownload(transport, gekRef.current, entry.id, totalChunks); if (cancelled) return; @@ -1283,13 +1503,14 @@ function ChatImage({ filename, entries, transportRef, gekRef }) { const mime = ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : ext === 'svg' ? 'image/svg+xml' : 'image/jpeg'; const blob = new Blob(chunks, { type: mime }); + loadedRef.current = true; setBlobUrl(URL.createObjectURL(blob)); } catch { /* ignore */ } if (!cancelled) setLoading(false); }; load(); return () => { cancelled = true; }; - }, [filename]); + }, [filename, entries.length]); useEffect(() => { return () => { if (blobUrl) URL.revokeObjectURL(blobUrl); }; @@ -1300,7 +1521,7 @@ function ChatImage({ filename, entries, transportRef, gekRef }) { return html`<img class="chat-att-thumb" src=${blobUrl} alt=${filename} />`; } -function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex }) { +function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, onPreview }) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [sending, setSending] = useState(false); @@ -1377,6 +1598,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex }) const buf = new Uint8Array(await slice.arrayBuffer()); await transport.uploadChunk(file.name, i, totalChunks, buf); } + await new Promise(r => setTimeout(r, 2500)); if (onRefreshIndex) await onRefreshIndex(); const ext = file.name.split('.').pop().toLowerCase(); const ftype = ['jpg','jpeg','png','gif','webp','svg'].includes(ext) ? 'image' @@ -1423,7 +1645,11 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex }) `} <div class="chat-bubble ${isOwn ? 'chat-bubble-own' : ''}"> ${att ? html` - <div class="chat-attachment"> + <div class="chat-attachment" style="cursor:pointer" onClick=${() => { + if (!onPreview) return; + const entry = entries.find(e => e.name === att.filename); + if (entry) onPreview(entry); + }}> ${att.type === 'image' ? html`<${ChatImage} filename=${att.filename} entries=${entries} transportRef=${transportRef} gekRef=${gekRef} />` @@ -1522,11 +1748,6 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) { }; const startStream = async () => { - if (!gekRef.current && window.MeshBayCrypto) { - const gekB64 = await transport.fetchGEK(); - gekRef.current = await window.MeshBayCrypto.importGEK(gekB64); - } - transport.onStreamInit = (msg) => { if (cancelled) return; const mime = `video/mp4; codecs="${msg.codec}"`; @@ -1744,6 +1965,18 @@ function SettingsPage({ user, theme, onThemeChange, groups }) { try { return JSON.parse(localStorage.getItem('mb_muted') || '{}'); } catch { return {}; } }); + const [nodeKey, setNodeKey] = useState(''); + const [currentNodeKey, setCurrentNodeKey] = useState(null); + const [nodeKeyStatus, setNodeKeyStatus] = useState(''); + const [nodeKeyLoading, setNodeKeyLoading] = useState(false); + + useEffect(() => { + hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token }) + .then(data => { + if (data.pk_node_ed25519) setCurrentNodeKey(data.pk_node_ed25519); + }) + .catch(() => {}); + }, [user.username, user.token]); const onLocaleChange = useCallback((e) => { const code = e.target.value; @@ -1764,6 +1997,26 @@ function SettingsPage({ user, theme, onThemeChange, groups }) { }); }, []); + const submitNodeKey = useCallback(async () => { + const key = nodeKey.trim(); + if (!key) return; + setNodeKeyLoading(true); + setNodeKeyStatus(''); + try { + await hubFetch('/v1/users/me/node_key', { + method: 'PUT', token: user.token, + body: { pk_node_ed25519: key }, + }); + setCurrentNodeKey(key); + setNodeKey(''); + setNodeKeyStatus(t('settings.node_key_success')); + } catch (e) { + setNodeKeyStatus(e.message); + } finally { + setNodeKeyLoading(false); + } + }, [nodeKey, user.token]); + return html` <div> <h2>${t('settings.title')}</h2> @@ -1781,6 +2034,32 @@ function SettingsPage({ user, theme, onThemeChange, groups }) { </div> <div class="settings-section"> + <h3 class="settings-heading">${t('settings.node_key')}</h3> + <p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px">${t('settings.node_key_desc')}</p> + ${currentNodeKey && html` + <div class="settings-row" style="margin-top:8px"> + <span class="settings-label">${t('settings.node_key_current')}</span> + <code class="settings-value" style="font-size:0.8em;word-break:break-all">${currentNodeKey}</code> + </div> + `} + <div style="display:flex;gap:8px;margin-top:10px;align-items:center"> + <input type="text" class="admin-search" style="flex:1;font-family:monospace;font-size:0.85em" + placeholder=${t('settings.node_key_placeholder')} + value=${nodeKey} onInput=${e => setNodeKey(e.target.value)} + onKeyDown=${e => e.key === 'Enter' && submitNodeKey()} /> + <button class="admin-btn" onClick=${submitNodeKey} + disabled=${nodeKeyLoading || !nodeKey.trim()}> + ${t('settings.node_key_submit')} + </button> + </div> + ${nodeKeyStatus && html` + <p style="margin-top:6px;font-size:0.85em;color:${nodeKeyStatus === t('settings.node_key_success') ? 'var(--green, #22c55e)' : 'var(--red, #ef4444)'}"> + ${nodeKeyStatus} + </p> + `} + </div> + + <div class="settings-section"> <h3 class="settings-heading">${t('settings.appearance')}</h3> <div class="settings-row"> <span class="settings-label">${t('settings.theme')}</span> @@ -1987,7 +2266,7 @@ function AdminPage({ token }) { <option value="admin">admin</option> </select> </td> - <td><span class="badge">${u.status}</span></td> + <td><span class="badge ${u.status === 'active' ? 'badge-ok' : u.status === 'suspended' ? 'badge-err' : ''}">${u.status}</span></td> <td>${new Date(u.created_at).toLocaleDateString()}</td> <td class="admin-actions"> <button class="admin-btn" onClick=${() => showUserDetail(u.id)}>${t('admin.btn_details')}</button> @@ -2024,7 +2303,7 @@ function AdminPage({ token }) { <td>${g.name}</td> <td><span class="badge">${g.visibility}</span></td> <td>${g.member_count}</td> - <td><span class="badge">${g.status}</span></td> + <td><span class="badge ${g.status === 'active' ? 'badge-ok' : g.status === 'suspended' ? 'badge-err' : ''}">${g.status}</span></td> <td>${new Date(g.created_at).toLocaleDateString()}</td> <td class="admin-actions"> ${g.status === 'active' @@ -2058,15 +2337,17 @@ function AdminPage({ token }) { <table class="admin-table"> <thead><tr> <th>${t('admin.col_time')}</th> + <th>${t('admin.col_user')}</th> <th>${t('admin.col_event')}</th> <th>${t('admin.col_ip')}</th> <th>${t('admin.col_detail')}</th> </tr></thead> <tbody> - ${logs.length === 0 && html`<tr><td colspan="4" class="admin-empty">${t('admin.no_logs')}</td></tr>`} + ${logs.length === 0 && html`<tr><td colspan="5" class="admin-empty">${t('admin.no_logs')}</td></tr>`} ${logs.map(lg => html` <tr key=${lg.id}> <td style="white-space:nowrap">${new Date(lg.timestamp).toLocaleString()}</td> + <td>${lg.username || ''}</td> <td><span class="badge">${lg.event}</span></td> <td>${lg.ip_address}</td> <td>${lg.detail || ''}</td> @@ -2208,8 +2489,8 @@ function App() { useEffect(() => { setMenuOpen(false); }, [route]); - const toggleTheme = useCallback(() => { - setTheme(prev => resolveTheme(prev) === 'dark' ? 'light' : 'dark'); + const changeTheme = useCallback((val) => { + setTheme(val); }, []); const authCtx = { @@ -2218,9 +2499,14 @@ function App() { let token, refreshToken; if (window.MeshBayKeys) { const data = await window.MeshBayKeys.loginAndRecover(username, password); - _sessionKeys = { skXB64: data.skXB64, skEdB64: data.skEdB64 }; token = data.accessToken; refreshToken = data.refreshToken; + _bundleKey = data.bundleKey; + await _storeBundleKey(_bundleKey); + if (data.skXB64) { + _sessionKeys = { skXB64: data.skXB64, skEdB64: data.skEdB64 }; + _pendingBundlePush = data.keypairBundleEnc; + } } else { const data = await hubFetch('/v1/users/login', { method: 'POST', @@ -2230,7 +2516,12 @@ function App() { refreshToken = data.refresh_token; } const me = await hubFetch('/v1/users/me', { token }); - const u = { username, token, refreshToken, role: me.role }; + if (_sessionKeys) { + const pubkeys = await hubFetch(`/v1/users/${username}/pubkeys`, { token }); + _sessionKeys.pkXB64 = pubkeys.pk_x25519; + _saveSessionKeys(); + } + const u = { username, userId: me.user_id, token, refreshToken, role: me.role }; setUser(u); saveAuth(u); }, @@ -2266,7 +2557,7 @@ function App() { const group = groups.find(g => g.id === groupId); page = html`<${GroupPage} groupId=${groupId} group=${group} token=${user.token} - username=${user.username} />`; + username=${user.username} userId=${user.userId} />`; } else if (route === '/admin') { page = (user.role === 'moderator' || user.role === 'admin') ? html`<${AdminPage} token=${user.token} />` @@ -2282,8 +2573,8 @@ function App() { <${AuthContext.Provider} value=${authCtx}> <${Nav} user=${user} - theme=${resolved} - onThemeToggle=${toggleTheme} + theme=${theme} + onThemeChange=${changeTheme} onLogout=${authCtx.logout} onMenuToggle=${() => setMenuOpen(o => !o)} unreadCount=${unreadCount} /> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js index eb96eef..5ebf624 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js @@ -230,8 +230,24 @@ function b64encode(bytes) { return btoa(String.fromCharCode(...bytes)); } +// ── GEK proof (HMAC-SHA256 for handshake challenge) ───────────────────────── + +async function hmacGEK(gekRaw, nonceB64, offerFp, answerFp) { + const nonce = b64decode(nonceB64); + const data = concatBuffers([ + nonce, + offerFp || new Uint8Array(0), + answerFp || new Uint8Array(0), + ]); + const key = await crypto.subtle.importKey( + 'raw', gekRaw, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + const sig = await crypto.subtle.sign('HMAC', key, data); + return b64encode(new Uint8Array(sig)); +} + // Export for use in app.js window.MeshBayCrypto = { importGEK, deriveChunkKey, decryptChunk, decryptChunkBin, decryptFile, generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode, + hmacGEK, }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index 2a91407..3450735 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -89,6 +89,8 @@ const en = { 'group.view': 'View', 'group.delete': 'Delete', 'group.delete_confirm': 'Delete {name}?', + 'group.delete_group': 'Delete group', + 'group.delete_group_confirm': 'Permanently delete "{name}" and remove all members? Files on the node are preserved.', 'group.err_transport': 'Transport module not loaded', // Status @@ -130,12 +132,25 @@ const en = { 'settings.role': 'Role', 'settings.groups': 'Group notifications', 'settings.notifications': 'Notifications', + 'settings.node_key': 'Link Node', + 'settings.node_key_desc': 'If you operate a MeshBay node, paste its Ed25519 public key here to link it to your account. You can find this key in your node\'s admin dashboard.', + 'settings.node_key_placeholder': 'Paste node Ed25519 public key (base64)', + 'settings.node_key_submit': 'Link Node', + 'settings.node_key_success': 'Node linked successfully', + 'settings.node_key_current': 'Linked node key', // Sidebar 'sidebar.create_group': 'Create group', 'sidebar.settings': 'Settings', 'sidebar.admin': 'Admin', + // User menu + 'usermenu.language': 'Language', + 'usermenu.settings': 'Settings', + 'usermenu.theme_light': 'Light mode', + 'usermenu.theme_dark': 'Dark mode', + 'usermenu.logout': 'Logout', + // Admin panel 'admin.title': 'Administration', 'admin.tab_stats': 'Stats', @@ -205,6 +220,8 @@ const en = { 'create_group.join_policy': 'Join policy', 'create_group.invite': 'Invite only', 'create_group.open': 'Open (anyone can join)', + 'create_group.description': 'Description', + 'create_group.description_hint': 'What is this group about? (optional)', 'create_group.submit': 'Create', 'create_group.hint': 'A group needs a node to host files. You can create the group now and connect a node later.', 'create_group.creating': 'Creating...', @@ -245,7 +262,7 @@ const _strings = { en }; let _locale = 'en'; export const LOCALES = [ - { code: 'en', name: 'English' }, + { code: 'en', name: 'English', flag: '\u{1F1EC}\u{1F1E7}' }, ]; export function getLocale() { return _locale; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js index 63baff5..ff3da33 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js @@ -23,6 +23,26 @@ const PBKDF2_ITERATIONS = 600000; // OWASP 2023 recommendation for PBKDF2-SHA512 const HUB = ''; // same origin +// ── Auth key derivation (password split) ────────────────────────────────────── + +/** + * Derive an auth key from password + username using PBKDF2-SHA512. + * This key is sent to the hub for authentication — the raw password never leaves the browser. + * Uses a different salt domain than deriveEncryptionKey (bundle key), so the two + * derived values are cryptographically independent. + */ +async function deriveAuthKey(password, username) { + const enc = new TextEncoder(); + const km = await crypto.subtle.importKey( + 'raw', enc.encode(password), 'PBKDF2', false, ['deriveBits']); + const salt = await crypto.subtle.digest( + 'SHA-256', enc.encode(`meshbay:auth:v1:${username}`)); + const bits = await crypto.subtle.deriveBits( + { name: 'PBKDF2', hash: 'SHA-512', salt, iterations: PBKDF2_ITERATIONS }, + km, 256); + return btoa(String.fromCharCode(...new Uint8Array(bits))); +} + // ── Key generation ──────────────────────────────────────────────────────────── /** @@ -105,20 +125,21 @@ async function decryptBundle(bundleB64, password, username) { * Full registration flow: * 1. Generate random keypairs * 2. Encrypt bundle with password - * 3. POST to hub (public keys + encrypted bundle) + * 3. POST to hub (public keys only — no keypair bundle) + * 4. Store encrypted bundle locally for backup to node on first connect * * Returns the raw private keys for immediate use after registration. */ async function registerUser(username, email, password) { const { skEdRaw, pkEdRaw, skXRaw, pkXRaw } = await generateKeypairs(); - // Convert SPKI public keys to raw 32-byte format expected by hub const pkEdCrypto = await crypto.subtle.importKey('spki', pkEdRaw, 'Ed25519', true, ['verify']); const pkXCrypto = await crypto.subtle.importKey('spki', pkXRaw, 'X25519', true, []); const pkEdBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkEdCrypto)); const pkXBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkXCrypto)); const encBundle = await encryptBundle(skEdRaw, skXRaw, password, username); + const authKey = await deriveAuthKey(password, username); const resp = await fetch(`${HUB}/v1/users/register`, { method: 'POST', @@ -126,39 +147,115 @@ async function registerUser(username, email, password) { body: JSON.stringify({ username, email, - password, + auth_key: authKey, pk_user_ed25519: btoa(String.fromCharCode(...pkEdBytes)), pk_user_x25519: btoa(String.fromCharCode(...pkXBytes)), - keypair_bundle: encBundle, // encrypted, hub stores but cannot read }), }); if (!resp.ok) throw new Error(`Registration failed: ${await resp.text()}`); - return { skEdRaw, skXRaw, pkEdBytes, pkXBytes }; + + // Store encrypted bundle locally — will be backed up to node on first group connect + try { localStorage.setItem(`meshbay_kp_${username}`, encBundle); } catch {} + + return { skEdRaw, skXRaw, pkEdBytes, pkXBytes, keypairBundleEnc: encBundle }; +} + +/** + * Decrypt a keypair bundle using a pre-derived AES-256 CryptoKey. + * Used when the bundle is fetched from the node (bundleKey was derived at login). + */ +async function decryptBundleWithKey(bundleB64, aesKey) { + const raw = Uint8Array.from(atob(bundleB64), c => c.charCodeAt(0)); + const nonce = raw.slice(0, 12); + const ct = raw.slice(12); + const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, aesKey, ct); + return JSON.parse(new TextDecoder().decode(plain)); } /** - * Login and recover private keys from the encrypted bundle. + * Login and recover private keys. + * + * If localStorage has a keypair bundle (new registration, not yet pushed to node), + * decrypts it and returns the keys + encrypted bundle for push to node. + * Otherwise returns bundleKey so the caller can fetch from node during handshake. */ async function loginAndRecover(username, password) { + const authKey = await deriveAuthKey(password, username); + const resp = await fetch(`${HUB}/v1/users/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }), + body: JSON.stringify({ username, auth_key: authKey }), }); + if (!resp.ok) throw new Error(`Login failed: ${await resp.text()}`); const data = await resp.json(); - const bundle = data.keypair_bundle; - if (!bundle) throw new Error('No keypair bundle in response — account may have been created via CLI'); - - const keys = await decryptBundle(bundle, password, username); - return { + const result = { accessToken: data.access_token, refreshToken: data.refresh_token, - skEdB64: keys.skEd, - skXB64: keys.skX, + bundleKey: await deriveEncryptionKey(password, username), }; + + // localStorage bundle = new registration, not yet pushed to node + const bundleEnc = (typeof localStorage !== 'undefined' + && localStorage.getItem(`meshbay_kp_${username}`)) || null; + + if (bundleEnc) { + const keys = await decryptBundle(bundleEnc, password, username); + result.skEdB64 = keys.skEd; + result.skXB64 = keys.skX; + result.keypairBundleEnc = bundleEnc; + } + + return result; +} + +async function regenerateKeys(token, username, password) { + const { skEdRaw, pkEdRaw, skXRaw, pkXRaw } = await generateKeypairs(); + + const pkEdCrypto = await crypto.subtle.importKey('spki', pkEdRaw, 'Ed25519', true, ['verify']); + const pkXCrypto = await crypto.subtle.importKey('spki', pkXRaw, 'X25519', true, []); + const pkEdBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkEdCrypto)); + const pkXBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkXCrypto)); + + const resp = await fetch(`${HUB}/v1/users/me/keys`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + pk_user_ed25519: btoa(String.fromCharCode(...pkEdBytes)), + pk_user_x25519: btoa(String.fromCharCode(...pkXBytes)), + }), + }); + + if (!resp.ok) throw new Error(`Key rotation failed: ${await resp.text()}`); + + const encBundle = await encryptBundle(skEdRaw, skXRaw, password, username); + try { localStorage.setItem(`meshbay_kp_${username}`, encBundle); } catch {} + + return { + skEdB64: btoa(String.fromCharCode(...new Uint8Array(skEdRaw))), + skXB64: btoa(String.fromCharCode(...new Uint8Array(skXRaw))), + pkEdB64: btoa(String.fromCharCode(...pkEdBytes)), + pkXB64: btoa(String.fromCharCode(...pkXBytes)), + keypairBundleEnc: encBundle, + }; +} + +async function signChallenge(skEdPkcs8B64, challengeB64) { + const skRaw = Uint8Array.from(atob(skEdPkcs8B64), c => c.charCodeAt(0)); + const sk = await crypto.subtle.importKey( + 'pkcs8', skRaw, { name: 'Ed25519' }, false, ['sign']); + const challenge = Uint8Array.from(atob(challengeB64), c => c.charCodeAt(0)); + const sig = await crypto.subtle.sign('Ed25519', sk, challenge); + return btoa(String.fromCharCode(...new Uint8Array(sig))); } -window.MeshBayKeys = { registerUser, loginAndRecover, generateKeypairs }; +window.MeshBayKeys = { + registerUser, loginAndRecover, regenerateKeys, generateKeypairs, signChallenge, + deriveAuthKey, decryptBundleWithKey, +}; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index f3dcbf4..e430201 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -95,20 +95,81 @@ a:hover { text-decoration: underline; } .nav-user { color: var(--text-dim); font-size: 0.9em; } -.nav-theme { - background: none; - border: 1px solid rgba(255, 255, 255, 0.15); - color: var(--nav-text); - width: 32px; - height: 32px; - border-radius: 6px; - cursor: pointer; - font-size: 1.1em; +/* ── User Menu ──────────────────────────────────────────────────────────── */ + +.user-menu-wrap { position: relative; } + +.user-menu-trigger { display: flex; align-items: center; - justify-content: center; + gap: 8px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + color: var(--nav-text); + padding: 4px 10px 4px 4px; + border-radius: 20px; + cursor: pointer; + font-size: 0.85em; + transition: background 0.12s; +} +.user-menu-trigger:hover { background: rgba(255, 255, 255, 0.15); } + +.user-menu-avatar { + width: 28px; height: 28px; + border-radius: 50%; + background: var(--accent); + color: #fff; + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 0.8em; + flex-shrink: 0; +} +.user-menu-avatar.lg { width: 36px; height: 36px; font-size: 0.95em; } + +.user-menu-name { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.user-menu-caret { font-size: 0.7em; color: rgba(255,255,255,0.5); } + +.user-menu-dropdown { + position: absolute; + right: 0; top: calc(100% + 6px); + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: var(--shadow-lg); + min-width: 220px; + z-index: 120; + overflow: hidden; + padding: 4px 0; } -.nav-theme:hover { border-color: rgba(255, 255, 255, 0.3); background: none; } + +.user-menu-header { + display: flex; align-items: center; gap: 10px; + padding: 12px 14px 8px; +} +.user-menu-uname { font-weight: 600; font-size: 0.9em; color: var(--text); } +.user-menu-role { font-size: 0.75em; color: var(--text-dim); text-transform: capitalize; } + +.user-menu-divider { height: 1px; background: var(--border); margin: 4px 0; } + +.user-menu-section-label { + padding: 6px 14px 2px; + font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.06em; + color: var(--text-dim); font-weight: 600; +} + +.user-menu-item { + display: flex; align-items: center; gap: 8px; + width: 100%; padding: 8px 14px; + background: none; border: none; border-radius: 0; + color: var(--text); font-size: 0.85em; + cursor: pointer; text-align: left; +} +.user-menu-item:hover { background: var(--bg-raised); } +.user-menu-logout { color: var(--error); } + +.umi-icon { width: 18px; text-align: center; font-size: 0.95em; flex-shrink: 0; } +.umi-arrow { margin-left: auto; font-size: 0.7em; color: var(--text-dim); } +.umi-check { margin-left: auto; color: var(--accent); font-size: 0.85em; } +.user-menu-sub { padding-left: 32px; } .nav-btn { background: rgba(255, 255, 255, 0.1); @@ -289,14 +350,17 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } border-radius: 12px; font-size: 0.75em; } +.badge-ok { background: #16a34a20; color: var(--success); } +.badge-err { background: var(--error-bg); color: var(--error); } /* ── Group page header ───────────────────────────────────────────────────── */ .group-header { display: flex; - align-items: center; + align-items: flex-start; gap: 12px; margin-bottom: 16px; + flex-wrap: wrap; } .group-header h2 { margin-bottom: 0; } @@ -688,8 +752,10 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } } .chat-bubble-own .chat-att-size { color: rgba(255, 255, 255, 0.6); } +.file-menu button { display: flex; align-items: center; gap: 8px; } .file-menu button.danger { color: var(--error); } .file-menu button.danger:hover { background: var(--error-bg); } +.fmi { font-size: 0.9em; width: 16px; text-align: center; flex-shrink: 0; } /* ── Download button + progress ──────────────────────────────────────────── */ @@ -1228,3 +1294,105 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } color: var(--text-secondary); white-space: nowrap; } + +/* ── Create Group page ───────────────────────────────────────────────────── */ + +.create-group-page { + display: flex; + justify-content: center; + padding-top: 8px; +} + +.create-group-card { + width: 100%; + max-width: 520px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 32px; + box-shadow: var(--shadow); +} + +.create-group-header { margin-bottom: 24px; } +.create-group-header h2 { margin-bottom: 8px; font-size: 1.3em; } +.create-group-hint { + color: var(--text-secondary); + font-size: 0.88em; + line-height: 1.5; +} + +.form-field { margin-bottom: 20px; } +.form-label { + display: block; + font-size: 0.82em; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 8px; +} + +.option-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } + +.option-card { + display: flex; flex-direction: column; align-items: center; gap: 4px; + padding: 14px 10px; + background: var(--bg-base); + border: 2px solid var(--border); + border-radius: 10px; + cursor: pointer; + text-align: center; + transition: border-color 0.12s, background 0.12s; +} +.option-card:hover { border-color: var(--accent); background: var(--bg-base); } +.option-card.selected { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 8%, var(--bg-base)); +} + +.option-icon { font-size: 1.5em; margin-bottom: 2px; } +.option-title { font-weight: 600; font-size: 0.9em; color: var(--text); } +.option-desc { font-size: 0.75em; color: var(--text-dim); line-height: 1.3; } + +.create-group-submit { + width: 100%; + padding: 12px; + font-size: 1em; + font-weight: 600; + margin-top: 8px; + border-radius: 8px; +} + +.form-textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-base); + color: var(--text); + font-size: 0.95em; + font-family: inherit; + resize: vertical; + min-height: 60px; + line-height: 1.5; +} +.form-textarea:focus { outline: none; border-color: var(--border-focus); } +.form-char-count { text-align: right; font-size: 0.75em; color: var(--text-dim); margin-top: 4px; } + +.group-desc { + font-size: 0.85em; + color: var(--text-secondary); + line-height: 1.4; + margin-bottom: 4px; +} + +.group-card-desc { + font-size: 0.8em; + color: var(--text-dim); + line-height: 1.3; + margin: 4px 0 6px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 1dc1ded..ca9c60e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -16,6 +16,16 @@ * transport.close(); */ +async function _pkFromSk(skPkcs8B64) { + const raw = Uint8Array.from(atob(skPkcs8B64), c => c.charCodeAt(0)); + const sk = await crypto.subtle.importKey('pkcs8', raw, { name: 'X25519' }, true, ['deriveBits']); + const jwk = await crypto.subtle.exportKey('jwk', sk); + const b64url = jwk.x; + const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/'); + const pad = b64.length % 4; + return pad ? b64 + '='.repeat(4 - pad) : b64; +} + class MeshBayTransport { constructor(hubUrl, accessToken) { this._hubUrl = hubUrl; @@ -30,6 +40,7 @@ class MeshBayTransport { this._onStreamInit = null; this._onStreamData = null; this._onStreamEnd = null; + this._onIndexSync = null; } get connected() { return this._connected; } @@ -38,8 +49,15 @@ class MeshBayTransport { set onStreamInit(fn) { this._onStreamInit = fn; } set onStreamData(fn) { this._onStreamData = fn; } set onStreamEnd(fn) { this._onStreamEnd = fn; } + set onIndexSync(fn) { this._onIndexSync = fn; } + + get sessionKeys() { return this._sessionKeys; } - async connect(nodeId, jwtToken, groupId) { + async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username) { + this._gekRaw = gekRaw || null; + this._sessionKeys = sessionKeys || null; + this._bundleKey = bundleKey || null; + this._username = username || null; this._pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], }); @@ -106,22 +124,94 @@ class MeshBayTransport { } const answer = await resp.json(); + this._rawAnswerSdp = answer.sdp; await this._pc.setRemoteDescription({ type: 'answer', sdp: answer.sdp }); await channelReady; - const ack = await this._sendAndWait({ + const reply = await this._sendAndWait({ type: 'handshake', v: '0.1', token: jwtToken, group_id: groupId || '', }); - if (ack.type !== 'handshake_ack') { - throw new Error('MNP handshake rejected: ' + (ack.detail || JSON.stringify(ack))); + if (reply.type === 'handshake_challenge') { + if (!window.MeshBayCrypto) { + throw new Error('Node requires GEK proof but no crypto available'); + } + + // Recover session keys from node if not available locally (P2P keypair bundle) + if (!this._sessionKeys && this._bundleKey && window.MeshBayKeys) { + const kpResp = await this._sendAndWait({ + type: 'keypair_bundle_fetch', v: '0.1', + }); + if (kpResp.type === 'keypair_bundle_resp' && kpResp.found) { + const keys = await window.MeshBayKeys.decryptBundleWithKey( + kpResp.bundle_enc, this._bundleKey); + const pkXB64 = await _pkFromSk(keys.skX); + this._sessionKeys = { skXB64: keys.skX, skEdB64: keys.skEd, pkXB64 }; + } + } + + // Fetch wrapped GEK bundle from node (P2P only — hub never touches crypto) + if (!gekRaw && this._sessionKeys) { + const bundleResp = await this._sendAndWait({ + type: 'gek_bundle_fetch', v: '0.1', + }); + if (bundleResp.type === 'gek_bundle_resp' && bundleResp.found) { + const skXRaw = Uint8Array.from(atob(this._sessionKeys.skXB64), c => c.charCodeAt(0)); + const myPkX = Uint8Array.from(atob(this._sessionKeys.pkXB64), c => c.charCodeAt(0)); + try { + gekRaw = await window.MeshBayCrypto.unwrapGEK(bundleResp, skXRaw, myPkX); + this._gekRaw = gekRaw; + } catch (e) { + console.warn('[MeshBay] GEK unwrap failed with local keys, trying node keypair bundle'); + if (this._bundleKey && window.MeshBayKeys) { + const kpResp = await this._sendAndWait({ + type: 'keypair_bundle_fetch', v: '0.1', + }); + if (kpResp.type === 'keypair_bundle_resp' && kpResp.found) { + const keys = await window.MeshBayKeys.decryptBundleWithKey( + kpResp.bundle_enc, this._bundleKey); + const pkXB64 = await _pkFromSk(keys.skX); + this._sessionKeys = { skXB64: keys.skX, skEdB64: keys.skEd, pkXB64 }; + const skXRaw2 = Uint8Array.from(atob(keys.skX), c => c.charCodeAt(0)); + const myPkX2 = Uint8Array.from(atob(pkXB64), c => c.charCodeAt(0)); + gekRaw = await window.MeshBayCrypto.unwrapGEK(bundleResp, skXRaw2, myPkX2); + this._gekRaw = gekRaw; + } + } + } + } + } + + if (!gekRaw) { + throw new Error('Node requires GEK proof but no GEK available'); + } + + let proof = ''; + if (gekRaw) { + const offerFp = _extractDtlsFingerprint(this._pc.localDescription.sdp); + const answerFp = _extractDtlsFingerprint(this._rawAnswerSdp); + proof = await window.MeshBayCrypto.hmacGEK(gekRaw, reply.nonce, offerFp, answerFp); + } + const ack = await this._sendAndWait({ + type: 'handshake_response', + v: '0.1', + proof, + }); + if (ack.type !== 'handshake_ack') { + throw new Error('GEK proof rejected: ' + (ack.detail || JSON.stringify(ack))); + } + return ack; } - return ack; + if (reply.type !== 'handshake_ack') { + throw new Error('MNP handshake rejected: ' + (reply.detail || JSON.stringify(reply))); + } + + return reply; } async fetchIndex() { @@ -130,12 +220,6 @@ class MeshBayTransport { return msg; } - async fetchGEK() { - const msg = await this._sendAndWait({ type: 'gek_req', v: '0.1' }); - if (msg.type === 'error') throw new Error(msg.detail); - return msg.gek_b64; - } - async fetchChunk(fileId, chunkIndex) { const msg = await this._sendAndWait({ type: 'file_req', @@ -182,13 +266,25 @@ class MeshBayTransport { return msg; } - async deleteFile(fileId) { + async deleteFile(fileId, signFn) { const msg = await this._sendAndWait({ type: 'file_delete', v: '0.1', file_id: fileId, }); if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + if (!signFn) throw new Error('Admin challenge received but no signing key available'); + const signature = await signFn(msg.challenge); + const ack = await this._sendAndWait({ + type: 'admin_response', + v: '0.1', + file_id: fileId, + signature, + }); + if (ack.type === 'error') throw new Error(ack.detail); + return ack; + } return msg; } @@ -208,6 +304,32 @@ class MeshBayTransport { return msg; } + async storeGekBundle(userId, groupId, bundle) { + const msg = await this._sendAndWait({ + type: 'gek_bundle_store', + v: '0.1', + user_id: userId, + group_id: groupId, + pk_eph_b64: bundle.pk_eph_b64, + nonce_b64: bundle.nonce_b64, + wrapped_b64: bundle.wrapped_b64, + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + async storeKeypairBundle(bundleEnc) { + const msg = await this._sendAndWait({ + type: 'keypair_bundle_store', + v: '0.1', + bundle_enc: bundleEnc, + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + get gekRaw() { return this._gekRaw; } + close() { if (this._channel) this._channel.close(); if (this._pc) this._pc.close(); @@ -226,6 +348,7 @@ class MeshBayTransport { reject(new Error('Response timeout')); }, 30000); this._pending.set(id, { + _reqType: obj.type, resolve: (msg) => { clearTimeout(timeout); this._pending.delete(id); resolve(msg); }, reject: (err) => { clearTimeout(timeout); this._pending.delete(id); reject(err); }, }); @@ -269,16 +392,25 @@ class MeshBayTransport { this._onChat(msg); return; } - if (msg.type === 'stream_init' && this._onStreamInit) { - this._onStreamInit(msg); + if (msg.type === 'stream_init') { + if (this._onStreamInit) this._onStreamInit(msg); + return; + } + if (msg.type === 'stream_data') { + if (this._onStreamData) this._onStreamData(msg); return; } - if (msg.type === 'stream_data' && this._onStreamData) { - this._onStreamData(msg); + if (msg.type === 'stream_end') { + if (this._onStreamEnd) this._onStreamEnd(msg); return; } - if (msg.type === 'stream_end' && this._onStreamEnd) { - this._onStreamEnd(msg); + + if (msg.type === 'index_sync' && msg.entries) { + if (this._onIndexSync) this._onIndexSync(msg); + const oldest = this._pending.entries().next(); + if (!oldest.done && oldest.value[1]._reqType === 'index_sync') { + oldest.value[1].resolve(msg); + } return; } @@ -485,5 +617,15 @@ function _b64decode(b64) { return bytes; } +function _extractDtlsFingerprint(sdp) { + const match = sdp.match(/a=fingerprint:sha-256 ([0-9A-Fa-f:]+)/); + if (!match) return new Uint8Array(0); + const hex = match[1].replace(/:/g, ''); + 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; +} + // Export window.MeshBayTransport = MeshBayTransport; |