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}
138
139/// What actually gets serialized onto a per-room gossipsub topic. New
140/// in v0.3.0 — previously, the raw `RoomMessage` JSON went on the wire.
141/// All outgoing messages now flow through this envelope so the receiver
142/// can tell signed from unsigned at the outer layer without trial-
143/// parsing. Tagged so future variants don't silently misparse.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145#[serde(tag = "type", content = "data", rename_all = "snake_case")]
146pub enum WireMessage {
147 /// Unsigned — equivalent to the old wire format. Used for messages
148 /// whose authenticity isn't security-critical: `Plain`, `Typing`,
149 /// `FileChunk`, etc. NOTE: `MemberAnnounce` moved to the **signed**
150 /// envelope in 0.7.11 (see `broadcast_member_announce`), so its
151 /// fingerprint pin can't be hijacked — and, as of 1.3, so its
152 /// ML-KEM key + ciphertext can't be stripped by a relay to force a
153 /// post-quantum downgrade without breaking the signature.
154 Plain(RoomMessage),
155 /// App-level Ed25519-signed envelope.
156 Signed(SignedRoomMessage),
157}
158
159/// Serialize an unsigned `RoomMessage` to its on-wire bytes inside the
160/// new `WireMessage::Plain` envelope. The single helper keeps every send
161/// site in `app/mod.rs` from open-coding the wrap.
162pub fn encode_wire(msg: &RoomMessage) -> serde_json::Result<Vec<u8>> {
163 serde_json::to_vec(&WireMessage::Plain(msg.clone()))
164}
165
166/// Serialize a `SignedRoomMessage` envelope to its on-wire bytes.
167pub fn encode_wire_signed(env: &SignedRoomMessage) -> serde_json::Result<Vec<u8>> {
168 serde_json::to_vec(&WireMessage::Signed(env.clone()))
169}
170
171/// Broadcast on the global ROOMS_TOPIC. Each peer republishes the rooms
172/// they're currently in, periodically. Listeners maintain a cache with TTL.
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct RoomAnnouncement {
175 pub room_id: String,
176 pub name: String,
177 pub encrypted: bool,
178 /// Argon2id salt — present iff `encrypted`. Joiners derive their
179 /// passphrase key from (passphrase, salt) to unwrap session keys.
180 pub passphrase_salt: Option<Vec<u8>>,
181 pub member_count: u32,
182 pub creator_fingerprint: String,
183 /// Seconds since UNIX_EPOCH when this announcement was emitted.
184 pub announced_at: i64,
185 /// Phase B: fingerprints with role = 'owner' — the soft moderator
186 /// set. Newcomers learn from this who's authorized to grant other
187 /// owners and to issue bans (signed via `SignedRoomMessage`).
188 /// `#[serde(default)]` for forward-compat with pre-0.3 senders.
189 #[serde(default)]
190 pub owner_fingerprints: Vec<String>,
191 /// Phase E: when true, existing members refuse to wrap their
192 /// session key for a joiner whose fingerprint isn't in the
193 /// global `verified_peers` set. Joiner sees a `JoinRefused`
194 /// reply from at least one owner so the UX isn't a silent hang.
195 /// `#[serde(default)]` so pre-0.3 senders default to permissive.
196 #[serde(default)]
197 pub verified_only: bool,
198 /// Phase D follow-up: dialable multiaddrs of the announcing node.
199 /// Populated from AutoNAT-confirmed external addresses + relay
200 /// circuit reservations (capped at 4 entries to keep the
201 /// announcement small). Empty for pre-0.3-followup senders.
202 ///
203 /// Consumer: when a peer sees an announcement with non-empty
204 /// `host_addrs` and isn't already connected to `creator_fingerprint`,
205 /// it opportunistically dials the first entry. This lets cross-
206 /// internet peers bootstrap via relay-circuit addresses without
207 /// requiring an invite link.
208 #[serde(default)]
209 pub host_addrs: Vec<String>,
210 /// huddle 0.7: explicit room kind. `RoomKind::Direct` (1-1 DM) is
211 /// filtered out by honest 0.7+ consumers if neither member is them —
212 /// DMs never leak past the two participants' sidebars. Pre-0.7
213 /// peers omit the field, which `#[serde(default)]` resolves to
214 /// `RoomKind::Group` (`Default` impl) — they keep working unchanged.
215 #[serde(default)]
216 pub kind: RoomKind,
217}
218
219/// All messages on a room's per-room topic.
220#[derive(Debug, Clone, Serialize, Deserialize)]
221pub enum RoomMessage {
222 /// Announce my presence in the room. For encrypted rooms, also share
223 /// my Megolm session key (passphrase-wrapped).
224 MemberAnnounce {
225 sender_fingerprint: String,
226 /// base64(nonce || chacha20poly1305_ciphertext) of the Megolm
227 /// SessionKey, encrypted under the passphrase-derived key.
228 /// None for unencrypted rooms.
229 wrapped_session_key: Option<String>,
230 /// Optional human-readable display name. Serde defaults to
231 /// `None` for forward compat with older peers.
232 #[serde(default)]
233 display_name: Option<String>,
234 /// Base64 of the sender's 32-byte Ed25519 public key. Lets every
235 /// existing member learn the new member's pubkey on first contact,
236 /// so they can verify future `SignedRoomMessage` envelopes from
237 /// this fingerprint. `#[serde(default)]` for forward compat: a
238 /// pre-0.3.0 peer that doesn't send this still works, but its
239 /// signed messages can't be verified until it re-announces.
240 #[serde(default)]
241 sender_ed25519_pubkey: Option<String>,
242 /// huddle 1.3: base64 of the sender's 1184-byte ML-KEM-768
243 /// encapsulation (public) key, for hybrid post-quantum DM key
244 /// agreement. Populated only on **Direct** room announces. Its
245 /// presence is how the peer signals PQ capability: a DM goes hybrid
246 /// iff the partner published this (or we have it pinned from a prior
247 /// announce — see `app::ensure_dm_key`). `#[serde(default,
248 /// skip_serializing_if = "Option::is_none")]` keeps pre-1.3 peers (and
249 /// all group announces) byte-compatible — when `None` the field simply
250 /// doesn't appear, and a missing field decodes back to `None`. (Do NOT
251 /// "simplify" this to serde's bare `skip`: that would drop the field on
252 /// the wire unconditionally and silently disable the whole PQ path.)
253 /// Carried inside the *signed* `MemberAnnounce` envelope, so the relay
254 /// can't strip it to force a downgrade without breaking the signature.
255 #[serde(default, skip_serializing_if = "Option::is_none")]
256 sender_mlkem_pubkey: Option<String>,
257 /// huddle 1.3: base64 of the 1088-byte ML-KEM-768 ciphertext sent by
258 /// the DM **initiator** (the lower-fingerprint peer) to the responder,
259 /// who decapsulates it to recover the shared post-quantum secret. Only
260 /// the initiator sets this; the responder's announces omit it.
261 #[serde(default, skip_serializing_if = "Option::is_none")]
262 mlkem_ciphertext: Option<String>,
263 },
264 /// A request from a recently-joined member: "I need session keys".
265 /// Existing members respond with MemberAnnounce.
266 SessionKeyRequest { requester_fingerprint: String },
267 /// An encrypted message in an encrypted room.
268 Encrypted {
269 sender_fingerprint: String,
270 session_id: String,
271 /// base64-encoded MegolmMessage bytes
272 ciphertext_b64: String,
273 /// huddle 2.0 (F10): sender-minted stable id for this message, used
274 /// as the cross-peer targeting key for reactions, edits, replies and
275 /// deletes. `#[serde(default, skip_serializing_if = "Option::is_none")]`
276 /// keeps pre-2.0 peers byte-compatible: when `None` the field is
277 /// omitted on the wire, and a missing field decodes back to `None`
278 /// (so messages from older peers simply can't be a content-affordance
279 /// target — content still flows).
280 #[serde(default, skip_serializing_if = "Option::is_none")]
281 client_msg_id: Option<String>,
282 /// huddle 2.0 (F10): `client_msg_id` of the message this one replies
283 /// to, if any. `None` (and omitted on the wire) for top-level messages
284 /// and pre-2.0 senders.
285 #[serde(default, skip_serializing_if = "Option::is_none")]
286 reply_to: Option<String>,
287 },
288 /// A plaintext message in an unencrypted room.
289 Plain {
290 sender_fingerprint: String,
291 body: String,
292 /// huddle 2.0 (F10): see `Encrypted::client_msg_id`. Sender-minted
293 /// stable id; `#[serde(default, skip_serializing_if = "Option::is_none")]`
294 /// for byte-compat with pre-2.0 peers.
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 client_msg_id: Option<String>,
297 /// huddle 2.0 (F10): `client_msg_id` of the message this one replies
298 /// to, if any. `None` for top-level messages and pre-2.0 senders.
299 #[serde(default, skip_serializing_if = "Option::is_none")]
300 reply_to: Option<String>,
301 },
302 /// Explicit leave notification.
303 MemberLeave {
304 sender_fingerprint: String,
305 /// huddle 2.0.3 (audit N-M2): the room this leave is for. The Ed25519
306 /// signature commits to the payload but NOT the gossip topic, so without
307 /// this a malicious relay could replay a signed leave from room A onto
308 /// room B's topic. `Option` + `skip_serializing_if` keeps the wire
309 /// byte-identical to pre-2.0.3 when unset (graceful back-compat); honest
310 /// receivers cross-check it against the delivery topic when present.
311 #[serde(default, skip_serializing_if = "Option::is_none")]
312 room_id: Option<String>,
313 },
314 /// "I'm rotating the room key — derive a new passphrase key from
315 /// `new_salt` + the new passphrase you'll be told out-of-band, then
316 /// wait for my MemberAnnounce." Phase 3 v1: simplistic — only the
317 /// rotator's outbound changes; receivers must opt in by entering
318 /// the new passphrase to decrypt new wrapped session keys.
319 RotateRoomKey {
320 rotator_fingerprint: String,
321 /// Argon2id salt for the new passphrase-derived key.
322 new_salt: Vec<u8>,
323 /// huddle 2.0.3 (audit N-M2): the room being rotated, cross-checked
324 /// against the delivery topic so a signed rotation can't be replayed
325 /// into another room. Optional for pre-2.0.3 back-compat.
326 #[serde(default, skip_serializing_if = "Option::is_none")]
327 room_id: Option<String>,
328 },
329 /// Ephemeral "I'm typing" signal. TTL on the receive side is 3s.
330 Typing { sender_fingerprint: String },
331 /// Announce a file the sender is about to push. The receiver creates
332 /// an attachment row (status=offered) and waits for chunks. For
333 /// encrypted rooms `encrypted_meta` carries the Megolm-wrapped file
334 /// key + ChaCha20 nonce.
335 FileOffer {
336 sender_fingerprint: String,
337 file_id: String,
338 name: String,
339 size_bytes: u64,
340 mime: Option<String>,
341 chunk_count: u32,
342 encrypted_meta: Option<EncryptedFileMeta>,
343 },
344 /// One chunk of an in-flight file. Receivers reassemble by index
345 /// and verify the final SHA-256 against `file_id`.
346 FileChunk {
347 sender_fingerprint: String,
348 file_id: String,
349 chunk_index: u32,
350 total_chunks: u32,
351 /// base64 of raw chunk bytes (plaintext bytes for unencrypted
352 /// rooms, ChaCha20-Poly1305 ciphertext for encrypted).
353 data_b64: String,
354 },
355 /// Phase B: an existing owner promotes `target_fingerprint` to
356 /// owner. MUST be sent inside `WireMessage::Signed` — the signer
357 /// must be on the room's current `owner_fingerprints` list for
358 /// honest receivers to apply the change.
359 OwnerGrant {
360 room_id: String,
361 target_fingerprint: String,
362 },
363 /// Phase B: an existing owner bans `target_fingerprint` from the
364 /// room. MUST be sent inside `WireMessage::Signed`. Honest clients
365 /// then ignore the banned fingerprint's MemberAnnounce + messages.
366 /// The cryptographic enforcement is the immediate `RotateRoomKey`
367 /// that the banning owner sends right after.
368 BanMember {
369 room_id: String,
370 target_fingerprint: String,
371 },
372 /// Phase G: SAS verification step 1. The initiator picks a random
373 /// `tx_id` and an ephemeral X25519 keypair, sends the pubkey.
374 /// MUST be sent inside `WireMessage::Signed` so the receiver can
375 /// bind this ephemeral key to the initiator's Ed25519 identity.
376 SasInit {
377 tx_id: String,
378 ephemeral_x25519_pubkey_b64: String,
379 target_fingerprint: String,
380 },
381 /// Phase G: SAS step 2 — responder's ephemeral X25519 pubkey.
382 /// Both sides now have what they need to compute the shared
383 /// secret and derive the SAS code locally. Signed.
384 SasResponse {
385 tx_id: String,
386 ephemeral_x25519_pubkey_b64: String,
387 },
388 /// Phase G: SAS step 3 — once both sides have OOB-compared the
389 /// derived code and pressed "Match", each broadcasts this. On
390 /// receiving the partner's `matched=true`, the local side flips
391 /// `verified=1` for the partner's fingerprint. Signed.
392 SasConfirm { tx_id: String, matched: bool },
393 /// Phase E: an existing owner of a `verified_only` room is
394 /// telling `target_fingerprint` (an unverified joiner) why their
395 /// announce went unanswered. Replaces a silent hang on the
396 /// joiner's side. Signed.
397 JoinRefused {
398 room_id: String,
399 target_fingerprint: String,
400 reason: String,
401 },
402 /// Phase F: a joiner is asking to enter a room using a short-lived
403 /// owner-issued code (no passphrase). Includes the joiner's
404 /// ephemeral X25519 pubkey for ECDH key delivery. Signed (so the
405 /// owner knows who's asking).
406 CodeJoinRequest {
407 room_id: String,
408 joiner_x25519_pubkey_b64: String,
409 code: String,
410 },
411 /// Phase F: an issuing owner's response to a valid `CodeJoinRequest`.
412 /// Carries the owner's ephemeral X25519 pubkey + the current Megolm
413 /// session key wrapped under the ECDH-derived key. Joiner does
414 /// X25519 the other direction, derives the same wrap key, unwraps
415 /// the session key. Signed.
416 CodeJoinResponse {
417 room_id: String,
418 target_fingerprint: String,
419 owner_x25519_pubkey_b64: String,
420 owner_session_id: String,
421 wrapped_session_key_b64: String,
422 nonce_b64: String,
423 },
424 /// Phase 0.5: a peer is announcing (or clearing) their self-declared
425 /// username. MUST be sent inside `WireMessage::Signed` — receivers
426 /// require `verified_signer == sender_fingerprint`. Last-write-wins
427 /// by `updated_at` (monotonic ms). `username = None` clears the
428 /// previously-set username and the peer renders as `[anonymous]`.
429 ProfileUpdate {
430 sender_fingerprint: String,
431 username: Option<String>,
432 updated_at: i64,
433 },
434 /// huddle 1.0: a contact/DM request delivered to the target's relay
435 /// inbox (`inbox_room_id`). MUST be sent inside `WireMessage::Signed` —
436 /// the signer's fingerprint IS the requester's identity (the whole
437 /// point: it proves who's asking). Carries the requester's Ed25519
438 /// pubkey so the recipient can TOFU-pin it and later derive the DM ECDH
439 /// key without a round-trip.
440 ContactRequest {
441 requester_fingerprint: String,
442 #[serde(default)]
443 display_name: Option<String>,
444 #[serde(default)]
445 note: Option<String>,
446 #[serde(default)]
447 sender_ed25519_pubkey: Option<String>,
448 },
449 /// huddle 2.0 (F10): add or remove an emoji reaction on another message
450 /// in this room. MUST be sent inside `WireMessage::Signed` — the signer
451 /// must equal `sender_fingerprint`; honest receivers drop unsigned
452 /// reactions (like `FileOffer` / `MemberAnnounce`). The toggle unit is
453 /// one emoji per (sender, target message): `removed = false` adds the
454 /// reaction, `removed = true` clears it. A brand-new variant, so pre-2.0
455 /// peers never produce it and ignore it on receipt.
456 Reaction {
457 sender_fingerprint: String,
458 /// `client_msg_id` of the message being reacted to (must exist in
459 /// this room for honest receivers to apply the reaction).
460 target_msg_id: String,
461 /// A single emoji or short custom code (e.g. "+1", "laugh").
462 emoji: String,
463 /// `false` = add the reaction, `true` = remove it (toggle off).
464 /// `#[serde(default)]` so a missing field decodes to `false` (add).
465 #[serde(default)]
466 removed: bool,
467 },
468 /// huddle 2.0 (F10): edit the body of an existing message. MUST be sent
469 /// inside `WireMessage::Signed`; honest receivers apply it only when the
470 /// signer is the original sender OR a current room owner (moderation).
471 /// Last-write-wins. For encrypted rooms the replacement body rides as a
472 /// fresh Megolm ciphertext in `new_ciphertext_b64` (decrypted exactly
473 /// like an `Encrypted` body, against the carried `session_id`); for
474 /// plaintext rooms it rides as `new_body`.
475 Edit {
476 sender_fingerprint: String,
477 /// `client_msg_id` of the message being edited.
478 target_msg_id: String,
479 /// base64 MegolmMessage bytes of the replacement body, for encrypted
480 /// rooms. Empty for plaintext rooms (which carry the new body in
481 /// `new_body` instead).
482 new_ciphertext_b64: String,
483 /// huddle 2.0 (F10): the Megolm `session_id` the editor encrypted
484 /// `new_ciphertext_b64` under, so receivers decrypt the edit against
485 /// the correct session — exactly like `Encrypted::session_id` — with
486 /// no reliance on an in-memory "last inbound session" guess (which
487 /// failed after a session rotation, after restart, from a second
488 /// device, or before any other message on the session). Empty (and
489 /// omitted on the wire) for plaintext rooms. `#[serde(default,
490 /// skip_serializing_if = "String::is_empty")]` keeps it additive: a
491 /// pre-session-id edit decodes to an empty string and is dropped
492 /// gracefully rather than mis-decrypted.
493 #[serde(default, skip_serializing_if = "String::is_empty")]
494 session_id: String,
495 /// Replacement plaintext body, for unencrypted rooms. `None` (and
496 /// omitted on the wire) for encrypted rooms, whose new body lives in
497 /// `new_ciphertext_b64`.
498 #[serde(default, skip_serializing_if = "Option::is_none")]
499 new_body: Option<String>,
500 },
501 /// huddle 2.0 (F10): delete (tombstone) an existing message. MUST be
502 /// sent inside `WireMessage::Signed`; honest receivers apply it only when
503 /// the signer is the original sender OR a current room owner. Idempotent —
504 /// the original message row is kept and a tombstone is recorded, so the
505 /// body renders as "[deleted]" everywhere.
506 Delete {
507 sender_fingerprint: String,
508 /// `client_msg_id` of the message being deleted.
509 target_msg_id: String,
510 },
511 /// huddle 2.0 (F9): a signed control message that sets this room's
512 /// disappearing-messages TTL. MUST be sent inside `WireMessage::Signed` —
513 /// honest receivers apply it only when the signer is the room creator or a
514 /// current owner. `disappearing_ttl_secs = 0` turns expiry off; any value
515 /// > 0 makes each peer locally auto-delete messages that many seconds
516 /// after they were stored. Pre-2.0 peers ignore the unknown variant and
517 /// never expire their local copies (graceful downgrade).
518 RoomSetting {
519 sender_fingerprint: String,
520 disappearing_ttl_secs: u64,
521 /// huddle 2.0.3 (audit N-M2): the room this setting applies to. Without
522 /// it a malicious relay could replay a signed disappearing-TTL from one
523 /// room (where the signer is an owner) onto another room they also own,
524 /// forcing a retroactive history purge there (chains L-24). Cross-checked
525 /// against the delivery topic on receive. Optional for back-compat.
526 #[serde(default, skip_serializing_if = "Option::is_none")]
527 room_id: Option<String>,
528 },
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534
535 #[test]
536 fn room_announcement_round_trip() {
537 let ann = RoomAnnouncement {
538 room_id: "rid".into(),
539 name: "general".into(),
540 encrypted: true,
541 passphrase_salt: Some(vec![1, 2, 3, 4]),
542 member_count: 3,
543 creator_fingerprint: "creator-fp".into(),
544 announced_at: 100,
545 owner_fingerprints: vec!["creator-fp".into()],
546 verified_only: false,
547 host_addrs: vec![],
548 kind: RoomKind::Group,
549 };
550 let json = serde_json::to_vec(&ann).unwrap();
551 let back: RoomAnnouncement = serde_json::from_slice(&json).unwrap();
552 assert_eq!(back.name, "general");
553 assert_eq!(back.passphrase_salt, Some(vec![1, 2, 3, 4]));
554 assert_eq!(back.kind, RoomKind::Group);
555 }
556
557 #[test]
558 fn room_announcement_direct_kind_round_trip() {
559 let ann = RoomAnnouncement {
560 room_id: "dm-rid".into(),
561 name: "dm".into(),
562 encrypted: false,
563 passphrase_salt: None,
564 member_count: 2,
565 creator_fingerprint: "alice-fp".into(),
566 announced_at: 100,
567 owner_fingerprints: vec![],
568 verified_only: false,
569 host_addrs: vec![],
570 kind: RoomKind::Direct,
571 };
572 let json = serde_json::to_vec(&ann).unwrap();
573 let back: RoomAnnouncement = serde_json::from_slice(&json).unwrap();
574 assert_eq!(back.kind, RoomKind::Direct);
575 }
576
577 #[test]
578 fn room_announcement_missing_kind_defaults_to_group() {
579 // Simulates a pre-0.7 peer's announcement: same JSON shape
580 // without the `kind` field. The serde(default) attribute on the
581 // field must resolve to RoomKind::Group so older peers keep
582 // working unchanged.
583 let pre_0_7_json = serde_json::json!({
584 "room_id": "rid",
585 "name": "general",
586 "encrypted": false,
587 "passphrase_salt": null,
588 "member_count": 1,
589 "creator_fingerprint": "creator-fp",
590 "announced_at": 100,
591 });
592 let back: RoomAnnouncement = serde_json::from_value(pre_0_7_json).unwrap();
593 assert_eq!(back.kind, RoomKind::Group);
594 }
595
596 #[test]
597 fn room_message_variants_round_trip() {
598 let msgs = vec![
599 RoomMessage::MemberAnnounce {
600 sender_fingerprint: "fp".into(),
601 wrapped_session_key: Some("base64data".into()),
602 display_name: Some("Daisy".into()),
603 sender_ed25519_pubkey: Some("AAA=".into()),
604 sender_mlkem_pubkey: Some("BBB=".into()),
605 mlkem_ciphertext: Some("CCC=".into()),
606 },
607 RoomMessage::Plain {
608 sender_fingerprint: "fp".into(),
609 body: "hi".into(),
610 client_msg_id: Some("cmid-1".into()),
611 reply_to: None,
612 },
613 RoomMessage::Encrypted {
614 sender_fingerprint: "fp".into(),
615 session_id: "sid".into(),
616 ciphertext_b64: "ct".into(),
617 client_msg_id: Some("cmid-2".into()),
618 reply_to: Some("cmid-1".into()),
619 },
620 RoomMessage::SessionKeyRequest {
621 requester_fingerprint: "fp".into(),
622 },
623 RoomMessage::MemberLeave {
624 sender_fingerprint: "fp".into(),
625 room_id: None,
626 },
627 RoomMessage::FileOffer {
628 sender_fingerprint: "fp".into(),
629 file_id: "fid".into(),
630 name: "f.bin".into(),
631 size_bytes: 1024,
632 mime: Some("application/octet-stream".into()),
633 chunk_count: 2,
634 encrypted_meta: None,
635 },
636 RoomMessage::FileChunk {
637 sender_fingerprint: "fp".into(),
638 file_id: "fid".into(),
639 chunk_index: 0,
640 total_chunks: 2,
641 data_b64: "AAA=".into(),
642 },
643 RoomMessage::RotateRoomKey {
644 rotator_fingerprint: "fp".into(),
645 new_salt: vec![1u8; 16],
646 room_id: None,
647 },
648 RoomMessage::Typing {
649 sender_fingerprint: "fp".into(),
650 },
651 // huddle 2.0 (F10/F9): new content + control variants.
652 RoomMessage::Reaction {
653 sender_fingerprint: "fp".into(),
654 target_msg_id: "cmid-1".into(),
655 emoji: "👍".into(),
656 removed: false,
657 },
658 RoomMessage::Edit {
659 sender_fingerprint: "fp".into(),
660 target_msg_id: "cmid-1".into(),
661 new_ciphertext_b64: "ct2".into(),
662 session_id: "sid".into(),
663 new_body: None,
664 },
665 RoomMessage::Edit {
666 sender_fingerprint: "fp".into(),
667 target_msg_id: "cmid-1".into(),
668 new_ciphertext_b64: String::new(),
669 session_id: String::new(),
670 new_body: Some("edited body".into()),
671 },
672 RoomMessage::Delete {
673 sender_fingerprint: "fp".into(),
674 target_msg_id: "cmid-1".into(),
675 },
676 RoomMessage::RoomSetting {
677 sender_fingerprint: "fp".into(),
678 disappearing_ttl_secs: 3600,
679 room_id: None,
680 },
681 ];
682 for m in msgs {
683 let json = serde_json::to_vec(&m).unwrap();
684 let back: RoomMessage = serde_json::from_slice(&json).unwrap();
685 assert_eq!(format!("{m:?}"), format!("{back:?}"));
686 }
687 }
688
689 #[test]
690 fn plain_without_new_fields_defaults_to_none() {
691 // Simulates a pre-2.0 peer's Plain message: externally-tagged JSON
692 // with no `client_msg_id` / `reply_to`. The serde(default) attrs must
693 // resolve both to `None` so old senders keep working unchanged.
694 let pre_2_0_json = serde_json::json!({
695 "Plain": {
696 "sender_fingerprint": "fp",
697 "body": "hi",
698 }
699 });
700 let back: RoomMessage = serde_json::from_value(pre_2_0_json).unwrap();
701 match back {
702 RoomMessage::Plain {
703 client_msg_id,
704 reply_to,
705 ..
706 } => {
707 assert_eq!(client_msg_id, None);
708 assert_eq!(reply_to, None);
709 }
710 other => panic!("expected Plain, got {other:?}"),
711 }
712 }
713
714 #[test]
715 fn plain_with_none_ids_omits_fields_on_wire() {
716 // skip_serializing_if must keep the wire bytes byte-compatible with
717 // pre-2.0 peers when the new fields are absent.
718 let m = RoomMessage::Plain {
719 sender_fingerprint: "fp".into(),
720 body: "hi".into(),
721 client_msg_id: None,
722 reply_to: None,
723 };
724 let v = serde_json::to_value(&m).unwrap();
725 let inner = &v["Plain"];
726 assert!(inner.get("client_msg_id").is_none());
727 assert!(inner.get("reply_to").is_none());
728 }
729
730 #[test]
731 fn reaction_missing_removed_defaults_to_false() {
732 // `removed` carries serde(default) so a peer that omits it (an "add"
733 // reaction) decodes to `false`.
734 let json = serde_json::json!({
735 "Reaction": {
736 "sender_fingerprint": "fp",
737 "target_msg_id": "cmid-1",
738 "emoji": "❤️",
739 }
740 });
741 let back: RoomMessage = serde_json::from_value(json).unwrap();
742 match back {
743 RoomMessage::Reaction { removed, emoji, .. } => {
744 assert!(!removed);
745 assert_eq!(emoji, "❤️");
746 }
747 other => panic!("expected Reaction, got {other:?}"),
748 }
749 }
750
751 #[test]
752 fn edit_missing_session_id_defaults_to_empty() {
753 // huddle 2.0.0 (F10): an `Edit` minted before `session_id` was added
754 // to the wire must decode to an empty `session_id` (graceful drop on
755 // the receive side) rather than fail to parse.
756 let json = serde_json::json!({
757 "Edit": {
758 "sender_fingerprint": "fp",
759 "target_msg_id": "cmid-1",
760 "new_ciphertext_b64": "ct2",
761 }
762 });
763 let back: RoomMessage = serde_json::from_value(json).unwrap();
764 match back {
765 RoomMessage::Edit { session_id, .. } => assert_eq!(session_id, ""),
766 other => panic!("expected Edit, got {other:?}"),
767 }
768 }
769
770 #[test]
771 fn edit_empty_session_id_omitted_on_wire() {
772 // A plaintext-room edit has no session; `skip_serializing_if` keeps
773 // those wire bytes free of an empty `session_id`. The encrypted-room
774 // edit, by contrast, always carries one.
775 let plain = RoomMessage::Edit {
776 sender_fingerprint: "fp".into(),
777 target_msg_id: "cmid-1".into(),
778 new_ciphertext_b64: String::new(),
779 session_id: String::new(),
780 new_body: Some("edited".into()),
781 };
782 let v = serde_json::to_value(&plain).unwrap();
783 assert!(v["Edit"].get("session_id").is_none());
784
785 let enc = RoomMessage::Edit {
786 sender_fingerprint: "fp".into(),
787 target_msg_id: "cmid-1".into(),
788 new_ciphertext_b64: "ct2".into(),
789 session_id: "sid".into(),
790 new_body: None,
791 };
792 let v = serde_json::to_value(&enc).unwrap();
793 assert_eq!(v["Edit"]["session_id"], "sid");
794 }
795
796 #[test]
797 fn room_topic_format() {
798 assert_eq!(room_topic("abc123"), "huddle-room-abc123");
799 }
800}