""" MeshBay Node POC v1 — Spike 3: Hub-Node registration and handshake. Sequence: 1. Load or generate node identity keypairs (Ed25519 + X25519) 2. Fetch and cache hub public key (first contact only) 3. Register user on hub (skip if already registered) 4. Login → receive access token (JWT) + refresh token 5. Verify JWT OFFLINE using hub public key — no hub roundtrip 6. Announce node to hub with endpoint hint 7. Retrieve node record from hub to confirm round-trip 8. Simulate token refresh """ import asyncio import base64 import json import time from pathlib import Path import httpx import jwt from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey HUB_URL = "http://meshbay.org" STATE_FILE = Path("node_state.json") # persists keys and tokens between runs PASS = "✓"; FAIL = "✗" # ── Key helpers ─────────────────────────────────────────────────────────────── def _sk_to_b64(sk) -> str: return base64.b64encode( sk.private_bytes(serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption()) ).decode() def _pk_to_b64(pk) -> str: return base64.b64encode( pk.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) ).decode() def _load_or_generate_keys(state: dict) -> tuple: """Return (sk_ed, sk_x) — load from state or generate fresh.""" if "sk_ed25519_b64" in state: sk_ed = Ed25519PrivateKey.from_private_bytes( base64.b64decode(state["sk_ed25519_b64"])) sk_x = X25519PrivateKey.from_private_bytes( base64.b64decode(state["sk_x25519_b64"])) print(f" Loaded existing keypair from {STATE_FILE}") else: sk_ed = Ed25519PrivateKey.generate() sk_x = X25519PrivateKey.generate() state["sk_ed25519_b64"] = _sk_to_b64(sk_ed) state["sk_x25519_b64"] = _sk_to_b64(sk_x) print(" Generated new keypair") return sk_ed, sk_x # ── Main ────────────────────────────────────────────────────────────────────── async def main(): print("\n=== MeshBay Node POC — Spike 3: Hub-Node Handshake ===\n") # Load persisted state (keys, tokens) or start fresh state = json.loads(STATE_FILE.read_text()) if STATE_FILE.exists() else {} # ── Step 1: Identity keypairs ───────────────────────────────────────────── print("[ 1 ] Identity keypairs") sk_ed, sk_x = _load_or_generate_keys(state) pk_ed_b64 = _pk_to_b64(sk_ed.public_key()) pk_x_b64 = _pk_to_b64(sk_x.public_key()) print(f" Ed25519 PK: {pk_ed_b64[:24]}...") print(f" X25519 PK: {pk_x_b64[:24]}...") async with httpx.AsyncClient(timeout=10) as client: # ── Step 2: Hub public key (cache after first fetch) ────────────────── print("\n[ 2 ] Hub public key") if "hub_pk_pem" not in state: r = await client.get(f"{HUB_URL}/v1/hub/pubkey") r.raise_for_status() state["hub_pk_pem"] = r.json()["pk_hub_pem"] print(f" {PASS} Fetched from hub and cached ({len(state['hub_pk_pem'])}B PEM)") else: print(f" Using cached hub PK ({len(state['hub_pk_pem'])}B) — no hub call") hub_pk_pem = state["hub_pk_pem"].encode() # ── Step 3: Register user ───────────────────────────────────────────── print("\n[ 3 ] User registration") username = state.get("username", "node_cbesson") password = state.get("password", "nodepass42!") state["username"] = username state["password"] = password r = await client.post(f"{HUB_URL}/v1/users/register", json={ "username": username, "password": password, "pk_user_ed25519": pk_ed_b64, "pk_user_x25519": pk_x_b64, }) if r.status_code == 201: state["user_id"] = r.json()["user_id"] print(f" {PASS} Registered — user_id={state['user_id'][:8]}...") elif r.status_code == 409: print(" Already registered (409) — continuing with existing account") else: raise RuntimeError(f"Register failed: {r.status_code} {r.text}") # ── Step 4: Login ───────────────────────────────────────────────────── print("\n[ 4 ] Login") r = await client.post(f"{HUB_URL}/v1/users/login", json={ "username": username, "password": password, }) r.raise_for_status() data = r.json() access_token = data["access_token"] refresh_token = data["refresh_token"] state["refresh_token"] = refresh_token print(f" {PASS} Login OK") print(f" access_token : {access_token[:40]}...") print(f" refresh_token : {refresh_token[:20]}...") print(f" expires_in : {data['expires_in']}s") # ── Step 5: Verify JWT OFFLINE ──────────────────────────────────────── print("\n[ 5 ] JWT offline verification (no hub call)") t0 = time.perf_counter() decoded = jwt.decode(access_token, hub_pk_pem, algorithms=["EdDSA"]) elapsed_us = (time.perf_counter() - t0) * 1_000_000 assert decoded["hub_id"] == "meshbay.org", "hub_id mismatch" assert decoded["pk_user"] == pk_ed_b64, "pk_user mismatch" assert decoded["exp"] > int(time.time()), "token already expired" print(f" {PASS} Signature valid") print(f" {PASS} hub_id = {decoded['hub_id']}") print(f" {PASS} sub = {decoded['sub'][:8]}...") print(f" {PASS} pk_user matches local Ed25519 public key") print(f" {PASS} Verified in {elapsed_us:.0f} µs — hub completely out of loop") # ── Step 6: Announce node ───────────────────────────────────────────── print("\n[ 6 ] Node announcement") headers = {"Authorization": f"Bearer {access_token}"} # For now, endpoint_hint = None (NAT traversal in Spike 4) r = await client.post(f"{HUB_URL}/v1/nodes/announce", json={"pk_node": pk_ed_b64, "endpoint_hint": None}, headers=headers) r.raise_for_status() node_id = r.json()["node_id"] state["node_id"] = node_id print(f" {PASS} Node announced — node_id={node_id[:8]}...") print(" endpoint_hint : None (STUN/UPnP discovery in Spike 4)") # ── Step 7: Retrieve node record ────────────────────────────────────── print("\n[ 7 ] Retrieve node record from hub") r = await client.get(f"{HUB_URL}/v1/nodes/{node_id}", headers=headers) r.raise_for_status() node = r.json() assert node["pk_node"] == pk_ed_b64, "pk_node mismatch" print(f" {PASS} node_id = {node['node_id'][:8]}...") print(f" {PASS} pk_node matches local Ed25519 public key") print(f" {PASS} endpoint_hint = {node['endpoint_hint']}") print(f" {PASS} username = {node['username']}") # ── Step 8: Token refresh ───────────────────────────────────────────── print("\n[ 8 ] Token refresh (simulate session renewal)") r = await client.post(f"{HUB_URL}/v1/users/token/refresh", json={"refresh_token": refresh_token}) r.raise_for_status() new_token = r.json()["access_token"] new_decoded = jwt.decode(new_token, hub_pk_pem, algorithms=["EdDSA"]) assert new_decoded["sub"] == decoded["sub"], "sub changed after refresh" assert new_token != access_token, "refresh should issue a new token" print(f" {PASS} New access token issued") print(f" {PASS} New token verified offline — same sub, new exp={new_decoded['exp']}") # Persist state for next spikes STATE_FILE.write_text(json.dumps(state, indent=2)) print(f"\n State saved to {STATE_FILE}") print("\n" + "="*55) print("Spike 3 COMPLETE — Hub-Node handshake fully validated.") print(f" Hub at {HUB_URL} is reachable and functional.") print(f" JWT offline verification: {elapsed_us:.0f} µs (no hub needed).") asyncio.run(main())