aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-13 03:56:30 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-13 03:56:30 +0200
commitf0248975908ad670fa8a820f865bf22ea8d0172d (patch)
treef4af64d36cacaccb4f6d13436e001aeb57e861e3 /packages/meshbay-hub/src/meshbay_hub/static
parent35130e5528a52161630fd1c93572e1b2b7cd911b (diff)
downloadmeshbay-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.js549
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/crypto.js16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js127
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css192
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js178
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;