1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
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.
|