diff options
Diffstat (limited to 'packages/meshbay-common')
| -rw-r--r-- | packages/meshbay-common/pyproject.toml | 25 | ||||
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/__init__.py | 5 | ||||
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/crypto.py | 170 | ||||
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/protocol.py | 72 |
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 |