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
11pub const ROOMS_TOPIC: &str = "huddle-rooms-v1";
12pub const ROOM_TOPIC_PREFIX: &str = "huddle-room-";
13
14pub fn room_topic(room_id: &str) -> String {
15    format!("{ROOM_TOPIC_PREFIX}{room_id}")
16}
17
18/// Broadcast on the global ROOMS_TOPIC. Each peer republishes the rooms
19/// they're currently in, periodically. Listeners maintain a cache with TTL.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct RoomAnnouncement {
22    pub room_id: String,
23    pub name: String,
24    pub encrypted: bool,
25    /// Argon2id salt — present iff `encrypted`. Joiners derive their
26    /// passphrase key from (passphrase, salt) to unwrap session keys.
27    pub passphrase_salt: Option<Vec<u8>>,
28    pub member_count: u32,
29    pub creator_fingerprint: String,
30    /// Seconds since UNIX_EPOCH when this announcement was emitted.
31    pub announced_at: i64,
32}
33
34/// All messages on a room's per-room topic.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub enum RoomMessage {
37    /// Announce my presence in the room. For encrypted rooms, also share
38    /// my Megolm session key (passphrase-wrapped).
39    MemberAnnounce {
40        sender_fingerprint: String,
41        /// base64(nonce || chacha20poly1305_ciphertext) of the Megolm
42        /// SessionKey, encrypted under the passphrase-derived key.
43        /// None for unencrypted rooms.
44        wrapped_session_key: Option<String>,
45    },
46    /// A request from a recently-joined member: "I need session keys".
47    /// Existing members respond with MemberAnnounce.
48    SessionKeyRequest {
49        requester_fingerprint: String,
50    },
51    /// An encrypted message in an encrypted room.
52    Encrypted {
53        sender_fingerprint: String,
54        session_id: String,
55        /// base64-encoded MegolmMessage bytes
56        ciphertext_b64: String,
57    },
58    /// A plaintext message in an unencrypted room.
59    Plain {
60        sender_fingerprint: String,
61        body: String,
62    },
63    /// Explicit leave notification.
64    MemberLeave {
65        sender_fingerprint: String,
66    },
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn room_announcement_round_trip() {
75        let ann = RoomAnnouncement {
76            room_id: "rid".into(),
77            name: "general".into(),
78            encrypted: true,
79            passphrase_salt: Some(vec![1, 2, 3, 4]),
80            member_count: 3,
81            creator_fingerprint: "creator-fp".into(),
82            announced_at: 100,
83        };
84        let json = serde_json::to_vec(&ann).unwrap();
85        let back: RoomAnnouncement = serde_json::from_slice(&json).unwrap();
86        assert_eq!(back.name, "general");
87        assert_eq!(back.passphrase_salt, Some(vec![1, 2, 3, 4]));
88    }
89
90    #[test]
91    fn room_message_variants_round_trip() {
92        let msgs = vec![
93            RoomMessage::MemberAnnounce {
94                sender_fingerprint: "fp".into(),
95                wrapped_session_key: Some("base64data".into()),
96            },
97            RoomMessage::Plain {
98                sender_fingerprint: "fp".into(),
99                body: "hi".into(),
100            },
101            RoomMessage::Encrypted {
102                sender_fingerprint: "fp".into(),
103                session_id: "sid".into(),
104                ciphertext_b64: "ct".into(),
105            },
106            RoomMessage::SessionKeyRequest {
107                requester_fingerprint: "fp".into(),
108            },
109            RoomMessage::MemberLeave {
110                sender_fingerprint: "fp".into(),
111            },
112        ];
113        for m in msgs {
114            let json = serde_json::to_vec(&m).unwrap();
115            let back: RoomMessage = serde_json::from_slice(&json).unwrap();
116            assert_eq!(format!("{m:?}"), format!("{back:?}"));
117        }
118    }
119
120    #[test]
121    fn room_topic_format() {
122        assert_eq!(room_topic("abc123"), "huddle-room-abc123");
123    }
124}