diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-07 17:50:28 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-07 17:50:28 +0200 |
| commit | 36cebf25d0e0f24cf63be4380ccb5d03da726a74 (patch) | |
| tree | 8509ec4cf68a058f7383299e11bdea97ab06cadf /packages | |
| parent | 8883d60d0afa2ed9dd1ef68bc21fe1b9a65a59ff (diff) | |
| download | meshbay-36cebf25d0e0f24cf63be4380ccb5d03da726a74.tar.gz | |
feat(chat): encrypt group chat under per-device epoch keys (MNP 2.0)
Chat messages are sealed with AES-256-GCM under a key derived per group, per
epoch, per *device*, and signed over the ciphertext with the device key the
node pinned. The node relays and archives; it cannot read a message.
There is no switch. MNP goes to 2.0 and MNP_MIN_SUPPORTED moves with it, so a
1.x peer is refused at the handshake with `version_too_old` rather than
admitted and then unable to speak. An opt-in flag was designed and rejected:
every node is a test node, so it would have bought nothing and left a plaintext
branch reachable — C6's lesson one feature later. A test reads the source and
refuses any code that consults a `chat_encrypted` setting.
Not Sender Keys, and `senderkeys.py` is now documented as unused. With
distribution under the group key and a node that serves history to devices
which were not present, the node must retain each chain's earliest key, and a
chain key at iteration i yields every message key from i on by pure HKDF —
forward secrecy is zero either way. What the ratchet was left buying was
stateful client code with silent failure modes, three of them reproduced: any
member could sign as any other, a second device dropped the first's chain, and
the skipped-key cache grew without bound. The reasoning is in
docs/chat-sender-keys.md, which is the specification and the decision record.
Epochs, not rotation: the epoch key is wrapped under the group key at delivery
and never stored under it, so `gek_rotate` is a re-wrap. A group-key-derived
archive key would have made every message ever sent unreadable on the first
`member unpin`, which is the documented step after removing a member. A new
epoch opens on member revoke/unpin, device revoke and `gek_rotate`; old epochs
are kept and still delivered, so history stays readable to everyone who could
already read it, and nothing anywhere deletes one.
Three prerequisites this needed, each a live defect on its own:
* The peer registry was keyed by user_id, so one account's second device
evicted the first and the broadcast skipped recipients by account — a
person's phone never saw what they typed on their laptop.
* The handshake authenticated an account, never a device. `device_hello`
(additive, signed, refused unless the key is a live device of this account in
the node's own roster) is what lets the node refuse a member claiming
somebody else's key.
* `_admin_exec_file_delete` authorized against the exact uploading key, so
device linking had already broken deleting your own file from your other
device. It now authorizes against any non-revoked device of `uploader_id`.
Found by driving the real panel over the real transport, not by reading source:
`chat_keys_resp` was routed by arrival order and handed to an unanswered
`media_meta_req` — the original frozen-tab defect in a message type that did
not exist when that probe was written. And `_asText` had been deleted with an
unrelated helper beside it; its only caller sits inside a promise the panel
catches, so every conversation rendered empty with nothing in the console.
Existing node data is migrated by QE/migration/migrate_chat_encryption.py
(not versioned, per the QE rule), run with the node stopped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
Diffstat (limited to 'packages')
40 files changed, 3681 insertions, 165 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py index b3e24e3..ca4c6eb 100644 --- a/packages/meshbay-common/src/meshbay_common/__init__.py +++ b/packages/meshbay-common/src/meshbay_common/__init__.py @@ -90,8 +90,19 @@ __version__ = "0.11.0" # makes the *next* breaking change cost a refusal message instead of a second # flag day. `MNP_MIN_SUPPORTED` in `handshake.py` is the other half. # +# **2.0 (2026-09-07): chat is encrypted, and there is no way to turn it off.** +# A MAJOR bump because it is a real break: a 1.x peer cannot produce a sealed +# chat message and cannot read one, so it is refused at the handshake with +# `version_too_old` rather than connecting and then failing to speak. Expressing +# the break in the version is what makes it a stated refusal instead of a +# conversation that silently does not work — `MNP_MIN_SUPPORTED` moves with it. +# +# There is deliberately no per-group switch. Every node in existence is a test +# node, so an opt-in flag would buy nothing and cost a compatibility path to +# maintain; existing node data is migrated by `QE/migrate-chat-encryption.py`. +# # The index at rest, `index_progress` (counters only, never a path — see -# `groupbox.py` and daemon.py `_push_index_progress`), chat, and file content -# on the operator's disk are all deliberately unchanged. -MNP_VERSION = "1.1" +# `groupbox.py` and daemon.py `_push_index_progress`), and file content on the +# operator's disk are all deliberately unchanged. +MNP_VERSION = "2.0" MHP_VERSION = "0.1" diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py index 9a56934..ed6e940 100644 --- a/packages/meshbay-common/src/meshbay_common/adminop.py +++ b/packages/meshbay-common/src/meshbay_common/adminop.py @@ -112,6 +112,14 @@ OP_ROOT_REMOVE = "root_remove" OP_APP_DIRECTORIES = "app_directories" OP_CHAT_DIRECTORY = "chat_directory" OP_CHAT_LINK_PREVIEW = "chat_link_preview" +# Open a new chat epoch for a group, by hand. The removals that matter open one +# by themselves (member revoke/unpin, device revoke, gek_rotate); this is the +# operator saying "do it anyway", which is the same shape as `gek_rotate` and +# signed for the same reason. +# +# There is no op for *enabling* chat encryption. It is not a setting — MNP 2.0 +# has no plaintext chat to fall back to. +OP_CHAT_EPOCH = "chat_epoch" OP_ROOT_UPDATE = "root_update" OP_ROOT_EJECT = "root_eject" OP_ROOT_PLUG = "root_plug" diff --git a/packages/meshbay-common/src/meshbay_common/chatbox.py b/packages/meshbay-common/src/meshbay_common/chatbox.py new file mode 100644 index 0000000..e04bd9b --- /dev/null +++ b/packages/meshbay-common/src/meshbay_common/chatbox.py @@ -0,0 +1,181 @@ +""" +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. + +**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 +its *own* subkey from it, by name, so: + +* two devices never share an AES key, and nonce reuse across devices is + impossible without any coordination — the property per-device ratchet chains + were wanted for, obtained by derivation instead of by mutable state (which is + C1 one level down, and is exactly what `GroupSenderKeyStore` got wrong); +* a receiver derives any sender's subkey from the epoch key it already has, so + nothing is distributed per device and there is no per-device state to + persist, migrate or lose; +* history keeps working, for new members and new devices alike, because the + epoch key does not move as messages are sent. + +**Epochs, not rotation of the archive.** The epoch key is wrapped under the +group key *at delivery*, never stored under it — so rotating the group key +(which is the documented step after removing a member) is a re-wrap and costs +nothing. A new epoch is opened when the set of devices that may read *future* +messages shrinks; old epochs are kept and still delivered to current members, +which is what keeps the history readable to the people who could already read it. + +**Signing is separate from encryption**, and is what actually establishes who +said something. The signature is over the *ciphertext*, so it can be checked +before decryption and by anyone holding the roster, and it names the device key +the node pinned — not a fresh key the sender invented, which is what made the +sender-key distribution format forgeable by any member. + +What none of this protects against, stated per the v5/v6 convention: the node +operator and every current group member hold the group key and therefore the +epoch keys. This is the same boundary as file access, by design. It protects +against someone who obtains the node's storage without the keystore password. +""" + +from __future__ import annotations + +import os + +import msgpack +from cryptography.hazmat.primitives import hashes +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 + +EPOCH_KEY_LEN = 32 +# 96-bit, the WebCrypto AES-GCM standard — and the same reasoning as +# `groupbox.py`: one key per device per epoch puts the NIST SP 800-38D ceiling +# of 2**32 random nonces far out of reach of a person typing. +NONCE_LEN = 12 +SIG_LEN = 64 + +_DEVICE_KEY_INFO = "meshbay:chat:dev:v1" +_SIG_PREFIX = b"meshbay:chat:v1" + + +def new_epoch_key() -> bytes: + """A fresh epoch key. Generated by the node — never by a member (C5b).""" + return os.urandom(EPOCH_KEY_LEN) + + +def device_key(epoch_key: bytes, group_id: str, device_b64: str) -> bytes: + """ + The AES-256 subkey one device encrypts under, in one group, in one epoch. + + Derived by name, so every member can compute every device's subkey from the + epoch key and nobody has to distribute anything per device. `salt=None` here + matches `salt: new Uint8Array(0)` in crypto.js — RFC 5869 extracts with a + zero key either way, which `deriveChunkKey` already relies on in production + and `test_js_python_parity` holds. + """ + if not epoch_key: + raise ValueError("no epoch key") + if not device_b64: + raise ValueError("no device") + info = f"{_DEVICE_KEY_INFO}|{group_id}|{device_b64}".encode() + return HKDF( + algorithm=hashes.SHA256(), length=32, salt=None, info=info, + ).derive(epoch_key) + + +def associated_data(group_id: str, epoch: int) -> bytes: + """ + What a chat ciphertext is bound to. + + The group stops a message being moved between two groups on one node; the + epoch stops one being re-presented as belonging to a later key, which would + otherwise let a member who kept an old epoch key make an old message look + current. Same reasoning as `groupbox.associated_data`, one field longer. + """ + return f"chat_msg|{group_id}|{epoch}".encode() + + +def signing_transcript(group_id: str, epoch: int, device: bytes, nonce: bytes, + ct: bytes) -> bytes: + """ + What the sending device signs — the ciphertext, never the plaintext. + + Over the ciphertext so a receiver can establish authorship *before* it + decrypts, and so anyone holding the roster can verify a stored message + without the epoch key. Length-prefixed and domain-separated, per L4, like + every other transcript in this codebase: without the lengths, a message + could be re-cut into a different one with the same bytes. + + Note what is *not* in here: this connection's nonce. Every other transcript + binds one, and this one cannot — a receiver reading history has no access to + the connection the message arrived on. Replay is therefore refused by + storage instead, on the unique `(device, nonce)` pair. + """ + out = bytearray(_SIG_PREFIX) + for field in (group_id.encode(), str(epoch).encode(), device, nonce, ct): + out += len(field).to_bytes(4, "big") + out += field + return bytes(out) + + +def seal(epoch_key: bytes, group_id: str, epoch: int, device_b64: str, + device_raw: bytes, sk: Ed25519PrivateKey, payload: dict) -> dict: + """ + One message, sealed and signed: `{nonce, ct, sig}` for the caller to merge. + + The routing and authentication fields — `format`, `epoch`, `device` — stay + in clear, because a receiver has to select a key and check a signature + before it can decrypt, and because the node routes on them without being + able to read anything. + """ + key = device_key(epoch_key, group_id, device_b64) + # Random per message. Never derived from the payload: two identical messages + # under one device's key would then reuse it, and AES-GCM's failure under + # nonce reuse is not graceful. + nonce = os.urandom(NONCE_LEN) + ct = AESGCM(key).encrypt( + nonce, msgpack.packb(payload, use_bin_type=True), + associated_data(group_id, epoch)) + sig = sk.sign(signing_transcript(group_id, epoch, device_raw, nonce, ct)) + return {"nonce": nonce, "ct": ct, "sig": sig} + + +def verify(group_id: str, epoch: int, device_raw: bytes, nonce: bytes, + ct: bytes, sig: bytes) -> bool: + """Whether `sig` is this device's signature over this ciphertext.""" + try: + pk = Ed25519PublicKey.from_public_bytes(device_raw) + pk.verify(sig, signing_transcript(group_id, epoch, device_raw, nonce, ct)) + return True + except Exception: + return False + + +def open_message(epoch_key: bytes, group_id: str, epoch: int, device_b64: str, + nonce: bytes, ct: bytes) -> dict: + """ + Open a sealed message. Raises on anything that does not open. + + Never a partial result and never a default — an unopenable message is not an + empty one, and rendering it as blank would make a message nobody can read + indistinguishable from a message nobody wrote. The caller marks it as + unreadable and shows the gap, which is the honest thing for a reader to see. + """ + key = device_key(epoch_key, group_id, device_b64) + plain = AESGCM(key).decrypt(bytes(nonce), bytes(ct), + associated_data(group_id, epoch)) + payload = msgpack.unpackb(plain, raw=False) + if not isinstance(payload, dict): + raise ValueError("chat: sealed payload is not a map") + return payload diff --git a/packages/meshbay-common/src/meshbay_common/device.py b/packages/meshbay-common/src/meshbay_common/device.py index 8951dbb..cfa8dd6 100644 --- a/packages/meshbay-common/src/meshbay_common/device.py +++ b/packages/meshbay-common/src/meshbay_common/device.py @@ -36,6 +36,7 @@ import hashlib DEVICE_REQUEST_PREFIX = b"meshbay:device_req:v1" DEVICE_ADD_PREFIX = b"meshbay:device_add:v1" +DEVICE_HELLO_PREFIX = b"meshbay:device_hello:v1" # Same as the join and admin transcripts: interactive exchanges that complete in # milliseconds, so anything older is a replay. @@ -123,3 +124,41 @@ def device_add_transcript( nonce_node, str(ts).encode(), ]) + + +def device_hello_transcript( + node_pk_b64: str, + group_id: str, + user_id: str, + pk_ed25519_b64: str, + nonce_node: bytes, + ts: int, +) -> bytes: + """ + Signed by the device on an already-authenticated connection, saying **which + of the account's devices this connection is**. + + The handshake proves membership of a group (a GEK-HMAC) and carries an + account from the hub's token; it proves nothing about *which* device is + talking. The node needed that the moment one account could hold several: + `_load_pinned_pk` was resolving "this account's oldest live device" and + recording it as the uploader of every file, so a phone's uploads were + attributed to a laptop. + + Additive and optional. A client that does not send it leaves the node where + it was, which is why this could ship without a breaking protocol change — + but a node that *has* been told refuses a later claim to be a different + device on the same connection. + + `nonce_node` is this connection's handshake nonce, so the signature cannot + be lifted onto another connection, and `node_pk` binds it to one node — + the same rule as every other transcript here. + """ + return _pack(DEVICE_HELLO_PREFIX, [ + node_pk_b64.encode(), + group_id.encode(), + user_id.encode(), + pk_ed25519_b64.encode(), + nonce_node, + str(ts).encode(), + ]) diff --git a/packages/meshbay-common/src/meshbay_common/groupbox.py b/packages/meshbay-common/src/meshbay_common/groupbox.py index f1091c3..ff60bba 100644 --- a/packages/meshbay-common/src/meshbay_common/groupbox.py +++ b/packages/meshbay-common/src/meshbay_common/groupbox.py @@ -41,6 +41,10 @@ from cryptography.hazmat.primitives.kdf.hkdf import HKDF PURPOSE_INDEX = "index" PURPOSE_ACK = "ack" +# The chat epoch keys themselves, on their way to a member. The keys are what +# the chat archive is encrypted under; this is only how they travel, which is +# why rotating the group key costs a re-wrap and not a re-encryption. +PURPOSE_CHAT_KEYS = "chat_keys" # `salt=None` here and `salt: new Uint8Array(0)` in crypto.js agree — RFC 5869 # extracts with a zero key either way. Already proven in production by @@ -48,6 +52,7 @@ PURPOSE_ACK = "ack" _INFO = { PURPOSE_INDEX: b"meshbay:index:v1", PURPOSE_ACK: b"meshbay:ack:v1", + PURPOSE_CHAT_KEYS: b"meshbay:chat_keys:v1", } NONCE_LEN = 12 # 96-bit, the WebCrypto AES-GCM standard diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py index f7d4911..188a8aa 100644 --- a/packages/meshbay-common/src/meshbay_common/handshake.py +++ b/packages/meshbay-common/src/meshbay_common/handshake.py @@ -66,11 +66,19 @@ from meshbay_common import MNP_VERSION HANDSHAKE_PREFIX = b"meshbay:mnp:handshake:v1" -# The oldest peer this build will talk to. MNP 1.0 sealed `index_sync`, -# `index_delta` and the `handshake_ack` payload under the group key, which no -# 0.x peer can open and which a 0.x peer's own messages do not carry — there is -# nothing to be compatible with, which is what makes it a MAJOR bump. -MNP_MIN_SUPPORTED = "1.0" +# The oldest peer this build will talk to. +# +# 2.0 (2026-09-07): chat messages are sealed under a per-device subkey of the +# group's chat epoch key, and the node refuses a plaintext one. A 1.x peer can +# neither produce nor read that, so there is nothing to be compatible with — +# the same reasoning that made 1.0 a MAJOR bump for the sealed index. +# +# Moving the floor with the version is the point: a 1.x client is refused here, +# with `version_too_old` and a sentence saying so, instead of completing a +# handshake and then discovering that every message it sends is rejected and +# every message it receives is unreadable. A stated refusal is a bug report; a +# chat that quietly does not work is a support case. +MNP_MIN_SUPPORTED = "2.0" ROLE_CLIENT = "client" ROLE_NODE = "node" diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index 689adbf..dfb5d56 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -38,7 +38,12 @@ class MNP: FILE_REQUEST = "file_req" # request chunk(s) FILE_CHUNK = "file_chunk" # encrypted chunk response STREAM_SEGMENT = "stream_seg" # HLS/DASH segment - CHAT_MESSAGE = "chat_msg" # Double Ratchet message + # Not a Double Ratchet message, and never was — `first-review.md` C1 + # rejected exactly that for groups. Plaintext until a group turns + # encryption on, then AES-256-GCM under a per-device subkey of the group's + # chat epoch key, signed with the sending device's pinned Ed25519 key + # (`chatbox.py`, docs/chat-sender-keys.md). + CHAT_MESSAGE = "chat_msg" # one chat message, plain or sealed CHAT_ATTACHMENT = "chat_attach" # attachment metadata CHAT_HISTORY = "chat_hist" # request message history (newest, or before a cursor) CHAT_HISTORY_RESPONSE = "chat_hist_resp" # history response with messages @@ -153,6 +158,14 @@ class MNP: DEVICE_LIST = "device_list" # anyone → node: my devices DEVICE_LIST_RESULT = "device_list_result" DEVICE_REVOKE = "device_revoke" # a device retires another + # "Which of this account's devices am I?" — signed, on an + # already-authenticated connection. The handshake proves the account and the + # group; it never proved the device, so the node attributed uploads to the + # account's oldest key and could not tell one device's chat from another's. + # Additive (MNP 1.2): a client that stays silent leaves the node exactly + # where it was. + DEVICE_HELLO = "device_hello" # device → node: this is me + DEVICE_HELLO_ACK = "device_hello_ack" # Rotation is the half of revocation that revocation cannot do: the node # generates a fresh key itself, so no key material crosses the wire. GEK_ROTATE = "gek_rotate" # operator → node: new group key @@ -176,6 +189,20 @@ class MNP: CHAT_DIRECTORY_ACK = "chat_directory_ack" CHAT_LINK_PREVIEW = "chat_link_preview" CHAT_LINK_PREVIEW_ACK = "chat_link_preview_ack" + # The keys a group's chat archive is encrypted under, on their way to a + # member. Sealed under a group-derived subkey, so the payload carries an + # authentication tag from a key the hub does not hold — and a member who has + # not completed the handshake is served a ciphertext rather than the keys. + # Requested rather than pushed on the ack: a group with no chat should not + # pay for this on every connection. + CHAT_KEYS_REQ = "chat_keys_req" + CHAT_KEYS_RESP = "chat_keys_resp" + # Node → this group: a new chat epoch was opened, because somebody was + # removed. Not a setting — there is no switch; chat is always encrypted + # (MNP 2.0). Pushed so a connected client stops sealing under the retired + # key without having to reconnect. + CHAT_EPOCH = "chat_epoch" + CHAT_EPOCH_ACK = "chat_epoch_ack" ROOT_UPDATE = "root_update" # operator → node: change writable/removable on a root ROOT_UPDATE_ACK = "root_update_ack" ROOT_EJECT = "root_eject" # operator → node: mark removable root as ejected diff --git a/packages/meshbay-common/src/meshbay_common/senderkeys.py b/packages/meshbay-common/src/meshbay_common/senderkeys.py index 932e2e6..9ad5107 100644 --- a/packages/meshbay-common/src/meshbay_common/senderkeys.py +++ b/packages/meshbay-common/src/meshbay_common/senderkeys.py @@ -1,21 +1,40 @@ """ -MeshBay — Sender Keys protocol for group messaging. +MeshBay — Sender Keys protocol. **Not used by group chat. Not used at all.** -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 +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. -Key components: - - Chain key ratchet: HKDF per message, provides forward secrecy +**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 - -Key distribution: - - On join: admin wraps each sender's SenderKeyDistribution with GEK - - On leave: all remaining members rotate their chain keys """ import os @@ -169,7 +188,13 @@ class SenderKeyRecord: # ── Group store ────────────────────────────────────────────────────────────── class GroupSenderKeyStore: - """All sender key states for one group, held by one member.""" + """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 diff --git a/packages/meshbay-common/tests/test_js_python_parity.py b/packages/meshbay-common/tests/test_js_python_parity.py index 6f7437f..dffee36 100644 --- a/packages/meshbay-common/tests/test_js_python_parity.py +++ b/packages/meshbay-common/tests/test_js_python_parity.py @@ -13,6 +13,7 @@ tests drive the real `crypto.js` under node and compare against the real Python. Skipped when node is unavailable; that is a coverage gap, not a pass. """ +import base64 import json import shutil import subprocess @@ -391,3 +392,211 @@ def test_the_browser_refuses_a_payload_sealed_for_another_message(groupbox_js): sealed["nonce"].hex(), sealed["ct"].hex()], capture_output=True, text=True, timeout=60) assert proc.stdout.strip() == "REFUSED", proc.stdout + proc.stderr + + +# ── chatbox: a chat message, sealed and signed, both directions ────────────── +# +# Same class of invisible disagreement as groupbox above, with one more moving +# part: the per-device subkey is derived from a *string* that carries the group +# id and the device's base64 key, so a mismatch in how either is encoded means +# messages that encrypt fine and never decrypt — and AES-GCM reports that +# exactly the way it reports a wrong key. +# +# The signature is checked in both directions too. It is the half that +# establishes who spoke, and unlike the ciphertext it is verified by clients +# that may never hold the epoch key at all. + +# (group_id, epoch) +CHAT_VECTORS = [ + ("g" * 32, 1), + # Epoch is inside the AAD and the transcript as a decimal string; 0 and a + # large value must not collide with each other or with the empty field. + ("g" * 32, 0), + ("g" * 32, 4294967296), + # Empty group id, the operator-pairing shape. + ("", 1), + # Non-ASCII: TextEncoder and Python's .encode() must agree. + ("groupe-café-日本", 2), + # A '|' inside the group id — the separator used by both the AAD and the + # HKDF info string. + ("a|b", 3), +] + +CHAT_EPOCH_KEY = bytes.fromhex("7c" * 32) + +_CHATBOX_HARNESS = r""" +const fs = require('fs'); + +globalThis.window = {}; +const src = fs.readFileSync(process.argv[2], 'utf8'); +const M = new Function(src + + '\nreturn { sealChat, openChat, chatSigningTranscript, verifyChatSignature };')(); + +const hex = (s) => { + const out = new Uint8Array(s.length / 2); + for (let i = 0; i < s.length; i += 2) out[i / 2] = parseInt(s.substr(i, 2), 16); + return out; +}; +const toHex = (u8) => + Array.from(u8).map(b => b.toString(16).padStart(2, '0')).join(''); + +(async () => { + const input = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')); + const epochKey = hex(input.epoch_key); + const out = { opened: [], sealed: [], transcripts: [], verified: [] }; + + for (const v of input.vectors) { + // Python sealed it; open it here. + out.opened.push(toHex(await M.openChat( + epochKey, v.group_id, v.epoch, v.device_b64, hex(v.nonce), hex(v.ct)))); + // Seal the same plaintext here, for Python to open. + const sealed = await M.sealChat( + epochKey, v.group_id, v.epoch, v.device_b64, hex(v.plaintext)); + out.sealed.push({ nonce: toHex(sealed.nonce), ct: toHex(sealed.ct) }); + // The signed bytes, and whether Python's signature verifies here. + out.transcripts.push(toHex(M.chatSigningTranscript( + v.group_id, v.epoch, hex(v.device_raw), hex(v.nonce), hex(v.ct)))); + out.verified.push(await M.verifyChatSignature( + hex(v.device_raw), v.group_id, v.epoch, hex(v.nonce), hex(v.ct), + hex(v.sig))); + } + + process.stdout.write(JSON.stringify(out)); +})().catch((e) => { console.error(e); process.exit(1); }); +""" + + +def _chat_payload(idx: int) -> dict: + """A distinct message per vector, so a crossed result cannot pass.""" + return {"text": f"message {idx} — café", "thread_id": None, + "sender_name": f"member-{idx}", "sent_at": 1_700_000_000 + idx} + + +@pytest.fixture(scope="module") +def chatbox_js(tmp_path_factory): + import msgpack + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + ) + + from meshbay_common.chatbox import seal + + d = tmp_path_factory.mktemp("chatbox-parity") + harness = d / "harness.js" + harness.write_text(_CHATBOX_HARNESS) + + vectors = [] + for i, (group_id, epoch) in enumerate(CHAT_VECTORS): + # A distinct device per vector: the subkey is derived from the device's + # own base64 key, so reusing one would hide a derivation that ignored it. + sk = Ed25519PrivateKey.from_private_bytes(bytes([i + 1]) * 32) + device_raw = sk.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw) + device_b64 = base64.b64encode(device_raw).decode() + payload = _chat_payload(i) + sealed = seal(CHAT_EPOCH_KEY, group_id, epoch, device_b64, device_raw, + sk, payload) + vectors.append({ + "group_id": group_id, "epoch": epoch, + "device_b64": device_b64, "device_raw": device_raw.hex(), + "nonce": sealed["nonce"].hex(), "ct": sealed["ct"].hex(), + "sig": sealed["sig"].hex(), + "plaintext": msgpack.packb(payload, use_bin_type=True).hex(), + }) + + payload_file = d / "vectors.json" + payload_file.write_text(json.dumps({"epoch_key": CHAT_EPOCH_KEY.hex(), + "vectors": vectors})) + + proc = subprocess.run( + ["node", str(harness), str(CRYPTO_JS), str(payload_file)], + capture_output=True, text=True, timeout=60, + ) + if proc.returncode != 0: + pytest.fail(f"node chatbox harness failed:\n{proc.stderr}") + return json.loads(proc.stdout), vectors + + +@pytest.mark.parametrize("idx,vector", list(enumerate(CHAT_VECTORS))) +def test_browser_opens_a_chat_message_python_sealed(idx, vector, chatbox_js): + import msgpack + + js, _ = chatbox_js + assert msgpack.unpackb(bytes.fromhex(js["opened"][idx]), raw=False) == \ + _chat_payload(idx) + + +@pytest.mark.parametrize("idx,vector", list(enumerate(CHAT_VECTORS))) +def test_python_opens_a_chat_message_the_browser_sealed(idx, vector, chatbox_js): + import msgpack + + from meshbay_common.chatbox import open_message + + js, vectors = chatbox_js + group_id, epoch = vector + sealed = js["sealed"][idx] + opened = open_message( + CHAT_EPOCH_KEY, group_id, epoch, vectors[idx]["device_b64"], + bytes.fromhex(sealed["nonce"]), bytes.fromhex(sealed["ct"])) + assert opened == _chat_payload(idx) + # And the msgpack the browser produced is what Python produces, so the two + # are not merely each self-consistent. + assert msgpack.packb(opened, use_bin_type=True) == \ + bytes.fromhex(vectors[idx]["plaintext"]) + + +@pytest.mark.parametrize("idx,vector", list(enumerate(CHAT_VECTORS))) +def test_the_signing_transcript_is_byte_identical(idx, vector, chatbox_js): + from meshbay_common.chatbox import signing_transcript + + js, vectors = chatbox_js + group_id, epoch = vector + v = vectors[idx] + expected = signing_transcript( + group_id, epoch, bytes.fromhex(v["device_raw"]), + bytes.fromhex(v["nonce"]), bytes.fromhex(v["ct"])) + assert js["transcripts"][idx] == expected.hex() + + +@pytest.mark.parametrize("idx,vector", list(enumerate(CHAT_VECTORS))) +def test_the_browser_verifies_a_signature_python_made(idx, vector, chatbox_js): + js, _ = chatbox_js + assert js["verified"][idx] is True + + +def test_a_message_does_not_open_under_another_epoch(chatbox_js): + """ + The epoch is in the AAD, so a message cannot be re-presented as belonging + to a later key. Without it, a member who kept an old epoch key could make + an old message look current — and an AEAD that ignored `additionalData` + would round-trip against itself and pass every other test here. + """ + import pytest as _pytest + + from meshbay_common.chatbox import open_message + + _js, vectors = chatbox_js + v = vectors[0] + group_id, epoch = CHAT_VECTORS[0] + with _pytest.raises(Exception): + open_message(CHAT_EPOCH_KEY, group_id, epoch + 1, v["device_b64"], + bytes.fromhex(v["nonce"]), bytes.fromhex(v["ct"])) + + +def test_a_message_does_not_open_under_another_devices_key(chatbox_js): + """ + Each device has its own subkey, derived from its own public key. That is + what makes nonce reuse across two devices of one person impossible without + any coordination — the property per-device ratchet chains were wanted for. + """ + import pytest as _pytest + + from meshbay_common.chatbox import open_message + + _js, vectors = chatbox_js + group_id, epoch = CHAT_VECTORS[0] + with _pytest.raises(Exception): + open_message(CHAT_EPOCH_KEY, group_id, epoch, vectors[1]["device_b64"], + bytes.fromhex(vectors[0]["nonce"]), + bytes.fromhex(vectors[0]["ct"])) diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js index 96fc52f..1eba59a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js @@ -17,7 +17,8 @@ import { FolderPickerField } from './folder-tree.js'; * has to be on a read-write root. The picker greys out the rest rather than * letting the node's refusal arrive after the fact. */ -function ChatSettings({ roots, dirs, settings, saveDirectories, transport, signFn }) { +function ChatSettings({ roots, dirs, settings, saveDirectories, transport, + signFn }) { const { busy, msg, run } = useSaver(); const [directory, setDirectory] = useState(settings.chatDirectory || ''); const [linkPreview, setLinkPreview] = useState(settings.chatLinkPreview !== false); @@ -59,6 +60,19 @@ function ChatSettings({ roots, dirs, settings, saveDirectories, transport, signF label=${t('settings_app.chat_link_preview_label')} /> <p class="settings-hint">${t('settings_app.chat_link_preview_hint')}</p> </div> + ${/* Not a toggle: chat is always encrypted (MNP 2.0), so there is + nothing here to turn on. What an operator may want is to move the + key on deliberately — the removals that matter already do it by + themselves. Stated rather than left invisible, because "is my chat + encrypted?" is a question people ask of a settings pane. */ ''} + <div class="settings-row" style="margin-top:12px"> + <p class="settings-hint">${t('settings_app.chat_encrypted_always')}</p> + <button class="app-save" disabled=${busy} + onClick=${() => run(() => transport.rotateChatEpoch(signFn))}> + ${busy ? t('settings_app.saving') : t('settings_app.chat_rotate_epoch')} + </button> + <p class="settings-hint">${t('settings_app.chat_rotate_epoch_hint')}</p> + </div> ${msg && html`<p class="settings-hint">${msg}</p>`} </div> `; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js index 0a7ef26..a7b8fd9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js @@ -203,8 +203,8 @@ function ChatImage({ filename, entries, transportRef, gekRef }) { // read one answer. Empty means the group has no writable root right now — every // root is read-only, or the one drive that was writable is unplugged — and the // paperclip says so rather than producing a refusal from the node. -function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, - onPreview, attachRoot = '', attachDir = '', +function ChatPanel({ transportRef, username, userId, entries, gekRef, + onRefreshIndex, onPreview, attachRoot = '', attachDir = '', onActivity, status }) { const [messages, setMessages] = useState([]); const [hasMore, setHasMore] = useState(false); @@ -257,13 +257,14 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, transport.onChat = (msg) => { const id = msg.id || `live-${Date.now()}-${Math.random().toString(36).slice(2)}`; + // Spread rather than rebuilt field by field: the transport is the one + // place that decides how a message is read, and copying a subset of its + // result here is how the live path and the history path come to disagree + // — which would show up only for messages that cannot be opened. setMessages(prev => [...prev, { + ...msg, id, - sender_id: msg.sender_id, - sender_name: msg.sender_name || '', - payload: msg.payload, timestamp: msg.timestamp || Date.now() / 1000, - thread_id: msg.thread_id, }]); if (!atBottomRef.current) setUnreadFrom(prev => prev ?? id); }; @@ -459,7 +460,8 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, await transport.sendChat(text, 0, null, username); setMessages(prev => [...prev, { id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`, - sender_id: username, + own: true, + sender_id: userId || username, sender_name: username, payload: text, timestamp: Date.now() / 1000, @@ -473,7 +475,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, setSending(false); setTimeout(() => { if (inputRef.current) inputRef.current.focus(); }); } - }, [input, username, jumpToBottom]); + }, [input, username, userId, jumpToBottom]); const attachFile = useCallback(async (e) => { const file = e.target.files?.[0]; @@ -501,7 +503,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, await transport.sendChat(structured, 0, null, username); setMessages(prev => [...prev, { id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`, - sender_id: username, sender_name: username, + own: true, sender_id: userId || username, sender_name: username, payload: structured, timestamp: Date.now() / 1000, thread_id: null, }]); jumpToBottom(); @@ -510,7 +512,16 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, } finally { setAttaching(false); } - }, [username, onRefreshIndex, jumpToBottom, attachRoot, attachDir]); + }, [username, userId, onRefreshIndex, jumpToBottom, attachRoot, attachDir]); + + // Chat is always encrypted, and sealing needs this device to have identified + // itself to the node (`device_hello`) — which is also what lets the node + // refuse a member claiming somebody else's key. Without it there is nothing + // to send with, so the composer says so before anything is typed rather than + // producing a refusal the reader cannot act on. + const transportNow = transportRef.current; + const cannotSend = !!(transportNow && transportNow.connected + && !transportNow.devicePk); const onKeyDown = useCallback((e) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -543,7 +554,14 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, </div> `} ${messages.map((m, i) => { - const isOwn = m.sender_name === username || m.sender_id === username; + // By account, and by an explicit flag on our own optimistic echo. + // Comparing a display name against a sender id happened to work + // while the echo invented `sender_id: username`, and would have + // started rendering other people's messages as the reader's own the + // moment two members shared a display name. + const isOwn = m.own === true + || (!!userId && m.sender_id === userId) + || (!userId && m.sender_name === username); const displayName = m.sender_name || '?'; const prev = messages[i - 1]; const showSender = !isOwn && (i === 0 || @@ -551,6 +569,26 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, // A conversation read over several days is unreadable without them. const daySep = i === 0 || !_sameDay(prev.timestamp, m.timestamp) ? _dayLabel(m.timestamp) : null; + // A message the transport could not open is shown as a gap, with + // what went wrong. Dropping it would leave a conversation quietly + // missing messages, which is worse than a visible hole: nobody can + // notice what they were never shown. + if (m.unreadable) { + return html` + ${daySep && html` + <div class="chat-day" key=${'d' + m.id}><span>${daySep}</span></div> + `} + <div key=${m.id} class="chat-msg ${isOwn ? 'chat-msg-own' : ''}"> + ${showSender && html`<div class="chat-sender">${displayName}</div>`} + <div class="chat-bubble chat-bubble-unreadable"> + <span class="chat-unreadable"> + ${t('chat.unreadable_' + m.unreadable) || t('chat.unreadable')} + </span> + <span class="chat-time">${formatTime(m.timestamp)}</span> + </div> + </div> + `; + } const parsed = _parsePayload(m.payload); const att = parsed && parsed.attachment; const msgText = parsed && typeof parsed.text === 'string' ? parsed.text : m.payload; @@ -613,13 +651,14 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, </span> `} <textarea class="chat-input" rows="1" ref=${inputRef} - placeholder="${t('chat.placeholder')}" + placeholder="${cannotSend ? t('chat.encrypted_cannot_send') + : t('chat.placeholder')}" value=${input} onInput=${e => setInput(e.target.value)} onKeyDown=${onKeyDown} - disabled=${sending} /> + disabled=${sending || cannotSend} /> <button class="chat-send" onClick=${sendMessage} - disabled=${sending || !input.trim()}> + disabled=${sending || cannotSend || !input.trim()}> ${t('chat.send')} </button> </div> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js index d2e19b7..f9cff08 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js @@ -126,6 +126,7 @@ async function decryptChunkBin(gek, fileHashHex, chunkIndex, nonce, ct) { const GROUPBOX_INFO = { index: new TextEncoder().encode('meshbay:index:v1'), ack: new TextEncoder().encode('meshbay:ack:v1'), + chat_keys: new TextEncoder().encode('meshbay:chat_keys:v1'), }; /** @@ -183,6 +184,96 @@ async function sealGroup(gek, purpose, msgType, groupId, plaintextBytes) { } +// ── Chat: per-device keys under a group chat epoch key ────────────────────── +// +// Mirrors meshbay_common/chatbox.py; held to it by test_js_python_parity. +// +// One key per group, per epoch, per *device*. The node generates the epoch key +// and hands it over wrapped under the group key; every member derives every +// device's subkey from it by name, so nothing is distributed per device and +// there is no per-device state to keep. Two devices therefore never share an +// AES key — the property per-device ratchet chains were wanted for, obtained by +// derivation rather than by mutable state that both of them advance. +// +// Signing is separate from encryption and is what establishes who spoke: over +// the *ciphertext*, so authorship can be checked before decryption and by +// anyone holding the roster, and with the device key the node pinned rather +// than a fresh key the sender invented. + +const CHAT_DEV_INFO = 'meshbay:chat:dev:v1'; +const CHAT_SIG_PREFIX = new TextEncoder().encode('meshbay:chat:v1'); + +async function chatDeviceKey(epochKey, groupId, deviceB64, usages) { + const info = new TextEncoder().encode( + `${CHAT_DEV_INFO}|${groupId}|${deviceB64}`); + const base = await crypto.subtle.importKey( + 'raw', epochKey, 'HKDF', false, ['deriveKey']); + return crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info }, + base, { name: 'AES-GCM', length: 256 }, false, usages); +} + +/** The group and the epoch a ciphertext is bound to. */ +function chatAad(groupId, epoch) { + return new TextEncoder().encode(`chat_msg|${groupId}|${epoch}`); +} + +/** + * What the sending device signs. Length-prefixed and domain-separated (L4): + * without the lengths a message could be re-cut into a different one with the + * same bytes. + * + * No connection nonce, unlike every other transcript here — a receiver reading + * history has no access to the connection a message arrived on. Replay is + * refused by the node's storage instead, on a unique (device, nonce). + */ +function chatSigningTranscript(groupId, epoch, device, nonce, ct) { + const enc = new TextEncoder(); + const body = _lenPrefixed([ + enc.encode(groupId), enc.encode(String(epoch)), device, nonce, ct, + ]); + const out = new Uint8Array(CHAT_SIG_PREFIX.length + body.length); + out.set(CHAT_SIG_PREFIX, 0); + out.set(body, CHAT_SIG_PREFIX.length); + return out; +} + +/** `{nonce, ct}` for one message. The caller signs and merges. */ +async function sealChat(epochKey, groupId, epoch, deviceB64, plaintextBytes) { + const key = await chatDeviceKey(epochKey, groupId, deviceB64, ['encrypt']); + // Random per message, never derived from the payload: two identical messages + // under one device's key would reuse it, and AES-GCM under nonce reuse does + // not fail gracefully. + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const ct = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: nonce, additionalData: chatAad(groupId, epoch) }, + key, plaintextBytes); + return { nonce, ct: new Uint8Array(ct) }; +} + +/** The msgpack bytes of one message. Throws if it does not open. */ +async function openChat(epochKey, groupId, epoch, deviceB64, nonce, ct) { + const key = await chatDeviceKey(epochKey, groupId, deviceB64, ['decrypt']); + const plain = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: nonce, additionalData: chatAad(groupId, epoch) }, + key, ct); + return new Uint8Array(plain); +} + +/** Whether this device signed this ciphertext. */ +async function verifyChatSignature(deviceRaw, groupId, epoch, nonce, ct, sig) { + try { + const key = await crypto.subtle.importKey( + 'raw', deviceRaw, { name: 'Ed25519' }, false, ['verify']); + return await crypto.subtle.verify( + 'Ed25519', key, sig, + chatSigningTranscript(groupId, epoch, deviceRaw, nonce, ct)); + } catch { + return false; + } +} + + // ── GEK generation + ECIES wrapping ────────────────────────────────────────── function generateGEK() { @@ -348,6 +439,7 @@ async function handshakeProof(gekRaw, role, groupId, nonceClient, nonceNode, bin const JOIN_PREFIX = new TextEncoder().encode('meshbay:join:v1'); const DEVICE_REQ_PREFIX = new TextEncoder().encode('meshbay:device_req:v1'); const DEVICE_ADD_PREFIX = new TextEncoder().encode('meshbay:device_add:v1'); +const DEVICE_HELLO_PREFIX = new TextEncoder().encode('meshbay:device_hello:v1'); function joinTranscript(nodePkB64, groupId, userId, pkEdB64, pkXB64, nonceNode, ts) { const enc = new TextEncoder(); @@ -400,6 +492,27 @@ function deviceAddTranscript(nodePkB64, userId, pkEdB64, pkXB64, nonceNode, ts) } /** + * "Which of this account's devices am I?", mirroring + * `meshbay_common/device.py:device_hello_transcript`. + * + * The handshake proves membership of a group and carries an account from the + * hub's token; it proves nothing about which device is talking. Sent once, after + * the handshake, so the node stops resolving "the account's oldest key" and + * attributing this device's uploads to another one. + */ +function deviceHelloTranscript(nodePkB64, groupId, userId, pkEdB64, nonceNode, ts) { + const enc = new TextEncoder(); + const body = _lenPrefixed([ + enc.encode(nodePkB64), enc.encode(groupId), enc.encode(userId), + enc.encode(pkEdB64), nonceNode, enc.encode(String(ts)), + ]); + const out = new Uint8Array(DEVICE_HELLO_PREFIX.length + body.length); + out.set(DEVICE_HELLO_PREFIX, 0); + out.set(body, DEVICE_HELLO_PREFIX.length); + return out; +} + +/** * sha256(code ‖ pk_ed ‖ pk_x), hex — the lookup key for a pending request. * * The keys go in with the code, so the hash identifies *this device asking with @@ -450,6 +563,8 @@ window.MeshBayCrypto = { generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode, adminTranscript, handshakeTranscript, handshakeProof, webrtcBinding, joinTranscript, verifyNodeSignature, constantTimeEqual, - deviceRequestTranscript, deviceAddTranscript, deviceCodeHash, + deviceRequestTranscript, deviceAddTranscript, deviceHelloTranscript, + deviceCodeHash, + sealChat, openChat, chatSigningTranscript, verifyChatSignature, normalizeCode, }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index d05d3a3..180c762 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -652,6 +652,15 @@ export default { 'chat.unread': 'Neue Nachrichten', 'chat.jump_latest': 'Zu den neuesten springen', 'chat.jump_new': 'Neue Nachrichten', + 'chat.unreadable': 'Diese Nachricht konnte nicht gelesen werden', + 'chat.unreadable_signature': 'Nicht verifizierte Nachricht — die Signatur passt nicht zum Absender', + 'chat.unreadable_epoch': 'Geschrieben, bevor dieses Gerät diese Unterhaltung lesen konnte', + 'chat.unreadable_keys': 'Chat-Schlüssel nicht verfügbar — neu verbinden, um dies zu lesen', + 'chat.unreadable_decrypt': 'Diese Nachricht konnte nicht entschlüsselt werden', + 'chat.unreadable_envelope': 'Diese Nachricht kam unvollständig an', + 'chat.unreadable_format': 'Diese Nachricht erfordert eine neuere Version von MeshBay', + 'chat.encrypted_needs_newer': 'Diese Unterhaltung ist verschlüsselt und dieser Client kann sie nicht lesen — MeshBay aktualisieren', + 'chat.encrypted_cannot_send': 'Diese Unterhaltung ist verschlüsselt und dieses Gerät kann noch nicht darin schreiben', 'group.leave': 'Gruppe verlassen', 'group.leave_confirm': '„{name}“ verlassen? Sie verlieren den Zugang zu ihren Dateien und zum Chat. Hochgeladene Dateien bleiben auf dem Node, und der Node behält die für Sie gemerkte Identität, bis sein Betreiber sie entfernt.', 'group.mute': 'Mute notifications', @@ -852,6 +861,9 @@ export default { 'settings_app.chat_no_writable_root': 'Diese Gruppe hat kein beschreibbares Verzeichnis, daher sind Anhänge aus.', 'settings_app.chat_link_preview_label': 'Link-Vorschauen', 'settings_app.chat_link_preview_hint': 'Postet ein Mitglied einen Link, holt der Node Titel und Bild der Seite. Das ist eine Anfrage von Ihrem Rechner an eine Website, die jemand anderes gewählt hat.', + 'settings_app.chat_encrypted_always': 'Der Chat dieser Gruppe ist immer verschlüsselt. Das lässt sich nicht abschalten.', + 'settings_app.chat_rotate_epoch': 'Chat-Schlüssel weiterdrehen', + 'settings_app.chat_rotate_epoch_hint': 'Mitglieder lesen weiterhin den gesamten Verlauf. Wer aus der Gruppe entfernt wurde, kann nicht mehr lesen, was ab jetzt geschrieben wird. Ein Mitglied oder Gerät zu entfernen tut das bereits von selbst.', 'settings_app.tmdb_token_prompt': 'Registrieren Sie sich bei TMDB, um einen eigenen API-Schlüssel zu erzeugen.', 'settings_app.tmdb_token_link': 'Schlüssel holen', 'settings_node.roots_offline_hint': 'Nicht mit dem Node verbunden — Änderungen laufen über den lokalen Node und greifen beim nächsten Neuladen.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 6dbb70f..996d5b6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -640,6 +640,9 @@ export default { 'settings_app.chat_no_writable_root': 'This group has no read-write directory, so attachments are off.', 'settings_app.chat_link_preview_label': 'Link previews', 'settings_app.chat_link_preview_hint': 'When a member posts a link, the node fetches the page\'s title and image. That is a request from your machine to a site somebody else chose.', + 'settings_app.chat_encrypted_always': 'Chat in this group is always encrypted. It cannot be turned off.', + 'settings_app.chat_rotate_epoch': 'Move the chat key on', + 'settings_app.chat_rotate_epoch_hint': 'Members keep reading the whole history. Anyone removed from the group cannot read what is written from now on. Removing a member or a device already does this by itself.', 'settings_app.tmdb_token_prompt': 'Sign up on TMDB to generate your own API key.', 'settings_app.tmdb_token_link': 'Get a key', 'settings_node.roots_offline_hint': 'Not connected to the node — changes go through the local node instead, and take effect on its next reload.', @@ -760,6 +763,20 @@ export default { 'chat.unread': 'New messages', 'chat.jump_latest': 'Jump to latest', 'chat.jump_new': 'New messages', + // A message this client could not open. Each one names what actually went + // wrong, because "could not read this message" sends an operator nowhere: + // a signature failure is somebody impersonating a member, a missing epoch key + // is a device that joined after the message was written, and a decryption + // failure is neither. + 'chat.unreadable': 'This message could not be read', + 'chat.unreadable_signature': 'Unverified message — the signature does not match the sender', + 'chat.unreadable_epoch': 'Written before this device could read this conversation', + 'chat.unreadable_keys': 'Chat keys unavailable — reconnect to read this', + 'chat.unreadable_decrypt': 'This message could not be decrypted', + 'chat.unreadable_envelope': 'This message arrived incomplete', + 'chat.unreadable_format': 'This message needs a newer version of MeshBay', + 'chat.encrypted_needs_newer': 'This conversation is encrypted and this client cannot read it — update MeshBay', + 'chat.encrypted_cannot_send': 'This conversation is encrypted and this device cannot post to it yet', 'group.leave': 'Leave group', 'group.leave_confirm': 'Leave “{name}”? You will lose access to its files and chat. Files you uploaded stay on the node, and the node keeps the identity it pinned for you until its operator removes it.', 'group.mute': 'Mute notifications', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index aa950cc..10fac89 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -648,6 +648,15 @@ export default { 'chat.unread': 'Mensajes nuevos', 'chat.jump_latest': 'Ir a lo más reciente', 'chat.jump_new': 'Mensajes nuevos', + 'chat.unreadable': 'No se pudo leer este mensaje', + 'chat.unreadable_signature': 'Mensaje no verificado: la firma no coincide con el remitente', + 'chat.unreadable_epoch': 'Escrito antes de que este dispositivo pudiera leer esta conversación', + 'chat.unreadable_keys': 'Claves de chat no disponibles: vuelve a conectarte para leerlo', + 'chat.unreadable_decrypt': 'No se pudo descifrar este mensaje', + 'chat.unreadable_envelope': 'Este mensaje llegó incompleto', + 'chat.unreadable_format': 'Este mensaje necesita una versión más reciente de MeshBay', + 'chat.encrypted_needs_newer': 'Esta conversación está cifrada y este cliente no puede leerla: actualiza MeshBay', + 'chat.encrypted_cannot_send': 'Esta conversación está cifrada y este dispositivo aún no puede escribir en ella', 'group.leave': 'Salir del grupo', 'group.leave_confirm': '¿Salir de «{name}»? Perderá el acceso a sus archivos y a su chat. Los archivos que subió permanecen en el node, y este conserva la identidad que fijó para usted hasta que su operador la retire.', 'group.mute': 'Mute notifications', @@ -848,6 +857,9 @@ export default { 'settings_app.chat_no_writable_root': 'Este grupo no tiene directorio de escritura, así que los adjuntos están desactivados.', 'settings_app.chat_link_preview_label': 'Vistas previas de enlaces', 'settings_app.chat_link_preview_hint': 'Cuando un miembro publica un enlace, el nodo obtiene el título y la imagen de la página. Es una petición desde tu máquina a un sitio que eligió otra persona.', + 'settings_app.chat_encrypted_always': 'El chat de este grupo siempre está cifrado. No se puede desactivar.', + 'settings_app.chat_rotate_epoch': 'Renovar la clave del chat', + 'settings_app.chat_rotate_epoch_hint': 'Los miembros siguen leyendo todo el historial. Quien haya sido expulsado del grupo no podrá leer lo que se escriba a partir de ahora. Quitar a un miembro o un dispositivo ya lo hace por sí solo.', 'settings_app.tmdb_token_prompt': 'Regístrate en TMDB para generar tu propia clave de API.', 'settings_app.tmdb_token_link': 'Obtener una clave', 'settings_node.roots_offline_hint': 'Sin conexión con el nodo: los cambios pasan por el nodo local y se aplican en su próxima recarga.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index daa9f94..f470bf1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -651,6 +651,15 @@ export default { 'chat.unread': 'Nouveaux messages', 'chat.jump_latest': 'Aller au plus récent', 'chat.jump_new': 'Nouveaux messages', + 'chat.unreadable': 'Ce message n\'a pas pu être lu', + 'chat.unreadable_signature': 'Message non vérifié — la signature ne correspond pas à l\'expéditeur', + 'chat.unreadable_epoch': 'Écrit avant que cet appareil puisse lire cette conversation', + 'chat.unreadable_keys': 'Clés de discussion indisponibles — reconnectez-vous pour lire ceci', + 'chat.unreadable_decrypt': 'Ce message n\'a pas pu être déchiffré', + 'chat.unreadable_envelope': 'Ce message est arrivé incomplet', + 'chat.unreadable_format': 'Ce message nécessite une version plus récente de MeshBay', + 'chat.encrypted_needs_newer': 'Cette conversation est chiffrée et ce client ne peut pas la lire — mettez MeshBay à jour', + 'chat.encrypted_cannot_send': 'Cette conversation est chiffrée et cet appareil ne peut pas encore y écrire', 'group.leave': 'Quitter le groupe', 'group.leave_confirm': 'Quitter « {name} » ? Vous perdrez l’accès à ses fichiers et à sa discussion. Les fichiers que vous avez envoyés restent sur le node, et celui-ci conserve l’identité qu’il a épinglée pour vous jusqu’à ce que son opérateur la retire.', 'group.mute': 'Couper les notifications', @@ -866,6 +875,9 @@ export default { 'settings_app.chat_no_writable_root': 'Ce groupe n\'a aucun répertoire en écriture : les pièces jointes sont désactivées.', 'settings_app.chat_link_preview_label': 'Aperçus des liens', 'settings_app.chat_link_preview_hint': 'Quand un membre poste un lien, le nœud récupère le titre et l\'image de la page. C\'est une requête depuis votre machine vers un site choisi par quelqu\'un d\'autre.', + 'settings_app.chat_encrypted_always': 'La discussion de ce groupe est toujours chiffrée. Cela ne peut pas être désactivé.', + 'settings_app.chat_rotate_epoch': 'Faire tourner la clé de discussion', + 'settings_app.chat_rotate_epoch_hint': 'Les membres continuent de lire tout l\'historique. Quiconque a été retiré du groupe ne peut plus lire ce qui sera écrit. Retirer un membre ou un appareil le fait déjà tout seul.', 'settings_app.tmdb_token_prompt': 'Créez un compte TMDB pour générer votre propre clé d\'API.', 'settings_app.tmdb_token_link': 'Obtenir une clé', 'settings_node.roots_offline_hint': 'Non connecté au nœud — les changements passent par le nœud local et prennent effet à son prochain rechargement.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 364c26c..f971320 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -650,6 +650,15 @@ export default { 'chat.unread': 'Nuovi messaggi', 'chat.jump_latest': 'Vai ai più recenti', 'chat.jump_new': 'Nuovi messaggi', + 'chat.unreadable': 'Impossibile leggere questo messaggio', + 'chat.unreadable_signature': 'Messaggio non verificato: la firma non corrisponde al mittente', + 'chat.unreadable_epoch': 'Scritto prima che questo dispositivo potesse leggere questa conversazione', + 'chat.unreadable_keys': 'Chiavi della chat non disponibili: riconnettiti per leggerlo', + 'chat.unreadable_decrypt': 'Impossibile decifrare questo messaggio', + 'chat.unreadable_envelope': 'Questo messaggio è arrivato incompleto', + 'chat.unreadable_format': 'Questo messaggio richiede una versione più recente di MeshBay', + 'chat.encrypted_needs_newer': 'Questa conversazione è cifrata e questo client non può leggerla: aggiorna MeshBay', + 'chat.encrypted_cannot_send': 'Questa conversazione è cifrata e questo dispositivo non può ancora scriverci', 'group.leave': 'Esci dal gruppo', 'group.leave_confirm': 'Uscire da «{name}»? Perderà l’accesso ai suoi file e alla chat. I file che ha caricato restano sul node, e il node mantiene l’identità che ha fissato per lei finché il suo operatore non la rimuove.', 'group.mute': 'Mute notifications', @@ -862,6 +871,9 @@ export default { 'settings_app.chat_no_writable_root': 'Questo gruppo non ha directory scrivibili, quindi gli allegati sono disattivati.', 'settings_app.chat_link_preview_label': 'Anteprime dei link', 'settings_app.chat_link_preview_hint': 'Quando un membro pubblica un link, il nodo recupera titolo e immagine della pagina. È una richiesta dalla tua macchina a un sito scelto da qualcun altro.', + 'settings_app.chat_encrypted_always': 'La chat di questo gruppo è sempre cifrata. Non può essere disattivata.', + 'settings_app.chat_rotate_epoch': 'Ruota la chiave della chat', + 'settings_app.chat_rotate_epoch_hint': 'I membri continuano a leggere tutta la cronologia. Chi è stato rimosso dal gruppo non potrà leggere ciò che verrà scritto d\'ora in poi. Rimuovere un membro o un dispositivo lo fa già da solo.', 'settings_app.tmdb_token_prompt': 'Registrati su TMDB per generare la tua chiave API.', 'settings_app.tmdb_token_link': 'Ottieni una chiave', 'settings_node.roots_offline_hint': 'Non connesso al nodo: le modifiche passano dal nodo locale e hanno effetto al successivo ricaricamento.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index f778fc4..0817608 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -638,6 +638,15 @@ export default { 'chat.unread': '新しいメッセージ', 'chat.jump_latest': '最新へ移動', 'chat.jump_new': '新しいメッセージ', + 'chat.unreadable': 'このメッセージを読み取れませんでした', + 'chat.unreadable_signature': '未検証のメッセージ — 署名が送信者と一致しません', + 'chat.unreadable_epoch': 'この端末がこの会話を読めるようになる前に書かれました', + 'chat.unreadable_keys': 'チャットの鍵を取得できません — 再接続してください', + 'chat.unreadable_decrypt': 'このメッセージを復号できませんでした', + 'chat.unreadable_envelope': 'このメッセージは不完全な状態で届きました', + 'chat.unreadable_format': 'このメッセージには新しいバージョンの MeshBay が必要です', + 'chat.encrypted_needs_newer': 'この会話は暗号化されており、このクライアントでは読めません — MeshBay を更新してください', + 'chat.encrypted_cannot_send': 'この会話は暗号化されており、この端末はまだ投稿できません', 'group.leave': 'グループを退出', 'group.leave_confirm': '「{name}」を退出しますか?ファイルとチャットへのアクセスがなくなります。アップロードしたファイルは node に残り、node は固定した識別情報を運営者が削除するまで保持します。', 'group.mute': 'Mute notifications', @@ -846,6 +855,9 @@ export default { 'settings_app.chat_no_writable_root': 'このグループには書き込み可能なディレクトリがないため、添付は無効です。', 'settings_app.chat_link_preview_label': 'リンクのプレビュー', 'settings_app.chat_link_preview_hint': 'メンバーがリンクを投稿すると、ノードがページのタイトルと画像を取得します。これは他人が選んだサイトへの、あなたのマシンからのリクエストです。', + 'settings_app.chat_encrypted_always': 'このグループのチャットは常に暗号化されています。無効にはできません。', + 'settings_app.chat_rotate_epoch': 'チャット鍵を更新する', + 'settings_app.chat_rotate_epoch_hint': 'メンバーは履歴全体を引き続き読めます。グループから外された相手は、これ以降に書かれた内容を読めません。メンバーや端末を削除すると、これは自動的に行われます。', 'settings_app.tmdb_token_prompt': 'TMDB に登録して、自分の API キーを発行してください。', 'settings_app.tmdb_token_link': 'キーを取得', 'settings_node.roots_offline_hint': 'ノードに接続していません — 変更はローカルノード経由で行われ、次回の再読み込みで反映されます。', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 6e6842e..25bf89d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -652,6 +652,15 @@ export default { 'chat.unread': 'Nieuwe berichten', 'chat.jump_latest': 'Naar de nieuwste', 'chat.jump_new': 'Nieuwe berichten', + 'chat.unreadable': 'Dit bericht kon niet worden gelezen', + 'chat.unreadable_signature': 'Niet-geverifieerd bericht — de handtekening past niet bij de afzender', + 'chat.unreadable_epoch': 'Geschreven voordat dit apparaat dit gesprek kon lezen', + 'chat.unreadable_keys': 'Chatsleutels niet beschikbaar — maak opnieuw verbinding om dit te lezen', + 'chat.unreadable_decrypt': 'Dit bericht kon niet worden ontsleuteld', + 'chat.unreadable_envelope': 'Dit bericht kwam onvolledig aan', + 'chat.unreadable_format': 'Dit bericht vereist een nieuwere versie van MeshBay', + 'chat.encrypted_needs_newer': 'Dit gesprek is versleuteld en deze client kan het niet lezen — werk MeshBay bij', + 'chat.encrypted_cannot_send': 'Dit gesprek is versleuteld en dit apparaat kan er nog niet in schrijven', 'group.leave': 'Groep verlaten', 'group.leave_confirm': '„{name}” verlaten? U verliest de toegang tot de bestanden en de chat ervan. Bestanden die u hebt geüpload blijven op de node, en de node houdt de voor u vastgezette identiteit tot zijn beheerder die weghaalt.', 'group.mute': 'Mute notifications', @@ -864,6 +873,9 @@ export default { 'settings_app.chat_no_writable_root': 'Deze groep heeft geen beschrijfbare map, dus bijlagen staan uit.', 'settings_app.chat_link_preview_label': 'Linkvoorbeelden', 'settings_app.chat_link_preview_hint': 'Als een lid een link plaatst, haalt de node de titel en afbeelding van de pagina op. Dat is een verzoek vanaf uw machine naar een site die iemand anders koos.', + 'settings_app.chat_encrypted_always': 'De chat van deze groep is altijd versleuteld. Dit kan niet worden uitgezet.', + 'settings_app.chat_rotate_epoch': 'Chatsleutel doordraaien', + 'settings_app.chat_rotate_epoch_hint': 'Leden blijven de hele geschiedenis lezen. Wie uit de groep is verwijderd, kan niet lezen wat er vanaf nu wordt geschreven. Een lid of apparaat verwijderen doet dit al vanzelf.', 'settings_app.tmdb_token_prompt': 'Meld u aan bij TMDB om uw eigen API-sleutel te maken.', 'settings_app.tmdb_token_link': 'Sleutel ophalen', 'settings_node.roots_offline_hint': 'Niet verbonden met de node — wijzigingen gaan via de lokale node en worden bij de volgende herlaadbeurt actief.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index c8cbd16..d63d4ae 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -670,6 +670,15 @@ export default { 'chat.unread': 'Nowe wiadomości', 'chat.jump_latest': 'Przejdź do najnowszych', 'chat.jump_new': 'Nowe wiadomości', + 'chat.unreadable': 'Nie udało się odczytać tej wiadomości', + 'chat.unreadable_signature': 'Niezweryfikowana wiadomość — podpis nie zgadza się z nadawcą', + 'chat.unreadable_epoch': 'Napisana, zanim to urządzenie mogło czytać tę rozmowę', + 'chat.unreadable_keys': 'Klucze czatu niedostępne — połącz się ponownie, aby to odczytać', + 'chat.unreadable_decrypt': 'Nie udało się odszyfrować tej wiadomości', + 'chat.unreadable_envelope': 'Ta wiadomość dotarła niekompletna', + 'chat.unreadable_format': 'Ta wiadomość wymaga nowszej wersji MeshBay', + 'chat.encrypted_needs_newer': 'Ta rozmowa jest zaszyfrowana i ten klient nie może jej odczytać — zaktualizuj MeshBay', + 'chat.encrypted_cannot_send': 'Ta rozmowa jest zaszyfrowana i to urządzenie nie może jeszcze w niej pisać', 'group.leave': 'Opuść grupę', 'group.leave_confirm': 'Opuścić grupę „{name}”? Utraci Pan(i) dostęp do jej plików i czatu. Wysłane pliki pozostaną na node, a node zachowa przypiętą dla Pana/Pani tożsamość do czasu, aż jego operator ją usunie.', 'group.mute': 'Mute notifications', @@ -890,6 +899,9 @@ export default { 'settings_app.chat_no_writable_root': 'Ta grupa nie ma katalogu do zapisu, więc załączniki są wyłączone.', 'settings_app.chat_link_preview_label': 'Podglądy linków', 'settings_app.chat_link_preview_hint': 'Gdy członek wysyła link, węzeł pobiera tytuł i obraz strony. To żądanie z Twojego komputera do witryny wybranej przez kogoś innego.', + 'settings_app.chat_encrypted_always': 'Czat w tej grupie jest zawsze szyfrowany. Nie da się tego wyłączyć.', + 'settings_app.chat_rotate_epoch': 'Zmień klucz czatu', + 'settings_app.chat_rotate_epoch_hint': 'Członkowie nadal czytają całą historię. Osoba usunięta z grupy nie odczyta tego, co zostanie napisane od teraz. Usunięcie członka lub urządzenia robi to już samo.', 'settings_app.tmdb_token_prompt': 'Zarejestruj się w TMDB, aby wygenerować własny klucz API.', 'settings_app.tmdb_token_link': 'Pobierz klucz', 'settings_node.roots_offline_hint': 'Brak połączenia z węzłem — zmiany przechodzą przez węzeł lokalny i zaczną działać po jego następnym przeładowaniu.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 6eb2242..f23f237 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -649,6 +649,15 @@ export default { 'chat.unread': 'Mensagens novas', 'chat.jump_latest': 'Ir para a mais recente', 'chat.jump_new': 'Mensagens novas', + 'chat.unreadable': 'Não foi possível ler esta mensagem', + 'chat.unreadable_signature': 'Mensagem não verificada — a assinatura não corresponde ao remetente', + 'chat.unreadable_epoch': 'Escrita antes de este dispositivo poder ler esta conversa', + 'chat.unreadable_keys': 'Chaves do chat indisponíveis — reconecte-se para ler isto', + 'chat.unreadable_decrypt': 'Não foi possível descriptografar esta mensagem', + 'chat.unreadable_envelope': 'Esta mensagem chegou incompleta', + 'chat.unreadable_format': 'Esta mensagem exige uma versão mais recente do MeshBay', + 'chat.encrypted_needs_newer': 'Esta conversa é criptografada e este cliente não consegue lê-la — atualize o MeshBay', + 'chat.encrypted_cannot_send': 'Esta conversa é criptografada e este dispositivo ainda não pode escrever nela', 'group.leave': 'Sair do grupo', 'group.leave_confirm': 'Sair de “{name}”? Você perderá o acesso aos arquivos e à conversa. Os arquivos que você enviou permanecem no node, e ele mantém a identidade que fixou para você até que o operador dele a remova.', 'group.mute': 'Mute notifications', @@ -849,6 +858,9 @@ export default { 'settings_app.chat_no_writable_root': 'Este grupo não tem diretório gravável, então os anexos estão desativados.', 'settings_app.chat_link_preview_label': 'Prévias de links', 'settings_app.chat_link_preview_hint': 'Quando alguém publica um link, o nó busca o título e a imagem da página. É uma requisição da sua máquina para um site escolhido por outra pessoa.', + 'settings_app.chat_encrypted_always': 'O chat deste grupo é sempre criptografado. Não é possível desativar.', + 'settings_app.chat_rotate_epoch': 'Girar a chave do chat', + 'settings_app.chat_rotate_epoch_hint': 'Os membros continuam lendo todo o histórico. Quem foi removido do grupo não conseguirá ler o que for escrito daqui em diante. Remover um membro ou dispositivo já faz isso sozinho.', 'settings_app.tmdb_token_prompt': 'Cadastre-se no TMDB para gerar sua própria chave de API.', 'settings_app.tmdb_token_link': 'Obter uma chave', 'settings_node.roots_offline_hint': 'Sem conexão com o nó — as alterações passam pelo nó local e entram em vigor no próximo recarregamento.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 044611e..cd0332c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -625,6 +625,15 @@ export default { 'chat.unread': '新消息', 'chat.jump_latest': '跳到最新', 'chat.jump_new': '新消息', + 'chat.unreadable': '无法读取此消息', + 'chat.unreadable_signature': '未验证的消息 — 签名与发送者不符', + 'chat.unreadable_epoch': '在此设备能够读取该对话之前写入', + 'chat.unreadable_keys': '聊天密钥不可用 — 请重新连接以读取', + 'chat.unreadable_decrypt': '无法解密此消息', + 'chat.unreadable_envelope': '此消息传输不完整', + 'chat.unreadable_format': '此消息需要更新版本的 MeshBay', + 'chat.encrypted_needs_newer': '该对话已加密,此客户端无法读取 — 请更新 MeshBay', + 'chat.encrypted_cannot_send': '该对话已加密,此设备尚无法在其中发言', 'group.leave': '退出群组', 'group.leave_confirm': '退出“{name}”?您将失去其文件和聊天的访问权。您上传的文件仍留在 node 上,node 也会保留它为您固定的身份,直到其运营者将其移除。', 'group.mute': 'Mute notifications', @@ -833,6 +842,9 @@ export default { 'settings_app.chat_no_writable_root': '该群组没有可写目录,因此附件已停用。', 'settings_app.chat_link_preview_label': '链接预览', 'settings_app.chat_link_preview_hint': '当成员发布链接时,节点会抓取该页面的标题和图片。这是从你的机器发往他人所选站点的请求。', + 'settings_app.chat_encrypted_always': '本群组的聊天始终加密,无法关闭。', + 'settings_app.chat_rotate_epoch': '更换聊天密钥', + 'settings_app.chat_rotate_epoch_hint': '成员仍可阅读全部历史记录。已被移出群组的人无法阅读此后写入的内容。移除成员或设备时已自动执行此操作。', 'settings_app.tmdb_token_prompt': '在 TMDB 注册以生成你自己的 API 密钥。', 'settings_app.tmdb_token_link': '获取密钥', 'settings_node.roots_offline_hint': '未连接到节点 — 变更将通过本地节点进行,并在其下次重新加载时生效。', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index d1d5732..a6a6e7c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -4216,3 +4216,16 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } .hw-item .icon { width: 15px; height: 15px; flex-shrink: 0; } .hw-name { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .hw-size { flex: 0 0 auto; color: var(--text-dim); font-size: 0.85em; } + +/* A chat message this client could not open. Deliberately visible rather than + hidden: a conversation quietly missing messages is worse than a gap, because + nobody can notice what they were never shown. */ +.chat-bubble-unreadable { + background: var(--bg-raised); + border: 1px dashed var(--border); + color: var(--text-secondary); +} +.chat-unreadable { + font-style: italic; + opacity: 0.75; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 179292e..927956d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -86,6 +86,7 @@ const BROADCAST_ACK_TYPES = new Set([ 'root_update_ack', 'root_eject_ack', 'root_plug_ack', 'root_add_ack', 'root_remove_ack', 'app_directories_ack', 'chat_directory_ack', 'chat_link_preview_ack', + 'chat_epoch_ack', ]); /** Hand a broadcast ack to the callback that would have had it from a peer. */ @@ -96,6 +97,8 @@ function _replayBroadcast(transport, msg) { transport._onChatDirectory(msg.path || ''); } else if (msg.type === 'chat_link_preview_ack' && transport._onChatLinkPreview) { transport._onChatLinkPreview(Boolean(msg.enabled)); + } else if (msg.type === 'chat_epoch_ack') { + transport._applyChatEpoch(msg); } else if (transport._onRootsChanged) { transport._onRootsChanged(msg); } @@ -107,7 +110,7 @@ const ADMIN_OP_TYPES = new Set([ 'musicbrainz_enabled', 'file_delete', 'dir_delete', 'apps_enabled', 'set_scan_settings', 'member_revoke', 'root_add', 'root_remove', 'root_update', 'root_eject', 'root_plug', - 'app_directories', 'chat_directory', 'chat_link_preview', + 'app_directories', 'chat_directory', 'chat_link_preview', 'chat_epoch', 'member_unpin', 'gek_rotate', 'group_attach', 'group_detach', 'invite_create', ]); @@ -380,6 +383,7 @@ class MeshBayTransport { set onAppDirectories(fn) { this._onAppDirectories = fn; } set onChatDirectory(fn) { this._onChatDirectory = fn; } set onChatLinkPreview(fn) { this._onChatLinkPreview = fn; } + set onChatEpoch(fn) { this._onChatEpoch = fn; } set onTmdbConfig(fn) { this._onTmdbConfig = fn; } set onTmdbEnabled(fn) { this._onTmdbEnabled = fn; } set onVideoRoot(fn) { this._onVideoRoot = fn; } @@ -437,6 +441,10 @@ class MeshBayTransport { this._recoveryKey = recoveryKey || null; this._username = username || null; this._userId = userId || null; + // The group this connection is for. Kept on the instance because the + // handshake is not the only thing that needs it any more: device_hello and + // the chat envelope both bind to it, and both run outside connect()'s scope. + this._groupId = groupId || ''; this._newNodeBundle = null; this._newNodeBundleRecovery = null; this._joinError = null; @@ -848,6 +856,29 @@ class MeshBayTransport { delete ack.ct; Object.assign(ack, config); + // Tell the node which of this account's devices is on this connection. + // Deliberately after the ack, and gated on the node's own version rather + // than sent hopefully: a node that does not know the message answers + // nothing at all, which would leave a `device_hello` sitting in + // `_pending` for the full 30s — and the arrival-order fallback hands an + // unrouted reply to the *oldest* pending request, which right after a + // handshake is exactly this one. That is the routed-by-luck bug the chat + // ack comment above was written for; not repeating it. + this._nodeMnp = String(ack.v || ''); + // From the *sealed* part of the ack: a forged epoch would have this + // client sealing under a key the group has retired. + this.chatEpoch = ack.chat_epoch || 0; + // Per connection: a reconnect may land on a node whose epoch has moved, + // and keeping a stale set would silently seal under a retired key. + this._chatKeys = null; + this._chatKeysInFlight = null; + // Not gated on a version any more: a node that reached this point speaks + // MNP 2.0, where identifying the device is what makes chat possible at + // all. `check_version` refused anything older before we got here. + await this._announceDevice().catch((e) => { + console.warn('[MeshBay] device_hello failed — chat will not work:', e); + }); + return ack; } @@ -863,6 +894,37 @@ class MeshBayTransport { } /** + * "This connection is device X of account Y", signed with the device key. + * + * Best effort by construction: a browser that has not recovered its identity + * keys has nothing to sign with, and a node older than MNP 1.2 does not know + * the message. Neither is an error — the node simply keeps the weaker + * attribution it had before, which is what every client did until now. + */ + async _announceDevice() { + if (!this._sessionKeys || !this._sessionKeys.skEdB64) return; + if (!this._nonceNode || !this.nodePk || !this._userId) return; + + const C = window.MeshBayCrypto; + // Derived from our own secret key, never read back from anywhere — the same + // rule as pairOperator: signing a public key someone handed us is the + // substitution this mechanism exists to close. + const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64); + const ts = Math.floor(Date.now() / 1000); + const transcript = C.deviceHelloTranscript( + this.nodePk, this._groupId || '', this._userId, pkEdB64, + this._nonceNode, ts); + const sig = await window.MeshBayKeys.signBytes( + this._sessionKeys.skEdB64, transcript); + + const resp = await this._sendAndWait({ + type: 'device_hello', v: '2.0', pk_ed25519: pkEdB64, ts, sig, + }); + this.devicePk = (resp && resp.type === 'device_hello_ack') ? pkEdB64 : ''; + return this.devicePk; + } + + /** * Kick off (or join, if one is already running) the automatic reconnect * after the WebRTC connection is declared unrecoverable. Idempotent: every * caller racing to reconnect at once — the connectionstatechange handler, @@ -1319,6 +1381,29 @@ class MeshBayTransport { } /** + * Open a new chat epoch by hand. Operator only, and signed. + * + * Not a switch — there is nothing to turn on. The removals that matter open + * an epoch by themselves; this is the operator saying "move the key anyway", + * the same instruction as `rotateGek` and signed for the same reason. + */ + async rotateChatEpoch(signFn) { + // This connection's own group, not a parameter. Every settings pane takes + // the same props by design (`test_app_settings_plugin.py`), so reaching for + // a `groupId` here would make the loop that renders them conditional — and + // the transport already knows which group it is connected to. + const groupId = this._groupId || ''; + const msg = await this._sendAndWait({ + type: 'chat_epoch', v: '2.0', group_id: groupId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'chat_epoch', groupId, signFn); + } + return msg; + } + + /** * MusicBrainz metadata for one track (Music app, docs/musicbay.md §4.3) * — same shape as fetchMediaMeta, minus a season/episode concept: * album-level (release), resolved from the track's own artist/album @@ -1403,7 +1488,42 @@ class MeshBayTransport { limit: limit, }); if (msg.type === 'error') throw new Error(msg.detail); - return { messages: msg.messages || [], hasMore: !!msg.has_more }; + const rows = msg.messages || []; + const messages = []; + for (const row of rows) messages.push(await this._openChatMessage(row)); + return { messages, hasMore: !!msg.has_more }; + } + + /** + * Turn one stored or relayed chat message into what the panel renders. + * + * **The one place that decides how a message is read.** Live messages and + * history arrive by different routes and used to be shaped at each of them; + * with a `format` column and more than one way to read a payload, two copies + * of that decision is two places to get it wrong, and the disagreement would + * show up only in history. + * + * `payload` is bytes on the wire now — the node stopped decoding it as UTF-8, + * which mangled anything that was not text. A message that cannot be read + * comes back marked rather than thrown away: a gap in a conversation the + * reader can see is honest, and silently dropping messages is not. + */ + async _openChatMessage(row) { + const base = { + id: row.id, + sender_id: row.sender_id, + sender_name: row.sender_name || '', + timestamp: row.timestamp, + thread_id: row.thread_id, + }; + const format = row.format || 0; + if (format === 0) { + return { ...base, payload: _asText(row.payload) }; + } + if (format !== 1) { + return { ...base, payload: '', unreadable: 'format' }; + } + return this._openSealedChat(base, row); } /** @@ -1420,16 +1540,163 @@ class MeshBayTransport { return Math.round(performance.now() - started); } - async sendChat(payload, iteration, threadId, senderName) { - const msg = await this._sendAndWait({ + /** + * Send one message, sealed under this group's current chat epoch key and + * signed with this device's key. + * + * There is no plaintext path. MNP 2.0 has no unencrypted chat and the node + * refuses one, so a fallback here could only ever produce a refusal the user + * cannot act on — and a client that quietly posted in clear into a group + * whose members believe their chat is encrypted is the downgrade the whole + * design is about not having. + * + * `sender_name` goes **inside** the envelope. On the wire it is a field any + * peer can set to anything, and the node caches it to render history — so + * display-name spoofing is free while chat is plaintext. Sealed and signed, + * it is as authenticated as the message it names. + * + * Refuses rather than falls back. A client that cannot seal must not quietly + * post in clear into a group whose members believe their chat is encrypted; + * the node refuses it too, and the two refusals agreeing is the point. + */ + async sendChat(text, iteration, threadId, senderName) { + const keys = await this.chatKeys(); + const epoch = this.chatEpoch || keys.current; + const epochKey = keys.byEpoch.get(epoch); + if (!epochKey) throw new Error('No chat key for this group — reconnect'); + if (!this.devicePk || !this._sessionKeys || !this._sessionKeys.skEdB64) { + throw new Error('This device is not identified to the node — reconnect'); + } + + const C = window.MeshBayCrypto; + const gid = this._groupId || ''; + const plaintext = msgpack_encode({ + text: String(text), + thread_id: threadId || null, + sender_name: senderName || '', + sent_at: Math.floor(Date.now() / 1000), + }); + const { nonce, ct } = await C.sealChat( + epochKey, gid, epoch, this.devicePk, plaintext); + const device = C.b64decode(this.devicePk); + const sig = C.b64decode(await window.MeshBayKeys.signBytes( + this._sessionKeys.skEdB64, + C.chatSigningTranscript(gid, epoch, device, nonce, ct))); + + return this._sendAndWait({ type: 'chat_msg', - v: '0.1', - payload: payload, - iteration: iteration || 0, + v: '2.0', + format: 1, + epoch, + ct, + nonce, + device, + sig, thread_id: threadId || null, - sender_name: senderName || null, + // Deliberately absent: the display name is inside the envelope now. + sender_name: null, }); - return msg; + } + + /** + * This group's chat epoch moved. + * + * An epoch opens when somebody is removed, and a client that kept sealing + * under the retired key would be writing messages the group can still read + * but that the removed member could read too. Dropping the cached keys is + * what makes the next send fetch the new one. + */ + _applyChatEpoch(msg) { + if (msg.epoch) this.chatEpoch = msg.epoch; + this._chatKeys = null; + this._chatKeysInFlight = null; + if (this._onChatEpoch) this._onChatEpoch(this.chatEpoch); + } + + /** + * Every chat epoch key for this group, fetched once per connection. + * + * Every epoch, not just the current one — that is what lets a device linked + * this morning read a conversation from last year, and a member who joined + * yesterday read the history the group already had. The node decides which + * epochs a member is entitled to; this asks for what it is given. + */ + async chatKeys() { + if (this._chatKeys) return this._chatKeys; + if (this._chatKeysInFlight) return this._chatKeysInFlight; + + this._chatKeysInFlight = (async () => { + const resp = await this._sendAndWait({ + type: 'chat_keys_req', v: '2.0', group_id: this._groupId || '', + }); + if (resp.type === 'error') throw new Error(resp.detail); + // Sealed under a group-derived subkey. A payload that does not open is + // not "no keys" — it is a peer we cannot talk to, and treating it as an + // empty set would present an encrypted group as one with no history. + const payload = msgpack_decode(await window.MeshBayCrypto.openGroup( + this._gekRaw, 'chat_keys', 'chat_keys_resp', this._groupId || '', resp)); + const byEpoch = new Map(); + for (const e of payload.epochs || []) byEpoch.set(e.epoch, e.key); + this._chatKeys = { byEpoch, current: payload.current || 0 }; + return this._chatKeys; + })(); + try { + return await this._chatKeysInFlight; + } finally { + this._chatKeysInFlight = null; + } + } + + /** + * Open one sealed message, or mark it unreadable and say why. + * + * Authorship is established **before** decryption: the signature is over the + * ciphertext, so a message that does not verify is never rendered as having + * been written by the account it claims — which is the whole point of signing + * rather than trusting the node's `sender_id`. + * + * An unreadable message is kept and marked, never dropped. A gap the reader + * can see is honest; a conversation quietly missing messages is not. + */ + async _openSealedChat(base, row) { + const C = window.MeshBayCrypto; + const gid = this._groupId || ''; + const epoch = row.epoch || 0; + const device = row.device; + const nonce = row.nonce; + const ct = row.ct; + if (!device || !nonce || !ct || !row.sig) { + return { ...base, payload: '', unreadable: 'envelope' }; + } + + if (!await C.verifyChatSignature(device, gid, epoch, nonce, ct, row.sig)) { + return { ...base, payload: '', unreadable: 'signature' }; + } + + let keys; + try { + keys = await this.chatKeys(); + } catch { + return { ...base, payload: '', unreadable: 'keys' }; + } + const epochKey = keys.byEpoch.get(epoch); + if (!epochKey) return { ...base, payload: '', unreadable: 'epoch' }; + + const deviceB64 = C.b64encode(device); + try { + const plain = msgpack_decode( + await C.openChat(epochKey, gid, epoch, deviceB64, nonce, ct)); + return { + ...base, + payload: String(plain.text || ''), + sender_name: plain.sender_name || base.sender_name, + thread_id: plain.thread_id ?? base.thread_id, + device: deviceB64, + verified: true, + }; + } catch { + return { ...base, payload: '', unreadable: 'decrypt' }; + } } /** @@ -2402,7 +2669,12 @@ class MeshBayTransport { return; } if (msg.type === 'chat_msg' && this._onChat) { - this._onChat(msg); + // Shaped through the same reader as history, and asynchronously — a live + // message and a stored one are the same message, and a second way of + // reading one is a second thing to get wrong. + this._openChatMessage(msg) + .then(m => { if (this._onChat) this._onChat(m); }) + .catch(e => console.warn('[MeshBay] chat message unreadable', e)); return; } if (msg.type === 'stream_init') { @@ -2447,6 +2719,10 @@ class MeshBayTransport { if (msg.type === 'chat_link_preview_ack' && this._onChatLinkPreview) { this._onChatLinkPreview(Boolean(msg.enabled)); } + if (msg.type === 'chat_epoch_ack') { + this._applyChatEpoch(msg); + return; + } // Node-wide (not per-group) — the operator supplied/cleared a custom // token, or changed the query language. `token_customized` only says @@ -2679,6 +2955,35 @@ class MeshBayTransport { return; } + // device_hello_ack ends in `_ack` but is not an admin op, so the branch + // above looks it up under `admin:device_hello`, finds nothing, and drops it + // through to the arrival-order guess. Routed by request type instead: a + // request type deserves a key, and a reply deserves something to key it by. + if (msg.type === 'device_hello_ack') { + for (const [, handler] of this._pending) { + if (handler._reqType === 'device_hello') { handler.resolve(msg); return; } + } + console.warn('[MeshBay] device_hello_ack with no matching request'); + return; + } + + // Same shape as chat_hist_resp above, and found the same way — by driving + // the panel rather than by reading this file. `chat_keys_resp` answers a + // `chat_keys_req` under a different type string, so without this it fell + // to the arrival-order guess at the end and was handed to whatever was + // oldest in `_pending`. `chat_send_probe.py` caught it on its first run: + // the Videos tab's unanswered `media_meta_req` swallowed the chat keys, + // and the send then waited out its own 30s timeout with the composer + // disabled — which is a frozen Chat tab, the exact defect that harness + // exists for, reappearing one feature later. + if (msg.type === 'chat_keys_resp') { + for (const [, handler] of this._pending) { + if (handler._reqType === 'chat_keys_req') { handler.resolve(msg); return; } + } + console.warn('[MeshBay] chat_keys_resp with no matching request'); + return; + } + // A bare `ack` answers three requests: sending a chat message, and storing // or withdrawing a keypair bundle. The bundle acks name themselves in // `detail`; the chat one carries nothing at all, so it was left to the @@ -2734,6 +3039,25 @@ class MeshBayTransport { } } +/** + * A wire payload as text. + * + * A plaintext message arrives as a string from the node; msgpack `bin` arrives + * as a Uint8Array. Both have to render. + * + * This function was deleted once, with an unrelated helper that sat next to it, + * and nothing complained: its only caller is inside `_openChatMessage`, whose + * rejection the chat panel swallows in a `.catch()` that just marks the page + * unloaded. The visible result was a conversation that rendered completely + * empty, with no error in the console and the node answering perfectly — found + * by `chat_send_probe.py`, not by reading this file. + */ +function _asText(payload) { + if (payload instanceof Uint8Array) return new TextDecoder().decode(payload); + if (payload == null) return ''; + return String(payload); +} + // ── Minimal msgpack encode/decode ──────────────────────────────────────────── // Covers the subset used by MNP: maps, strings, integers, binary, arrays, null. diff --git a/packages/meshbay-hub/tests/harness/chat_send_probe.py b/packages/meshbay-hub/tests/harness/chat_send_probe.py index f1cc191..1f0557b 100644 --- a/packages/meshbay-hub/tests/harness/chat_send_probe.py +++ b/packages/meshbay-hub/tests/harness/chat_send_probe.py @@ -38,7 +38,32 @@ PORT = 8755 RECORDS = [] socketserver.TCPServer.allow_reuse_address = True -PAGE = r"""<!doctype html><html><head><meta charset=utf-8> +GROUP_ID = "g" * 32 +GEK = bytes.fromhex("5a" * 32) +EPOCH_KEY = bytes.fromhex("7c" * 32) + + +def _page() -> str: + """ + The page, with a real sealed `chat_keys_resp` baked in. + + Sealed here, by the shipped Python, rather than assembled in the browser: + msgpack is private to transport.js and exported to nothing, and a payload + the test built itself would prove only that the page agrees with the page. + """ + import msgpack + + from meshbay_common.groupbox import PURPOSE_CHAT_KEYS, seal + + sealed = seal(GEK, PURPOSE_CHAT_KEYS, "chat_keys_resp", GROUP_ID, + {"epochs": [{"epoch": 1, "key": EPOCH_KEY}], "current": 1}) + return (PAGE_TEMPLATE + .replace("__GROUP_ID__", GROUP_ID) + .replace("__GEK_HEX__", GEK.hex()) + .replace("__KEYS_NONCE_HEX__", sealed["nonce"].hex()) + .replace("__KEYS_CT_HEX__", sealed["ct"].hex())) + +PAGE_TEMPLATE = r"""<!doctype html><html><head><meta charset=utf-8> <link rel="stylesheet" href="/style.css"></head> <body> <div class="layout"><div class="main"> @@ -46,6 +71,13 @@ PAGE = r"""<!doctype html><html><head><meta charset=utf-8> <div class="group-tabs"><button class="group-tab active">Chat</button></div> <div id="root"></div> </div></div> +<!-- The two the real page loads and the transport reaches for by global: + `sealChat`/`openGroup` live in crypto.js, `signBytes` in keyderive.js. + Without them a send fails with "cannot read properties of undefined", + which is what this probe reported the first time it exercised the + encrypted path. --> +<script src="/crypto.js"></script> +<script src="/keyderive.js"></script> <script src="/transport.js"></script> <script type="module"> import { html, render, useRef } from '/vendor/htm-preact.js'; @@ -53,6 +85,13 @@ import { ChatPanel } from '/chat-app.js'; const log = []; window.addEventListener('error', e => log.push('error: ' + e.message)); +window.addEventListener('unhandledrejection', + e => log.push('rejected: ' + (e.reason && e.reason.message || e.reason))); +const _warn = console.warn, _err = console.error; +console.warn = (...a) => { log.push('warn: ' + a.join(' ')); _warn(...a); }; +console.error = (...a) => { log.push('console error: ' + a.join(' ')); _err(...a); }; + +const hex = (s) => Uint8Array.from(s.match(/../g) || [], b => parseInt(b, 16)); // The real transport, with only the channel replaced: _send takes the plain // object _sendAndWait built, so the framing and msgpack are the only things @@ -61,6 +100,25 @@ const tp = new window.MeshBayTransport('', 'token'); tp._connected = true; tp._channel = { readyState: 'open', send() {} }; +// Chat is encrypted (MNP 2.0), so a send that is going to come back has to +// seal and sign for real. The device key is generated here rather than stubbed +// — `signBytes` imports a pkcs8 key and WebCrypto will not be fooled — and the +// group key and epoch keys come from Python, which sealed the `chat_keys_resp` +// below exactly as the node does. So this exercises `chatKeys()`, `openGroup`, +// `sealChat` and the real signature, not a model of any of them. +tp._groupId = '__GROUP_ID__'; +tp._gekRaw = hex('__GEK_HEX__'); +tp.chatEpoch = 1; + +const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, + ['sign', 'verify']); +const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))); +tp._sessionKeys = { + skEdB64: b64(await crypto.subtle.exportKey('pkcs8', kp.privateKey)), +}; +// What `device_hello` sets on a live connection. +tp.devicePk = b64(await crypto.subtle.exportKey('raw', kp.publicKey)); + const now = Date.now() / 1000; const history = []; for (let i = 0; i < 5; i++) { @@ -76,18 +134,42 @@ for (let i = 0; i < 5; i++) { tp._send = (obj) => { log.push('sent ' + obj.type); if (obj.type === 'chat_hist') { - setTimeout(() => tp._dispatch( - { type: 'chat_hist_resp', v: '0.2', messages: history, has_more: false }), 10); + setTimeout(() => { + log.push('answering chat_hist'); + tp._dispatch({ type: 'chat_hist_resp', v: '0.2', messages: history, + has_more: false }); + }, 10); + } else if (obj.type === 'chat_keys_req') { + // Sealed under the group key, as `_do_chat_keys_req` sends it. + setTimeout(() => tp._dispatch({ + type: 'chat_keys_resp', v: '2.0', group_id: tp._groupId, + nonce: hex('__KEYS_NONCE_HEX__'), ct: hex('__KEYS_CT_HEX__'), + }), 10); } else if (obj.type === 'chat_msg') { - setTimeout(() => tp._dispatch({ type: 'ack', v: '0.14' }), 10); + // Recorded so the test can assert the message really was sealed and + // signed rather than sent in clear past a composer that let it through. + log.push('chat_msg format=' + obj.format + ' epoch=' + obj.epoch + + ' ct=' + (obj.ct ? obj.ct.length : 0) + + ' sig=' + (obj.sig ? obj.sig.length : 0) + + ' plaintextLeak=' + JSON.stringify(obj).includes('hello')); + setTimeout(() => tp._dispatch({ type: 'ack', v: '2.0' }), 10); } }; +// The panel swallows a send failure into `setInput(text)`, which is right for +// a person and useless for a probe: the symptom is the message not appearing, +// with no reason anywhere. Surfaced here so a failure names itself. +const _sendChat = tp.sendChat.bind(tp); +tp.sendChat = (...a) => _sendChat(...a).catch((e) => { + log.push('sendChat failed: ' + (e && e.message || e)); + throw e; +}); + function Host() { const transportRef = useRef(tp); const gekRef = useRef(null); return html`<${ChatPanel} transportRef=${transportRef} gekRef=${gekRef} - username="me" entries=${[]} status="connected" />`; + username="me" userId="user-me" entries=${[]} status="connected" />`; } render(html`<${Host} />`, document.getElementById('root')); @@ -99,6 +181,7 @@ function snap(label) { out.steps.push({ label, bubbles: document.querySelectorAll('.chat-bubble').length, + msgs: document.querySelectorAll('.chat-msg').length, lastText: [...document.querySelectorAll('.chat-text')].pop()?.textContent ?? null, // What a frozen tab actually is: the composer is disabled for as long as // a send is in flight. @@ -159,7 +242,7 @@ class H(http.server.BaseHTTPRequestHandler): def do_GET(self): if self.path == "/": - body, ctype = PAGE.encode(), "text/html; charset=utf-8" + body, ctype = _page().encode(), "text/html; charset=utf-8" else: path = (STATIC / self.path.lstrip("/")).resolve() if not str(path).startswith(str(STATIC)) or not path.is_file(): diff --git a/packages/meshbay-hub/tests/test_chat_send.py b/packages/meshbay-hub/tests/test_chat_send.py index 4224fdb..250c9a3 100644 --- a/packages/meshbay-hub/tests/test_chat_send.py +++ b/packages/meshbay-hub/tests/test_chat_send.py @@ -1,5 +1,5 @@ """ -Sending a chat message must come back. +Sending a chat message must come back — and must go out encrypted. The node answers a chat message with a bare `{"type": "ack"}` — no request id, no type of its own — so `_dispatch` had nothing to match it on and left it to @@ -18,10 +18,26 @@ Videos tab that asked about a file the index no longer has leaves a None of that is visible in `chat-app.js`, where every line is correct, so this drives the real panel over the real transport in a browser rather than reading either source. + +Extended for MNP 2.0, where a send seals and signs before it goes anywhere. +That turned out to matter twice on its first run: + + * `chat_keys_resp` answers a `chat_keys_req` under a different type string, + so it fell through to the arrival-order guess and was handed to the very + `media_meta_req` this probe leaves outstanding — the original defect, one + feature later, in a message type that did not exist when it was written. + * `_asText` had been deleted along with an unrelated helper beside it. Its + only caller is inside `_openChatMessage`, whose rejection the panel + swallows, so the whole conversation rendered empty with nothing in the + console and the node answering perfectly. + +Neither is visible in any source file, and neither would have been caught by a +test that reads one. """ import json import shutil import subprocess +import sys from pathlib import Path import pytest @@ -36,7 +52,12 @@ pytestmark = pytest.mark.skipif( @pytest.fixture(scope="module") def probe(): - run = subprocess.run(["python3", str(HARNESS)], capture_output=True, timeout=180) + # `sys.executable`, not a bare "python3": the harness now imports + # `meshbay_common` to seal the chat keys the way the node does, and the + # system interpreter has neither that nor msgpack. The other probes get + # away with "python3" because they import nothing from this project. + run = subprocess.run([sys.executable, str(HARNESS)], + capture_output=True, timeout=180) assert run.returncode == 0, run.stderr.decode()[-2000:] data = json.loads(run.stdout.decode()) return data, {s["label"]: s for s in data["steps"]} @@ -71,3 +92,35 @@ def test_the_ack_is_not_handed_to_another_request(probe): "the chat ack was routed to the pending media_meta_req -- that request " "now believes it has an answer, and the chat send is waiting for a " "reply that already arrived") + + +def test_the_message_goes_out_sealed_and_signed(probe): + """ + What actually left the browser. A composer that let a plaintext message + through would be refused by the node, but the refusal arrives after the + fact and reads as "the message did not send" — so assert the shape here, + where the reason is visible. + """ + data, _ = probe + sent = [line for line in data["log"] if line.startswith("chat_msg ")] + assert sent, ("no chat_msg reached the stand-in node — the send did not " + f"complete. log: {data['log']}") + assert "format=1" in sent[0], "the message was not sealed" + assert "sig=64" in sent[0], "the message was not signed" + assert "ct=" in sent[0] and "ct=0" not in sent[0], "there was no ciphertext" + assert "plaintextLeak=false" in sent[0], ( + "the text the person typed appears somewhere in the message that went " + "on the wire") + + +def test_the_chat_keys_answer_is_not_handed_to_another_request(probe): + """ + The original defect's shape, in the message type that carries the group's + chat keys. An unanswered request is the ordinary case, not a rare one, and + the one this probe leaves outstanding swallowed the keys on the first run. + """ + data, _ = probe + assert not any("media_meta resolved with chat_keys_resp" in line + for line in data["log"]), ( + "chat_keys_resp was routed by arrival order and handed to the stale " + "media_meta_req — the send then waits out its own 30s timeout") diff --git a/packages/meshbay-node/src/meshbay_node/bundle_store.py b/packages/meshbay-node/src/meshbay_node/bundle_store.py index 7f03caa..86b8de4 100644 --- a/packages/meshbay-node/src/meshbay_node/bundle_store.py +++ b/packages/meshbay-node/src/meshbay_node/bundle_store.py @@ -30,6 +30,30 @@ CREATE TABLE IF NOT EXISTS gek_bundles ( ); """ +# Chat epoch keys. Wrapped to the node's own X25519 key, exactly as the node's +# copy of the group key is — never stored raw. +# +# That is the whole basis of the claim chat encryption makes: "unreadable to +# someone who obtains the node's storage without the keystore password". A +# plaintext table beside chat.db would collapse it to nothing, silently, and it +# is the obvious thing to write. `test_chat_key_storage.py` reads the file back +# and refuses to find the live key in it. +# +# Rows are kept, never replaced: opening a new epoch must not make the history +# of the old one unreadable to the members who could already read it, which is +# the difference between an epoch and a rotation. +_SCHEMA_CHAT_EPOCHS = """\ +CREATE TABLE IF NOT EXISTS chat_epochs ( + group_id TEXT NOT NULL, + epoch INTEGER NOT NULL, + pk_eph_b64 TEXT NOT NULL, + nonce_b64 TEXT NOT NULL, + wrapped_b64 TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (group_id, epoch) +); +""" + _SCHEMA_KEYPAIR = """\ CREATE TABLE IF NOT EXISTS keypair_bundles ( user_id TEXT PRIMARY KEY, @@ -50,6 +74,7 @@ class BundleStore: self._db = await aiosqlite.connect(str(self._db_path)) await self._db.execute(_SCHEMA_GEK) await self._db.execute(_SCHEMA_KEYPAIR) + await self._db.execute(_SCHEMA_CHAT_EPOCHS) await self._migrate_keypair_recovery() await self._db.commit() @@ -155,6 +180,54 @@ class BundleStore: await self._db.commit() return cur.rowcount > 0 + # ── Chat epoch keys ────────────────────────────────────────────────── + + async def store_chat_epoch( + self, + group_id: str, + epoch: int, + pk_eph_b64: str, + nonce_b64: str, + wrapped_b64: str, + ) -> None: + """ + Record one epoch key, wrapped to the node's own key. + + `INSERT OR IGNORE`, not `REPLACE`: an epoch's key is written once and is + then the only way to read the messages sent under it. Overwriting one — + which a retry, or two callers racing to open the same epoch, would do — + would destroy that history with no error anywhere. + """ + assert self._db + await self._db.execute( + "INSERT OR IGNORE INTO chat_epochs " + "(group_id, epoch, pk_eph_b64, nonce_b64, wrapped_b64) " + "VALUES (?, ?, ?, ?, ?)", + (group_id, epoch, pk_eph_b64, nonce_b64, wrapped_b64), + ) + await self._db.commit() + + async def fetch_chat_epochs(self, group_id: str) -> list[dict]: + """Every epoch this group has had, oldest first.""" + assert self._db + async with self._db.execute( + "SELECT epoch, pk_eph_b64, nonce_b64, wrapped_b64 FROM chat_epochs " + "WHERE group_id = ? ORDER BY epoch", (group_id,) + ) as cur: + rows = await cur.fetchall() + return [{"epoch": r[0], "pk_eph_b64": r[1], "nonce_b64": r[2], + "wrapped_b64": r[3]} for r in rows] + + async def latest_chat_epoch(self, group_id: str) -> int: + """The highest epoch number, or 0 when the group has none yet.""" + assert self._db + async with self._db.execute( + "SELECT MAX(epoch) FROM chat_epochs WHERE group_id = ?", + (group_id,) + ) as cur: + row = await cur.fetchone() + return int(row[0] or 0) + async def close(self) -> None: if self._db: await self._db.close() diff --git a/packages/meshbay-node/src/meshbay_node/chat/__init__.py b/packages/meshbay-node/src/meshbay_node/chat/__init__.py index f647e19..cb2c868 100644 --- a/packages/meshbay-node/src/meshbay_node/chat/__init__.py +++ b/packages/meshbay-node/src/meshbay_node/chat/__init__.py @@ -1,4 +1,18 @@ -"""MeshBay Node — chat module (Sender Keys encrypted group messaging).""" -from .store import ChatStore +"""MeshBay Node — chat storage and relay. -__all__ = ["ChatStore"] +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`. +""" +from .store import ( + FORMAT_PLAIN, + FORMAT_SEALED_V1, + ChatStore, + ReplayedMessage, + StoredMessage, +) + +__all__ = ["ChatStore", "StoredMessage", "ReplayedMessage", + "FORMAT_PLAIN", "FORMAT_SEALED_V1"] diff --git a/packages/meshbay-node/src/meshbay_node/chat/store.py b/packages/meshbay-node/src/meshbay_node/chat/store.py index 6f23905..9e5ee90 100644 --- a/packages/meshbay-node/src/meshbay_node/chat/store.py +++ b/packages/meshbay-node/src/meshbay_node/chat/store.py @@ -1,9 +1,26 @@ """ MeshBay Node — SQLite-backed chat message store. -One database per group. Stores encrypted Sender Keys messages for offline -retrieval and history. Messages are stored as received (ciphertext) — -decryption happens on the client side. +One database per group. The node is a relay and an archive: it stores what it +was handed, serves it back, and — once a group has chat encryption switched on — +cannot read any of it. Decryption happens in the client, which is the only place +that holds the epoch key (`docs/chat-sender-keys.md` §5). + +Three things about the schema are load-bearing rather than incidental: + +* **`payload` is bytes, always.** It used to be a UTF-8 string in practice, and + the history path decoded it with `errors="replace"` — which substitutes + U+FFFD for every byte that is not valid UTF-8, i.e. for most of a ciphertext. + That would have corrupted history while live messages worked, which reads as + an intermittent decryption bug rather than as a wire-format error. +* **`format` says how to read a row**, so messages written before a group turned + encryption on keep rendering. Nothing is ever rewritten in place by the + switch; see `chat encrypt-history` for the explicit, backed-up alternative. +* **`(device, nonce)` is unique.** The nonce is 96 random bits chosen per + message by the sending device, so it is already required to be unique for + AES-GCM to be safe — making it a key costs nothing and turns a replayed + message (which is validly signed, being a copy of a real one) into an + integrity error instead of a duplicate. """ import logging @@ -15,6 +32,17 @@ import aiosqlite log = logging.getLogger(__name__) + +class ReplayedMessage(Exception): + """This device has already sent a message under this nonce.""" + +# Plaintext, as every message was before chat encryption existed. Rows keep it +# for ever; nothing rewrites them. +FORMAT_PLAIN = 0 +# Sealed under a chat epoch key: `payload` is the AES-256-GCM ciphertext, +# `nonce` its 96-bit nonce, `sig` the sender device's Ed25519 signature. +FORMAT_SEALED_V1 = 1 + _SCHEMA = """ CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -29,8 +57,27 @@ CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(timestamp); CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id); """ -_MIGRATE_SENDER_NAME = ( - "ALTER TABLE messages ADD COLUMN sender_name TEXT DEFAULT ''" +# Additive, every one with a default, so an existing chat.db opens unchanged and +# a node that is downgraded still reads its own rows. `CREATE TABLE IF NOT +# EXISTS` adds no column to a table that already exists — the same trap +# `create_all()` is recorded for on the hub — so each of these runs on its own +# and a duplicate-column error is the expected outcome on the second start. +_MIGRATIONS = ( + "ALTER TABLE messages ADD COLUMN sender_name TEXT DEFAULT ''", + "ALTER TABLE messages ADD COLUMN format INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE messages ADD COLUMN epoch INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE messages ADD COLUMN device BLOB DEFAULT NULL", + "ALTER TABLE messages ADD COLUMN nonce BLOB DEFAULT NULL", + "ALTER TABLE messages ADD COLUMN sig BLOB DEFAULT NULL", +) + +# A replay is a validly signed copy of a real message, so nothing about the +# signature refuses it. The nonce does: it is per message, per device, and a +# repeat is either an attack or a bug. Partial, because plaintext rows carry no +# nonce at all and NULLs are distinct in SQLite anyway. +_REPLAY_INDEX = ( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_replay " + "ON messages(device, nonce) WHERE nonce IS NOT NULL" ) @@ -43,11 +90,24 @@ class StoredMessage: timestamp: float thread_id: str | None sender_name: str = "" + format: int = FORMAT_PLAIN + epoch: int = 0 + device: bytes | None = None + nonce: bytes | None = None + sig: bytes | None = None + + +_COLUMNS = ("id, sender_id, iteration, payload, timestamp, thread_id, " + "sender_name, format, epoch, device, nonce, sig") def _row(r) -> StoredMessage: - return StoredMessage(id=r[0], sender_id=r[1], iteration=r[2], payload=r[3], - timestamp=r[4], thread_id=r[5], sender_name=r[6] or "") + return StoredMessage( + id=r[0], sender_id=r[1], iteration=r[2], payload=r[3], + timestamp=r[4], thread_id=r[5], sender_name=r[6] or "", + format=r[7] or FORMAT_PLAIN, epoch=r[8] or 0, + device=r[9], nonce=r[10], sig=r[11], + ) class ChatStore: @@ -61,10 +121,17 @@ class ChatStore: self._db_path.parent.mkdir(parents=True, exist_ok=True) self._db = await aiosqlite.connect(str(self._db_path)) await self._db.executescript(_SCHEMA) - try: - await self._db.execute(_MIGRATE_SENDER_NAME) - except Exception: - pass + for statement in _MIGRATIONS: + try: + await self._db.execute(statement) + except Exception: + # Already applied. Swallowed per column rather than per batch: + # one loop with a shared `try` would stop at the first + # already-present column and silently skip every later one, so a + # node upgraded twice would be missing the newest fields with + # nothing to show for it. + pass + await self._db.execute(_REPLAY_INDEX) await self._db.commit() async def close(self) -> None: @@ -86,14 +153,32 @@ class ChatStore: payload: bytes, thread_id: str | None = None, sender_name: str = "", + *, + format: int = FORMAT_PLAIN, + epoch: int = 0, + device: bytes | None = None, + nonce: bytes | None = None, + sig: bytes | None = None, ) -> int: - """Store a message. Returns the row id.""" + """ + Store a message. Returns the row id. + + Raises `ReplayedMessage` if this device has already used this nonce — + see `_REPLAY_INDEX`. The caller must not turn that into a generic + failure the sender retries: it means the message is already stored. + """ ts = time.time() - cursor = await self._db.execute( - "INSERT INTO messages (sender_id, iteration, payload, timestamp, thread_id, sender_name) " - "VALUES (?, ?, ?, ?, ?, ?)", - (sender_id, iteration, payload, ts, thread_id, sender_name), - ) + try: + cursor = await self._db.execute( + "INSERT INTO messages (sender_id, iteration, payload, timestamp, " + " thread_id, sender_name, format, epoch, device, nonce, sig) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (sender_id, iteration, payload, ts, thread_id, sender_name, + format, epoch, device, nonce, sig), + ) + except aiosqlite.IntegrityError as e: + await self._db.rollback() + raise ReplayedMessage(str(e)) from e await self._db.commit() return cursor.lastrowid @@ -104,17 +189,12 @@ class ChatStore: ) -> list[StoredMessage]: """Get messages after a timestamp, most recent last.""" cursor = await self._db.execute( - "SELECT id, sender_id, iteration, payload, timestamp, thread_id, sender_name " + f"SELECT {_COLUMNS} " "FROM messages WHERE timestamp > ? ORDER BY timestamp ASC LIMIT ?", (since, limit), ) rows = await cursor.fetchall() - return [ - StoredMessage(id=r[0], sender_id=r[1], iteration=r[2], - payload=r[3], timestamp=r[4], thread_id=r[5], - sender_name=r[6] or "") - for r in rows - ] + return [_row(r) for r in rows] async def get_recent(self, limit: int = 100) -> list[StoredMessage]: """The newest `limit` messages, oldest first so they render in order. @@ -125,7 +205,7 @@ class ChatStore: rows means ordering DESC in SQL and reversing here. """ cursor = await self._db.execute( - "SELECT id, sender_id, iteration, payload, timestamp, thread_id, sender_name " + f"SELECT {_COLUMNS} " "FROM messages ORDER BY id DESC LIMIT ?", (limit,), ) @@ -141,7 +221,7 @@ class ChatStore: `id` is AUTOINCREMENT: unique, and ordered by insertion. """ cursor = await self._db.execute( - "SELECT id, sender_id, iteration, payload, timestamp, thread_id, sender_name " + f"SELECT {_COLUMNS} " "FROM messages WHERE id < ? ORDER BY id DESC LIMIT ?", (before_id, limit), ) @@ -157,17 +237,57 @@ class ChatStore: async def get_thread(self, thread_id: str, limit: int = 100) -> list[StoredMessage]: """Get messages in a thread.""" cursor = await self._db.execute( - "SELECT id, sender_id, iteration, payload, timestamp, thread_id, sender_name " + f"SELECT {_COLUMNS} " "FROM messages WHERE thread_id = ? ORDER BY timestamp ASC LIMIT ?", (thread_id, limit), ) rows = await cursor.fetchall() - return [ - StoredMessage(id=r[0], sender_id=r[1], iteration=r[2], - payload=r[3], timestamp=r[4], thread_id=r[5], - sender_name=r[6] or "") - for r in rows - ] + return [_row(r) for r in rows] + + @property + def db_path(self) -> Path: + """Where this store lives — the re-encryption command backs it up.""" + return self._db_path + + async def count_by_format(self) -> tuple[int, int]: + """(plaintext, sealed). What the operator is deciding from.""" + cursor = await self._db.execute( + "SELECT format, COUNT(*) FROM messages GROUP BY format") + counts = {row[0]: row[1] for row in await cursor.fetchall()} + return (counts.get(FORMAT_PLAIN, 0), counts.get(FORMAT_SEALED_V1, 0)) + + async def all_plaintext(self) -> list[StoredMessage]: + """Every message still stored in the clear, oldest first.""" + cursor = await self._db.execute( + f"SELECT {_COLUMNS} FROM messages WHERE format = ? ORDER BY id", + (FORMAT_PLAIN,)) + return [_row(r) for r in await cursor.fetchall()] + + async def reseal(self, message_id: int, *, epoch: int, device: bytes, + nonce: bytes, ct: bytes, sig: bytes) -> None: + """ + Replace one plaintext row with its sealed form. **Does not commit** — + the caller commits once, so a re-encryption that fails half way leaves + the database as it was rather than half readable. + + `sender_name` is cleared because it moves inside the envelope; leaving + it would keep in the clear the one field the sealing was for. + """ + await self._db.execute( + "UPDATE messages SET format = ?, epoch = ?, device = ?, " + " nonce = ?, payload = ?, sig = ?, sender_name = '' " + "WHERE id = ?", + (FORMAT_SEALED_V1, epoch, device, nonce, ct, sig, message_id)) + + async def commit(self) -> None: + await self._db.commit() + + async def delete_older_than(self, cutoff: float) -> int: + """Retention. Returns how many rows went.""" + cursor = await self._db.execute( + "DELETE FROM messages WHERE timestamp < ?", (cutoff,)) + await self._db.commit() + return cursor.rowcount async def message_count(self) -> int: cursor = await self._db.execute("SELECT COUNT(*) FROM messages") diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index f9e992c..e270b6c 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -415,6 +415,11 @@ class NodeDaemon: # Whether the node unfurls links members post here. "chat_link_preview": await self._roster.chat_link_preview( group_cfg.id) if self._roster else True, + # Which chat epoch key is current. Opened here if the + # group has none, because chat is always encrypted (MNP + # 2.0) and a group with no epoch is a group nobody can + # speak in — the node cannot wait for an operator to notice. + "chat_epoch": await self._ensure_chat_epoch(group_cfg.id), # Whether TMDB lookups run for this group at all — # per-group (2026-08-24, used to be node-wide), same # "read once, kept current in place by the signed op" @@ -633,6 +638,10 @@ class NodeDaemon: self._state["bundle_store"] = self._bundle_store self._state["roster"] = self._roster self._state["node_user_id"] = session.user_id + # The node's own Ed25519 key. Needed by `encrypt_chat_history`, + # which seals migrated messages under a synthetic device of the + # node's rather than pretending to hold a member's signing key. + self._state["sk_node"] = keys.sk_ed25519 self._state["webrtc"] = self._webrtc self._state["quic_server"] = self._quic_server self._state["hub"] = hub @@ -869,6 +878,7 @@ class NodeDaemon: "chat_link_preview": ( await self._roster.chat_link_preview(group_cfg.id) if self._roster else True), + "chat_epoch": await self._ensure_chat_epoch(group_cfg.id), "tmdb_enabled": ( await self._roster.tmdb_enabled(group_cfg.id) if self._roster else True), @@ -1007,6 +1017,35 @@ class NodeDaemon: log.warning("No unwrappable GEK bundle found for group %s", group_id[:8]) return None + async def _ensure_chat_epoch(self, group_id: str) -> int: + """ + The group's current chat epoch, opening the first one if it has none. + + Chat is always encrypted, so a group with no epoch key is a group in + which nobody can say anything. Attaching one is the node's job and + happens here rather than on the first message: a failure at start-up is + in the log the operator is already reading, and a failure on someone's + first message is a chat that mysteriously refuses them. + + Never fatal. A group whose epoch cannot be opened keeps every other + function — files, video, the index — and only its chat is unusable, + which is strictly better than refusing to host the group at all. + """ + if not self._bundle_store: + return 0 + try: + epoch = await self._bundle_store.latest_chat_epoch(group_id) + if epoch: + return epoch + from meshbay_node import ops + + return (await ops.open_chat_epoch(self._state, group_id))["epoch"] + except Exception as e: + log.error("chat: no epoch key for group %s (%s) — chat is " + "unusable in this group until this is fixed", + group_id[:8], e) + return 0 + async def _progress_pusher(self, indexer: DirectoryIndexer, interval: float = 2.0) -> None: """ @@ -1759,7 +1798,8 @@ def main() -> None: parser.add_argument("command", nargs="?", choices=["init", "reset", "status", "gek-init", "gek", "operator", "member", "group", "root", - "file", "video", "denylist", "stun", "reload", + "file", "video", "chat", "denylist", "stun", + "reload", "restart-daemon", "autostart", "service", "calibrate-argon2"], help="init: provision config + keystore | reset: erase all " @@ -1770,7 +1810,9 @@ def main() -> None: "| root list|add|remove|set|eject|plug " "| gek init|rotate | file list|rm " "| video rematch: re-resolve TMDB matches for a group's " - "videos | denylist show|clear " + "videos " + "| chat status|rotate|encrypt-history|prune " + "| denylist show|clear " "| stun list|add|remove|reset " "| reload: re-read node.toml (hot; systemd or the " "loopback API) | restart-daemon: restart the node " @@ -1827,7 +1869,7 @@ def main() -> None: # Query commands print a report; library logging would interleave with it. quiet = args.command in ("status", "gek-init", "gek", "operator", - "member", "group", "root", "file", "video", + "member", "group", "root", "file", "video", "chat", "denylist", "stun", "reload", "restart-daemon", "reset") logging.basicConfig( @@ -2370,6 +2412,61 @@ def main() -> None: sys.exit(1) return + if args.command == "chat": + cfg = load_config(args.config or DEFAULT_CONFIG_PATH) + sub = args.subcommand or "status" + group_id = _resolve_group(cfg, args.group) + + if sub == "status": + out = _daemon_api(cfg, f"/api/groups/{group_id}/chat") + print(f"encryption always on (MNP {MNP_VERSION})") + print(f"epoch {out.get('epoch', 0)}") + print(f"messages {out.get('encrypted_messages', 0)} encrypted, " + f"{out.get('plaintext_messages', 0)} in the clear") + if out.get("plaintext_messages"): + print("\nThose messages were written before this node spoke MNP " + "2.0 and are\nstill readable off this disk. " + "`chat encrypt-history` converts them.") + return + + if sub == "rotate": + out = _daemon_api(cfg, f"/api/groups/{group_id}/chat/epoch", + method="POST") + print(f"chat epoch {out['epoch']} opened") + print("Everyone still in the group keeps reading the history; " + "whoever left\ncannot read what is written from now on.") + return + + if sub == "encrypt-history": + if not args.yes: + print("This rewrites the only copy of this group's older " + "messages.") + print("A backup of chat.db is taken first, beside it.") + if input("re-encrypt now? [y/N] ").strip().lower() not in ("y", "yes"): + print("cancelled") + return + out = _daemon_api(cfg, f"/api/groups/{group_id}/chat/encrypt-history", + method="POST", timeout=300) + print(f"re-encrypted {out['converted']} message(s) under epoch " + f"{out['epoch']}") + print(f"backup {out['backup']}") + return + + if sub == "prune": + days = int(args.target or 0) + if days < 1: + print("usage: meshbay-node chat prune <days> [--group G]") + sys.exit(1) + out = _daemon_api( + cfg, f"/api/groups/{group_id}/chat/prune?max_age_days={days}", + method="POST") + print(f"removed {out['removed']} message(s) older than {days} day(s)") + return + + print("usage: meshbay-node chat " + "status|rotate|encrypt-history|prune [--group G]") + sys.exit(1) + if args.command == "denylist": cfg = load_config(args.config or DEFAULT_CONFIG_PATH) sub = args.subcommand or "show" diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index a10504e..1bad487 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -24,12 +24,18 @@ from __future__ import annotations import asyncio import logging +import time as _time import re from dataclasses import asdict from pathlib import Path from typing import Any -from meshbay_common.crypto import generate_gek, wrap_gek_aes +from meshbay_common.chatbox import new_epoch_key +from meshbay_common.crypto import ( + generate_gek, + unwrap_gek_aes, + wrap_gek_aes, +) from meshbay_node.config import DEFAULT_CONFIG_PATH from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR from meshbay_node.roots import RootError, RootSet @@ -262,6 +268,247 @@ async def unpin_member(state: dict, user_id: str) -> dict: return {"status": "unpinned", "user_id": user_id} +# ── Chat epoch keys ────────────────────────────────────────────────────────── +# +# The key a group's chat archive is encrypted under. Generated here, by the +# node, and never by a member — the C5b rule is about key material arriving from +# outside, and this is the same rule that lets `gek_rotate` be a signed +# instruction rather than a delivery. +# +# An *epoch* rather than a rotation, and the distinction is the whole design: +# opening a new one stops a departing member reading what comes next, while +# every earlier epoch is kept and still delivered to current members, so the +# history they could already read stays readable. Rotating instead — replacing +# the key, as `set_gek` does — would make every message anyone ever sent +# permanently unreadable to everybody, which is what a plain GEK-derived +# archive key would have done on the very first `member unpin` +# (docs/chat-sender-keys.md F4). + + +async def _wrap_for_node(state: dict, key: bytes) -> dict: + """ + Wrap a key to the node's own X25519 key, the way `set_gek` does for the GEK. + + Wrapped, not raw: the claim chat encryption makes is against someone who + obtains the node's storage *without the keystore password*, and the node's + X25519 private key is what the keystore protects. A raw key in SQLite would + leave nothing behind that claim. + """ + pk_x_node_raw = state.get("pk_x25519_raw") + if not pk_x_node_raw: + raise OpError("Node identity not available", status=503) + return wrap_gek_aes(key, pk_x_node_raw) + + +async def chat_epoch_keys(state: dict, group_id: str) -> list[dict]: + """ + Every chat epoch key this group has, oldest first, in the clear *in memory*. + + Cached on the group context: unwrapping is an ECIES operation per epoch and + this is on the path of every member connecting to a group with chat on. + """ + ctx = _group_ctx(state, group_id) + cached = ctx.get("chat_epoch_keys") + if cached is not None: + return cached + + bundle_store = state.get("bundle_store") + if not bundle_store: + raise OpError("Bundle store not available", status=503) + sk_x_raw = state.get("sk_x25519_raw") + pk_x_raw = state.get("pk_x25519_raw") + if not (sk_x_raw and pk_x_raw): + raise OpError("Node identity not available", status=503) + + keys: list[dict] = [] + for row in await bundle_store.fetch_chat_epochs(group_id): + try: + keys.append({"epoch": row["epoch"], + "key": unwrap_gek_aes(row, sk_x_raw, pk_x_raw)}) + except Exception as e: + # Loud, and not fatal: one unreadable epoch must not take the + # readable ones with it. The messages of that epoch are lost, which + # is a thing the operator needs told rather than a thing to hide. + log.error("chat: epoch %d of group %s will not unwrap (%s) — " + "its messages are unreadable", row["epoch"], + group_id[:8], e) + ctx["chat_epoch_keys"] = keys + return keys + + +async def open_chat_epoch(state: dict, group_id: str) -> dict: + """ + Open a new chat epoch. Idempotent only in the sense that it always adds one. + + Called when the set of devices that may read *future* messages shrinks: a + member removed, a device revoked or unpinned, the group key rotated, or the + operator asking directly. Never on a schedule — an epoch nobody needed is an + epoch key the node has to keep for ever. + """ + bundle_store = state.get("bundle_store") + if not bundle_store: + raise OpError("Bundle store not available", status=503) + + epoch = await bundle_store.latest_chat_epoch(group_id) + 1 + key = new_epoch_key() + wrapped = await _wrap_for_node(state, key) + await bundle_store.store_chat_epoch( + group_id, epoch, wrapped["pk_eph_b64"], wrapped["nonce_b64"], + wrapped["wrapped_b64"]) + + # Tolerant of a group context that does not exist yet: the daemon opens the + # first epoch **while it is building** `groups_ctx`, before publishing it on + # the state, because a group with no epoch key is a group nobody can speak + # in. Insisting on the context here would make start-up the one moment this + # cannot be called. + ctx = (state.get("groups_ctx") or {}).get(group_id) + if ctx is not None: + cached = ctx.get("chat_epoch_keys") + if cached is not None: + cached.append({"epoch": epoch, "key": key}) + ctx["chat_epoch"] = epoch + + # The transports hold their own view of the group, exactly as `set_gek` + # notes: an epoch that did not reach them would have members sealing under + # a key the node no longer thinks is current. + for transport_key in ("webrtc", "quic_server"): + transport = state.get(transport_key) + groups = getattr(transport, "_ctx", {}).get("groups") if transport else None + if groups and group_id in groups: + groups[group_id]["chat_epoch"] = epoch + groups[group_id].pop("chat_epoch_keys", None) + + log.info("Chat epoch %d opened for group %s", epoch, group_id[:8]) + return {"epoch": epoch} + + +async def ensure_chat_epoch(state: dict, group_id: str) -> int: + """The current epoch, opening the first one if the group has none.""" + bundle_store = state.get("bundle_store") + if not bundle_store: + raise OpError("Bundle store not available", status=503) + epoch = await bundle_store.latest_chat_epoch(group_id) + if epoch: + return epoch + return (await open_chat_epoch(state, group_id))["epoch"] + + +async def chat_status(state: dict, group_id: str) -> dict: + """What the operator needs to decide anything about this group's chat.""" + ctx = _group_ctx(state, group_id) + bundle_store = state.get("bundle_store") + store = ctx.get("chat_store") + plain = sealed = 0 + if store is not None: + plain, sealed = await store.count_by_format() + return { + "group_id": group_id, + "epoch": (await bundle_store.latest_chat_epoch(group_id) + if bundle_store else 0), + # Rows written before MNP 2.0. Not a state the node can be *in* — chat + # is always encrypted now — but a state its disk can be in until + # `chat encrypt-history` has run, and the operator has to be told, + # because those messages are the ones still readable off a stolen disk. + "plaintext_messages": plain, + "encrypted_messages": sealed, + } + + +async def encrypt_chat_history(state: dict, group_id: str) -> dict: + """ + Re-encrypt the messages written before this group turned encryption on. + + Deliberately **not** done by the switch. It rewrites the only copy of a + conversation, and a toggle that does that is one somebody flips twice; this + is an explicit command, it copies the database first, and it runs in one + transaction. + + The node can do this at all only because it holds those rows in plaintext — + it is the last moment at which anyone can. Afterwards nothing on this + machine can read them without an epoch key. + + Messages are sealed under a **synthetic device** belonging to the node, not + under the original sender's key: the node does not hold anyone's signing key + and must not pretend to. They are marked as such, so a reader is told these + carry the node's word for who wrote them — which is all they ever carried, + since they were written before signing existed. + """ + import shutil + + from meshbay_common.chatbox import seal + + ctx = _group_ctx(state, group_id) + store = ctx.get("chat_store") + if store is None: + raise OpError("This group has no chat store", status=404) + + epoch = await ensure_chat_epoch(state, group_id) + keys = {k["epoch"]: k["key"] for k in await chat_epoch_keys(state, group_id)} + key = keys.get(epoch) + if not key: + raise OpError("No chat key for this group", status=503) + + sk_node = state.get("sk_node") + if sk_node is None: + raise OpError("Node identity not available", status=503) + from cryptography.hazmat.primitives import serialization + + device_raw = sk_node.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw) + import base64 as _b64 + + device_b64 = _b64.b64encode(device_raw).decode() + + backup = store.db_path.with_name( + f"{store.db_path.name}.bak-{int(_time.time())}") + shutil.copy2(store.db_path, backup) + + converted = 0 + for row in await store.all_plaintext(): + text = (row.payload.decode("utf-8", errors="replace") + if isinstance(row.payload, bytes) else str(row.payload)) + env = seal(key, group_id, epoch, device_b64, device_raw, sk_node, { + "text": text, + "thread_id": row.thread_id, + "sender_name": row.sender_name, + "sent_at": int(row.timestamp), + # The node sealed this after the fact; it did not witness it being + # signed. Said in the payload rather than inferred from the device. + "migrated": True, + }) + await store.reseal(row.id, epoch=epoch, device=device_raw, + nonce=env["nonce"], ct=env["ct"], sig=env["sig"]) + converted += 1 + await store.commit() + + log.info("Chat history re-encrypted for group %s: %d message(s), backup %s", + group_id[:8], converted, backup.name) + return {"group_id": group_id, "converted": converted, + "backup": str(backup), "epoch": epoch} + + +async def prune_chat(state: dict, group_id: str, max_age_days: int) -> dict: + """ + Delete messages older than `max_age_days`. Epoch keys are never touched. + + An epoch whose messages have all aged out costs 32 bytes and keeps the + operation reversible in the only direction that matters: nothing that is + still stored becomes unreadable because something else was deleted. + """ + ctx = _group_ctx(state, group_id) + store = ctx.get("chat_store") + if store is None: + raise OpError("This group has no chat store", status=404) + if max_age_days < 1: + raise OpError("max_age_days must be at least 1", status=400) + removed = await store.delete_older_than( + _time.time() - max_age_days * 86400) + log.info("Chat retention for group %s: %d message(s) removed", + group_id[:8], removed) + return {"group_id": group_id, "removed": removed, + "max_age_days": max_age_days} + + # ── Group keys ─────────────────────────────────────────────────────────────── async def set_gek(state: dict, group_id: str, *, rotate: bool = False) -> dict: diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py index 360b9ac..284b488 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py @@ -23,6 +23,7 @@ import logging import os import struct import subprocess +import uuid from pathlib import Path from typing import Any, Callable @@ -207,6 +208,9 @@ class _MNPServerProtocol(QuicConnectionProtocol): def __init__(self, *args, node_ctx: dict, **kwargs): super().__init__(*args, **kwargs) self._ctx = node_ctx # shared server context (keys, index, etc.) + # Per connection, never per account — one person may hold several + # devices. See webrtc_server.WebRTCPeerSession._registry_key. + self._registry_key: str = uuid.uuid4().hex self._user_id: str | None = None self._group_id: str | None = None self._buffers: dict[int, _StreamBuffer] = {} @@ -362,7 +366,7 @@ class _MNPServerProtocol(QuicConnectionProtocol): self._user_id = peer.user_id self._group_id = peer.group_id - self._peer_registry()[self._user_id] = self + self._peer_registry()[self._registry_key] = self transcript = handshake_transcript( ROLE_NODE, peer.group_id, self._nonce_client, self._gek_challenge, binding) @@ -507,8 +511,10 @@ class _MNPServerProtocol(QuicConnectionProtocol): "thread_id": msg.get("thread_id"), "group_id": self._group_id or "", } - for uid, proto in list(self._peer_registry().items()): - if uid != self._user_id and proto is not self: + # Per connection, not per account — see the WebRTC path and + # docs/chat-sender-keys.md F7. A person's other devices are recipients. + for proto in list(self._peer_registry().values()): + if proto is not self: try: proto._send(0, broadcast) except Exception: @@ -518,7 +524,7 @@ class _MNPServerProtocol(QuicConnectionProtocol): def connection_lost(self, exc) -> None: if self._user_id: - self._peer_registry().pop(self._user_id, None) + self._peer_registry().pop(self._registry_key, None) for task in list(self._tasks): task.cancel() super().connection_lost(exc) 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 8e357c9..9774831 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -31,6 +31,7 @@ import os import struct import tempfile import time +import uuid from pathlib import Path from typing import Any @@ -78,6 +79,7 @@ from meshbay_common.adminop import ( OP_PHOTO_ROOTS, OP_APP_DIRECTORIES, OP_CHAT_DIRECTORY, + OP_CHAT_EPOCH, OP_CHAT_LINK_PREVIEW, OP_ROOT_ADD, OP_ROOT_REMOVE, @@ -93,9 +95,10 @@ from meshbay_common.device import ( DEVICE_TTL, device_add_transcript, device_code_hash, + device_hello_transcript, device_request_transcript, ) -from meshbay_common.groupbox import PURPOSE_ACK, seal +from meshbay_common.groupbox import PURPOSE_ACK, PURPOSE_CHAT_KEYS, seal from meshbay_common.join import ( JOIN_TTL, ROLE_MEMBER, @@ -103,6 +106,8 @@ from meshbay_common.join import ( join_transcript, ) from meshbay_common.protocol import MNP, chunk_ciphertext, file_chunk_wire +from meshbay_common.chatbox import NONCE_LEN as CHAT_NONCE_LEN, SIG_LEN as CHAT_SIG_LEN +from meshbay_node.chat import FORMAT_PLAIN, FORMAT_SEALED_V1, ReplayedMessage from meshbay_node.transport.wire import index_sync_message from meshbay_node.indexer import GroupIndex from meshbay_node.indexer.indexer import DirectoryIndexer @@ -326,9 +331,27 @@ class WebRTCPeerSession: self._peer_id: str = peer_id self._remote_ip: str = "" 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. + 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. + # + # This is the account's *oldest* live device unless `device_hello` has + # told us better — see _do_device_hello. Treat it as "a device of this + # account", not "the device on this connection", anywhere that has not + # checked `_device_confirmed`. self._pinned_pk: str = "" + # True once this connection proved which device it is. Until then the + # node knows the account and not the key, which is all it ever knew + # before device linking existed. + self._device_confirmed: bool = False # Flow control for video: how many segments the client says it can take. self._stream_credit = 0 self._stream_credit_evt = asyncio.Event() @@ -470,6 +493,8 @@ class WebRTCPeerSession: self._spawn(self._do_device_list(msg)) elif mtype == MNP.DEVICE_REVOKE: self._spawn(self._do_device_revoke(msg)) + elif mtype == MNP.DEVICE_HELLO and self._nonce_node: + self._spawn(self._do_device_hello(msg)) elif mtype == MNP.MEMBER_UPLOAD: self._do_member_upload(msg) elif mtype == MNP.APPS_ENABLED: @@ -492,6 +517,10 @@ class WebRTCPeerSession: self._do_chat_directory(msg) elif mtype == MNP.CHAT_LINK_PREVIEW: self._do_chat_link_preview(msg) + elif mtype == MNP.CHAT_EPOCH: + self._do_chat_epoch(msg) + elif mtype == MNP.CHAT_KEYS_REQ: + self._spawn(self._do_chat_keys_req(msg)) elif mtype == MNP.MEDIA_META_REQ: self._spawn(self._do_media_meta_request(msg)) elif mtype == MNP.SEASON_META_REQ: @@ -743,7 +772,7 @@ class WebRTCPeerSession: self._username = self._pending_username self._spawn(self._load_pinned_pk()) - self._peer_registry()[self._user_id] = self + self._register_peer() node_user_id = self._ctx.get("node_user_id") log.info("WebRTC handshake OK — user=%s group=%s", @@ -827,6 +856,15 @@ class WebRTCPeerSession: # on, which is what it did before this existed. "chat_link_preview": bool( self._group_ctx().get("chat_link_preview", True)), + # Which chat epoch key a client should be sealing under. Inside + # the sealed part of the ack like every other configuration field, + # so it carries an authentication tag from a key the hub does not + # hold — a forged epoch would have a client sealing under a key the + # group has retired. + # + # No `chat_encrypted` beside it: there is no switch. A peer that + # reached this point speaks MNP 2.0, and 2.0 has no plaintext chat. + "chat_epoch": int(self._group_ctx().get("chat_epoch", 0) or 0), # So a client that connects mid-scan shows the indexing state # immediately, instead of waiting for the next periodic # INDEX_PROGRESS push. Never a path or filename — see @@ -1378,6 +1416,79 @@ class WebRTCPeerSession: self._send({"type": MNP.DEVICE_ADD_ACK, "v": MNP_VERSION, "pk_ed25519": pk_ed_b64}) + async def _do_device_hello(self, msg: dict) -> None: + """ + Learn which of this account's devices is on this connection. + + The handshake authenticates a *group membership* (the GEK-HMAC) and an + *account* (the hub's token). It has never authenticated a device, and + while one account meant one key that was the same statement. It stopped + being so on 2026-08-18, and `_load_pinned_pk` — which resolves the + account's oldest live device — has been standing in for the real answer + ever since, including as the recorded uploader of every file. + + What is checked, in order: the key is a live device *of this account* in + the node's own roster (never a token claim — that is + `per-node-identity-v1.md`'s rule), the timestamp is fresh, and the + signature verifies over a transcript naming this node, this group and + this connection's nonce. A key that is merely well-formed proves nothing. + + Idempotent for the same key, refused for a different one: a connection + does not get to change device half way through, which would let one + session's uploads be attributed to two. + """ + roster = self._ctx.get("roster") + if roster is None or not self._user_id: + self._send({"type": "error", "detail": "Roster not available"}) + return + if not self._spend_device_attempt(): + return + + pk_ed_b64 = str(msg.get("pk_ed25519", "")) + ts = int(msg.get("ts", 0) or 0) + if not pk_ed_b64: + self._send({"type": "error", "detail": "Missing device key"}) + return + if self._device_confirmed and pk_ed_b64 != self._pinned_pk: + self._send({"type": "error", + "detail": "This connection is already another device"}) + return + if abs(time.time() - ts) > DEVICE_TTL: + self._send({"type": "error", "detail": "Stale device_hello"}) + return + + device = await roster.find_device(self._user_id, pk_ed_b64) + if device is None: + self._audit("device_hello_refused", pk_ed_b64[:16]) + self._send({"type": "error", + "detail": "Not a device paired here"}) + return + + transcript = device_hello_transcript( + node_pk_b64=self._node_pk_b64(), group_id=self._group_id or "", + user_id=self._user_id, pk_ed25519_b64=pk_ed_b64, + nonce_node=self._nonce_node, ts=ts) + try: + pk = Ed25519PublicKey.from_public_bytes(base64.b64decode(pk_ed_b64)) + except Exception: + self._send({"type": "error", "detail": "Unreadable device key"}) + return + try: + sig = base64.b64decode(msg.get("sig", "")) + except Exception: + sig = b"" + if not self._verify_sig(pk, transcript, sig): + self._audit("device_hello_refused", pk_ed_b64[:16]) + self._send({"type": "error", "detail": "Signature verification failed"}) + return + + self._pinned_pk = pk_ed_b64 + self._device_confirmed = True + log.info("Device identified on connection: user=%s device=%s", + self._user_id[:8], pk_ed_b64[:16]) + self._send({"type": MNP.DEVICE_HELLO_ACK, "v": MNP_VERSION, + "pk_ed25519": pk_ed_b64}) + async def _do_device_list(self, msg: dict) -> None: """This account's devices. Anyone may read their own, nobody else's.""" roster = self._ctx.get("roster") @@ -1398,6 +1509,43 @@ class WebRTCPeerSession: ], }) + async def _new_chat_epoch(self, group_id: str, reason: str) -> None: + """ + Open a chat epoch because the set of devices that may read future + messages just shrank. + + Called on every removal — a member, a device, an unpin — and on group + key rotation, because the operator rotates precisely when someone has + left. It is the exact counterpart of "still rotate the GEK, the + ex-member holds the current one": revocation stops the node handing + over the *next* key, and nothing else takes the current one away. + + Best effort by design: a failure here must never turn a successful + revocation into a refused one — the revocation is the control, and this + is the follow-through. It is logged loudly instead, because an operator + who removed someone needs to know if the chat key did not move. + """ + if not group_id: + return + try: + result = await self._run_op(ops.open_chat_epoch, group_id) + except Exception as e: + log.error("chat: could not open a new epoch for group %s after " + "%s (%s) — the removed party still holds the current " + "chat key", group_id[:8], reason, e) + self._audit("chat_epoch_failed", reason) + return + self._audit("chat_epoch", f"{reason}:{result['epoch']}") + # Everyone still connected picks the new key up without reconnecting. + for session in list( + (self._ctx.get("groups") or {}).get(group_id, {}) + .get("_peers", {}).values()): + try: + session._send({"type": MNP.CHAT_EPOCH_ACK, "v": MNP_VERSION, + "epoch": result["epoch"]}) + except Exception: + pass + async def _do_device_revoke(self, msg: dict) -> None: """ Retire one of this account's devices — a lost laptop. @@ -1446,6 +1594,9 @@ class WebRTCPeerSession: return await roster.revoke_device(self._user_id, target) + # A revoked device holds every chat key it ever received — a lost laptop + # reads the group's chat until the epoch moves. + await self._new_chat_epoch(self._group_id or "", "device_revoke") self._audit("device_revoked", f"{target[:16]} by {signer[:16]}") log.info("Device revoked for %s: %s", self._user_id[:8], target[:16]) self._send({"type": MNP.DEVICE_ADD_ACK, "v": MNP_VERSION, @@ -1769,6 +1920,11 @@ class WebRTCPeerSession: except ops.OpError as e: self._send({"type": "error", "detail": e.message}) return + # The operator is rotating because somebody left, and the chat archive + # key is not derived from the group key — so rotating that one does not + # move this one. Doing both here is what makes "rotate after a removal" + # mean the same thing for chat as it does for files. + await self._new_chat_epoch(pending["subject"], "gek_rotate") self._audit("gek_rotate", pending["subject"]) self._send({ "type": MNP.GEK_ROTATE_ACK, "v": MNP_VERSION, @@ -1806,6 +1962,7 @@ class WebRTCPeerSession: return try: await self._run_op(ops.unpin_member, user_id) + await self._new_chat_epoch(self._group_id or "", "member_unpin") except ops.OpError as e: self._send({"type": "error", "detail": e.message}) return @@ -2280,6 +2437,76 @@ class WebRTCPeerSession: self._broadcast_to_group({"type": MNP.CHAT_LINK_PREVIEW_ACK, "v": MNP_VERSION, "enabled": enabled}) + def _do_chat_epoch(self, msg: dict) -> None: + """ + Open a new chat epoch by hand. Operator only, and signed. + + There is no switch to turn chat encryption on: MNP 2.0 has no plaintext + chat to fall back to. What an operator may want to do deliberately is + move the key on — the same instruction as `gek_rotate`, and signed for + the same reason. The removals that matter (member revoke, member unpin, + device revoke, `gek_rotate`) already open one by themselves. + """ + group_id = str(msg.get("group_id", "")).strip() or self._group_id + if not group_id: + self._send({"type": "error", "detail": "No group on this connection"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + self._issue_admin_challenge(OP_CHAT_EPOCH, group_id, group_id=group_id) + + async def _admin_exec_chat_epoch( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", f"chat_epoch:{pending['subject'][:8]}") + return + try: + result = await self._run_op(ops.open_chat_epoch, pending["subject"]) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + self._audit("chat_epoch", f"manual:{result['epoch']}") + self._broadcast_to_group({"type": MNP.CHAT_EPOCH_ACK, + "v": MNP_VERSION, "epoch": result["epoch"]}) + + async def _do_chat_keys_req(self, msg: dict) -> None: + """ + Hand this member every chat epoch key the group has, sealed. + + Sealed under a group-derived subkey rather than sent in clear: the same + reasoning as the index and the handshake ack, and one step stronger + here, because the payload *is* key material. A peer that has completed + the handshake holds the group key and can open it; anything short of + that gets a ciphertext. + + **Every** live epoch, not just the current one, which is what keeps the + history readable to a member who joined after it was written and to a + device linked this morning. Whether a new member should receive the back + catalogue at all is a policy question with a per-group answer; the shape + is here so that answer can be given without a wire change. + """ + gctx = self._group_ctx() + gek = gctx.get("gek") + if not gek: + self._send({"type": "error", "detail": "Group encryption not initialized"}) + return + try: + keys = await self._run_op(ops.chat_epoch_keys, self._group_id or "") + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + + payload = {"epochs": [{"epoch": k["epoch"], "key": k["key"]} + for k in keys], + "current": keys[-1]["epoch"] if keys else 0} + sealed = seal(gek, PURPOSE_CHAT_KEYS, MNP.CHAT_KEYS_RESP, + self._group_id or "", payload) + self._send({"type": MNP.CHAT_KEYS_RESP, "v": MNP_VERSION, + "group_id": self._group_id or "", **sealed}) + def _broadcast_to_group(self, notice: dict) -> None: """ Tell everyone connected to this group about a setting that changed. @@ -2852,6 +3079,7 @@ class WebRTCPeerSession: try: result = await self._run_op( ops.revoke_member, user_id, self._group_id or "") + await self._new_chat_epoch(self._group_id or "", "member_revoke") except ops.OpError as e: self._send({"type": "error", "detail": e.message}) return @@ -2859,8 +3087,11 @@ class WebRTCPeerSession: # Anyone connected right now keeps the key they already unwrapped; what # they lose is the next one. Rotating it is the operator's call, and the # ack says so rather than implying this undid anything already read. - peer = self._peer_registry().get(user_id) - if peer is not None: + # Every connection that account holds, not "the" one: with device + # linking a person may be connected from several at once, and the + # registry is keyed per connection precisely because it cannot hold + # only one of them. + for peer in self._sessions_of(user_id): try: await peer.close() except Exception: @@ -2961,6 +3192,29 @@ class WebRTCPeerSession: "total_bytes": progress.total_bytes, } + def _register_peer(self) -> None: + """Add this connection to its group's peer set. + + One place decides the key, and it is `_registry_key` — per connection, + never per account. Written as a method so a test drives the real + registration rather than a second copy of this line that agrees with it + by construction. + """ + self._peer_registry()[self._registry_key] = self + + def _unregister_peer(self) -> None: + self._peer_registry().pop(self._registry_key, None) + + def _sessions_of(self, user_id: str) -> list["WebRTCPeerSession"]: + """Every live connection this account holds in this group. + + Never "the" connection: with device linking a person may be connected + from a laptop and a phone at once, and an operation that acts on one of + them at random is a revocation that leaves a session running. + """ + return [s for s in list(self._peer_registry().values()) + if s._user_id == user_id] + def _peer_registry(self) -> dict: """ Connected peers for THIS group only. @@ -3817,22 +4071,65 @@ class WebRTCPeerSession: }) def _do_chat_message(self, msg: dict) -> None: - # Per-group store — see _peer_registry() and finding H1. Reading chat_store - # off the shared transport context sent every group's messages to the first - # group's database, and served them back to anyone on the node. - chat_store = self._group_ctx().get("chat_store") - payload = msg.get("payload", "") + """ + Store one message and hand it to everyone else in this group. + + The node is a relay and an archive here, not a reader: once a group has + chat encryption on, `payload` is a ciphertext it cannot open, and every + decision below is made from fields that stay in clear — which is why + those fields are the ones that must be *authenticated* rather than + merely present. + + `sender_id` comes from the authenticated session and never from the wire + (NS6). What the wire may now assert is the sending *device*, and that is + checked against this connection rather than believed: a member who could + name any device could sign as anyone once receivers verify signatures. + """ + # Per-group store — see _peer_registry() and finding H1. Reading + # chat_store off the shared transport context sent every group's + # messages to the first group's database, and served them back to + # anyone on the node. + gctx = self._group_ctx() + chat_store = gctx.get("chat_store") sender_name = msg.get("sender_name", "") + + # Two shapes, and keeping them apart is what makes this deployable. + # + # A plaintext message is exactly what it has always been: a string in + # `payload`. A sealed one carries its ciphertext in `ct`, beside the + # `nonce`/`device`/`sig` that authenticate it. Putting the ciphertext in + # `payload` instead would have been tidier and wrong: `payload` reaches + # older clients — the UI ships inside the desktop package now, so it can + # be months behind the node — and they would render bytes where they + # expect text. A field they have never heard of is ignored instead. + fmt = int(msg.get("format", 0) or 0) + epoch = int(msg.get("epoch", 0) or 0) + device = msg.get("device") + nonce = msg.get("nonce") + sig = msg.get("sig") + + if fmt == FORMAT_SEALED_V1: + payload = "" + raw = bytes(msg.get("ct") or b"") + else: + payload = msg.get("payload", "") + raw = (payload.encode() if isinstance(payload, str) + else bytes(payload or b"")) + + refusal = self._check_chat_envelope(gctx, fmt, raw, device, nonce, sig) + if refusal: + self._send({"type": "error", "detail": refusal}) + self._audit("chat_refused", refusal) + return + if sender_name: self._user_names()[self._user_id] = sender_name if chat_store: - raw = payload.encode() if isinstance(payload, str) else payload - self._spawn(chat_store.save_message( - sender_id=self._user_id, - iteration=msg.get("iteration", 0), - payload=raw, - thread_id=msg.get("thread_id"), - sender_name=sender_name, + self._spawn(self._store_chat_message( + chat_store, + iteration=msg.get("iteration", 0), payload=raw, + thread_id=msg.get("thread_id"), sender_name=sender_name, + format=fmt, epoch=epoch, device=device, nonce=nonce, sig=sig, )) peers = self._peer_registry() @@ -3843,10 +4140,21 @@ class WebRTCPeerSession: "sender_name": sender_name, "payload": payload, "thread_id": msg.get("thread_id"), - "timestamp": __import__("time").time(), + "timestamp": time.time(), + "format": fmt, + "epoch": epoch, + "device": device, + "nonce": nonce, + "sig": sig, } - for uid, session in list(peers.items()): - if uid != self._user_id and session is not self: + if fmt == FORMAT_SEALED_V1: + broadcast["ct"] = raw + # Excludes this connection, not this account. The sender's other + # devices are ordinary recipients: they did not compose the message and + # have no local echo of it, so skipping them by user_id left a person's + # second device silently missing everything they said from the first. + for session in list(peers.values()): + if session is not self: try: session._send(broadcast) except Exception: @@ -3859,11 +4167,16 @@ class WebRTCPeerSession: self._spawn(hub_ws.send(_json.dumps({ "type": "chat_notify", "group_id": self._group_id, - "sender_name": sender_name, - # Who actually wrote it, from the authenticated session. The - # hub used to fall back to this node's own token subject — - # the operator — so everyone was notified of their own - # messages and the operator was notified of nobody's. + # No sender_name. The body is unreadable to the hub the + # moment a group turns encryption on, and shipping the + # author's display name beside it would leave the hub a + # per-message record of who spoke where — the metadata the + # feature is otherwise about not producing. The hub renders + # "New message in <group>". + # + # `sender_user_id` stays: the hub needs it to not notify + # the author of their own message, and it already knows the + # group's membership. "sender_user_id": self._user_id, }))) except Exception: @@ -3872,6 +4185,66 @@ class WebRTCPeerSession: self._send({"type": "ack", "v": MNP_VERSION}) self._audit("chat_message") + def _check_chat_envelope(self, gctx: dict, fmt: int, ct: bytes, device, + nonce, sig) -> str: + """ + Why this message is refused, or "" to accept it. + + Two rules, and the first is the one that matters: + + **A device may only send as itself.** `device` is what receivers verify + a signature against, so a member free to name another member's key could + be that member to everyone — worse than the node-asserted attribution it + replaces (NS6), not better. The connection has proved which device it is + (`device_hello`), and this must match it. + + **Plaintext is refused, always.** Not "accepts and marks", and not + "unless a switch says otherwise": a member who can post in clear into a + group whose members believe their chat is encrypted is a downgrade, and + C6 is the standing lesson that the bypass left open is the one that gets + used. There is no switch to leave open — MNP 2.0 refuses a 1.x peer at + the handshake, so nothing that reaches here is unable to seal. + + `FORMAT_PLAIN` still exists, because rows written before 2.0 are still + in `chat.db` and still served. It is a *storage* state, never something + this accepts from the wire. + """ + if fmt != FORMAT_SEALED_V1: + return "Chat messages must be encrypted" + + if not (isinstance(device, (bytes, bytearray)) + and isinstance(nonce, (bytes, bytearray)) + and isinstance(sig, (bytes, bytearray))): + return "Sealed chat message is missing its envelope" + if len(nonce) != CHAT_NONCE_LEN or len(sig) != CHAT_SIG_LEN: + return "Sealed chat message has a malformed envelope" + if not ct: + return "Sealed chat message has no ciphertext" + + claimed = base64.b64encode(bytes(device)).decode() + if not self._device_confirmed: + return ("Identify this device before sending chat (device_hello)") + if claimed != self._pinned_pk: + return "That is not the device on this connection" + + return "" + + async def _store_chat_message(self, chat_store, **kwargs) -> None: + """ + Persist one message, treating a replay as already-done. + + A replayed message is a *validly signed* copy of a real one, so nothing + about the signature refuses it; the unique `(device, nonce)` does. It is + logged and dropped rather than raised at the sender: the message it + duplicates is already stored, so there is nothing for anyone to retry. + """ + try: + await chat_store.save_message(sender_id=self._user_id, **kwargs) + except ReplayedMessage: + log.warning("Replayed chat message from %s dropped", + (self._user_id or "?")[:8]) + self._audit("chat_replay_dropped") + def _do_ping(self, msg: dict) -> None: """Answer a liveness probe on an open channel, echoing the caller's token. @@ -3912,20 +4285,49 @@ class WebRTCPeerSession: "type": MNP.CHAT_HISTORY_RESPONSE, "v": MNP_VERSION, "has_more": has_more, - "messages": [ - { - "id": m.id, - "sender_id": m.sender_id, - "sender_name": m.sender_name or names.get(m.sender_id, ""), - "payload": m.payload.decode("utf-8", errors="replace") - if isinstance(m.payload, bytes) else m.payload, - "timestamp": m.timestamp, - "thread_id": m.thread_id, - } - for m in msgs - ], + # `payload` goes out as **bytes**, never decoded here. It used to be + # `.decode("utf-8", errors="replace")`, which substitutes U+FFFD for + # every byte that is not valid UTF-8 — fine while chat was text, and + # silent destruction of a ciphertext. Live messages would have kept + # working (they are relayed, not re-read), so the symptom would have + # been "history won't decrypt", which is the hardest possible place + # to look. msgpack carries `bin` on both sides; the client decides + # how to read it from `format`. + "messages": [self._history_row(m, names) for m in msgs], }) + @staticmethod + def _history_row(m, names: dict) -> dict: + """One stored message on the wire. + + A plaintext row goes out under `payload` as a string, exactly as it + always has — an older client reads this response and must keep working. + A sealed row's ciphertext goes out under `ct` as bytes and `payload` + stays empty: decoding a ciphertext as UTF-8 (which is what this did, + with `errors="replace"`) substitutes U+FFFD for most of it, and the + symptom would have been history that will not decrypt while live + messages worked — the hardest possible place to look. + """ + row = { + "id": m.id, + "sender_id": m.sender_id, + "sender_name": m.sender_name or names.get(m.sender_id, ""), + "timestamp": m.timestamp, + "thread_id": m.thread_id, + "format": m.format, + "epoch": m.epoch, + "device": m.device, + "nonce": m.nonce, + "sig": m.sig, + } + if m.format == FORMAT_SEALED_V1: + row["payload"] = "" + row["ct"] = m.payload + else: + row["payload"] = (m.payload.decode("utf-8", errors="replace") + if isinstance(m.payload, bytes) else m.payload) + return row + def _link_preview_rate_ok(self) -> bool: """ True when this preview fetch is within both the per-connection and the @@ -4226,8 +4628,11 @@ class WebRTCPeerSession: self._send({"type": "error", "detail": "File not found"}) return - has_uploader_pk = bool(entry.uploader_pk) - if not self._has_admin_authority() and not has_uploader_pk: + # An owner is an *account* now, so an entry that records one is + # challengeable even if the device that uploaded it is gone. + has_uploader = bool(entry.uploader_pk + or getattr(entry, "uploader_id", "")) + if not self._has_admin_authority() and not has_uploader: self._send({"type": "error", "detail": "No authorized key for deletion"}) return @@ -4283,13 +4688,66 @@ class WebRTCPeerSession: except Exception: return False + async def _verify_uploader_sig(self, entry, transcript: bytes, + sig: bytes) -> bool: + """ + Whether this signature comes from a live device of the file's uploader. + + Every non-revoked device of `entry.uploader_id` is tried, the same way + `_verify_device_signer` tries every device that may approve a new one. + Two properties worth keeping straight: + + - **Ownership survives device revocation.** A retired laptop's uploads + keep their owner, because the account is what owns them; the revoked + key simply is not among the ones that may act. + - **Ownership survives the account losing every device**, where nothing + verifies here and the operator remains able to delete — which is the + behaviour a group needs when someone leaves. + + Falls back to the recorded `uploader_pk` only when the roster cannot + answer at all (no roster wired, or no `uploader_id` on an entry written + before that field existed). That is the pre-device-linking behaviour, so + an old index does not become undeletable. + """ + roster = self._ctx.get("roster") + uploader_id = getattr(entry, "uploader_id", "") or "" + if roster is not None and uploader_id: + for device in await roster.list_devices(uploader_id): + try: + pk = Ed25519PublicKey.from_public_bytes( + base64.b64decode(device["pk_ed25519"])) + except Exception: + continue + if self._verify_sig(pk, transcript, sig): + return True + return False + + if not entry.uploader_pk: + return False + try: + pk = Ed25519PublicKey.from_public_bytes( + base64.b64decode(entry.uploader_pk)) + except Exception: + return False + return self._verify_sig(pk, transcript, sig) + async def _load_pinned_pk(self) -> None: - """Remember which key this node pinned for the peer we just authenticated.""" + """ + A key this node pinned for the account we just authenticated. + + `get_identity` returns the account's **oldest** live device, which is a + stand-in, not an answer: the handshake never said which device is on + this connection. `device_hello` is the answer, and it arrives later — + so this must never overwrite a confirmed one. It is spawned from + `_complete_handshake` and can therefore finish *after* a fast client has + already identified itself, which is exactly the ordering that would put + the wrong key back. + """ roster = self._ctx.get("roster") - if roster is None or not self._user_id: + if roster is None or not self._user_id or self._device_confirmed: return ident = await roster.get_identity(self._user_id) - if ident: + if ident and not self._device_confirmed: self._pinned_pk = ident["pk_ed25519"] def _is_node_admin(self) -> bool: @@ -4433,6 +4891,9 @@ class WebRTCPeerSession: elif pending["op"] == OP_CHAT_LINK_PREVIEW: self._spawn( self._admin_exec_chat_link_preview(pending, transcript, sig_bytes)) + elif pending["op"] == OP_CHAT_EPOCH: + self._spawn( + self._admin_exec_chat_epoch(pending, transcript, sig_bytes)) elif pending["op"] == OP_ROOT_UPDATE: self._spawn( self._admin_exec_root_update(pending, transcript, sig_bytes)) @@ -4461,18 +4922,23 @@ class WebRTCPeerSession: self._send({"type": "error", "detail": "File not found"}) return - uploader_pk = None - if entry.uploader_pk: - try: - uploader_pk = Ed25519PublicKey.from_public_bytes( - base64.b64decode(entry.uploader_pk)) - except Exception: - uploader_pk = None - - # Node operator, or the user who uploaded this file — verified by the key - # recorded at upload time, never by a JWT claim (the hub controls those). + # Node operator, or the account that uploaded this file — **any of its + # non-revoked devices**, resolved through the node's own roster. + # + # This used to verify against `entry.uploader_pk` alone, the exact key + # that uploaded. Device linking broke that on 2026-08-18 without + # anything failing loudly: a file uploaded from a phone could not be + # deleted from the same person's laptop, and the only symptom was + # "Signature verification failed" on their own file + # (docs/desktop-client-v1.md §4.8 A). + # + # `uploader_pk` is kept, and stops being the authorization key: it is + # now the audit record of *which device* did it. Authorization is by + # account, through the roster — never through a token claim, which is + # the protection `per-node-identity-v1.md` added and which a lookup by + # `uploader_id` in the hub's world would give straight back. if not (await self._verify_admin_sig(transcript, sig) - or self._verify_sig(uploader_pk, transcript, sig)): + or await self._verify_uploader_sig(entry, transcript, sig)): self._send({"type": "error", "detail": "Signature verification failed"}) self._audit("admin_auth_failed", f"file_delete:{file_id[:16]}") return @@ -4941,7 +5407,7 @@ class WebRTCPeerSession: async def close(self) -> None: self._audit("disconnect") if self._user_id: - self._peer_registry().pop(self._user_id, None) + self._unregister_peer() await self.shutdown_tasks() await self._pc.close() diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index fc6c04a..2b99f20 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -334,6 +334,24 @@ def create_ui_app(state: dict) -> FastAPI: async def unpin_member(user_id: str): return await _op(lambda: ops.unpin_member(state, user_id)) + # ── Chat encryption (operator only, localhost) ───────────────────────── + + @app.get("/api/groups/{group_id}/chat") + async def chat_status(group_id: str): + return await _op(lambda: ops.chat_status(state, group_id)) + + @app.post("/api/groups/{group_id}/chat/epoch") + async def rotate_chat_epoch(group_id: str): + return await _op(lambda: ops.open_chat_epoch(state, group_id)) + + @app.post("/api/groups/{group_id}/chat/encrypt-history") + async def encrypt_chat_history(group_id: str): + return await _op(lambda: ops.encrypt_chat_history(state, group_id)) + + @app.post("/api/groups/{group_id}/chat/prune") + async def prune_chat(group_id: str, max_age_days: int): + return await _op(lambda: ops.prune_chat(state, group_id, max_age_days)) + # ── GEK initialization (operator only, localhost) ────────────────────── @app.post("/api/groups/{group_id}/gek") diff --git a/packages/meshbay-node/tests/test_chat_encryption.py b/packages/meshbay-node/tests/test_chat_encryption.py new file mode 100644 index 0000000..ea4de2f --- /dev/null +++ b/packages/meshbay-node/tests/test_chat_encryption.py @@ -0,0 +1,517 @@ +""" +Chat encryption: what the node stores, what it refuses, and what survives. + +Design A of `docs/chat-sender-keys.md`. Every test here is written as "this +does not work" or "this still works after X" — the regressions the plan's +register names, in the order they would bite. + +The load-bearing ones are the last three. Rotation is the failure the design +exists to avoid: a chat key derived from the group key would have made every +message ever sent unreadable on the first `member unpin`, for everybody, +including the operator, and that is the *documented* procedure after removing +someone. Key storage is the failure that would make the whole feature a +decoration. Downgrade is C6's lesson, one feature later. +""" + +import base64 +import time +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_common.chatbox import open_message, seal +from meshbay_common.crypto import generate_gek +from meshbay_common.groupbox import PURPOSE_CHAT_KEYS, unseal +from meshbay_common.protocol import MNP +from meshbay_node import ops +from meshbay_node.bundle_store import BundleStore +from meshbay_node.chat import FORMAT_SEALED_V1, ChatStore +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roster import open_roster +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +from conftest import one_root + +GROUP = "g" * 32 + + +def _device(): + sk = Ed25519PrivateKey.generate() + raw = sk.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw) + return sk, raw, base64.b64encode(raw).decode() + + +@pytest.fixture +async def node(tmp_path): + """A daemon state with the pieces the chat path actually touches.""" + roster = await open_roster(tmp_path) + bundles = BundleStore(tmp_path / "bundles.db") + await bundles.open() + chat = ChatStore(tmp_path / "chat.db") + await chat.open() + + sk_x = Ed25519PrivateKey.generate() # stand-in shape; X25519 below + from cryptography.hazmat.primitives.asymmetric.x25519 import ( + X25519PrivateKey, + ) + sk_x = X25519PrivateKey.generate() + sk_x_raw = sk_x.private_bytes( + serialization.Encoding.Raw, serialization.PrivateFormat.Raw, + serialization.NoEncryption()) + pk_x_raw = sk_x.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw) + + shared = tmp_path / "shared" + shared.mkdir(exist_ok=True) + index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) + gek = generate_gek() + + group_ctx = { + "gek": gek, "index": index, "roots": one_root(shared), + "chat_store": chat, "chat_epoch": 0, + "_peers": {}, + } + state = { + "roster": roster, "bundle_store": bundles, + "sk_x25519_raw": sk_x_raw, "pk_x25519_raw": pk_x_raw, + "groups_ctx": {GROUP: group_ctx}, "node_user_id": "operator", + } + yield {"state": state, "group_ctx": group_ctx, "gek": gek, + "chat": chat, "roster": roster, "bundles": bundles, + "index": index, "tmp_path": tmp_path} + await chat.close() + await bundles.close() + await roster.close() + + +def _session(node, user_id="alice", device_b64=""): + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = {"groups": {GROUP: node["group_ctx"]}, + "daemon_state": node["state"]} + session._group_id = GROUP + session._user_id = user_id + session._username = user_id + session._pinned_pk = device_b64 + session._device_confirmed = bool(device_b64) + session._registry_key = f"conn-{user_id}-{len(node['group_ctx']['_peers'])}" + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + return session + + +async def _drain(session, coro_holder): + """`_spawn` stubbed to await inline, so a test sees the store written.""" + pass + + +def _spawn_inline(session): + import asyncio + + pending = [] + session._spawn = lambda coro: pending.append( + asyncio.get_event_loop().create_task(coro)) + return pending + + +async def _send_sealed(node, session, sk, device_raw, device_b64, text, + epoch=None): + keys = await ops.chat_epoch_keys(node["state"], GROUP) + epoch = epoch or keys[-1]["epoch"] + key = next(k["key"] for k in keys if k["epoch"] == epoch) + env = seal(key, GROUP, epoch, device_b64, device_raw, sk, + {"text": text, "sender_name": session._user_id}) + pending = _spawn_inline(session) + session._do_chat_message({ + "format": FORMAT_SEALED_V1, "epoch": epoch, "device": device_raw, + "ct": env["ct"], "nonce": env["nonce"], "sig": env["sig"], + }) + for task in pending: + await task + return env + + +# ── the archive survives what would destroy it ─────────────────────────────── + +async def test_history_survives_a_group_key_rotation(node): + """ + R1, and the reason Design A exists. + + A chat key derived from the group key would be gone the moment the operator + rotates — which is the documented step after removing a member. Every + message ever sent would become unreadable, for everybody. The epoch key is + wrapped under the group key *at delivery* and never stored under it, so a + rotation is a re-wrap and costs nothing. + """ + await ops.ensure_chat_epoch(node["state"], GROUP) + sk, raw, b64 = _device() + session = _session(node, device_b64=b64) + await _send_sealed(node, session, sk, raw, b64, "before the rotation") + + # Rotate the group key, exactly as the operator does after a removal. + node["group_ctx"]["gek"] = generate_gek() + + keys = await ops.chat_epoch_keys(node["state"], GROUP) + stored = (await node["chat"].get_recent(10))[0] + opened = open_message( + next(k["key"] for k in keys if k["epoch"] == stored.epoch), + GROUP, stored.epoch, b64, stored.nonce, stored.payload) + assert opened["text"] == "before the rotation", ( + "rotating the group key must not make the chat archive unreadable — " + "F4, and the whole reason the epoch key is not derived from it") + + +async def test_a_new_epoch_does_not_orphan_the_old_ones(node): + """ + R2. Opening an epoch stops a removed member reading what comes *next*; it + must leave what they could already read readable to everybody else. + """ + await ops.ensure_chat_epoch(node["state"], GROUP) + sk, raw, b64 = _device() + session = _session(node, device_b64=b64) + await _send_sealed(node, session, sk, raw, b64, "epoch one") + + await ops.open_chat_epoch(node["state"], GROUP) + await _send_sealed(node, session, sk, raw, b64, "epoch two") + + keys = {k["epoch"]: k["key"] + for k in await ops.chat_epoch_keys(node["state"], GROUP)} + assert len(keys) == 2 + texts = [] + for m in await node["chat"].get_recent(10): + texts.append(open_message(keys[m.epoch], GROUP, m.epoch, b64, + m.nonce, m.payload)["text"]) + assert texts == ["epoch one", "epoch two"] + + +async def test_an_epoch_key_is_never_written_in_the_clear(node): + """ + R15. The claim chat encryption makes is against someone who takes the + node's storage *without the keystore password*. An epoch key sitting in a + plaintext SQLite beside chat.db would collapse that to nothing, silently, + and it is the obvious thing to write. + """ + await ops.ensure_chat_epoch(node["state"], GROUP) + keys = await ops.chat_epoch_keys(node["state"], GROUP) + assert keys + + live = keys[-1]["key"] + for path in sorted(node["tmp_path"].rglob("*")): + if not path.is_file(): + continue + assert live not in path.read_bytes(), ( + f"the live chat epoch key appears verbatim in {path.name} — " + "it must be wrapped to the node's own key, as the GEK is") + + +async def test_the_stored_message_contains_neither_text_nor_display_name(node): + """ + What "encrypted at rest" has to mean. The display name is inside the + envelope too: on the wire it is a field any peer can set to anything, and + the node caches it to render history, so leaving it outside would both + leak it and leave spoofing free. + """ + await ops.ensure_chat_epoch(node["state"], GROUP) + sk, raw, b64 = _device() + session = _session(node, device_b64=b64) + await _send_sealed(node, session, sk, raw, b64, "a secret message") + + blob = (node["tmp_path"] / "chat.db").read_bytes() + assert b"a secret message" not in blob + stored = (await node["chat"].get_recent(10))[0] + assert stored.format == FORMAT_SEALED_V1 + assert b"a secret message" not in stored.payload + + +# ── refusals ───────────────────────────────────────────────────────────────── + +async def test_plaintext_is_refused_always(node): + """ + R5 / C6's lesson one feature later, and now unconditional: there is no + switch to leave in the wrong position. A member who can post in clear into + a group whose members believe their chat is encrypted is a downgrade anyone + could ask for. + """ + await ops.ensure_chat_epoch(node["state"], GROUP) + session = _session(node) + _spawn_inline(session) + session._do_chat_message({"payload": "in the clear", "sender_name": "alice"}) + + assert session.sent[-1]["type"] == "error" + assert await node["chat"].message_count() == 0 + + +async def test_there_is_no_setting_that_re_enables_plaintext(node): + """ + The switch is gone, not defaulted. A `chat_encrypted` in the group context + — left by an older node's roster row, or invented by anything reading one — + must not be consulted, or the bypass is back with a name. + """ + node["group_ctx"]["chat_encrypted"] = False + await ops.ensure_chat_epoch(node["state"], GROUP) + session = _session(node) + _spawn_inline(session) + session._do_chat_message({"payload": "in the clear", "sender_name": "alice"}) + + assert session.sent[-1]["type"] == "error" + assert await node["chat"].message_count() == 0 + + source = (Path(__file__).parent.parent / "src" / "meshbay_node" + / "transport" / "webrtc_server.py").read_text(encoding="utf-8") + assert 'get("chat_encrypted"' not in source, ( + "nothing may read a chat_encrypted setting — there is no switch") + + +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. + """ + await ops.ensure_chat_epoch(node["state"], GROUP) + _sk_alice, raw_alice, b64_alice = _device() + sk_mallory, raw_mallory, b64_mallory = _device() + + session = _session(node, user_id="mallory", device_b64=b64_mallory) + keys = await ops.chat_epoch_keys(node["state"], GROUP) + epoch, key = keys[-1]["epoch"], keys[-1]["key"] + # Correctly sealed and correctly signed — by Mallory, claiming to be Alice. + env = seal(key, GROUP, epoch, b64_alice, raw_alice, sk_mallory, + {"text": "not from alice"}) + _spawn_inline(session) + session._do_chat_message({ + "format": FORMAT_SEALED_V1, "epoch": epoch, "device": raw_alice, + "ct": env["ct"], "nonce": env["nonce"], "sig": env["sig"], + }) + + assert session.sent[-1]["type"] == "error" + assert await node["chat"].message_count() == 0 + + +async def test_a_signed_message_cannot_be_replayed(node): + """ + A replay is a *validly signed* copy of a real message, so nothing about + the signature refuses it. The unique (device, nonce) does — and the nonce + is already required to be unique for AES-GCM to be safe, so it costs + nothing to make it a key. + """ + await ops.ensure_chat_epoch(node["state"], GROUP) + sk, raw, b64 = _device() + session = _session(node, device_b64=b64) + env = await _send_sealed(node, session, sk, raw, b64, "said once") + assert await node["chat"].message_count() == 1 + + keys = await ops.chat_epoch_keys(node["state"], GROUP) + pending = _spawn_inline(session) + session._do_chat_message({ + "format": FORMAT_SEALED_V1, "epoch": keys[-1]["epoch"], "device": raw, + "ct": env["ct"], "nonce": env["nonce"], "sig": env["sig"], + }) + for task in pending: + await task + assert await node["chat"].message_count() == 1, ( + "a replayed message must not be stored twice") + + +async def test_an_unidentified_connection_cannot_send_a_signed_message(node): + """ + `device_hello` is what makes "that is not the device on this connection" + checkable at all. Without it the node knows the account and not the key, + and a `device` field would be an assertion nobody verified. + """ + await ops.ensure_chat_epoch(node["state"], GROUP) + sk, raw, b64 = _device() + session = _session(node) # no device_hello + keys = await ops.chat_epoch_keys(node["state"], GROUP) + epoch, key = keys[-1]["epoch"], keys[-1]["key"] + env = seal(key, GROUP, epoch, b64, raw, sk, {"text": "x"}) + _spawn_inline(session) + session._do_chat_message({ + "format": FORMAT_SEALED_V1, "epoch": epoch, "device": raw, + "ct": env["ct"], "nonce": env["nonce"], "sig": env["sig"], + }) + assert session.sent[-1]["type"] == "error" + + +# ── key delivery ───────────────────────────────────────────────────────────── + +async def test_the_keys_are_delivered_sealed_under_the_group_key(node): + """ + Sealed for the same reason the index and the ack are, one step stronger: + the payload *is* key material. A peer that has completed the handshake + holds the group key and can open it; anything short of that gets a + ciphertext. + """ + await ops.ensure_chat_epoch(node["state"], GROUP) + await ops.open_chat_epoch(node["state"], GROUP) + session = _session(node) + await session._do_chat_keys_req({}) + + resp = session.sent[-1] + assert resp["type"] == MNP.CHAT_KEYS_RESP + assert "epochs" not in resp, "the keys must not travel in clear" + payload = unseal(node["gek"], PURPOSE_CHAT_KEYS, MNP.CHAT_KEYS_RESP, + GROUP, resp) + assert [e["epoch"] for e in payload["epochs"]] == [1, 2] + assert payload["current"] == 2 + for e in payload["epochs"]: + assert len(e["key"]) == 32 + + +async def test_every_epoch_is_delivered_not_just_the_current_one(node): + """ + R2 again, from the delivery side: this is what lets a device linked this + morning read a conversation from last year. + """ + await ops.ensure_chat_epoch(node["state"], GROUP) + for _ in range(3): + await ops.open_chat_epoch(node["state"], GROUP) + session = _session(node) + await session._do_chat_keys_req({}) + payload = unseal(node["gek"], PURPOSE_CHAT_KEYS, MNP.CHAT_KEYS_RESP, + GROUP, session.sent[-1]) + assert [e["epoch"] for e in payload["epochs"]] == [1, 2, 3, 4] + + +# ── epochs move when access shrinks ───────────────────────────────────────── + +async def test_revoking_a_device_opens_a_new_epoch(node): + """ + A revoked device holds every chat key it ever received. Revocation stops + the node handing over the *next* one; nothing else takes the current one + away — the exact counterpart of "still rotate the GEK". + """ + await ops.ensure_chat_epoch(node["state"], GROUP) + before = await node["bundles"].latest_chat_epoch(GROUP) + session = _session(node) + await session._new_chat_epoch(GROUP, "device_revoke") + assert await node["bundles"].latest_chat_epoch(GROUP) == before + 1 + + +async def test_a_group_always_gets_an_epoch(node): + """ + Chat is always encrypted, so a group with no epoch key is a group nobody + can speak in. `ensure_chat_epoch` is what the daemon calls at group load — + at start-up, where a failure lands in the log the operator is already + reading rather than on somebody's first message. + """ + assert await node["bundles"].latest_chat_epoch(GROUP) == 0 + epoch = await ops.ensure_chat_epoch(node["state"], GROUP) + assert epoch == 1 + # Idempotent: called at every group load, and a second epoch per restart + # would be a key nobody needed and the node keeps for ever. + assert await ops.ensure_chat_epoch(node["state"], GROUP) == 1 + + +async def test_an_epoch_key_is_never_deleted(node): + """ + Nothing in the system removes an epoch key, and nothing may: the messages + sealed under it become unreadable the moment it goes, for everybody. The + only operation that touches the table adds a row. + """ + await ops.ensure_chat_epoch(node["state"], GROUP) + sk, raw, b64 = _device() + session = _session(node, device_b64=b64) + await _send_sealed(node, session, sk, raw, b64, "still readable") + await ops.open_chat_epoch(node["state"], GROUP) + await ops.prune_chat(node["state"], GROUP, 3650) + + keys = await ops.chat_epoch_keys(node["state"], GROUP) + assert [k["epoch"] for k in keys] == [1, 2] + + source = (Path(__file__).parent.parent / "src" / "meshbay_node" + / "bundle_store.py").read_text(encoding="utf-8") + assert "DELETE FROM chat_epochs" not in source + assert "INSERT OR REPLACE INTO chat_epochs" not in source, ( + "an epoch key is written once — REPLACE would destroy the history " + "sealed under it, with no error anywhere") + + +# ── the explicit history migration, and retention ─────────────────────────── + +async def test_encrypt_history_converts_the_old_plaintext(node): + """ + The migration for a node that ran before MNP 2.0. + + The plaintext row is written straight into the store, because that is the + only way one can exist now: `_do_chat_message` refuses plaintext outright. + Such rows are the ones still readable off a stolen disk, and the node can + convert them only because it holds them in the clear — it is the last + moment at which anyone can. + """ + node["state"]["sk_node"] = Ed25519PrivateKey.generate() + await node["chat"].save_message( + sender_id="alice", iteration=0, payload=b"written in the clear", + sender_name="alice") + await ops.ensure_chat_epoch(node["state"], GROUP) + + result = await ops.encrypt_chat_history(node["state"], GROUP) + + assert result["converted"] == 1 + stored = (await node["chat"].get_recent(10))[0] + assert stored.format == FORMAT_SEALED_V1 + assert b"written in the clear" not in stored.payload + assert stored.sender_name == "", ( + "the display name moves inside the envelope — leaving it would keep in " + "the clear the one field the sealing was for") + + keys = {k["epoch"]: k["key"] + for k in await ops.chat_epoch_keys(node["state"], GROUP)} + device_b64 = base64.b64encode(stored.device).decode() + opened = open_message(keys[stored.epoch], GROUP, stored.epoch, device_b64, + stored.nonce, stored.payload) + assert opened["text"] == "written in the clear" + assert opened["sender_name"] == "alice" + assert opened["migrated"] is True, ( + "a migrated message carries the node's word for who wrote it, which is " + "all it ever carried — that has to be visible, not inferred") + + +async def test_encrypt_history_backs_the_database_up_first(node): + node["state"]["sk_node"] = Ed25519PrivateKey.generate() + await node["chat"].save_message( + sender_id="alice", iteration=0, payload=b"one", sender_name="alice") + await ops.ensure_chat_epoch(node["state"], GROUP) + + result = await ops.encrypt_chat_history(node["state"], GROUP) + + from pathlib import Path + backup = Path(result["backup"]) + assert backup.exists() and backup.stat().st_size > 0 + assert b"one" in backup.read_bytes(), ( + "the backup is taken before the rewrite, or it is not a backup") + + +async def test_retention_deletes_messages_and_never_epoch_keys(node): + """ + R16. An epoch whose messages have all aged out costs 32 bytes; deleting it + would make anything still stored under it unreadable. + """ + await ops.ensure_chat_epoch(node["state"], GROUP) + sk, raw, b64 = _device() + session = _session(node, device_b64=b64) + await _send_sealed(node, session, sk, raw, b64, "old news") + + # Age it past the cutoff. + await node["chat"]._db.execute( + "UPDATE messages SET timestamp = ?", (time.time() - 40 * 86400,)) + await node["chat"].commit() + + result = await ops.prune_chat(node["state"], GROUP, 30) + + assert result["removed"] == 1 + assert await node["chat"].message_count() == 0 + assert await ops.chat_epoch_keys(node["state"], GROUP), ( + "retention deletes messages, never keys") + + +async def test_retention_refuses_a_zero_day_window(node): + """`prune 0` would delete the whole conversation and read as a typo.""" + with pytest.raises(ops.OpError): + await ops.prune_chat(node["state"], GROUP, 0) diff --git a/packages/meshbay-node/tests/test_chat_history_binary.py b/packages/meshbay-node/tests/test_chat_history_binary.py new file mode 100644 index 0000000..18efbf5 --- /dev/null +++ b/packages/meshbay-node/tests/test_chat_history_binary.py @@ -0,0 +1,181 @@ +""" +A ciphertext must survive the history path. + +`_send_chat_history` used to put every stored payload through +`.decode("utf-8", errors="replace")`, which substitutes U+FFFD for every byte +that is not valid UTF-8 — i.e. for most of a ciphertext. Live messages are +relayed rather than re-read, so they would have kept working: the symptom would +have been "history will not decrypt" and nothing else, which is the hardest +possible place to look for a wire-format error. + +The fix keeps plaintext exactly where it has always been (a string in +`payload`, which older clients read) and gives ciphertext its own `ct` field. +That way this is not a compatibility break either — `docs/chat-sender-keys.md` +R3. +""" + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_common.protocol import MNP +from meshbay_node.chat import FORMAT_PLAIN, FORMAT_SEALED_V1, ChatStore +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +from conftest import one_root + +GROUP = "g" * 32 + +# Deliberately not valid UTF-8: a lone continuation byte, an over-long form and +# a bare 0xff, which is what a random AES-GCM ciphertext is full of. +CIPHERTEXT = bytes([0x80, 0xff, 0xc0, 0x80, 0xfe, 0x00, 0x41, 0xed, 0xa0, 0x80]) + + +@pytest.fixture +async def store(tmp_path): + s = ChatStore(tmp_path / "chat.db") + await s.open() + yield s + await s.close() + + +def _session(store, tmp_path): + index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) + shared = tmp_path / "shared" + shared.mkdir(exist_ok=True) + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = {"groups": {GROUP: { + "index": index, "roots": one_root(shared), "chat_store": store}}} + session._group_id = GROUP + session._user_id = "alice" + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + return session + + +async def test_a_ciphertext_survives_the_history_path(store, tmp_path): + await store.save_message( + sender_id="alice", iteration=0, payload=CIPHERTEXT, + format=FORMAT_SEALED_V1, epoch=1, device=b"\x01" * 32, + nonce=b"\x02" * 12, sig=b"\x03" * 64) + + session = _session(store, tmp_path) + await session._send_chat_history(store, None, 50) + + resp = session.sent[-1] + assert resp["type"] == MNP.CHAT_HISTORY_RESPONSE + row = resp["messages"][0] + assert row["ct"] == CIPHERTEXT, ( + "the ciphertext must come back byte for byte — decoded as UTF-8 with " + "errors='replace' it comes back as U+FFFD and nothing decrypts") + assert row["format"] == FORMAT_SEALED_V1 + assert row["epoch"] == 1 + assert row["nonce"] == b"\x02" * 12 + assert row["sig"] == b"\x03" * 64 + + +async def test_plaintext_history_keeps_the_shape_older_clients_read( + store, tmp_path): + """ + The compatibility half. The UI ships inside the desktop package now, so a + client can be months behind the node; a plaintext message must still arrive + as a string under `payload`, exactly as it always has. + """ + await store.save_message(sender_id="alice", iteration=0, + payload="bonjour ç'est moi".encode()) + + session = _session(store, tmp_path) + await session._send_chat_history(store, None, 50) + + row = session.sent[-1]["messages"][0] + assert row["payload"] == "bonjour ç'est moi" + assert isinstance(row["payload"], str) + assert row["format"] == FORMAT_PLAIN + assert "ct" not in row + + +async def test_a_mixed_history_reads_both_ways(store, tmp_path): + """ + R4: rows written before a group turned encryption on keep rendering. The + switch never rewrites anything, so every group that turns it on has a + history of both kinds for ever. + """ + await store.save_message(sender_id="alice", iteration=0, payload=b"before") + await store.save_message( + sender_id="alice", iteration=0, payload=CIPHERTEXT, + format=FORMAT_SEALED_V1, epoch=1, device=b"\x01" * 32, + nonce=b"\x02" * 12, sig=b"\x03" * 64) + + session = _session(store, tmp_path) + await session._send_chat_history(store, None, 50) + + rows = session.sent[-1]["messages"] + assert [r["format"] for r in rows] == [FORMAT_PLAIN, FORMAT_SEALED_V1] + assert rows[0]["payload"] == "before" + assert rows[1]["ct"] == CIPHERTEXT + + +async def test_an_existing_database_opens_and_keeps_its_rows(tmp_path): + """ + The migration, from the only angle that matters: a chat.db written before + the new columns existed must open, keep every row, and read back as + plaintext. `CREATE TABLE IF NOT EXISTS` adds no column to a table that is + already there — the same trap `create_all()` is recorded for on the hub. + """ + import aiosqlite + + path = tmp_path / "old_chat.db" + async with aiosqlite.connect(str(path)) as db: + await db.execute( + "CREATE TABLE messages (id INTEGER PRIMARY KEY AUTOINCREMENT, " + "sender_id TEXT NOT NULL, iteration INTEGER NOT NULL, " + "payload BLOB NOT NULL, timestamp REAL NOT NULL, " + "thread_id TEXT DEFAULT NULL, sender_name TEXT DEFAULT '')") + await db.execute( + "INSERT INTO messages (sender_id, iteration, payload, timestamp) " + "VALUES ('alice', 0, ?, 1700000000.0)", (b"an old message",)) + await db.commit() + + store = ChatStore(path) + await store.open() + try: + rows = await store.get_recent(10) + assert len(rows) == 1 + assert rows[0].payload == b"an old message" + assert rows[0].format == FORMAT_PLAIN + assert rows[0].epoch == 0 + assert rows[0].device is None + # And it is still writable, including with the new columns. + await store.save_message( + sender_id="bob", iteration=0, payload=CIPHERTEXT, + format=FORMAT_SEALED_V1, epoch=1, device=b"\x09" * 32, + nonce=b"\x08" * 12, sig=b"\x07" * 64) + assert await store.message_count() == 2 + finally: + await store.close() + + +async def test_opening_twice_keeps_every_column(tmp_path): + """ + The migrations are swallowed per statement, not per batch: one shared + `try` would stop at the first already-present column and silently skip + every later one, so a node upgraded twice would be missing the newest + fields with nothing to show for it. + """ + path = tmp_path / "twice.db" + for _ in range(2): + store = ChatStore(path) + await store.open() + await store.close() + + store = ChatStore(path) + await store.open() + try: + await store.save_message( + sender_id="alice", iteration=0, payload=CIPHERTEXT, + format=FORMAT_SEALED_V1, epoch=3, device=b"\x01" * 32, + nonce=b"\x02" * 12, sig=b"\x03" * 64) + row = (await store.get_recent(1))[0] + assert row.epoch == 3 and row.sig == b"\x03" * 64 + finally: + await store.close() diff --git a/packages/meshbay-node/tests/test_chat_multidevice.py b/packages/meshbay-node/tests/test_chat_multidevice.py new file mode 100644 index 0000000..d718b2a --- /dev/null +++ b/packages/meshbay-node/tests/test_chat_multidevice.py @@ -0,0 +1,160 @@ +""" +One account, several devices, on one node. + +Device linking (2026-08-18) made `identities` a table keyed by +`(user_id, pk_ed25519)`, so a person legitimately holds several keys here. The +chat path never followed: the peer registry was keyed by `user_id`, so the +second connection of one account **evicted the first**, and the broadcast loop +skipped recipients by account, so a person's own other devices never received +what they said. + +Neither shows up as an error anywhere. The first is a message that silently +reaches nobody after a second device connects and disconnects; the second is a +phone that never shows what was typed on the laptop. Both are +`docs/chat-sender-keys.md` F7, and both are the same "keyed by account where it +should be keyed by connection" mistake as `pin_identity`'s old INSERT OR REPLACE. +""" + +from pathlib import Path + +import base64 +import hashlib + +from aiortc import RTCPeerConnection +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +from conftest import one_root + + +def _sealed(device_raw: bytes, text: bytes = b"ciphertext") -> dict: + """A well-formed sealed envelope. + + The bytes are not a real ciphertext and do not need to be: the node never + opens one. What it *does* check is the envelope's shape and that the device + is the connection's own, and going through the real `_do_chat_message` + rather than around it is the point — these tests are about delivery, and + delivery now runs after that check. + """ + return {"format": 1, "epoch": 1, "device": device_raw, "ct": text, + "nonce": b"\x02" * 12, "sig": b"\x03" * 64} + + +def _session(ctx: dict, user_id: str, group_id: str) -> WebRTCPeerSession: + """A peer session with only what the chat path touches wired up. + + Built through the real `__init__` — with a bare RTCPeerConnection, which + costs under a millisecond and opens no socket — so `_registry_key` is the + one production assigns. Constructing it in the test instead would make + these tests agree with the fix by construction, which is precisely the + trap the repo's own notes record. + """ + session = WebRTCPeerSession(pc=RTCPeerConnection(), node_ctx=ctx) + session._group_id = group_id + session._user_id = user_id + session._username = user_id + # Each connection is a distinct device of that account — which is the whole + # subject here, and what `_check_chat_envelope` compares a message against. + session._pinned_pk = base64.b64encode(_device_raw(session)).decode() + session._device_confirmed = True + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + session._spawn = lambda coro: coro.close() + return session + + +def _ctx(tmp_path: Path, group_id: str) -> dict: + index = GroupIndex(group_id=group_id, sk_node=Ed25519PrivateKey.generate()) + root = tmp_path / "shared" + root.mkdir(exist_ok=True) + return {"groups": {group_id: { + "index": index, "roots": one_root(root), "chat_store": None, + }}} + + +GROUP = "g" * 32 + + +def _device_raw(session) -> bytes: + """A stable 32-byte stand-in for this connection's device key. + + Derived from the registry key, so two sessions of one account get two + devices — which is exactly the situation being tested, and a shared one + would make the envelope check pass for the wrong reason. + """ + return hashlib.sha256(session._registry_key.encode()).digest() + + +def test_two_devices_of_one_account_both_stay_registered(tmp_path): + """ + F7: keyed by `user_id`, the second device overwrote the first, and closing + either then removed the other's entry — so one person's two devices could + never both be reachable. + """ + ctx = _ctx(tmp_path, GROUP) + laptop = _session(ctx, "alice", GROUP) + phone = _session(ctx, "alice", GROUP) + + laptop._register_peer() + phone._register_peer() + + registry = laptop._peer_registry() + assert len(registry) == 2, ( + "one account's two devices must both be in the registry; keyed by " + "user_id the second silently replaced the first") + assert set(registry.values()) == {laptop, phone} + + +def test_a_message_reaches_the_senders_other_device(tmp_path): + """ + F7, the visible half: the broadcast excluded recipients whose `user_id` + matched the sender's, so everything typed on the laptop was missing from + the phone — with no error and no way to notice but to hold both. + """ + ctx = _ctx(tmp_path, GROUP) + laptop = _session(ctx, "alice", GROUP) + phone = _session(ctx, "alice", GROUP) + bob = _session(ctx, "bob", GROUP) + for s in (laptop, phone, bob): + s._register_peer() + laptop._user_names = lambda: ctx["groups"][GROUP].setdefault("_names", {}) + + laptop._do_chat_message(_sealed(_device_raw(laptop), b"hello-ciphertext")) + + def chats(session): + return [m for m in session.sent if m.get("type") == "chat_msg"] + + assert len(chats(phone)) == 1, ( + "the sender's other device is an ordinary recipient — it composed " + "nothing and has no local echo to fall back on") + assert chats(phone)[0]["ct"] == b"hello-ciphertext" + assert len(chats(bob)) == 1 + assert chats(laptop) == [], "the composing connection must not echo to itself" + + +def test_closing_one_device_leaves_the_other_connected(tmp_path): + """ + The teardown half. `close()` popped `self._user_id`, so the phone + disconnecting unregistered the laptop, which then received nothing for the + rest of its session while still reading as connected. + """ + ctx = _ctx(tmp_path, GROUP) + laptop = _session(ctx, "alice", GROUP) + phone = _session(ctx, "alice", GROUP) + for s in (laptop, phone): + s._register_peer() + + phone._unregister_peer() + + assert laptop._registry_key in laptop._peer_registry() + assert len(laptop._peer_registry()) == 1 + + +def test_registry_key_is_per_connection_not_per_account(tmp_path): + """The property the two tests above depend on, asserted directly.""" + ctx = _ctx(tmp_path, GROUP) + a = _session(ctx, "alice", GROUP) + b = _session(ctx, "alice", GROUP) + assert a._registry_key != b._registry_key diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py index cf91564..6f43772 100644 --- a/packages/meshbay-node/tests/test_cli_dispatch.py +++ b/packages/meshbay-node/tests/test_cli_dispatch.py @@ -49,6 +49,11 @@ VERBS = [ ["file", "list"], ["file", "rm", "abc", "--yes"], ["video", "rematch", "--yes"], + ["chat", "status"], + ["chat", "rotate"], + ["chat", "encrypt-history", "--yes"], + ["chat", "prune", "30"], + ["chat", "prune"], # missing days: usage, then exit ["denylist", "show"], ["denylist", "clear", "--yes"], ["stun", "list"], @@ -78,6 +83,12 @@ def stub_daemon(monkeypatch, tmp_path): "user_id": "u", "authorized_members": 0, "errors": [], "name": "g", "group_id": "g", "shared_dir": str(tmp_path), "config": str(tmp_path / "node.toml"), + # Chat encryption: the switch's answer, the epoch a rotation + # opened, and what a history re-encryption reports. + "enabled": False, "epoch": 1, "converted": 0, + "backup": str(tmp_path / "chat.db.bak"), + "encrypted": False, "plaintext_messages": 0, + "encrypted_messages": 0, "max_age_days": 30, } monkeypatch.setattr(daemon_mod, "_daemon_api", fake_api) diff --git a/packages/meshbay-node/tests/test_device_on_connection.py b/packages/meshbay-node/tests/test_device_on_connection.py new file mode 100644 index 0000000..3da8a8c --- /dev/null +++ b/packages/meshbay-node/tests/test_device_on_connection.py @@ -0,0 +1,287 @@ +""" +Which device is on this connection, and what depends on knowing. + +The MNP handshake authenticates a *group membership* (the GEK-HMAC) and an +*account* (the hub's token). It has never authenticated a device. While one +person meant one key on a node those were the same statement; device linking +(2026-08-18) ended that, and two things were left resolving "the account's +oldest live device" and calling it the answer: + + * `_load_pinned_pk`, whose result is recorded as `entry.uploader_pk` on every + upload — so a phone's uploads were attributed to a laptop; + * `_admin_exec_file_delete`, which authorized deletion against **that exact + key** — so a person could not delete their own file from their other device, + and the only symptom was "Signature verification failed" on their own upload + (`docs/desktop-client-v1.md` §4.8 A). + +`device_hello` closes the first: additive, signed, refused unless the key is a +live device *of this account in the node's own roster*. The second is closed by +authorizing against the account rather than the key. +""" + +import base64 +import time + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_common.crypto import pk_to_b64 +from meshbay_common.device import device_hello_transcript +from meshbay_common.protocol import MNP +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roster import open_roster +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +from conftest import one_root + +GROUP = "g" * 32 +NONCE = b"\x11" * 32 + + +@pytest.fixture +async def roster(tmp_path): + r = await open_roster(tmp_path) + yield r + await r.close() + + +def _keys(): + sk_ed = Ed25519PrivateKey.generate() + sk_x = Ed25519PrivateKey.generate() # stand-in; only its b64 is used + return sk_ed, pk_to_b64(sk_ed.public_key()), pk_to_b64(sk_x.public_key()) + + +def _session(tmp_path, roster, user_id="alice"): + shared = tmp_path / "shared" + shared.mkdir(exist_ok=True) + index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = { + "roots": one_root(shared), "index": index, "sk_node": index.sk_node, + "roster": roster, + "groups": {GROUP: {"gek": b"\x01" * 32, "index": index, + "roots": one_root(shared)}}, + } + session._group_id = GROUP + session._user_id = user_id + session._username = user_id + session._pinned_pk = "" + session._device_confirmed = False + session._nonce_node = NONCE + session._remote_ip = "" + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + return session + + +async def _hello(session, sk, pk_ed, *, ts=None, user_id=None): + ts = int(time.time()) if ts is None else ts + transcript = device_hello_transcript( + node_pk_b64=session._node_pk_b64(), group_id=session._group_id, + user_id=user_id or session._user_id, pk_ed25519_b64=pk_ed, + nonce_node=NONCE, ts=ts) + await session._do_device_hello({ + "pk_ed25519": pk_ed, "ts": ts, + "sig": base64.b64encode(sk.sign(transcript)).decode(), + }) + + +def _last(session): + return session.sent[-1] if session.sent else {} + + +# ── device_hello ───────────────────────────────────────────────────────────── + +async def test_a_pinned_device_identifies_itself(tmp_path, roster): + sk_a, pk_ed_a, pk_x_a = _keys() + sk_b, pk_ed_b, pk_x_b = _keys() + await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code") + await roster.pin_identity("alice", "alice", pk_ed_b, pk_x_b, via="device") + + session = _session(tmp_path, roster) + await _hello(session, sk_b, pk_ed_b) + + assert _last(session)["type"] == MNP.DEVICE_HELLO_ACK + assert session._pinned_pk == pk_ed_b, ( + "the connection must be the device that signed, not the account's " + "oldest key") + assert session._device_confirmed is True + + +async def test_a_key_this_node_never_pinned_is_refused(tmp_path, roster): + sk_a, pk_ed_a, pk_x_a = _keys() + await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code") + sk_x, pk_ed_x, _ = _keys() + + session = _session(tmp_path, roster) + await _hello(session, sk_x, pk_ed_x) + + assert _last(session)["type"] == "error" + assert session._device_confirmed is False + + +async def test_a_revoked_device_cannot_identify_itself(tmp_path, roster): + sk_a, pk_ed_a, pk_x_a = _keys() + sk_b, pk_ed_b, pk_x_b = _keys() + await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code") + await roster.pin_identity("alice", "alice", pk_ed_b, pk_x_b, via="device") + await roster.revoke_device("alice", pk_ed_b) + + session = _session(tmp_path, roster) + await _hello(session, sk_b, pk_ed_b) + + assert _last(session)["type"] == "error", ( + "a revoked key must stay refused — that is why revocation marks the " + "row instead of deleting it") + + +async def test_another_accounts_device_cannot_identify_here(tmp_path, roster): + """The roster lookup is scoped to *this* account, never to the key alone.""" + sk_a, pk_ed_a, pk_x_a = _keys() + sk_m, pk_ed_m, pk_x_m = _keys() + await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code") + await roster.pin_identity("mallory", "mallory", pk_ed_m, pk_x_m, via="code") + + session = _session(tmp_path, roster, user_id="alice") + await _hello(session, sk_m, pk_ed_m) + + assert _last(session)["type"] == "error" + + +async def test_a_signature_for_another_connection_does_not_transfer( + tmp_path, roster): + """`nonce_node` binds the statement to one connection (L4).""" + sk_a, pk_ed_a, pk_x_a = _keys() + await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code") + + session = _session(tmp_path, roster) + ts = int(time.time()) + transcript = device_hello_transcript( + node_pk_b64=session._node_pk_b64(), group_id=GROUP, + user_id="alice", pk_ed25519_b64=pk_ed_a, + nonce_node=b"\x99" * 32, ts=ts) # another connection's nonce + await session._do_device_hello({ + "pk_ed25519": pk_ed_a, "ts": ts, + "sig": base64.b64encode(sk_a.sign(transcript)).decode(), + }) + + assert _last(session)["type"] == "error" + + +async def test_a_stale_hello_is_refused(tmp_path, roster): + sk_a, pk_ed_a, pk_x_a = _keys() + await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code") + session = _session(tmp_path, roster) + await _hello(session, sk_a, pk_ed_a, ts=int(time.time()) - 3600) + assert _last(session)["type"] == "error" + + +async def test_a_connection_cannot_become_a_second_device(tmp_path, roster): + """ + Both keys are legitimately this account's, and it is still refused: one + connection's uploads must be attributable to one device. + """ + sk_a, pk_ed_a, pk_x_a = _keys() + sk_b, pk_ed_b, pk_x_b = _keys() + await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code") + await roster.pin_identity("alice", "alice", pk_ed_b, pk_x_b, via="device") + + session = _session(tmp_path, roster) + await _hello(session, sk_a, pk_ed_a) + assert _last(session)["type"] == MNP.DEVICE_HELLO_ACK + await _hello(session, sk_b, pk_ed_b) + assert _last(session)["type"] == "error" + assert session._pinned_pk == pk_ed_a + + +async def test_the_late_roster_load_does_not_undo_a_confirmed_device( + tmp_path, roster): + """ + `_load_pinned_pk` is spawned at handshake and can finish *after* a fast + client has identified itself. It must not put the account's oldest key back + — a race that would have been intermittent and attributed to nothing. + """ + sk_a, pk_ed_a, pk_x_a = _keys() + sk_b, pk_ed_b, pk_x_b = _keys() + await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code") + await roster.pin_identity("alice", "alice", pk_ed_b, pk_x_b, via="device") + + session = _session(tmp_path, roster) + await _hello(session, sk_b, pk_ed_b) + await session._load_pinned_pk() # arrives late + + assert session._pinned_pk == pk_ed_b + + +# ── deletion is authorized by account, not by the exact device ─────────────── + +class _Entry: + def __init__(self, uploader_id="", uploader_pk=""): + self.uploader_id = uploader_id + self.uploader_pk = uploader_pk + + +async def test_a_second_device_can_delete_the_first_devices_upload( + tmp_path, roster): + """ + §4.8 A. Alice uploads from her phone and deletes from her desktop. Before + the fix this failed with "Signature verification failed" on her own file. + """ + sk_phone, pk_phone, pk_x_phone = _keys() + sk_desk, pk_desk, pk_x_desk = _keys() + await roster.pin_identity("alice", "alice", pk_phone, pk_x_phone, via="code") + await roster.pin_identity("alice", "alice", pk_desk, pk_x_desk, via="device") + + session = _session(tmp_path, roster) + entry = _Entry(uploader_id="alice", uploader_pk=pk_phone) + transcript = b"delete-this-file" + + assert await session._verify_uploader_sig( + entry, transcript, sk_desk.sign(transcript)) is True + + +async def test_a_stranger_still_cannot_delete_someone_elses_upload( + tmp_path, roster): + sk_alice, pk_alice, pk_x_alice = _keys() + sk_mallory, pk_mallory, pk_x_mallory = _keys() + await roster.pin_identity("alice", "alice", pk_alice, pk_x_alice, via="code") + await roster.pin_identity("mallory", "mallory", pk_mallory, pk_x_mallory, + via="code") + + session = _session(tmp_path, roster) + entry = _Entry(uploader_id="alice", uploader_pk=pk_alice) + transcript = b"delete-this-file" + + assert await session._verify_uploader_sig( + entry, transcript, sk_mallory.sign(transcript)) is False + + +async def test_a_revoked_device_can_no_longer_delete(tmp_path, roster): + """Ownership survives revocation; the revoked *key* stops being able to act.""" + sk_old, pk_old, pk_x_old = _keys() + sk_new, pk_new, pk_x_new = _keys() + await roster.pin_identity("alice", "alice", pk_old, pk_x_old, via="code") + await roster.pin_identity("alice", "alice", pk_new, pk_x_new, via="device") + await roster.revoke_device("alice", pk_old) + + session = _session(tmp_path, roster) + entry = _Entry(uploader_id="alice", uploader_pk=pk_old) + transcript = b"delete-this-file" + + assert await session._verify_uploader_sig( + entry, transcript, sk_old.sign(transcript)) is False + assert await session._verify_uploader_sig( + entry, transcript, sk_new.sign(transcript)) is True, ( + "the file is still Alice's — a retired laptop does not orphan its uploads") + + +async def test_an_entry_with_no_uploader_id_falls_back_to_the_recorded_key( + tmp_path, roster): + """An index written before `uploader_id` existed must not become undeletable.""" + sk_a, pk_a, pk_x_a = _keys() + session = _session(tmp_path, roster) + entry = _Entry(uploader_id="", uploader_pk=pk_a) + transcript = b"delete-this-file" + + assert await session._verify_uploader_sig( + entry, transcript, sk_a.sign(transcript)) is True diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py index dc74752..ea13d96 100644 --- a/packages/meshbay-node/tests/test_webrtc_transport.py +++ b/packages/meshbay-node/tests/test_webrtc_transport.py @@ -244,6 +244,35 @@ async def _open_channel(transport, peer_id): return pc, ch, q +def _sealed_chat(session, text: bytes = b"ciphertext") -> dict: + """ + A chat message in the shape MNP 2.0 requires, on a live session. + + There is no plaintext chat any more, so a test that wants to exercise + delivery has to send a real envelope. The bytes need not be a real + ciphertext — the node never opens one — but the envelope's shape and the + device claim are checked, and the device must be the one this connection + identified itself as. Identifying it here is what `device_hello` does over + the wire; doing it directly keeps this test about chat rather than about + device linking, which `test_device_on_connection.py` covers. + """ + device = hashlib.sha256(session._registry_key.encode()).digest() + session._pinned_pk = base64.b64encode(device).decode() + session._device_confirmed = True + return { + "type": MNP.CHAT_MESSAGE, "v": MNP_VERSION, + "format": 1, "epoch": 1, "device": device, "ct": text, + "nonce": b"\x02" * 12, "sig": b"\x03" * 64, + } + + +def _only_session(transport): + """The one live peer session on a transport, for tests that made one.""" + sessions = list(transport._sessions.values()) + assert len(sessions) == 1, f"expected one session, got {len(sessions)}" + return sessions[0] + + async def _setup_peer(transport, sk_hub, gek, peer_id, jwt_sub="user-001", sk_user=None, group_id=TEST_GROUP): """Create a peer connection, perform handshake with GEK proof, return (pc, channel, queue).""" @@ -574,11 +603,8 @@ async def test_webrtc_chat_send_and_history(sk_node, sk_hub, gek, shared_dir, tm browser_pc, channel, received = await _setup_peer( transport, sk_hub, gek, "peer-chat") - channel.send(_pack({ - "type": MNP.CHAT_MESSAGE, - "v": MNP_VERSION, - "payload": "hello from browser", - })) + channel.send(_pack(_sealed_chat(_only_session(transport), + b"hello from browser"))) chat_ack = await asyncio.wait_for(received.get(), timeout=5.0) assert chat_ack["type"] == "ack" @@ -593,7 +619,12 @@ async def test_webrtc_chat_send_and_history(sk_node, sk_hub, gek, shared_dir, tm hist = await asyncio.wait_for(received.get(), timeout=5.0) assert hist["type"] == MNP.CHAT_HISTORY_RESPONSE assert len(hist["messages"]) == 1 - assert hist["messages"][0]["payload"] == "hello from browser" + # The ciphertext comes back under `ct`, byte for byte — `payload` is the + # plaintext field and stays empty for a sealed row. Decoding a ciphertext + # as UTF-8, which the history path used to do, would mangle it. + assert hist["messages"][0]["ct"] == b"hello from browser" + assert hist["messages"][0]["payload"] == "" + assert hist["messages"][0]["format"] == 1 assert hist["messages"][0]["sender_id"] == "user-001" await chat_store.close() @@ -650,17 +681,22 @@ async def test_webrtc_chat_broadcast(sk_node, sk_hub, gek, shared_dir, tmp_path) pc_a, ch_a, q_a = await _setup_peer(transport, sk_hub, gek, "peer-A", "user-A") pc_b, ch_b, q_b = await _setup_peer(transport, sk_hub, gek, "peer-B", "user-B") - ch_a.send(_pack({ - "type": MNP.CHAT_MESSAGE, "v": MNP_VERSION, "payload": "hi from A", - })) + session_a = next(s for s in transport._sessions.values() + if s._user_id == "user-A") + ch_a.send(_pack(_sealed_chat(session_a, b"hi from A"))) ack_a = await asyncio.wait_for(q_a.get(), timeout=5.0) assert ack_a["type"] == "ack" broadcast = await asyncio.wait_for(q_b.get(), timeout=5.0) assert broadcast["type"] == MNP.CHAT_MESSAGE + # `sender_id` is still the node's, from the authenticated session (NS6). + # What it now carries beside it is the sending device and a signature over + # the ciphertext, which is what makes the claim checkable by the receiver + # rather than taken on the node's word. assert broadcast["sender_id"] == "user-A" - assert broadcast["payload"] == "hi from A" + assert broadcast["ct"] == b"hi from A" + assert broadcast["device"] == base64.b64decode(session_a._pinned_pk) await chat_store.close() await pc_a.close() @@ -731,12 +767,16 @@ async def test_webrtc_peer_cleanup_on_close(sk_node, sk_hub, gek, shared_dir): browser_pc, channel, received = await _setup_peer( transport, sk_hub, gek, "peer-cleanup") - assert "user-001" in transport._ctx["_peers"] + # Keyed per connection, not per account (docs/chat-sender-keys.md F7), so + # membership is asserted by the session object rather than by user_id — + # one account may hold several entries here. + peers = transport._ctx["_peers"] + assert [s._user_id for s in peers.values()] == ["user-001"] assert transport.active_peers == 1 await transport.close_peer("peer-cleanup") - assert "user-001" not in transport._ctx["_peers"] + assert transport._ctx["_peers"] == {} assert transport.active_peers == 0 await browser_pc.close() |