aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-09 04:06:46 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-09 04:06:46 +0200
commitb92b076bed49da15ce1ba96d80eb84db539a0778 (patch)
tree28539f8fd9e7349a2f87dbe3aac6bc51598eedcd /packages/meshbay-node/src
parent6b6b1a9d2febaa75da7609db671a82a458b40b4b (diff)
downloadmeshbay-b92b076bed49da15ce1ba96d80eb84db539a0778.tar.gz
feat(node): add hub client with JWT offline verify and GEK fetch
Register/login/announce/token-refresh/GEK-unwrap. JWT jti and pk_user verified at login. Hub PK cached after first fetch. 6/6 tests passing with httpx.MockTransport (no network). Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src')
-rw-r--r--packages/meshbay-node/src/meshbay_node/hub_client.py256
1 files changed, 256 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py
new file mode 100644
index 0000000..d91b945
--- /dev/null
+++ b/packages/meshbay-node/src/meshbay_node/hub_client.py
@@ -0,0 +1,256 @@
+"""
+MeshBay Node — Hub client.
+
+Handles all communication from the node to a Mesh Hub:
+ - User registration (first run)
+ - Login → JWT (access token + refresh token)
+ - JWT offline verification and auto-refresh
+ - Node announcement (endpoint_hint)
+ - GEK bundle retrieval for a group
+ - User public key lookup (for GEK wrapping)
+
+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.
+"""
+
+import base64
+import json
+import logging
+import time
+from dataclasses import dataclass, field
+from pathlib import Path
+
+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__)
+
+TOKEN_REFRESH_MARGIN = 300 # refresh access token 5 min before expiry
+
+
+@dataclass
+class HubSession:
+ hub_url: str
+ username: str
+ user_id: str
+ access_token: str
+ refresh_token: str
+ hub_pk_pem: bytes # cached hub Ed25519 public key
+ node_id: str = ""
+ _token_exp: int = 0
+
+ @property
+ def auth_headers(self) -> dict:
+ return {"Authorization": f"Bearer {self.access_token}"}
+
+ @property
+ def token_expires_in(self) -> int:
+ return max(0, self._token_exp - int(time.time()))
+
+ @property
+ def token_needs_refresh(self) -> bool:
+ return self.token_expires_in < TOKEN_REFRESH_MARGIN
+
+
+@dataclass
+class HubConfig:
+ hub_url: str
+ username: str
+ password: str
+ cache_dir: Path = field(default_factory=lambda: Path.home() / ".config" / "meshbay")
+
+ @property
+ def hub_pk_cache_path(self) -> Path:
+ safe = self.hub_url.replace("://", "_").replace("/", "_").replace(":", "_")
+ return self.cache_dir / f"hub_pk_{safe}.pem"
+
+
+# ── Hub client ────────────────────────────────────────────────────────────────
+
+class HubClient:
+ """Async hub client. Use as async context manager or call close() explicitly."""
+
+ def __init__(self, config: HubConfig, keys: NodeKeys):
+ self._config = config
+ self._keys = keys
+ self._http = httpx.AsyncClient(timeout=15, base_url=config.hub_url)
+ self._session: HubSession | None = None
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *_):
+ await self.close()
+
+ async def close(self):
+ await self._http.aclose()
+
+ # ── Hub public key ────────────────────────────────────────────────────────
+
+ async def _fetch_hub_pk(self) -> bytes:
+ """Fetch and cache hub Ed25519 public key PEM."""
+ cache = self._config.hub_pk_cache_path
+ if cache.exists():
+ log.debug("Hub PK loaded from cache: %s", cache)
+ return cache.read_bytes()
+
+ r = await self._http.get("/v1/hub/pubkey")
+ r.raise_for_status()
+ pem = r.json()["pk_hub_pem"].encode()
+
+ cache.parent.mkdir(parents=True, exist_ok=True)
+ cache.write_bytes(pem)
+ 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 ─────────────────────────────────────────────────────────────────
+
+ async def login(self) -> HubSession:
+ """Login, verify JWT offline, return 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,
+ })
+ r.raise_for_status()
+ data = r.json()
+
+ access_token = data["access_token"]
+ refresh_token = data["refresh_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"],
+ )
+ 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."""
+ if self._session and self._session.token_needs_refresh:
+ await self.refresh_token()
+
+ # ── Node announcement ─────────────────────────────────────────────────────
+
+ async def announce_node(self, endpoint_hint: str | None = None) -> str:
+ """Announce this node to the hub. Returns node_id."""
+ if self._session is None:
+ raise RuntimeError("Not logged in")
+ await self.ensure_fresh_token()
+
+ r = await self._http.post("/v1/nodes/announce", json={
+ "pk_node": self._keys.pk_ed25519_b64,
+ "endpoint_hint": endpoint_hint,
+ }, headers=self._session.auth_headers)
+ r.raise_for_status()
+ node_id = r.json()["node_id"]
+ self._session.node_id = node_id
+ 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:
+ """Return {'pk_ed25519': str, 'pk_x25519': str} for a user."""
+ if self._session is None:
+ raise RuntimeError("Not logged in")
+ await self.ensure_fresh_token()
+
+ r = await self._http.get(f"/v1/users/{username}/pubkeys",
+ headers=self._session.auth_headers)
+ if r.status_code == 404:
+ raise LookupError(f"User not found: {username!r}")
+ r.raise_for_status()
+ return r.json()
+
+ # ── Convenience: full startup sequence ───────────────────────────────────
+
+ async def startup(self, endpoint_hint: str | None = None) -> HubSession:
+ """
+ Full startup sequence: register (idempotent) → login → announce node.
+ Returns an active HubSession.
+ """
+ await self.register()
+ session = await self.login()
+ await self.announce_node(endpoint_hint)
+ return session