summaryrefslogtreecommitdiffstats
path: root/poc/spike3_node.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-09 03:52:58 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-09 03:52:58 +0200
commit271adc8504aad32075d75d06fd42023877a649ec (patch)
treefe8697761d635a5cac7e0693f2e588a38a7968b9 /poc/spike3_node.py
downloadmeshbay-271adc8504aad32075d75d06fd42023877a649ec.tar.gz
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) <noreply@anthropic.com>
Diffstat (limited to 'poc/spike3_node.py')
-rw-r--r--poc/spike3_node.py186
1 files changed, 186 insertions, 0 deletions
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())