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}