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