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, 0 insertions, 767 deletions
diff --git a/docs/poc-v1.md b/docs/poc-v1.md
deleted file mode 100644
index 8f66159..0000000
--- a/docs/poc-v1.md
+++ /dev/null
@@ -1,767 +0,0 @@
-# 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.