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}