aboutsummaryrefslogtreecommitdiffstats
path: root/docs/poc-v1.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/poc-v1.md')
-rw-r--r--docs/poc-v1.md767
1 files changed, 767 insertions, 0 deletions
diff --git a/docs/poc-v1.md b/docs/poc-v1.md
new file mode 100644
index 0000000..8f66159
--- /dev/null
+++ b/docs/poc-v1.md
@@ -0,0 +1,767 @@
+# MeshBay — POC v1
+
+> Goal: validate key concepts before committing to a full implementation.
+> Scope: Hub/Node exchange in Python, crypto stack, NAT traversal, encrypted file chunk transfer.
+> Everything in-memory (no database), minimal code, TCP only (no QUIC yet).
+
+---
+
+## Environment
+
+### Remote — meshbay.org (Hub)
+- OVH VPS, Ubuntu 26.04 LTS, Python 3.14.4
+- Public fixed IP, ports 80 and 443 open
+- Clean slate: no web server installed
+- SSH access: `ssh cbesson@meshbay.org`
+
+### Local — Fedora 44 (Node)
+- Laptop behind SFR residential NAT (likely Restricted Cone NAT — UPnP supported)
+- Python 3.13+ via system packages
+- User: `cbesson` (sudoer, no password)
+
+---
+
+## Python Dependencies
+
+```bash
+# Shared (hub and node)
+cryptography>=43.0 # Ed25519, X25519, ChaCha20-Poly1305, Argon2id
+PyJWT>=2.9 # JWT with EdDSA (Ed25519) support
+blake3>=1.0 # Fast content hashing
+
+# Hub only (meshbay.org)
+fastapi>=0.115
+uvicorn[standard]>=0.30
+
+# Node only (Fedora laptop)
+httpx>=0.28 # Async HTTP client for hub→node calls
+aioice>=0.9 # STUN queries for NAT discovery
+miniupnpc>=2.2 # UPnP port mapping on SFR box
+```
+
+Install on each machine:
+```bash
+python3 -m venv .venv
+source .venv/bin/activate
+pip install <packages above>
+```
+
+---
+
+## Hub Setup on meshbay.org
+
+For the POC, uvicorn runs directly on port 80 via iptables redirect (no Caddy/nginx needed yet — HTTPS added before production).
+
+```bash
+# On meshbay.org
+# Redirect port 80 → 8000 (persistent via iptables-save if needed)
+sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8000
+
+# Run hub (from poc directory, venv activated)
+uvicorn hub:app --host 127.0.0.1 --port 8000 --reload
+```
+
+> Note: HTTPS (via Caddy + Let's Encrypt) is mandatory before any data beyond this POC. Not in scope here.
+
+---
+
+## Spike Overview
+
+| # | Name | Where | Validates | Duration |
+|---|---|---|---|---|
+| 1 | Crypto primitives | Local | Python crypto stack covers all needs | ~1h |
+| 2 | Hub skeleton | meshbay.org | Hub API, JWT issuance | ~2h |
+| 3 | Node registration | Fedora | Hub-Node handshake, JWT offline verify | ~1h |
+| 4 | NAT traversal | Both | SFR box UPnP + STUN, P2P reachability | ~2h |
+| 5 | Encrypted transfer | Both | On-the-fly GEK encryption, P2P chunk | ~2h |
+
+---
+
+## Spike 1 — Crypto Primitives (local only)
+
+**Goal:** confirm `cryptography` (PyCA) covers all MeshBay cryptographic needs without gaps or performance surprises.
+
+**File:** `spike1_crypto.py`
+
+**What to test:**
+
+```python
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
+from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
+from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+from cryptography.hazmat.primitives.kdf.argon2 import Argon2id # PyCA 43+
+from cryptography.hazmat.primitives import hashes, serialization
+import blake3, os, time
+```
+
+**Test 1: Ed25519 — hub keypair, sign JWT payload, verify**
+```python
+sk_hub = Ed25519PrivateKey.generate()
+pk_hub = sk_hub.public_key()
+msg = b"test payload"
+sig = sk_hub.sign(msg)
+pk_hub.verify(sig, msg) # raises if invalid
+print("Ed25519 OK")
+```
+
+**Test 2: X25519 — two-party key agreement for GEK wrapping**
+```python
+sk_a = X25519PrivateKey.generate()
+sk_b = X25519PrivateKey.generate()
+shared_a = sk_a.exchange(sk_b.public_key())
+shared_b = sk_b.exchange(sk_a.public_key())
+assert shared_a == shared_b
+print("X25519 OK")
+```
+
+**Test 3: GEK derivation and ChaCha20-Poly1305 on a 1 MB chunk**
+```python
+gek = ChaCha20Poly1305.generate_key()
+cipher = ChaCha20Poly1305(gek)
+chunk = os.urandom(1024 * 1024) # 1 MB
+
+t0 = time.perf_counter()
+nonce = os.urandom(12)
+ct = cipher.encrypt(nonce, chunk, None)
+pt = cipher.decrypt(nonce, ct, None)
+elapsed = time.perf_counter() - t0
+
+assert pt == chunk
+print(f"ChaCha20-Poly1305 1MB: {elapsed*1000:.1f} ms")
+```
+
+**Test 4: HKDF chunk key derivation**
+```python
+from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+from cryptography.hazmat.primitives import hashes
+chunk_key = HKDF(
+ algorithm=hashes.SHA256(), length=32, salt=None,
+ info=b"file:" + blake3.blake3(chunk).digest() + b":chunk:0"
+).derive(gek)
+print(f"HKDF derived key: {chunk_key.hex()[:16]}...")
+```
+
+**Test 5: Argon2id keystore key derivation**
+```python
+from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
+salt = os.urandom(16)
+t0 = time.perf_counter()
+kdf = Argon2id(salt=salt, length=32, iterations=3, lanes=4, memory_cost=65536)
+key = kdf.derive(b"mypassword")
+print(f"Argon2id: {(time.perf_counter()-t0)*1000:.0f} ms, key: {key.hex()[:16]}...")
+```
+
+**Test 6: PyJWT with Ed25519 (EdDSA)**
+```python
+import jwt
+sk_hub_pem = sk_hub.private_bytes(
+ serialization.Encoding.PEM,
+ serialization.PrivateFormat.PKCS8,
+ serialization.NoEncryption()
+)
+pk_hub_pem = pk_hub.public_bytes(
+ serialization.Encoding.PEM,
+ serialization.PublicFormat.SubjectPublicKeyInfo
+)
+payload = {"sub": "user_abc", "pk_user": "base64...", "exp": 9999999999}
+token = jwt.encode(payload, sk_hub_pem, algorithm="EdDSA")
+decoded = jwt.decode(token, pk_hub_pem, algorithms=["EdDSA"])
+assert decoded["sub"] == "user_abc"
+print("JWT EdDSA OK")
+```
+
+**Success criteria:** all tests pass, ChaCha20 1MB < 20ms, Argon2id ~1s.
+
+---
+
+## Spike 2 — Hub Skeleton (meshbay.org)
+
+**Goal:** minimal FastAPI hub, in-memory storage, 5 endpoints.
+
+**File:** `hub.py` (on meshbay.org)
+
+### Hub keypair generation (run once, save to disk)
+
+```python
+# gen_hub_keys.py — run once on meshbay.org
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives import serialization
+import base64, json
+
+sk = Ed25519PrivateKey.generate()
+pk = sk.public_key()
+
+with open("hub_private.pem", "wb") as f:
+ f.write(sk.private_bytes(
+ serialization.Encoding.PEM,
+ serialization.PrivateFormat.PKCS8,
+ serialization.NoEncryption()
+ ))
+with open("hub_public.pem", "wb") as f:
+ f.write(pk.public_bytes(
+ serialization.Encoding.PEM,
+ serialization.PublicFormat.SubjectPublicKeyInfo
+ ))
+print("Hub keypair generated.")
+```
+
+### Hub API (`hub.py`)
+
+```python
+from fastapi import FastAPI, HTTPException, Depends, Header
+from pydantic import BaseModel
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives import serialization, hashes
+from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
+import jwt, uuid, os, time, base64
+
+app = FastAPI(title="MeshBay Hub POC")
+
+# Load hub keypair
+with open("hub_private.pem", "rb") as f:
+ HUB_SK_PEM = f.read()
+with open("hub_public.pem", "rb") as f:
+ HUB_PK_PEM = f.read()
+
+HUB_ID = "meshbay.org"
+ACCESS_TOKEN_TTL = 3600 # 1 hour
+REFRESH_TOKEN_TTL = 86400 * 30 # 30 days
+
+# In-memory stores (POC only — not persistent)
+users = {} # username → {user_id, pw_hash, pw_salt, pk_ed25519, pk_x25519}
+nodes = {} # node_id → {user_id, pk_node, endpoint_hint, registered_at}
+refresh_tokens = {} # token → user_id
+
+# --- Models ---
+
+class UserRegister(BaseModel):
+ username: str
+ password: str
+ pk_user_ed25519: str # base64
+ pk_user_x25519: str # base64
+
+class UserLogin(BaseModel):
+ username: str
+ password: str
+
+class NodeAnnounce(BaseModel):
+ pk_node: str # base64 Ed25519 public key
+ endpoint_hint: str | None = None # "ip:port" or null
+
+# --- Helpers ---
+
+def hash_password(password: str) -> tuple[bytes, bytes]:
+ salt = os.urandom(16)
+ kdf = Argon2id(salt=salt, length=32, iterations=3, lanes=4, memory_cost=65536)
+ return kdf.derive(password.encode()), salt
+
+def verify_password(password: str, pw_hash: bytes, salt: bytes) -> bool:
+ kdf = Argon2id(salt=salt, length=32, iterations=3, lanes=4, memory_cost=65536)
+ try:
+ kdf.verify(password.encode(), pw_hash)
+ return True
+ except Exception:
+ return False
+
+def issue_access_token(user: dict) -> str:
+ payload = {
+ "iss": HUB_ID,
+ "sub": user["user_id"],
+ "pk_user": user["pk_ed25519"],
+ "hub_id": HUB_ID,
+ "iat": int(time.time()),
+ "exp": int(time.time()) + ACCESS_TOKEN_TTL,
+ }
+ return jwt.encode(payload, HUB_SK_PEM, algorithm="EdDSA")
+
+def get_current_user(authorization: str = Header(...)) -> dict:
+ try:
+ scheme, token = authorization.split()
+ if scheme.lower() != "bearer":
+ raise ValueError
+ payload = jwt.decode(token, HUB_PK_PEM, algorithms=["EdDSA"])
+ user_id = payload["sub"]
+ user = next((u for u in users.values() if u["user_id"] == user_id), None)
+ if not user:
+ raise HTTPException(status_code=401, detail="User not found")
+ return user
+ except Exception:
+ raise HTTPException(status_code=401, detail="Invalid token")
+
+# --- Endpoints ---
+
+@app.get("/v1/hub/info")
+def hub_info():
+ return {
+ "hub_id": HUB_ID,
+ "pk_hub_ed25519": base64.b64encode(
+ Ed25519PrivateKey.from_private_bytes(
+ # shortcut for POC — load pk directly
+ open("hub_public.pem","rb").read()
+ ).public_bytes(...) # see note below
+ ).decode(),
+ "mnp_version": "0.1",
+ "mhp_version": "0.1",
+ }
+ # Note: return pk_hub_pem directly for POC, nodes store it on first contact
+
+@app.get("/v1/hub/pubkey")
+def hub_pubkey():
+ """Return hub Ed25519 public key PEM — cached by nodes on first contact."""
+ return {"pk_hub_pem": HUB_PK_PEM.decode()}
+
+@app.post("/v1/users/register", status_code=201)
+def register(body: UserRegister):
+ if body.username in users:
+ raise HTTPException(status_code=409, detail="Username taken")
+ pw_hash, pw_salt = hash_password(body.password)
+ user_id = str(uuid.uuid4())
+ users[body.username] = {
+ "user_id": user_id,
+ "username": body.username,
+ "pw_hash": pw_hash,
+ "pw_salt": pw_salt,
+ "pk_ed25519": body.pk_user_ed25519,
+ "pk_x25519": body.pk_user_x25519,
+ }
+ return {"user_id": user_id}
+
+@app.post("/v1/users/login")
+def login(body: UserLogin):
+ user = users.get(body.username)
+ if not user or not verify_password(body.password, user["pw_hash"], user["pw_salt"]):
+ raise HTTPException(status_code=401, detail="Invalid credentials")
+ access_token = issue_access_token(user)
+ refresh_token = base64.urlsafe_b64encode(os.urandom(32)).decode()
+ refresh_tokens[refresh_token] = user["user_id"]
+ return {
+ "access_token": access_token,
+ "refresh_token": refresh_token,
+ "token_type": "bearer",
+ "expires_in": ACCESS_TOKEN_TTL,
+ }
+
+@app.post("/v1/users/token/refresh")
+def refresh(body: dict):
+ rt = body.get("refresh_token", "")
+ user_id = refresh_tokens.get(rt)
+ if not user_id:
+ raise HTTPException(status_code=401, detail="Invalid refresh token")
+ user = next((u for u in users.values() if u["user_id"] == user_id), None)
+ if not user:
+ raise HTTPException(status_code=401, detail="User not found")
+ return {"access_token": issue_access_token(user), "token_type": "bearer"}
+
+@app.post("/v1/nodes/announce", status_code=201)
+def announce_node(body: NodeAnnounce, user: dict = Depends(get_current_user)):
+ node_id = str(uuid.uuid4())
+ nodes[node_id] = {
+ "node_id": node_id,
+ "user_id": user["user_id"],
+ "pk_node": body.pk_node,
+ "endpoint_hint": body.endpoint_hint,
+ "announced_at": int(time.time()),
+ }
+ return {"node_id": node_id}
+
+@app.get("/v1/nodes/{node_id}")
+def get_node(node_id: str, user: dict = Depends(get_current_user)):
+ node = nodes.get(node_id)
+ if not node:
+ raise HTTPException(status_code=404, detail="Node not found")
+ return {
+ "node_id": node["node_id"],
+ "pk_node": node["pk_node"],
+ "endpoint_hint": node["endpoint_hint"],
+ }
+```
+
+**Success criteria:**
+- Hub starts, all 6 endpoints respond correctly
+- `GET /v1/hub/pubkey` returns the PEM
+- `POST /v1/users/register` + `POST /v1/users/login` returns a valid JWT
+- JWT verified by `jwt.decode()` with hub public key — passes
+
+---
+
+## Spike 3 — Node Registration (Fedora laptop)
+
+**Goal:** node generates its keypair, registers a user on the hub, gets a JWT, and verifies it locally without contacting the hub again.
+
+**File:** `node.py`
+
+```python
+import httpx, asyncio, jwt, base64, os
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
+from cryptography.hazmat.primitives import serialization
+
+HUB_URL = "http://meshbay.org" # HTTP for POC, HTTPS later
+
+async def main():
+ async with httpx.AsyncClient() as client:
+
+ # 1. Fetch hub public key (first contact — cache this)
+ r = await client.get(f"{HUB_URL}/v1/hub/pubkey")
+ hub_pk_pem = r.json()["pk_hub_pem"].encode()
+ print(f"[node] Hub PK fetched ({len(hub_pk_pem)} bytes)")
+
+ # 2. Generate node identity keypairs
+ sk_ed = Ed25519PrivateKey.generate()
+ pk_ed = sk_ed.public_key()
+ sk_x = X25519PrivateKey.generate()
+ pk_x = sk_x.public_key()
+
+ pk_ed_b64 = base64.b64encode(
+ pk_ed.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
+ ).decode()
+ pk_x_b64 = base64.b64encode(
+ pk_x.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
+ ).decode()
+
+ # 3. Register user (skip if already registered)
+ r = await client.post(f"{HUB_URL}/v1/users/register", json={
+ "username": "testnode",
+ "password": "testpass123",
+ "pk_user_ed25519": pk_ed_b64,
+ "pk_user_x25519": pk_x_b64,
+ })
+ print(f"[node] Register: {r.status_code} {r.text}")
+
+ # 4. Login, get access token
+ r = await client.post(f"{HUB_URL}/v1/users/login", json={
+ "username": "testnode",
+ "password": "testpass123",
+ })
+ data = r.json()
+ access_token = data["access_token"]
+ print(f"[node] Login OK, token: {access_token[:40]}...")
+
+ # 5. Verify JWT locally — NO hub roundtrip
+ decoded = jwt.decode(access_token, hub_pk_pem, algorithms=["EdDSA"])
+ print(f"[node] JWT verified locally: sub={decoded['sub']}, exp={decoded['exp']}")
+
+ # 6. Announce node to hub
+ r = await client.post(
+ f"{HUB_URL}/v1/nodes/announce",
+ json={"pk_node": pk_ed_b64, "endpoint_hint": None},
+ headers={"Authorization": f"Bearer {access_token}"}
+ )
+ node_id = r.json()["node_id"]
+ print(f"[node] Node announced: {node_id}")
+
+asyncio.run(main())
+```
+
+**Success criteria:**
+- Node registers, logs in, receives JWT
+- JWT decoded offline using only the hub's public key — no hub call
+- Node announced; `GET /v1/nodes/{node_id}` from hub returns correct PK
+
+---
+
+## Spike 4 — NAT Traversal (both machines)
+
+**Goal:** discover the local node's external IP:port via STUN and UPnP; test reachability from meshbay.org.
+
+**File:** `spike4_nat.py` (Fedora laptop)
+
+### Part A — UPnP (try first, most reliable on SFR box)
+
+```python
+import miniupnpc
+import socket
+
+def try_upnp(internal_port=19000):
+ u = miniupnpc.UPnP()
+ u.discoverdelay = 200
+ ndevices = u.discover()
+ if ndevices == 0:
+ print("UPnP: no IGD found")
+ return None
+
+ u.selectigd()
+ external_ip = u.externalipaddress()
+ local_ip = socket.gethostbyname(socket.gethostname())
+
+ result = u.addportmapping(
+ internal_port, 'TCP', local_ip, internal_port,
+ 'MeshBay POC', ''
+ )
+ if result:
+ print(f"UPnP: mapped {external_ip}:{internal_port} → {local_ip}:{internal_port}")
+ return f"{external_ip}:{internal_port}"
+ else:
+ print("UPnP: mapping failed")
+ return None
+```
+
+### Part B — STUN discovery
+
+```python
+import asyncio
+import aioice
+
+async def stun_discover(local_port=19001):
+ # Use Cloudflare STUN server
+ stun_servers = [("stun.cloudflare.com", 3478), ("stun.l.google.com", 19302)]
+
+ connection = aioice.Connection(ice_controlling=True, stun_server=stun_servers[0])
+ await connection.gather_candidates()
+
+ for candidate in connection.local_candidates:
+ if candidate.type == "srflx": # server-reflexive = external address
+ print(f"STUN srflx: {candidate.host}:{candidate.port}")
+ return f"{candidate.host}:{candidate.port}"
+
+ print("STUN: no srflx candidate found (may be symmetric NAT)")
+ return None
+```
+
+### Part C — Reachability test from meshbay.org
+
+Once the node has an external address (from UPnP or STUN), it announces it to the hub (`endpoint_hint`). Then from meshbay.org:
+
+```bash
+# On meshbay.org — manually test TCP reachability
+nc -zv <external_ip> <external_port>
+# or
+python3 -c "import socket; s=socket.create_connection(('<external_ip>', <port>), timeout=5); print('REACHABLE'); s.close()"
+```
+
+And on the Fedora node, a simple listener:
+```python
+# On Fedora, open a listener on the discovered port
+import socket
+s = socket.socket()
+s.bind(('', 19000))
+s.listen(1)
+print("Listening on 19000...")
+conn, addr = s.accept()
+print(f"Connection from {addr}")
+conn.sendall(b"HELLO FROM NODE\n")
+conn.close()
+```
+
+**Expected outcomes on SFR residential:**
+
+| Method | Expected result | Confidence |
+|---|---|---|
+| UPnP | Works — SFR La Box supports UPnP IGD | High |
+| STUN srflx | Discovered — SFR is cone NAT for residential | High |
+| Direct TCP from meshbay.org | Works if UPnP succeeded | High |
+| Hole punching only | Depends on NAT type discovered | Medium |
+
+**Success criteria:** at least one method allows meshbay.org to reach the Fedora node's port directly.
+
+---
+
+## Spike 5 — Encrypted File Transfer (both machines)
+
+**Goal:** node serves an encrypted file chunk via direct P2P TCP connection; client decrypts and verifies.
+
+**Prerequisite:** Spike 4 succeeded — external IP:port is known and reachable.
+
+**File:** `spike5_server.py` (Fedora), `spike5_client.py` (meshbay.org)
+
+### Node side — serve one encrypted chunk
+
+```python
+# spike5_server.py — Fedora laptop
+import asyncio, os, base64
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
+from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+from cryptography.hazmat.primitives import hashes, serialization
+import blake3, struct, json
+
+# Keypair (reuse from Spike 3 or generate here)
+sk_node = Ed25519PrivateKey.generate()
+pk_node_bytes = sk_node.public_key().public_bytes(
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw
+)
+
+# Generate GEK (in a real system, loaded from keystore)
+gek_raw = ChaCha20Poly1305.generate_key()
+cipher = ChaCha20Poly1305(gek_raw)
+
+CHUNK_SIZE = 1024 * 1024 # 1 MB
+
+def make_chunk(file_path: str, chunk_index: int) -> bytes:
+ """Read, compress (skipped for POC), encrypt, sign a chunk."""
+ with open(file_path, "rb") as f:
+ f.seek(chunk_index * CHUNK_SIZE)
+ data = f.read(CHUNK_SIZE)
+
+ file_hash = blake3.blake3(open(file_path, "rb").read()).digest()
+
+ # Per-chunk key derivation
+ chunk_key = HKDF(
+ algorithm=hashes.SHA256(), length=32, salt=None,
+ info=b"file:" + file_hash + b":chunk:" + chunk_index.to_bytes(4, "big")
+ ).derive(gek_raw)
+ chunk_cipher = ChaCha20Poly1305(chunk_key)
+
+ nonce = os.urandom(12)
+ ct = chunk_cipher.encrypt(nonce, data, None)
+ chunk_hash = blake3.blake3(ct).digest()
+
+ # Sign: chunk_index + nonce + ciphertext_hash
+ sig_payload = chunk_index.to_bytes(4, "big") + nonce + chunk_hash
+ sig = sk_node.sign(sig_payload)
+
+ return json.dumps({
+ "chunk_index": chunk_index,
+ "nonce": base64.b64encode(nonce).decode(),
+ "ciphertext": base64.b64encode(ct).decode(),
+ "chunk_hash": base64.b64encode(chunk_hash).decode(),
+ "signature": base64.b64encode(sig).decode(),
+ "pk_node": base64.b64encode(pk_node_bytes).decode(),
+ "gek_hint": base64.b64encode(gek_raw).decode(), # POC: send GEK in band — never in production!
+ }).encode()
+
+async def handle_client(reader, writer):
+ request = await reader.read(1024)
+ req = json.loads(request)
+ chunk_index = req.get("chunk_index", 0)
+ file_path = req.get("file", "testfile.bin")
+
+ print(f"[node] Client requests chunk {chunk_index} of {file_path}")
+ chunk_data = make_chunk(file_path, chunk_index)
+
+ writer.write(len(chunk_data).to_bytes(4, "big") + chunk_data)
+ await writer.drain()
+ writer.close()
+ print(f"[node] Chunk {chunk_index} sent ({len(chunk_data)} bytes)")
+
+async def main():
+ # Create a 5MB test file
+ if not os.path.exists("testfile.bin"):
+ with open("testfile.bin", "wb") as f:
+ f.write(os.urandom(5 * 1024 * 1024))
+ print("[node] Test file created (5 MB)")
+
+ server = await asyncio.start_server(handle_client, "0.0.0.0", 19000)
+ print("[node] Serving on port 19000 — waiting for client...")
+ async with server:
+ await server.serve_forever()
+
+asyncio.run(main())
+```
+
+### Client side — request, verify, decrypt
+
+```python
+# spike5_client.py — meshbay.org
+import asyncio, base64, json
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
+from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
+from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+from cryptography.hazmat.primitives import hashes, serialization
+import blake3
+
+NODE_HOST = "<external_ip>" # from Spike 4
+NODE_PORT = 19000
+
+async def main():
+ reader, writer = await asyncio.open_connection(NODE_HOST, NODE_PORT)
+
+ # Request chunk 0
+ request = json.dumps({"file": "testfile.bin", "chunk_index": 0}).encode()
+ writer.write(request)
+ await writer.drain()
+
+ # Receive
+ length_bytes = await reader.readexactly(4)
+ length = int.from_bytes(length_bytes, "big")
+ data = await reader.readexactly(length)
+ writer.close()
+
+ chunk = json.loads(data)
+ print(f"[client] Received chunk {chunk['chunk_index']}")
+
+ # 1. Verify signature
+ pk_node_bytes = base64.b64decode(chunk["pk_node"])
+ pk_node = Ed25519PublicKey.from_public_bytes(pk_node_bytes)
+ ct = base64.b64decode(chunk["ciphertext"])
+ nonce = base64.b64decode(chunk["nonce"])
+ chunk_hash = base64.b64decode(chunk["chunk_hash"])
+ sig = base64.b64decode(chunk["signature"])
+
+ sig_payload = (0).to_bytes(4, "big") + nonce + chunk_hash
+ pk_node.verify(sig, sig_payload) # raises on failure
+ print("[client] Signature OK")
+
+ # 2. Verify ciphertext hash
+ assert blake3.blake3(ct).digest() == chunk_hash
+ print("[client] Ciphertext hash OK")
+
+ # 3. Derive chunk key and decrypt (GEK from POC hint — never in production)
+ gek_raw = base64.b64decode(chunk["gek_hint"])
+ # (in production, client has GEK from hub's GEK bundle)
+ chunk_key = HKDF(
+ algorithm=hashes.SHA256(), length=32, salt=None,
+ info=b"file:" + bytes(32) + b":chunk:" + (0).to_bytes(4, "big")
+ # Note: in production, file_hash is sent separately or in index
+ ).derive(gek_raw)
+ plaintext = ChaCha20Poly1305(chunk_key).decrypt(nonce, ct, None)
+ print(f"[client] Decrypted {len(plaintext)} bytes")
+ print("[client] Encrypted P2P transfer: SUCCESS")
+
+asyncio.run(main())
+```
+
+**Note on GEK in POC:** the GEK is included in the response as `gek_hint` for POC convenience only. In production, the client gets the GEK from the hub's encrypted GEK bundle (delivered at login, decrypted client-side with the user's X25519 private key).
+
+**Success criteria:**
+- Client receives chunk from node via direct TCP connection
+- Signature verification passes
+- Ciphertext hash matches
+- Decryption produces the original bytes
+- End-to-end: `original_bytes == decrypted_bytes` ✓
+
+---
+
+## What POC Validates (and Doesn't)
+
+### Validated by these spikes
+
+| Concept | Spike | Validation |
+|---|---|---|
+| Python crypto stack is sufficient | 1 | All primitives work, performance acceptable |
+| Hub/Node JWT handshake | 2, 3 | JWT issued by hub, verified offline by node |
+| Hub-Node REST protocol (minimal MNP/HTTP) | 2, 3 | API contract works end-to-end |
+| SFR NAT traversal via UPnP | 4 | P2P reachability confirmed |
+| STUN external address discovery | 4 | Confirmed/fallback documented |
+| On-the-fly per-chunk encryption | 5 | GEK + HKDF chunk derivation + ChaCha20 |
+| Chunk signature and verification | 5 | Ed25519 sign/verify before decryption |
+| Real P2P file transfer | 5 | No hub in data path |
+
+### NOT in scope
+
+- Database (all in-memory)
+- HTTPS / TLS (HTTP for POC)
+- QUIC transport (plain TCP)
+- GEK bundle distribution via hub (GEK sent in-band for POC)
+- Group management
+- Chat / Double Ratchet
+- Mesh Group Index
+- MHP federation
+- Android client
+- Module system
+- Persistence between restarts
+
+---
+
+## Spike Order Dependency Graph
+
+```
+Spike 1 (crypto)
+ └──→ Spike 2 (hub skeleton)
+ └──→ Spike 3 (node registration)
+ └──→ Spike 4 (NAT traversal)
+ └──→ Spike 5 (encrypted transfer)
+```
+
+Spike 1 is a prerequisite for all others. Spikes 2 and 3 can overlap if two people work in parallel. Spike 4 can begin independently once Spike 3 is running.