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/// Application-level signed envelope around a `RoomMessage`. Used for
21/// any message whose authenticity matters beyond gossipsub's transport-
22/// level signing — `RotateRoomKey`, `OwnerGrant`, `BanMember`, SAS
23/// handshakes, code-join requests/responses, `JoinRefused`.
24///
25/// Verification happens via `crate::crypto::verify_signed`: it re-derives
26/// the fingerprint from `ed25519_pubkey_b64`, asserts equality with
27/// `fingerprint`, then `Ed25519::verify` over `payload_b64` decoded.
28///
29/// Format choice: payload is base64'd serialized `RoomMessage` JSON
30/// (not the JSON bytes directly) so the envelope itself is plain JSON
31/// without escaping nightmares.
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub struct SignedRoomMessage {
34    pub fingerprint: String,
35    pub ed25519_pubkey_b64: String,
36    pub payload_b64: String,
37    pub signature_b64: String,
38}
39
40/// What actually gets serialized onto a per-room gossipsub topic. New
41/// in v0.3.0 — previously, the raw `RoomMessage` JSON went on the wire.
42/// All outgoing messages now flow through this envelope so the receiver
43/// can tell signed from unsigned at the outer layer without trial-
44/// parsing. Tagged so future variants don't silently misparse.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(tag = "type", content = "data", rename_all = "snake_case")]
47pub enum WireMessage {
48    /// Unsigned — equivalent to the old wire format. Used for messages
49    /// whose authenticity isn't security-critical: `Plain`, `Typing`,
50    /// `MemberAnnounce` (the wrapped key in encrypted rooms is itself
51    /// AEAD-authenticated), `FileChunk`, etc.
52    Plain(RoomMessage),
53    /// App-level Ed25519-signed envelope.
54    Signed(SignedRoomMessage),
55}
56
57/// Serialize an unsigned `RoomMessage` to its on-wire bytes inside the
58/// new `WireMessage::Plain` envelope. The single helper keeps every send
59/// site in `app/mod.rs` from open-coding the wrap.
60pub fn encode_wire(msg: &RoomMessage) -> serde_json::Result<Vec<u8>> {
61    serde_json::to_vec(&WireMessage::Plain(msg.clone()))
62}
63
64/// Serialize a `SignedRoomMessage` envelope to its on-wire bytes.
65pub fn encode_wire_signed(env: &SignedRoomMessage) -> serde_json::Result<Vec<u8>> {
66    serde_json::to_vec(&WireMessage::Signed(env.clone()))
67}
68
69/// Broadcast on the global ROOMS_TOPIC. Each peer republishes the rooms
70/// they're currently in, periodically. Listeners maintain a cache with TTL.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct RoomAnnouncement {
73    pub room_id: String,
74    pub name: String,
75    pub encrypted: bool,
76    /// Argon2id salt — present iff `encrypted`. Joiners derive their
77    /// passphrase key from (passphrase, salt) to unwrap session keys.
78    pub passphrase_salt: Option<Vec<u8>>,
79    pub member_count: u32,
80    pub creator_fingerprint: String,
81    /// Seconds since UNIX_EPOCH when this announcement was emitted.
82    pub announced_at: i64,
83    /// Phase B: fingerprints with role = 'owner' — the soft moderator
84    /// set. Newcomers learn from this who's authorized to grant other
85    /// owners and to issue bans (signed via `SignedRoomMessage`).
86    /// `#[serde(default)]` for forward-compat with pre-0.3 senders.
87    #[serde(default)]
88    pub owner_fingerprints: Vec<String>,
89    /// Phase E: when true, existing members refuse to wrap their
90    /// session key for a joiner whose fingerprint isn't in the
91    /// global `verified_peers` set. Joiner sees a `JoinRefused`
92    /// reply from at least one owner so the UX isn't a silent hang.
93    /// `#[serde(default)]` so pre-0.3 senders default to permissive.
94    #[serde(default)]
95    pub verified_only: bool,
96}
97
98/// All messages on a room's per-room topic.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub enum RoomMessage {
101    /// Announce my presence in the room. For encrypted rooms, also share
102    /// my Megolm session key (passphrase-wrapped).
103    MemberAnnounce {
104        sender_fingerprint: String,
105        /// base64(nonce || chacha20poly1305_ciphertext) of the Megolm
106        /// SessionKey, encrypted under the passphrase-derived key.
107        /// None for unencrypted rooms.
108        wrapped_session_key: Option<String>,
109        /// Optional human-readable display name. Serde defaults to
110        /// `None` for forward compat with older peers.
111        #[serde(default)]
112        display_name: Option<String>,
113        /// Base64 of the sender's 32-byte Ed25519 public key. Lets every
114        /// existing member learn the new member's pubkey on first contact,
115        /// so they can verify future `SignedRoomMessage` envelopes from
116        /// this fingerprint. `#[serde(default)]` for forward compat: a
117        /// pre-0.3.0 peer that doesn't send this still works, but its
118        /// signed messages can't be verified until it re-announces.
119        #[serde(default)]
120        sender_ed25519_pubkey: Option<String>,
121    },
122    /// A request from a recently-joined member: "I need session keys".
123    /// Existing members respond with MemberAnnounce.
124    SessionKeyRequest {
125        requester_fingerprint: String,
126    },
127    /// An encrypted message in an encrypted room.
128    Encrypted {
129        sender_fingerprint: String,
130        session_id: String,
131        /// base64-encoded MegolmMessage bytes
132        ciphertext_b64: String,
133    },
134    /// A plaintext message in an unencrypted room.
135    Plain {
136        sender_fingerprint: String,
137        body: String,
138    },
139    /// Explicit leave notification.
140    MemberLeave {
141        sender_fingerprint: String,
142    },
143    /// "I'm rotating the room key — derive a new passphrase key from
144    /// `new_salt` + the new passphrase you'll be told out-of-band, then
145    /// wait for my MemberAnnounce." Phase 3 v1: simplistic — only the
146    /// rotator's outbound changes; receivers must opt in by entering
147    /// the new passphrase to decrypt new wrapped session keys.
148    RotateRoomKey {
149        rotator_fingerprint: String,
150        /// Argon2id salt for the new passphrase-derived key.
151        new_salt: Vec<u8>,
152    },
153    /// Ephemeral "I'm typing" signal. TTL on the receive side is 3s.
154    Typing {
155        sender_fingerprint: String,
156    },
157    /// Announce a file the sender is about to push. The receiver creates
158    /// an attachment row (status=offered) and waits for chunks. For
159    /// encrypted rooms `encrypted_meta` carries the Megolm-wrapped file
160    /// key + ChaCha20 nonce.
161    FileOffer {
162        sender_fingerprint: String,
163        file_id: String,
164        name: String,
165        size_bytes: u64,
166        mime: Option<String>,
167        chunk_count: u32,
168        encrypted_meta: Option<EncryptedFileMeta>,
169    },
170    /// One chunk of an in-flight file. Receivers reassemble by index
171    /// and verify the final SHA-256 against `file_id`.
172    FileChunk {
173        sender_fingerprint: String,
174        file_id: String,
175        chunk_index: u32,
176        total_chunks: u32,
177        /// base64 of raw chunk bytes (plaintext bytes for unencrypted
178        /// rooms, ChaCha20-Poly1305 ciphertext for encrypted).
179        data_b64: String,
180    },
181    /// Phase B: an existing owner promotes `target_fingerprint` to
182    /// owner. MUST be sent inside `WireMessage::Signed` — the signer
183    /// must be on the room's current `owner_fingerprints` list for
184    /// honest receivers to apply the change.
185    OwnerGrant {
186        room_id: String,
187        target_fingerprint: String,
188    },
189    /// Phase B: an existing owner bans `target_fingerprint` from the
190    /// room. MUST be sent inside `WireMessage::Signed`. Honest clients
191    /// then ignore the banned fingerprint's MemberAnnounce + messages.
192    /// The cryptographic enforcement is the immediate `RotateRoomKey`
193    /// that the banning owner sends right after.
194    BanMember {
195        room_id: String,
196        target_fingerprint: String,
197    },
198    /// Phase G: SAS verification step 1. The initiator picks a random
199    /// `tx_id` and an ephemeral X25519 keypair, sends the pubkey.
200    /// MUST be sent inside `WireMessage::Signed` so the receiver can
201    /// bind this ephemeral key to the initiator's Ed25519 identity.
202    SasInit {
203        tx_id: String,
204        ephemeral_x25519_pubkey_b64: String,
205        target_fingerprint: String,
206    },
207    /// Phase G: SAS step 2 — responder's ephemeral X25519 pubkey.
208    /// Both sides now have what they need to compute the shared
209    /// secret and derive the SAS code locally. Signed.
210    SasResponse {
211        tx_id: String,
212        ephemeral_x25519_pubkey_b64: String,
213    },
214    /// Phase G: SAS step 3 — once both sides have OOB-compared the
215    /// derived code and pressed "Match", each broadcasts this. On
216    /// receiving the partner's `matched=true`, the local side flips
217    /// `verified=1` for the partner's fingerprint. Signed.
218    SasConfirm {
219        tx_id: String,
220        matched: bool,
221    },
222    /// Phase E: an existing owner of a `verified_only` room is
223    /// telling `target_fingerprint` (an unverified joiner) why their
224    /// announce went unanswered. Replaces a silent hang on the
225    /// joiner's side. Signed.
226    JoinRefused {
227        room_id: String,
228        target_fingerprint: String,
229        reason: String,
230    },
231    /// Phase F: a joiner is asking to enter a room using a short-lived
232    /// owner-issued code (no passphrase). Includes the joiner's
233    /// ephemeral X25519 pubkey for ECDH key delivery. Signed (so the
234    /// owner knows who's asking).
235    CodeJoinRequest {
236        room_id: String,
237        joiner_x25519_pubkey_b64: String,
238        code: String,
239    },
240    /// Phase F: an issuing owner's response to a valid `CodeJoinRequest`.
241    /// Carries the owner's ephemeral X25519 pubkey + the current Megolm
242    /// session key wrapped under the ECDH-derived key. Joiner does
243    /// X25519 the other direction, derives the same wrap key, unwraps
244    /// the session key. Signed.
245    CodeJoinResponse {
246        room_id: String,
247        target_fingerprint: String,
248        owner_x25519_pubkey_b64: String,
249        owner_session_id: String,
250        wrapped_session_key_b64: String,
251        nonce_b64: String,
252    },
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn room_announcement_round_trip() {
261        let ann = RoomAnnouncement {
262            room_id: "rid".into(),
263            name: "general".into(),
264            encrypted: true,
265            passphrase_salt: Some(vec![1, 2, 3, 4]),
266            member_count: 3,
267            creator_fingerprint: "creator-fp".into(),
268            announced_at: 100,
269            owner_fingerprints: vec!["creator-fp".into()],
270            verified_only: false,
271        };
272        let json = serde_json::to_vec(&ann).unwrap();
273        let back: RoomAnnouncement = serde_json::from_slice(&json).unwrap();
274        assert_eq!(back.name, "general");
275        assert_eq!(back.passphrase_salt, Some(vec![1, 2, 3, 4]));
276    }
277
278    #[test]
279    fn room_message_variants_round_trip() {
280        let msgs = vec![
281            RoomMessage::MemberAnnounce {
282                sender_fingerprint: "fp".into(),
283                wrapped_session_key: Some("base64data".into()),
284                display_name: Some("Daisy".into()),
285                sender_ed25519_pubkey: Some("AAA=".into()),
286            },
287            RoomMessage::Plain {
288                sender_fingerprint: "fp".into(),
289                body: "hi".into(),
290            },
291            RoomMessage::Encrypted {
292                sender_fingerprint: "fp".into(),
293                session_id: "sid".into(),
294                ciphertext_b64: "ct".into(),
295            },
296            RoomMessage::SessionKeyRequest {
297                requester_fingerprint: "fp".into(),
298            },
299            RoomMessage::MemberLeave {
300                sender_fingerprint: "fp".into(),
301            },
302            RoomMessage::FileOffer {
303                sender_fingerprint: "fp".into(),
304                file_id: "fid".into(),
305                name: "f.bin".into(),
306                size_bytes: 1024,
307                mime: Some("application/octet-stream".into()),
308                chunk_count: 2,
309                encrypted_meta: None,
310            },
311            RoomMessage::FileChunk {
312                sender_fingerprint: "fp".into(),
313                file_id: "fid".into(),
314                chunk_index: 0,
315                total_chunks: 2,
316                data_b64: "AAA=".into(),
317            },
318            RoomMessage::RotateRoomKey {
319                rotator_fingerprint: "fp".into(),
320                new_salt: vec![1u8; 16],
321            },
322            RoomMessage::Typing {
323                sender_fingerprint: "fp".into(),
324            },
325        ];
326        for m in msgs {
327            let json = serde_json::to_vec(&m).unwrap();
328            let back: RoomMessage = serde_json::from_slice(&json).unwrap();
329            assert_eq!(format!("{m:?}"), format!("{back:?}"));
330        }
331    }
332
333    #[test]
334    fn room_topic_format() {
335        assert_eq!(room_topic("abc123"), "huddle-room-abc123");
336    }
337}