diff options
Diffstat (limited to 'docs/poc-v1.md')
| -rw-r--r-- | docs/poc-v1.md | 767 |
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. |