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