From 271adc8504aad32075d75d06fd42023877a649ec Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 9 Aug 2026 03:52:58 +0200 Subject: 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) --- poc/spike-results.md | 266 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 poc/spike-results.md (limited to 'poc/spike-results.md') diff --git a/poc/spike-results.md b/poc/spike-results.md new file mode 100644 index 0000000..6e3cbb9 --- /dev/null +++ b/poc/spike-results.md @@ -0,0 +1,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. -- cgit v1.2.3