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