summaryrefslogtreecommitdiffstats
path: root/poc
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-09 03:52:58 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-09 03:52:58 +0200
commit271adc8504aad32075d75d06fd42023877a649ec (patch)
treefe8697761d635a5cac7e0693f2e588a38a7968b9 /poc
downloadmeshbay-271adc8504aad32075d75d06fd42023877a649ec.tar.gz
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) <noreply@anthropic.com>
Diffstat (limited to 'poc')
-rw-r--r--poc/spike-results.md266
-rw-r--r--poc/spike1_crypto.py388
-rw-r--r--poc/spike2_hub.py268
-rw-r--r--poc/spike3_node.py186
-rw-r--r--poc/spike4_nat.py279
-rw-r--r--poc/spike5_client.py140
-rw-r--r--poc/spike5_node.py175
-rw-r--r--poc/spike6_gek.py310
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())