diff options
Diffstat (limited to 'poc')
| -rw-r--r-- | poc/spike-results.md | 266 | ||||
| -rw-r--r-- | poc/spike1_crypto.py | 388 | ||||
| -rw-r--r-- | poc/spike2_hub.py | 268 | ||||
| -rw-r--r-- | poc/spike3_node.py | 186 | ||||
| -rw-r--r-- | poc/spike4_nat.py | 279 | ||||
| -rw-r--r-- | poc/spike5_client.py | 140 | ||||
| -rw-r--r-- | poc/spike5_node.py | 175 | ||||
| -rw-r--r-- | poc/spike6_gek.py | 310 |
8 files changed, 2012 insertions, 0 deletions
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. diff --git a/poc/spike1_crypto.py b/poc/spike1_crypto.py new file mode 100644 index 0000000..335758c --- /dev/null +++ b/poc/spike1_crypto.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +""" +MeshBay — Spike 1: Crypto Primitives +Validates the full cryptographic stack needed for MeshBay. +""" + +import os, time, base64, sys + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives.kdf.argon2 import Argon2id +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +import blake3 +import jwt + +PASS = "✓" +FAIL = "✗" +results = [] + +def check(name, fn): + try: + result = fn() + msg = result if isinstance(result, str) else PASS + print(f" {PASS} {name}: {msg}") + results.append((name, True)) + except Exception as e: + print(f" {FAIL} {name}: {e}") + results.append((name, False)) + + +print("\n=== Test 1: Ed25519 — Hub keypair, sign, verify ===") + +sk_hub = Ed25519PrivateKey.generate() +pk_hub = sk_hub.public_key() +sk_user = Ed25519PrivateKey.generate() +pk_user = sk_user.public_key() + +def test_ed25519_sign_verify(): + msg = b"meshbay hub token payload" + sig = sk_hub.sign(msg) + pk_hub.verify(sig, msg) + return f"signature {len(sig)} bytes" + +def test_ed25519_wrong_sig(): + msg = b"original" + sig = sk_hub.sign(msg) + try: + pk_hub.verify(sig, b"tampered") + return f"{FAIL} should have raised" + except Exception: + return "tampered message correctly rejected" + +def test_ed25519_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 + ) + pk_raw = pk_hub.public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw + ) + return f"PEM sk={len(sk_pem)}B pk={len(pk_pem)}B raw={len(pk_raw)}B" + +check("Sign + verify", test_ed25519_sign_verify) +check("Tampered message rejected", test_ed25519_wrong_sig) +check("PEM serialization", test_ed25519_serialization) + + +print("\n=== Test 2: X25519 — Two-party key agreement (GEK wrapping) ===") + +def test_x25519_agreement(): + 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, "Shared secrets don't match" + return f"shared secret {len(shared_a)} bytes, both sides match" + +def test_x25519_different_pairs(): + sk_a = X25519PrivateKey.generate() + sk_b = X25519PrivateKey.generate() + sk_c = X25519PrivateKey.generate() + shared_ab = sk_a.exchange(sk_b.public_key()) + shared_ac = sk_a.exchange(sk_c.public_key()) + assert shared_ab != shared_ac, "Different pairs should produce different secrets" + return "different key pairs produce different secrets" + +check("Two-party agreement", test_x25519_agreement) +check("Different pairs ≠ same secret", test_x25519_different_pairs) + + +print("\n=== Test 3: ChaCha20-Poly1305 — GEK encryption ===") + +gek_raw = ChaCha20Poly1305.generate_key() +cipher_gek = ChaCha20Poly1305(gek_raw) +CHUNK_SIZE = 1024 * 1024 # 1 MB +chunk_data = os.urandom(CHUNK_SIZE) + +def test_chacha_roundtrip(): + nonce = os.urandom(12) + ct = cipher_gek.encrypt(nonce, chunk_data, None) + pt = cipher_gek.decrypt(nonce, ct, None) + assert pt == chunk_data, "Decrypted data doesn't match" + overhead = len(ct) - len(chunk_data) + return f"1 MB roundtrip OK, AEAD overhead={overhead}B" + +def test_chacha_perf(): + times = [] + for _ in range(5): + nonce = os.urandom(12) + t0 = time.perf_counter() + ct = cipher_gek.encrypt(nonce, chunk_data, None) + cipher_gek.decrypt(nonce, ct, None) + times.append(time.perf_counter() - t0) + avg_ms = sum(times) / len(times) * 1000 + throughput = (CHUNK_SIZE * 2) / (sum(times) / len(times)) / (1024**2) + return f"avg {avg_ms:.1f}ms/chunk (encrypt+decrypt), {throughput:.0f} MB/s" + +def test_chacha_tamper(): + nonce = os.urandom(12) + ct = bytearray(cipher_gek.encrypt(nonce, b"secret", None)) + ct[0] ^= 0xFF # flip a bit + try: + cipher_gek.decrypt(nonce, bytes(ct), None) + return f"{FAIL} should have raised" + except Exception: + return "tampered ciphertext correctly rejected" + +check("Encrypt/decrypt roundtrip (1 MB)", test_chacha_roundtrip) +check("Performance (5 runs)", test_chacha_perf) +check("Tampered ciphertext rejected", test_chacha_tamper) + + +print("\n=== Test 4: HKDF — Per-chunk key derivation ===") + +def test_hkdf_chunk_keys(): + file_hash = blake3.blake3(chunk_data).digest() + keys = [] + for i in range(3): + key = HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=None, + info=b"file:" + file_hash + b":chunk:" + i.to_bytes(4, "big") + ).derive(gek_raw) + keys.append(key) + assert keys[0] != keys[1] != keys[2], "Chunk keys must differ" + return f"3 distinct chunk keys derived, each {len(keys[0])} bytes" + +def test_hkdf_same_input_same_output(): + file_hash = blake3.blake3(chunk_data).digest() + key1 = HKDF( + algorithm=hashes.SHA256(), length=32, salt=None, + info=b"file:" + file_hash + b":chunk:" + (0).to_bytes(4, "big") + ).derive(gek_raw) + key2 = HKDF( + algorithm=hashes.SHA256(), length=32, salt=None, + info=b"file:" + file_hash + b":chunk:" + (0).to_bytes(4, "big") + ).derive(gek_raw) + assert key1 == key2, "Same input must produce same key" + return "deterministic: same input → same key" + +check("3 distinct chunk keys", test_hkdf_chunk_keys) +check("Deterministic derivation", test_hkdf_same_input_same_output) + + +print("\n=== Test 5: blake3 — Content hashing ===") + +def test_blake3_hash(): + data = os.urandom(1024 * 1024) + t0 = time.perf_counter() + h = blake3.blake3(data).digest() + elapsed = (time.perf_counter() - t0) * 1000 + return f"1 MB hashed in {elapsed:.1f}ms, digest={h.hex()[:16]}..." + +def test_blake3_deterministic(): + data = b"test content" + h1 = blake3.blake3(data).digest() + h2 = blake3.blake3(data).digest() + assert h1 == h2 + return "deterministic hashing confirmed" + +def test_blake3_different_data(): + h1 = blake3.blake3(b"file chunk 0").digest() + h2 = blake3.blake3(b"file chunk 1").digest() + assert h1 != h2 + return "different data → different hashes" + +check("1 MB hash performance", test_blake3_hash) +check("Deterministic", test_blake3_deterministic) +check("Collision resistance (basic)", test_blake3_different_data) + + +print("\n=== Test 6: Argon2id — Keystore key derivation ===") + +def test_argon2id_derive(): + 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"my_node_password") + elapsed = (time.perf_counter() - t0) * 1000 + return f"derived {len(key)}-byte key in {elapsed:.0f}ms" + +def test_argon2id_different_salts(): + pw = b"same_password" + salt1, salt2 = os.urandom(16), os.urandom(16) + k1 = Argon2id(salt=salt1, length=32, iterations=3, lanes=4, memory_cost=65536).derive(pw) + k2 = Argon2id(salt=salt2, length=32, iterations=3, lanes=4, memory_cost=65536).derive(pw) + assert k1 != k2 + return "different salts → different keys (no rainbow table attack)" + +def test_argon2id_verify(): + salt = os.urandom(16) + kdf1 = Argon2id(salt=salt, length=32, iterations=3, lanes=4, memory_cost=65536) + key = kdf1.derive(b"correct_password") + kdf2 = Argon2id(salt=salt, length=32, iterations=3, lanes=4, memory_cost=65536) + kdf2.verify(b"correct_password", key) + try: + kdf3 = Argon2id(salt=salt, length=32, iterations=3, lanes=4, memory_cost=65536) + kdf3.verify(b"wrong_password", key) + return f"{FAIL} wrong password should be rejected" + except Exception: + return "correct password accepted, wrong password rejected" + +check("Key derivation (~1s target)", test_argon2id_derive) +check("Salt uniqueness", test_argon2id_different_salts) +check("Verify correct/wrong password", test_argon2id_verify) + + +print("\n=== Test 7: AES-256-GCM — Keystore encryption ===") + +def test_aes_gcm_keystore(): + # Derive an AES key from Argon2id (as done for keystore unlock) + salt = os.urandom(16) + aes_key = Argon2id(salt=salt, length=32, iterations=3, lanes=4, memory_cost=65536).derive(b"password") + + # Encrypt a mock keystore blob + keystore_data = b'{"sk_user": "base64...", "sk_group": "base64..."}' + iv = os.urandom(16) + encryptor = Cipher(algorithms.AES(aes_key), modes.GCM(iv)).encryptor() + ct = encryptor.update(keystore_data) + encryptor.finalize() + tag = encryptor.tag + + # Decrypt + decryptor = Cipher(algorithms.AES(aes_key), modes.GCM(iv, tag)).decryptor() + pt = decryptor.update(ct) + decryptor.finalize() + assert pt == keystore_data + return f"keystore encrypt/decrypt OK ({len(keystore_data)}B → {len(ct)}B + {len(tag)}B tag)" + +check("AES-256-GCM keystore roundtrip", test_aes_gcm_keystore) + + +print("\n=== Test 8: PyJWT EdDSA — Hub JWT issuance and offline verification ===") + +sk_hub_pem = sk_hub.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption() +) +pk_hub_pem = pk_hub.public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo +) +pk_user_raw_b64 = base64.b64encode( + pk_user.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) +).decode() + +def test_jwt_issue_and_verify(): + payload = { + "iss": "meshbay.org", + "sub": "user-uuid-1234", + "pk_user": pk_user_raw_b64, + "hub_id": "meshbay.org", + "iat": int(time.time()), + "exp": int(time.time()) + 3600, + } + token = jwt.encode(payload, sk_hub_pem, algorithm="EdDSA") + decoded = jwt.decode(token, pk_hub_pem, algorithms=["EdDSA"]) + assert decoded["sub"] == payload["sub"] + assert decoded["hub_id"] == "meshbay.org" + assert decoded["pk_user"] == pk_user_raw_b64 + return f"token {len(token)} chars, all claims verified offline" + +def test_jwt_tampered_rejected(): + payload = {"sub": "user-1", "exp": int(time.time()) + 3600} + token = jwt.encode(payload, sk_hub_pem, algorithm="EdDSA") + # Tamper: flip one char in the signature (last segment) + parts = token.split(".") + tampered = parts[0] + "." + parts[1] + "." + parts[2][:-4] + "AAAA" + try: + jwt.decode(tampered, pk_hub_pem, algorithms=["EdDSA"]) + return f"{FAIL} tampered token should be rejected" + except Exception: + return "tampered JWT correctly rejected" + +def test_jwt_wrong_key_rejected(): + sk_other = Ed25519PrivateKey.generate() + sk_other_pem = sk_other.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption() + ) + payload = {"sub": "attacker", "exp": int(time.time()) + 3600} + fake_token = jwt.encode(payload, sk_other_pem, algorithm="EdDSA") + try: + jwt.decode(fake_token, pk_hub_pem, algorithms=["EdDSA"]) + return f"{FAIL} wrong key should be rejected" + except Exception: + return "token signed with wrong key correctly rejected" + +def test_jwt_expired_rejected(): + payload = {"sub": "user-1", "exp": int(time.time()) - 10} # already expired + token = jwt.encode(payload, sk_hub_pem, algorithm="EdDSA") + try: + jwt.decode(token, pk_hub_pem, algorithms=["EdDSA"]) + return f"{FAIL} expired token should be rejected" + except jwt.ExpiredSignatureError: + return "expired JWT correctly rejected" + +check("Issue and verify offline (no hub call)", test_jwt_issue_and_verify) +check("Tampered JWT rejected", test_jwt_tampered_rejected) +check("Wrong signing key rejected", test_jwt_wrong_key_rejected) +check("Expired JWT rejected", test_jwt_expired_rejected) + + +print("\n=== Test 9: Full pipeline — Encrypt chunk + sign + verify + decrypt ===") + +def test_full_pipeline(): + # Simulate: node encrypts a chunk and signs it; client verifies and decrypts + + # Node side + file_data = os.urandom(CHUNK_SIZE) + file_hash = blake3.blake3(file_data).digest() + chunk_index = 0 + + # Derive per-chunk key + chunk_key_raw = HKDF( + algorithm=hashes.SHA256(), length=32, salt=None, + info=b"file:" + file_hash + b":chunk:" + chunk_index.to_bytes(4, "big") + ).derive(gek_raw) + chunk_cipher = ChaCha20Poly1305(chunk_key_raw) + + nonce = os.urandom(12) + t0 = time.perf_counter() + ciphertext = chunk_cipher.encrypt(nonce, file_data, None) + ct_hash = blake3.blake3(ciphertext).digest() + + # Sign: chunk_index || nonce || ciphertext_hash + sig_payload = chunk_index.to_bytes(4, "big") + nonce + ct_hash + sig = sk_user.sign(sig_payload) + encrypt_ms = (time.perf_counter() - t0) * 1000 + + # Client side + t1 = time.perf_counter() + # 1. Verify signature + pk_user.verify(sig, sig_payload) + # 2. Verify ciphertext hash + assert blake3.blake3(ciphertext).digest() == ct_hash + # 3. Decrypt + plaintext = chunk_cipher.decrypt(nonce, ciphertext, None) + assert plaintext == file_data + verify_ms = (time.perf_counter() - t1) * 1000 + + return f"1 MB: encrypt+sign={encrypt_ms:.1f}ms, verify+decrypt={verify_ms:.1f}ms" + +check("Full encrypt→sign→verify→decrypt pipeline (1 MB)", test_full_pipeline) + + +print("\n" + "="*55) +passed = sum(1 for _, ok in results if ok) +total = len(results) +print(f"Results: {passed}/{total} passed") +if passed == total: + print("All crypto primitives validated. Spike 1 COMPLETE.") +else: + print("Some tests failed — review above.") + failed = [name for name, ok in results if not ok] + for name in failed: + print(f" {FAIL} {name}") + sys.exit(1) diff --git a/poc/spike2_hub.py b/poc/spike2_hub.py new file mode 100644 index 0000000..f8c1b08 --- /dev/null +++ b/poc/spike2_hub.py @@ -0,0 +1,268 @@ +""" +MeshBay Hub POC v1 +Minimal FastAPI hub: user registration, JWT issuance, node announcement. +In-memory storage only — not persistent across restarts. +""" + +from fastapi import FastAPI, HTTPException, Depends, Header +from pydantic import BaseModel +from cryptography.hazmat.primitives.kdf.argon2 import Argon2id +import jwt, uuid, os, time, base64 + +app = FastAPI(title="MeshBay Hub POC", version="0.1.0") + +HUB_ID = "meshbay.org" +MNP_VERSION = "0.1" +MHP_VERSION = "0.1" +ACCESS_TOKEN_TTL = 3600 # 1 hour +REFRESH_TOKEN_TTL = 86400 * 30 # 30 days + +# Load hub keypair (generated once with gen_hub_keys.py) +with open("hub_private.pem", "rb") as f: + HUB_SK_PEM = f.read() +with open("hub_public.pem", "rb") as f: + HUB_PK_PEM = f.read() + +# In-memory stores (POC — lost on restart) +users: dict = {} # username → user record +nodes: dict = {} # node_id → node record +refresh_tokens: dict = {} # token → user_id +groups: dict = {} # group_id → group record +gek_bundles: dict = {} # (group_id, user_id) → encrypted GEK bundle + + +# ── Models ──────────────────────────────────────────────────────────────────── + +class UserRegister(BaseModel): + username: str + password: str + pk_user_ed25519: str # base64 raw 32 bytes + pk_user_x25519: str # base64 raw 32 bytes + +class UserLogin(BaseModel): + username: str + password: str + +class RefreshRequest(BaseModel): + refresh_token: str + +class NodeAnnounce(BaseModel): + pk_node: str # base64 Ed25519 raw public key + endpoint_hint: str | None = None # "ip:port" discovered via STUN/UPnP + +class GroupCreate(BaseModel): + name: str + +class GEKBundle(BaseModel): + pk_eph_b64: str # ephemeral X25519 public key used during wrapping + nonce_b64: str # ChaCha20-Poly1305 nonce + wrapped_b64: str # encrypted GEK (opaque to hub) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _hash_password(password: str) -> tuple[bytes, bytes]: + salt = os.urandom(16) + kdf = Argon2id(salt=salt, length=32, iterations=3, lanes=4, memory_cost=65536) + return kdf.derive(password.encode()), salt + +def _verify_password(password: str, pw_hash: bytes, salt: bytes) -> bool: + try: + Argon2id(salt=salt, length=32, iterations=3, lanes=4, memory_cost=65536 + ).verify(password.encode(), pw_hash) + return True + except Exception: + return False + +def _issue_access_token(user: dict) -> str: + now = int(time.time()) + payload = { + "iss": HUB_ID, + "sub": user["user_id"], + "pk_user": user["pk_ed25519"], + "hub_id": HUB_ID, + "jti": str(uuid.uuid4()), # unique per token — enables revocation, prevents replay + "iat": now, + "exp": now + ACCESS_TOKEN_TTL, + } + return jwt.encode(payload, HUB_SK_PEM, algorithm="EdDSA") + +def _get_current_user(authorization: str = Header(...)) -> dict: + try: + scheme, token = authorization.split(None, 1) + if scheme.lower() != "bearer": + raise ValueError("Not bearer") + payload = jwt.decode(token, HUB_PK_PEM, algorithms=["EdDSA"]) + user = next((u for u in users.values() if u["user_id"] == payload["sub"]), None) + if not user: + raise HTTPException(status_code=401, detail="User not found") + return user + except HTTPException: + raise + except Exception: + raise HTTPException(status_code=401, detail="Invalid or expired token") + + +# ── Endpoints ───────────────────────────────────────────────────────────────── + +@app.get("/v1/hub/info") +def hub_info(): + return { + "hub_id": HUB_ID, + "mnp_version": MNP_VERSION, + "mhp_version": MHP_VERSION, + "users": len(users), + "nodes": len(nodes), + } + +@app.get("/v1/hub/pubkey") +def hub_pubkey(): + """Return hub Ed25519 public key PEM — nodes cache this on first contact.""" + return {"pk_hub_pem": HUB_PK_PEM.decode()} + +@app.post("/v1/users/register", status_code=201) +def register(body: UserRegister): + if body.username in users: + raise HTTPException(status_code=409, detail="Username already taken") + if len(body.password) < 8: + raise HTTPException(status_code=422, detail="Password too short") + pw_hash, pw_salt = _hash_password(body.password) + user_id = str(uuid.uuid4()) + users[body.username] = { + "user_id": user_id, + "username": body.username, + "pw_hash": pw_hash, + "pw_salt": pw_salt, + "pk_ed25519": body.pk_user_ed25519, + "pk_x25519": body.pk_user_x25519, + "created_at": int(time.time()), + } + return {"user_id": user_id} + +@app.post("/v1/users/login") +def login(body: UserLogin): + user = users.get(body.username) + if not user or not _verify_password(body.password, user["pw_hash"], user["pw_salt"]): + raise HTTPException(status_code=401, detail="Invalid credentials") + access_token = _issue_access_token(user) + refresh_token = base64.urlsafe_b64encode(os.urandom(32)).decode() + refresh_tokens[refresh_token] = user["user_id"] + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer", + "expires_in": ACCESS_TOKEN_TTL, + } + +@app.post("/v1/users/token/refresh") +def token_refresh(body: RefreshRequest): + user_id = refresh_tokens.get(body.refresh_token) + if not user_id: + raise HTTPException(status_code=401, detail="Invalid refresh token") + user = next((u for u in users.values() if u["user_id"] == user_id), None) + if not user: + raise HTTPException(status_code=401, detail="User not found") + return { + "access_token": _issue_access_token(user), + "token_type": "bearer", + "expires_in": ACCESS_TOKEN_TTL, + } + +@app.post("/v1/nodes/announce", status_code=201) +def announce_node(body: NodeAnnounce, user: dict = Depends(_get_current_user)): + node_id = str(uuid.uuid4()) + nodes[node_id] = { + "node_id": node_id, + "user_id": user["user_id"], + "username": user["username"], + "pk_node": body.pk_node, + "endpoint_hint": body.endpoint_hint, + "announced_at": int(time.time()), + } + return {"node_id": node_id} + +@app.get("/v1/nodes/{node_id}") +def get_node(node_id: str, user: dict = Depends(_get_current_user)): + node = nodes.get(node_id) + if not node: + raise HTTPException(status_code=404, detail="Node not found") + return { + "node_id": node["node_id"], + "username": node["username"], + "pk_node": node["pk_node"], + "endpoint_hint": node["endpoint_hint"], + "announced_at": node["announced_at"], + } + +@app.get("/v1/users/{username}/pubkeys") +def get_user_pubkeys(username: str, user: dict = Depends(_get_current_user)): + """Return a user's public keys so the admin can wrap the GEK for them.""" + target = users.get(username) + if not target: + raise HTTPException(status_code=404, detail="User not found") + return { + "user_id": target["user_id"], + "username": target["username"], + "pk_ed25519": target["pk_ed25519"], + "pk_x25519": target["pk_x25519"], + } + +@app.post("/v1/groups", status_code=201) +def create_group(body: GroupCreate, user: dict = Depends(_get_current_user)): + group_id = str(uuid.uuid4()) + groups[group_id] = { + "group_id": group_id, + "name": body.name, + "admin_id": user["user_id"], + "admin_name": user["username"], + "created_at": int(time.time()), + "members": [user["user_id"]], + } + return {"group_id": group_id, "name": body.name} + +@app.post("/v1/groups/{group_id}/members/{username}/gek", status_code=201) +def store_gek_bundle( + group_id: str, username: str, + body: GEKBundle, + user: dict = Depends(_get_current_user) +): + """Admin stores an encrypted GEK bundle for a group member. + The hub stores the bundle opaquely — it cannot decrypt it.""" + group = groups.get(group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + if group["admin_id"] != user["user_id"]: + raise HTTPException(status_code=403, detail="Only group admin can add members") + target = users.get(username) + if not target: + raise HTTPException(status_code=404, detail="User not found") + + key = (group_id, target["user_id"]) + gek_bundles[key] = { + "group_id": group_id, + "user_id": target["user_id"], + "pk_eph_b64": body.pk_eph_b64, + "nonce_b64": body.nonce_b64, + "wrapped_b64": body.wrapped_b64, + "stored_at": int(time.time()), + } + if target["user_id"] not in group["members"]: + group["members"].append(target["user_id"]) + return {"status": "stored", "group_id": group_id, "username": username} + +@app.get("/v1/groups/{group_id}/gek") +def get_my_gek_bundle(group_id: str, user: dict = Depends(_get_current_user)): + """Authenticated member retrieves their own encrypted GEK bundle.""" + group = groups.get(group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + key = (group_id, user["user_id"]) + bundle = gek_bundles.get(key) + if not bundle: + raise HTTPException(status_code=404, detail="No GEK bundle for this user in this group") + return { + "group_id": group_id, + "pk_eph_b64": bundle["pk_eph_b64"], + "nonce_b64": bundle["nonce_b64"], + "wrapped_b64": bundle["wrapped_b64"], + } diff --git a/poc/spike3_node.py b/poc/spike3_node.py new file mode 100644 index 0000000..c0ddde0 --- /dev/null +++ b/poc/spike3_node.py @@ -0,0 +1,186 @@ +""" +MeshBay Node POC v1 — Spike 3: Hub-Node registration and handshake. + +Sequence: + 1. Load or generate node identity keypairs (Ed25519 + X25519) + 2. Fetch and cache hub public key (first contact only) + 3. Register user on hub (skip if already registered) + 4. Login → receive access token (JWT) + refresh token + 5. Verify JWT OFFLINE using hub public key — no hub roundtrip + 6. Announce node to hub with endpoint hint + 7. Retrieve node record from hub to confirm round-trip + 8. Simulate token refresh +""" + +import asyncio, httpx, base64, json, os, time +from pathlib import Path +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from cryptography.hazmat.primitives import serialization +import jwt + +HUB_URL = "http://meshbay.org" +STATE_FILE = Path("node_state.json") # persists keys and tokens between runs + +PASS = "✓"; FAIL = "✗" + +# ── Key helpers ─────────────────────────────────────────────────────────────── + +def _sk_to_b64(sk) -> str: + return base64.b64encode( + sk.private_bytes(serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + serialization.NoEncryption()) + ).decode() + +def _pk_to_b64(pk) -> str: + return base64.b64encode( + pk.public_bytes(serialization.Encoding.Raw, + serialization.PublicFormat.Raw) + ).decode() + +def _load_or_generate_keys(state: dict) -> tuple: + """Return (sk_ed, sk_x) — load from state or generate fresh.""" + if "sk_ed25519_b64" in state: + sk_ed = Ed25519PrivateKey.from_private_bytes( + base64.b64decode(state["sk_ed25519_b64"])) + sk_x = X25519PrivateKey.from_private_bytes( + base64.b64decode(state["sk_x25519_b64"])) + print(f" Loaded existing keypair from {STATE_FILE}") + else: + sk_ed = Ed25519PrivateKey.generate() + sk_x = X25519PrivateKey.generate() + state["sk_ed25519_b64"] = _sk_to_b64(sk_ed) + state["sk_x25519_b64"] = _sk_to_b64(sk_x) + print(f" Generated new keypair") + return sk_ed, sk_x + +# ── Main ────────────────────────────────────────────────────────────────────── + +async def main(): + print("\n=== MeshBay Node POC — Spike 3: Hub-Node Handshake ===\n") + + # Load persisted state (keys, tokens) or start fresh + state = json.loads(STATE_FILE.read_text()) if STATE_FILE.exists() else {} + + # ── Step 1: Identity keypairs ───────────────────────────────────────────── + print("[ 1 ] Identity keypairs") + sk_ed, sk_x = _load_or_generate_keys(state) + pk_ed_b64 = _pk_to_b64(sk_ed.public_key()) + pk_x_b64 = _pk_to_b64(sk_x.public_key()) + print(f" Ed25519 PK: {pk_ed_b64[:24]}...") + print(f" X25519 PK: {pk_x_b64[:24]}...") + + async with httpx.AsyncClient(timeout=10) as client: + + # ── Step 2: Hub public key (cache after first fetch) ────────────────── + print("\n[ 2 ] Hub public key") + if "hub_pk_pem" not in state: + r = await client.get(f"{HUB_URL}/v1/hub/pubkey") + r.raise_for_status() + state["hub_pk_pem"] = r.json()["pk_hub_pem"] + print(f" {PASS} Fetched from hub and cached ({len(state['hub_pk_pem'])}B PEM)") + else: + print(f" Using cached hub PK ({len(state['hub_pk_pem'])}B) — no hub call") + hub_pk_pem = state["hub_pk_pem"].encode() + + # ── Step 3: Register user ───────────────────────────────────────────── + print("\n[ 3 ] User registration") + username = state.get("username", "node_cbesson") + password = state.get("password", "nodepass42!") + state["username"] = username + state["password"] = password + + r = await client.post(f"{HUB_URL}/v1/users/register", json={ + "username": username, + "password": password, + "pk_user_ed25519": pk_ed_b64, + "pk_user_x25519": pk_x_b64, + }) + if r.status_code == 201: + state["user_id"] = r.json()["user_id"] + print(f" {PASS} Registered — user_id={state['user_id'][:8]}...") + elif r.status_code == 409: + print(f" Already registered (409) — continuing with existing account") + else: + raise RuntimeError(f"Register failed: {r.status_code} {r.text}") + + # ── Step 4: Login ───────────────────────────────────────────────────── + print("\n[ 4 ] Login") + r = await client.post(f"{HUB_URL}/v1/users/login", json={ + "username": username, + "password": password, + }) + r.raise_for_status() + data = r.json() + access_token = data["access_token"] + refresh_token = data["refresh_token"] + state["refresh_token"] = refresh_token + print(f" {PASS} Login OK") + print(f" access_token : {access_token[:40]}...") + print(f" refresh_token : {refresh_token[:20]}...") + print(f" expires_in : {data['expires_in']}s") + + # ── Step 5: Verify JWT OFFLINE ──────────────────────────────────────── + print("\n[ 5 ] JWT offline verification (no hub call)") + t0 = time.perf_counter() + decoded = jwt.decode(access_token, hub_pk_pem, algorithms=["EdDSA"]) + elapsed_us = (time.perf_counter() - t0) * 1_000_000 + + assert decoded["hub_id"] == "meshbay.org", "hub_id mismatch" + assert decoded["pk_user"] == pk_ed_b64, "pk_user mismatch" + assert decoded["exp"] > int(time.time()), "token already expired" + + print(f" {PASS} Signature valid") + print(f" {PASS} hub_id = {decoded['hub_id']}") + print(f" {PASS} sub = {decoded['sub'][:8]}...") + print(f" {PASS} pk_user matches local Ed25519 public key") + print(f" {PASS} Verified in {elapsed_us:.0f} µs — hub completely out of loop") + + # ── Step 6: Announce node ───────────────────────────────────────────── + print("\n[ 6 ] Node announcement") + headers = {"Authorization": f"Bearer {access_token}"} + + # For now, endpoint_hint = None (NAT traversal in Spike 4) + r = await client.post(f"{HUB_URL}/v1/nodes/announce", + json={"pk_node": pk_ed_b64, "endpoint_hint": None}, + headers=headers) + r.raise_for_status() + node_id = r.json()["node_id"] + state["node_id"] = node_id + print(f" {PASS} Node announced — node_id={node_id[:8]}...") + print(f" endpoint_hint : None (STUN/UPnP discovery in Spike 4)") + + # ── Step 7: Retrieve node record ────────────────────────────────────── + print("\n[ 7 ] Retrieve node record from hub") + r = await client.get(f"{HUB_URL}/v1/nodes/{node_id}", headers=headers) + r.raise_for_status() + node = r.json() + assert node["pk_node"] == pk_ed_b64, "pk_node mismatch" + print(f" {PASS} node_id = {node['node_id'][:8]}...") + print(f" {PASS} pk_node matches local Ed25519 public key") + print(f" {PASS} endpoint_hint = {node['endpoint_hint']}") + print(f" {PASS} username = {node['username']}") + + # ── Step 8: Token refresh ───────────────────────────────────────────── + print("\n[ 8 ] Token refresh (simulate session renewal)") + r = await client.post(f"{HUB_URL}/v1/users/token/refresh", + json={"refresh_token": refresh_token}) + r.raise_for_status() + new_token = r.json()["access_token"] + new_decoded = jwt.decode(new_token, hub_pk_pem, algorithms=["EdDSA"]) + assert new_decoded["sub"] == decoded["sub"], "sub changed after refresh" + assert new_token != access_token, "refresh should issue a new token" + print(f" {PASS} New access token issued") + print(f" {PASS} New token verified offline — same sub, new exp={new_decoded['exp']}") + + # Persist state for next spikes + STATE_FILE.write_text(json.dumps(state, indent=2)) + print(f"\n State saved to {STATE_FILE}") + + print("\n" + "="*55) + print("Spike 3 COMPLETE — Hub-Node handshake fully validated.") + print(f" Hub at {HUB_URL} is reachable and functional.") + print(f" JWT offline verification: {elapsed_us:.0f} µs (no hub needed).") + +asyncio.run(main()) diff --git a/poc/spike4_nat.py b/poc/spike4_nat.py new file mode 100644 index 0000000..44bcdca --- /dev/null +++ b/poc/spike4_nat.py @@ -0,0 +1,279 @@ +""" +MeshBay Node POC — Spike 4: NAT Traversal (v2) + +Tests: + A. STUN — external address discovery (pure UDP, no lib) + B. NAT type probe — does external port change per destination? (cone vs symmetric) + C. Bidirectional UDP hole punching: + node → meshbay.org:19002 (creates NAT entry for that dest) + meshbay.org receives, echoes to the source addr it saw + node receives echo → bidirectional UDP confirmed + D. UPnP — attempt port mapping on SFR box + E. Update hub endpoint_hint +""" + +import asyncio, socket, struct, os, json, httpx, time, subprocess +from pathlib import Path + +PASS = "✓"; FAIL = "✗"; SKIP = "–" + +LOCAL_PORT = 19000 +MESHBAY_IP = "164.132.246.44" # meshbay.org resolved +MESHBAY_ECHO_PORT = 19002 +STATE_FILE = Path("node_state.json") +HUB_URL = "http://meshbay.org" +MESH_SERVER = "cbesson@meshbay.org" + +STUN_SERVERS = [ + ("stun.cloudflare.com", 3478), + ("stun.l.google.com", 19302), +] + +# ── STUN (pure UDP) ──────────────────────────────────────────────────────────── + +def stun_query(local_port: int, stun_host: str, stun_port: int) -> tuple[str|None, int|None]: + """Single STUN query from local_port. Returns (ext_ip, ext_port) or (None, None).""" + MAGIC = 0x2112A442 + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(3) + sock.bind(('', local_port)) + txn = os.urandom(12) + sock.sendto(struct.pack('>HHI12s', 0x0001, 0, MAGIC, txn), (stun_host, stun_port)) + data, _ = sock.recvfrom(1024) + sock.close() + msg_type, msg_len, magic, _ = struct.unpack_from('>HHI12s', data) + if msg_type != 0x0101 or magic != MAGIC: + return None, None + offset = 20 + while offset < 20 + msg_len: + atype, alen = struct.unpack_from('>HH', data, offset) + offset += 4 + if atype == 0x0020: + family = struct.unpack_from('>xB', data, offset)[0] + if family == 0x01: + xport = struct.unpack_from('>H', data, offset + 2)[0] + xaddr = struct.unpack_from('>I', data, offset + 4)[0] + return (socket.inet_ntoa(struct.pack('>I', xaddr ^ MAGIC)), + xport ^ (MAGIC >> 16)) + offset += alen + (4 - alen % 4) % 4 + except Exception: + pass + return None, None + + +# ── UPnP ────────────────────────────────────────────────────────────────────── + +def try_upnp(port: int) -> tuple[str|None, int|None]: + try: + import miniupnpc + u = miniupnpc.UPnP() + u.discoverdelay = 500 + if u.discover() == 0: + return None, None + u.selectigd() + ext_ip = u.externalipaddress() + local_ip = u.lanaddr + if u.addportmapping(port, 'TCP', local_ip, port, 'MeshBay POC', ''): + return ext_ip, port + except Exception: + pass + return None, None + + +# ── Bidirectional UDP hole punch test ───────────────────────────────────────── + +async def start_echo_server_on_meshbay() -> asyncio.subprocess.Process: + """SSH to meshbay.org and start a one-shot UDP echo server.""" + echo_script = ( + f"python3 -c \"" + f"import socket;" + f"s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);" + f"s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1);" + f"s.bind(('0.0.0.0',{MESHBAY_ECHO_PORT}));" + f"s.settimeout(15);" + f"print('ECHO_READY',flush=True);" + f"data,addr=s.recvfrom(256);" + f"print('RECEIVED from',addr,'data',data.decode(),flush=True);" + f"s.sendto(b'ECHO:'+data,addr);" + f"print('ECHOED to',addr,flush=True);" + f"s.close()\"" + ) + proc = await asyncio.create_subprocess_exec( + 'ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'ConnectTimeout=5', + MESH_SERVER, echo_script, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + # Wait for ECHO_READY + line = await asyncio.wait_for(proc.stdout.readline(), timeout=8) + if b'ECHO_READY' not in line: + proc.terminate() + return None + return proc + + +async def udp_hole_punch_test(local_port: int) -> tuple[bool, str|None, str|None]: + """ + 1. Start UDP listener locally + 2. Send probe to meshbay.org echo server (creates NAT mapping) + 3. Echo server responds to actual source addr/port it received + 4. Check if echo arrives locally + Returns (success, seen_ext_addr, echo_content) + """ + loop = asyncio.get_event_loop() + result = {'addr': None, 'data': None} + recv_event = asyncio.Event() + + class RxProtocol(asyncio.DatagramProtocol): + def __init__(self, transport_holder): + self._transport_holder = transport_holder + def connection_made(self, transport): + self._transport_holder.append(transport) + def datagram_received(self, data, addr): + result['addr'] = addr + result['data'] = data + recv_event.set() + def error_received(self, exc): + pass + + transport_holder = [] + transport, _ = await loop.create_datagram_endpoint( + lambda: RxProtocol(transport_holder), + local_addr=('0.0.0.0', local_port) + ) + + # Send probe — this is the hole punch + transport.sendto(b'PING:MESHBAY:HOLEPUNCH', (MESHBAY_IP, MESHBAY_ECHO_PORT)) + + try: + await asyncio.wait_for(recv_event.wait(), timeout=8) + except asyncio.TimeoutError: + pass + finally: + transport.close() + + if result['data']: + return True, str(result['addr']), result['data'].decode() + return False, None, None + + +# ── Main ────────────────────────────────────────────────────────────────────── + +async def main(): + print("\n=== MeshBay Node POC — Spike 4: NAT Traversal ===\n") + + state = json.loads(STATE_FILE.read_text()) if STATE_FILE.exists() else {} + + endpoint_hint = None + stun_ip1 = stun_ip2 = None + stun_port1 = stun_port2 = None + + # ── Phase A: STUN discovery ──────────────────────────────────────────────── + print("[ A ] STUN external address discovery") + stun_ip1, stun_port1 = stun_query(LOCAL_PORT, *STUN_SERVERS[0]) + if stun_ip1: + print(f" {PASS} via {STUN_SERVERS[0][0]}: {stun_ip1}:{stun_port1}") + else: + print(f" {FAIL} {STUN_SERVERS[0][0]} unreachable") + + # ── Phase B: NAT type probe (cone vs symmetric) ──────────────────────────── + print("\n[ B ] NAT type detection") + stun_ip2, stun_port2 = stun_query(LOCAL_PORT, *STUN_SERVERS[1]) + if stun_ip1 and stun_ip2: + if stun_port1 == stun_port2: + nat_type = "Cone NAT (same external port for both STUN servers)" + print(f" {PASS} {nat_type}") + print(f" Cloudflare STUN : {stun_ip1}:{stun_port1}") + print(f" Google STUN : {stun_ip2}:{stun_port2}") + endpoint_hint = f"{stun_ip1}:{stun_port1}" + else: + nat_type = "Symmetric NAT (different port per destination)" + print(f" {FAIL} {nat_type}") + print(f" Cloudflare STUN : {stun_ip1}:{stun_port1}") + print(f" Google STUN : {stun_ip2}:{stun_port2}") + print(f" → Hole punching unreliable; TURN relay required") + elif stun_ip1: + nat_type = "Unknown (only one STUN server responded)" + print(f" {SKIP} {nat_type}") + endpoint_hint = f"{stun_ip1}:{stun_port1}" + else: + nat_type = "Unknown (STUN unavailable)" + print(f" {FAIL} {nat_type}") + + # ── Phase C: Bidirectional UDP hole punching ─────────────────────────────── + print(f"\n[ C ] Bidirectional UDP hole punch (node → meshbay.org → echo back)") + print(f" Starting UDP echo server on meshbay.org:{MESHBAY_ECHO_PORT} ...") + udp_ok = False + seen_ext_addr = None + try: + echo_proc = await asyncio.wait_for( + start_echo_server_on_meshbay(), timeout=10) + if echo_proc: + print(f" Echo server ready. Sending probe from local:{LOCAL_PORT} ...") + udp_ok, seen_ext_addr, echo_data = await udp_hole_punch_test(LOCAL_PORT) + stdout, _ = await asyncio.wait_for(echo_proc.communicate(), timeout=5) + server_log = stdout.decode().strip() + + if udp_ok: + print(f" {PASS} Echo received: '{echo_data}'") + print(f" {PASS} meshbay.org saw us as: {seen_ext_addr}") + print(f" {PASS} Server log: {server_log}") + # seen_ext_addr is the actual external addr for meshbay.org dest + # May differ from STUN if symmetric NAT + actual_ext = seen_ext_addr.replace("('", "").replace("'", "").replace(", ", ":") + if endpoint_hint and actual_ext != endpoint_hint: + print(f" ⚠ STUN addr {endpoint_hint} ≠ actual {actual_ext} (symmetric NAT confirmed)") + endpoint_hint = actual_ext + else: + print(f" {FAIL} No echo received (timeout)") + print(f" Server log: {server_log}") + else: + print(f" {FAIL} Could not start echo server on meshbay.org") + except Exception as e: + print(f" {FAIL} Error: {e}") + + # ── Phase D: UPnP ───────────────────────────────────────────────────────── + print(f"\n[ D ] UPnP port mapping") + upnp_ip, upnp_port = try_upnp(LOCAL_PORT) + if upnp_ip: + print(f" {PASS} Mapped {upnp_ip}:{upnp_port}") + endpoint_hint = f"{upnp_ip}:{upnp_port}" + else: + print(f" {FAIL} UPnP not available on this router") + + # ── Phase E: Update hub ──────────────────────────────────────────────────── + print(f"\n[ E ] Update endpoint_hint on hub") + if endpoint_hint and state.get("username"): + async with httpx.AsyncClient(timeout=10) as client: + r = await client.post(f"{HUB_URL}/v1/users/login", json={ + "username": state["username"], "password": state["password"]}) + access_token = r.json()["access_token"] + pk_b64 = state.get("sk_ed25519_b64", "") + r = await client.post(f"{HUB_URL}/v1/nodes/announce", + json={"pk_node": pk_b64, "endpoint_hint": endpoint_hint}, + headers={"Authorization": f"Bearer {access_token}"}) + if r.status_code == 201: + state["node_id"] = r.json()["node_id"] + state["endpoint_hint"] = endpoint_hint + print(f" {PASS} endpoint_hint={endpoint_hint} on hub") + else: + print(f" {SKIP} No endpoint to register") + + STATE_FILE.write_text(json.dumps(state, indent=2)) + + print(f"\n{'='*55}") + print("Spike 4 — NAT Traversal Summary") + print(f" External IP (STUN) : {stun_ip1 or 'unknown'}") + print(f" NAT type : {nat_type if stun_ip1 else 'unknown'}") + print(f" UDP bidirectional : {PASS + ' works' if udp_ok else FAIL + ' blocked'}") + print(f" UPnP : {PASS + ' works' if upnp_ip else FAIL + ' disabled'}") + print(f" endpoint_hint : {endpoint_hint or 'none'}") + if udp_ok: + print(f"\n P2P UDP is functional. QUIC transport will work.") + print(f" Spike 4 COMPLETE.") + else: + print(f"\n P2P blocked — TURN relay needed for this configuration.") + print(f" Spike 4 COMPLETE (with finding: relay required).") + +asyncio.run(main()) diff --git a/poc/spike5_client.py b/poc/spike5_client.py new file mode 100644 index 0000000..11b3ac0 --- /dev/null +++ b/poc/spike5_client.py @@ -0,0 +1,140 @@ +""" +MeshBay Spike 5 — Encrypted Transfer CLIENT (runs on meshbay.org) + +Acts as the "file requester": + - Opens TCP server on :19003, waits for the node to connect + - Sends a chunk request + - Receives the encrypted chunk + - Verifies Ed25519 signature + - Verifies blake3 ciphertext hash + - Decrypts with GEK (included in POC response — never in production) + - Verifies plaintext integrity via blake3 + - Reports timing and results +""" + +import asyncio, json, base64, struct, time, sys +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives import hashes +import blake3 + +PORT = 19003 +CHUNK_INDEX = 0 +PASS = "✓"; FAIL = "✗" + +# ── Wire helpers ─────────────────────────────────────────────────────────────── + +async def send_msg(writer, obj: dict): + data = json.dumps(obj).encode() + writer.write(struct.pack('>I', len(data)) + data) + await writer.drain() + +async def recv_msg(reader) -> dict: + raw_len = await reader.readexactly(4) + length = struct.unpack('>I', raw_len)[0] + data = await reader.readexactly(length) + return json.loads(data) + +# ── Handler ──────────────────────────────────────────────────────────────────── + +async def handle_node(reader, writer): + peer = writer.get_extra_info('peername') + print(f" Node connected from {peer[0]}:{peer[1]}") + + # 1. Send chunk request + await send_msg(writer, { + "type": "chunk_req", + "file_id": "testfile.bin", + "chunk_index": CHUNK_INDEX, + }) + print(f" Chunk request sent (file=testfile.bin, chunk={CHUNK_INDEX})") + + # 2. Receive encrypted chunk + t_recv_start = time.perf_counter() + chunk = await recv_msg(reader) + recv_ms = (time.perf_counter() - t_recv_start) * 1000 + writer.close() + + ct = base64.b64decode(chunk["ct_b64"]) + nonce = base64.b64decode(chunk["nonce_b64"]) + ct_hash = base64.b64decode(chunk["ct_hash_b64"]) + sig = base64.b64decode(chunk["sig_b64"]) + pk_bytes = base64.b64decode(chunk["pk_node_b64"]) + gek_raw = base64.b64decode(chunk["gek_b64"]) # POC only + file_hash= base64.b64decode(chunk["file_hash_b64"]) + ci = chunk["chunk_index"] + chunk_sz = chunk["plaintext_size"] + + print(f"\n Received {len(ct)/1024:.1f} KB ciphertext in {recv_ms:.0f}ms") + print(f" chunk_index={ci} plaintext_size={chunk_sz/1024:.1f} KB") + + t_verify = time.perf_counter() + + # 3. Verify Ed25519 signature: sig covers (chunk_index || nonce || ct_hash) + pk_node = Ed25519PublicKey.from_public_bytes(pk_bytes) + sig_payload = ci.to_bytes(4, 'big') + nonce + ct_hash + try: + pk_node.verify(sig, sig_payload) + print(f" {PASS} Ed25519 signature valid") + except Exception as e: + print(f" {FAIL} Signature INVALID: {e}") + sys.exit(1) + + # 4. Verify ciphertext hash + computed_hash = blake3.blake3(ct).digest() + if computed_hash == ct_hash: + print(f" {PASS} blake3(ciphertext) matches") + else: + print(f" {FAIL} Ciphertext hash MISMATCH") + sys.exit(1) + + # 5. Derive per-chunk key and decrypt + chunk_key = HKDF( + algorithm=hashes.SHA256(), length=32, salt=None, + info=b"file:" + file_hash + b":chunk:" + ci.to_bytes(4, 'big') + ).derive(gek_raw) + + try: + plaintext = ChaCha20Poly1305(chunk_key).decrypt(nonce, ct, None) + print(f" {PASS} Decrypted {len(plaintext)/1024:.1f} KB") + except Exception as e: + print(f" {FAIL} Decryption FAILED: {e}") + sys.exit(1) + + # 6. Verify plaintext hash + pt_hash = blake3.blake3(plaintext).digest() + expected_pt_hash = base64.b64decode(chunk["pt_hash_b64"]) + if pt_hash == expected_pt_hash: + print(f" {PASS} blake3(plaintext) matches — data integrity confirmed") + else: + print(f" {FAIL} Plaintext hash MISMATCH — corruption or wrong key") + sys.exit(1) + + verify_ms = (time.perf_counter() - t_verify) * 1000 + total_ms = recv_ms + verify_ms + + print(f"\n {'='*50}") + print(f" Timings:") + print(f" Network receive : {recv_ms:.0f} ms") + print(f" Verify+decrypt : {verify_ms:.1f} ms") + print(f" Total : {total_ms:.0f} ms") + throughput = (chunk_sz / 1024 / 1024) / (recv_ms / 1000) + print(f" Throughput : {throughput:.1f} MB/s") + print(f"\n {PASS} Spike 5 CLIENT — all checks passed.") + sys.exit(0) + + +async def main(): + print("\n=== MeshBay Spike 5 — Encrypted Transfer CLIENT ===") + print(f"Waiting for node on TCP :{PORT} ...") + + server = await asyncio.start_server(handle_node, '0.0.0.0', PORT) + async with server: + try: + await asyncio.wait_for(server.serve_forever(), timeout=30) + except asyncio.TimeoutError: + print(f" {FAIL} Timeout — node did not connect within 30s") + sys.exit(1) + +asyncio.run(main()) diff --git a/poc/spike5_node.py b/poc/spike5_node.py new file mode 100644 index 0000000..6104e00 --- /dev/null +++ b/poc/spike5_node.py @@ -0,0 +1,175 @@ +""" +MeshBay Spike 5 — Encrypted Transfer NODE (runs on Fedora, behind NAT) + +Acts as the "file server": + - Creates a 5 MB test file if not present + - Generates or loads GEK from node_state.json + - Connects TCP OUT to meshbay.org:19003 (outgoing = always works through NAT) + - Receives chunk request + - Reads chunk from disk, derives per-chunk key via HKDF, encrypts, signs + - Sends encrypted chunk with metadata + - Reports timing + +Note: node initiates the TCP connection (reversed for POC — in production, +the QUIC client connects to the node using the hole-punching from Spike 4). +""" + +import asyncio, json, base64, struct, os, time +from pathlib import Path +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives import hashes, serialization +import blake3 + +MESHBAY_HOST = "meshbay.org" +MESHBAY_PORT = 19003 +CHUNK_SIZE = 1024 * 1024 # 1 MB +TEST_FILE = Path("testfile.bin") +STATE_FILE = Path("node_state.json") +PASS = "✓"; FAIL = "✗" + +# ── Wire helpers ─────────────────────────────────────────────────────────────── + +async def send_msg(writer, obj: dict): + data = json.dumps(obj).encode() + writer.write(struct.pack('>I', len(data)) + data) + await writer.drain() + +async def recv_msg(reader) -> dict: + raw_len = await reader.readexactly(4) + length = struct.unpack('>I', raw_len)[0] + data = await reader.readexactly(length) + return json.loads(data) + +# ── Chunk encryption ─────────────────────────────────────────────────────────── + +def make_encrypted_chunk( + sk_node: Ed25519PrivateKey, + gek_raw: bytes, + file_path: Path, + file_hash: bytes, + chunk_index: int +) -> dict: + """Read chunk from disk, compress (skipped), encrypt, sign. Return wire dict.""" + + # Read plaintext chunk + with open(file_path, 'rb') as f: + f.seek(chunk_index * CHUNK_SIZE) + plaintext = f.read(CHUNK_SIZE) + + pt_hash = blake3.blake3(plaintext).digest() + + # Derive per-chunk key (deterministic from GEK + file identity + chunk index) + chunk_key = HKDF( + algorithm=hashes.SHA256(), length=32, salt=None, + info=b"file:" + file_hash + b":chunk:" + chunk_index.to_bytes(4, 'big') + ).derive(gek_raw) + + # Encrypt with ChaCha20-Poly1305 + nonce = os.urandom(12) + ciphertext = ChaCha20Poly1305(chunk_key).encrypt(nonce, plaintext, None) + ct_hash = blake3.blake3(ciphertext).digest() + + # Sign: chunk_index || nonce || ct_hash + sig_payload = chunk_index.to_bytes(4, 'big') + nonce + ct_hash + signature = sk_node.sign(sig_payload) + + pk_bytes = sk_node.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw) + + return { + "chunk_index": chunk_index, + "plaintext_size": len(plaintext), + "nonce_b64": base64.b64encode(nonce).decode(), + "ct_b64": base64.b64encode(ciphertext).decode(), + "ct_hash_b64": base64.b64encode(ct_hash).decode(), + "pt_hash_b64": base64.b64encode(pt_hash).decode(), + "sig_b64": base64.b64encode(signature).decode(), + "pk_node_b64": base64.b64encode(pk_bytes).decode(), + "file_hash_b64": base64.b64encode(file_hash).decode(), + "gek_b64": base64.b64encode(gek_raw).decode(), # POC ONLY + } + +# ── Main ────────────────────────────────────────────────────────────────────── + +async def main(): + print("\n=== MeshBay Spike 5 — Encrypted Transfer NODE ===\n") + + # Load state + state = json.loads(STATE_FILE.read_text()) if STATE_FILE.exists() else {} + + # Load node Ed25519 key + sk_node = Ed25519PrivateKey.from_private_bytes( + base64.b64decode(state["sk_ed25519_b64"])) + pk_bytes = sk_node.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw) + print(f" Node PK: {base64.b64encode(pk_bytes).decode()[:24]}...") + + # GEK — load or generate + if "gek_b64" not in state: + gek_raw = ChaCha20Poly1305.generate_key() + state["gek_b64"] = base64.b64encode(gek_raw).decode() + STATE_FILE.write_text(json.dumps(state, indent=2)) + print(f" {PASS} GEK generated and saved") + else: + gek_raw = base64.b64decode(state["gek_b64"]) + print(f" {PASS} GEK loaded from state") + + # Create test file if needed (5 MB random) + if not TEST_FILE.exists(): + print(f" Creating {TEST_FILE} (5 MB)...") + with open(TEST_FILE, 'wb') as f: + f.write(os.urandom(5 * 1024 * 1024)) + file_size = TEST_FILE.stat().st_size + print(f" {PASS} Test file: {TEST_FILE} ({file_size/1024/1024:.1f} MB)") + + # Pre-compute file hash (used in HKDF info string — identifies the file) + print(f" Computing file hash...") + t0 = time.perf_counter() + file_hash = blake3.blake3(TEST_FILE.read_bytes()).digest() + print(f" {PASS} File hash: {file_hash.hex()[:16]}... ({(time.perf_counter()-t0)*1000:.0f}ms)") + + # Connect to meshbay.org + print(f"\n Connecting to {MESHBAY_HOST}:{MESHBAY_PORT} ...") + reader, writer = await asyncio.open_connection(MESHBAY_HOST, MESHBAY_PORT) + print(f" {PASS} TCP connection established") + + # Receive chunk request + req = await recv_msg(reader) + assert req["type"] == "chunk_req", f"Unexpected message type: {req['type']}" + chunk_index = req["chunk_index"] + file_id = req["file_id"] + print(f" {PASS} Request received: file={file_id} chunk={chunk_index}") + + # Encrypt chunk on-the-fly + print(f"\n Encrypting chunk {chunk_index} ({CHUNK_SIZE/1024:.0f} KB)...") + t_enc = time.perf_counter() + chunk_data = make_encrypted_chunk(sk_node, gek_raw, TEST_FILE, file_hash, chunk_index) + enc_ms = (time.perf_counter() - t_enc) * 1000 + + ct_size = len(base64.b64decode(chunk_data["ct_b64"])) + print(f" {PASS} Encrypted: {CHUNK_SIZE/1024:.0f} KB → {ct_size/1024:.1f} KB ciphertext") + print(f" {PASS} Encrypt+sign time: {enc_ms:.1f} ms") + + # Send encrypted chunk + print(f"\n Sending to {MESHBAY_HOST}:{MESHBAY_PORT}...") + t_send = time.perf_counter() + await send_msg(writer, chunk_data) + writer.close() + await writer.wait_closed() + send_ms = (time.perf_counter() - t_send) * 1000 + + total_ms = enc_ms + send_ms + payload_kb = (4 + len(json.dumps(chunk_data).encode())) / 1024 + throughput = (payload_kb / 1024) / (send_ms / 1000) + + print(f" {PASS} Sent {payload_kb:.1f} KB in {send_ms:.0f}ms ({throughput:.1f} MB/s)") + print(f"\n {'='*50}") + print(f" Timings (node side):") + print(f" Encrypt + sign : {enc_ms:.1f} ms") + print(f" TCP send : {send_ms:.0f} ms") + print(f" Total : {total_ms:.0f} ms") + print(f"\n {PASS} Spike 5 NODE — chunk served successfully.") + +asyncio.run(main()) diff --git a/poc/spike6_gek.py b/poc/spike6_gek.py new file mode 100644 index 0000000..89ab06c --- /dev/null +++ b/poc/spike6_gek.py @@ -0,0 +1,310 @@ +""" +MeshBay Spike 6 — GEK Distribution via X25519 + HKDF + +Validates the full GEK lifecycle: + + 1. Alice (admin/node) and Bob (member) register on hub + 2. Alice creates a group on hub + 3. Alice wraps the GEK for herself → stores bundle on hub + 4. Alice fetches Bob's X25519 public key from hub + 5. Alice wraps the GEK for Bob → stores bundle on hub + 6. Bob retrieves his bundle from hub + 7. Bob unwraps → recovers GEK + 8. Verify: recovered_gek == original_gek + 9. Bob decrypts content encrypted by Alice (Spike 5 chunk) with recovered GEK + +The hub stores opaque encrypted blobs — it never sees the GEK in cleartext. +""" + +import asyncio, httpx, base64, os, json, time +from pathlib import Path +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey +from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives import hashes, serialization +import blake3 + +HUB_URL = "http://meshbay.org" +STATE_FILE = Path("node_state.json") +PASS = "✓"; FAIL = "✗" + + +# ── Key helpers ──────────────────────────────────────────────────────────────── + +def _sk_ed_to_b64(sk) -> str: + return base64.b64encode(sk.private_bytes( + serialization.Encoding.Raw, serialization.PrivateFormat.Raw, + serialization.NoEncryption())).decode() + +def _pk_to_b64(pk) -> str: + return base64.b64encode(pk.public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw)).decode() + + +# ── GEK wrapping (ECIES-like) ───────────────────────────────────────────────── + +def wrap_gek(gek_raw: bytes, pk_recipient_raw: bytes) -> dict: + """ + Wrap GEK for a recipient using ephemeral X25519 key agreement + HKDF. + + Protocol (ECIES-like): + 1. Generate ephemeral keypair (sk_eph, pk_eph) + 2. shared = X25519(sk_eph, pk_recipient) + 3. wrap_key = HKDF(shared, salt=pk_eph, info="meshbay:gek_wrap:v1") + 4. wrapped = ChaCha20-Poly1305(wrap_key).encrypt(nonce, gek, aad=pk_recipient) + 5. Bundle = {pk_eph, nonce, wrapped} ← stored on hub, opaque + + Security properties: + - Only the recipient (who has sk_recipient) can unwrap + - AAD binds the bundle to the specific recipient (prevents reassignment) + - Ephemeral key ensures each bundle is unique even for the same GEK/recipient + """ + sk_eph = X25519PrivateKey.generate() + pk_eph_raw = _pk_to_b64(sk_eph.public_key()) + + pk_recipient = X25519PublicKey.from_public_bytes(pk_recipient_raw) + shared = sk_eph.exchange(pk_recipient) + + wrap_key = HKDF( + algorithm=hashes.SHA256(), length=32, + salt=base64.b64decode(pk_eph_raw), + info=b"meshbay:gek_wrap:v1" + ).derive(shared) + + nonce = os.urandom(12) + wrapped = ChaCha20Poly1305(wrap_key).encrypt(nonce, gek_raw, pk_recipient_raw) + + return { + "pk_eph_b64": pk_eph_raw, + "nonce_b64": base64.b64encode(nonce).decode(), + "wrapped_b64": base64.b64encode(wrapped).decode(), + } + + +def unwrap_gek(bundle: dict, sk_recipient_raw: bytes, pk_recipient_raw: bytes) -> bytes: + """ + Unwrap GEK using the recipient's X25519 private key. + Mirrors wrap_gek() exactly. + """ + pk_eph_raw = base64.b64decode(bundle["pk_eph_b64"]) + nonce = base64.b64decode(bundle["nonce_b64"]) + wrapped = base64.b64decode(bundle["wrapped_b64"]) + + sk_recipient = X25519PrivateKey.from_private_bytes(sk_recipient_raw) + pk_eph = X25519PublicKey.from_public_bytes(pk_eph_raw) + shared = sk_recipient.exchange(pk_eph) + + wrap_key = HKDF( + algorithm=hashes.SHA256(), length=32, + salt=pk_eph_raw, + info=b"meshbay:gek_wrap:v1" + ).derive(shared) + + return ChaCha20Poly1305(wrap_key).decrypt(nonce, wrapped, pk_recipient_raw) + + +# ── Main ────────────────────────────────────────────────────────────────────── + +async def main(): + print("\n=== MeshBay Spike 6 — GEK Distribution ===\n") + + # Load Alice's state (admin/node operator from Spike 3/5) + state = json.loads(STATE_FILE.read_text()) if STATE_FILE.exists() else {} + + sk_alice_ed = Ed25519PrivateKey.from_private_bytes( + base64.b64decode(state["sk_ed25519_b64"])) + sk_alice_x = X25519PrivateKey.from_private_bytes( + base64.b64decode(state["sk_x25519_b64"])) + pk_alice_x_raw = base64.b64decode(_pk_to_b64(sk_alice_x.public_key())) + + gek_raw = base64.b64decode(state["gek_b64"]) + print(f" Alice PK (X25519): {_pk_to_b64(sk_alice_x.public_key())[:24]}...") + print(f" GEK (to protect): {base64.b64encode(gek_raw).decode()[:24]}...") + + # Load or generate Bob's keypairs (persist so re-runs are idempotent) + bob_state_file = Path("bob_state.json") + if bob_state_file.exists(): + bob_st = json.loads(bob_state_file.read_text()) + sk_bob_ed = Ed25519PrivateKey.from_private_bytes(base64.b64decode(bob_st["sk_ed"])) + sk_bob_x = X25519PrivateKey.from_private_bytes(base64.b64decode(bob_st["sk_x"])) + else: + sk_bob_ed = Ed25519PrivateKey.generate() + sk_bob_x = X25519PrivateKey.generate() + sk_bob_x_raw = sk_bob_x.private_bytes( + serialization.Encoding.Raw, serialization.PrivateFormat.Raw, + serialization.NoEncryption()) + bob_state_file.write_text(json.dumps({ + "sk_ed": _sk_ed_to_b64(sk_bob_ed), + "sk_x": base64.b64encode(sk_bob_x_raw).decode(), + })) + pk_bob_x_raw = base64.b64decode(_pk_to_b64(sk_bob_x.public_key())) + + async with httpx.AsyncClient(timeout=15) as c: + + # ── Step 1: Register Alice (may already exist → 409 ok) ─────────────── + print("\n[ 1 ] Register Alice and Bob on hub") + r = await c.post(f"{HUB_URL}/v1/users/register", json={ + "username": state.get("username", "node_cbesson"), + "password": state.get("password", "nodepass42!"), + "pk_user_ed25519": _pk_to_b64(sk_alice_ed.public_key()), + "pk_user_x25519": _pk_to_b64(sk_alice_x.public_key()), + }) + if r.status_code in (201, 409): + print(f" {PASS} Alice: {'registered' if r.status_code == 201 else 'already exists'}") + else: + raise RuntimeError(f"Alice register failed: {r.text}") + + # ── Register Bob ─────────────────────────────────────────────────────── + r = await c.post(f"{HUB_URL}/v1/users/register", json={ + "username": "bob_member", + "password": "bobpass42!", + "pk_user_ed25519": _pk_to_b64(sk_bob_ed.public_key()), + "pk_user_x25519": _pk_to_b64(sk_bob_x.public_key()), + }) + assert r.status_code in (201, 409), f"Bob register failed: {r.text}" + print(f" {PASS} Bob: {'registered' if r.status_code == 201 else 'already exists'}") + + # ── Login both ───────────────────────────────────────────────────────── + r = await c.post(f"{HUB_URL}/v1/users/login", json={ + "username": state.get("username", "node_cbesson"), + "password": state.get("password", "nodepass42!")}) + r.raise_for_status() + alice_token = r.json()["access_token"] + alice_hdrs = {"Authorization": f"Bearer {alice_token}"} + + r = await c.post(f"{HUB_URL}/v1/users/login", json={ + "username": "bob_member", "password": "bobpass42!"}) + r.raise_for_status() + bob_token = r.json()["access_token"] + bob_hdrs = {"Authorization": f"Bearer {bob_token}"} + print(f" {PASS} Both logged in") + + # ── Step 2: Alice creates a group ────────────────────────────────────── + print("\n[ 2 ] Alice creates group 'test-group'") + r = await c.post(f"{HUB_URL}/v1/groups", + json={"name": "test-group"}, headers=alice_hdrs) + r.raise_for_status() + group_id = r.json()["group_id"] + print(f" {PASS} Group created: group_id={group_id[:8]}...") + + # ── Step 3: Alice wraps GEK for herself and stores on hub ────────────── + print("\n[ 3 ] Alice wraps GEK for herself → stores on hub") + t0 = time.perf_counter() + bundle_alice = wrap_gek(gek_raw, pk_alice_x_raw) + print(f" {PASS} Wrapped in {(time.perf_counter()-t0)*1000:.2f}ms") + print(f" pk_eph : {bundle_alice['pk_eph_b64'][:24]}...") + print(f" wrapped : {bundle_alice['wrapped_b64'][:24]}... ({len(base64.b64decode(bundle_alice['wrapped_b64']))}B)") + + r = await c.post( + f"{HUB_URL}/v1/groups/{group_id}/members/{state.get('username','node_cbesson')}/gek", + json=bundle_alice, headers=alice_hdrs) + r.raise_for_status() + print(f" {PASS} Bundle stored on hub for Alice") + + # ── Step 4: Alice fetches Bob's public key from hub ─────────────────── + print("\n[ 4 ] Alice fetches Bob's X25519 public key from hub") + r = await c.get(f"{HUB_URL}/v1/users/bob_member/pubkeys", headers=alice_hdrs) + r.raise_for_status() + bob_pubkeys = r.json() + pk_bob_x_from_hub = base64.b64decode(bob_pubkeys["pk_x25519"]) + assert pk_bob_x_from_hub == pk_bob_x_raw, "Bob's PK from hub doesn't match!" + print(f" {PASS} Bob's X25519 PK fetched: {bob_pubkeys['pk_x25519'][:24]}...") + + # ── Step 5: Alice wraps GEK for Bob and stores on hub ───────────────── + print("\n[ 5 ] Alice wraps GEK for Bob → stores on hub") + t0 = time.perf_counter() + bundle_bob = wrap_gek(gek_raw, pk_bob_x_from_hub) + wrap_ms = (time.perf_counter()-t0)*1000 + print(f" {PASS} Wrapped in {wrap_ms:.2f}ms") + print(f" pk_eph : {bundle_bob['pk_eph_b64'][:24]}...") + print(f" (different from Alice's bundle — ephemeral key is unique)") + + r = await c.post( + f"{HUB_URL}/v1/groups/{group_id}/members/bob_member/gek", + json=bundle_bob, headers=alice_hdrs) + r.raise_for_status() + print(f" {PASS} Bundle stored on hub for Bob") + + # ── Step 6: Bob retrieves his bundle from hub ────────────────────────── + print("\n[ 6 ] Bob retrieves his GEK bundle from hub") + r = await c.get(f"{HUB_URL}/v1/groups/{group_id}/gek", headers=bob_hdrs) + r.raise_for_status() + retrieved_bundle = r.json() + assert retrieved_bundle["pk_eph_b64"] == bundle_bob["pk_eph_b64"] + assert retrieved_bundle["wrapped_b64"] == bundle_bob["wrapped_b64"] + print(f" {PASS} Bundle retrieved (matches what Alice stored)") + + # ── Step 7: Bob unwraps GEK ──────────────────────────────────────────── + print("\n[ 7 ] Bob unwraps GEK using his X25519 private key") + t0 = time.perf_counter() + recovered_gek = unwrap_gek( + retrieved_bundle, + sk_bob_x.private_bytes(serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + serialization.NoEncryption()), + pk_bob_x_raw + ) + unwrap_ms = (time.perf_counter()-t0)*1000 + print(f" {PASS} Unwrapped in {unwrap_ms:.2f}ms") + + # ── Step 8: Verify recovered GEK == original ────────────────────────── + print("\n[ 8 ] Verify recovered GEK matches original") + if recovered_gek == gek_raw: + print(f" {PASS} recovered_gek == original_gek ← KEY RESULT") + print(f" GEK: {base64.b64encode(recovered_gek).decode()[:24]}...") + else: + print(f" {FAIL} GEK MISMATCH — crypto error") + return + + # ── Step 9: Bob decrypts a chunk encrypted by Alice ─────────────────── + print("\n[ 9 ] Bob decrypts content Alice encrypted with the GEK (Spike 5 validation)") + test_data = b"Secret group content: " + os.urandom(64) + file_hash = blake3.blake3(test_data).digest() + + # Alice encrypts (node side) + chunk_key = HKDF( + algorithm=hashes.SHA256(), length=32, salt=None, + info=b"file:" + file_hash + b":chunk:" + (0).to_bytes(4, 'big') + ).derive(gek_raw) + nonce = os.urandom(12) + ciphertext = ChaCha20Poly1305(chunk_key).encrypt(nonce, test_data, None) + print(f" Alice encrypted {len(test_data)}B → {len(ciphertext)}B ciphertext") + + # Bob decrypts (client side, using recovered GEK) + chunk_key_bob = HKDF( + algorithm=hashes.SHA256(), length=32, salt=None, + info=b"file:" + file_hash + b":chunk:" + (0).to_bytes(4, 'big') + ).derive(recovered_gek) # ← uses recovered GEK, not original + plaintext = ChaCha20Poly1305(chunk_key_bob).decrypt(nonce, ciphertext, None) + + if plaintext == test_data: + print(f" {PASS} Bob decrypted content correctly using recovered GEK") + else: + print(f" {FAIL} Decryption produced wrong plaintext") + return + + # ── Tampering check ─────────────────────────────────────────────────── + print("\n[ 10 ] Security: wrong private key cannot unwrap ─────────────────") + sk_eve = X25519PrivateKey.generate() + pk_eve = base64.b64decode(_pk_to_b64(sk_eve.public_key())) + try: + _ = unwrap_gek( + retrieved_bundle, + sk_eve.private_bytes(serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + serialization.NoEncryption()), + pk_eve + ) + print(f" {FAIL} Wrong key should have been rejected!") + except Exception: + print(f" {PASS} Wrong private key correctly rejected (AEAD auth failed)") + + print("\n" + "="*55) + print(f"Spike 6 COMPLETE — GEK distribution validated.") + print(f" wrap_gek : {wrap_ms:.2f} ms") + print(f" unwrap_gek : {unwrap_ms:.2f} ms") + print(f" Hub role : stores opaque bundle, never sees GEK in clear") + print(f" Security : wrong key rejected by AEAD authentication tag") + +asyncio.run(main()) |