diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-10 03:07:56 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-10 03:07:56 +0200 |
| commit | 4b3e8c3b8b9d10c8ac333dd8db614a7569052472 (patch) | |
| tree | 8828d7d549adc8bb21d8a7f6533c8e9db9c298b9 /packages/meshbay-common | |
| parent | 8ccbe262ecf4a8f7545cbf1e9f1cc5a485acae67 (diff) | |
| download | meshbay-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.py | 2 | ||||
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/senderkeys.py | 287 | ||||
| -rw-r--r-- | packages/meshbay-common/tests/test_senderkeys.py | 211 |
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 |