summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--packages/meshbay-common/src/meshbay_common/chatbox.py19
-rw-r--r--packages/meshbay-common/src/meshbay_common/senderkeys.py312
-rw-r--r--packages/meshbay-common/tests/test_senderkeys.py211
-rw-r--r--packages/meshbay-node/src/meshbay_node/chat/__init__.py8
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py10
-rw-r--r--packages/meshbay-node/tests/test_chat_encryption.py7
6 files changed, 19 insertions, 548 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)
diff --git a/packages/meshbay-common/tests/test_senderkeys.py b/packages/meshbay-common/tests/test_senderkeys.py
deleted file mode 100644
index a1181e1..0000000
--- a/packages/meshbay-common/tests/test_senderkeys.py
+++ /dev/null
@@ -1,211 +0,0 @@
-"""
-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
diff --git a/packages/meshbay-node/src/meshbay_node/chat/__init__.py b/packages/meshbay-node/src/meshbay_node/chat/__init__.py
index cb2c868..400cb1a 100644
--- a/packages/meshbay-node/src/meshbay_node/chat/__init__.py
+++ b/packages/meshbay-node/src/meshbay_node/chat/__init__.py
@@ -1,10 +1,8 @@
"""MeshBay Node — chat storage and relay.
-Encryption is the client's: the node holds an epoch key it delivers to members
-and never a plaintext message once a group has the switch on. See
-`docs/chat-sender-keys.md`. It is not the Sender Keys ratchet this module's
-docstring used to name — `senderkeys.py` is unused by production and is kept for
-a possible future 1:1 DM, alongside `ratchet.py`.
+Encryption is the client's: the node holds an epoch key it delivers to members,
+and never a plaintext message. Not a ratchet — a key per group, per epoch, per
+device, for the reasons `meshbay_common/chatbox.py` sets out.
"""
from .store import (
FORMAT_PLAIN,
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index ec7ef5d..2a90bb4 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -383,12 +383,10 @@ class WebRTCPeerSession:
self._username: str = ""
# This connection's key in the group's peer registry. **Per connection,
# never per account**: one person may hold several devices here, and
- # keying the registry by user_id made the second evict the first — the
- # same "keyed by account where it should be keyed by device" mistake as
- # `pin_identity`'s old INSERT OR REPLACE and as GroupSenderKeyStore's
- # silent overwrite. Symptom was invisible: two devices of one account
- # could not both be connected, and whichever disconnected took the
- # other's chat delivery with it. See docs/chat-sender-keys.md F7.
+ # keying the registry by user_id makes the second evict the first, and
+ # the symptom is invisible: two devices of one account cannot both be
+ # connected, and whichever disconnects takes the other's chat delivery
+ # with it.
self._registry_key: str = uuid.uuid4().hex
# Set from the roster: the key this node pinned for this account. Never
# from the JWT — the hub picks what goes in there.
diff --git a/packages/meshbay-node/tests/test_chat_encryption.py b/packages/meshbay-node/tests/test_chat_encryption.py
index ea4de2f..0401d27 100644
--- a/packages/meshbay-node/tests/test_chat_encryption.py
+++ b/packages/meshbay-node/tests/test_chat_encryption.py
@@ -269,10 +269,9 @@ async def test_a_member_cannot_send_as_another_members_device(node):
The hole that would have made encrypted chat *worse* than plaintext chat.
Receivers verify a signature against the `device` field, so a member free
- to name somebody else's key could be that member to everyone — which is
- exactly what `GroupSenderKeyStore.add_sender` allowed, one design earlier
- (`docs/chat-sender-keys.md` F1). The connection has proved which device it
- is, and the claim must match it.
+ to name somebody else's key could be that member to everyone — and the
+ signature would check out. The connection has proved which device it is,
+ and the claim must match it.
"""
await ops.ensure_chat_epoch(node["state"], GROUP)
_sk_alice, raw_alice, b64_alice = _device()