From f0248975908ad670fa8a820f865bf22ea8d0172d Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 13 Aug 2026 03:56:30 +0200 Subject: feat: Phase 12 — P2P crypto material, password split, node Ed25519 auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../meshbay-node/src/meshbay_node/hub_client.py | 130 ++++++--------------- 1 file changed, 36 insertions(+), 94 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/hub_client.py') diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py index ba9d3ff..432af0a 100644 --- a/packages/meshbay-node/src/meshbay_node/hub_client.py +++ b/packages/meshbay-node/src/meshbay_node/hub_client.py @@ -2,15 +2,15 @@ MeshBay Node — Hub client. Handles all communication from the node to a Mesh Hub: - - User registration (first run) - - Login → JWT (access token + refresh token) + - Ed25519 authentication (node-scoped JWT, no password material on node) - JWT offline verification and auto-refresh - Node announcement (endpoint_hint) - - GEK bundle retrieval for a group - User public key lookup (for GEK wrapping) + - Swarm hash registration -JWT verification is done locally using the hub's cached Ed25519 public key. -The hub is only contacted for login and refresh — not for every request. +The node authenticates via Ed25519 challenge-response (/v1/nodes/auth). +No auth_key or password is ever stored on or transmitted from the node. +The hub issues a node-scoped JWT that cannot manage group membership. """ import base64 @@ -23,10 +23,7 @@ from typing import Any, Callable import httpx import jwt -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey -from cryptography.hazmat.primitives import serialization -from meshbay_common.crypto import pk_to_b64, unwrap_gek from meshbay_node.keystore import NodeKeys log = logging.getLogger(__name__) @@ -62,7 +59,6 @@ class HubSession: class HubConfig: hub_url: str username: str - password: str cache_dir: Path = field(default_factory=lambda: Path.home() / ".config" / "meshbay") @property @@ -110,80 +106,54 @@ class HubClient: log.info("Hub PK fetched and cached: %s", cache) return pem - # ── Registration ────────────────────────────────────────────────────────── - - async def register(self) -> str: - """Register this node's user on the hub. Returns user_id. Idempotent (409 ok).""" - r = await self._http.post("/v1/users/register", json={ - "username": self._config.username, - "password": self._config.password, - "pk_user_ed25519": self._keys.pk_ed25519_b64, - "pk_user_x25519": self._keys.pk_x25519_b64, - }) - if r.status_code == 201: - log.info("Registered user '%s' on hub", self._config.username) - return r.json()["user_id"] - if r.status_code == 409: - log.debug("User '%s' already registered", self._config.username) - return "" - r.raise_for_status() - return "" - - # ── Login ───────────────────────────────────────────────────────────────── + # ── Ed25519 authentication ─────────────────────────────────────────────── async def login(self) -> HubSession: - """Login, verify JWT offline, return HubSession.""" + """Authenticate via Ed25519 challenge-response. Returns node-scoped HubSession.""" hub_pk_pem = await self._fetch_hub_pk() - r = await self._http.post("/v1/users/login", json={ - "username": self._config.username, - "password": self._config.password, + timestamp = int(time.time()) + message = f"meshbay:node_auth:{self._config.username}:{timestamp}".encode() + signature = self._keys.sk_ed25519.sign(message) + + r = await self._http.post("/v1/nodes/auth", json={ + "username": self._config.username, + "timestamp": timestamp, + "signature": base64.b64encode(signature).decode(), }) r.raise_for_status() data = r.json() - access_token = data["access_token"] - refresh_token = data["refresh_token"] + access_token = data["access_token"] - # Verify offline — if this passes, the hub's identity is confirmed decoded = jwt.decode(access_token, hub_pk_pem, algorithms=["EdDSA"]) assert decoded["pk_user"] == self._keys.pk_ed25519_b64, \ "Hub returned token for wrong public key" assert "jti" in decoded, "Hub token missing jti — hub is outdated" - - self._session = HubSession( - hub_url=self._config.hub_url, - username=self._config.username, - user_id=decoded["sub"], - access_token=access_token, - refresh_token=refresh_token, - hub_pk_pem=hub_pk_pem, - _token_exp=decoded["exp"], - ) + assert decoded.get("scope") == "node", \ + "Expected node-scoped token" + + if self._session: + self._session.access_token = access_token + self._session._token_exp = decoded["exp"] + else: + self._session = HubSession( + hub_url=self._config.hub_url, + username=self._config.username, + user_id=decoded["sub"], + access_token=access_token, + refresh_token="", + hub_pk_pem=hub_pk_pem, + _token_exp=decoded["exp"], + ) log.info("Logged in as '%s' (exp in %ds)", self._config.username, self._session.token_expires_in) return self._session - async def refresh_token(self) -> None: - """Refresh the access token using the refresh token.""" - if self._session is None: - raise RuntimeError("Not logged in") - - r = await self._http.post("/v1/users/token/refresh", json={ - "refresh_token": self._session.refresh_token, - }) - r.raise_for_status() - new_token = r.json()["access_token"] - - decoded = jwt.decode(new_token, self._session.hub_pk_pem, algorithms=["EdDSA"]) - self._session.access_token = new_token - self._session._token_exp = decoded["exp"] - log.debug("Access token refreshed (exp in %ds)", self._session.token_expires_in) - async def ensure_fresh_token(self) -> None: - """Auto-refresh token if close to expiry.""" + """Re-authenticate with Ed25519 if token is close to expiry.""" if self._session and self._session.token_needs_refresh: - await self.refresh_token() + await self.login() # ── Node announcement ───────────────────────────────────────────────────── @@ -203,33 +173,6 @@ class HubClient: log.info("Node announced: %s (hint=%s)", node_id[:8], endpoint_hint) return node_id - # ── GEK retrieval ───────────────────────────────────────────────────────── - - async def fetch_gek(self, group_id: str) -> bytes: - """ - Fetch and unwrap the GEK bundle for a group. - Returns the raw GEK bytes. - """ - if self._session is None: - raise RuntimeError("Not logged in") - await self.ensure_fresh_token() - - r = await self._http.get(f"/v1/groups/{group_id}/gek", - headers=self._session.auth_headers) - if r.status_code == 404: - raise LookupError(f"No GEK bundle found for group {group_id!r}") - r.raise_for_status() - - bundle = r.json() - sk_x_raw = self._keys.sk_x25519.private_bytes( - serialization.Encoding.Raw, serialization.PrivateFormat.Raw, - serialization.NoEncryption()) - pk_x_raw = base64.b64decode(self._keys.pk_x25519_b64) - - gek = unwrap_gek(bundle, sk_x_raw, pk_x_raw) - log.info("GEK unwrapped for group %s", group_id[:8]) - return gek - # ── User pubkey lookup ──────────────────────────────────────────────────── async def get_user_pubkeys(self, username: str) -> dict: @@ -354,10 +297,9 @@ class HubClient: async def startup(self, endpoint_hint: str | None = None) -> HubSession: """ - Full startup sequence: register (idempotent) → login → announce node. - Returns an active HubSession. + Full startup sequence: Ed25519 login → announce node. + The operator must register separately (browser or setup script). """ - await self.register() session = await self.login() await self.announce_node(endpoint_hint) return session -- cgit v1.2.3