aboutsummaryrefslogtreecommitdiffstats
path: root/poc/spike6_gek.py
diff options
context:
space:
mode:
Diffstat (limited to 'poc/spike6_gek.py')
-rw-r--r--poc/spike6_gek.py310
1 files changed, 310 insertions, 0 deletions
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())