aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/ui
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-node/src/meshbay_node/ui
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-node/src/meshbay_node/ui')
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py163
1 files changed, 160 insertions, 3 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index 5e77ed8..b4885af 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -12,15 +12,17 @@ Served only on 127.0.0.1 — not exposed to the network.
No authentication required (localhost only).
"""
+import base64
import json
import logging
import time
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query
-from fastapi.responses import HTMLResponse
+from fastapi.responses import HTMLResponse, JSONResponse
from meshbay_node import __version__
+from meshbay_common.crypto import generate_gek, wrap_gek_aes
log = logging.getLogger(__name__)
@@ -52,6 +54,7 @@ def create_ui_app(state: dict) -> FastAPI:
"group_count": len(groups_ctx),
"total_files": total_files,
"webrtc_peers": webrtc.active_peers if webrtc else 0,
+ "pk_node_ed25519": state.get("pk_node_ed25519", ""),
}
@app.get("/api/groups")
@@ -167,6 +170,95 @@ def create_ui_app(state: dict) -> FastAPI:
],
}
+ # ── GEK initialization (operator only, localhost) ──────────────────────
+
+ @app.post("/api/groups/{group_id}/gek")
+ async def init_gek(group_id: str):
+ """Generate GEK, wrap for all group members, store, and activate."""
+ groups_ctx = state.get("groups_ctx", {})
+ if group_id not in groups_ctx:
+ return JSONResponse({"error": "Group not hosted on this node"}, 404)
+
+ hub = state.get("hub")
+ if not hub or not hub._session:
+ return JSONResponse({"error": "Hub not connected"}, 503)
+
+ bundle_store = state.get("bundle_store")
+ if not bundle_store:
+ return JSONResponse({"error": "Bundle store not available"}, 503)
+
+ await hub.ensure_fresh_token()
+ session = hub._session
+ members_resp = await hub._http.get(
+ f"/v1/groups/{group_id}/members",
+ headers=session.auth_headers,
+ )
+ if not members_resp.is_success:
+ return JSONResponse(
+ {"error": f"Failed to fetch members: {members_resp.status_code}"}, 502)
+ members = members_resp.json().get("members", [])
+ if not members:
+ return JSONResponse({"error": "No members in group"}, 400)
+
+ existing_gek = groups_ctx[group_id].get("gek")
+ gek = existing_gek or generate_gek()
+
+ wrapped_count = 0
+ errors = []
+ for member in members:
+ username = member["username"]
+ user_id = member["user_id"]
+ try:
+ pk_data = await hub.get_user_pubkeys(username)
+ pk_x_raw = base64.b64decode(pk_data["pk_x25519"])
+ bundle = wrap_gek_aes(gek, pk_x_raw)
+ await bundle_store.store(
+ group_id, user_id,
+ bundle["pk_eph_b64"], bundle["nonce_b64"], bundle["wrapped_b64"],
+ )
+ wrapped_count += 1
+ log.info("GEK wrapped for %s (%s)", username, user_id[:8])
+ except Exception as e:
+ errors.append(f"{username}: {e}")
+ log.warning("Failed to wrap GEK for %s: %s", username, e)
+
+ if wrapped_count == 0:
+ return JSONResponse(
+ {"error": "Failed to wrap GEK for any member", "details": errors}, 500)
+
+ # Also store a copy wrapped for the node keystore X25519 key
+ # so the daemon can reload GEK on restart without the operator's browser keys
+ config = state.get("config")
+ node_user_id = hub._session.user_id if hub._session else None
+ pk_x_node_raw = state.get("pk_x25519_raw")
+ if pk_x_node_raw and node_user_id:
+ try:
+ node_bundle = wrap_gek_aes(gek, pk_x_node_raw)
+ await bundle_store.store(
+ group_id, f"_node_{node_user_id}",
+ node_bundle["pk_eph_b64"], node_bundle["nonce_b64"],
+ node_bundle["wrapped_b64"],
+ )
+ log.info("GEK also wrapped for node keystore (daemon reload)")
+ except Exception as e:
+ log.warning("Failed to wrap GEK for node keystore: %s", e)
+
+ groups_ctx[group_id]["gek"] = gek
+ log.info("GEK initialized for group %s — wrapped for %d/%d members",
+ group_id[:8], wrapped_count, len(members))
+
+ webrtc = state.get("webrtc")
+ if webrtc and "groups" in webrtc._ctx and group_id in webrtc._ctx["groups"]:
+ webrtc._ctx["groups"][group_id]["gek"] = gek
+
+ return {
+ "status": "ok",
+ "group_id": group_id,
+ "wrapped_count": wrapped_count,
+ "total_members": len(members),
+ "errors": errors,
+ }
+
# ── Chat endpoints ───────────────────────────────────────────────────────
_chat_subscribers: list[WebSocket] = []
@@ -246,7 +338,10 @@ def _render_page(state: dict) -> str:
webrtc = state.get("webrtc")
total_files = sum(idx.count for idx in indexes.values())
peer_count = webrtc.active_peers if webrtc else 0
- status_color = {"running": "#22c55e", "error": "#ef4444"}.get(status, "#f59e0b")
+ status_color = {
+ "running": "#22c55e", "error": "#ef4444",
+ "waiting_for_node_key": "#f97316",
+ }.get(status, "#f59e0b")
# Groups section
groups_html = ""
@@ -269,13 +364,34 @@ def _render_page(state: dict) -> str:
f"<td>{_fmt_size(e.size)}</td><td>{e.path or '/'}</td></tr>"
)
+ has_gek = bool(ctx.get("gek"))
+ gek_badge = (
+ '<span class="badge" style="background:#22c55e">GEK active</span>'
+ if has_gek
+ else '<span class="badge" style="background:#ef4444">No GEK</span>'
+ )
+ gek_label = "Re-wrap GEK for all members" if has_gek else "Initialize GEK"
+ gek_color = "#3b82f6" if has_gek else "#22c55e"
+ gek_action = f"""
+ <div style="margin:10px 0">
+ <button onclick="initGEK('{gid}')"
+ id="gek-btn-{gid[:8]}"
+ style="padding:8px 16px;background:{gek_color};color:#fff;border:none;
+ border-radius:6px;cursor:pointer;font-size:0.85em">
+ {gek_label}
+ </button>
+ <span id="gek-status-{gid[:8]}" class="muted" style="margin-left:8px"></span>
+ </div>"""
+
groups_html += f"""
<div class="card">
<h3>{name}
<span class="badge" style="background:#6366f1">{vis}</span>
+ {gek_badge}
</h3>
<p><b>Directory:</b> <code>{shared}</code></p>
<p><b>Files:</b> {fcount} &mdash; <b>Total:</b> {_fmt_size(total_size)}</p>
+ {gek_action}
<p class="muted">ID: {gid}</p>
<details><summary>File list</summary>
<table>
@@ -382,6 +498,21 @@ def _render_page(state: dict) -> str:
<p><b>Node ID:</b> <code>{state.get("endpoint_hint") or "—"}</code></p>
</div>
+ <h2>Link Node to Hub Account</h2>
+ <div class="card">
+ <p>To connect to your group from a browser, link this node to your hub account.
+ Copy the key below and paste it in <b>Settings &gt; Link Node</b> on the hub.</p>
+ <div style="margin:12px 0;display:flex;align-items:center;gap:8px">
+ <code id="nodeKey" style="flex:1;padding:8px;word-break:break-all;background:var(--border);
+ border-radius:4px;font-size:0.9em;user-select:all">{state.get("pk_node_ed25519", "—")}</code>
+ <button onclick="navigator.clipboard.writeText(document.getElementById('nodeKey').textContent).then(()=>{{this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',2000)}})"
+ style="padding:8px 16px;background:var(--accent);color:#fff;border:none;border-radius:6px;
+ cursor:pointer;font-size:0.85em;white-space:nowrap">Copy</button>
+ </div>
+ <p class="muted">This is the node's Ed25519 public key. It's safe to share — it identifies
+ this node but cannot be used to impersonate it.</p>
+ </div>
+
<div class="footer">
MeshBay Node v{__version__} &mdash; localhost only &mdash;
<a href="/api/status">status</a> &middot;
@@ -392,7 +523,33 @@ def _render_page(state: dict) -> str:
&mdash; auto-refresh 10s
</div>
</div>
-<script>setTimeout(()=>location.reload(), 10000);</script>
+<script>
+async function initGEK(groupId) {{
+ const btn = document.getElementById('gek-btn-' + groupId.slice(0,8));
+ const status = document.getElementById('gek-status-' + groupId.slice(0,8));
+ if (btn) btn.disabled = true;
+ if (status) status.textContent = 'Initializing...';
+ try {{
+ const resp = await fetch('/api/groups/' + groupId + '/gek', {{ method: 'POST' }});
+ const data = await resp.json();
+ if (resp.ok) {{
+ if (status) status.textContent = 'GEK initialized — wrapped for '
+ + data.wrapped_count + '/' + data.total_members + ' members';
+ if (status) status.style.color = '#22c55e';
+ setTimeout(() => location.reload(), 2000);
+ }} else {{
+ if (status) status.textContent = data.error || 'Failed';
+ if (status) status.style.color = '#ef4444';
+ if (btn) btn.disabled = false;
+ }}
+ }} catch (e) {{
+ if (status) status.textContent = 'Error: ' + e.message;
+ if (status) status.style.color = '#ef4444';
+ if (btn) btn.disabled = false;
+ }}
+}}
+setTimeout(()=>location.reload(), 10000);
+</script>
</body>
</html>"""