diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-09 03:52:58 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-09 03:52:58 +0200 |
| commit | 271adc8504aad32075d75d06fd42023877a649ec (patch) | |
| tree | fe8697761d635a5cac7e0693f2e588a38a7968b9 /packages | |
| download | meshbay-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')
19 files changed, 401 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 diff --git a/packages/meshbay-hub/pyproject.toml b/packages/meshbay-hub/pyproject.toml new file mode 100644 index 0000000..e0a880a --- /dev/null +++ b/packages/meshbay-hub/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "meshbay-hub" +version = "0.1.0" +description = "MeshBay Hub — identity authority and group registry server" +requires-python = ">=3.12" +dependencies = [ + "meshbay-common>=0.1.0", + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "sqlalchemy>=2.0", + "alembic>=1.13", + "asyncpg>=0.30", # PostgreSQL async driver + "httpx>=0.28", +] + +[project.optional-dependencies] +dev = ["pytest>=8", "pytest-asyncio>=0.24", "ruff>=0.6", "httpx>=0.28"] + +[project.scripts] +meshbay-hub = "meshbay_hub.daemon:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/meshbay_hub"] + +# RPM/DEB: meshbay-hub +# Systemd service: meshbay-hub.service +# Config: /etc/meshbay/hub.conf +# Data: /var/lib/meshbay/hub/ diff --git a/packages/meshbay-hub/src/meshbay_hub/__init__.py b/packages/meshbay-hub/src/meshbay_hub/__init__.py new file mode 100644 index 0000000..6372557 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/__init__.py @@ -0,0 +1,3 @@ +"""MeshBay Hub — identity authority and group registry.""" + +__version__ = "0.1.0" diff --git a/packages/meshbay-hub/src/meshbay_hub/api/__init__.py b/packages/meshbay-hub/src/meshbay_hub/api/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/api/__init__.py diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py new file mode 100644 index 0000000..3b9ee4f --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/app.py @@ -0,0 +1,31 @@ +""" +MeshBay Hub — FastAPI application. + +POC endpoints are implemented in this file (in-memory storage). +Production implementation will use db/ with SQLAlchemy + PostgreSQL. +""" + +from fastapi import FastAPI +from meshbay_hub import __version__ +from meshbay_common import MNP_VERSION, MHP_VERSION + +app = FastAPI( + title="MeshBay Hub", + version=__version__, + description="MeshBay identity authority and group registry", +) + +# TODO: import and include routers from api/ submodules +# from meshbay_hub.api import users, nodes, groups +# app.include_router(users.router, prefix="/v1") +# app.include_router(nodes.router, prefix="/v1") +# app.include_router(groups.router, prefix="/v1") + + +@app.get("/v1/hub/info") +async def hub_info() -> dict: + return { + "hub_id": "meshbay.org", + "mnp_version": MNP_VERSION, + "mhp_version": MHP_VERSION, + } diff --git a/packages/meshbay-hub/src/meshbay_hub/daemon.py b/packages/meshbay-hub/src/meshbay_hub/daemon.py new file mode 100644 index 0000000..ff8158a --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/daemon.py @@ -0,0 +1,17 @@ +"""Entry point for the meshbay-hub systemd service.""" + +import uvicorn +from meshbay_hub.app import app # noqa: F401 + + +def main() -> None: + uvicorn.run( + "meshbay_hub.app:app", + host="127.0.0.1", + port=8000, + log_level="info", + ) + + +if __name__ == "__main__": + main() diff --git a/packages/meshbay-hub/src/meshbay_hub/db/__init__.py b/packages/meshbay-hub/src/meshbay_hub/db/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/db/__init__.py diff --git a/packages/meshbay-hub/tests/__init__.py b/packages/meshbay-hub/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/packages/meshbay-hub/tests/__init__.py diff --git a/packages/meshbay-node/pyproject.toml b/packages/meshbay-node/pyproject.toml new file mode 100644 index 0000000..48231ad --- /dev/null +++ b/packages/meshbay-node/pyproject.toml @@ -0,0 +1,34 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "meshbay-node" +version = "0.1.0" +description = "MeshBay Node — local file host, streaming server, and group daemon" +requires-python = ">=3.12" +dependencies = [ + "meshbay-common>=0.1.0", + "fastapi>=0.115", # local web UI on localhost:18000 + "uvicorn[standard]>=0.30", + "httpx>=0.28", # hub client + "watchdog>=5.0", # directory monitoring + "aioice>=0.9", # ICE/STUN for NAT traversal + # aioquic>=1.0 added in v2 (QUIC transport) +] + +[project.optional-dependencies] +dev = ["pytest>=8", "pytest-asyncio>=0.24", "ruff>=0.6"] + +[project.scripts] +meshbay-node = "meshbay_node.daemon:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/meshbay_node"] + +# RPM/DEB: meshbay-node +# Systemd service: meshbay-node.service +# Config: /etc/meshbay/node.conf (or ~/.config/meshbay/node.conf per-user) +# Data: ~/.local/share/meshbay/node/ +# Keystore: ~/.config/meshbay/keystore.enc +# Local UI: http://localhost:18000 diff --git a/packages/meshbay-node/src/meshbay_node/__init__.py b/packages/meshbay-node/src/meshbay_node/__init__.py new file mode 100644 index 0000000..91f6593 --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/__init__.py @@ -0,0 +1,3 @@ +"""MeshBay Node — local file host, streaming server, and group daemon.""" + +__version__ = "0.1.0" diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py new file mode 100644 index 0000000..83c0ed7 --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -0,0 +1,9 @@ +"""Entry point for the meshbay-node systemd service.""" + + +def main() -> None: + raise NotImplementedError("Node daemon not yet implemented — see Phase 2") + + +if __name__ == "__main__": + main() diff --git a/packages/meshbay-node/src/meshbay_node/indexer/__init__.py b/packages/meshbay-node/src/meshbay_node/indexer/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/indexer/__init__.py diff --git a/packages/meshbay-node/src/meshbay_node/modules/__init__.py b/packages/meshbay-node/src/meshbay_node/modules/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/modules/__init__.py diff --git a/packages/meshbay-node/src/meshbay_node/transport/__init__.py b/packages/meshbay-node/src/meshbay_node/transport/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/transport/__init__.py diff --git a/packages/meshbay-node/src/meshbay_node/ui/__init__.py b/packages/meshbay-node/src/meshbay_node/ui/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/ui/__init__.py diff --git a/packages/meshbay-node/tests/__init__.py b/packages/meshbay-node/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/packages/meshbay-node/tests/__init__.py |