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