summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-10 03:07:56 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-10 03:07:56 +0200
commit4b3e8c3b8b9d10c8ac333dd8db614a7569052472 (patch)
tree8828d7d549adc8bb21d8a7f6533c8e9db9c298b9 /packages/meshbay-common
parent8ccbe262ecf4a8f7545cbf1e9f1cc5a485acae67 (diff)
downloadmeshbay-4b3e8c3b8b9d10c8ac333dd8db614a7569052472.tar.gz
feat: Phase 7 — Node v2 (multi-group, Sender Keys, 0-RTT, chat, denylist)
Implements all 8 milestones (7.0-7.7): - 7.0: JWT carries `groups` claim; node verifies group membership at MNP handshake (QUIC + TCP+TLS). Resolves security review C2. - 7.1: QUIC 0-RTT session resumption via stored session tickets (17-21ms reconnect vs 47ms cold). - 7.2: Hub→node WebSocket signaling for NAT punch coordination (`client_incoming`/`punch_ready`) + jti denylist push. Denylist class blocks revoked users/jtis at handshake. - 7.3: Multi-group daemon — one QUIC port serves N groups with per-group GEK, shared_root, and index routing. - 7.4: HLS streaming via QUIC (STREAM_SEGMENT message type, ffmpeg segment extraction). - 7.5: Sender Keys protocol for group chat (Signal Groups approach). Each member has own sending chain key, HKDF chain ratchet, AES-256-GCM encryption, Ed25519 signing. Resolves security review C1. - 7.6: Chat store (SQLite via aiosqlite), CHAT_MESSAGE MNP wire type with peer broadcast, web UI with WebSocket push. - 7.7: Argon2id calibration CLI. First security review included (first-review.md). 109 tests, demo-v3 validated against meshbay.org production hub. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-common')
-rw-r--r--packages/meshbay-common/src/meshbay_common/crypto.py2
-rw-r--r--packages/meshbay-common/src/meshbay_common/senderkeys.py287
-rw-r--r--packages/meshbay-common/tests/test_senderkeys.py211
3 files changed, 499 insertions, 1 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/crypto.py b/packages/meshbay-common/src/meshbay_common/crypto.py
index 2104469..6066f5f 100644
--- a/packages/meshbay-common/src/meshbay_common/crypto.py
+++ b/packages/meshbay-common/src/meshbay_common/crypto.py
@@ -145,7 +145,7 @@ def derive_keystore_key(password: str, salt: bytes) -> bytes:
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)
+ iv = os.urandom(12)
enc = Cipher(algorithms.AES(key), modes.GCM(iv)).encryptor()
ct = enc.update(plaintext) + enc.finalize()
return iv, ct, enc.tag
diff --git a/packages/meshbay-common/src/meshbay_common/senderkeys.py b/packages/meshbay-common/src/meshbay_common/senderkeys.py
new file mode 100644
index 0000000..932e2e6
--- /dev/null
+++ b/packages/meshbay-common/src/meshbay_common/senderkeys.py
@@ -0,0 +1,287 @@
+"""
+MeshBay — Sender Keys protocol for group messaging.
+
+Signal Groups approach: each member maintains their own sending chain.
+Advantages over shared Double Ratchet:
+ - O(N) state per group (one chain per member) vs O(N^2) pairwise
+ - Single encrypt per message (not N encryptions)
+ - No key/nonce reuse — each sender has an independent chain
+
+Key components:
+ - Chain key ratchet: HKDF per message, provides forward secrecy
+ - Message key derivation: separate HKDF from chain key
+ - Ed25519 signing: each sender signs their ciphertext
+ - AES-256-GCM encryption: browser-compatible symmetric cipher
+
+Key distribution:
+ - On join: admin wraps each sender's SenderKeyDistribution with GEK
+ - On leave: all remaining members rotate their chain keys
+"""
+
+import os
+import struct
+from dataclasses import dataclass, field
+
+from cryptography.hazmat.primitives.asymmetric.ed25519 import (
+ Ed25519PrivateKey,
+ Ed25519PublicKey,
+)
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+from cryptography.hazmat.primitives import hashes, serialization
+
+
+CHAIN_INFO = b"meshbay:sk:chain:v1"
+MSG_KEY_INFO = b"meshbay:sk:msg:v1"
+CHAIN_KEY_LEN = 32
+MSG_KEY_LEN = 32
+MAX_SKIP = 256
+
+
+def _hkdf(ikm: bytes, info: bytes, length: int = 32) -> bytes:
+ return HKDF(
+ algorithm=hashes.SHA256(), length=length, salt=None, info=info,
+ ).derive(ikm)
+
+
+def _ratchet_chain(chain_key: bytes) -> tuple[bytes, bytes]:
+ """Advance chain key → (new_chain_key, message_key)."""
+ new_ck = _hkdf(chain_key, CHAIN_INFO, CHAIN_KEY_LEN)
+ mk = _hkdf(chain_key, MSG_KEY_INFO, MSG_KEY_LEN)
+ return new_ck, mk
+
+
+# ── Data structures ──────────────────────────────────────────────────────────
+
+@dataclass
+class SenderKeyDistribution:
+ """Sent to group members when a sender joins or rotates."""
+ sender_id: str
+ chain_key: bytes # 32-byte initial chain key
+ iteration: int # current message counter
+ signing_pk: bytes # 32-byte raw Ed25519 public key
+
+ def serialize(self) -> bytes:
+ sender_bytes = self.sender_id.encode()
+ return (
+ struct.pack(">H", len(sender_bytes))
+ + sender_bytes
+ + self.chain_key
+ + struct.pack(">I", self.iteration)
+ + self.signing_pk
+ )
+
+ @classmethod
+ def deserialize(cls, data: bytes) -> "SenderKeyDistribution":
+ sender_len = struct.unpack(">H", data[:2])[0]
+ offset = 2
+ sender_id = data[offset:offset + sender_len].decode()
+ offset += sender_len
+ chain_key = data[offset:offset + 32]
+ offset += 32
+ iteration = struct.unpack(">I", data[offset:offset + 4])[0]
+ offset += 4
+ signing_pk = data[offset:offset + 32]
+ return cls(sender_id=sender_id, chain_key=chain_key,
+ iteration=iteration, signing_pk=signing_pk)
+
+
+@dataclass
+class SenderKeyState:
+ """One sender's chain state as seen by any group member."""
+ sender_id: str
+ chain_key: bytes
+ iteration: int
+ signing_key: Ed25519PublicKey
+ _skipped_keys: dict[int, bytes] = field(default_factory=dict)
+
+ @classmethod
+ def from_distribution(cls, dist: SenderKeyDistribution) -> "SenderKeyState":
+ pk = Ed25519PublicKey.from_public_bytes(dist.signing_pk)
+ return cls(
+ sender_id=dist.sender_id,
+ chain_key=dist.chain_key,
+ iteration=dist.iteration,
+ signing_key=pk,
+ )
+
+ def advance_to(self, target: int) -> bytes:
+ """Advance chain to target iteration, caching skipped keys. Returns message key."""
+ if target < self.iteration:
+ mk = self._skipped_keys.pop(target, None)
+ if mk is None:
+ raise ValueError(f"Message key {target} already consumed or too old")
+ return mk
+
+ skip_count = target - self.iteration
+ if skip_count > MAX_SKIP:
+ raise ValueError(f"Too many skipped messages: {skip_count}")
+
+ for i in range(skip_count):
+ new_ck, mk = _ratchet_chain(self.chain_key)
+ self._skipped_keys[self.iteration] = mk
+ self.chain_key = new_ck
+ self.iteration += 1
+
+ new_ck, mk = _ratchet_chain(self.chain_key)
+ self.chain_key = new_ck
+ self.iteration += 1
+ return mk
+
+
+@dataclass
+class SenderKeyRecord:
+ """Sender's own key state (includes signing private key)."""
+ sender_id: str
+ chain_key: bytes
+ iteration: int
+ signing_sk: Ed25519PrivateKey
+
+ @classmethod
+ def create(cls, sender_id: str) -> "SenderKeyRecord":
+ return cls(
+ sender_id=sender_id,
+ chain_key=os.urandom(CHAIN_KEY_LEN),
+ iteration=0,
+ signing_sk=Ed25519PrivateKey.generate(),
+ )
+
+ def distribution(self) -> SenderKeyDistribution:
+ pk_raw = self.signing_sk.public_key().public_bytes(
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw)
+ return SenderKeyDistribution(
+ sender_id=self.sender_id,
+ chain_key=self.chain_key,
+ iteration=self.iteration,
+ signing_pk=pk_raw,
+ )
+
+ def rotate(self) -> "SenderKeyRecord":
+ """Create a new record with fresh chain key (call on member removal)."""
+ return SenderKeyRecord(
+ sender_id=self.sender_id,
+ chain_key=os.urandom(CHAIN_KEY_LEN),
+ iteration=0,
+ signing_sk=Ed25519PrivateKey.generate(),
+ )
+
+
+# ── Group store ──────────────────────────────────────────────────────────────
+
+class GroupSenderKeyStore:
+ """All sender key states for one group, held by one member."""
+
+ def __init__(self, group_id: str):
+ self.group_id = group_id
+ self._states: dict[str, SenderKeyState] = {}
+
+ def add_sender(self, dist: SenderKeyDistribution) -> None:
+ self._states[dist.sender_id] = SenderKeyState.from_distribution(dist)
+
+ def remove_sender(self, sender_id: str) -> None:
+ self._states.pop(sender_id, None)
+
+ def get_state(self, sender_id: str) -> SenderKeyState | None:
+ return self._states.get(sender_id)
+
+ @property
+ def sender_count(self) -> int:
+ return len(self._states)
+
+
+# ── Encrypt / Decrypt ────────────────────────────────────────────────────────
+
+@dataclass
+class SenderKeyMessage:
+ """Wire format for a Sender Keys encrypted message."""
+ sender_id: str
+ iteration: int
+ ciphertext: bytes
+ nonce: bytes
+ signature: bytes
+
+ def serialize(self) -> bytes:
+ sender_bytes = self.sender_id.encode()
+ return (
+ struct.pack(">H", len(sender_bytes))
+ + sender_bytes
+ + struct.pack(">I", self.iteration)
+ + struct.pack(">I", len(self.ciphertext))
+ + self.ciphertext
+ + self.nonce
+ + self.signature
+ )
+
+ @classmethod
+ def deserialize(cls, data: bytes) -> "SenderKeyMessage":
+ offset = 0
+ sender_len = struct.unpack(">H", data[offset:offset + 2])[0]
+ offset += 2
+ sender_id = data[offset:offset + sender_len].decode()
+ offset += sender_len
+ iteration = struct.unpack(">I", data[offset:offset + 4])[0]
+ offset += 4
+ ct_len = struct.unpack(">I", data[offset:offset + 4])[0]
+ offset += 4
+ ciphertext = data[offset:offset + ct_len]
+ offset += ct_len
+ nonce = data[offset:offset + 12]
+ offset += 12
+ signature = data[offset:offset + 64]
+ return cls(sender_id=sender_id, iteration=iteration,
+ ciphertext=ciphertext, nonce=nonce, signature=signature)
+
+
+def encrypt_message(
+ record: SenderKeyRecord,
+ plaintext: bytes,
+ aad: bytes = b"",
+) -> tuple[SenderKeyMessage, SenderKeyRecord]:
+ """
+ Encrypt a message with the sender's chain key.
+ Returns (message, updated_record).
+ """
+ new_ck, mk = _ratchet_chain(record.chain_key)
+ iteration = record.iteration
+
+ nonce = os.urandom(12)
+ ct = AESGCM(mk).encrypt(nonce, plaintext, aad or None)
+
+ sig_payload = struct.pack(">I", iteration) + nonce + ct
+ signature = record.signing_sk.sign(sig_payload)
+
+ msg = SenderKeyMessage(
+ sender_id=record.sender_id,
+ iteration=iteration,
+ ciphertext=ct,
+ nonce=nonce,
+ signature=signature,
+ )
+
+ updated = SenderKeyRecord(
+ sender_id=record.sender_id,
+ chain_key=new_ck,
+ iteration=iteration + 1,
+ signing_sk=record.signing_sk,
+ )
+ return msg, updated
+
+
+def decrypt_message(
+ store: GroupSenderKeyStore,
+ msg: SenderKeyMessage,
+ aad: bytes = b"",
+) -> bytes:
+ """
+ Decrypt and verify a Sender Keys message.
+ Advances the sender's chain state in the store.
+ """
+ state = store.get_state(msg.sender_id)
+ if state is None:
+ raise ValueError(f"Unknown sender: {msg.sender_id}")
+
+ sig_payload = struct.pack(">I", msg.iteration) + msg.nonce + msg.ciphertext
+ state.signing_key.verify(msg.signature, sig_payload)
+
+ mk = state.advance_to(msg.iteration)
+ return AESGCM(mk).decrypt(msg.nonce, msg.ciphertext, aad or None)
diff --git a/packages/meshbay-common/tests/test_senderkeys.py b/packages/meshbay-common/tests/test_senderkeys.py
new file mode 100644
index 0000000..a1181e1
--- /dev/null
+++ b/packages/meshbay-common/tests/test_senderkeys.py
@@ -0,0 +1,211 @@
+"""
+Tests for the Sender Keys group messaging protocol.
+
+Covers: key creation, distribution, encrypt/decrypt, multi-member groups,
+out-of-order delivery, serialization, and key rotation on member removal.
+"""
+
+import pytest
+
+from meshbay_common.senderkeys import (
+ SenderKeyRecord,
+ SenderKeyDistribution,
+ SenderKeyMessage,
+ GroupSenderKeyStore,
+ encrypt_message,
+ decrypt_message,
+)
+
+
+def test_basic_encrypt_decrypt():
+ """Alice encrypts, Bob decrypts using Alice's distributed sender key."""
+ alice_rec = SenderKeyRecord.create("alice")
+ alice_dist = alice_rec.distribution()
+
+ bob_store = GroupSenderKeyStore("group-1")
+ bob_store.add_sender(alice_dist)
+
+ msg, alice_rec = encrypt_message(alice_rec, b"hello group")
+ plaintext = decrypt_message(bob_store, msg)
+ assert plaintext == b"hello group"
+
+
+def test_multiple_messages_sequential():
+ """Multiple messages from the same sender decrypt in order."""
+ alice_rec = SenderKeyRecord.create("alice")
+ store = GroupSenderKeyStore("group-1")
+ store.add_sender(alice_rec.distribution())
+
+ for i in range(5):
+ msg, alice_rec = encrypt_message(alice_rec, f"message {i}".encode())
+ pt = decrypt_message(store, msg)
+ assert pt == f"message {i}".encode()
+
+
+def test_multi_member_group():
+ """Three members: Alice sends, Bob and Carol both decrypt."""
+ alice_rec = SenderKeyRecord.create("alice")
+ alice_dist = alice_rec.distribution()
+
+ bob_store = GroupSenderKeyStore("group-1")
+ bob_store.add_sender(alice_dist)
+
+ carol_store = GroupSenderKeyStore("group-1")
+ carol_store.add_sender(alice_dist)
+
+ msg, alice_rec = encrypt_message(alice_rec, b"broadcast")
+
+ assert decrypt_message(bob_store, msg) == b"broadcast"
+ assert decrypt_message(carol_store, msg) == b"broadcast"
+
+
+def test_bidirectional_chat():
+ """Alice and Bob both send and receive."""
+ alice_rec = SenderKeyRecord.create("alice")
+ bob_rec = SenderKeyRecord.create("bob")
+
+ alice_store = GroupSenderKeyStore("group-1")
+ alice_store.add_sender(bob_rec.distribution())
+
+ bob_store = GroupSenderKeyStore("group-1")
+ bob_store.add_sender(alice_rec.distribution())
+
+ msg1, alice_rec = encrypt_message(alice_rec, b"hi bob")
+ assert decrypt_message(bob_store, msg1) == b"hi bob"
+
+ msg2, bob_rec = encrypt_message(bob_rec, b"hi alice")
+ assert decrypt_message(alice_store, msg2) == b"hi alice"
+
+
+def test_out_of_order_delivery():
+ """Messages delivered out of order are decrypted correctly (up to MAX_SKIP)."""
+ alice_rec = SenderKeyRecord.create("alice")
+ store = GroupSenderKeyStore("group-1")
+ store.add_sender(alice_rec.distribution())
+
+ msg0, alice_rec = encrypt_message(alice_rec, b"msg 0")
+ msg1, alice_rec = encrypt_message(alice_rec, b"msg 1")
+ msg2, alice_rec = encrypt_message(alice_rec, b"msg 2")
+
+ # Deliver out of order: 2, 0, 1
+ assert decrypt_message(store, msg2) == b"msg 2"
+ assert decrypt_message(store, msg0) == b"msg 0"
+ assert decrypt_message(store, msg1) == b"msg 1"
+
+
+def test_replay_rejected():
+ """A message decrypted twice raises an error (replay protection)."""
+ alice_rec = SenderKeyRecord.create("alice")
+ store = GroupSenderKeyStore("group-1")
+ store.add_sender(alice_rec.distribution())
+
+ msg, alice_rec = encrypt_message(alice_rec, b"once only")
+ decrypt_message(store, msg)
+
+ with pytest.raises(ValueError, match="already consumed"):
+ decrypt_message(store, msg)
+
+
+def test_unknown_sender_rejected():
+ """Message from an unknown sender raises ValueError."""
+ alice_rec = SenderKeyRecord.create("alice")
+ store = GroupSenderKeyStore("group-1")
+
+ msg, _ = encrypt_message(alice_rec, b"who am i")
+ with pytest.raises(ValueError, match="Unknown sender"):
+ decrypt_message(store, msg)
+
+
+def test_non_member_cannot_decrypt():
+ """Eve (not in group) cannot decrypt Alice's messages."""
+ alice_rec = SenderKeyRecord.create("alice")
+ eve_store = GroupSenderKeyStore("group-1")
+
+ msg, _ = encrypt_message(alice_rec, b"secret")
+ with pytest.raises(ValueError, match="Unknown sender"):
+ decrypt_message(eve_store, msg)
+
+
+def test_key_rotation_on_member_removal():
+ """After rotation, old chain keys cannot decrypt new messages."""
+ alice_rec = SenderKeyRecord.create("alice")
+ old_dist = alice_rec.distribution()
+
+ # Eve had Alice's old key
+ eve_store = GroupSenderKeyStore("group-1")
+ eve_store.add_sender(old_dist)
+
+ # Alice rotates (member removed from group)
+ alice_rec = alice_rec.rotate()
+ new_dist = alice_rec.distribution()
+
+ # Bob gets the new distribution
+ bob_store = GroupSenderKeyStore("group-1")
+ bob_store.add_sender(new_dist)
+
+ msg, alice_rec = encrypt_message(alice_rec, b"post-rotation")
+ assert decrypt_message(bob_store, msg) == b"post-rotation"
+
+ # Eve cannot decrypt with old key
+ with pytest.raises(Exception):
+ decrypt_message(eve_store, msg)
+
+
+def test_distribution_serialization():
+ """SenderKeyDistribution round-trips through serialize/deserialize."""
+ rec = SenderKeyRecord.create("alice")
+ dist = rec.distribution()
+ data = dist.serialize()
+ recovered = SenderKeyDistribution.deserialize(data)
+
+ assert recovered.sender_id == dist.sender_id
+ assert recovered.chain_key == dist.chain_key
+ assert recovered.iteration == dist.iteration
+ assert recovered.signing_pk == dist.signing_pk
+
+
+def test_message_serialization():
+ """SenderKeyMessage round-trips through serialize/deserialize."""
+ rec = SenderKeyRecord.create("alice")
+ store = GroupSenderKeyStore("group-1")
+ store.add_sender(rec.distribution())
+
+ msg, _ = encrypt_message(rec, b"serialize me")
+ data = msg.serialize()
+ recovered = SenderKeyMessage.deserialize(data)
+
+ assert recovered.sender_id == msg.sender_id
+ assert recovered.iteration == msg.iteration
+ assert recovered.ciphertext == msg.ciphertext
+ assert recovered.nonce == msg.nonce
+ assert recovered.signature == msg.signature
+
+ # Deserialized message still decrypts
+ pt = decrypt_message(store, recovered)
+ assert pt == b"serialize me"
+
+
+def test_tampered_ciphertext_rejected():
+ """Modifying the ciphertext makes signature verification fail."""
+ alice_rec = SenderKeyRecord.create("alice")
+ store = GroupSenderKeyStore("group-1")
+ store.add_sender(alice_rec.distribution())
+
+ msg, _ = encrypt_message(alice_rec, b"authentic")
+ msg.ciphertext = bytes([b ^ 0xff for b in msg.ciphertext])
+
+ with pytest.raises(Exception):
+ decrypt_message(store, msg)
+
+
+def test_store_sender_count():
+ """GroupSenderKeyStore tracks sender count correctly."""
+ store = GroupSenderKeyStore("group-1")
+ assert store.sender_count == 0
+
+ store.add_sender(SenderKeyRecord.create("alice").distribution())
+ store.add_sender(SenderKeyRecord.create("bob").distribution())
+ assert store.sender_count == 2
+
+ store.remove_sender("alice")
+ assert store.sender_count == 1