aboutsummaryrefslogtreecommitdiffstats
path: root/poc/spike3_node.py
blob: 562239ff610ae9746603ce356d0ac59b607b2342 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
"""
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())