diff options
Diffstat (limited to 'packages/meshbay-common/src')
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/chatbox.py | 19 | ||||
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/senderkeys.py | 312 |
2 files changed, 9 insertions, 322 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/chatbox.py b/packages/meshbay-common/src/meshbay_common/chatbox.py index f3cfc76..740e829 100644 --- a/packages/meshbay-common/src/meshbay_common/chatbox.py +++ b/packages/meshbay-common/src/meshbay_common/chatbox.py @@ -4,13 +4,13 @@ Chat message encryption and sender authentication. Design A of `docs/chat-sender-keys.md`, decided 2026-09-07. What it is, and what it deliberately is not, in the order the decisions were made: -**Not a ratchet.** `senderkeys.py` implements Signal-style sender keys and is -unused by production. With sender keys distributed under the group key and a -node that serves history to devices which were not present, the node must retain -and hand out each chain's *earliest* key — and a chain key at iteration *i* -yields every message key from *i* onward by pure HKDF. Forward secrecy is then -zero, and the ratchet is computing HKDF over a value every member already holds. -The property is given up on the record rather than inherited by accident. +**Not a ratchet, and not sender keys.** With per-sender chains distributed under +the group key, and a node that serves history to devices which were not present, +the node must retain and hand out each chain's *earliest* key — and a chain key +at iteration *i* yields every message key from *i* onward by pure HKDF. Forward +secrecy is then zero, and the ratchet is computing HKDF over a value every +member already holds. The property is given up on the record rather than +inherited by accident. **A key per group, per epoch, per device.** The node generates an epoch key and delivers it to members wrapped under the current group key. Each device derives @@ -18,9 +18,8 @@ its *own* subkey from it, by name, so: * two *keys* never share a subkey, and — the part that actually matters — **there is no mutable sending state at all**, so nothing can be advanced - twice. §15.0b wanted per-device chains because two devices advancing one - chain produce key and nonce reuse (C1, one level down, and exactly what - `GroupSenderKeyStore` got wrong). Derivation plus a *random* nonce removes + twice. Two devices advancing one chain produce key and nonce reuse, which is + the failure this design cannot have; derivation plus a *random* nonce removes the hazard rather than partitioning it; Be precise about what that does **not** say, because the obvious stronger diff --git a/packages/meshbay-common/src/meshbay_common/senderkeys.py b/packages/meshbay-common/src/meshbay_common/senderkeys.py deleted file mode 100644 index 9ad5107..0000000 --- a/packages/meshbay-common/src/meshbay_common/senderkeys.py +++ /dev/null @@ -1,312 +0,0 @@ -""" -MeshBay — Sender Keys protocol. **Not used by group chat. Not used at all.** - -Kept the way `ratchet.py` is kept: a working implementation of a protocol that -may earn a place in a future 1:1 DM, where there is no server-side history to -contradict it. Group chat is `chatbox.py`, and the decision to build that -instead is `docs/chat-sender-keys.md` §4 (operator, 2026-09-07). Do not read a -green test run here as evidence that group chat is encrypted; nothing in -production imports this module. - -**Why it is not what group chat uses.** With sender keys distributed under the -group key, and a node that serves history to devices which were not present when -a message was sent, the node must retain and hand out each chain's *earliest* -key — and a chain key at iteration *i* yields every message key from *i* onward -by pure HKDF. Forward secrecy is then zero, and what is left is a large amount -of stateful client code whose failure modes are silent. Three of them are real -and reproduced in the design document: - - * `GroupSenderKeyStore.add_sender` accepts any distribution for any - `sender_id` and overwrites what is there, and `SenderKeyRecord.create` - invents a signing key bound to nothing — so under group-key distribution any - member can replace another member's chain and sign as them (F1); - * a second device registering under one `sender_id` drops the first device's - chain, and its messages then fail signature verification rather than failing - visibly at registration (F2); - * `SenderKeyState.advance_to` caches every skipped message key and nothing - trims `_skipped_keys` (F3). - -They are findings about a module nothing calls, and are deliberately not fixed -here. Anyone bringing this back for 1:1 DM must fix all three first — and must -bind the distribution to a key the node pinned, which is what F1 is really about. - -Key components, as implemented: - - Chain key ratchet: HKDF per message - - Message key derivation: separate HKDF from chain key - - Ed25519 signing: each sender signs their ciphertext - - AES-256-GCM encryption: browser-compatible symmetric cipher -""" - -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. - - One chain per **device**, were this ever used: a shared per-person chain - advanced by two devices produces key and nonce reuse, which is `first- - review.md` C1 one level down. `add_sender` does not enforce that — see the - module docstring, F2. - """ - - 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) |