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