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