diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-09 03:52:58 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-09 03:52:58 +0200 |
| commit | 271adc8504aad32075d75d06fd42023877a649ec (patch) | |
| tree | fe8697761d635a5cac7e0693f2e588a38a7968b9 /docs/poc-v1-fr.md | |
| download | meshbay-271adc8504aad32075d75d06fd42023877a649ec.tar.gz | |
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) <noreply@anthropic.com>
Diffstat (limited to 'docs/poc-v1-fr.md')
| -rw-r--r-- | docs/poc-v1-fr.md | 432 |
1 files changed, 432 insertions, 0 deletions
diff --git a/docs/poc-v1-fr.md b/docs/poc-v1-fr.md new file mode 100644 index 0000000..262a245 --- /dev/null +++ b/docs/poc-v1-fr.md @@ -0,0 +1,432 @@ +# 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 <paquets ci-dessus> +``` + +--- + +## 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": "<user_id>", + "pk_user": "<base64 Ed25519 publique>", + "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(('<ip_externe>', <port>), 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. |