Skip to main content

huddle_core/network/
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};
10
11use crate::files::encryption::EncryptedFileMeta;
12use crate::storage::repo::RoomKind;
13
14pub const ROOMS_TOPIC: &str = "huddle-rooms-v1";
15pub const ROOM_TOPIC_PREFIX: &str = "huddle-room-";
16
17pub fn room_topic(room_id: &str) -> String {
18    format!("{ROOM_TOPIC_PREFIX}{room_id}")
19}
20
21/// Application-level signed envelope around a `RoomMessage`. Used for
22/// any message whose authenticity matters beyond gossipsub's transport-
23/// level signing.
24///
25/// The following variants MUST be sent inside a `Signed` envelope, and
26/// receivers MUST drop them when they arrive unsigned:
27///   - `MemberLeave` (signer must equal the claimed `sender_fingerprint`;
28///     huddle 0.7.11 — closes the unsigned-leave spoof bug)
29///   - `MemberAnnounce` (signer must equal the claimed `sender_fingerprint`;
30///     huddle 0.7.11 — closes the TOFU-pubkey hijack bug)
31///   - `FileOffer` (signer must equal the claimed `sender_fingerprint`;
32///     huddle 0.7.11 — prevents attribution spoofing)
33///   - `RotateRoomKey` (signer must equal the claimed `rotator_fingerprint`)
34///   - `OwnerGrant`, `BanMember` (signer must be a current room owner)
35///   - `SasInit`, `SasResponse`, `SasConfirm` (SAS handshake — signature
36///     binds the ephemeral X25519 pubkey to the sender's identity)
37///   - `CodeJoinRequest`, `CodeJoinResponse` (signer is the joiner /
38///     owner)
39///   - `JoinRefused` (signer is a room owner; tells the rejected joiner
40///     it really came from the room)
41///   - `ProfileUpdate` (signer must equal the claimed `sender_fingerprint`;
42///     prevents anyone from spoofing another peer's username)
43///
44/// Verification happens via `crate::crypto::verify_signed`: it re-derives
45/// the fingerprint from `ed25519_pubkey_b64`, asserts equality with
46/// `fingerprint`, runs `Ed25519::verify_strict` over the decoded
47/// `payload_b64`, and rejects envelopes whose `signed_at_ms` falls
48/// outside a ±5 min window from now (replay protection).
49///
50/// Format choice: payload is base64'd serialized `RoomMessage` JSON
51/// (not the JSON bytes directly) so the envelope itself is plain JSON
52/// without escaping nightmares.
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54pub struct SignedRoomMessage {
55    pub fingerprint: String,
56    pub ed25519_pubkey_b64: String,
57    pub payload_b64: String,
58    pub signature_b64: String,
59    /// huddle 0.7.11: epoch-ms timestamp the sender bound into the
60    /// signature, used by receivers as replay protection. `#[serde(default)]`
61    /// for forward-compat parsing — but the verifier rejects `0` and
62    /// values outside the configured window, so legacy pre-0.7.11 senders
63    /// no longer satisfy `verify_signed`.
64    #[serde(default)]
65    pub signed_at_ms: i64,
66}
67
68/// What actually gets serialized onto a per-room gossipsub topic. New
69/// in v0.3.0 — previously, the raw `RoomMessage` JSON went on the wire.
70/// All outgoing messages now flow through this envelope so the receiver
71/// can tell signed from unsigned at the outer layer without trial-
72/// parsing. Tagged so future variants don't silently misparse.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74#[serde(tag = "type", content = "data", rename_all = "snake_case")]
75pub enum WireMessage {
76    /// Unsigned — equivalent to the old wire format. Used for messages
77    /// whose authenticity isn't security-critical: `Plain`, `Typing`,
78    /// `MemberAnnounce` (the wrapped key in encrypted rooms is itself
79    /// AEAD-authenticated), `FileChunk`, etc.
80    Plain(RoomMessage),
81    /// App-level Ed25519-signed envelope.
82    Signed(SignedRoomMessage),
83}
84
85/// Serialize an unsigned `RoomMessage` to its on-wire bytes inside the
86/// new `WireMessage::Plain` envelope. The single helper keeps every send
87/// site in `app/mod.rs` from open-coding the wrap.
88pub fn encode_wire(msg: &RoomMessage) -> serde_json::Result<Vec<u8>> {
89    serde_json::to_vec(&WireMessage::Plain(msg.clone()))
90}
91
92/// Serialize a `SignedRoomMessage` envelope to its on-wire bytes.
93pub fn encode_wire_signed(env: &SignedRoomMessage) -> serde_json::Result<Vec<u8>> {
94    serde_json::to_vec(&WireMessage::Signed(env.clone()))
95}
96
97/// Broadcast on the global ROOMS_TOPIC. Each peer republishes the rooms
98/// they're currently in, periodically. Listeners maintain a cache with TTL.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct RoomAnnouncement {
101    pub room_id: String,
102    pub name: String,
103    pub encrypted: bool,
104    /// Argon2id salt — present iff `encrypted`. Joiners derive their
105    /// passphrase key from (passphrase, salt) to unwrap session keys.
106    pub passphrase_salt: Option<Vec<u8>>,
107    pub member_count: u32,
108    pub creator_fingerprint: String,
109    /// Seconds since UNIX_EPOCH when this announcement was emitted.
110    pub announced_at: i64,
111    /// Phase B: fingerprints with role = 'owner' — the soft moderator
112    /// set. Newcomers learn from this who's authorized to grant other
113    /// owners and to issue bans (signed via `SignedRoomMessage`).
114    /// `#[serde(default)]` for forward-compat with pre-0.3 senders.
115    #[serde(default)]
116    pub owner_fingerprints: Vec<String>,
117    /// Phase E: when true, existing members refuse to wrap their
118    /// session key for a joiner whose fingerprint isn't in the
119    /// global `verified_peers` set. Joiner sees a `JoinRefused`
120    /// reply from at least one owner so the UX isn't a silent hang.
121    /// `#[serde(default)]` so pre-0.3 senders default to permissive.
122    #[serde(default)]
123    pub verified_only: bool,
124    /// Phase D follow-up: dialable multiaddrs of the announcing node.
125    /// Populated from AutoNAT-confirmed external addresses + relay
126    /// circuit reservations (capped at 4 entries to keep the
127    /// announcement small). Empty for pre-0.3-followup senders.
128    ///
129    /// Consumer: when a peer sees an announcement with non-empty
130    /// `host_addrs` and isn't already connected to `creator_fingerprint`,
131    /// it opportunistically dials the first entry. This lets cross-
132    /// internet peers bootstrap via relay-circuit addresses without
133    /// requiring an invite link.
134    #[serde(default)]
135    pub host_addrs: Vec<String>,
136    /// huddle 0.7: explicit room kind. `RoomKind::Direct` (1-1 DM) is
137    /// filtered out by honest 0.7+ consumers if neither member is them —
138    /// DMs never leak past the two participants' sidebars. Pre-0.7
139    /// peers omit the field, which `#[serde(default)]` resolves to
140    /// `RoomKind::Group` (`Default` impl) — they keep working unchanged.
141    #[serde(default)]
142    pub kind: RoomKind,
143}
144
145/// All messages on a room's per-room topic.
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub enum RoomMessage {
148    /// Announce my presence in the room. For encrypted rooms, also share
149    /// my Megolm session key (passphrase-wrapped).
150    MemberAnnounce {
151        sender_fingerprint: String,
152        /// base64(nonce || chacha20poly1305_ciphertext) of the Megolm
153        /// SessionKey, encrypted under the passphrase-derived key.
154        /// None for unencrypted rooms.
155        wrapped_session_key: Option<String>,
156        /// Optional human-readable display name. Serde defaults to
157        /// `None` for forward compat with older peers.
158        #[serde(default)]
159        display_name: Option<String>,
160        /// Base64 of the sender's 32-byte Ed25519 public key. Lets every
161        /// existing member learn the new member's pubkey on first contact,
162        /// so they can verify future `SignedRoomMessage` envelopes from
163        /// this fingerprint. `#[serde(default)]` for forward compat: a
164        /// pre-0.3.0 peer that doesn't send this still works, but its
165        /// signed messages can't be verified until it re-announces.
166        #[serde(default)]
167        sender_ed25519_pubkey: Option<String>,
168    },
169    /// A request from a recently-joined member: "I need session keys".
170    /// Existing members respond with MemberAnnounce.
171    SessionKeyRequest {
172        requester_fingerprint: String,
173    },
174    /// An encrypted message in an encrypted room.
175    Encrypted {
176        sender_fingerprint: String,
177        session_id: String,
178        /// base64-encoded MegolmMessage bytes
179        ciphertext_b64: String,
180    },
181    /// A plaintext message in an unencrypted room.
182    Plain {
183        sender_fingerprint: String,
184        body: String,
185    },
186    /// Explicit leave notification.
187    MemberLeave {
188        sender_fingerprint: String,
189    },
190    /// "I'm rotating the room key — derive a new passphrase key from
191    /// `new_salt` + the new passphrase you'll be told out-of-band, then
192    /// wait for my MemberAnnounce." Phase 3 v1: simplistic — only the
193    /// rotator's outbound changes; receivers must opt in by entering
194    /// the new passphrase to decrypt new wrapped session keys.
195    RotateRoomKey {
196        rotator_fingerprint: String,
197        /// Argon2id salt for the new passphrase-derived key.
198        new_salt: Vec<u8>,
199    },
200    /// Ephemeral "I'm typing" signal. TTL on the receive side is 3s.
201    Typing {
202        sender_fingerprint: String,
203    },
204    /// Announce a file the sender is about to push. The receiver creates
205    /// an attachment row (status=offered) and waits for chunks. For
206    /// encrypted rooms `encrypted_meta` carries the Megolm-wrapped file
207    /// key + ChaCha20 nonce.
208    FileOffer {
209        sender_fingerprint: String,
210        file_id: String,
211        name: String,
212        size_bytes: u64,
213        mime: Option<String>,
214        chunk_count: u32,
215        encrypted_meta: Option<EncryptedFileMeta>,
216    },
217    /// One chunk of an in-flight file. Receivers reassemble by index
218    /// and verify the final SHA-256 against `file_id`.
219    FileChunk {
220        sender_fingerprint: String,
221        file_id: String,
222        chunk_index: u32,
223        total_chunks: u32,
224        /// base64 of raw chunk bytes (plaintext bytes for unencrypted
225        /// rooms, ChaCha20-Poly1305 ciphertext for encrypted).
226        data_b64: String,
227    },
228    /// Phase B: an existing owner promotes `target_fingerprint` to
229    /// owner. MUST be sent inside `WireMessage::Signed` — the signer
230    /// must be on the room's current `owner_fingerprints` list for
231    /// honest receivers to apply the change.
232    OwnerGrant {
233        room_id: String,
234        target_fingerprint: String,
235    },
236    /// Phase B: an existing owner bans `target_fingerprint` from the
237    /// room. MUST be sent inside `WireMessage::Signed`. Honest clients
238    /// then ignore the banned fingerprint's MemberAnnounce + messages.
239    /// The cryptographic enforcement is the immediate `RotateRoomKey`
240    /// that the banning owner sends right after.
241    BanMember {
242        room_id: String,
243        target_fingerprint: String,
244    },
245    /// Phase G: SAS verification step 1. The initiator picks a random
246    /// `tx_id` and an ephemeral X25519 keypair, sends the pubkey.
247    /// MUST be sent inside `WireMessage::Signed` so the receiver can
248    /// bind this ephemeral key to the initiator's Ed25519 identity.
249    SasInit {
250        tx_id: String,
251        ephemeral_x25519_pubkey_b64: String,
252        target_fingerprint: String,
253    },
254    /// Phase G: SAS step 2 — responder's ephemeral X25519 pubkey.
255    /// Both sides now have what they need to compute the shared
256    /// secret and derive the SAS code locally. Signed.
257    SasResponse {
258        tx_id: String,
259        ephemeral_x25519_pubkey_b64: String,
260    },
261    /// Phase G: SAS step 3 — once both sides have OOB-compared the
262    /// derived code and pressed "Match", each broadcasts this. On
263    /// receiving the partner's `matched=true`, the local side flips
264    /// `verified=1` for the partner's fingerprint. Signed.
265    SasConfirm {
266        tx_id: String,
267        matched: bool,
268    },
269    /// Phase E: an existing owner of a `verified_only` room is
270    /// telling `target_fingerprint` (an unverified joiner) why their
271    /// announce went unanswered. Replaces a silent hang on the
272    /// joiner's side. Signed.
273    JoinRefused {
274        room_id: String,
275        target_fingerprint: String,
276        reason: String,
277    },
278    /// Phase F: a joiner is asking to enter a room using a short-lived
279    /// owner-issued code (no passphrase). Includes the joiner's
280    /// ephemeral X25519 pubkey for ECDH key delivery. Signed (so the
281    /// owner knows who's asking).
282    CodeJoinRequest {
283        room_id: String,
284        joiner_x25519_pubkey_b64: String,
285        code: String,
286    },
287    /// Phase F: an issuing owner's response to a valid `CodeJoinRequest`.
288    /// Carries the owner's ephemeral X25519 pubkey + the current Megolm
289    /// session key wrapped under the ECDH-derived key. Joiner does
290    /// X25519 the other direction, derives the same wrap key, unwraps
291    /// the session key. Signed.
292    CodeJoinResponse {
293        room_id: String,
294        target_fingerprint: String,
295        owner_x25519_pubkey_b64: String,
296        owner_session_id: String,
297        wrapped_session_key_b64: String,
298        nonce_b64: String,
299    },
300    /// Phase 0.5: a peer is announcing (or clearing) their self-declared
301    /// username. MUST be sent inside `WireMessage::Signed` — receivers
302    /// require `verified_signer == sender_fingerprint`. Last-write-wins
303    /// by `updated_at` (monotonic ms). `username = None` clears the
304    /// previously-set username and the peer renders as `[anonymous]`.
305    ProfileUpdate {
306        sender_fingerprint: String,
307        username: Option<String>,
308        updated_at: i64,
309    },
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn room_announcement_round_trip() {
318        let ann = RoomAnnouncement {
319            room_id: "rid".into(),
320            name: "general".into(),
321            encrypted: true,
322            passphrase_salt: Some(vec![1, 2, 3, 4]),
323            member_count: 3,
324            creator_fingerprint: "creator-fp".into(),
325            announced_at: 100,
326            owner_fingerprints: vec!["creator-fp".into()],
327            verified_only: false,
328            host_addrs: vec![],
329            kind: RoomKind::Group,
330        };
331        let json = serde_json::to_vec(&ann).unwrap();
332        let back: RoomAnnouncement = serde_json::from_slice(&json).unwrap();
333        assert_eq!(back.name, "general");
334        assert_eq!(back.passphrase_salt, Some(vec![1, 2, 3, 4]));
335        assert_eq!(back.kind, RoomKind::Group);
336    }
337
338    #[test]
339    fn room_announcement_direct_kind_round_trip() {
340        let ann = RoomAnnouncement {
341            room_id: "dm-rid".into(),
342            name: "dm".into(),
343            encrypted: false,
344            passphrase_salt: None,
345            member_count: 2,
346            creator_fingerprint: "alice-fp".into(),
347            announced_at: 100,
348            owner_fingerprints: vec![],
349            verified_only: false,
350            host_addrs: vec![],
351            kind: RoomKind::Direct,
352        };
353        let json = serde_json::to_vec(&ann).unwrap();
354        let back: RoomAnnouncement = serde_json::from_slice(&json).unwrap();
355        assert_eq!(back.kind, RoomKind::Direct);
356    }
357
358    #[test]
359    fn room_announcement_missing_kind_defaults_to_group() {
360        // Simulates a pre-0.7 peer's announcement: same JSON shape
361        // without the `kind` field. The serde(default) attribute on the
362        // field must resolve to RoomKind::Group so older peers keep
363        // working unchanged.
364        let pre_0_7_json = serde_json::json!({
365            "room_id": "rid",
366            "name": "general",
367            "encrypted": false,
368            "passphrase_salt": null,
369            "member_count": 1,
370            "creator_fingerprint": "creator-fp",
371            "announced_at": 100,
372        });
373        let back: RoomAnnouncement = serde_json::from_value(pre_0_7_json).unwrap();
374        assert_eq!(back.kind, RoomKind::Group);
375    }
376
377    #[test]
378    fn room_message_variants_round_trip() {
379        let msgs = vec![
380            RoomMessage::MemberAnnounce {
381                sender_fingerprint: "fp".into(),
382                wrapped_session_key: Some("base64data".into()),
383                display_name: Some("Daisy".into()),
384                sender_ed25519_pubkey: Some("AAA=".into()),
385            },
386            RoomMessage::Plain {
387                sender_fingerprint: "fp".into(),
388                body: "hi".into(),
389            },
390            RoomMessage::Encrypted {
391                sender_fingerprint: "fp".into(),
392                session_id: "sid".into(),
393                ciphertext_b64: "ct".into(),
394            },
395            RoomMessage::SessionKeyRequest {
396                requester_fingerprint: "fp".into(),
397            },
398            RoomMessage::MemberLeave {
399                sender_fingerprint: "fp".into(),
400            },
401            RoomMessage::FileOffer {
402                sender_fingerprint: "fp".into(),
403                file_id: "fid".into(),
404                name: "f.bin".into(),
405                size_bytes: 1024,
406                mime: Some("application/octet-stream".into()),
407                chunk_count: 2,
408                encrypted_meta: None,
409            },
410            RoomMessage::FileChunk {
411                sender_fingerprint: "fp".into(),
412                file_id: "fid".into(),
413                chunk_index: 0,
414                total_chunks: 2,
415                data_b64: "AAA=".into(),
416            },
417            RoomMessage::RotateRoomKey {
418                rotator_fingerprint: "fp".into(),
419                new_salt: vec![1u8; 16],
420            },
421            RoomMessage::Typing {
422                sender_fingerprint: "fp".into(),
423            },
424        ];
425        for m in msgs {
426            let json = serde_json::to_vec(&m).unwrap();
427            let back: RoomMessage = serde_json::from_slice(&json).unwrap();
428            assert_eq!(format!("{m:?}"), format!("{back:?}"));
429        }
430    }
431
432    #[test]
433    fn room_topic_format() {
434        assert_eq!(room_topic("abc123"), "huddle-room-abc123");
435    }
436}