summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/hub_client.py
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/hub_client.py
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/hub_client.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/hub_client.py128
1 files changed, 35 insertions, 93 deletions
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"
+ assert decoded.get("scope") == "node", \
+ "Expected node-scoped token"
- 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"],
- )
+ 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