From 271adc8504aad32075d75d06fd42023877a649ec Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 9 Aug 2026 03:52:58 +0200 Subject: chore: initialize monorepo structure for MeshBay 3-package layout: meshbay-common (shared crypto/protocol), meshbay-hub (FastAPI server), meshbay-node (local daemon). Includes validated POC spikes 1-6 in poc/, architecture drafts v1/v2 in docs/, and CLAUDE.md project conventions. All cryptographic primitives extracted from POC into meshbay_common/crypto.py (GEK wrap/unwrap, chunk key derivation, keystore encryption, chunk signing). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- poc/spike3_node.py | 186 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 poc/spike3_node.py (limited to 'poc/spike3_node.py') diff --git a/poc/spike3_node.py b/poc/spike3_node.py new file mode 100644 index 0000000..c0ddde0 --- /dev/null +++ b/poc/spike3_node.py @@ -0,0 +1,186 @@ +""" +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, httpx, base64, json, os, time +from pathlib import Path +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from cryptography.hazmat.primitives import serialization +import jwt + +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(f" 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(f" 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(f" 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()) -- cgit v1.2.3