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}