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) --- docs/poc-v1.md | 767 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 767 insertions(+) create mode 100644 docs/poc-v1.md (limited to 'docs/poc-v1.md') 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 +``` + +--- + +## 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 +# or +python3 -c "import socket; s=socket.create_connection(('', ), 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 = "" # 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. -- cgit v1.2.3