aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src/meshbay_common
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-10 17:30:22 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-10 17:30:22 +0200
commit6cf21a019963468cb853e5c763ef0097115efa46 (patch)
treeac0fd6261252b744ffdf15f0b100af7fd252d221 /packages/meshbay-common/src/meshbay_common
parentefc93c187dba9027292b51ff0e9caa29349c953e (diff)
downloadmeshbay-6cf21a019963468cb853e5c763ef0097115efa46.tar.gz
refactor(common): delete the sender-key implementation nothing uses
`senderkeys.py` and its 13 tests implemented Signal-style sender keys, and production has never called them: chat is a key per group, per epoch, per device, derived by name. The reasoning that ruled the ratchet out stays where it belongs — in `chatbox.py`, at the top of the module that replaced it — because the argument is the useful part, and it now stands on its own instead of pointing at a file to compare against. Kept code that nothing calls is worse than absent code: it reads as an alternative somebody may reach for, and it has to be maintained past every refactor to stay compiling, which is maintenance spent on a decision already made. The three comments naming `GroupSenderKeyStore` are rewritten to say the thing they were illustrating. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
Diffstat (limited to 'packages/meshbay-common/src/meshbay_common')
-rw-r--r--packages/meshbay-common/src/meshbay_common/chatbox.py19
-rw-r--r--packages/meshbay-common/src/meshbay_common/senderkeys.py312
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)