From 6cf21a019963468cb853e5c763ef0097115efa46 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 10 Sep 2026 17:30:22 +0200 Subject: refactor(common): delete the sender-key implementation nothing uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3 --- .../meshbay-common/src/meshbay_common/chatbox.py | 19 +- .../src/meshbay_common/senderkeys.py | 312 --------------------- packages/meshbay-common/tests/test_senderkeys.py | 211 -------------- .../meshbay-node/src/meshbay_node/chat/__init__.py | 8 +- .../src/meshbay_node/transport/webrtc_server.py | 10 +- .../meshbay-node/tests/test_chat_encryption.py | 7 +- 6 files changed, 19 insertions(+), 548 deletions(-) delete mode 100644 packages/meshbay-common/src/meshbay_common/senderkeys.py delete mode 100644 packages/meshbay-common/tests/test_senderkeys.py (limited to 'packages') 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() -- cgit v1.2.3