summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common
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 /packages/meshbay-common
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 'packages/meshbay-common')
-rw-r--r--packages/meshbay-common/pyproject.toml25
-rw-r--r--packages/meshbay-common/src/meshbay_common/__init__.py5
-rw-r--r--packages/meshbay-common/src/meshbay_common/crypto.py170
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py72
4 files changed, 272 insertions, 0 deletions
diff --git a/packages/meshbay-common/pyproject.toml b/packages/meshbay-common/pyproject.toml
new file mode 100644
index 0000000..ac7ba08
--- /dev/null
+++ b/packages/meshbay-common/pyproject.toml
@@ -0,0 +1,25 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "meshbay-common"
+version = "0.1.0"
+description = "MeshBay shared cryptographic primitives and protocol types"
+requires-python = ">=3.12"
+dependencies = [
+ "cryptography>=43.0",
+ "PyJWT>=2.9",
+ "blake3>=1.0",
+ "msgpack>=1.1",
+ "zstandard>=0.23",
+]
+
+[project.optional-dependencies]
+dev = ["pytest>=8", "pytest-asyncio>=0.24", "ruff>=0.6"]
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/meshbay_common"]
+
+# RPM/DEB: python3-meshbay-common
+# Dependency of both meshbay-hub and meshbay-node
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py
new file mode 100644
index 0000000..94b3d27
--- /dev/null
+++ b/packages/meshbay-common/src/meshbay_common/__init__.py
@@ -0,0 +1,5 @@
+"""MeshBay common — shared crypto primitives and protocol types."""
+
+__version__ = "0.1.0"
+MNP_VERSION = "0.1"
+MHP_VERSION = "0.1"
diff --git a/packages/meshbay-common/src/meshbay_common/crypto.py b/packages/meshbay-common/src/meshbay_common/crypto.py
new file mode 100644
index 0000000..2104469
--- /dev/null
+++ b/packages/meshbay-common/src/meshbay_common/crypto.py
@@ -0,0 +1,170 @@
+"""
+MeshBay cryptographic primitives.
+
+Validated in Spike 1 and Spike 6 of the POC.
+All operations use PyCA cryptography (OpenSSL-backed, hardware-accelerated).
+"""
+
+import os
+import base64
+
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
+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.kdf.argon2 import Argon2id
+from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+from cryptography.hazmat.primitives import hashes, serialization
+import blake3
+
+# ── Key serialisation helpers ─────────────────────────────────────────────────
+
+def sk_to_raw(sk: Ed25519PrivateKey | X25519PrivateKey) -> bytes:
+ return sk.private_bytes(
+ serialization.Encoding.Raw,
+ serialization.PrivateFormat.Raw,
+ serialization.NoEncryption(),
+ )
+
+def pk_to_raw(pk: Ed25519PublicKey | X25519PublicKey) -> bytes:
+ return pk.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
+
+def pk_to_b64(pk: Ed25519PublicKey | X25519PublicKey) -> str:
+ return base64.b64encode(pk_to_raw(pk)).decode()
+
+def sk_to_b64(sk: Ed25519PrivateKey | X25519PrivateKey) -> str:
+ return base64.b64encode(sk_to_raw(sk)).decode()
+
+# ── GEK (Group Encryption Key) ────────────────────────────────────────────────
+
+def generate_gek() -> bytes:
+ """Generate a fresh 256-bit Group Encryption Key."""
+ return ChaCha20Poly1305.generate_key()
+
+def chunk_key(gek: bytes, file_hash: bytes, chunk_index: int) -> bytes:
+ """Derive a per-chunk encryption key from the GEK (deterministic)."""
+ return HKDF(
+ algorithm=hashes.SHA256(),
+ length=32,
+ salt=None,
+ info=b"file:" + file_hash + b":chunk:" + chunk_index.to_bytes(4, "big"),
+ ).derive(gek)
+
+def encrypt_chunk(key: bytes, plaintext: bytes) -> tuple[bytes, bytes]:
+ """Encrypt plaintext with ChaCha20-Poly1305. Returns (nonce, ciphertext)."""
+ nonce = os.urandom(12)
+ ct = ChaCha20Poly1305(key).encrypt(nonce, plaintext, None)
+ return nonce, ct
+
+def decrypt_chunk(key: bytes, nonce: bytes, ciphertext: bytes) -> bytes:
+ """Decrypt ciphertext. Raises InvalidTag on authentication failure."""
+ return ChaCha20Poly1305(key).decrypt(nonce, ciphertext, None)
+
+def file_hash(path_or_bytes) -> bytes:
+ """Compute blake3 hash of a file (bytes or path-like)."""
+ if isinstance(path_or_bytes, (str, bytes)) and not isinstance(path_or_bytes, bytes):
+ import pathlib
+ data = pathlib.Path(path_or_bytes).read_bytes()
+ elif isinstance(path_or_bytes, bytes):
+ data = path_or_bytes
+ else:
+ data = path_or_bytes.read_bytes()
+ return blake3.blake3(data).digest()
+
+# ── GEK wrapping (ECIES-like) ─────────────────────────────────────────────────
+
+GEK_WRAP_INFO = b"meshbay:gek_wrap:v1"
+
+def wrap_gek(gek: bytes, pk_recipient: bytes) -> dict:
+ """
+ Wrap a GEK for a recipient using ephemeral X25519 + HKDF + ChaCha20-Poly1305.
+
+ Protocol:
+ 1. Generate ephemeral (sk_eph, pk_eph)
+ 2. shared = X25519(sk_eph, pk_recipient)
+ 3. wrap_key = HKDF(shared, salt=pk_eph, info=GEK_WRAP_INFO)
+ 4. wrapped = ChaCha20-Poly1305(wrap_key).encrypt(nonce, gek, aad=pk_recipient)
+
+ The hub stores {pk_eph, nonce, wrapped} — opaque, cannot decrypt.
+ """
+ sk_eph = X25519PrivateKey.generate()
+ pk_eph_raw = pk_to_raw(sk_eph.public_key())
+
+ shared = sk_eph.exchange(X25519PublicKey.from_public_bytes(pk_recipient))
+ wrap_key = HKDF(
+ algorithm=hashes.SHA256(), length=32,
+ salt=pk_eph_raw, info=GEK_WRAP_INFO,
+ ).derive(shared)
+
+ nonce = os.urandom(12)
+ wrapped = ChaCha20Poly1305(wrap_key).encrypt(nonce, gek, pk_recipient)
+
+ return {
+ "pk_eph_b64": base64.b64encode(pk_eph_raw).decode(),
+ "nonce_b64": base64.b64encode(nonce).decode(),
+ "wrapped_b64": base64.b64encode(wrapped).decode(),
+ }
+
+def unwrap_gek(bundle: dict, sk_recipient: bytes, pk_recipient: bytes) -> bytes:
+ """
+ Unwrap a GEK bundle using the recipient's X25519 private key.
+ Raises InvalidTag if the key is wrong or the bundle was tampered.
+ """
+ pk_eph_raw = base64.b64decode(bundle["pk_eph_b64"])
+ nonce = base64.b64decode(bundle["nonce_b64"])
+ wrapped = base64.b64decode(bundle["wrapped_b64"])
+
+ shared = X25519PrivateKey.from_private_bytes(sk_recipient).exchange(
+ X25519PublicKey.from_public_bytes(pk_eph_raw)
+ )
+ wrap_key = HKDF(
+ algorithm=hashes.SHA256(), length=32,
+ salt=pk_eph_raw, info=GEK_WRAP_INFO,
+ ).derive(shared)
+
+ return ChaCha20Poly1305(wrap_key).decrypt(nonce, wrapped, pk_recipient)
+
+# ── Keystore (local key storage) ──────────────────────────────────────────────
+
+# Argon2id parameters — calibrate to ~500ms on target hardware before production.
+# POC measured 78ms with these; increase memory_cost to 262144 (256MB) for prod.
+ARGON2_ITERATIONS = 3
+ARGON2_MEMORY_COST = 65536 # 64 MB — increase to 262144 for production
+ARGON2_LANES = 4
+ARGON2_KEY_LENGTH = 32
+
+def derive_keystore_key(password: str, salt: bytes) -> bytes:
+ """Derive AES-256 key from password using Argon2id."""
+ return Argon2id(
+ salt=salt,
+ length=ARGON2_KEY_LENGTH,
+ iterations=ARGON2_ITERATIONS,
+ lanes=ARGON2_LANES,
+ memory_cost=ARGON2_MEMORY_COST,
+ ).derive(password.encode())
+
+def encrypt_keystore(plaintext: bytes, key: bytes) -> tuple[bytes, bytes, bytes]:
+ """Encrypt keystore blob with AES-256-GCM. Returns (iv, ciphertext, tag)."""
+ iv = os.urandom(16)
+ enc = Cipher(algorithms.AES(key), modes.GCM(iv)).encryptor()
+ ct = enc.update(plaintext) + enc.finalize()
+ return iv, ct, enc.tag
+
+def decrypt_keystore(iv: bytes, ciphertext: bytes, tag: bytes, key: bytes) -> bytes:
+ """Decrypt keystore blob. Raises on authentication failure."""
+ dec = Cipher(algorithms.AES(key), modes.GCM(iv, tag)).decryptor()
+ return dec.update(ciphertext) + dec.finalize()
+
+# ── Chunk signing ─────────────────────────────────────────────────────────────
+
+def sign_chunk(sk_node: Ed25519PrivateKey, chunk_index: int,
+ nonce: bytes, ct_hash: bytes) -> bytes:
+ """Sign chunk metadata. Payload: chunk_index || nonce || ct_hash."""
+ payload = chunk_index.to_bytes(4, "big") + nonce + ct_hash
+ return sk_node.sign(payload)
+
+def verify_chunk_signature(pk_node: Ed25519PublicKey, chunk_index: int,
+ nonce: bytes, ct_hash: bytes, signature: bytes) -> None:
+ """Verify chunk signature. Raises InvalidSignature on failure."""
+ payload = chunk_index.to_bytes(4, "big") + nonce + ct_hash
+ pk_node.verify(signature, payload)
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py
new file mode 100644
index 0000000..c3f2b1a
--- /dev/null
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -0,0 +1,72 @@
+"""
+MeshBay protocol constants and message type definitions.
+
+MNP (Mesh Node Protocol) — v0.1
+MHP (Mesh Bay Hub Protocol) — v0.1
+
+All wire messages are length-prefixed msgpack (4-byte big-endian length header).
+Every message carries a "v" field for protocol version.
+"""
+
+from dataclasses import dataclass, field
+from typing import Any
+
+MNP_VERSION = "0.1"
+MHP_VERSION = "0.1"
+
+
+# ── MNP message types ─────────────────────────────────────────────────────────
+
+class MNP:
+ HANDSHAKE = "handshake"
+ HANDSHAKE_ACK = "handshake_ack"
+ INDEX_SYNC = "index_sync" # full Mesh Group Index
+ INDEX_DELTA = "index_delta" # incremental update
+ FILE_REQUEST = "file_req" # request chunk(s)
+ FILE_CHUNK = "file_chunk" # encrypted chunk response
+ STREAM_SEGMENT = "stream_seg" # HLS/DASH segment
+ CHAT_MESSAGE = "chat_msg" # Double Ratchet message
+ CHAT_ATTACHMENT = "chat_attach" # attachment metadata
+ EPHEMERAL_STREAM = "ephemeral_stream" # reserved — mobile live push
+
+
+# ── Index entry ───────────────────────────────────────────────────────────────
+
+@dataclass
+class IndexEntry:
+ id: str # blake3 hash of file (hex)
+ name: str # filename
+ path: str # path relative to shared directory
+ size: int # bytes
+ type: str # video | audio | image | document | archive | other
+ added_at: int # unix timestamp
+ duration: int | None = None # seconds, for media
+ thumb_hash: str | None = None # blake3 of thumbnail
+
+
+@dataclass
+class IndexDelta:
+ base_version: int
+ version: int
+ additions: list[IndexEntry] = field(default_factory=list)
+ deletions: list[str] = field(default_factory=list) # list of ids
+
+
+# ── Chunk request/response ────────────────────────────────────────────────────
+
+@dataclass
+class ChunkRequest:
+ file_id: str # blake3 hash of file (hex)
+ chunk_index: int
+
+@dataclass
+class ChunkResponse:
+ chunk_index: int
+ plaintext_size: int
+ nonce_b64: str
+ ct_b64: str
+ ct_hash_b64: str
+ pt_hash_b64: str
+ sig_b64: str
+ pk_node_b64: str
+ file_hash_b64: str