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;
12
13pub const ROOMS_TOPIC: &str = "huddle-rooms-v1";
14pub const ROOM_TOPIC_PREFIX: &str = "huddle-room-";
15
16pub fn room_topic(room_id: &str) -> String {
17    format!("{ROOM_TOPIC_PREFIX}{room_id}")
18}
19
20/// Broadcast on the global ROOMS_TOPIC. Each peer republishes the rooms
21/// they're currently in, periodically. Listeners maintain a cache with TTL.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct RoomAnnouncement {
24    pub room_id: String,
25    pub name: String,
26    pub encrypted: bool,
27    /// Argon2id salt — present iff `encrypted`. Joiners derive their
28    /// passphrase key from (passphrase, salt) to unwrap session keys.
29    pub passphrase_salt: Option<Vec<u8>>,
30    pub member_count: u32,
31    pub creator_fingerprint: String,
32    /// Seconds since UNIX_EPOCH when this announcement was emitted.
33    pub announced_at: i64,
34}
35
36/// All messages on a room's per-room topic.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub enum RoomMessage {
39    /// Announce my presence in the room. For encrypted rooms, also share
40    /// my Megolm session key (passphrase-wrapped).
41    MemberAnnounce {
42        sender_fingerprint: String,
43        /// base64(nonce || chacha20poly1305_ciphertext) of the Megolm
44        /// SessionKey, encrypted under the passphrase-derived key.
45        /// None for unencrypted rooms.
46        wrapped_session_key: Option<String>,
47        /// Optional human-readable display name. Serde defaults to
48        /// `None` for forward compat with older peers.
49        #[serde(default)]
50        display_name: Option<String>,
51    },
52    /// A request from a recently-joined member: "I need session keys".
53    /// Existing members respond with MemberAnnounce.
54    SessionKeyRequest {
55        requester_fingerprint: String,
56    },
57    /// An encrypted message in an encrypted room.
58    Encrypted {
59        sender_fingerprint: String,
60        session_id: String,
61        /// base64-encoded MegolmMessage bytes
62        ciphertext_b64: String,
63    },
64    /// A plaintext message in an unencrypted room.
65    Plain {
66        sender_fingerprint: String,
67        body: String,
68    },
69    /// Explicit leave notification.
70    MemberLeave {
71        sender_fingerprint: String,
72    },
73    /// "I'm rotating the room key — derive a new passphrase key from
74    /// `new_salt` + the new passphrase you'll be told out-of-band, then
75    /// wait for my MemberAnnounce." Phase 3 v1: simplistic — only the
76    /// rotator's outbound changes; receivers must opt in by entering
77    /// the new passphrase to decrypt new wrapped session keys.
78    RotateRoomKey {
79        rotator_fingerprint: String,
80        /// Argon2id salt for the new passphrase-derived key.
81        new_salt: Vec<u8>,
82    },
83    /// Ephemeral "I'm typing" signal. TTL on the receive side is 3s.
84    Typing {
85        sender_fingerprint: String,
86    },
87    /// Announce a file the sender is about to push. The receiver creates
88    /// an attachment row (status=offered) and waits for chunks. For
89    /// encrypted rooms `encrypted_meta` carries the Megolm-wrapped file
90    /// key + ChaCha20 nonce.
91    FileOffer {
92        sender_fingerprint: String,
93        file_id: String,
94        name: String,
95        size_bytes: u64,
96        mime: Option<String>,
97        chunk_count: u32,
98        encrypted_meta: Option<EncryptedFileMeta>,
99    },
100    /// One chunk of an in-flight file. Receivers reassemble by index
101    /// and verify the final SHA-256 against `file_id`.
102    FileChunk {
103        sender_fingerprint: String,
104        file_id: String,
105        chunk_index: u32,
106        total_chunks: u32,
107        /// base64 of raw chunk bytes (plaintext bytes for unencrypted
108        /// rooms, ChaCha20-Poly1305 ciphertext for encrypted).
109        data_b64: String,
110    },
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn room_announcement_round_trip() {
119        let ann = RoomAnnouncement {
120            room_id: "rid".into(),
121            name: "general".into(),
122            encrypted: true,
123            passphrase_salt: Some(vec![1, 2, 3, 4]),
124            member_count: 3,
125            creator_fingerprint: "creator-fp".into(),
126            announced_at: 100,
127        };
128        let json = serde_json::to_vec(&ann).unwrap();
129        let back: RoomAnnouncement = serde_json::from_slice(&json).unwrap();
130        assert_eq!(back.name, "general");
131        assert_eq!(back.passphrase_salt, Some(vec![1, 2, 3, 4]));
132    }
133
134    #[test]
135    fn room_message_variants_round_trip() {
136        let msgs = vec![
137            RoomMessage::MemberAnnounce {
138                sender_fingerprint: "fp".into(),
139                wrapped_session_key: Some("base64data".into()),
140                display_name: Some("Daisy".into()),
141            },
142            RoomMessage::Plain {
143                sender_fingerprint: "fp".into(),
144                body: "hi".into(),
145            },
146            RoomMessage::Encrypted {
147                sender_fingerprint: "fp".into(),
148                session_id: "sid".into(),
149                ciphertext_b64: "ct".into(),
150            },
151            RoomMessage::SessionKeyRequest {
152                requester_fingerprint: "fp".into(),
153            },
154            RoomMessage::MemberLeave {
155                sender_fingerprint: "fp".into(),
156            },
157            RoomMessage::FileOffer {
158                sender_fingerprint: "fp".into(),
159                file_id: "fid".into(),
160                name: "f.bin".into(),
161                size_bytes: 1024,
162                mime: Some("application/octet-stream".into()),
163                chunk_count: 2,
164                encrypted_meta: None,
165            },
166            RoomMessage::FileChunk {
167                sender_fingerprint: "fp".into(),
168                file_id: "fid".into(),
169                chunk_index: 0,
170                total_chunks: 2,
171                data_b64: "AAA=".into(),
172            },
173            RoomMessage::RotateRoomKey {
174                rotator_fingerprint: "fp".into(),
175                new_salt: vec![1u8; 16],
176            },
177            RoomMessage::Typing {
178                sender_fingerprint: "fp".into(),
179            },
180        ];
181        for m in msgs {
182            let json = serde_json::to_vec(&m).unwrap();
183            let back: RoomMessage = serde_json::from_slice(&json).unwrap();
184            assert_eq!(format!("{m:?}"), format!("{back:?}"));
185        }
186    }
187
188    #[test]
189    fn room_topic_format() {
190        assert_eq!(room_topic("abc123"), "huddle-room-abc123");
191    }
192}