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
|
# MeshBay POC — Spike Results
---
## Spike 1 — Crypto Primitives
**Date:** 2026-08-09
**Machine:** Fedora 44 local (cbesson laptop)
**Python:** 3.14.6
**Libs:** cryptography 50.0.0, PyJWT 2.13.0, blake3 1.0.9
### Results: 22/22 PASSED
| Test | Result |
|---|---|
| Ed25519 sign + verify | ✓ signature 64 bytes |
| Ed25519 tampered message rejected | ✓ |
| Ed25519 PEM serialization | ✓ sk=119B pk=113B raw=32B |
| X25519 two-party agreement | ✓ shared secret 32 bytes, both sides match |
| X25519 different pairs ≠ same secret | ✓ |
| ChaCha20-Poly1305 roundtrip (1 MB) | ✓ AEAD overhead=16B |
| ChaCha20-Poly1305 performance | ✓ **avg 1.1ms/chunk, 1750 MB/s** |
| ChaCha20-Poly1305 tampered ciphertext rejected | ✓ |
| HKDF 3 distinct chunk keys | ✓ each 32 bytes |
| HKDF deterministic derivation | ✓ |
| blake3 1 MB performance | ✓ **0.3ms** |
| blake3 deterministic | ✓ |
| blake3 collision resistance (basic) | ✓ |
| Argon2id key derivation | ✓ 78ms (⚠ see note) |
| Argon2id salt uniqueness | ✓ |
| Argon2id verify correct/wrong password | ✓ |
| AES-256-GCM keystore roundtrip | ✓ 49B → 49B + 16B tag |
| JWT EdDSA issue + verify offline | ✓ token 335 chars |
| JWT tampered rejected | ✓ |
| JWT wrong signing key rejected | ✓ |
| JWT expired rejected | ✓ |
| Full pipeline: encrypt+sign / verify+decrypt (1 MB) | ✓ **1.2ms / 1.3ms** |
### Key Figures
| Metric | Value | Note |
|---|---|---|
| ChaCha20-Poly1305 (1 MB, encrypt+decrypt) | 1.1 ms / 1750 MB/s | Encryption is not the bottleneck |
| blake3 (1 MB) | 0.3 ms | Near-instant hashing |
| Full pipeline (1 MB chunk) | 2.5 ms total | Well within all requirements |
| Argon2id (iterations=3, memory=64MB) | 78 ms | ⚠ Too fast for production keystore |
| JWT EdDSA token | 335 chars, offline verify | Hub not needed after login |
### Notes / Actions
- **Argon2id at 78ms is too weak for a production keystore.** Parameters need tuning to target 500ms–1s on the node hardware. Increase `memory_cost` (e.g. 262144 = 256MB) or `iterations`. To calibrate during implementation.
- All crypto primitives confirmed available in `cryptography` 50.0.0 — no gaps.
- PyJWT 2.13.0 EdDSA support works correctly with PEM keys.
- blake3 PyPI package required (not in stdlib as of Python 3.14).
---
## Spike 2 — Hub Skeleton (meshbay.org)
**Date:** 2026-08-09
**Machine:** meshbay.org — Ubuntu 26.04 LTS, Python 3.14.4
**Libs:** fastapi 0.115, uvicorn, cryptography 50.0.0, PyJWT 2.13.0
**Deployment:** uvicorn sur port 80 via authbind (UFW actif — ports 22/80/443)
### Results: 12/12 PASSED
| Test | Result |
|---|---|
| GET /v1/hub/info | ✓ hub_id, versions, compteurs |
| GET /v1/hub/pubkey | ✓ PEM 113B retourné |
| POST /v1/users/register | ✓ user_id UUID retourné |
| register doublon | ✓ → 409 Conflict |
| POST /v1/users/login | ✓ access_token 364 chars + refresh_token |
| login mauvais mot de passe | ✓ → 401 |
| JWT vérifié OFFLINE (clé publique hub seulement) | ✓ sub, hub_id, pk_user corrects, expiry 3600s |
| POST /v1/users/token/refresh | ✓ nouveau token valide |
| POST /v1/nodes/announce (auth) | ✓ node_id UUID retourné |
| GET /v1/nodes/{id} (auth) | ✓ pk_node et endpoint_hint corrects |
| GET /v1/nodes/{id} sans auth | ✓ → 422 |
| /v1/hub/info mise à jour (users=1, nodes=1) | ✓ |
### Notes / Actions
- **authbind** requis pour lier port 80 sans root sur Ubuntu avec UFW actif.
PREROUTING iptables + uvicorn sur 8000 ne fonctionne pas : UFW bloque le port 8000 en INPUT après le DNAT.
- Hub joignable publiquement sur http://meshbay.org depuis le laptop Fedora.
- JWT offline verify : concept clé validé — le node n'a **aucun besoin de contacter le hub** pour authentifier un client après la phase de login.
- Hub en mémoire uniquement — redémarrer le hub efface users/nodes/tokens (attendu pour le POC).
- **À faire avant production :** HTTPS (Caddy + Let's Encrypt), persistance DB (PostgreSQL).
---
## Spike 3 — Node Registration
**Date:** 2026-08-09
**Machine:** Fedora 44 local → meshbay.org
**Python:** 3.14.6 local / 3.14.4 remote
### Results: 8/8 PASSED
| Test | Result |
|---|---|
| Génération keypair Ed25519 + X25519 | ✓ |
| Fetch et cache clé publique hub (1 seul appel réseau) | ✓ 113B PEM |
| Enregistrement utilisateur sur hub | ✓ user_id UUID |
| Login → access token + refresh token | ✓ token 364 chars |
| **JWT vérifié OFFLINE (clé publique hub uniquement)** | ✓ **884 µs — aucun appel hub** |
| Annonce du node (endpoint_hint=None) | ✓ node_id UUID |
| Récupération du node record depuis hub | ✓ pk_node et username corrects |
| Token refresh → nouveau token vérifié offline | ✓ jti différent, même sub |
### Notes / Actions
- **Bug découvert et corrigé :** le hub n'incluait pas de champ `jti` (JWT ID) dans les tokens.
Sans `jti`, deux tokens émis dans la même seconde sont identiques (Ed25519 est déterministe sur un payload identique).
Fix : ajout d'un `uuid4()` en `jti` à chaque émission — chaque token est désormais unique même à la même seconde. Le `jti` permettra aussi la révocation individuelle de tokens en production.
- **JWT offline verify à 884 µs** : concept fondamental validé — le node n'a jamais besoin de contacter le hub pour authentifier un client.
- L'état du node (keypairs, tokens, node_id) est persisté dans `node_state.json` pour les spikes suivants.
- `endpoint_hint=None` pour l'instant — sera rempli par le résultat du Spike 4 (STUN/UPnP).
---
## Spike 4 — NAT Traversal
**Date:** 2026-08-09
**Machine:** Fedora 44 (SFR résidentiel) → meshbay.org (OVH VPS)
**Méthodes testées:** STUN (2 serveurs), détection type NAT, hole punching UDP bidirectionnel, UPnP
### Results: PASSED (P2P UDP fonctionnel)
| Test | Résultat |
|---|---|
| STUN via stun.cloudflare.com | ✓ 81.220.170.32:51250 |
| STUN via stun.l.google.com | ✓ 81.220.170.32:51250 (même port) |
| **Détection type NAT** | ✓ **Cone NAT** — même port externe pour les deux destinations |
| **UDP bidirectionnel hole punch** | ✓ node → meshbay.org → echo reçu |
| meshbay.org a vu le node comme | ✓ 81.220.170.32:51250 (confirme STUN) |
| UPnP | ✗ désactivé sur la box SFR |
| endpoint_hint hub | ✓ 81.220.170.32:51250 |
### Findings clés
- **Cone NAT confirmé** : le port externe (51250) est identique quelle que soit la destination (Cloudflare STUN ou Google STUN). Le hole punching UDP fonctionne donc sans TURN relay.
- **UDP P2P opérationnel** : echo reçu de meshbay.org après probe sortant. Confirme que QUIC (UDP) peut fonctionner en P2P depuis cette configuration SFR résidentielle.
- **UPnP désactivé** sur la box SFR testée — pas bloquant grâce au Cone NAT.
- **Adresse externe stable** : 81.220.170.32 (IP publique SFR, pas de CGNAT).
### Bugs découverts et corrigés
- `seen_ext_addr` capturait l'IP source de l'écho (meshbay.org:19002) au lieu de notre adresse externe — confusion entre "qui m'a répondu" et "comment ils m'ont vu". Le log serveur (`RECEIVED from ('81.220.170.32', 51250)`) est la source de vérité.
- `endpoint_hint` avait une parenthèse parasite due à la conversion de tuple. Corrigé en `node_state.json`.
### Implication pour le design
Le Mesh Relay (TURN) sera nécessaire uniquement pour les utilisateurs derrière **NAT symétrique** (typiquement : CGNAT mobile, certains FAI pro). Pour les connexions résidentielles standard (SFR, Orange, Free, etc.), le Cone NAT permet le hole punching direct → P2P sans relay.
---
## Spike 6 — GEK Distribution (X25519 + HKDF)
**Date:** 2026-08-09
**Machine:** Fedora 44 local → meshbay.org
**Protocole :** ECIES-like : X25519 ephémère + HKDF + ChaCha20-Poly1305 + AAD
### Results: 10/10 PASSED
| Test | Résultat |
|---|---|
| Alice enregistrée sur hub | ✓ |
| Bob enregistré sur hub (avec pk_x25519) | ✓ |
| Alice crée groupe sur hub | ✓ |
| Alice wraps GEK pour elle-même (bundle opaque sur hub) | ✓ 1.20ms |
| Alice fetch pk_x25519 de Bob depuis hub | ✓ clé correcte |
| Alice wraps GEK pour Bob (bundle opaque différent) | ✓ 0.48ms |
| Bob récupère son bundle depuis hub | ✓ bundle intact |
| **Bob unwrap GEK avec sa sk_x25519** | ✓ **0.59ms** |
| **recovered_gek == original_gek** | ✓ **RÉSULTAT CLÉ** |
| Bob déchiffre contenu Alice avec GEK récupérée | ✓ |
| Mauvaise clé privée rejetée (AEAD auth tag) | ✓ |
### Timings
| Opération | Durée |
|---|---|
| wrap_gek (X25519 + HKDF + ChaCha20) | 0.48–1.20 ms |
| unwrap_gek (X25519 + HKDF + ChaCha20) | 0.59 ms |
### Protocole validé (ECIES-like)
```
Admin side (wrap):
sk_eph, pk_eph = X25519.generate()
shared = X25519(sk_eph, pk_recipient)
wrap_key = HKDF(shared, salt=pk_eph, info="meshbay:gek_wrap:v1")
wrapped = ChaCha20-Poly1305(wrap_key).encrypt(nonce, gek, aad=pk_recipient)
bundle = {pk_eph, nonce, wrapped} → hub (opaque)
Member side (unwrap):
shared = X25519(sk_recipient, pk_eph)
wrap_key = HKDF(shared, salt=pk_eph, info="meshbay:gek_wrap:v1")
gek = ChaCha20-Poly1305(wrap_key).decrypt(nonce, wrapped, aad=pk_recipient)
```
### Propriétés de sécurité vérifiées
- Le hub ne voit jamais la GEK en clair (bundle opaque de 48 bytes)
- La clé éphémère est unique par bundle (même GEK, même destinataire → bundles différents)
- L'AAD (`pk_recipient`) lie le bundle au destinataire → impossible de réutiliser pour un autre membre
- Mauvaise clé privée → AEAD authentication tag échec (InvalidTag) → rejet immédiat
### Bugs découverts
- Double appel `unwrap_gek` dans le code initial (copie/colle résiduelle) → `InvalidTag` au premier appel
- Keypairs de Bob non persistés entre les runs → incohérence hub vs local → `AssertionError`
Fix : `bob_state.json` pour rendre le spike idempotent
---
## Spike 5 — Encrypted File Transfer
**Date:** 2026-08-09
**Machine:** Fedora 44 (node, derrière NAT SFR) → meshbay.org (client, IP publique OVH)
**Transport:** TCP sortant depuis le node (contournement NAT pour le POC — en production : QUIC + hole punching Spike 4)
**Chunk:** 1 MB (chunk 0 d'un fichier test 5 MB)
### Results: 4/4 PASSED
| Test | Résultat |
|---|---|
| Ed25519 signature valid | ✓ |
| blake3(ciphertext) matches | ✓ |
| Déchiffrement ChaCha20-Poly1305 | ✓ 1024 KB → 1024 KB |
| blake3(plaintext) matches — intégrité bout en bout | ✓ |
### Timings
| Mesure | Node (Fedora) | Client (meshbay.org) |
|---|---|---|
| Chiffrement + signature (1 MB) | **3.2 ms** | — |
| Envoi TCP | 99 ms | — |
| Réception TCP | — | 234 ms |
| Vérification + déchiffrement | — | **3.9 ms** |
| **Overhead crypto total** | **3.2 ms** | **3.9 ms** |
| Débit réseau effectif | 13.5 MB/s envoi | **4.3 MB/s réception** |
Le débit 4.3 MB/s (~34 Mbps) est la limite réseau OVH → SFR résidentiel, pas la limite crypto.
L'overhead crypto (chiffrement + déchiffrement) est **< 10 ms pour 1 MB** — totalement négligeable.
### Pipeline validé
```
Fichier disque (clair)
→ lecture 1 MB chunk
→ HKDF(GEK, file_hash, chunk_index) → clé 32B
→ ChaCha20-Poly1305 encrypt (nonce aléatoire)
→ blake3(ciphertext) → ct_hash
→ Ed25519 sign(chunk_index || nonce || ct_hash)
→ envoi JSON/TCP length-prefixed
→ réception
→ Ed25519 verify ✓
→ blake3(ct) == ct_hash ✓
→ ChaCha20-Poly1305 decrypt ✓
→ blake3(plaintext) == pt_hash ✓
```
### Notes / Actions
- `sys.exit(0)` dans un handler asyncio génère un log d'exception cosmétique — sans impact sur le résultat. À corriger (utiliser `server.close()` + event).
- La GEK est incluse dans la réponse (`gek_b64`) pour le POC uniquement. En production : la GEK est distribuée via le bundle chiffré du hub (jamais en clair sur le réseau).
- Le `file_hash` (blake3 du fichier complet) est utilisé dans l'info HKDF pour identifier le fichier. En production, il est dans le Mesh Group Index chiffré.
- La compression (zstd avant chiffrement) n'est pas dans ce spike — à valider dans l'implémentation.
- **En production** : même pipeline mais sur QUIC (UDP hole-punching Spike 4) au lieu de TCP.
|