1pub mod events;
2
3use std::collections::{HashMap, HashSet};
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use base64::engine::general_purpose::STANDARD as B64;
9use base64::Engine;
10use libp2p::{Multiaddr, PeerId};
11use tokio::sync::broadcast;
12use tracing::{debug, error, info, warn};
13
14use crate::config;
15use crate::crypto::passphrase::{self, KEY_LEN, SALT_LEN};
16use crate::crypto::RoomCrypto;
17use crate::error::{HuddleError, Result};
18use crate::files::encryption::{self as file_encryption, EncryptedFileMeta};
19use crate::files::FileManager;
20use crate::identity::Identity;
21use crate::network::events::NetworkEvent;
22use crate::network::protocol::{encode_wire, RoomAnnouncement, RoomMessage, WireMessage};
23use crate::network::{self, NetworkHandle, NetworkMode};
24use crate::storage::repo::{
25 self, derive_room_id, AttachmentStatus, KnownPeer, RoomKind, StoredAttachment, StoredRoom,
26 StoredRoomMember,
27};
28use crate::storage::{self, Db};
29
30pub use self::events::{AppEvent, DiscoveredRoom};
31
32#[derive(Debug, Clone)]
35pub struct KnownPeerStatus {
36 pub address: String,
37 pub label: Option<String>,
38 pub last_connected_at: Option<i64>,
39 pub connected_peer_id: Option<PeerId>,
40}
41
42pub fn canonical_dm_room_id(a: &str, b: &str) -> String {
53 use sha2::{Digest, Sha256};
54 let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
55 let mut hasher = Sha256::new();
56 hasher.update(b"huddle-dm-v1\0");
57 hasher.update(lo.as_bytes());
58 hasher.update(b"\0");
59 hasher.update(hi.as_bytes());
60 hex::encode(&hasher.finalize()[..16])
61}
62
63pub fn parse_dial_address(input: &str) -> Result<Multiaddr> {
66 let trimmed = input.trim();
67 if trimmed.is_empty() {
68 return Err(HuddleError::Other("address is empty".into()));
69 }
70 if trimmed.starts_with('/') {
71 return trimmed
72 .parse::<Multiaddr>()
73 .map_err(|e| HuddleError::Other(format!("invalid multiaddr: {e}")));
74 }
75 if let Some(rest) = trimmed.strip_prefix('[') {
76 let (host, port) = rest
77 .split_once("]:")
78 .ok_or_else(|| HuddleError::Other(format!("expected [ipv6]:port, got {trimmed}")))?;
79 let port: u16 = port
80 .parse()
81 .map_err(|_| HuddleError::Other(format!("invalid port: {port}")))?;
82 return format!("/ip6/{}/tcp/{}", host, port)
83 .parse::<Multiaddr>()
84 .map_err(|e| HuddleError::Other(format!("invalid ipv6 address: {e}")));
85 }
86 let (host, port) = trimmed
87 .rsplit_once(':')
88 .ok_or_else(|| HuddleError::Other(format!("expected ip:port, got {trimmed}")))?;
89 if host.contains(':') {
90 return Err(HuddleError::Other(format!(
91 "ambiguous IPv6 address — wrap host in brackets: [{host}]:{port}"
92 )));
93 }
94 let port: u16 = port
95 .parse()
96 .map_err(|_| HuddleError::Other(format!("invalid port: {port}")))?;
97 format!("/ip4/{}/tcp/{}", host, port)
98 .parse::<Multiaddr>()
99 .map_err(|e| HuddleError::Other(format!("invalid address: {e}")))
100}
101
102struct ActiveRoom {
104 info: StoredRoom,
105 crypto: Option<RoomCrypto>,
106 passphrase_key: Option<[u8; KEY_LEN]>,
109 members: HashSet<String>,
111 typers: HashMap<String, i64>,
114 read_only: bool,
121 issued_codes: Vec<(String, i64)>,
125}
126
127const TYPING_TTL_SECS: i64 = 3;
128
129const DISCOVERED_TTL_SECS: i64 = 45;
132const ANNOUNCE_INTERVAL_SECS: u64 = 15;
133
134struct SasFlow {
138 room_id: String,
139 partner_fingerprint: String,
140 our_secret: x25519_dalek::StaticSecret,
141 sas_code: Option<crate::crypto::sas::SasCode>,
143 our_confirmed: bool,
144 their_confirmed: bool,
145}
146
147#[derive(Clone)]
148pub struct AppHandle {
149 identity: Arc<Identity>,
150 network: NetworkHandle,
151 mode: NetworkMode,
152 active_rooms: Arc<Mutex<HashMap<String, ActiveRoom>>>,
153 discovered_rooms: Arc<Mutex<HashMap<String, DiscoveredRoom>>>,
154 restorable_rooms: Arc<Mutex<HashMap<String, StoredRoom>>>,
158 connected_dial_addrs: Arc<Mutex<HashMap<String, PeerId>>>,
161 file_manager: Arc<FileManager>,
163 db: Db,
164 session_persist_key: [u8; 32],
168 sas_flows: Arc<Mutex<HashMap<String, SasFlow>>>,
171 pending_code_secrets:
178 Arc<Mutex<HashMap<(String, String), x25519_dalek::StaticSecret>>>,
179 pending_invite_dials: Arc<Mutex<HashMap<String, String>>>,
192 nat_reachable_addrs: Arc<Mutex<HashSet<String>>>,
198 relay_circuit_addrs: Arc<Mutex<HashSet<String>>>,
204 host_addr_dial_attempts: Arc<Mutex<HashMap<String, i64>>>,
209 last_profile_broadcast_at_ms: Arc<Mutex<HashMap<String, i64>>>,
216 app_event_tx: broadcast::Sender<AppEvent>,
217}
218
219const HOST_ADDR_DIAL_BACKOFF_SECS: i64 = 300;
222
223const PROFILE_REBROADCAST_FLOOR_MS: i64 = 60_000;
227
228impl AppHandle {
229 pub async fn start() -> Result<Self> {
230 Self::start_with_options(NetworkMode::Mdns, 0, None, Vec::new()).await
231 }
232
233 pub async fn start_with_options(
234 mode: NetworkMode,
235 port: u16,
236 master_key: Option<&[u8; 32]>,
237 relays: Vec<Multiaddr>,
238 ) -> Result<Self> {
239 config::ensure_data_dir()?;
240 let session_persist_key = match master_key {
245 Some(mk) => storage::keychain::derive_subkey(mk, b"megolm-persist"),
246 None => [0u8; 32],
247 };
248 let db = storage::open_db(&config::db_path(), master_key)?;
249 Self::start_with_db_and_options(db, mode, port, session_persist_key, relays).await
250 }
251
252 pub async fn start_with_db(db: Db) -> Result<Self> {
253 Self::start_with_db_and_options(db, NetworkMode::Mdns, 0, [0u8; 32], Vec::new()).await
254 }
255
256 pub async fn start_with_db_and_options(
257 db: Db,
258 mode: NetworkMode,
259 port: u16,
260 session_persist_key: [u8; 32],
261 relays: Vec<Multiaddr>,
262 ) -> Result<Self> {
263 let identity = Self::load_or_create_identity(&db)?;
264 let identity = Arc::new(identity);
265 info!(fingerprint = %identity.fingerprint(), peer_id = %identity.peer_id(), mode = %mode.as_str(), port, relay_count = relays.len(), "identity loaded");
266
267 let (net_event_tx, net_event_rx) = tokio::sync::mpsc::channel::<NetworkEvent>(256);
268 let (app_event_tx, _) = broadcast::channel::<AppEvent>(256);
269 let network =
270 network::start_network_with(&identity, net_event_tx, mode, port, relays)?;
271
272 let active_rooms = Arc::new(Mutex::new(HashMap::new()));
273 let discovered_rooms = Arc::new(Mutex::new(HashMap::new()));
274 let restorable_rooms = Arc::new(Mutex::new(HashMap::new()));
275 let connected_dial_addrs = Arc::new(Mutex::new(HashMap::new()));
276 let file_manager = Arc::new(FileManager::new(&config::data_dir())?);
277
278 let handle = Self {
279 identity,
280 network,
281 mode,
282 active_rooms,
283 discovered_rooms,
284 restorable_rooms,
285 connected_dial_addrs,
286 file_manager,
287 db,
288 session_persist_key,
289 sas_flows: Arc::new(Mutex::new(HashMap::new())),
290 pending_code_secrets: Arc::new(Mutex::new(HashMap::new())),
291 pending_invite_dials: Arc::new(Mutex::new(HashMap::new())),
292 nat_reachable_addrs: Arc::new(Mutex::new(HashSet::new())),
293 relay_circuit_addrs: Arc::new(Mutex::new(HashSet::new())),
294 host_addr_dial_attempts: Arc::new(Mutex::new(HashMap::new())),
295 last_profile_broadcast_at_ms: Arc::new(Mutex::new(HashMap::new())),
296 app_event_tx,
297 };
298
299 handle.spawn_event_processor(net_event_rx);
300 handle.spawn_announcement_ticker();
301 handle.spawn_discovered_room_pruner();
302 handle.spawn_known_peer_reconnector();
303 handle.restore_rooms_from_db().await;
304
305 Ok(handle)
306 }
307
308 pub fn mode(&self) -> NetworkMode {
309 self.mode
310 }
311
312 pub fn subscribe(&self) -> broadcast::Receiver<AppEvent> {
313 self.app_event_tx.subscribe()
314 }
315
316 pub fn fingerprint(&self) -> &str {
317 self.identity.fingerprint()
318 }
319
320 pub fn peer_id(&self) -> PeerId {
321 self.identity.peer_id()
322 }
323
324 pub fn discovered_rooms(&self) -> Vec<DiscoveredRoom> {
325 let now = now_unix();
326 let our_fp = self.identity.fingerprint().to_string();
327 let mut by_id: HashMap<String, DiscoveredRoom> = self
328 .discovered_rooms
329 .lock()
330 .unwrap()
331 .clone();
332
333 for room in self.active_rooms.lock().unwrap().values() {
337 let entry = DiscoveredRoom {
338 room_id: room.info.id.clone(),
339 name: room.info.name.clone(),
340 encrypted: room.info.encrypted,
341 member_count: room.members.len() as u32,
342 creator_fingerprint: room.info.creator_fingerprint.clone(),
343 last_seen: now,
344 restorable: false,
345 host_addrs: Vec::new(),
346 kind: room.info.kind,
347 };
348 by_id
349 .entry(room.info.id.clone())
350 .and_modify(|d| {
351 d.last_seen = now;
352 if entry.member_count > d.member_count {
353 d.member_count = entry.member_count;
354 }
355 d.restorable = false;
356 d.kind = entry.kind;
357 })
358 .or_insert(entry);
359 }
360
361 for (id, stored) in self.restorable_rooms.lock().unwrap().iter() {
365 if by_id.contains_key(id) {
366 continue;
367 }
368 by_id.insert(
369 id.clone(),
370 DiscoveredRoom {
371 room_id: id.clone(),
372 name: stored.name.clone(),
373 encrypted: stored.encrypted,
374 member_count: 0,
375 creator_fingerprint: stored.creator_fingerprint.clone(),
376 last_seen: stored.last_active.unwrap_or(stored.created_at),
377 restorable: true,
378 host_addrs: Vec::new(),
379 kind: stored.kind,
380 },
381 );
382 }
383
384 by_id.retain(|room_id, d| {
392 if d.kind != RoomKind::Direct {
393 return true;
394 }
395 if self
398 .active_rooms
399 .lock()
400 .unwrap()
401 .contains_key(room_id)
402 {
403 return true;
404 }
405 canonical_dm_room_id(&our_fp, &d.creator_fingerprint) == *room_id
408 });
409
410 let mut v: Vec<DiscoveredRoom> = by_id.into_values().collect();
411 v.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
412 v
413 }
414
415 pub fn dm_partner_fingerprint(&self, room_id: &str) -> Option<String> {
420 let our_fp = self.identity.fingerprint().to_string();
421 let rooms = self.active_rooms.lock().unwrap();
422 let room = rooms.get(room_id)?;
423 if room.info.kind != RoomKind::Direct {
424 return None;
425 }
426 room.members
427 .iter()
428 .find(|m| **m != our_fp)
429 .cloned()
430 }
431
432 pub fn active_room_ids(&self) -> Vec<String> {
433 self.active_rooms.lock().unwrap().keys().cloned().collect()
434 }
435
436 pub fn active_room_info(&self, room_id: &str) -> Option<StoredRoom> {
437 self.active_rooms
438 .lock()
439 .unwrap()
440 .get(room_id)
441 .map(|r| r.info.clone())
442 }
443
444 pub fn room_members(&self, room_id: &str) -> Vec<String> {
445 self.active_rooms
446 .lock()
447 .unwrap()
448 .get(room_id)
449 .map(|r| {
450 let mut m: Vec<String> = r.members.iter().cloned().collect();
451 m.sort();
452 m
453 })
454 .unwrap_or_default()
455 }
456
457 pub fn room_messages(&self, room_id: &str, limit: i64) -> Result<Vec<repo::StoredRoomMessage>> {
458 repo::get_room_messages(&self.db, room_id, limit)
459 }
460
461 pub fn search_room_messages(
462 &self,
463 room_id: &str,
464 query: &str,
465 limit: i64,
466 ) -> Result<Vec<repo::StoredRoomMessage>> {
467 repo::search_room_messages(&self.db, room_id, query, limit)
468 }
469
470 pub async fn start_room(
478 &self,
479 name: &str,
480 encrypted: bool,
481 passphrase: Option<&str>,
482 kind: RoomKind,
483 ) -> Result<String> {
484 if encrypted && passphrase.is_none() {
485 return Err(HuddleError::Other(
486 "encrypted room requires a passphrase".into(),
487 ));
488 }
489
490 let created_at = now_unix();
491 let creator_fp = self.identity.fingerprint().to_string();
492 let room_id = derive_room_id(&creator_fp, name, created_at);
493
494 let (passphrase_salt, passphrase_key) = if encrypted {
495 let salt = passphrase::random_salt();
496 let key = passphrase::derive_key(passphrase.unwrap(), &salt)?;
497 (Some(salt.to_vec()), Some(key))
498 } else {
499 (None, None)
500 };
501
502 let info = StoredRoom {
503 id: room_id.clone(),
504 name: name.to_string(),
505 creator_fingerprint: creator_fp.clone(),
506 encrypted,
507 passphrase_salt: passphrase_salt.clone(),
508 created_at,
509 last_active: Some(created_at),
510 kind,
511 };
512 repo::insert_room(&self.db, &info)?;
513
514 let crypto = if encrypted {
515 Some(RoomCrypto::new_for_room(
516 self.db.clone(),
517 room_id.clone(),
518 creator_fp.clone(),
519 self.session_persist_key,
520 )?)
521 } else {
522 None
523 };
524
525 let mut members = HashSet::new();
526 members.insert(creator_fp.clone());
527
528 repo::upsert_room_member(
532 &self.db,
533 &StoredRoomMember {
534 room_id: room_id.clone(),
535 peer_id: String::new(),
536 fingerprint: creator_fp.clone(),
537 last_seen: Some(created_at),
538 verified: true, ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
540 role: "owner".into(),
541 },
542 )?;
543
544 self.active_rooms.lock().unwrap().insert(
545 room_id.clone(),
546 ActiveRoom {
547 info: info.clone(),
548 crypto,
549 passphrase_key,
550 members,
551 typers: HashMap::new(),
552 read_only: false,
553 issued_codes: Vec::new(),
554 },
555 );
556
557 self.network.subscribe_room(room_id.clone()).await;
558 self.announce_room_now(&info, 1).await;
559
560 let app = self.clone();
563 let rid = room_id.clone();
564 tokio::spawn(async move {
565 tokio::time::sleep(Duration::from_millis(500)).await;
566 if let Err(e) = app.broadcast_member_announce(&rid).await {
567 warn!(%e, "broadcast member announce");
568 }
569 });
570
571 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
572 room_id: room_id.clone(),
573 });
574
575 Ok(room_id)
576 }
577
578 pub async fn start_direct(&self, partner_fingerprint: &str) -> Result<String> {
594 let our_fp = self.identity.fingerprint().to_string();
595 if partner_fingerprint == our_fp {
596 return Err(HuddleError::Other("cannot DM yourself".into()));
597 }
598 let room_id = canonical_dm_room_id(&our_fp, partner_fingerprint);
599
600 if self.active_rooms.lock().unwrap().contains_key(&room_id) {
605 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
606 room_id: room_id.clone(),
607 });
608 return Ok(room_id);
609 }
610 if repo::get_room(&self.db, &room_id)?.is_some() {
611 return self.bootstrap_direct_room(&room_id, partner_fingerprint).await;
613 }
614
615 let created_at = now_unix();
616 let name = format!("dm-{}", short_fp_for_msg(partner_fingerprint));
620
621 let info = StoredRoom {
622 id: room_id.clone(),
623 name,
624 creator_fingerprint: our_fp.clone(),
625 encrypted: false,
626 passphrase_salt: None,
627 created_at,
628 last_active: Some(created_at),
629 kind: RoomKind::Direct,
630 };
631 repo::insert_room(&self.db, &info)?;
632
633 let mut members = HashSet::new();
634 members.insert(our_fp.clone());
635 repo::upsert_room_member(
636 &self.db,
637 &StoredRoomMember {
638 room_id: room_id.clone(),
639 peer_id: String::new(),
640 fingerprint: our_fp.clone(),
641 last_seen: Some(created_at),
642 verified: true,
643 ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
644 role: "member".into(),
645 },
646 )?;
647
648 self.active_rooms.lock().unwrap().insert(
649 room_id.clone(),
650 ActiveRoom {
651 info: info.clone(),
652 crypto: None,
653 passphrase_key: None,
654 members,
655 typers: HashMap::new(),
656 read_only: false,
657 issued_codes: Vec::new(),
658 },
659 );
660
661 self.network.subscribe_room(room_id.clone()).await;
662 self.announce_room_now(&info, 1).await;
663
664 let app = self.clone();
665 let rid = room_id.clone();
666 tokio::spawn(async move {
667 tokio::time::sleep(Duration::from_millis(500)).await;
668 if let Err(e) = app.broadcast_member_announce(&rid).await {
669 warn!(%e, "broadcast member announce for DM");
670 }
671 });
672
673 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
674 room_id: room_id.clone(),
675 });
676 Ok(room_id)
677 }
678
679 async fn bootstrap_direct_room(
685 &self,
686 room_id: &str,
687 partner_fingerprint: &str,
688 ) -> Result<String> {
689 let our_fp = self.identity.fingerprint().to_string();
690 let info = repo::get_room(&self.db, room_id)?
691 .ok_or_else(|| HuddleError::Other(format!("DM room {room_id} not found on disk")))?;
692 let mut members = HashSet::new();
693 members.insert(our_fp.clone());
694 members.insert(partner_fingerprint.to_string());
695
696 if let Ok(stored_members) = repo::list_room_members(&self.db, room_id) {
698 for m in stored_members {
699 members.insert(m.fingerprint);
700 }
701 }
702
703 self.active_rooms.lock().unwrap().insert(
704 room_id.to_string(),
705 ActiveRoom {
706 info: info.clone(),
707 crypto: None,
708 passphrase_key: None,
709 members,
710 typers: HashMap::new(),
711 read_only: false,
712 issued_codes: Vec::new(),
713 },
714 );
715
716 self.network.subscribe_room(room_id.to_string()).await;
717 self.announce_room_now(&info, 2).await;
718
719 let app = self.clone();
720 let rid = room_id.to_string();
721 tokio::spawn(async move {
722 tokio::time::sleep(Duration::from_millis(500)).await;
723 if let Err(e) = app.broadcast_member_announce(&rid).await {
724 warn!(%e, "broadcast member announce on DM bootstrap");
725 }
726 });
727
728 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
729 room_id: room_id.to_string(),
730 });
731 Ok(room_id.to_string())
732 }
733
734 pub async fn join_room(&self, room_id: &str, passphrase: Option<&str>) -> Result<()> {
738 let (name, creator_fingerprint, encrypted, salt_opt) = {
740 if let Some(d) = self.discovered_rooms.lock().unwrap().get(room_id).cloned() {
741 let salt = self.get_room_salt(room_id);
742 (d.name, d.creator_fingerprint, d.encrypted, salt)
743 } else if let Some(stored) = self.restorable_rooms.lock().unwrap().get(room_id).cloned()
744 {
745 (
746 stored.name,
747 stored.creator_fingerprint,
748 stored.encrypted,
749 stored.passphrase_salt,
750 )
751 } else if let Some(stored) = repo::get_room(&self.db, room_id)? {
752 (
753 stored.name,
754 stored.creator_fingerprint,
755 stored.encrypted,
756 stored.passphrase_salt,
757 )
758 } else {
759 return Err(HuddleError::Other(format!("room {room_id} not found")));
760 }
761 };
762
763 if encrypted && passphrase.is_none() {
764 return Err(HuddleError::Other(
765 "encrypted room requires a passphrase".into(),
766 ));
767 }
768
769 let passphrase_key = if encrypted {
770 let salt = salt_opt
771 .clone()
772 .ok_or_else(|| HuddleError::Other("missing salt for encrypted room".into()))?;
773 Some(passphrase::derive_key(passphrase.unwrap(), &salt)?)
774 } else {
775 None
776 };
777
778 let kind = self
783 .discovered_rooms
784 .lock()
785 .unwrap()
786 .get(room_id)
787 .map(|d| d.kind)
788 .or_else(|| {
789 repo::get_room(&self.db, room_id)
790 .ok()
791 .flatten()
792 .map(|r| r.kind)
793 })
794 .unwrap_or_default();
795
796 let info = StoredRoom {
797 id: room_id.to_string(),
798 name,
799 creator_fingerprint,
800 encrypted,
801 passphrase_salt: salt_opt.clone(),
802 created_at: now_unix(),
803 last_active: Some(now_unix()),
804 kind,
805 };
806 repo::insert_room(&self.db, &info)?;
807
808 let crypto = if encrypted {
809 let our_fp = self.identity.fingerprint().to_string();
812 let existing = RoomCrypto::load(
813 self.db.clone(),
814 room_id.to_string(),
815 our_fp.clone(),
816 self.session_persist_key,
817 )?;
818 Some(match existing {
819 Some(c) => c,
820 None => RoomCrypto::new_for_room(
821 self.db.clone(),
822 room_id.to_string(),
823 our_fp,
824 self.session_persist_key,
825 )?,
826 })
827 } else {
828 None
829 };
830
831 let mut members = HashSet::new();
832 members.insert(self.identity.fingerprint().to_string());
833
834 self.active_rooms.lock().unwrap().insert(
835 room_id.to_string(),
836 ActiveRoom {
837 info: info.clone(),
838 crypto,
839 passphrase_key,
840 members,
841 typers: HashMap::new(),
842 read_only: false,
843 issued_codes: Vec::new(),
844 },
845 );
846 self.restorable_rooms.lock().unwrap().remove(room_id);
848
849 self.network.subscribe_room(room_id.to_string()).await;
850
851 let app = self.clone();
852 let rid = room_id.to_string();
853 tokio::spawn(async move {
854 tokio::time::sleep(Duration::from_millis(500)).await;
855 if let Err(e) = app.broadcast_member_announce(&rid).await {
856 warn!(%e, "broadcast member announce");
857 }
858 let req = RoomMessage::SessionKeyRequest {
860 requester_fingerprint: app.identity.fingerprint().to_string(),
861 };
862 if let Ok(bytes) = encode_wire(&req) {
863 app.network.publish_room_message(rid.clone(), bytes).await;
864 }
865 });
866
867 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
868 room_id: room_id.to_string(),
869 });
870
871 Ok(())
872 }
873
874 async fn restore_rooms_from_db(&self) {
879 let rooms = match repo::list_rooms(&self.db) {
880 Ok(v) => v,
881 Err(e) => {
882 warn!(%e, "list rooms on restore");
883 return;
884 }
885 };
886 let our_fp = self.identity.fingerprint().to_string();
887 let count = rooms.len();
888 for info in rooms {
889 if info.encrypted {
890 self.restorable_rooms
891 .lock()
892 .unwrap()
893 .insert(info.id.clone(), info);
894 continue;
895 }
896 let mut members = HashSet::new();
897 members.insert(our_fp.clone());
898 if let Ok(stored_members) = repo::list_room_members(&self.db, &info.id) {
899 for m in stored_members {
900 members.insert(m.fingerprint);
901 }
902 }
903 let member_count = members.len() as u32;
904 self.active_rooms.lock().unwrap().insert(
905 info.id.clone(),
906 ActiveRoom {
907 info: info.clone(),
908 crypto: None,
909 passphrase_key: None,
910 members,
911 typers: HashMap::new(),
912 read_only: false,
913 issued_codes: Vec::new(),
914 },
915 );
916 self.network.subscribe_room(info.id.clone()).await;
917 self.announce_room_now(&info, member_count).await;
918 info!(room_id = %info.id, name = %info.name, "restored room");
919 }
920 if count > 0 {
921 debug!(count, "restored rooms from db");
922 }
923 }
924
925 pub async fn leave_room(&self, room_id: &str) -> Result<bool> {
930 let leave_msg = RoomMessage::MemberLeave {
932 sender_fingerprint: self.identity.fingerprint().to_string(),
933 };
934 let dispatched = match encode_wire(&leave_msg) {
935 Ok(bytes) => {
936 self.network
937 .publish_room_message(room_id.to_string(), bytes)
938 .await;
939 true
940 }
941 Err(e) => {
942 warn!(%e, %room_id, "failed to encode MemberLeave notice");
943 false
944 }
945 };
946
947 self.active_rooms.lock().unwrap().remove(room_id);
948 self.network.unsubscribe_room(room_id.to_string()).await;
949
950 let _ = self.app_event_tx.send(AppEvent::RoomLeft {
951 room_id: room_id.to_string(),
952 });
953 Ok(dispatched)
954 }
955
956 pub async fn send_room_message(&self, room_id: &str, body: &str) -> Result<()> {
957 let our_fp = self.identity.fingerprint().to_string();
958 let msg = {
959 let mut rooms = self.active_rooms.lock().unwrap();
960 let room = rooms
961 .get_mut(room_id)
962 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
963
964 if room.read_only {
965 return Err(HuddleError::Other(
966 "this room is read-only — you joined via code without the passphrase. Ask an owner for the passphrase or wait for a key rotation that includes you.".into(),
967 ));
968 }
969
970 if room.info.encrypted {
971 let crypto = room
972 .crypto
973 .as_mut()
974 .ok_or_else(|| HuddleError::Session("encrypted room missing crypto".into()))?;
975 let (session_id, ct_bytes) = crypto.encrypt(body.as_bytes())?;
976 RoomMessage::Encrypted {
977 sender_fingerprint: our_fp.clone(),
978 session_id,
979 ciphertext_b64: base64::Engine::encode(
980 &base64::engine::general_purpose::STANDARD,
981 &ct_bytes,
982 ),
983 }
984 } else {
985 RoomMessage::Plain {
986 sender_fingerprint: our_fp.clone(),
987 body: body.to_string(),
988 }
989 }
990 };
991
992 let bytes = encode_wire(&msg)?;
993 self.network
994 .publish_room_message(room_id.to_string(), bytes)
995 .await;
996
997 let now = now_unix();
998 let msg_id =
999 repo::insert_room_message(&self.db, room_id, &our_fp, "out", body, now)?;
1000 repo::update_room_last_active(&self.db, room_id, now)?;
1001
1002 let _ = self.app_event_tx.send(AppEvent::MessageSent {
1003 room_id: room_id.to_string(),
1004 body: body.to_string(),
1005 message_id: msg_id,
1006 });
1007
1008 Ok(())
1009 }
1010
1011 pub async fn shutdown(&self) {
1012 self.network.shutdown().await;
1013 }
1014
1015 pub async fn dial_by_id_or_username(&self, input: &str) -> Result<()> {
1042 let trimmed = input.trim();
1043 if trimmed.is_empty() {
1044 return Err(HuddleError::Other("input is empty".into()));
1045 }
1046 let target_fp = if let Some(fp) = normalize_to_fingerprint(trimmed) {
1047 fp
1048 } else {
1049 let matches = repo::find_peers_by_username(&self.db, trimmed)?;
1050 if matches.is_empty() {
1051 return Err(HuddleError::Other(format!(
1052 "no peer named `{}` known yet — paste their invite link instead",
1053 trimmed
1054 )));
1055 }
1056 if matches.len() > 1 {
1057 return Err(HuddleError::Other(format!(
1058 "username `{}` is ambiguous ({} peers share it) — use their HD- ID instead",
1059 trimmed,
1060 matches.len()
1061 )));
1062 }
1063 matches.into_iter().next().unwrap()
1064 };
1065 if target_fp == self.identity.fingerprint() {
1066 return Err(HuddleError::Other("that's your own ID".into()));
1067 }
1068 let candidates = self.resolve_dial_addrs(&target_fp);
1069 if candidates.is_empty() {
1070 return Err(HuddleError::Other(format!(
1071 "haven't seen `{}` on the network yet — ask them for an invite link",
1072 short_fp_for_msg(&target_fp)
1073 )));
1074 }
1075 let now = now_unix();
1080 for addr in &candidates {
1081 let _ = repo::upsert_known_peer(
1082 &self.db,
1083 &KnownPeer {
1084 address: addr.clone(),
1085 label: None,
1086 last_connected_at: None,
1087 last_attempt_at: Some(now),
1088 created_at: now,
1089 fingerprint: Some(target_fp.clone()),
1090 trusted: false,
1091 },
1092 );
1093 }
1094 let multiaddrs: Vec<Multiaddr> = candidates
1098 .iter()
1099 .filter_map(|s| s.parse::<Multiaddr>().ok())
1100 .collect();
1101 if multiaddrs.is_empty() {
1102 return Err(HuddleError::Other(
1103 "every known address for that peer is malformed".into(),
1104 ));
1105 }
1106 let _ = self.app_event_tx.send(AppEvent::Dialing {
1107 address: candidates[0].clone(),
1108 });
1109 info!(
1110 target_fp = %target_fp,
1111 n = multiaddrs.len(),
1112 "dialing peer with {} candidate addresses",
1113 multiaddrs.len()
1114 );
1115 self.network.dial_addresses(multiaddrs).await;
1116 Ok(())
1117 }
1118
1119 fn resolve_dial_addrs(&self, fingerprint: &str) -> Vec<String> {
1127 let mut set: std::collections::HashSet<String> = std::collections::HashSet::new();
1128 for room in self.discovered_rooms.lock().unwrap().values() {
1129 if room.creator_fingerprint == fingerprint {
1130 for addr in &room.host_addrs {
1131 set.insert(addr.clone());
1132 }
1133 }
1134 }
1135 if let Ok(known) = repo::list_known_peers(&self.db) {
1136 for peer in known {
1137 if peer.fingerprint.as_deref() == Some(fingerprint) {
1138 set.insert(peer.address);
1139 }
1140 }
1141 }
1142 let mut v: Vec<String> = set.into_iter().collect();
1143 v.sort_by_key(|a| address_preference(a));
1144 v
1145 }
1146
1147 pub async fn dial(&self, input: &str) -> Result<()> {
1148 let multiaddr = parse_dial_address(input)?;
1149 let canonical = multiaddr.to_string();
1150 info!(%canonical, "dialing");
1151
1152 repo::upsert_known_peer(
1153 &self.db,
1154 &KnownPeer {
1155 address: canonical.clone(),
1156 label: None,
1157 last_connected_at: None,
1158 last_attempt_at: Some(now_unix()),
1159 created_at: now_unix(),
1160 fingerprint: None,
1164 trusted: false,
1165 },
1166 )?;
1167
1168 let _ = self.app_event_tx.send(AppEvent::Dialing {
1169 address: canonical.clone(),
1170 });
1171 self.network.dial(multiaddr).await;
1172 Ok(())
1173 }
1174
1175 pub fn nat_reachable_addrs(&self) -> Vec<String> {
1180 self.nat_reachable_addrs
1181 .lock()
1182 .unwrap()
1183 .iter()
1184 .cloned()
1185 .collect()
1186 }
1187
1188 pub fn dialable_addrs(&self) -> Vec<String> {
1196 let mut out: Vec<String> = self
1197 .relay_circuit_addrs
1198 .lock()
1199 .unwrap()
1200 .iter()
1201 .cloned()
1202 .collect();
1203 for a in self.nat_reachable_addrs.lock().unwrap().iter() {
1204 if !out.contains(a) {
1205 out.push(a.clone());
1206 }
1207 }
1208 out.truncate(4);
1209 out
1210 }
1211
1212 pub async fn dial_invite(&self, address: &str, claimed_fp: &str) -> Result<()> {
1225 let multiaddr = parse_dial_address(address)?;
1226 let canonical = multiaddr.to_string();
1227 self.pending_invite_dials
1228 .lock()
1229 .unwrap()
1230 .insert(canonical.clone(), claimed_fp.to_string());
1231 self.dial(address).await
1234 }
1235
1236 pub fn known_peers(&self) -> Vec<KnownPeerStatus> {
1237 let connected = self.connected_dial_addrs.lock().unwrap().clone();
1238 let stored = repo::list_known_peers(&self.db).unwrap_or_default();
1239 stored
1240 .into_iter()
1241 .map(|p| {
1242 let connected_peer = connected.get(&p.address).copied();
1243 KnownPeerStatus {
1244 address: p.address,
1245 label: p.label,
1246 last_connected_at: p.last_connected_at,
1247 connected_peer_id: connected_peer,
1248 }
1249 })
1250 .collect()
1251 }
1252
1253 pub async fn forget_peer(&self, address: &str) -> Result<()> {
1254 repo::forget_known_peer(&self.db, address)?;
1255 self.connected_dial_addrs.lock().unwrap().remove(address);
1256 Ok(())
1257 }
1258
1259 pub async fn redial(&self, address: &str) -> Result<()> {
1261 self.dial(address).await
1262 }
1263
1264 pub async fn accept_inbound(&self, peer_id: PeerId, address: &str) {
1269 self.network.accept_inbound(peer_id).await;
1270 self.connected_dial_addrs
1271 .lock()
1272 .unwrap()
1273 .insert(address.to_string(), peer_id);
1274 }
1275
1276 pub async fn reject_inbound(&self, peer_id: PeerId, fingerprint: &str) -> Result<()> {
1281 self.network.reject_inbound(peer_id).await;
1282 repo::block_peer(&self.db, fingerprint, now_unix())?;
1283 Ok(())
1284 }
1285
1286 pub async fn trust_inbound(
1289 &self,
1290 peer_id: PeerId,
1291 fingerprint: &str,
1292 address: &str,
1293 ) -> Result<()> {
1294 self.network.accept_inbound(peer_id).await;
1295 self.connected_dial_addrs
1296 .lock()
1297 .unwrap()
1298 .insert(address.to_string(), peer_id);
1299 repo::upsert_known_peer(
1303 &self.db,
1304 &KnownPeer {
1305 address: address.to_string(),
1306 label: None,
1307 last_connected_at: Some(now_unix()),
1308 last_attempt_at: Some(now_unix()),
1309 created_at: now_unix(),
1310 fingerprint: Some(fingerprint.to_string()),
1311 trusted: true,
1312 },
1313 )?;
1314 Ok(())
1315 }
1316
1317 fn spawn_known_peer_reconnector(&self) {
1318 let handle = self.clone();
1319 tokio::spawn(async move {
1320 tokio::time::sleep(Duration::from_millis(500)).await;
1322 let known = repo::list_known_peers(&handle.db).unwrap_or_default();
1323 for (i, peer) in known.into_iter().enumerate() {
1327 let handle = handle.clone();
1328 tokio::spawn(async move {
1329 let jitter = (peer.address.len() as u64 * 37) % 200;
1332 tokio::time::sleep(Duration::from_millis(150 * i as u64 + jitter)).await;
1333 if let Err(e) = handle.dial(&peer.address).await {
1334 debug!(%e, addr = %peer.address, "auto-reconnect failed");
1335 }
1336 });
1337 }
1338 });
1339 }
1340
1341 fn load_or_create_identity(db: &Db) -> Result<Identity> {
1346 if let Some(stored) = repo::load_identity(db)? {
1347 let mut bytes = [0u8; 32];
1348 bytes.copy_from_slice(&stored.ed25519_secret);
1349 Identity::from_secret_bytes(bytes)
1350 } else {
1351 let id = Identity::generate()?;
1352 repo::save_identity(db, &id.secret_bytes(), now_unix())?;
1353 Ok(id)
1354 }
1355 }
1356
1357 fn get_room_salt(&self, room_id: &str) -> Option<Vec<u8>> {
1358 self.active_rooms
1359 .lock()
1360 .unwrap()
1361 .get(room_id)
1362 .and_then(|r| r.info.passphrase_salt.clone())
1363 .or_else(|| {
1364 ROOM_SALT_CACHE
1366 .lock()
1367 .unwrap()
1368 .get(room_id)
1369 .cloned()
1370 })
1371 }
1372
1373 async fn announce_room_now(&self, info: &StoredRoom, member_count: u32) {
1374 let owner_fingerprints =
1375 repo::list_room_owners(&self.db, &info.id).unwrap_or_default();
1376 let verified_only = repo::get_room_verified_only(&self.db, &info.id).unwrap_or(false);
1377 let host_addrs = self.dialable_addrs();
1378 let ann = RoomAnnouncement {
1379 room_id: info.id.clone(),
1380 name: info.name.clone(),
1381 encrypted: info.encrypted,
1382 passphrase_salt: info.passphrase_salt.clone(),
1383 member_count,
1384 creator_fingerprint: info.creator_fingerprint.clone(),
1385 announced_at: now_unix(),
1386 owner_fingerprints,
1387 verified_only,
1388 host_addrs,
1389 kind: info.kind,
1390 };
1391 self.network.announce_room(ann).await;
1392 }
1393
1394 async fn broadcast_member_announce(&self, room_id: &str) -> Result<()> {
1395 let our_fp = self.identity.fingerprint().to_string();
1396 let wrapped = {
1397 let mut rooms = self.active_rooms.lock().unwrap();
1398 let room = rooms
1399 .get_mut(room_id)
1400 .ok_or_else(|| HuddleError::Other("not in room".into()))?;
1401 if room.info.encrypted {
1402 let crypto = room.crypto.as_mut().unwrap();
1403 let session_key = crypto.our_session_key_b64();
1404 let passphrase_key = room
1405 .passphrase_key
1406 .as_ref()
1407 .ok_or_else(|| HuddleError::Session("missing passphrase key".into()))?;
1408 Some(passphrase::wrap(session_key.as_bytes(), passphrase_key)?)
1409 } else {
1410 None
1411 }
1412 };
1413 let display_name = repo::get_display_name(&self.db).unwrap_or(None);
1414 let msg = RoomMessage::MemberAnnounce {
1415 sender_fingerprint: our_fp,
1416 wrapped_session_key: wrapped,
1417 display_name,
1418 sender_ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
1419 };
1420 let bytes = encode_wire(&msg)?;
1421 self.network
1422 .publish_room_message(room_id.to_string(), bytes)
1423 .await;
1424 Ok(())
1425 }
1426
1427 fn spawn_event_processor(&self, mut net_rx: tokio::sync::mpsc::Receiver<NetworkEvent>) {
1428 let handle = self.clone();
1429 tokio::spawn(async move {
1430 while let Some(event) = net_rx.recv().await {
1431 handle.process_network_event(event).await;
1432 }
1433 info!("event processor stopped");
1434 });
1435 }
1436
1437 fn spawn_announcement_ticker(&self) {
1438 let handle = self.clone();
1439 tokio::spawn(async move {
1440 let mut interval =
1441 tokio::time::interval(Duration::from_secs(ANNOUNCE_INTERVAL_SECS));
1442 interval.tick().await; loop {
1444 interval.tick().await;
1445 let snapshot: Vec<(StoredRoom, u32)> = {
1446 let active = handle.active_rooms.lock().unwrap();
1447 active
1448 .values()
1449 .map(|r| (r.info.clone(), r.members.len() as u32))
1450 .collect()
1451 };
1452 for (info, member_count) in snapshot {
1453 handle.announce_room_now(&info, member_count).await;
1454 }
1455 }
1456 });
1457 }
1458
1459 fn spawn_discovered_room_pruner(&self) {
1460 let handle = self.clone();
1461 tokio::spawn(async move {
1462 let mut interval = tokio::time::interval(Duration::from_secs(10));
1463 interval.tick().await;
1464 loop {
1465 interval.tick().await;
1466 let now = now_unix();
1467 let mut to_drop = Vec::new();
1468 {
1469 let mut map = handle.discovered_rooms.lock().unwrap();
1470 map.retain(|id, r| {
1471 if now - r.last_seen > DISCOVERED_TTL_SECS {
1472 to_drop.push(id.clone());
1473 false
1474 } else {
1475 true
1476 }
1477 });
1478 }
1479 for id in to_drop {
1480 let _ = handle.app_event_tx.send(AppEvent::RoomLost { room_id: id });
1481 }
1482 }
1483 });
1484 }
1485
1486 async fn process_network_event(&self, event: NetworkEvent) {
1487 match event {
1488 NetworkEvent::PeerDiscovered { peer_id } => {
1489 let _ = self.app_event_tx.send(AppEvent::PeerDiscovered { peer_id });
1490 }
1491 NetworkEvent::PeerExpired { peer_id } => {
1492 self.connected_dial_addrs
1498 .lock()
1499 .unwrap()
1500 .retain(|_addr, pid| *pid != peer_id);
1501 let _ = self.app_event_tx.send(AppEvent::PeerExpired { peer_id });
1502 }
1503 NetworkEvent::ListeningOn { address } => {
1504 let _ = self.app_event_tx.send(AppEvent::ListeningOn {
1505 address: address.to_string(),
1506 });
1507 }
1508 NetworkEvent::RoomAnnouncementReceived(ann) => {
1509 if let Some(salt) = &ann.passphrase_salt {
1511 ROOM_SALT_CACHE
1512 .lock()
1513 .unwrap()
1514 .insert(ann.room_id.clone(), salt.clone());
1515 }
1516 let our_fp_for_dial = self.identity.fingerprint().to_string();
1521 if ann.creator_fingerprint != our_fp_for_dial && !ann.host_addrs.is_empty() {
1522 let now = now_unix();
1523 let should_dial = {
1524 let mut attempts = self.host_addr_dial_attempts.lock().unwrap();
1525 match attempts.get(&ann.creator_fingerprint).copied() {
1526 Some(last) if now - last < HOST_ADDR_DIAL_BACKOFF_SECS => false,
1527 _ => {
1528 attempts.insert(ann.creator_fingerprint.clone(), now);
1529 true
1530 }
1531 }
1532 };
1533 if should_dial {
1534 if let Some(first) = ann.host_addrs.first() {
1535 info!(
1536 announcer = %ann.creator_fingerprint,
1537 addr = %first,
1538 "opportunistic dial via room announcement host_addrs"
1539 );
1540 let _ = self.dial(first).await;
1543 }
1544 }
1545 }
1546 let discovered = DiscoveredRoom {
1547 room_id: ann.room_id.clone(),
1548 name: ann.name.clone(),
1549 encrypted: ann.encrypted,
1550 member_count: ann.member_count,
1551 creator_fingerprint: ann.creator_fingerprint.clone(),
1552 last_seen: now_unix(),
1553 restorable: false,
1554 host_addrs: ann.host_addrs.clone(),
1555 kind: ann.kind,
1556 };
1557 if self.active_rooms.lock().unwrap().contains_key(&ann.room_id) {
1562 self.discovered_rooms
1563 .lock()
1564 .unwrap()
1565 .insert(ann.room_id.clone(), discovered);
1566 return;
1567 }
1568 if ann.kind == RoomKind::Direct {
1578 let our_fp_for_filter = self.identity.fingerprint().to_string();
1579 if canonical_dm_room_id(&our_fp_for_filter, &ann.creator_fingerprint)
1580 != ann.room_id
1581 {
1582 debug!(
1583 announcer = %ann.creator_fingerprint,
1584 room_id = %ann.room_id,
1585 "dropping Direct announcement: not addressed to us"
1586 );
1587 return;
1588 }
1589 self.discovered_rooms
1594 .lock()
1595 .unwrap()
1596 .insert(ann.room_id.clone(), discovered.clone());
1597 let _ = self
1598 .app_event_tx
1599 .send(AppEvent::RoomDiscovered(discovered.clone()));
1600 let app = self.clone();
1601 let partner = ann.creator_fingerprint.clone();
1602 let rid = ann.room_id.clone();
1603 tokio::spawn(async move {
1604 if let Err(e) = app.start_direct(&partner).await {
1605 debug!(%e, room_id = %rid, "auto-bootstrap of inbound DM failed");
1606 }
1607 });
1608 return;
1609 }
1610 self.discovered_rooms
1611 .lock()
1612 .unwrap()
1613 .insert(ann.room_id.clone(), discovered.clone());
1614 let _ = self.app_event_tx.send(AppEvent::RoomDiscovered(discovered));
1615 }
1616 NetworkEvent::RoomMessageReceived {
1617 room_id,
1618 payload,
1619 from_peer: _,
1620 } => {
1621 let wire: WireMessage = match serde_json::from_slice(&payload) {
1628 Ok(w) => w,
1629 Err(e) => {
1630 warn!(%e, "bad wire envelope");
1631 return;
1632 }
1633 };
1634 let (msg, verified_signer) = match wire {
1635 WireMessage::Plain(m) => (m, None),
1636 WireMessage::Signed(env) => {
1637 let claimed_pubkey = env.ed25519_pubkey_b64.clone();
1638 match crate::crypto::verify_signed(&env) {
1639 Ok((m, fp)) => {
1640 match repo::get_member_ed25519_pubkey(
1647 &self.db, &room_id, &fp,
1648 ) {
1649 Ok(Some(known)) if known != claimed_pubkey => {
1650 warn!(
1651 %fp, %room_id,
1652 "pubkey mismatch vs stored; dropping signed message"
1653 );
1654 return;
1655 }
1656 _ => {}
1657 }
1658 (m, Some(fp))
1659 }
1660 Err(e) => {
1661 warn!(%e, fp = %env.fingerprint, "signed envelope verify failed");
1662 return;
1663 }
1664 }
1665 }
1666 };
1667 self.handle_room_message(&room_id, msg, verified_signer).await;
1668 }
1669 NetworkEvent::DialSucceeded { peer_id, address } => {
1670 let addr_s = address.to_string();
1671 self.connected_dial_addrs
1672 .lock()
1673 .unwrap()
1674 .insert(addr_s.clone(), peer_id);
1675 let _ = repo::upsert_known_peer(
1679 &self.db,
1680 &KnownPeer {
1681 address: addr_s.clone(),
1682 label: None,
1683 last_connected_at: Some(now_unix()),
1684 last_attempt_at: Some(now_unix()),
1685 created_at: now_unix(),
1686 fingerprint: None,
1687 trusted: false,
1688 },
1689 );
1690 let _ = self.app_event_tx.send(AppEvent::DialSucceeded {
1691 address: addr_s,
1692 peer_id,
1693 });
1694 }
1695 NetworkEvent::DialFailed { address, error } => {
1696 let addr_s = address.to_string();
1697 let _ = self.app_event_tx.send(AppEvent::DialFailed {
1698 address: addr_s,
1699 error,
1700 });
1701 }
1702 NetworkEvent::PeerIdentified { peer_id, fingerprint } => {
1703 let matched_addrs: Vec<String> = {
1709 let map = self.connected_dial_addrs.lock().unwrap();
1710 map.iter()
1711 .filter_map(|(addr, pid)| {
1712 if *pid == peer_id {
1713 Some(addr.clone())
1714 } else {
1715 None
1716 }
1717 })
1718 .collect()
1719 };
1720 let mismatch = {
1730 let mut map = self.pending_invite_dials.lock().unwrap();
1731 let mut found: Option<(String, String)> = None;
1732 for addr in &matched_addrs {
1733 if let Some(claimed) = map.remove(addr) {
1734 if claimed != fingerprint {
1735 found = Some((addr.clone(), claimed));
1736 break;
1737 }
1738 }
1739 }
1740 found
1741 };
1742 if let Some((addr, claimed)) = mismatch {
1743 warn!(
1744 %addr, %claimed, actual=%fingerprint,
1745 "invite fingerprint mismatch — disconnecting"
1746 );
1747 self.network.disconnect_peer(peer_id).await;
1748 let _ = self.app_event_tx.send(AppEvent::InviteFingerprintMismatch {
1749 address: addr,
1750 claimed,
1751 actual: fingerprint.clone(),
1752 });
1753 return;
1754 }
1755 for addr in matched_addrs {
1756 let _ = repo::upsert_known_peer(
1757 &self.db,
1758 &KnownPeer {
1759 address: addr,
1760 label: None,
1761 last_connected_at: Some(now_unix()),
1762 last_attempt_at: Some(now_unix()),
1763 created_at: now_unix(),
1764 fingerprint: Some(fingerprint.clone()),
1765 trusted: true,
1766 },
1767 );
1768 }
1769 let our_username = repo::get_display_name(&self.db).unwrap_or(None);
1777 if our_username.is_some() {
1778 let now_ms = now_unix_ms();
1779 let should_send = {
1780 let mut last = self.last_profile_broadcast_at_ms.lock().unwrap();
1781 match last.get(&fingerprint) {
1782 Some(prev) if now_ms - prev < PROFILE_REBROADCAST_FLOOR_MS => false,
1783 _ => {
1784 last.insert(fingerprint.clone(), now_ms);
1785 true
1786 }
1787 }
1788 };
1789 if should_send {
1790 let msg = RoomMessage::ProfileUpdate {
1791 sender_fingerprint: self.identity.fingerprint().to_string(),
1792 username: our_username,
1793 updated_at: now_ms,
1794 };
1795 if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
1796 if let Ok(bytes) =
1797 crate::network::protocol::encode_wire_signed(&env)
1798 {
1799 let rooms: Vec<String> = self
1800 .active_rooms
1801 .lock()
1802 .unwrap()
1803 .keys()
1804 .cloned()
1805 .collect();
1806 for room_id in rooms {
1807 self.network
1808 .publish_room_message(room_id, bytes.clone())
1809 .await;
1810 }
1811 }
1812 }
1813 }
1814 }
1815 }
1816 NetworkEvent::RelayReservationEstablished { address } => {
1817 info!(addr = %address, "relay reservation established");
1822 self.relay_circuit_addrs
1823 .lock()
1824 .unwrap()
1825 .insert(address.to_string());
1826 let _ = self.app_event_tx.send(AppEvent::ListeningOn {
1827 address: address.to_string(),
1828 });
1829 }
1830 NetworkEvent::NatProbeResult {
1831 tested_addr,
1832 reachable,
1833 } => {
1834 let addr_s = tested_addr.to_string();
1835 let (transitioned, becomes_reachable) = {
1836 let mut set = self.nat_reachable_addrs.lock().unwrap();
1837 let was_empty = set.is_empty();
1838 if reachable {
1839 set.insert(addr_s.clone());
1840 } else {
1841 set.remove(&addr_s);
1842 }
1843 let is_empty = set.is_empty();
1844 (was_empty != is_empty, !is_empty)
1845 };
1846 if transitioned {
1847 let label = if becomes_reachable {
1848 "reachable".to_string()
1849 } else {
1850 "private".to_string()
1851 };
1852 info!(reachable = %becomes_reachable, "NAT reachability changed");
1853 let _ = self.app_event_tx.send(AppEvent::NatStatusChanged {
1854 label,
1855 reachable: becomes_reachable,
1856 });
1857 }
1858 }
1859 NetworkEvent::DcutrUpgrade {
1860 remote_peer,
1861 success,
1862 } => {
1863 if success {
1864 let s = remote_peer.to_base58();
1868 let tail: String = s.chars().rev().take(8).collect::<String>()
1869 .chars()
1870 .rev()
1871 .collect();
1872 let _ = self.app_event_tx.send(AppEvent::DcutrSucceeded {
1873 peer_label: tail,
1874 });
1875 }
1876 }
1877 NetworkEvent::InboundDial {
1878 peer_id,
1879 fingerprint,
1880 address,
1881 } => {
1882 if repo::is_peer_blocked(&self.db, &fingerprint).unwrap_or(false) {
1884 info!(%fingerprint, "inbound dial auto-rejected: peer is blocked");
1885 self.network.reject_inbound(peer_id).await;
1886 return;
1887 }
1888 let global_verified_only =
1893 repo::get_setting(&self.db, "verified_only_inbound")
1894 .ok()
1895 .flatten()
1896 .map(|v| v == "1")
1897 .unwrap_or(false);
1898 if global_verified_only {
1899 let is_verified =
1900 repo::is_globally_verified(&self.db, &fingerprint).unwrap_or(false)
1901 || repo::is_fingerprint_trusted(&self.db, &fingerprint)
1902 .unwrap_or(false);
1903 if !is_verified {
1904 info!(
1905 %fingerprint,
1906 "inbound dial auto-rejected: verified-only mode"
1907 );
1908 self.network.reject_inbound(peer_id).await;
1909 return;
1910 }
1911 }
1912 if repo::is_fingerprint_trusted(&self.db, &fingerprint).unwrap_or(false) {
1913 info!(%fingerprint, "inbound dial auto-accepted: peer is trusted");
1914 self.connected_dial_addrs
1917 .lock()
1918 .unwrap()
1919 .insert(address.to_string(), peer_id);
1920 let _ = repo::upsert_known_peer(
1921 &self.db,
1922 &KnownPeer {
1923 address: address.to_string(),
1924 label: None,
1925 last_connected_at: Some(now_unix()),
1926 last_attempt_at: Some(now_unix()),
1927 created_at: now_unix(),
1928 fingerprint: Some(fingerprint),
1929 trusted: true,
1930 },
1931 );
1932 self.network.accept_inbound(peer_id).await;
1933 return;
1934 }
1935 let _ = self.app_event_tx.send(AppEvent::InboundDial {
1937 peer_id,
1938 fingerprint,
1939 address: address.to_string(),
1940 });
1941 }
1942 }
1943 }
1944
1945 async fn handle_room_message(
1951 &self,
1952 room_id: &str,
1953 msg: RoomMessage,
1954 verified_signer: Option<String>,
1955 ) {
1956 let our_fp = self.identity.fingerprint().to_string();
1957 match msg {
1958 RoomMessage::MemberAnnounce {
1959 sender_fingerprint,
1960 wrapped_session_key,
1961 display_name,
1962 sender_ed25519_pubkey,
1963 } => {
1964 if sender_fingerprint == our_fp {
1965 return;
1966 }
1967 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
1970 .unwrap_or(false)
1971 {
1972 info!(%sender_fingerprint, %room_id, "dropping MemberAnnounce from banned peer");
1973 return;
1974 }
1975 if repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
1982 && !repo::is_globally_verified(&self.db, &sender_fingerprint).unwrap_or(false)
1983 {
1984 info!(
1985 %sender_fingerprint, %room_id,
1986 "dropping MemberAnnounce: room is verified-only and joiner isn't verified"
1987 );
1988 let owners = repo::list_room_owners(&self.db, room_id).unwrap_or_default();
1989 let lowest_owner = owners.iter().min().cloned();
1990 if lowest_owner.as_deref() == Some(&our_fp) {
1991 let msg = RoomMessage::JoinRefused {
1992 room_id: room_id.to_string(),
1993 target_fingerprint: sender_fingerprint.clone(),
1994 reason: "room requires SAS verification — ask an existing member to verify you".into(),
1995 };
1996 if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
1997 if let Ok(bytes) =
1998 crate::network::protocol::encode_wire_signed(&env)
1999 {
2000 self.network
2001 .publish_room_message(room_id.to_string(), bytes)
2002 .await;
2003 }
2004 }
2005 }
2006 return;
2007 }
2008 let need_inbound = {
2009 let mut rooms = self.active_rooms.lock().unwrap();
2010 let room = match rooms.get_mut(room_id) {
2011 Some(r) => r,
2012 None => return,
2013 };
2014 if room.info.kind == RoomKind::Direct
2022 && !room.members.contains(&sender_fingerprint)
2023 && room.members.len() >= 2
2024 {
2025 info!(
2026 %sender_fingerprint, %room_id,
2027 "dropping MemberAnnounce on Direct room: already at 2-member cap"
2028 );
2029 return;
2030 }
2031 let newly_added = room.members.insert(sender_fingerprint.clone());
2032 if newly_added {
2033 let _ = self.app_event_tx.send(AppEvent::MemberJoined {
2034 room_id: room_id.to_string(),
2035 fingerprint: sender_fingerprint.clone(),
2036 });
2037 }
2038 let _ = repo::upsert_room_member(
2043 &self.db,
2044 &StoredRoomMember {
2045 room_id: room_id.to_string(),
2046 peer_id: String::new(), fingerprint: sender_fingerprint.clone(),
2048 last_seen: Some(now_unix()),
2049 verified: false,
2050 ed25519_pubkey: sender_ed25519_pubkey.clone(),
2051 role: "member".into(),
2057 },
2058 );
2059 if let Some(name) = display_name.as_deref() {
2060 let _ = repo::set_member_display_name(
2061 &self.db,
2062 room_id,
2063 &sender_fingerprint,
2064 Some(name),
2065 );
2066 }
2067 room.info.encrypted && wrapped_session_key.is_some()
2068 };
2069
2070 if need_inbound {
2071 let wrapped = wrapped_session_key.unwrap();
2072 let result = {
2073 let mut rooms = self.active_rooms.lock().unwrap();
2074 let room = rooms.get_mut(room_id).unwrap();
2075 let passphrase_key = match &room.passphrase_key {
2076 Some(k) => k,
2077 None => {
2078 warn!("no passphrase key when receiving session key");
2079 return;
2080 }
2081 };
2082 match passphrase::unwrap(&wrapped, passphrase_key) {
2083 Ok(plain) => match String::from_utf8(plain) {
2084 Ok(key_b64) => {
2085 let crypto = room.crypto.as_mut().unwrap();
2086 crypto.add_inbound_session(&sender_fingerprint, &key_b64)
2087 }
2088 Err(e) => Err(HuddleError::Session(format!("utf8: {e}"))),
2089 },
2090 Err(e) => Err(e),
2091 }
2092 };
2093 if let Err(e) = result {
2094 error!(%e, "add inbound session failed");
2095 }
2096 }
2097 }
2098 RoomMessage::SessionKeyRequest {
2099 requester_fingerprint,
2100 } => {
2101 if requester_fingerprint == our_fp {
2102 return;
2103 }
2104 if let Err(e) = self.broadcast_member_announce(room_id).await {
2106 warn!(%e, "broadcast member announce on request");
2107 }
2108 }
2109 RoomMessage::Encrypted {
2110 sender_fingerprint,
2111 session_id,
2112 ciphertext_b64,
2113 } => {
2114 if sender_fingerprint == our_fp {
2115 return;
2116 }
2117 let ct_bytes = match base64::Engine::decode(
2118 &base64::engine::general_purpose::STANDARD,
2119 &ciphertext_b64,
2120 ) {
2121 Ok(b) => b,
2122 Err(e) => {
2123 warn!(%e, "bad base64 ciphertext");
2124 return;
2125 }
2126 };
2127 let plaintext = {
2128 let mut rooms = self.active_rooms.lock().unwrap();
2129 let room = match rooms.get_mut(room_id) {
2130 Some(r) => r,
2131 None => return,
2132 };
2133 let crypto = match room.crypto.as_mut() {
2134 Some(c) => c,
2135 None => return,
2136 };
2137 crypto.decrypt(&sender_fingerprint, &session_id, &ct_bytes)
2138 };
2139 match plaintext {
2140 Ok(pt) => {
2141 let body = String::from_utf8_lossy(&pt).to_string();
2142 let sent_at = now_unix();
2143 let _ = repo::insert_room_message(
2144 &self.db,
2145 room_id,
2146 &sender_fingerprint,
2147 "in",
2148 &body,
2149 sent_at,
2150 );
2151 let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
2152 self.maybe_emit_mention(room_id, &body);
2153 let _ = self.app_event_tx.send(AppEvent::MessageReceived {
2154 room_id: room_id.to_string(),
2155 sender_fingerprint,
2156 body,
2157 sent_at,
2158 });
2159 }
2160 Err(e) => {
2161 debug!(%e, "decrypt failed (probably missing session key)");
2162 }
2163 }
2164 }
2165 RoomMessage::Plain {
2166 sender_fingerprint,
2167 body,
2168 } => {
2169 if sender_fingerprint == our_fp {
2170 return;
2171 }
2172 let sent_at = now_unix();
2173 let _ = repo::insert_room_message(
2174 &self.db,
2175 room_id,
2176 &sender_fingerprint,
2177 "in",
2178 &body,
2179 sent_at,
2180 );
2181 let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
2182 self.maybe_emit_mention(room_id, &body);
2183 let _ = self.app_event_tx.send(AppEvent::MessageReceived {
2184 room_id: room_id.to_string(),
2185 sender_fingerprint,
2186 body,
2187 sent_at,
2188 });
2189 }
2190 RoomMessage::Typing { sender_fingerprint } => {
2191 if sender_fingerprint == our_fp {
2192 return;
2193 }
2194 let expiry = now_unix() + TYPING_TTL_SECS;
2195 let mut rooms = self.active_rooms.lock().unwrap();
2196 if let Some(room) = rooms.get_mut(room_id) {
2197 room.typers.insert(sender_fingerprint, expiry);
2198 }
2199 drop(rooms);
2200 let _ = self.app_event_tx.send(AppEvent::TypingChanged {
2201 room_id: room_id.to_string(),
2202 });
2203 }
2204 RoomMessage::RotateRoomKey {
2205 rotator_fingerprint,
2206 new_salt,
2207 } => {
2208 if rotator_fingerprint == our_fp {
2209 return;
2210 }
2211 let signer = match verified_signer {
2216 Some(fp) => fp,
2217 None => {
2218 warn!(%room_id, "RotateRoomKey arrived unsigned; dropping");
2219 return;
2220 }
2221 };
2222 if signer != rotator_fingerprint {
2223 warn!(
2224 %signer, %rotator_fingerprint, %room_id,
2225 "RotateRoomKey signer mismatch with claimed rotator; dropping"
2226 );
2227 return;
2228 }
2229 let _ = self.app_event_tx.send(AppEvent::RotationRequested {
2230 room_id: room_id.to_string(),
2231 rotator_fingerprint,
2232 new_salt,
2233 });
2234 }
2235 RoomMessage::MemberLeave { sender_fingerprint } => {
2236 if sender_fingerprint == our_fp {
2237 return;
2238 }
2239 let removed = {
2240 let mut rooms = self.active_rooms.lock().unwrap();
2241 if let Some(room) = rooms.get_mut(room_id) {
2242 room.members.remove(&sender_fingerprint)
2243 } else {
2244 false
2245 }
2246 };
2247 if removed {
2248 let _ = self.app_event_tx.send(AppEvent::MemberLeft {
2249 room_id: room_id.to_string(),
2250 fingerprint: sender_fingerprint,
2251 });
2252 }
2253 }
2254 RoomMessage::FileOffer {
2255 sender_fingerprint,
2256 file_id,
2257 name,
2258 size_bytes,
2259 mime,
2260 chunk_count,
2261 encrypted_meta,
2262 } => {
2263 if sender_fingerprint == our_fp {
2264 return; }
2266 self.handle_file_offer(
2267 room_id,
2268 sender_fingerprint,
2269 file_id,
2270 name,
2271 size_bytes,
2272 mime,
2273 chunk_count,
2274 encrypted_meta,
2275 );
2276 }
2277 RoomMessage::FileChunk {
2278 sender_fingerprint,
2279 file_id,
2280 chunk_index,
2281 total_chunks,
2282 data_b64,
2283 } => {
2284 if sender_fingerprint == our_fp {
2285 return;
2286 }
2287 self.handle_file_chunk(
2288 room_id,
2289 sender_fingerprint,
2290 file_id,
2291 chunk_index,
2292 total_chunks,
2293 data_b64,
2294 );
2295 }
2296 RoomMessage::OwnerGrant {
2297 room_id: announced_room_id,
2298 target_fingerprint,
2299 } => {
2300 if announced_room_id != room_id {
2305 warn!(payload_room = %announced_room_id, topic_room = %room_id, "OwnerGrant room mismatch");
2306 return;
2307 }
2308 let signer = match verified_signer {
2309 Some(fp) => fp,
2310 None => {
2311 warn!(%room_id, "OwnerGrant arrived unsigned; dropping");
2312 return;
2313 }
2314 };
2315 if !self.is_owner(room_id, &signer) {
2316 warn!(%signer, %room_id, "OwnerGrant signer isn't an owner; dropping");
2317 return;
2318 }
2319 info!(%signer, %target_fingerprint, %room_id, "OwnerGrant applied");
2320 if let Err(e) =
2321 repo::set_member_role(&self.db, room_id, &target_fingerprint, "owner")
2322 {
2323 warn!(%e, "OwnerGrant: set_member_role failed");
2324 }
2325 }
2326 RoomMessage::BanMember {
2327 room_id: announced_room_id,
2328 target_fingerprint,
2329 } => {
2330 if announced_room_id != room_id {
2331 warn!(payload_room = %announced_room_id, topic_room = %room_id, "BanMember room mismatch");
2332 return;
2333 }
2334 let signer = match verified_signer {
2335 Some(fp) => fp,
2336 None => {
2337 warn!(%room_id, "BanMember arrived unsigned; dropping");
2338 return;
2339 }
2340 };
2341 if !self.is_owner(room_id, &signer) {
2342 warn!(%signer, %room_id, "BanMember signer isn't an owner; dropping");
2343 return;
2344 }
2345 if target_fingerprint == our_fp {
2346 info!(%room_id, %signer, "we were kicked from this room");
2352 self.active_rooms.lock().unwrap().remove(room_id);
2353 let _ = self.app_event_tx.send(AppEvent::RoomLeft {
2354 room_id: room_id.to_string(),
2355 });
2356 return;
2357 }
2358 info!(%signer, %target_fingerprint, %room_id, "BanMember applied");
2359 if let Err(e) = repo::add_room_ban(
2360 &self.db,
2361 room_id,
2362 &target_fingerprint,
2363 &signer,
2364 "", now_unix(),
2366 ) {
2367 warn!(%e, "BanMember: add_room_ban failed");
2368 }
2369 self.evict_banned_member(room_id, &target_fingerprint);
2370 }
2371 RoomMessage::SasInit {
2372 tx_id,
2373 ephemeral_x25519_pubkey_b64,
2374 target_fingerprint,
2375 } => {
2376 if target_fingerprint != our_fp {
2377 return;
2382 }
2383 let signer = match verified_signer {
2384 Some(fp) => fp,
2385 None => {
2386 warn!("SasInit arrived unsigned; dropping");
2387 return;
2388 }
2389 };
2390 let their_pub =
2391 match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
2392 Ok(pk) => pk,
2393 Err(e) => {
2394 warn!(%e, "SasInit: bad x25519 pubkey");
2395 return;
2396 }
2397 };
2398 let tx_id_bytes = match B64.decode(&tx_id) {
2399 Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
2400 let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
2401 arr.copy_from_slice(&b);
2402 arr
2403 }
2404 _ => {
2405 warn!(%tx_id, "SasInit: bad tx_id length");
2406 return;
2407 }
2408 };
2409 let (_, our_secret, our_pub) = crate::crypto::sas::new_session();
2410 let sas_code =
2411 crate::crypto::sas::derive_sas_code(&our_secret, &their_pub, &tx_id_bytes);
2412 self.sas_flows.lock().unwrap().insert(
2413 tx_id.clone(),
2414 SasFlow {
2415 room_id: room_id.to_string(),
2416 partner_fingerprint: signer.clone(),
2417 our_secret,
2418 sas_code: Some(sas_code.clone()),
2419 our_confirmed: false,
2420 their_confirmed: false,
2421 },
2422 );
2423 let response = RoomMessage::SasResponse {
2426 tx_id: tx_id.clone(),
2427 ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2428 };
2429 if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
2430 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
2431 self.network
2432 .publish_room_message(room_id.to_string(), bytes)
2433 .await;
2434 }
2435 }
2436 let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
2437 room_id: room_id.to_string(),
2438 partner_fingerprint: signer,
2439 tx_id,
2440 emoji_string: sas_code.emoji_string(),
2441 emoji_labels: sas_code.emoji_labels(),
2442 decimal: sas_code.decimal,
2443 });
2444 }
2445 RoomMessage::SasResponse {
2446 tx_id,
2447 ephemeral_x25519_pubkey_b64,
2448 } => {
2449 let signer = match verified_signer {
2450 Some(fp) => fp,
2451 None => {
2452 warn!("SasResponse arrived unsigned; dropping");
2453 return;
2454 }
2455 };
2456 let their_pub =
2457 match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
2458 Ok(pk) => pk,
2459 Err(e) => {
2460 warn!(%e, "SasResponse: bad x25519 pubkey");
2461 return;
2462 }
2463 };
2464 let tx_id_bytes = match B64.decode(&tx_id) {
2465 Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
2466 let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
2467 arr.copy_from_slice(&b);
2468 arr
2469 }
2470 _ => return,
2471 };
2472 let emit = {
2473 let mut flows = self.sas_flows.lock().unwrap();
2474 let flow = match flows.get_mut(&tx_id) {
2475 Some(f) => f,
2476 None => {
2477 warn!(%tx_id, "SasResponse for unknown tx_id");
2478 return;
2479 }
2480 };
2481 if flow.partner_fingerprint != signer {
2482 warn!(
2483 expected = %flow.partner_fingerprint, got = %signer,
2484 "SasResponse signer doesn't match flow's partner; dropping"
2485 );
2486 return;
2487 }
2488 let code = crate::crypto::sas::derive_sas_code(
2489 &flow.our_secret,
2490 &their_pub,
2491 &tx_id_bytes,
2492 );
2493 flow.sas_code = Some(code.clone());
2494 code
2495 };
2496 let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
2497 room_id: room_id.to_string(),
2498 partner_fingerprint: signer,
2499 tx_id,
2500 emoji_string: emit.emoji_string(),
2501 emoji_labels: emit.emoji_labels(),
2502 decimal: emit.decimal,
2503 });
2504 }
2505 RoomMessage::CodeJoinRequest {
2506 room_id: announced_room_id,
2507 joiner_x25519_pubkey_b64,
2508 code,
2509 } => {
2510 if announced_room_id != room_id {
2511 return;
2512 }
2513 let joiner_fp = match verified_signer {
2514 Some(fp) => fp,
2515 None => {
2516 warn!("CodeJoinRequest unsigned; dropping");
2517 return;
2518 }
2519 };
2520 let our_fp = self.identity.fingerprint().to_string();
2524 if !self.is_owner(room_id, &our_fp) {
2525 return;
2526 }
2527 let now = now_unix();
2529 let (code_ok, our_session_id, wrap_input) = {
2530 let mut rooms = self.active_rooms.lock().unwrap();
2531 let room = match rooms.get_mut(room_id) {
2532 Some(r) => r,
2533 None => return,
2534 };
2535 if room.passphrase_key.is_none() {
2536 warn!("CodeJoinRequest: no passphrase key locally; can't respond");
2537 return;
2538 }
2539 let original_len = room.issued_codes.len();
2540 room.issued_codes.retain(|(c, exp)| !(c == &code && *exp > now));
2541 let matched = room.issued_codes.len() < original_len;
2542 if !matched {
2543 info!(%joiner_fp, "CodeJoinRequest: code invalid or expired; ignoring");
2544 return;
2545 }
2546 let crypto = room.crypto.as_ref().unwrap();
2547 (
2548 true,
2549 crypto.our_session_id(),
2550 crypto.our_session_key_b64(),
2551 )
2552 };
2553 let _ = code_ok;
2554 let their_pub = match crate::crypto::sas::parse_pubkey(&joiner_x25519_pubkey_b64) {
2556 Ok(pk) => pk,
2557 Err(e) => {
2558 warn!(%e, "CodeJoinRequest: bad pubkey");
2559 return;
2560 }
2561 };
2562 use x25519_dalek::{PublicKey, StaticSecret};
2563 let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
2564 let our_pub = PublicKey::from(&our_secret);
2565 let shared = our_secret.diffie_hellman(&their_pub);
2566 let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
2568 let mut wrap_key = [0u8; passphrase::KEY_LEN];
2569 hk.expand(b"huddle-code-join-v1", &mut wrap_key)
2570 .expect("32 bytes is within HKDF limits");
2571 let wrapped = match passphrase::wrap(wrap_input.as_bytes(), &wrap_key) {
2574 Ok(w) => w,
2575 Err(e) => {
2576 warn!(%e, "CodeJoinRequest: wrap failed");
2577 return;
2578 }
2579 };
2580 let response = RoomMessage::CodeJoinResponse {
2581 room_id: room_id.to_string(),
2582 target_fingerprint: joiner_fp.clone(),
2583 owner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2584 owner_session_id: our_session_id,
2585 wrapped_session_key_b64: wrapped,
2586 nonce_b64: String::new(), };
2588 if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
2589 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
2590 self.network
2591 .publish_room_message(room_id.to_string(), bytes)
2592 .await;
2593 }
2594 }
2595 info!(%joiner_fp, %room_id, "issued CodeJoinResponse");
2596 }
2597 RoomMessage::CodeJoinResponse {
2598 room_id: announced_room_id,
2599 target_fingerprint,
2600 owner_x25519_pubkey_b64,
2601 owner_session_id,
2602 wrapped_session_key_b64,
2603 nonce_b64: _,
2604 } => {
2605 if announced_room_id != room_id || target_fingerprint != our_fp {
2606 return;
2607 }
2608 let owner_fp = match verified_signer {
2609 Some(fp) => fp,
2610 None => {
2611 warn!("CodeJoinResponse unsigned; dropping");
2612 return;
2613 }
2614 };
2615 let our_secret = match self
2616 .pending_code_secrets
2617 .lock()
2618 .unwrap()
2619 .remove(&(room_id.to_string(), our_fp.clone()))
2620 {
2621 Some(s) => s,
2622 None => {
2623 warn!(%room_id, "CodeJoinResponse with no pending code-join state");
2624 return;
2625 }
2626 };
2627 let owner_pub = match crate::crypto::sas::parse_pubkey(&owner_x25519_pubkey_b64) {
2628 Ok(pk) => pk,
2629 Err(e) => {
2630 warn!(%e, "CodeJoinResponse: bad owner pubkey");
2631 return;
2632 }
2633 };
2634 let shared = our_secret.diffie_hellman(&owner_pub);
2635 let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
2636 let mut wrap_key = [0u8; passphrase::KEY_LEN];
2637 hk.expand(b"huddle-code-join-v1", &mut wrap_key)
2638 .expect("32 bytes within HKDF limits");
2639 let session_key_bytes =
2640 match passphrase::unwrap(&wrapped_session_key_b64, &wrap_key) {
2641 Ok(b) => b,
2642 Err(e) => {
2643 warn!(%e, "CodeJoinResponse: unwrap failed");
2644 return;
2645 }
2646 };
2647 let session_key_str = match String::from_utf8(session_key_bytes) {
2648 Ok(s) => s,
2649 Err(e) => {
2650 warn!(%e, "CodeJoinResponse: session key wasn't valid utf8");
2651 return;
2652 }
2653 };
2654 let mut rooms = self.active_rooms.lock().unwrap();
2656 if let Some(room) = rooms.get_mut(room_id) {
2657 if let Some(crypto) = room.crypto.as_mut() {
2658 if let Err(e) =
2659 crypto.add_inbound_session(&owner_fp, &session_key_str)
2660 {
2661 warn!(%e, "CodeJoinResponse: add_inbound_session failed");
2662 } else {
2663 info!(%room_id, %owner_fp, %owner_session_id, "code-join completed; can decrypt owner's messages");
2664 room.members.insert(owner_fp.clone());
2665 let _ = self.app_event_tx.send(AppEvent::MemberJoined {
2666 room_id: room_id.to_string(),
2667 fingerprint: owner_fp,
2668 });
2669 }
2670 }
2671 }
2672 }
2673 RoomMessage::JoinRefused {
2674 room_id: announced_room_id,
2675 target_fingerprint,
2676 reason,
2677 } => {
2678 if announced_room_id != room_id || target_fingerprint != our_fp {
2679 return;
2680 }
2681 let _ = self.app_event_tx.send(AppEvent::Error {
2685 description: format!("join refused: {reason}"),
2686 });
2687 }
2688 RoomMessage::SasConfirm { tx_id, matched } => {
2689 let signer = match verified_signer {
2690 Some(fp) => fp,
2691 None => return,
2692 };
2693 let (room_id_done, partner_fp_done, both_done) = {
2694 let mut flows = self.sas_flows.lock().unwrap();
2695 let flow = match flows.get_mut(&tx_id) {
2696 Some(f) => f,
2697 None => return,
2698 };
2699 if flow.partner_fingerprint != signer {
2700 return;
2701 }
2702 if !matched {
2703 let _ = flow;
2705 flows.remove(&tx_id);
2706 return;
2707 }
2708 flow.their_confirmed = true;
2709 if flow.our_confirmed && flow.their_confirmed {
2710 (
2711 Some(flow.room_id.clone()),
2712 Some(flow.partner_fingerprint.clone()),
2713 true,
2714 )
2715 } else {
2716 (None, None, false)
2717 }
2718 };
2719 if both_done {
2720 if let (Some(rid), Some(pfp)) = (room_id_done, partner_fp_done) {
2721 if let Err(e) = self.finish_sas(&tx_id, &rid, &pfp).await {
2722 warn!(%e, "finish_sas failed");
2723 }
2724 }
2725 }
2726 }
2727 RoomMessage::ProfileUpdate {
2728 sender_fingerprint,
2729 username,
2730 updated_at,
2731 } => {
2732 let signer = match verified_signer {
2738 Some(fp) => fp,
2739 None => {
2740 warn!(
2741 sender = %sender_fingerprint,
2742 "dropping unsigned ProfileUpdate"
2743 );
2744 return;
2745 }
2746 };
2747 if signer != sender_fingerprint {
2748 warn!(
2749 signer = %signer,
2750 claimed = %sender_fingerprint,
2751 "dropping ProfileUpdate with signer != sender"
2752 );
2753 return;
2754 }
2755 if let Err(e) = repo::upsert_peer_profile(
2756 &self.db,
2757 &sender_fingerprint,
2758 username.as_deref(),
2759 updated_at,
2760 ) {
2761 warn!(%e, "upsert_peer_profile failed");
2762 return;
2763 }
2764 let _ = self.app_event_tx.send(AppEvent::PeerProfileUpdated {
2765 fingerprint: sender_fingerprint,
2766 username,
2767 });
2768 }
2769 }
2770 }
2771
2772 pub async fn send_file(&self, room_id: &str, path: &Path) -> Result<String> {
2780 let bytes = std::fs::read(path)?;
2781 let name = path
2782 .file_name()
2783 .map(|n| n.to_string_lossy().to_string())
2784 .unwrap_or_else(|| "untitled".into());
2785 let mime = crate::files::guess_mime(&name);
2786 let original_path = path.to_path_buf();
2787
2788 let (room_encrypted, mut maybe_session_id, encrypted_meta_opt, wire_bytes) = {
2789 let mut rooms = self.active_rooms.lock().unwrap();
2790 let room = rooms
2791 .get_mut(room_id)
2792 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
2793 if room.info.encrypted {
2794 let crypto = room
2795 .crypto
2796 .as_mut()
2797 .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
2798 let (ciphertext, meta) = file_encryption::encrypt_file(&bytes, crypto)?;
2799 (true, Some(meta.megolm_session_id.clone()), Some(meta), ciphertext)
2800 } else {
2801 (false, None, None, bytes)
2802 }
2803 };
2804 let _ = &mut maybe_session_id; let plan =
2807 self.file_manager
2808 .prepare_outgoing_from_bytes(&name, mime.clone(), wire_bytes)?;
2809 let file_id = plan.file_id.clone();
2810 let total = plan.chunks.len() as u32;
2811 let our_fp = self.identity.fingerprint().to_string();
2812
2813 let attachment = StoredAttachment {
2814 id: 0,
2815 room_id: room_id.to_string(),
2816 message_id: None,
2817 sender_fingerprint: our_fp.clone(),
2818 file_id: file_id.clone(),
2819 name: name.clone(),
2820 mime: mime.clone(),
2821 size_bytes: plan.size_bytes as i64,
2822 status: AttachmentStatus::Ready,
2823 cache_path: Some(self.file_manager.cache_path(&file_id).to_string_lossy().into()),
2824 saved_path: Some(original_path.to_string_lossy().into()),
2825 error: None,
2826 encrypted: room_encrypted,
2827 wrapped_key: encrypted_meta_opt.as_ref().map(|m| m.wrapped_key_b64.clone()),
2828 nonce: encrypted_meta_opt.as_ref().map(|m| m.nonce_b64.clone()),
2829 megolm_session_id: encrypted_meta_opt
2830 .as_ref()
2831 .map(|m| m.megolm_session_id.clone()),
2832 content_hash: encrypted_meta_opt.as_ref().map(|m| m.content_hash.clone()),
2833 created_at: now_unix(),
2834 };
2835 repo::upsert_attachment(&self.db, &attachment)?;
2836 let _ = self.app_event_tx.send(AppEvent::FileOffered {
2837 room_id: room_id.to_string(),
2838 file_id: file_id.clone(),
2839 name: name.clone(),
2840 size_bytes: plan.size_bytes,
2841 sender_fingerprint: our_fp.clone(),
2842 });
2843
2844 let offer = RoomMessage::FileOffer {
2846 sender_fingerprint: our_fp.clone(),
2847 file_id: file_id.clone(),
2848 name,
2849 size_bytes: plan.size_bytes,
2850 mime,
2851 chunk_count: total,
2852 encrypted_meta: encrypted_meta_opt,
2853 };
2854 if let Ok(bytes) = encode_wire(&offer) {
2855 self.network
2856 .publish_room_message(room_id.to_string(), bytes)
2857 .await;
2858 }
2859
2860 let net = self.network.clone();
2863 let room = room_id.to_string();
2864 let our = our_fp.clone();
2865 let fid = file_id.clone();
2866 let chunks = plan.chunks.clone();
2867 tokio::spawn(async move {
2868 for (i, data) in chunks.iter().enumerate() {
2869 let msg = RoomMessage::FileChunk {
2870 sender_fingerprint: our.clone(),
2871 file_id: fid.clone(),
2872 chunk_index: i as u32,
2873 total_chunks: total,
2874 data_b64: B64.encode(data),
2875 };
2876 if let Ok(bytes) = encode_wire(&msg) {
2877 net.publish_room_message(room.clone(), bytes).await;
2878 }
2879 tokio::time::sleep(Duration::from_millis(40)).await;
2880 }
2881 });
2882
2883 Ok(file_id)
2884 }
2885
2886 pub async fn save_to_downloads(&self, room_id: &str, file_id: &str) -> Result<PathBuf> {
2889 let attachment = repo::get_attachment(&self.db, room_id, file_id)?
2890 .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
2891 if !matches!(
2892 attachment.status,
2893 AttachmentStatus::Ready | AttachmentStatus::Saved
2894 ) {
2895 return Err(HuddleError::Other(format!(
2896 "attachment is not ready (status={})",
2897 attachment.status.as_str()
2898 )));
2899 }
2900 let plaintext = if attachment.encrypted
2905 && attachment.sender_fingerprint == self.identity.fingerprint()
2906 {
2907 match attachment
2908 .saved_path
2909 .as_deref()
2910 .filter(|p| Path::new(p).exists())
2911 {
2912 Some(src) => std::fs::read(src)?,
2913 None => {
2914 return Err(HuddleError::Other(
2915 "your original file has moved or been deleted — it can't be \
2916 recovered from the encrypted cache"
2917 .into(),
2918 ));
2919 }
2920 }
2921 } else {
2922 let cached = self.file_manager.read_cache(file_id)?;
2923 if attachment.encrypted {
2924 let meta = EncryptedFileMeta {
2925 megolm_session_id: attachment
2926 .megolm_session_id
2927 .clone()
2928 .ok_or_else(|| HuddleError::Other("missing megolm_session_id".into()))?,
2929 wrapped_key_b64: attachment
2930 .wrapped_key
2931 .clone()
2932 .ok_or_else(|| HuddleError::Other("missing wrapped_key".into()))?,
2933 nonce_b64: attachment
2934 .nonce
2935 .clone()
2936 .ok_or_else(|| HuddleError::Other("missing nonce".into()))?,
2937 content_hash: attachment
2938 .content_hash
2939 .clone()
2940 .ok_or_else(|| HuddleError::Other("missing content_hash".into()))?,
2941 };
2942 self.decrypt_attachment(
2943 room_id,
2944 &attachment.sender_fingerprint,
2945 &cached,
2946 &meta,
2947 )?
2948 } else {
2949 cached
2950 }
2951 };
2952 let saved = self.file_manager.write_to_downloads(&attachment.name, &plaintext)?;
2953 repo::update_attachment_paths(
2954 &self.db,
2955 room_id,
2956 file_id,
2957 None,
2958 Some(&saved.to_string_lossy()),
2959 )?;
2960 repo::update_attachment_status(&self.db, room_id, file_id, AttachmentStatus::Saved, None)?;
2961 let _ = self.app_event_tx.send(AppEvent::FileSaved {
2962 file_id: file_id.into(),
2963 path: saved.to_string_lossy().into(),
2964 });
2965 Ok(saved)
2966 }
2967
2968 pub async fn cancel_transfer(&self, room_id: &str, file_id: &str) -> Result<()> {
2970 self.file_manager.cancel_incoming(file_id);
2971 repo::update_attachment_status(
2972 &self.db,
2973 room_id,
2974 file_id,
2975 AttachmentStatus::Cancelled,
2976 None,
2977 )?;
2978 Ok(())
2979 }
2980
2981 pub fn open_saved(&self, room_id: &str, file_id: &str) -> Result<()> {
2983 let attachment = repo::get_attachment(&self.db, room_id, file_id)?
2984 .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
2985 let path = attachment
2986 .saved_path
2987 .ok_or_else(|| HuddleError::Other("not saved yet — press Enter to save first".into()))?;
2988 open_with_system(&path)
2989 }
2990
2991 pub fn list_room_attachments(&self, room_id: &str) -> Result<Vec<StoredAttachment>> {
2992 repo::list_room_attachments(&self.db, room_id)
2993 }
2994
2995 pub fn set_member_verified(
2999 &self,
3000 room_id: &str,
3001 fingerprint: &str,
3002 verified: bool,
3003 ) -> Result<()> {
3004 let members = repo::list_room_members(&self.db, room_id).unwrap_or_default();
3009 if !members.iter().any(|m| m.fingerprint == fingerprint) {
3010 repo::upsert_room_member(
3011 &self.db,
3012 &StoredRoomMember {
3013 room_id: room_id.to_string(),
3014 peer_id: String::new(),
3015 fingerprint: fingerprint.to_string(),
3016 last_seen: Some(now_unix()),
3017 verified,
3018 ed25519_pubkey: None,
3019 role: "member".into(),
3020 },
3021 )?;
3022 }
3023 repo::set_member_verified(&self.db, room_id, fingerprint, verified)
3024 }
3025
3026 pub fn verified_fingerprints(&self, room_id: &str) -> Vec<String> {
3027 repo::list_verified_fingerprints(&self.db, room_id).unwrap_or_default()
3028 }
3029
3030 pub fn is_owner(&self, room_id: &str, fingerprint: &str) -> bool {
3033 repo::list_room_owners(&self.db, room_id)
3034 .unwrap_or_default()
3035 .iter()
3036 .any(|fp| fp == fingerprint)
3037 }
3038
3039 pub fn we_are_owner(&self, room_id: &str) -> bool {
3040 self.is_owner(room_id, &self.identity.fingerprint().to_string())
3041 }
3042
3043 pub fn room_owners(&self, room_id: &str) -> Vec<String> {
3046 repo::list_room_owners(&self.db, room_id).unwrap_or_default()
3047 }
3048
3049 pub fn verified_only_inbound(&self) -> bool {
3052 repo::get_setting(&self.db, "verified_only_inbound")
3053 .unwrap_or(None)
3054 .map(|v| v == "1")
3055 .unwrap_or(false)
3056 }
3057
3058 pub fn set_verified_only_inbound(&self, on: bool) -> Result<()> {
3059 repo::set_setting(&self.db, "verified_only_inbound", if on { "1" } else { "0" })
3060 }
3061
3062 pub fn room_verified_only(&self, room_id: &str) -> bool {
3067 repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
3068 }
3069
3070 pub fn set_room_verified_only(&self, room_id: &str, on: bool) -> Result<()> {
3071 repo::set_room_verified_only(&self.db, room_id, on)
3072 }
3073
3074 pub fn onboarding_seen(&self) -> bool {
3076 repo::is_onboarding_seen(&self.db).unwrap_or(true)
3077 }
3078
3079 pub fn mark_onboarding_seen(&self) -> Result<()> {
3080 repo::mark_onboarding_seen(&self.db)
3081 }
3082
3083 pub fn last_seen_onboarding_version(&self) -> Option<String> {
3087 repo::get_last_seen_onboarding_version(&self.db).unwrap_or(None)
3088 }
3089
3090 pub fn set_last_seen_onboarding_version(&self, version: &str) -> Result<()> {
3091 repo::set_last_seen_onboarding_version(&self.db, version)
3092 }
3093
3094 pub fn update_check_enabled(&self) -> Option<bool> {
3097 repo::get_update_check_enabled(&self.db).unwrap_or(None)
3098 }
3099
3100 pub fn set_update_check_enabled(&self, enabled: bool) -> Result<()> {
3101 repo::set_update_check_enabled(&self.db, enabled)
3102 }
3103
3104 pub fn last_update_check_at(&self) -> i64 {
3107 repo::get_setting(&self.db, "last_update_check_at")
3108 .ok()
3109 .flatten()
3110 .and_then(|s| s.parse().ok())
3111 .unwrap_or(0)
3112 }
3113
3114 pub fn set_last_update_check_at(&self, ts: i64) -> Result<()> {
3115 repo::set_setting(&self.db, "last_update_check_at", &ts.to_string())
3116 }
3117
3118 pub fn last_known_remote_version(&self) -> Option<String> {
3122 repo::get_setting(&self.db, "last_known_remote_version")
3123 .ok()
3124 .flatten()
3125 }
3126
3127 pub fn set_last_known_remote_version(&self, v: &str) -> Result<()> {
3128 repo::set_setting(&self.db, "last_known_remote_version", v)
3129 }
3130
3131 pub async fn grant_owner(&self, room_id: &str, target_fingerprint: &str) -> Result<()> {
3135 let our_fp = self.identity.fingerprint().to_string();
3136 if !self.is_owner(room_id, &our_fp) {
3137 return Err(HuddleError::Other(
3138 "only an owner can grant owner".into(),
3139 ));
3140 }
3141 let msg = RoomMessage::OwnerGrant {
3142 room_id: room_id.to_string(),
3143 target_fingerprint: target_fingerprint.to_string(),
3144 };
3145 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3146 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3147 self.network
3148 .publish_room_message(room_id.to_string(), bytes)
3149 .await;
3150 repo::set_member_role(&self.db, room_id, target_fingerprint, "owner")?;
3152 Ok(())
3153 }
3154
3155 pub async fn kick_member(
3166 &self,
3167 room_id: &str,
3168 target_fingerprint: &str,
3169 ) -> Result<String> {
3170 let our_fp = self.identity.fingerprint().to_string();
3171 if !self.is_owner(room_id, &our_fp) {
3172 return Err(HuddleError::Other("only an owner can kick".into()));
3173 }
3174 if target_fingerprint == our_fp {
3175 return Err(HuddleError::Other("can't kick yourself".into()));
3176 }
3177 let info = self
3178 .active_rooms
3179 .lock()
3180 .unwrap()
3181 .get(room_id)
3182 .map(|r| r.info.clone())
3183 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3184 if !info.encrypted {
3185 let msg = RoomMessage::BanMember {
3189 room_id: room_id.to_string(),
3190 target_fingerprint: target_fingerprint.to_string(),
3191 };
3192 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3193 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3194 self.network
3195 .publish_room_message(room_id.to_string(), bytes)
3196 .await;
3197 repo::add_room_ban(
3198 &self.db,
3199 room_id,
3200 target_fingerprint,
3201 &our_fp,
3202 &env.signature_b64,
3203 now_unix(),
3204 )?;
3205 self.evict_banned_member(room_id, target_fingerprint);
3206 return Ok(String::new());
3207 }
3208 let new_passphrase = generate_join_passphrase();
3210 let msg = RoomMessage::BanMember {
3211 room_id: room_id.to_string(),
3212 target_fingerprint: target_fingerprint.to_string(),
3213 };
3214 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3215 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3216 self.network
3217 .publish_room_message(room_id.to_string(), bytes)
3218 .await;
3219 repo::add_room_ban(
3220 &self.db,
3221 room_id,
3222 target_fingerprint,
3223 &our_fp,
3224 &env.signature_b64,
3225 now_unix(),
3226 )?;
3227 self.evict_banned_member(room_id, target_fingerprint);
3228 self.rotate_room(room_id, &new_passphrase).await?;
3231 Ok(new_passphrase)
3232 }
3233
3234 pub fn generate_join_code(&self, room_id: &str) -> Result<String> {
3241 let our_fp = self.identity.fingerprint().to_string();
3242 if !self.is_owner(room_id, &our_fp) {
3243 return Err(HuddleError::Other(
3244 "only an owner can issue join codes".into(),
3245 ));
3246 }
3247 let code = generate_alphanumeric_code(8);
3248 let expires_at = now_unix() + 10 * 60;
3249 let mut rooms = self.active_rooms.lock().unwrap();
3250 let room = rooms
3251 .get_mut(room_id)
3252 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3253 let now = now_unix();
3255 room.issued_codes.retain(|(_, exp)| *exp > now);
3256 room.issued_codes.push((code.clone(), expires_at));
3257 Ok(code)
3258 }
3259
3260 pub async fn join_room_with_code(
3267 &self,
3268 room_id: &str,
3269 code: &str,
3270 ) -> Result<()> {
3271 let info = {
3273 let d = self.discovered_rooms.lock().unwrap().get(room_id).cloned();
3274 match d {
3275 Some(d) => StoredRoom {
3276 id: room_id.to_string(),
3277 name: d.name,
3278 creator_fingerprint: d.creator_fingerprint,
3279 encrypted: d.encrypted,
3280 passphrase_salt: None, created_at: now_unix(),
3282 last_active: Some(now_unix()),
3283 kind: d.kind,
3286 },
3287 None => {
3288 return Err(HuddleError::Other(format!(
3289 "room {room_id} not visible — wait for an announcement"
3290 )))
3291 }
3292 }
3293 };
3294 if !info.encrypted {
3295 return Err(HuddleError::Other(
3296 "code-join only applies to encrypted rooms".into(),
3297 ));
3298 }
3299 let our_fp = self.identity.fingerprint().to_string();
3300 use x25519_dalek::{PublicKey, StaticSecret};
3303 let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
3304 let our_pub = PublicKey::from(&our_secret);
3305 let key = (room_id.to_string(), our_fp.clone());
3310 self.pending_code_secrets
3311 .lock()
3312 .unwrap()
3313 .insert(key.clone(), our_secret);
3314 let map = self.pending_code_secrets.clone();
3319 let tx = self.app_event_tx.clone();
3320 let timeout_room = room_id.to_string();
3321 tokio::spawn(async move {
3322 tokio::time::sleep(std::time::Duration::from_secs(30)).await;
3323 let still_pending = map.lock().unwrap().remove(&key).is_some();
3324 if still_pending {
3325 let _ = tx.send(AppEvent::CodeJoinTimedOut {
3326 room_id: timeout_room,
3327 reason: "no response from owner — code may be wrong or expired".into(),
3328 });
3329 }
3330 });
3331 repo::insert_room(&self.db, &info)?;
3338 self.active_rooms.lock().unwrap().insert(
3341 room_id.to_string(),
3342 ActiveRoom {
3343 info: info.clone(),
3344 crypto: Some(RoomCrypto::new_for_room(
3345 self.db.clone(),
3346 room_id.to_string(),
3347 our_fp.clone(),
3348 self.session_persist_key,
3349 )?),
3350 passphrase_key: None,
3351 members: {
3352 let mut s = HashSet::new();
3353 s.insert(our_fp.clone());
3354 s
3355 },
3356 typers: HashMap::new(),
3357 read_only: true,
3358 issued_codes: Vec::new(),
3359 },
3360 );
3361 self.network.subscribe_room(room_id.to_string()).await;
3362 let req = RoomMessage::CodeJoinRequest {
3364 room_id: room_id.to_string(),
3365 joiner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
3366 code: code.to_string(),
3367 };
3368 let env = crate::crypto::sign_message(&self.identity, &req)?;
3369 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3370 self.network
3371 .publish_room_message(room_id.to_string(), bytes)
3372 .await;
3373 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
3376 room_id: room_id.to_string(),
3377 });
3378 Ok(())
3379 }
3380
3381 pub async fn sas_start(&self, room_id: &str, target_fingerprint: &str) -> Result<String> {
3387 let (tx_id_bytes, our_secret, our_pub) = crate::crypto::sas::new_session();
3388 let tx_id = B64.encode(tx_id_bytes);
3389 let msg = RoomMessage::SasInit {
3390 tx_id: tx_id.clone(),
3391 ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
3392 target_fingerprint: target_fingerprint.to_string(),
3393 };
3394 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3395 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3396 self.sas_flows.lock().unwrap().insert(
3397 tx_id.clone(),
3398 SasFlow {
3399 room_id: room_id.to_string(),
3400 partner_fingerprint: target_fingerprint.to_string(),
3401 our_secret,
3402 sas_code: None,
3403 our_confirmed: false,
3404 their_confirmed: false,
3405 },
3406 );
3407 self.network
3408 .publish_room_message(room_id.to_string(), bytes)
3409 .await;
3410 Ok(tx_id)
3411 }
3412
3413 pub async fn sas_match(&self, tx_id: &str) -> Result<()> {
3417 let (room_id, partner_fp, both_done) = {
3418 let mut flows = self.sas_flows.lock().unwrap();
3419 let flow = flows
3420 .get_mut(tx_id)
3421 .ok_or_else(|| HuddleError::Other("unknown SAS tx_id".into()))?;
3422 flow.our_confirmed = true;
3423 (
3424 flow.room_id.clone(),
3425 flow.partner_fingerprint.clone(),
3426 flow.our_confirmed && flow.their_confirmed,
3427 )
3428 };
3429 let msg = RoomMessage::SasConfirm {
3430 tx_id: tx_id.to_string(),
3431 matched: true,
3432 };
3433 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3434 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3435 self.network
3436 .publish_room_message(room_id.clone(), bytes)
3437 .await;
3438 if both_done {
3439 self.finish_sas(tx_id, &room_id, &partner_fp).await?;
3440 }
3441 Ok(())
3442 }
3443
3444 pub fn sas_cancel(&self, tx_id: &str) {
3448 self.sas_flows.lock().unwrap().remove(tx_id);
3449 }
3450
3451 async fn finish_sas(
3454 &self,
3455 tx_id: &str,
3456 room_id: &str,
3457 partner_fingerprint: &str,
3458 ) -> Result<()> {
3459 repo::set_member_verified(&self.db, room_id, partner_fingerprint, true)?;
3460 repo::add_verified_peer(&self.db, partner_fingerprint, now_unix())?;
3461 self.sas_flows.lock().unwrap().remove(tx_id);
3462 let _ = self.app_event_tx.send(AppEvent::SasVerified {
3463 room_id: room_id.to_string(),
3464 partner_fingerprint: partner_fingerprint.to_string(),
3465 });
3466 Ok(())
3467 }
3468
3469 fn evict_banned_member(&self, room_id: &str, fingerprint: &str) {
3474 if let Some(room) = self.active_rooms.lock().unwrap().get_mut(room_id) {
3475 room.members.remove(fingerprint);
3476 }
3477 let _ = self.app_event_tx.send(AppEvent::MemberLeft {
3478 room_id: room_id.to_string(),
3479 fingerprint: fingerprint.to_string(),
3480 });
3481 }
3482
3483 pub fn display_name(&self) -> Option<String> {
3484 repo::get_display_name(&self.db).unwrap_or(None)
3485 }
3486
3487 pub fn set_display_name(&self, name: Option<&str>) -> Result<()> {
3488 repo::set_display_name(&self.db, name)
3489 }
3490
3491 pub async fn set_username(&self, name: Option<&str>) -> Result<()> {
3497 repo::set_display_name(&self.db, name)?;
3498 let msg = RoomMessage::ProfileUpdate {
3499 sender_fingerprint: self.identity.fingerprint().to_string(),
3500 username: name.map(|s| s.to_string()),
3501 updated_at: now_unix_ms(),
3502 };
3503 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3504 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3505 let rooms: Vec<String> = self.active_rooms.lock().unwrap().keys().cloned().collect();
3506 for room_id in rooms {
3507 self.network
3508 .publish_room_message(room_id, bytes.clone())
3509 .await;
3510 }
3511 Ok(())
3512 }
3513
3514 pub fn lookup_username(&self, fingerprint: &str) -> Option<String> {
3519 repo::get_peer_username(&self.db, fingerprint).unwrap_or(None)
3520 }
3521
3522 pub fn lookup_member_display_name(&self, fingerprint: &str) -> Option<String> {
3526 self.lookup_username(fingerprint)
3527 }
3528
3529 pub fn is_room_muted(&self, room_id: &str) -> bool {
3530 repo::is_room_muted(&self.db, room_id).unwrap_or(false)
3531 }
3532
3533 pub fn list_room_bans(&self, room_id: &str) -> Vec<String> {
3538 repo::list_room_bans(&self.db, room_id).unwrap_or_default()
3539 }
3540
3541 pub fn list_verified_peers(&self) -> Vec<String> {
3547 repo::list_verified_peers(&self.db).unwrap_or_default()
3548 }
3549
3550 pub fn list_blocked_peers(&self) -> Vec<String> {
3551 repo::list_blocked_peers(&self.db).unwrap_or_default()
3552 }
3553
3554 pub fn unblock_peer(&self, fingerprint: &str) -> Result<()> {
3558 repo::unblock_peer(&self.db, fingerprint)
3559 }
3560
3561 pub fn block_peer(&self, fingerprint: &str) -> Result<()> {
3565 repo::block_peer(&self.db, fingerprint, now_unix())
3566 }
3567
3568 pub fn is_room_read_only(&self, room_id: &str) -> bool {
3574 self.active_rooms
3575 .lock()
3576 .unwrap()
3577 .get(room_id)
3578 .map(|r| r.read_only)
3579 .unwrap_or(false)
3580 }
3581
3582 pub fn set_room_muted(&self, room_id: &str, muted: bool) -> Result<()> {
3583 repo::set_room_muted(&self.db, room_id, muted)
3584 }
3585
3586 pub async fn broadcast_typing(&self, room_id: &str) {
3589 if !self.active_rooms.lock().unwrap().contains_key(room_id) {
3590 return;
3591 }
3592 let msg = RoomMessage::Typing {
3593 sender_fingerprint: self.identity.fingerprint().to_string(),
3594 };
3595 if let Ok(bytes) = encode_wire(&msg) {
3596 self.network
3597 .publish_room_message(room_id.to_string(), bytes)
3598 .await;
3599 }
3600 }
3601
3602 pub fn typers_in_room(&self, room_id: &str) -> Vec<String> {
3605 let now = now_unix();
3606 let mut rooms = self.active_rooms.lock().unwrap();
3607 let room = match rooms.get_mut(room_id) {
3608 Some(r) => r,
3609 None => return Vec::new(),
3610 };
3611 room.typers.retain(|_, exp| *exp > now);
3612 let mut v: Vec<String> = room.typers.keys().cloned().collect();
3613 v.sort();
3614 v
3615 }
3616
3617 pub async fn rotate_room(&self, room_id: &str, new_passphrase: &str) -> Result<()> {
3627 if new_passphrase.is_empty() {
3628 return Err(HuddleError::Other("new passphrase is empty".into()));
3629 }
3630 let new_salt = passphrase::random_salt();
3631 let new_key = passphrase::derive_key(new_passphrase, &new_salt)?;
3632
3633 let info = {
3634 let mut rooms = self.active_rooms.lock().unwrap();
3635 let room = rooms
3636 .get_mut(room_id)
3637 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3638 if !room.info.encrypted {
3639 return Err(HuddleError::Other(
3640 "rotation only applies to encrypted rooms".into(),
3641 ));
3642 }
3643 let new_crypto = RoomCrypto::new_for_room(
3645 self.db.clone(),
3646 room_id.to_string(),
3647 self.identity.fingerprint().to_string(),
3648 self.session_persist_key,
3649 )?;
3650 room.crypto = Some(new_crypto);
3651 room.passphrase_key = Some(new_key);
3652 room.info.passphrase_salt = Some(new_salt.to_vec());
3653 room.info.clone()
3654 };
3655
3656 let rot = RoomMessage::RotateRoomKey {
3662 rotator_fingerprint: self.identity.fingerprint().to_string(),
3663 new_salt: new_salt.to_vec(),
3664 };
3665 if let Ok(env) = crate::crypto::sign_message(&self.identity, &rot) {
3669 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
3670 self.network
3671 .publish_room_message(room_id.to_string(), bytes)
3672 .await;
3673 }
3674 }
3675 if let Err(e) = self.broadcast_member_announce(room_id).await {
3677 warn!(%e, "rotate: broadcast announce failed");
3678 }
3679
3680 repo::insert_room(&self.db, &info)?;
3682 Ok(())
3683 }
3684
3685 pub async fn accept_rotation(
3689 &self,
3690 room_id: &str,
3691 new_salt: &[u8],
3692 new_passphrase: &str,
3693 ) -> Result<()> {
3694 let new_key = passphrase::derive_key(new_passphrase, new_salt)?;
3695 let info = {
3696 let mut rooms = self.active_rooms.lock().unwrap();
3697 let room = rooms
3698 .get_mut(room_id)
3699 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3700 room.passphrase_key = Some(new_key);
3701 room.info.passphrase_salt = Some(new_salt.to_vec());
3702 room.info.clone()
3703 };
3704 let req = RoomMessage::SessionKeyRequest {
3708 requester_fingerprint: self.identity.fingerprint().to_string(),
3709 };
3710 if let Ok(bytes) = encode_wire(&req) {
3711 self.network
3712 .publish_room_message(room_id.to_string(), bytes)
3713 .await;
3714 }
3715 repo::insert_room(&self.db, &info)?;
3716 Ok(())
3717 }
3718
3719 #[allow(clippy::too_many_arguments)]
3724 fn handle_file_offer(
3725 &self,
3726 room_id: &str,
3727 sender_fingerprint: String,
3728 file_id: String,
3729 name: String,
3730 size_bytes: u64,
3731 mime: Option<String>,
3732 _chunk_count: u32,
3733 encrypted_meta: Option<EncryptedFileMeta>,
3734 ) {
3735 let encrypted = encrypted_meta.is_some();
3736 let attachment = StoredAttachment {
3737 id: 0,
3738 room_id: room_id.to_string(),
3739 message_id: None,
3740 sender_fingerprint: sender_fingerprint.clone(),
3741 file_id: file_id.clone(),
3742 name: name.clone(),
3743 mime,
3744 size_bytes: size_bytes as i64,
3745 status: AttachmentStatus::Offered,
3746 cache_path: None,
3747 saved_path: None,
3748 error: None,
3749 encrypted,
3750 wrapped_key: encrypted_meta.as_ref().map(|m| m.wrapped_key_b64.clone()),
3751 nonce: encrypted_meta.as_ref().map(|m| m.nonce_b64.clone()),
3752 megolm_session_id: encrypted_meta.as_ref().map(|m| m.megolm_session_id.clone()),
3753 content_hash: encrypted_meta.as_ref().map(|m| m.content_hash.clone()),
3754 created_at: now_unix(),
3755 };
3756 if let Err(e) = repo::upsert_attachment(&self.db, &attachment) {
3757 warn!(%e, "upsert attachment");
3758 return;
3759 }
3760 self.file_manager.set_expected_size(&file_id, size_bytes);
3763 let _ = self.app_event_tx.send(AppEvent::FileOffered {
3764 room_id: room_id.to_string(),
3765 file_id,
3766 name,
3767 size_bytes,
3768 sender_fingerprint,
3769 });
3770 }
3771
3772 fn handle_file_chunk(
3773 &self,
3774 room_id: &str,
3775 _sender_fingerprint: String,
3776 file_id: String,
3777 chunk_index: u32,
3778 total_chunks: u32,
3779 data_b64: String,
3780 ) {
3781 let data = match B64.decode(&data_b64) {
3782 Ok(d) => d,
3783 Err(e) => {
3784 warn!(%e, "bad chunk base64");
3785 return;
3786 }
3787 };
3788 let expected_size = match repo::get_attachment(&self.db, room_id, &file_id) {
3792 Ok(Some(a)) => {
3793 if matches!(
3794 a.status,
3795 AttachmentStatus::Cancelled | AttachmentStatus::Failed
3796 ) {
3797 return;
3798 }
3799 a.size_bytes as u64
3800 }
3801 Ok(None) => crate::files::MAX_FILE_SIZE,
3802 Err(e) => {
3803 warn!(%e, "get attachment for chunk");
3804 crate::files::MAX_FILE_SIZE
3805 }
3806 };
3807
3808 let result = self.file_manager.accept_chunk(
3809 &file_id,
3810 chunk_index,
3811 total_chunks,
3812 data,
3813 expected_size,
3814 );
3815 match result {
3816 Ok(None) => {
3817 let _ = repo::update_attachment_status(
3819 &self.db,
3820 room_id,
3821 &file_id,
3822 AttachmentStatus::Downloading,
3823 None,
3824 );
3825 let bytes_so_far = self
3828 .file_manager
3829 .progress(&file_id)
3830 .map(|(b, _)| b)
3831 .unwrap_or(0);
3832 let _ = self.app_event_tx.send(AppEvent::FileProgress {
3833 file_id: file_id.clone(),
3834 bytes_received: bytes_so_far,
3835 total_bytes: expected_size,
3836 });
3837 }
3838 Ok(Some(completed)) => {
3839 let _ = repo::update_attachment_paths(
3840 &self.db,
3841 room_id,
3842 &file_id,
3843 Some(&completed.cache_path.to_string_lossy()),
3844 None,
3845 );
3846 let _ = repo::update_attachment_status(
3847 &self.db,
3848 room_id,
3849 &file_id,
3850 AttachmentStatus::Ready,
3851 None,
3852 );
3853 let _ = self.app_event_tx.send(AppEvent::FileReady {
3854 file_id: file_id.clone(),
3855 });
3856 }
3857 Err(e) => {
3858 let msg = e.to_string();
3859 warn!(%msg, "chunk processing failed");
3860 let _ = repo::update_attachment_status(
3861 &self.db,
3862 room_id,
3863 &file_id,
3864 AttachmentStatus::Failed,
3865 Some(&msg),
3866 );
3867 let _ = self.app_event_tx.send(AppEvent::FileFailed {
3868 file_id: file_id.clone(),
3869 reason: msg,
3870 });
3871 }
3872 }
3873 }
3874
3875 fn maybe_emit_mention(&self, room_id: &str, body: &str) {
3878 let full = self.identity.fingerprint().to_lowercase();
3879 let short: &str = full.split('-').next().unwrap_or(&full);
3881 let lower = body.to_lowercase();
3882 let hit = lower.contains(full.as_str())
3886 || lower
3887 .split(|c: char| !c.is_ascii_hexdigit())
3888 .any(|tok| tok == short);
3889 if hit {
3890 let _ = self.app_event_tx.send(AppEvent::MentionReceived {
3891 room_id: room_id.to_string(),
3892 body: body.to_string(),
3893 });
3894 }
3895 }
3896
3897 fn decrypt_attachment(
3898 &self,
3899 room_id: &str,
3900 sender_fingerprint: &str,
3901 ciphertext: &[u8],
3902 meta: &EncryptedFileMeta,
3903 ) -> Result<Vec<u8>> {
3904 let mut rooms = self.active_rooms.lock().unwrap();
3905 let room = rooms
3906 .get_mut(room_id)
3907 .ok_or_else(|| HuddleError::Other("not in room".into()))?;
3908 let crypto = room
3909 .crypto
3910 .as_mut()
3911 .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
3912 file_encryption::decrypt_file(ciphertext, meta, crypto, sender_fingerprint)
3913 }
3914
3915 pub async fn go_dark(&self, master_passphrase: &str) -> Result<()> {
3927 let no_master = self.session_persist_key == [0u8; 32];
3928 if !no_master {
3929 let salt = storage::keychain::load_or_create_salt()?;
3930 let candidate_master =
3931 storage::keychain::derive_master_key(master_passphrase, &salt)?;
3932 let candidate_subkey =
3933 storage::keychain::derive_subkey(&candidate_master, b"megolm-persist");
3934 if !ct_eq_32(&candidate_subkey, &self.session_persist_key) {
3935 return Err(HuddleError::Other(
3936 "incorrect master passphrase".into(),
3937 ));
3938 }
3939 }
3940
3941 let room_ids: Vec<String> = self
3942 .active_rooms
3943 .lock()
3944 .unwrap()
3945 .keys()
3946 .cloned()
3947 .collect();
3948 let _ = tokio::time::timeout(Duration::from_secs(2), async {
3949 for room_id in &room_ids {
3950 if let Err(e) = self.leave_room(room_id).await {
3951 warn!(%room_id, %e, "go_dark: leave_room failed");
3952 }
3953 }
3954 })
3955 .await;
3956
3957 self.network.shutdown().await;
3958 tokio::time::sleep(Duration::from_millis(300)).await;
3959
3960 let data_dir = config::data_dir();
3961 let candidates = [
3962 "huddle.db",
3963 "huddle.db-shm",
3964 "huddle.db-wal",
3965 "keychain.salt",
3966 "huddle.log",
3967 "config.toml",
3968 ];
3969 for name in &candidates {
3970 let path = data_dir.join(name);
3971 wipe_file(&path);
3972 }
3973 if let Ok(read) = std::fs::read_dir(&data_dir) {
3974 for entry in read.flatten() {
3975 if let Some(name) = entry.file_name().to_str() {
3976 if name.starts_with("huddle.log.") {
3977 wipe_file(&entry.path());
3978 }
3979 }
3980 }
3981 }
3982 let files_dir = data_dir.join("files");
3986 if let Ok(read) = std::fs::read_dir(&files_dir) {
3987 for entry in read.flatten() {
3988 let path = entry.path();
3989 if path.is_file() {
3990 wipe_file(&path);
3991 } else if path.is_dir() {
3992 if let Ok(inner) = std::fs::read_dir(&path) {
3995 for inner_entry in inner.flatten() {
3996 if inner_entry.path().is_file() {
3997 wipe_file(&inner_entry.path());
3998 }
3999 }
4000 }
4001 let _ = std::fs::remove_dir(&path);
4002 }
4003 }
4004 }
4005 let _ = std::fs::remove_dir(&files_dir);
4006 let _ = std::fs::remove_dir(&data_dir);
4007
4008 let _ = self.app_event_tx.send(AppEvent::WentDark);
4009 Ok(())
4010 }
4011}
4012
4013pub fn normalize_to_fingerprint(input: &str) -> Option<String> {
4020 let s = input
4021 .trim()
4022 .trim_start_matches("HD-")
4023 .trim_start_matches("hd-")
4024 .to_string();
4025 let hex_only: String = s.chars().filter(|c| *c != '-').collect();
4026 if hex_only.len() != 24 || !hex_only.chars().all(|c| c.is_ascii_hexdigit()) {
4027 return None;
4028 }
4029 let lower = hex_only.to_ascii_lowercase();
4030 let chunks: Vec<String> = lower
4031 .as_bytes()
4032 .chunks(4)
4033 .map(|c| std::str::from_utf8(c).unwrap().to_string())
4034 .collect();
4035 Some(chunks.join("-"))
4036}
4037
4038fn address_preference(addr: &str) -> u8 {
4044 if addr.contains("/p2p-circuit") {
4045 return 9; }
4047 if let Some(rest) = addr.strip_prefix("/ip4/") {
4048 if let Some(ip_str) = rest.split('/').next() {
4049 if let Ok(ip) = ip_str.parse::<std::net::Ipv4Addr>() {
4050 if ip.is_loopback() {
4051 return 1; }
4053 if is_rfc1918(&ip) || ip.is_link_local() {
4054 return 0; }
4056 return 3; }
4058 }
4059 return 3;
4060 }
4061 if addr.starts_with("/ip6/") {
4062 return 4;
4063 }
4064 if addr.starts_with("/dns4/") || addr.starts_with("/dns6/") || addr.starts_with("/dnsaddr/") {
4065 return 5;
4066 }
4067 7
4068}
4069
4070fn is_rfc1918(ip: &std::net::Ipv4Addr) -> bool {
4074 let octets = ip.octets();
4075 octets[0] == 10
4076 || (octets[0] == 172 && (16..=31).contains(&octets[1]))
4077 || (octets[0] == 192 && octets[1] == 168)
4078}
4079
4080fn short_fp_for_msg(fingerprint: &str) -> String {
4084 let head: String = fingerprint
4085 .chars()
4086 .filter(|c| *c != '-')
4087 .take(4)
4088 .collect::<String>()
4089 .to_ascii_uppercase();
4090 format!("HD-{}…", head)
4091}
4092
4093fn ct_eq_32(a: &[u8; 32], b: &[u8; 32]) -> bool {
4097 let mut diff = 0u8;
4098 for i in 0..32 {
4099 diff |= a[i] ^ b[i];
4100 }
4101 diff == 0
4102}
4103
4104fn wipe_file(path: &Path) {
4108 use std::io::Write;
4109 if let Ok(meta) = std::fs::metadata(path) {
4110 if let Ok(mut f) = std::fs::OpenOptions::new().write(true).open(path) {
4111 let zeros = vec![0u8; meta.len() as usize];
4112 let _ = f.write_all(&zeros);
4113 let _ = f.sync_all();
4114 }
4115 }
4116 if let Err(e) = std::fs::remove_file(path) {
4117 if e.kind() != std::io::ErrorKind::NotFound {
4118 warn!(?path, %e, "wipe_file: remove failed");
4119 }
4120 }
4121}
4122
4123fn open_with_system(path: &str) -> Result<()> {
4125 #[cfg(target_os = "macos")]
4126 let cmd = "open";
4127 #[cfg(target_os = "linux")]
4128 let cmd = "xdg-open";
4129 #[cfg(target_os = "windows")]
4130 let cmd = "cmd";
4131 #[cfg(target_os = "windows")]
4132 let args = vec!["/C", "start", "", path];
4133 #[cfg(not(target_os = "windows"))]
4134 let args = vec![path];
4135
4136 std::process::Command::new(cmd)
4137 .args(args)
4138 .spawn()
4139 .map_err(|e| HuddleError::Other(format!("spawn opener: {e}")))?;
4140 Ok(())
4141}
4142
4143static ROOM_SALT_CACHE: std::sync::LazyLock<Mutex<HashMap<String, Vec<u8>>>> =
4146 std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
4147
4148pub fn salt_len() -> usize {
4153 SALT_LEN
4154}
4155
4156fn now_unix() -> i64 {
4157 SystemTime::now()
4158 .duration_since(UNIX_EPOCH)
4159 .unwrap()
4160 .as_secs() as i64
4161}
4162
4163fn now_unix_ms() -> i64 {
4164 SystemTime::now()
4165 .duration_since(UNIX_EPOCH)
4166 .unwrap()
4167 .as_millis() as i64
4168}
4169
4170fn generate_join_passphrase() -> String {
4176 use rand::RngCore;
4177 let mut bytes = [0u8; 16];
4178 rand::thread_rng().fill_bytes(&mut bytes);
4179 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
4182}
4183
4184fn generate_alphanumeric_code(len: usize) -> String {
4189 use rand::Rng;
4190 const ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ23456789";
4191 let mut rng = rand::thread_rng();
4192 let mut out = String::with_capacity(len + 1);
4193 for i in 0..len {
4194 if i == 4 && len == 8 {
4195 out.push('-'); }
4197 let idx = rng.gen_range(0..ALPHABET.len());
4198 out.push(ALPHABET[idx] as char);
4199 }
4200 out
4201}
4202
4203#[cfg(test)]
4204mod parser_tests {
4205 use super::parse_dial_address;
4206
4207 #[test]
4208 fn parses_ipv4_port() {
4209 let m = parse_dial_address("10.3.72.53:9027").unwrap();
4210 assert_eq!(m.to_string(), "/ip4/10.3.72.53/tcp/9027");
4211 }
4212
4213 #[test]
4214 fn parses_bracketed_ipv6() {
4215 let m = parse_dial_address("[::1]:9027").unwrap();
4216 assert_eq!(m.to_string(), "/ip6/::1/tcp/9027");
4217 }
4218
4219 #[test]
4220 fn rejects_unbracketed_ipv6() {
4221 let err = parse_dial_address("fe80::1:9027").unwrap_err();
4222 assert!(err.to_string().contains("brackets"));
4223 }
4224
4225 #[test]
4226 fn passes_through_raw_multiaddr() {
4227 let m = parse_dial_address("/ip4/1.2.3.4/tcp/9000").unwrap();
4228 assert_eq!(m.to_string(), "/ip4/1.2.3.4/tcp/9000");
4229 }
4230
4231 #[test]
4232 fn empty_address_is_error() {
4233 assert!(parse_dial_address(" ").is_err());
4234 }
4235
4236 #[test]
4237 fn rejects_bad_port() {
4238 assert!(parse_dial_address("1.2.3.4:notaport").is_err());
4239 }
4240}
4241
4242#[cfg(test)]
4243mod transport_preference_tests {
4244 use super::{address_preference, normalize_to_fingerprint};
4245
4246 #[test]
4247 fn lan_beats_public_beats_circuit() {
4248 let lan = address_preference("/ip4/192.168.1.5/tcp/9027");
4249 let pub_v4 = address_preference("/ip4/8.8.8.8/tcp/9027");
4250 let circuit = address_preference(
4251 "/ip4/1.2.3.4/tcp/4001/p2p/12D3Koo/p2p-circuit/p2p/12D3KooXYZ",
4252 );
4253 assert!(lan < pub_v4, "LAN {} should beat public {}", lan, pub_v4);
4254 assert!(
4255 pub_v4 < circuit,
4256 "public {} should beat circuit {}",
4257 pub_v4,
4258 circuit
4259 );
4260 }
4261
4262 #[test]
4263 fn all_rfc1918_ranges_are_lan() {
4264 assert_eq!(
4265 address_preference("/ip4/10.0.0.1/tcp/9027"),
4266 address_preference("/ip4/192.168.0.1/tcp/9027"),
4267 );
4268 assert_eq!(
4269 address_preference("/ip4/172.16.0.1/tcp/9027"),
4270 address_preference("/ip4/192.168.0.1/tcp/9027"),
4271 );
4272 assert!(
4274 address_preference("/ip4/172.32.0.1/tcp/9027")
4275 > address_preference("/ip4/172.16.0.1/tcp/9027")
4276 );
4277 }
4278
4279 #[test]
4280 fn normalize_id_accepts_branded_and_raw() {
4281 let canon = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4282 assert_eq!(
4283 normalize_to_fingerprint("HD-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF").as_deref(),
4284 Some(canon)
4285 );
4286 assert_eq!(
4287 normalize_to_fingerprint("aaaabbbbccccddddeeeeffff").as_deref(),
4288 Some(canon)
4289 );
4290 assert_eq!(normalize_to_fingerprint(canon).as_deref(), Some(canon));
4291 assert!(normalize_to_fingerprint("alice").is_none());
4292 assert!(normalize_to_fingerprint("HD-ZZZZ").is_none());
4293 }
4294}
4295
4296#[cfg(test)]
4297mod canonical_dm_room_id_tests {
4298 use super::canonical_dm_room_id;
4299
4300 #[test]
4301 fn dm_room_id_is_commutative() {
4302 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4305 let b = "1111-2222-3333-4444-5555-6666";
4306 assert_eq!(canonical_dm_room_id(a, b), canonical_dm_room_id(b, a));
4307 }
4308
4309 #[test]
4310 fn dm_room_id_differs_per_pair() {
4311 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4312 let b = "1111-2222-3333-4444-5555-6666";
4313 let c = "9999-8888-7777-6666-5555-4444";
4314 assert_ne!(canonical_dm_room_id(a, b), canonical_dm_room_id(a, c));
4315 assert_ne!(canonical_dm_room_id(a, b), canonical_dm_room_id(b, c));
4316 }
4317
4318 #[test]
4319 fn dm_room_id_is_stable() {
4320 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4324 let b = "1111-2222-3333-4444-5555-6666";
4325 let id1 = canonical_dm_room_id(a, b);
4326 let id2 = canonical_dm_room_id(a, b);
4327 assert_eq!(id1, id2);
4328 assert_eq!(id1.len(), 32);
4332 }
4333}