# 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.