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-node/src/meshbay_node/ui/app.py | |
| 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-node/src/meshbay_node/ui/app.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ui/app.py | 163 |
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} — <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 > 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__} — localhost only — <a href="/api/status">status</a> · @@ -392,7 +523,33 @@ def _render_page(state: dict) -> str: — 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>""" |