summaryrefslogtreecommitdiffstats
path: root/poc/spike1_crypto.py
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/spike1_crypto.py
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/spike1_crypto.py')
-rw-r--r--poc/spike1_crypto.py388
1 files changed, 388 insertions, 0 deletions
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)