Skip to main content

huddle_protocol/
protocol.rs

1//! Wire protocol for room discovery and message broadcast.
2//!
3//! Two gossipsub topics:
4//!   - `ROOMS_TOPIC` — global, every node subscribes. Used for room
5//!     advertisements (so all peers see "rooms in this network").
6//!   - `format!("{ROOM_TOPIC_PREFIX}{room_id}")` — per-room. Only members
7//!     of a room subscribe. All room messages flow here.
8
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12// huddle 2.0.4 (WS1.1): `RoomKind` and `EncryptedFileMeta` are part of the wire
13// surface (`RoomAnnouncement.kind`, `FileOffer.encrypted_meta`), so they live
14// here in `huddle-protocol`. `huddle-core` re-exports both from their original
15// module paths (`storage::repo::RoomKind`, `files::encryption::EncryptedFileMeta`)
16// so existing call sites are unchanged. Serde representation is byte-identical.
17
18/// huddle 0.7: explicit room kind. `Direct` = 1-1 DM (encrypted, no name, no
19/// member-list chrome, no kick/grant). `Group` = N-way room (full moderation,
20/// named, optionally encrypted). Persisted on `rooms.kind` and echoed on
21/// `RoomAnnouncement.kind` (with `#[serde(default)]` so pre-0.7 peers'
22/// announcements deserialize as `Group`).
23#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum RoomKind {
26    Direct,
27    #[default]
28    Group,
29}
30
31impl RoomKind {
32    pub fn as_str(&self) -> &'static str {
33        match self {
34            RoomKind::Direct => "direct",
35            RoomKind::Group => "group",
36        }
37    }
38
39    pub fn from_str(s: &str) -> Self {
40        match s {
41            "direct" => RoomKind::Direct,
42            _ => RoomKind::Group,
43        }
44    }
45}
46
47/// Metadata for an encrypted file transfer, carried in `FileOffer`. The
48/// ChaCha20-Poly1305 file key is Megolm-wrapped (`wrapped_key_b64`); the
49/// `content_hash` is bound as AEAD associated data.
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
51pub struct EncryptedFileMeta {
52    pub megolm_session_id: String,
53    pub wrapped_key_b64: String,
54    pub nonce_b64: String,
55    /// SHA-256 of the plaintext, hex-encoded. Bound as AEAD associated
56    /// data so the (key, nonce, ciphertext) triple can't be replayed
57    /// against different content, and verified after decryption.
58    pub content_hash: String,
59}
60
61pub const ROOMS_TOPIC: &str = "huddle-rooms-v1";
62pub const ROOM_TOPIC_PREFIX: &str = "huddle-room-";
63
64pub fn room_topic(room_id: &str) -> String {
65    format!("{ROOM_TOPIC_PREFIX}{room_id}")
66}
67
68/// huddle 1.0: a stable, per-identity "inbox" room id for relay-routed
69/// contact requests — `inbox:<hex(sha256("huddle-inbox-v1" || fingerprint))>`.
70/// Lets "add by HD-ID" work over the internet (not just the LAN mesh): the
71/// requester publishes a signed `ContactRequest` here and the owner, who
72/// auto-subscribes to their own inbox, picks it up (live or from the relay
73/// mailbox). The relay only ever sees this hash, never the raw fingerprint
74/// (preimage resistance), so it can't reconstruct a contact graph; the
75/// `inbox:` prefix + distinct salt keep it from colliding with DM / group
76/// room ids. Both sides derive the same id from the target fingerprint.
77pub fn inbox_room_id(fingerprint: &str) -> String {
78    let mut h = Sha256::new();
79    h.update(b"huddle-inbox-v1");
80    h.update(fingerprint.as_bytes());
81    format!("inbox:{}", hex::encode(h.finalize()))
82}
83
84/// Application-level signed envelope around a `RoomMessage`. Used for
85/// any message whose authenticity matters beyond gossipsub's transport-
86/// level signing.
87///
88/// The following variants MUST be sent inside a `Signed` envelope, and
89/// receivers MUST drop them when they arrive unsigned:
90///   - `MemberLeave` (signer must equal the claimed `sender_fingerprint`;
91///     huddle 0.7.11 — closes the unsigned-leave spoof bug)
92///   - `MemberAnnounce` (signer must equal the claimed `sender_fingerprint`;
93///     huddle 0.7.11 — closes the TOFU-pubkey hijack bug)
94///   - `FileOffer` (signer must equal the claimed `sender_fingerprint`;
95///     huddle 0.7.11 — prevents attribution spoofing)
96///   - `RotateRoomKey` (signer must equal the claimed `rotator_fingerprint`)
97///   - `OwnerGrant`, `BanMember` (signer must be a current room owner)
98///   - `SasInit`, `SasResponse`, `SasConfirm` (SAS handshake — signature
99///     binds the ephemeral X25519 pubkey to the sender's identity)
100///   - `CodeJoinRequest`, `CodeJoinResponse` (signer is the joiner /
101///     owner)
102///   - `JoinRefused` (signer is a room owner; tells the rejected joiner
103///     it really came from the room)
104///   - `ProfileUpdate` (signer must equal the claimed `sender_fingerprint`;
105///     prevents anyone from spoofing another peer's username)
106///   - `ContactRequest` (signer must equal the claimed `requester_fingerprint`;
107///     huddle 1.0 — the signature proves who's asking to connect)
108///   - `Reaction`, `Edit`, `Delete` (huddle 2.0 / F10 — signer must equal
109///     the claimed `sender_fingerprint`; edits & deletes are additionally
110///     applied only when the signer is the original sender OR a current
111///     room owner)
112///   - `RoomSetting` (huddle 2.0 / F9 — disappearing-messages TTL; the
113///     signer must be the room creator or a current owner)
114///
115/// Verification happens via `crate::crypto::verify_signed`: it re-derives
116/// the fingerprint from `ed25519_pubkey_b64`, asserts equality with
117/// `fingerprint`, runs `Ed25519::verify_strict` over the decoded
118/// `payload_b64`, and rejects envelopes whose `signed_at_ms` falls
119/// outside a ±5 min window from now (replay protection).
120///
121/// Format choice: payload is base64'd serialized `RoomMessage` JSON
122/// (not the JSON bytes directly) so the envelope itself is plain JSON
123/// without escaping nightmares.
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
125pub struct SignedRoomMessage {
126    pub fingerprint: String,
127    pub ed25519_pubkey_b64: String,
128    pub payload_b64: String,
129    pub signature_b64: String,
130    /// huddle 0.7.11: epoch-ms timestamp the sender bound into the
131    /// signature, used by receivers as replay protection. `#[serde(default)]`
132    /// for forward-compat parsing — but the verifier rejects `0` and
133    /// values outside the configured window, so legacy pre-0.7.11 senders
134    /// no longer satisfy `verify_signed`.
135    #[serde(default)]
136    pub signed_at_ms: i64,
137    /// huddle 2.0.6 (WS2-a): optional **composite ML-DSA-65 post-quantum
138    /// signature**. When the hybrid path is used, the same `signed_bytes`
139    /// (payload || domain || timestamp) are signed with the sender's ML-DSA-65
140    /// key too; `mldsa_pubkey_b64` carries that key. A verifier that has
141    /// **pinned** the sender's ML-DSA key (learned from a prior signed announce,
142    /// like the ML-KEM pin) checks this signature via
143    /// [`crate::crypto::verify_signed_mldsa`], so a quantum adversary that forges
144    /// the Ed25519 signature still cannot forge the envelope. Both fields are
145    /// absent on classical envelopes — byte-identical to pre-2.0.6 — and ignored
146    /// by older peers.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub mldsa_pubkey_b64: Option<String>,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub mldsa_signature_b64: Option<String>,
151}
152
153/// What actually gets serialized onto a per-room gossipsub topic. New
154/// in v0.3.0 — previously, the raw `RoomMessage` JSON went on the wire.
155/// All outgoing messages now flow through this envelope so the receiver
156/// can tell signed from unsigned at the outer layer without trial-
157/// parsing. Tagged so future variants don't silently misparse.
158#[derive(Debug, Clone, Serialize, Deserialize)]
159#[serde(tag = "type", content = "data", rename_all = "snake_case")]
160pub enum WireMessage {
161    /// Unsigned — equivalent to the old wire format. Used for messages
162    /// whose authenticity isn't security-critical: `Plain`, `Typing`,
163    /// `FileChunk`, etc. NOTE: `MemberAnnounce` moved to the **signed**
164    /// envelope in 0.7.11 (see `broadcast_member_announce`), so its
165    /// fingerprint pin can't be hijacked — and, as of 1.3, so its
166    /// ML-KEM key + ciphertext can't be stripped by a relay to force a
167    /// post-quantum downgrade without breaking the signature.
168    Plain(RoomMessage),
169    /// App-level Ed25519-signed envelope.
170    Signed(SignedRoomMessage),
171}
172
173/// Serialize an unsigned `RoomMessage` to its on-wire bytes inside the
174/// new `WireMessage::Plain` envelope. The single helper keeps every send
175/// site in `app/mod.rs` from open-coding the wrap.
176pub fn encode_wire(msg: &RoomMessage) -> serde_json::Result<Vec<u8>> {
177    serde_json::to_vec(&WireMessage::Plain(msg.clone()))
178}
179
180/// Serialize a `SignedRoomMessage` envelope to its on-wire bytes.
181pub fn encode_wire_signed(env: &SignedRoomMessage) -> serde_json::Result<Vec<u8>> {
182    serde_json::to_vec(&WireMessage::Signed(env.clone()))
183}
184
185/// Broadcast on the global ROOMS_TOPIC. Each peer republishes the rooms
186/// they're currently in, periodically. Listeners maintain a cache with TTL.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct RoomAnnouncement {
189    pub room_id: String,
190    pub name: String,
191    pub encrypted: bool,
192    /// Argon2id salt — present iff `encrypted`. Joiners derive their
193    /// passphrase key from (passphrase, salt) to unwrap session keys.
194    pub passphrase_salt: Option<Vec<u8>>,
195    pub member_count: u32,
196    pub creator_fingerprint: String,
197    /// Seconds since UNIX_EPOCH when this announcement was emitted.
198    pub announced_at: i64,
199    /// Phase B: fingerprints with role = 'owner' — the soft moderator
200    /// set. Newcomers learn from this who's authorized to grant other
201    /// owners and to issue bans (signed via `SignedRoomMessage`).
202    /// `#[serde(default)]` for forward-compat with pre-0.3 senders.
203    #[serde(default)]
204    pub owner_fingerprints: Vec<String>,
205    /// Phase E: when true, existing members refuse to wrap their
206    /// session key for a joiner whose fingerprint isn't in the
207    /// global `verified_peers` set. Joiner sees a `JoinRefused`
208    /// reply from at least one owner so the UX isn't a silent hang.
209    /// `#[serde(default)]` so pre-0.3 senders default to permissive.
210    #[serde(default)]
211    pub verified_only: bool,
212    /// Phase D follow-up: dialable multiaddrs of the announcing node.
213    /// Populated from AutoNAT-confirmed external addresses + relay
214    /// circuit reservations (capped at 4 entries to keep the
215    /// announcement small). Empty for pre-0.3-followup senders.
216    ///
217    /// Consumer: when a peer sees an announcement with non-empty
218    /// `host_addrs` and isn't already connected to `creator_fingerprint`,
219    /// it opportunistically dials the first entry. This lets cross-
220    /// internet peers bootstrap via relay-circuit addresses without
221    /// requiring an invite link.
222    #[serde(default)]
223    pub host_addrs: Vec<String>,
224    /// huddle 0.7: explicit room kind. `RoomKind::Direct` (1-1 DM) is
225    /// filtered out by honest 0.7+ consumers if neither member is them —
226    /// DMs never leak past the two participants' sidebars. Pre-0.7
227    /// peers omit the field, which `#[serde(default)]` resolves to
228    /// `RoomKind::Group` (`Default` impl) — they keep working unchanged.
229    #[serde(default)]
230    pub kind: RoomKind,
231}
232
233/// All messages on a room's per-room topic.
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub enum RoomMessage {
236    /// Announce my presence in the room. For encrypted rooms, also share
237    /// my Megolm session key (passphrase-wrapped).
238    MemberAnnounce {
239        sender_fingerprint: String,
240        /// base64(nonce || chacha20poly1305_ciphertext) of the Megolm
241        /// SessionKey, encrypted under the passphrase-derived key.
242        /// None for unencrypted rooms.
243        wrapped_session_key: Option<String>,
244        /// Optional human-readable display name. Serde defaults to
245        /// `None` for forward compat with older peers.
246        #[serde(default)]
247        display_name: Option<String>,
248        /// Base64 of the sender's 32-byte Ed25519 public key. Lets every
249        /// existing member learn the new member's pubkey on first contact,
250        /// so they can verify future `SignedRoomMessage` envelopes from
251        /// this fingerprint. `#[serde(default)]` for forward compat: a
252        /// pre-0.3.0 peer that doesn't send this still works, but its
253        /// signed messages can't be verified until it re-announces.
254        #[serde(default)]
255        sender_ed25519_pubkey: Option<String>,
256        /// huddle 1.3: base64 of the sender's 1184-byte ML-KEM-768
257        /// encapsulation (public) key, for hybrid post-quantum DM key
258        /// agreement. Populated only on **Direct** room announces. Its
259        /// presence is how the peer signals PQ capability: a DM goes hybrid
260        /// iff the partner published this (or we have it pinned from a prior
261        /// announce — see `app::ensure_dm_key`). `#[serde(default,
262        /// skip_serializing_if = "Option::is_none")]` keeps pre-1.3 peers (and
263        /// all group announces) byte-compatible — when `None` the field simply
264        /// doesn't appear, and a missing field decodes back to `None`. (Do NOT
265        /// "simplify" this to serde's bare `skip`: that would drop the field on
266        /// the wire unconditionally and silently disable the whole PQ path.)
267        /// Carried inside the *signed* `MemberAnnounce` envelope, so the relay
268        /// can't strip it to force a downgrade without breaking the signature.
269        #[serde(default, skip_serializing_if = "Option::is_none")]
270        sender_mlkem_pubkey: Option<String>,
271        /// huddle 1.3: base64 of the 1088-byte ML-KEM-768 ciphertext sent by
272        /// the DM **initiator** (the lower-fingerprint peer) to the responder,
273        /// who decapsulates it to recover the shared post-quantum secret. Only
274        /// the initiator sets this; the responder's announces omit it.
275        #[serde(default, skip_serializing_if = "Option::is_none")]
276        mlkem_ciphertext: Option<String>,
277    },
278    /// A request from a recently-joined member: "I need session keys".
279    /// Existing members respond with MemberAnnounce.
280    SessionKeyRequest { requester_fingerprint: String },
281    /// An encrypted message in an encrypted room.
282    Encrypted {
283        sender_fingerprint: String,
284        session_id: String,
285        /// base64-encoded MegolmMessage bytes
286        ciphertext_b64: String,
287        /// huddle 2.0 (F10): sender-minted stable id for this message, used
288        /// as the cross-peer targeting key for reactions, edits, replies and
289        /// deletes. `#[serde(default, skip_serializing_if = "Option::is_none")]`
290        /// keeps pre-2.0 peers byte-compatible: when `None` the field is
291        /// omitted on the wire, and a missing field decodes back to `None`
292        /// (so messages from older peers simply can't be a content-affordance
293        /// target — content still flows).
294        #[serde(default, skip_serializing_if = "Option::is_none")]
295        client_msg_id: Option<String>,
296        /// huddle 2.0 (F10): `client_msg_id` of the message this one replies
297        /// to, if any. `None` (and omitted on the wire) for top-level messages
298        /// and pre-2.0 senders.
299        #[serde(default, skip_serializing_if = "Option::is_none")]
300        reply_to: Option<String>,
301    },
302    /// A plaintext message in an unencrypted room.
303    Plain {
304        sender_fingerprint: String,
305        body: String,
306        /// huddle 2.0 (F10): see `Encrypted::client_msg_id`. Sender-minted
307        /// stable id; `#[serde(default, skip_serializing_if = "Option::is_none")]`
308        /// for byte-compat with pre-2.0 peers.
309        #[serde(default, skip_serializing_if = "Option::is_none")]
310        client_msg_id: Option<String>,
311        /// huddle 2.0 (F10): `client_msg_id` of the message this one replies
312        /// to, if any. `None` for top-level messages and pre-2.0 senders.
313        #[serde(default, skip_serializing_if = "Option::is_none")]
314        reply_to: Option<String>,
315    },
316    /// Explicit leave notification.
317    MemberLeave {
318        sender_fingerprint: String,
319        /// huddle 2.0.3 (audit N-M2): the room this leave is for. The Ed25519
320        /// signature commits to the payload but NOT the gossip topic, so without
321        /// this a malicious relay could replay a signed leave from room A onto
322        /// room B's topic. `Option` + `skip_serializing_if` keeps the wire
323        /// byte-identical to pre-2.0.3 when unset (graceful back-compat); honest
324        /// receivers cross-check it against the delivery topic when present.
325        #[serde(default, skip_serializing_if = "Option::is_none")]
326        room_id: Option<String>,
327    },
328    /// "I'm rotating the room key — derive a new passphrase key from
329    /// `new_salt` + the new passphrase you'll be told out-of-band, then
330    /// wait for my MemberAnnounce." Phase 3 v1: simplistic — only the
331    /// rotator's outbound changes; receivers must opt in by entering
332    /// the new passphrase to decrypt new wrapped session keys.
333    RotateRoomKey {
334        rotator_fingerprint: String,
335        /// Argon2id salt for the new passphrase-derived key.
336        new_salt: Vec<u8>,
337        /// huddle 2.0.3 (audit N-M2): the room being rotated, cross-checked
338        /// against the delivery topic so a signed rotation can't be replayed
339        /// into another room. Optional for pre-2.0.3 back-compat.
340        #[serde(default, skip_serializing_if = "Option::is_none")]
341        room_id: Option<String>,
342    },
343    /// Ephemeral "I'm typing" signal. TTL on the receive side is 3s.
344    Typing { sender_fingerprint: String },
345    /// Announce a file the sender is about to push. The receiver creates
346    /// an attachment row (status=offered) and waits for chunks. For
347    /// encrypted rooms `encrypted_meta` carries the Megolm-wrapped file
348    /// key + ChaCha20 nonce.
349    FileOffer {
350        sender_fingerprint: String,
351        file_id: String,
352        name: String,
353        size_bytes: u64,
354        mime: Option<String>,
355        chunk_count: u32,
356        encrypted_meta: Option<EncryptedFileMeta>,
357    },
358    /// One chunk of an in-flight file. Receivers reassemble by index
359    /// and verify the final SHA-256 against `file_id`.
360    FileChunk {
361        sender_fingerprint: String,
362        file_id: String,
363        chunk_index: u32,
364        total_chunks: u32,
365        /// base64 of raw chunk bytes (plaintext bytes for unencrypted
366        /// rooms, ChaCha20-Poly1305 ciphertext for encrypted).
367        data_b64: String,
368    },
369    /// Phase B: an existing owner promotes `target_fingerprint` to
370    /// owner. MUST be sent inside `WireMessage::Signed` — the signer
371    /// must be on the room's current `owner_fingerprints` list for
372    /// honest receivers to apply the change.
373    OwnerGrant {
374        room_id: String,
375        target_fingerprint: String,
376    },
377    /// Phase B: an existing owner bans `target_fingerprint` from the
378    /// room. MUST be sent inside `WireMessage::Signed`. Honest clients
379    /// then ignore the banned fingerprint's MemberAnnounce + messages.
380    /// The cryptographic enforcement is the immediate `RotateRoomKey`
381    /// that the banning owner sends right after.
382    BanMember {
383        room_id: String,
384        target_fingerprint: String,
385    },
386    /// Phase G: SAS verification step 1. The initiator picks a random
387    /// `tx_id` and an ephemeral X25519 keypair, sends the pubkey.
388    /// MUST be sent inside `WireMessage::Signed` so the receiver can
389    /// bind this ephemeral key to the initiator's Ed25519 identity.
390    SasInit {
391        tx_id: String,
392        ephemeral_x25519_pubkey_b64: String,
393        target_fingerprint: String,
394    },
395    /// Phase G: SAS step 2 — responder's ephemeral X25519 pubkey.
396    /// Both sides now have what they need to compute the shared
397    /// secret and derive the SAS code locally. Signed.
398    SasResponse {
399        tx_id: String,
400        ephemeral_x25519_pubkey_b64: String,
401    },
402    /// Phase G: SAS step 3 — once both sides have OOB-compared the
403    /// derived code and pressed "Match", each broadcasts this. On
404    /// receiving the partner's `matched=true`, the local side flips
405    /// `verified=1` for the partner's fingerprint. Signed.
406    SasConfirm { tx_id: String, matched: bool },
407    /// Phase E: an existing owner of a `verified_only` room is
408    /// telling `target_fingerprint` (an unverified joiner) why their
409    /// announce went unanswered. Replaces a silent hang on the
410    /// joiner's side. Signed.
411    JoinRefused {
412        room_id: String,
413        target_fingerprint: String,
414        reason: String,
415    },
416    /// Phase F: a joiner is asking to enter a room using a short-lived
417    /// owner-issued code (no passphrase). Includes the joiner's
418    /// ephemeral X25519 pubkey for ECDH key delivery. Signed (so the
419    /// owner knows who's asking).
420    CodeJoinRequest {
421        room_id: String,
422        joiner_x25519_pubkey_b64: String,
423        code: String,
424    },
425    /// Phase F: an issuing owner's response to a valid `CodeJoinRequest`.
426    /// Carries the owner's ephemeral X25519 pubkey + the current Megolm
427    /// session key wrapped under the ECDH-derived key. Joiner does
428    /// X25519 the other direction, derives the same wrap key, unwraps
429    /// the session key. Signed.
430    CodeJoinResponse {
431        room_id: String,
432        target_fingerprint: String,
433        owner_x25519_pubkey_b64: String,
434        owner_session_id: String,
435        wrapped_session_key_b64: String,
436        nonce_b64: String,
437    },
438    /// Phase 0.5: a peer is announcing (or clearing) their self-declared
439    /// username. MUST be sent inside `WireMessage::Signed` — receivers
440    /// require `verified_signer == sender_fingerprint`. Last-write-wins
441    /// by `updated_at` (monotonic ms). `username = None` clears the
442    /// previously-set username and the peer renders as `[anonymous]`.
443    ProfileUpdate {
444        sender_fingerprint: String,
445        username: Option<String>,
446        updated_at: i64,
447    },
448    /// huddle 1.0: a contact/DM request delivered to the target's relay
449    /// inbox (`inbox_room_id`). MUST be sent inside `WireMessage::Signed` —
450    /// the signer's fingerprint IS the requester's identity (the whole
451    /// point: it proves who's asking). Carries the requester's Ed25519
452    /// pubkey so the recipient can TOFU-pin it and later derive the DM ECDH
453    /// key without a round-trip.
454    ContactRequest {
455        requester_fingerprint: String,
456        #[serde(default)]
457        display_name: Option<String>,
458        #[serde(default)]
459        note: Option<String>,
460        #[serde(default)]
461        sender_ed25519_pubkey: Option<String>,
462    },
463    /// huddle 2.0 (F10): add or remove an emoji reaction on another message
464    /// in this room. MUST be sent inside `WireMessage::Signed` — the signer
465    /// must equal `sender_fingerprint`; honest receivers drop unsigned
466    /// reactions (like `FileOffer` / `MemberAnnounce`). The toggle unit is
467    /// one emoji per (sender, target message): `removed = false` adds the
468    /// reaction, `removed = true` clears it. A brand-new variant, so pre-2.0
469    /// peers never produce it and ignore it on receipt.
470    Reaction {
471        sender_fingerprint: String,
472        /// `client_msg_id` of the message being reacted to (must exist in
473        /// this room for honest receivers to apply the reaction).
474        target_msg_id: String,
475        /// A single emoji or short custom code (e.g. "+1", "laugh").
476        emoji: String,
477        /// `false` = add the reaction, `true` = remove it (toggle off).
478        /// `#[serde(default)]` so a missing field decodes to `false` (add).
479        #[serde(default)]
480        removed: bool,
481    },
482    /// huddle 2.0 (F10): edit the body of an existing message. MUST be sent
483    /// inside `WireMessage::Signed`; honest receivers apply it only when the
484    /// signer is the original sender OR a current room owner (moderation).
485    /// Last-write-wins. For encrypted rooms the replacement body rides as a
486    /// fresh Megolm ciphertext in `new_ciphertext_b64` (decrypted exactly
487    /// like an `Encrypted` body, against the carried `session_id`); for
488    /// plaintext rooms it rides as `new_body`.
489    Edit {
490        sender_fingerprint: String,
491        /// `client_msg_id` of the message being edited.
492        target_msg_id: String,
493        /// base64 MegolmMessage bytes of the replacement body, for encrypted
494        /// rooms. Empty for plaintext rooms (which carry the new body in
495        /// `new_body` instead).
496        new_ciphertext_b64: String,
497        /// huddle 2.0 (F10): the Megolm `session_id` the editor encrypted
498        /// `new_ciphertext_b64` under, so receivers decrypt the edit against
499        /// the correct session — exactly like `Encrypted::session_id` — with
500        /// no reliance on an in-memory "last inbound session" guess (which
501        /// failed after a session rotation, after restart, from a second
502        /// device, or before any other message on the session). Empty (and
503        /// omitted on the wire) for plaintext rooms. `#[serde(default,
504        /// skip_serializing_if = "String::is_empty")]` keeps it additive: a
505        /// pre-session-id edit decodes to an empty string and is dropped
506        /// gracefully rather than mis-decrypted.
507        #[serde(default, skip_serializing_if = "String::is_empty")]
508        session_id: String,
509        /// Replacement plaintext body, for unencrypted rooms. `None` (and
510        /// omitted on the wire) for encrypted rooms, whose new body lives in
511        /// `new_ciphertext_b64`.
512        #[serde(default, skip_serializing_if = "Option::is_none")]
513        new_body: Option<String>,
514    },
515    /// huddle 2.0 (F10): delete (tombstone) an existing message. MUST be
516    /// sent inside `WireMessage::Signed`; honest receivers apply it only when
517    /// the signer is the original sender OR a current room owner. Idempotent —
518    /// the original message row is kept and a tombstone is recorded, so the
519    /// body renders as "[deleted]" everywhere.
520    Delete {
521        sender_fingerprint: String,
522        /// `client_msg_id` of the message being deleted.
523        target_msg_id: String,
524    },
525    /// huddle 2.0 (F9): a signed control message that sets this room's
526    /// disappearing-messages TTL. MUST be sent inside `WireMessage::Signed` —
527    /// honest receivers apply it only when the signer is the room creator or a
528    /// current owner. `disappearing_ttl_secs = 0` turns expiry off; any value
529    /// > 0 makes each peer locally auto-delete messages that many seconds
530    /// after they were stored. Pre-2.0 peers ignore the unknown variant and
531    /// never expire their local copies (graceful downgrade).
532    RoomSetting {
533        sender_fingerprint: String,
534        disappearing_ttl_secs: u64,
535        /// huddle 2.0.3 (audit N-M2): the room this setting applies to. Without
536        /// it a malicious relay could replay a signed disappearing-TTL from one
537        /// room (where the signer is an owner) onto another room they also own,
538        /// forcing a retroactive history purge there (chains L-24). Cross-checked
539        /// against the delivery topic on receive. Optional for back-compat.
540        #[serde(default, skip_serializing_if = "Option::is_none")]
541        room_id: Option<String>,
542    },
543
544    // huddle 2.1 (WS2-b): MLS (RFC 9420) group messaging — the post-quantum-ready
545    // group layer. These variants carry the MLS handshake + application traffic
546    // for rooms that opt into MLS, so classical Megolm rooms are unaffected. All
547    // are ADDITIVE: a pre-2.1 peer drops the unknown variant gracefully (the
548    // payload fails to deserialize and is logged + dropped), so MLS and classical
549    // peers coexist. Commit ordering rides the relay's per-room `seq` (huddle
550    // 2.0.8). Payloads are opaque TLS-serialized MLS objects, so this crate stays
551    // runtime-free — the MLS engine lives in `huddle-core` behind its `mls`
552    // feature; see PROTOCOL.md §11 and the rollout spec.
553    /// A member publishes its MLS `KeyPackage` so existing members can add it to
554    /// the group.
555    MlsKeyPackage {
556        sender_fingerprint: String,
557        /// base64 TLS-serialized MLS `KeyPackage`.
558        key_package_b64: String,
559    },
560    /// A `Welcome` hands a freshly-added member the group's secrets. MUST be
561    /// `WireMessage::Signed`; directed at one new member.
562    MlsWelcome {
563        target_fingerprint: String,
564        /// base64 TLS-serialized MLS `Welcome`.
565        welcome_b64: String,
566    },
567    /// A `Commit` advances the group's epoch (add / remove / update). MUST be
568    /// `WireMessage::Signed`; applied in the relay's per-room `seq` order so every
569    /// member converges on the same epoch sequence — TreeKEM forward secrecy,
570    /// post-compromise security, and cryptographically-enforced removal.
571    MlsCommit {
572        sender_fingerprint: String,
573        /// base64 TLS-serialized MLS handshake `MlsMessage` (a Commit).
574        commit_b64: String,
575    },
576    /// An MLS-encrypted application (chat) message under the current epoch key.
577    MlsApplication {
578        sender_fingerprint: String,
579        /// base64 TLS-serialized MLS application `MlsMessage`.
580        ciphertext_b64: String,
581    },
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587
588    #[test]
589    fn room_announcement_round_trip() {
590        let ann = RoomAnnouncement {
591            room_id: "rid".into(),
592            name: "general".into(),
593            encrypted: true,
594            passphrase_salt: Some(vec![1, 2, 3, 4]),
595            member_count: 3,
596            creator_fingerprint: "creator-fp".into(),
597            announced_at: 100,
598            owner_fingerprints: vec!["creator-fp".into()],
599            verified_only: false,
600            host_addrs: vec![],
601            kind: RoomKind::Group,
602        };
603        let json = serde_json::to_vec(&ann).unwrap();
604        let back: RoomAnnouncement = serde_json::from_slice(&json).unwrap();
605        assert_eq!(back.name, "general");
606        assert_eq!(back.passphrase_salt, Some(vec![1, 2, 3, 4]));
607        assert_eq!(back.kind, RoomKind::Group);
608    }
609
610    #[test]
611    fn room_announcement_direct_kind_round_trip() {
612        let ann = RoomAnnouncement {
613            room_id: "dm-rid".into(),
614            name: "dm".into(),
615            encrypted: false,
616            passphrase_salt: None,
617            member_count: 2,
618            creator_fingerprint: "alice-fp".into(),
619            announced_at: 100,
620            owner_fingerprints: vec![],
621            verified_only: false,
622            host_addrs: vec![],
623            kind: RoomKind::Direct,
624        };
625        let json = serde_json::to_vec(&ann).unwrap();
626        let back: RoomAnnouncement = serde_json::from_slice(&json).unwrap();
627        assert_eq!(back.kind, RoomKind::Direct);
628    }
629
630    #[test]
631    fn room_announcement_missing_kind_defaults_to_group() {
632        // Simulates a pre-0.7 peer's announcement: same JSON shape
633        // without the `kind` field. The serde(default) attribute on the
634        // field must resolve to RoomKind::Group so older peers keep
635        // working unchanged.
636        let pre_0_7_json = serde_json::json!({
637            "room_id": "rid",
638            "name": "general",
639            "encrypted": false,
640            "passphrase_salt": null,
641            "member_count": 1,
642            "creator_fingerprint": "creator-fp",
643            "announced_at": 100,
644        });
645        let back: RoomAnnouncement = serde_json::from_value(pre_0_7_json).unwrap();
646        assert_eq!(back.kind, RoomKind::Group);
647    }
648
649    #[test]
650    fn room_message_variants_round_trip() {
651        let msgs = vec![
652            RoomMessage::MemberAnnounce {
653                sender_fingerprint: "fp".into(),
654                wrapped_session_key: Some("base64data".into()),
655                display_name: Some("Daisy".into()),
656                sender_ed25519_pubkey: Some("AAA=".into()),
657                sender_mlkem_pubkey: Some("BBB=".into()),
658                mlkem_ciphertext: Some("CCC=".into()),
659            },
660            RoomMessage::Plain {
661                sender_fingerprint: "fp".into(),
662                body: "hi".into(),
663                client_msg_id: Some("cmid-1".into()),
664                reply_to: None,
665            },
666            RoomMessage::Encrypted {
667                sender_fingerprint: "fp".into(),
668                session_id: "sid".into(),
669                ciphertext_b64: "ct".into(),
670                client_msg_id: Some("cmid-2".into()),
671                reply_to: Some("cmid-1".into()),
672            },
673            RoomMessage::SessionKeyRequest {
674                requester_fingerprint: "fp".into(),
675            },
676            RoomMessage::MemberLeave {
677                sender_fingerprint: "fp".into(),
678                room_id: None,
679            },
680            RoomMessage::FileOffer {
681                sender_fingerprint: "fp".into(),
682                file_id: "fid".into(),
683                name: "f.bin".into(),
684                size_bytes: 1024,
685                mime: Some("application/octet-stream".into()),
686                chunk_count: 2,
687                encrypted_meta: None,
688            },
689            RoomMessage::FileChunk {
690                sender_fingerprint: "fp".into(),
691                file_id: "fid".into(),
692                chunk_index: 0,
693                total_chunks: 2,
694                data_b64: "AAA=".into(),
695            },
696            RoomMessage::RotateRoomKey {
697                rotator_fingerprint: "fp".into(),
698                new_salt: vec![1u8; 16],
699                room_id: None,
700            },
701            RoomMessage::Typing {
702                sender_fingerprint: "fp".into(),
703            },
704            // huddle 2.0 (F10/F9): new content + control variants.
705            RoomMessage::Reaction {
706                sender_fingerprint: "fp".into(),
707                target_msg_id: "cmid-1".into(),
708                emoji: "👍".into(),
709                removed: false,
710            },
711            RoomMessage::Edit {
712                sender_fingerprint: "fp".into(),
713                target_msg_id: "cmid-1".into(),
714                new_ciphertext_b64: "ct2".into(),
715                session_id: "sid".into(),
716                new_body: None,
717            },
718            RoomMessage::Edit {
719                sender_fingerprint: "fp".into(),
720                target_msg_id: "cmid-1".into(),
721                new_ciphertext_b64: String::new(),
722                session_id: String::new(),
723                new_body: Some("edited body".into()),
724            },
725            RoomMessage::Delete {
726                sender_fingerprint: "fp".into(),
727                target_msg_id: "cmid-1".into(),
728            },
729            RoomMessage::RoomSetting {
730                sender_fingerprint: "fp".into(),
731                disappearing_ttl_secs: 3600,
732                room_id: None,
733            },
734        ];
735        for m in msgs {
736            let json = serde_json::to_vec(&m).unwrap();
737            let back: RoomMessage = serde_json::from_slice(&json).unwrap();
738            assert_eq!(format!("{m:?}"), format!("{back:?}"));
739        }
740    }
741
742    #[test]
743    fn plain_without_new_fields_defaults_to_none() {
744        // Simulates a pre-2.0 peer's Plain message: externally-tagged JSON
745        // with no `client_msg_id` / `reply_to`. The serde(default) attrs must
746        // resolve both to `None` so old senders keep working unchanged.
747        let pre_2_0_json = serde_json::json!({
748            "Plain": {
749                "sender_fingerprint": "fp",
750                "body": "hi",
751            }
752        });
753        let back: RoomMessage = serde_json::from_value(pre_2_0_json).unwrap();
754        match back {
755            RoomMessage::Plain {
756                client_msg_id,
757                reply_to,
758                ..
759            } => {
760                assert_eq!(client_msg_id, None);
761                assert_eq!(reply_to, None);
762            }
763            other => panic!("expected Plain, got {other:?}"),
764        }
765    }
766
767    #[test]
768    fn plain_with_none_ids_omits_fields_on_wire() {
769        // skip_serializing_if must keep the wire bytes byte-compatible with
770        // pre-2.0 peers when the new fields are absent.
771        let m = RoomMessage::Plain {
772            sender_fingerprint: "fp".into(),
773            body: "hi".into(),
774            client_msg_id: None,
775            reply_to: None,
776        };
777        let v = serde_json::to_value(&m).unwrap();
778        let inner = &v["Plain"];
779        assert!(inner.get("client_msg_id").is_none());
780        assert!(inner.get("reply_to").is_none());
781    }
782
783    #[test]
784    fn reaction_missing_removed_defaults_to_false() {
785        // `removed` carries serde(default) so a peer that omits it (an "add"
786        // reaction) decodes to `false`.
787        let json = serde_json::json!({
788            "Reaction": {
789                "sender_fingerprint": "fp",
790                "target_msg_id": "cmid-1",
791                "emoji": "❤️",
792            }
793        });
794        let back: RoomMessage = serde_json::from_value(json).unwrap();
795        match back {
796            RoomMessage::Reaction { removed, emoji, .. } => {
797                assert!(!removed);
798                assert_eq!(emoji, "❤️");
799            }
800            other => panic!("expected Reaction, got {other:?}"),
801        }
802    }
803
804    #[test]
805    fn edit_missing_session_id_defaults_to_empty() {
806        // huddle 2.0.0 (F10): an `Edit` minted before `session_id` was added
807        // to the wire must decode to an empty `session_id` (graceful drop on
808        // the receive side) rather than fail to parse.
809        let json = serde_json::json!({
810            "Edit": {
811                "sender_fingerprint": "fp",
812                "target_msg_id": "cmid-1",
813                "new_ciphertext_b64": "ct2",
814            }
815        });
816        let back: RoomMessage = serde_json::from_value(json).unwrap();
817        match back {
818            RoomMessage::Edit { session_id, .. } => assert_eq!(session_id, ""),
819            other => panic!("expected Edit, got {other:?}"),
820        }
821    }
822
823    #[test]
824    fn edit_empty_session_id_omitted_on_wire() {
825        // A plaintext-room edit has no session; `skip_serializing_if` keeps
826        // those wire bytes free of an empty `session_id`. The encrypted-room
827        // edit, by contrast, always carries one.
828        let plain = RoomMessage::Edit {
829            sender_fingerprint: "fp".into(),
830            target_msg_id: "cmid-1".into(),
831            new_ciphertext_b64: String::new(),
832            session_id: String::new(),
833            new_body: Some("edited".into()),
834        };
835        let v = serde_json::to_value(&plain).unwrap();
836        assert!(v["Edit"].get("session_id").is_none());
837
838        let enc = RoomMessage::Edit {
839            sender_fingerprint: "fp".into(),
840            target_msg_id: "cmid-1".into(),
841            new_ciphertext_b64: "ct2".into(),
842            session_id: "sid".into(),
843            new_body: None,
844        };
845        let v = serde_json::to_value(&enc).unwrap();
846        assert_eq!(v["Edit"]["session_id"], "sid");
847    }
848
849    #[test]
850    fn room_topic_format() {
851        assert_eq!(room_topic("abc123"), "huddle-room-abc123");
852    }
853}