# MeshBay — POC v1 (FR) > Objectif : valider les concepts clés avant de s'engager dans une implémentation complète. > Périmètre : échange Hub/Node en Python, stack crypto, traversée NAT, transfert chiffré de chunk de fichier. > Tout en mémoire (pas de base de données), code minimal, TCP uniquement (pas de QUIC pour l'instant). --- ## Environnement ### Distant — meshbay.org (Hub) - OVH VPS, Ubuntu 26.04 LTS, Python 3.14.4 - IP fixe publique, ports 80 et 443 ouverts - Serveur vierge : aucun serveur web installé - Accès SSH : `ssh cbesson@meshbay.org` ### Local — Fedora 44 (Node) - Laptop derrière NAT résidentiel SFR (vraisemblablement Restricted Cone NAT — UPnP supporté) - Python 3.13+ via paquets système - Utilisateur : `cbesson` (sudoer sans mot de passe) --- ## Dépendances Python ```bash # Partagé (hub et node) cryptography>=43.0 # Ed25519, X25519, ChaCha20-Poly1305, Argon2id PyJWT>=2.9 # JWT avec support EdDSA (Ed25519) blake3>=1.0 # Hachage rapide du contenu # Hub uniquement (meshbay.org) fastapi>=0.115 uvicorn[standard]>=0.30 # Node uniquement (laptop Fedora) httpx>=0.28 # Client HTTP async pour les appels node→hub aioice>=0.9 # Requêtes STUN pour la découverte NAT miniupnpc>=2.2 # Ouverture de port UPnP sur la box SFR ``` Installation sur chaque machine : ```bash python3 -m venv .venv source .venv/bin/activate pip install ``` --- ## Configuration du hub sur meshbay.org Pour le POC, uvicorn tourne directement sur le port 80 via une redirection iptables (pas de Caddy/nginx pour l'instant — HTTPS ajouté avant la production). ```bash # Sur meshbay.org # Redirection port 80 → 8000 sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8000 # Lancer le hub (depuis le répertoire poc, venv activé) uvicorn hub:app --host 127.0.0.1 --port 8000 --reload ``` > Note : HTTPS (via Caddy + Let's Encrypt) est obligatoire avant tout usage réel au-delà de ce POC. --- ## Vue d'ensemble des spikes | # | Nom | Où | Ce que ça valide | Durée | |---|---|---|---|---| | 1 | Primitives crypto | Local | La stack Python crypto couvre tous les besoins | ~1h | | 2 | Squelette hub | meshbay.org | API hub, émission JWT | ~2h | | 3 | Enregistrement node | Fedora | Handshake Hub-Node, vérification JWT offline | ~1h | | 4 | Traversée NAT | Les deux | UPnP box SFR + STUN, accessibilité P2P | ~2h | | 5 | Transfert chiffré | Les deux | Chiffrement GEK à la volée, chunk P2P | ~2h | --- ## Spike 1 — Primitives cryptographiques (local uniquement) **Objectif :** confirmer que `cryptography` (PyCA) couvre tous les besoins cryptographiques de MeshBay sans lacune ni surprise de performance. **Fichier :** `spike1_crypto.py` **Test 1 : Ed25519 — keypair hub, signature, vérification** ```python from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey sk_hub = Ed25519PrivateKey.generate() pk_hub = sk_hub.public_key() msg = b"test payload" sig = sk_hub.sign(msg) pk_hub.verify(sig, msg) # lève une exception si invalide print("Ed25519 OK") ``` **Test 2 : X25519 — accord de clé pour l'enveloppement de la GEK** ```python from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey 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 : ChaCha20-Poly1305 sur un chunk de 1 Mo** ```python from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 import os, time gek = ChaCha20Poly1305.generate_key() cipher = ChaCha20Poly1305(gek) chunk = os.urandom(1024 * 1024) # 1 Mo 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 1 Mo : {elapsed*1000:.1f} ms") ``` **Test 4 : Dérivation de clé de chunk par HKDF** ```python from cryptography.hazmat.primitives.kdf.hkdf import HKDF from cryptography.hazmat.primitives import hashes import blake3 chunk_key = HKDF( algorithm=hashes.SHA256(), length=32, salt=None, info=b"file:" + blake3.blake3(chunk).digest() + b":chunk:0" ).derive(gek) print(f"Clé HKDF : {chunk_key.hex()[:16]}...") ``` **Test 5 : Argon2id — dérivation de clé keystore** ```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"motdepasse") print(f"Argon2id : {(time.perf_counter()-t0)*1000:.0f} ms") ``` **Test 6 : PyJWT avec Ed25519 (EdDSA)** ```python import jwt from cryptography.hazmat.primitives import serialization sk_pem = sk_hub.private_bytes( serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() ) pk_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_pem, algorithm="EdDSA") decoded = jwt.decode(token, pk_pem, algorithms=["EdDSA"]) assert decoded["sub"] == "user_abc" print("JWT EdDSA OK") ``` **Critères de succès :** tous les tests passent, ChaCha20 1 Mo < 20 ms, Argon2id ~1s. --- ## Spike 2 — Squelette du hub (meshbay.org) **Objectif :** hub FastAPI minimal avec stockage en mémoire, 6 endpoints. **Fichier :** `hub.py` (sur meshbay.org) ### Génération du keypair hub (une seule fois) ```python # gen_hub_keys.py — exécuter une seule fois sur meshbay.org from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives import serialization sk = Ed25519PrivateKey.generate() 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(sk.public_key().public_bytes( serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo )) print("Keypair hub généré.") ``` ### Endpoints du hub ``` GET /v1/hub/pubkey → PEM de la clé publique Ed25519 du hub POST /v1/users/register → {username, password, pk_user_ed25519, pk_user_x25519} → {user_id} POST /v1/users/login → {username, password} → {access_token, refresh_token} POST /v1/users/token/refresh → {refresh_token} → {access_token} POST /v1/nodes/announce → (auth) {pk_node, endpoint_hint} → {node_id} GET /v1/nodes/{node_id} → (auth) {pk_node, endpoint_hint} ``` ### Structure JWT (access token) ```json { "iss": "meshbay.org", "sub": "", "pk_user": "", "hub_id": "meshbay.org", "iat": 1720000000, "exp": 1720003600 } ``` Signé avec la clé Ed25519 privée du hub. Vérifiable par n'importe qui possédant la clé publique du hub — aucun appel hub requis. **Critères de succès :** - Hub démarre, tous les endpoints répondent correctement - `POST /v1/users/register` + `POST /v1/users/login` retourne un JWT valide - `jwt.decode()` avec la clé publique du hub passe sans erreur --- ## Spike 3 — Enregistrement du node (laptop Fedora) **Objectif :** le node génère son keypair, s'enregistre sur le hub, obtient un JWT, et le vérifie localement sans contacter le hub. **Fichier :** `node.py` **Séquence :** 1. Récupérer la clé publique du hub (`GET /v1/hub/pubkey`) — mettre en cache 2. Générer le keypair Ed25519 + X25519 du node 3. Enregistrer l'utilisateur sur le hub 4. Se connecter, recevoir l'access token (JWT) 5. **Vérifier le JWT localement** avec la clé publique du hub — aucun appel réseau 6. Annoncer le node au hub **Vérification JWT offline (point clé) :** ```python # Aucun appel hub — juste la signature Ed25519 decoded = jwt.decode(access_token, hub_pk_pem, algorithms=["EdDSA"]) print(f"[node] JWT vérifié localement : sub={decoded['sub']}") ``` C'est la validation du concept fondamental : le hub est une autorité d'identité qui émet des credentials vérifiables hors ligne. Après le login, le hub n'est plus dans la boucle. **Critères de succès :** - Node s'enregistre, se connecte, reçoit un JWT - JWT décodé offline avec la seule clé publique du hub - Node annoncé ; `GET /v1/nodes/{node_id}` depuis le hub retourne le bon PK --- ## Spike 4 — Traversée NAT (les deux machines) **Objectif :** découvrir l'IP:port externe du node local via UPnP et STUN ; tester l'accessibilité depuis meshbay.org. **Fichier :** `spike4_nat.py` (laptop Fedora) ### Partie A — UPnP (à tenter en premier, plus fiable sur box SFR) ```python import miniupnpc, socket def try_upnp(internal_port=19000): u = miniupnpc.UPnP() u.discoverdelay = 200 if u.discover() == 0: print("UPnP : aucune IGD trouvée") return None u.selectigd() external_ip = u.externalipaddress() local_ip = socket.gethostbyname(socket.gethostname()) if u.addportmapping(internal_port, 'TCP', local_ip, internal_port, 'MeshBay POC', ''): print(f"UPnP : {external_ip}:{internal_port} → {local_ip}:{internal_port}") return f"{external_ip}:{internal_port}" print("UPnP : échec du mapping") return None ``` ### Partie B — Découverte STUN ```python import asyncio, aioice async def stun_discover(): connection = aioice.Connection( ice_controlling=True, stun_server=("stun.cloudflare.com", 3478) ) await connection.gather_candidates() for candidate in connection.local_candidates: if candidate.type == "srflx": # server-reflexive = adresse externe print(f"STUN srflx : {candidate.host}:{candidate.port}") return f"{candidate.host}:{candidate.port}" print("STUN : aucun candidat srflx (NAT symétrique possible)") return None ``` ### Partie C — Test d'accessibilité depuis meshbay.org Le node annonce son `endpoint_hint` au hub. Depuis meshbay.org : ```bash # Test TCP depuis meshbay.org python3 -c " import socket s = socket.create_connection(('', ), timeout=5) print('ACCESSIBLE') s.close() " ``` Sur le laptop Fedora, un listener simple sur le port découvert : ```python import socket s = socket.socket() s.bind(('', 19000)) s.listen(1) print("En écoute sur 19000...") conn, addr = s.accept() print(f"Connexion depuis {addr}") conn.sendall(b"BONJOUR DU NODE\n") conn.close() ``` **Résultats attendus sur SFR résidentiel :** | Méthode | Résultat attendu | Niveau de confiance | |---|---|---| | UPnP | Fonctionne — La Box SFR supporte UPnP IGD | Élevé | | STUN srflx | Découvert — SFR est un cone NAT pour le résidentiel | Élevé | | TCP direct depuis meshbay.org | Fonctionne si UPnP a réussi | Élevé | | Hole punching seul | Dépend du type NAT découvert | Moyen | **Critères de succès :** au moins une méthode permet à meshbay.org d'atteindre directement le port du laptop Fedora. --- ## Spike 5 — Transfert chiffré de fichier (les deux machines) **Objectif :** le node sert un chunk de fichier chiffré via connexion TCP P2P directe ; le client déchiffre et vérifie. **Prérequis :** Spike 4 réussi — IP:port externe connu et accessible. ### Côté node (laptop Fedora) Pipeline : lire le chunk → dériver la clé via HKDF(GEK, file_hash, chunk_index) → chiffrer ChaCha20-Poly1305 → signer Ed25519 → envoyer. **Points clés :** - Clé par chunk dérivée de la GEK (pas la GEK directement) - Chaque chunk signaturé avant envoi - La GEK n'est jamais envoyée en clair en production (envoyée en clair uniquement pour ce POC — voir note ci-dessous) ### Côté client (meshbay.org) Pipeline : recevoir → vérifier signature Ed25519 → vérifier hash blake3 du ciphertext → déchiffrer ChaCha20-Poly1305 → obtenir les octets en clair. ### Note sur la GEK dans le POC Pour ce POC, la GEK est transmise dans la réponse comme `gek_hint` pour des raisons de commodité. **En production, le client obtient la GEK depuis le bundle GEK chiffré du hub** (déchiffré côté client avec sa clé X25519 privée). Le mécanisme de distribution de la GEK est délibérément hors périmètre de ce POC. **Critères de succès :** - Le client reçoit le chunk depuis le node via TCP direct (sans hub dans le chemin) - La vérification de signature passe ✓ - Le hash du ciphertext correspond ✓ - Le déchiffrement produit les octets originaux ✓ - `octets_originaux == octets_déchiffrés` ✓ --- ## Ce que le POC valide (et ne valide pas) ### Validé par ces spikes | Concept | Spike | Validation | |---|---|---| | Stack Python crypto suffisante | 1 | Toutes les primitives fonctionnent, performance acceptable | | Handshake Hub/Node via JWT | 2, 3 | JWT émis par le hub, vérifié offline par le node | | Protocole REST Hub-Node minimal | 2, 3 | Contrat API validé bout en bout | | Traversée NAT SFR via UPnP | 4 | Accessibilité P2P confirmée | | Découverte adresse externe STUN | 4 | Confirmée / fallback documenté | | Chiffrement par chunk à la volée | 5 | GEK + HKDF par chunk + ChaCha20 | | Signature et vérification de chunk | 5 | Ed25519 sign/verify avant déchiffrement | | Transfert de fichier P2P réel | 5 | Aucun hub dans le chemin des données | ### Hors périmètre - Base de données (tout en mémoire) - HTTPS / TLS (HTTP pour le POC) - Transport QUIC (TCP simple) - Distribution du bundle GEK via hub (GEK transmise en clair pour le POC) - Gestion de groupes - Chat / Double Ratchet - Mesh Group Index - Fédération MHP - Client Android - Système de modules - Persistance entre les redémarrages --- ## Graphe de dépendance des spikes ``` Spike 1 (crypto) └──→ Spike 2 (squelette hub) └──→ Spike 3 (enregistrement node) └──→ Spike 4 (traversée NAT) └──→ Spike 5 (transfert chiffré) ``` Le Spike 1 est un prérequis pour tous les autres. Les Spikes 2 et 3 peuvent être menés en parallèle si deux personnes travaillent. Le Spike 4 peut commencer indépendamment dès que le Spike 3 est fonctionnel.