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