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 pub fingerprint: Option<String>,
44}
45
46pub fn canonical_dm_room_id(a: &str, b: &str) -> String {
57 use sha2::{Digest, Sha256};
58 let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
59 let mut hasher = Sha256::new();
60 hasher.update(b"huddle-dm-v1\0");
61 hasher.update(lo.as_bytes());
62 hasher.update(b"\0");
63 hasher.update(hi.as_bytes());
64 hex::encode(&hasher.finalize()[..16])
65}
66
67pub fn parse_dial_address(input: &str) -> Result<Multiaddr> {
70 let trimmed = input.trim();
71 if trimmed.is_empty() {
72 return Err(HuddleError::Other("address is empty".into()));
73 }
74 if trimmed.starts_with('/') {
75 return trimmed
76 .parse::<Multiaddr>()
77 .map_err(|e| HuddleError::Other(format!("invalid multiaddr: {e}")));
78 }
79 if let Some(rest) = trimmed.strip_prefix('[') {
80 let (host, port) = rest
81 .split_once("]:")
82 .ok_or_else(|| HuddleError::Other(format!("expected [ipv6]:port, got {trimmed}")))?;
83 let port: u16 = port
84 .parse()
85 .map_err(|_| HuddleError::Other(format!("invalid port: {port}")))?;
86 return format!("/ip6/{}/tcp/{}", host, port)
87 .parse::<Multiaddr>()
88 .map_err(|e| HuddleError::Other(format!("invalid ipv6 address: {e}")));
89 }
90 let (host, port) = trimmed
91 .rsplit_once(':')
92 .ok_or_else(|| HuddleError::Other(format!("expected ip:port, got {trimmed}")))?;
93 if host.contains(':') {
94 return Err(HuddleError::Other(format!(
95 "ambiguous IPv6 address — wrap host in brackets: [{host}]:{port}"
96 )));
97 }
98 let port: u16 = port
99 .parse()
100 .map_err(|_| HuddleError::Other(format!("invalid port: {port}")))?;
101 format!("/ip4/{}/tcp/{}", host, port)
102 .parse::<Multiaddr>()
103 .map_err(|e| HuddleError::Other(format!("invalid address: {e}")))
104}
105
106struct ActiveRoom {
108 info: StoredRoom,
109 crypto: Option<RoomCrypto>,
110 passphrase_key: Option<[u8; KEY_LEN]>,
113 members: HashSet<String>,
115 typers: HashMap<String, i64>,
118 read_only: bool,
125 issued_codes: Vec<(String, i64)>,
129}
130
131const TYPING_TTL_SECS: i64 = 3;
132
133const DISCOVERED_TTL_SECS: i64 = 45;
136const ANNOUNCE_INTERVAL_SECS: u64 = 15;
137
138struct SasFlow {
142 room_id: String,
143 partner_fingerprint: String,
144 our_secret: x25519_dalek::StaticSecret,
145 sas_code: Option<crate::crypto::sas::SasCode>,
147 our_confirmed: bool,
148 their_confirmed: bool,
149}
150
151#[derive(Clone)]
152pub struct AppHandle {
153 identity: Arc<Identity>,
154 network: NetworkHandle,
155 mode: NetworkMode,
156 active_rooms: Arc<Mutex<HashMap<String, ActiveRoom>>>,
157 discovered_rooms: Arc<Mutex<HashMap<String, DiscoveredRoom>>>,
158 restorable_rooms: Arc<Mutex<HashMap<String, StoredRoom>>>,
162 connected_dial_addrs: Arc<Mutex<HashMap<String, PeerId>>>,
165 file_manager: Arc<FileManager>,
167 db: Db,
168 session_persist_key: [u8; 32],
172 sas_flows: Arc<Mutex<HashMap<String, SasFlow>>>,
175 pending_code_secrets:
182 Arc<Mutex<HashMap<(String, String), x25519_dalek::StaticSecret>>>,
183 pending_invite_dials: Arc<Mutex<HashMap<String, String>>>,
196 nat_reachable_addrs: Arc<Mutex<HashSet<String>>>,
202 relay_circuit_addrs: Arc<Mutex<HashSet<String>>>,
208 host_addr_dial_attempts: Arc<Mutex<HashMap<String, i64>>>,
213 last_profile_broadcast_at_ms: Arc<Mutex<HashMap<String, i64>>>,
220 pending_auto_dm_addrs: Arc<Mutex<HashSet<String>>>,
227 app_event_tx: broadcast::Sender<AppEvent>,
228}
229
230const HOST_ADDR_DIAL_BACKOFF_SECS: i64 = 300;
233
234const PROFILE_REBROADCAST_FLOOR_MS: i64 = 60_000;
238
239impl AppHandle {
240 pub async fn start() -> Result<Self> {
241 Self::start_with_options(NetworkMode::Mdns, 0, None, Vec::new()).await
242 }
243
244 pub async fn start_with_options(
245 mode: NetworkMode,
246 port: u16,
247 master_key: Option<&[u8; 32]>,
248 relays: Vec<Multiaddr>,
249 ) -> Result<Self> {
250 config::ensure_data_dir()?;
251 let session_persist_key = match master_key {
256 Some(mk) => storage::keychain::derive_subkey(mk, b"megolm-persist"),
257 None => [0u8; 32],
258 };
259 let db = storage::open_db(&config::db_path(), master_key)?;
260 Self::start_with_db_and_options(db, mode, port, session_persist_key, relays).await
261 }
262
263 pub async fn start_with_db(db: Db) -> Result<Self> {
264 Self::start_with_db_and_options(db, NetworkMode::Mdns, 0, [0u8; 32], Vec::new()).await
265 }
266
267 pub async fn start_with_db_and_options(
268 db: Db,
269 mode: NetworkMode,
270 port: u16,
271 session_persist_key: [u8; 32],
272 relays: Vec<Multiaddr>,
273 ) -> Result<Self> {
274 let identity = Self::load_or_create_identity(&db)?;
275 let identity = Arc::new(identity);
276 info!(fingerprint = %identity.fingerprint(), peer_id = %identity.peer_id(), mode = %mode.as_str(), port, relay_count = relays.len(), "identity loaded");
277
278 let (net_event_tx, net_event_rx) = tokio::sync::mpsc::channel::<NetworkEvent>(256);
279 let (app_event_tx, _) = broadcast::channel::<AppEvent>(256);
280 let network =
281 network::start_network_with(&identity, net_event_tx, mode, port, relays)?;
282
283 let active_rooms = Arc::new(Mutex::new(HashMap::new()));
284 let discovered_rooms = Arc::new(Mutex::new(HashMap::new()));
285 let restorable_rooms = Arc::new(Mutex::new(HashMap::new()));
286 let connected_dial_addrs = Arc::new(Mutex::new(HashMap::new()));
287 let file_manager = Arc::new(FileManager::new(&config::data_dir())?);
288
289 let handle = Self {
290 identity,
291 network,
292 mode,
293 active_rooms,
294 discovered_rooms,
295 restorable_rooms,
296 connected_dial_addrs,
297 file_manager,
298 db,
299 session_persist_key,
300 sas_flows: Arc::new(Mutex::new(HashMap::new())),
301 pending_code_secrets: Arc::new(Mutex::new(HashMap::new())),
302 pending_invite_dials: Arc::new(Mutex::new(HashMap::new())),
303 nat_reachable_addrs: Arc::new(Mutex::new(HashSet::new())),
304 relay_circuit_addrs: Arc::new(Mutex::new(HashSet::new())),
305 host_addr_dial_attempts: Arc::new(Mutex::new(HashMap::new())),
306 last_profile_broadcast_at_ms: Arc::new(Mutex::new(HashMap::new())),
307 pending_auto_dm_addrs: Arc::new(Mutex::new(HashSet::new())),
308 app_event_tx,
309 };
310
311 handle.spawn_event_processor(net_event_rx);
312 handle.spawn_announcement_ticker();
313 handle.spawn_discovered_room_pruner();
314 handle.spawn_known_peer_reconnector();
315 handle.restore_rooms_from_db().await;
316 if let Err(e) = repo::cleanup_expired_pending_friend_requests(&handle.db, now_unix()) {
320 warn!(%e, "failed to sweep expired pending friend requests");
321 }
322
323 Ok(handle)
324 }
325
326 pub fn mode(&self) -> NetworkMode {
327 self.mode
328 }
329
330 pub fn subscribe(&self) -> broadcast::Receiver<AppEvent> {
331 self.app_event_tx.subscribe()
332 }
333
334 pub fn fingerprint(&self) -> &str {
335 self.identity.fingerprint()
336 }
337
338 pub fn peer_id(&self) -> PeerId {
339 self.identity.peer_id()
340 }
341
342 pub fn discovered_rooms(&self) -> Vec<DiscoveredRoom> {
343 let now = now_unix();
344 let our_fp = self.identity.fingerprint().to_string();
345 let mut by_id: HashMap<String, DiscoveredRoom> = self
346 .discovered_rooms
347 .lock()
348 .unwrap()
349 .clone();
350
351 for room in self.active_rooms.lock().unwrap().values() {
355 let entry = DiscoveredRoom {
356 room_id: room.info.id.clone(),
357 name: room.info.name.clone(),
358 encrypted: room.info.encrypted,
359 member_count: room.members.len() as u32,
360 creator_fingerprint: room.info.creator_fingerprint.clone(),
361 last_seen: now,
362 restorable: false,
363 host_addrs: Vec::new(),
364 kind: room.info.kind,
365 };
366 by_id
367 .entry(room.info.id.clone())
368 .and_modify(|d| {
369 d.last_seen = now;
370 if entry.member_count > d.member_count {
371 d.member_count = entry.member_count;
372 }
373 d.restorable = false;
374 d.kind = entry.kind;
375 })
376 .or_insert(entry);
377 }
378
379 for (id, stored) in self.restorable_rooms.lock().unwrap().iter() {
383 if by_id.contains_key(id) {
384 continue;
385 }
386 by_id.insert(
387 id.clone(),
388 DiscoveredRoom {
389 room_id: id.clone(),
390 name: stored.name.clone(),
391 encrypted: stored.encrypted,
392 member_count: 0,
393 creator_fingerprint: stored.creator_fingerprint.clone(),
394 last_seen: stored.last_active.unwrap_or(stored.created_at),
395 restorable: true,
396 host_addrs: Vec::new(),
397 kind: stored.kind,
398 },
399 );
400 }
401
402 by_id.retain(|room_id, d| {
410 if d.kind != RoomKind::Direct {
411 return true;
412 }
413 if self
416 .active_rooms
417 .lock()
418 .unwrap()
419 .contains_key(room_id)
420 {
421 return true;
422 }
423 canonical_dm_room_id(&our_fp, &d.creator_fingerprint) == *room_id
426 });
427
428 let mut v: Vec<DiscoveredRoom> = by_id.into_values().collect();
429 v.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
430 v
431 }
432
433 pub fn dm_partner_fingerprint(&self, room_id: &str) -> Option<String> {
438 let our_fp = self.identity.fingerprint().to_string();
439 let rooms = self.active_rooms.lock().unwrap();
440 let room = rooms.get(room_id)?;
441 if room.info.kind != RoomKind::Direct {
442 return None;
443 }
444 room.members
445 .iter()
446 .find(|m| **m != our_fp)
447 .cloned()
448 }
449
450 pub fn active_room_ids(&self) -> Vec<String> {
451 self.active_rooms.lock().unwrap().keys().cloned().collect()
452 }
453
454 pub fn active_room_info(&self, room_id: &str) -> Option<StoredRoom> {
455 self.active_rooms
456 .lock()
457 .unwrap()
458 .get(room_id)
459 .map(|r| r.info.clone())
460 }
461
462 pub fn room_members(&self, room_id: &str) -> Vec<String> {
463 self.active_rooms
464 .lock()
465 .unwrap()
466 .get(room_id)
467 .map(|r| {
468 let mut m: Vec<String> = r.members.iter().cloned().collect();
469 m.sort();
470 m
471 })
472 .unwrap_or_default()
473 }
474
475 pub fn room_messages(&self, room_id: &str, limit: i64) -> Result<Vec<repo::StoredRoomMessage>> {
476 repo::get_room_messages(&self.db, room_id, limit)
477 }
478
479 pub fn search_room_messages(
480 &self,
481 room_id: &str,
482 query: &str,
483 limit: i64,
484 ) -> Result<Vec<repo::StoredRoomMessage>> {
485 repo::search_room_messages(&self.db, room_id, query, limit)
486 }
487
488 pub async fn start_room(
496 &self,
497 name: &str,
498 encrypted: bool,
499 passphrase: Option<&str>,
500 kind: RoomKind,
501 ) -> Result<String> {
502 if encrypted && passphrase.is_none() {
503 return Err(HuddleError::Other(
504 "encrypted room requires a passphrase".into(),
505 ));
506 }
507
508 let created_at = now_unix();
509 let creator_fp = self.identity.fingerprint().to_string();
510 let room_id = derive_room_id(&creator_fp, name, created_at);
511
512 let (passphrase_salt, passphrase_key) = if encrypted {
513 let salt = passphrase::random_salt();
514 let key = passphrase::derive_key(passphrase.unwrap(), &salt)?;
515 (Some(salt.to_vec()), Some(key))
516 } else {
517 (None, None)
518 };
519
520 let info = StoredRoom {
521 id: room_id.clone(),
522 name: name.to_string(),
523 creator_fingerprint: creator_fp.clone(),
524 encrypted,
525 passphrase_salt: passphrase_salt.clone(),
526 created_at,
527 last_active: Some(created_at),
528 kind,
529 };
530 repo::insert_room(&self.db, &info)?;
531
532 let crypto = if encrypted {
533 Some(RoomCrypto::new_for_room(
534 self.db.clone(),
535 room_id.clone(),
536 creator_fp.clone(),
537 self.session_persist_key,
538 )?)
539 } else {
540 None
541 };
542
543 let mut members = HashSet::new();
544 members.insert(creator_fp.clone());
545
546 repo::upsert_room_member(
550 &self.db,
551 &StoredRoomMember {
552 room_id: room_id.clone(),
553 peer_id: String::new(),
554 fingerprint: creator_fp.clone(),
555 last_seen: Some(created_at),
556 verified: true, ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
558 role: "owner".into(),
559 },
560 )?;
561
562 self.active_rooms.lock().unwrap().insert(
563 room_id.clone(),
564 ActiveRoom {
565 info: info.clone(),
566 crypto,
567 passphrase_key,
568 members,
569 typers: HashMap::new(),
570 read_only: false,
571 issued_codes: Vec::new(),
572 },
573 );
574
575 self.network.subscribe_room(room_id.clone()).await;
576 self.announce_room_now(&info, 1).await;
577
578 let app = self.clone();
581 let rid = room_id.clone();
582 tokio::spawn(async move {
583 tokio::time::sleep(Duration::from_millis(500)).await;
584 if let Err(e) = app.broadcast_member_announce(&rid).await {
585 warn!(%e, "broadcast member announce");
586 }
587 });
588
589 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
590 room_id: room_id.clone(),
591 });
592
593 Ok(room_id)
594 }
595
596 pub async fn start_direct(&self, partner_fingerprint: &str) -> Result<String> {
620 let our_fp = self.identity.fingerprint().to_string();
621 if partner_fingerprint == our_fp {
622 return Err(HuddleError::Other("cannot DM yourself".into()));
623 }
624 let room_id = canonical_dm_room_id(&our_fp, partner_fingerprint);
625
626 if self.active_rooms.lock().unwrap().contains_key(&room_id) {
631 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
632 room_id: room_id.clone(),
633 });
634 return Ok(room_id);
635 }
636 if repo::get_room(&self.db, &room_id)?.is_some() {
637 return self.bootstrap_direct_room(&room_id, partner_fingerprint).await;
639 }
640
641 let created_at = now_unix();
642 let name = format!("dm-{}", short_fp_for_msg(partner_fingerprint));
646
647 let dm_salt = hex::decode(&room_id).unwrap_or_else(|_| room_id.as_bytes().to_vec());
654 let info = StoredRoom {
655 id: room_id.clone(),
656 name,
657 creator_fingerprint: our_fp.clone(),
658 encrypted: true,
659 passphrase_salt: Some(dm_salt),
660 created_at,
661 last_active: Some(created_at),
662 kind: RoomKind::Direct,
663 };
664 repo::insert_room(&self.db, &info)?;
665
666 let mut members = HashSet::new();
667 members.insert(our_fp.clone());
668 repo::upsert_room_member(
669 &self.db,
670 &StoredRoomMember {
671 room_id: room_id.clone(),
672 peer_id: String::new(),
673 fingerprint: our_fp.clone(),
674 last_seen: Some(created_at),
675 verified: true,
676 ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
677 role: "member".into(),
678 },
679 )?;
680
681 let passphrase_key = self.try_derive_dm_key(&room_id, partner_fingerprint);
688
689 let crypto = Some(RoomCrypto::new_for_room(
694 self.db.clone(),
695 room_id.clone(),
696 our_fp.clone(),
697 self.session_persist_key,
698 )?);
699
700 self.active_rooms.lock().unwrap().insert(
701 room_id.clone(),
702 ActiveRoom {
703 info: info.clone(),
704 crypto,
705 passphrase_key,
706 members,
707 typers: HashMap::new(),
708 read_only: false,
709 issued_codes: Vec::new(),
710 },
711 );
712
713 self.network.subscribe_room(room_id.clone()).await;
714 self.announce_room_now(&info, 1).await;
715
716 let app = self.clone();
717 let rid = room_id.clone();
718 tokio::spawn(async move {
719 tokio::time::sleep(Duration::from_millis(500)).await;
720 if let Err(e) = app.broadcast_member_announce(&rid).await {
721 warn!(%e, "broadcast member announce for DM");
722 }
723 });
724
725 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
726 room_id: room_id.clone(),
727 });
728 Ok(room_id)
729 }
730
731 fn derive_dm_key_from_pubkey_b64(
736 &self,
737 room_id: &str,
738 pubkey_b64: &str,
739 ) -> Option<[u8; KEY_LEN]> {
740 let bytes = B64.decode(pubkey_b64).ok()?;
741 if bytes.len() != 32 {
742 return None;
743 }
744 let mut pubkey = [0u8; 32];
745 pubkey.copy_from_slice(&bytes);
746 let our_seed = self.identity.secret_bytes();
747 match crate::crypto::dm::derive_dm_key(&our_seed, &pubkey, room_id) {
748 Ok(k) => Some(k),
749 Err(e) => {
750 warn!(%e, "DM key derivation (from announce) failed");
751 None
752 }
753 }
754 }
755
756 fn try_derive_dm_key(
761 &self,
762 room_id: &str,
763 partner_fingerprint: &str,
764 ) -> Option<[u8; KEY_LEN]> {
765 let pubkey_b64 = repo::lookup_peer_ed25519_pubkey(&self.db, partner_fingerprint)
766 .ok()
767 .flatten()?;
768 let bytes = B64.decode(&pubkey_b64).ok()?;
769 if bytes.len() != 32 {
770 return None;
771 }
772 let mut pubkey = [0u8; 32];
773 pubkey.copy_from_slice(&bytes);
774 let our_seed = self.identity.secret_bytes();
775 match crate::crypto::dm::derive_dm_key(&our_seed, &pubkey, room_id) {
776 Ok(k) => Some(k),
777 Err(e) => {
778 warn!(%e, %partner_fingerprint, "DM key derivation failed");
779 None
780 }
781 }
782 }
783
784 async fn bootstrap_direct_room(
790 &self,
791 room_id: &str,
792 partner_fingerprint: &str,
793 ) -> Result<String> {
794 let our_fp = self.identity.fingerprint().to_string();
795 let info = repo::get_room(&self.db, room_id)?
796 .ok_or_else(|| HuddleError::Other(format!("DM room {room_id} not found on disk")))?;
797 let mut members = HashSet::new();
798 members.insert(our_fp.clone());
799 members.insert(partner_fingerprint.to_string());
800
801 if let Ok(stored_members) = repo::list_room_members(&self.db, room_id) {
803 for m in stored_members {
804 members.insert(m.fingerprint);
805 }
806 }
807
808 let (passphrase_key, crypto) = if info.encrypted {
816 let pk = self.try_derive_dm_key(room_id, partner_fingerprint);
817 let c = Some(RoomCrypto::load(
818 self.db.clone(),
819 room_id.to_string(),
820 our_fp.clone(),
821 self.session_persist_key,
822 )?
823 .unwrap_or_else(|| {
824 RoomCrypto::new_for_room(
825 self.db.clone(),
826 room_id.to_string(),
827 our_fp.clone(),
828 self.session_persist_key,
829 )
830 .expect("create RoomCrypto for DM re-bootstrap")
831 }));
832 (pk, c)
833 } else {
834 (None, None)
835 };
836
837 self.active_rooms.lock().unwrap().insert(
838 room_id.to_string(),
839 ActiveRoom {
840 info: info.clone(),
841 crypto,
842 passphrase_key,
843 members,
844 typers: HashMap::new(),
845 read_only: false,
846 issued_codes: Vec::new(),
847 },
848 );
849
850 self.network.subscribe_room(room_id.to_string()).await;
851 self.announce_room_now(&info, 2).await;
852
853 let app = self.clone();
854 let rid = room_id.to_string();
855 tokio::spawn(async move {
856 tokio::time::sleep(Duration::from_millis(500)).await;
857 if let Err(e) = app.broadcast_member_announce(&rid).await {
858 warn!(%e, "broadcast member announce on DM bootstrap");
859 }
860 });
861
862 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
863 room_id: room_id.to_string(),
864 });
865 Ok(room_id.to_string())
866 }
867
868 pub async fn join_room(&self, room_id: &str, passphrase: Option<&str>) -> Result<()> {
872 let (name, creator_fingerprint, encrypted, salt_opt) = {
874 if let Some(d) = self.discovered_rooms.lock().unwrap().get(room_id).cloned() {
875 let salt = self.get_room_salt(room_id);
876 (d.name, d.creator_fingerprint, d.encrypted, salt)
877 } else if let Some(stored) = self.restorable_rooms.lock().unwrap().get(room_id).cloned()
878 {
879 (
880 stored.name,
881 stored.creator_fingerprint,
882 stored.encrypted,
883 stored.passphrase_salt,
884 )
885 } else if let Some(stored) = repo::get_room(&self.db, room_id)? {
886 (
887 stored.name,
888 stored.creator_fingerprint,
889 stored.encrypted,
890 stored.passphrase_salt,
891 )
892 } else {
893 return Err(HuddleError::Other(format!("room {room_id} not found")));
894 }
895 };
896
897 if encrypted && passphrase.is_none() {
898 return Err(HuddleError::Other(
899 "encrypted room requires a passphrase".into(),
900 ));
901 }
902
903 let passphrase_key = if encrypted {
904 let salt = salt_opt
905 .clone()
906 .ok_or_else(|| HuddleError::Other("missing salt for encrypted room".into()))?;
907 Some(passphrase::derive_key(passphrase.unwrap(), &salt)?)
908 } else {
909 None
910 };
911
912 let kind = self
917 .discovered_rooms
918 .lock()
919 .unwrap()
920 .get(room_id)
921 .map(|d| d.kind)
922 .or_else(|| {
923 repo::get_room(&self.db, room_id)
924 .ok()
925 .flatten()
926 .map(|r| r.kind)
927 })
928 .unwrap_or_default();
929
930 let info = StoredRoom {
931 id: room_id.to_string(),
932 name,
933 creator_fingerprint,
934 encrypted,
935 passphrase_salt: salt_opt.clone(),
936 created_at: now_unix(),
937 last_active: Some(now_unix()),
938 kind,
939 };
940 repo::insert_room(&self.db, &info)?;
941
942 let crypto = if encrypted {
943 let our_fp = self.identity.fingerprint().to_string();
946 let existing = RoomCrypto::load(
947 self.db.clone(),
948 room_id.to_string(),
949 our_fp.clone(),
950 self.session_persist_key,
951 )?;
952 Some(match existing {
953 Some(c) => c,
954 None => RoomCrypto::new_for_room(
955 self.db.clone(),
956 room_id.to_string(),
957 our_fp,
958 self.session_persist_key,
959 )?,
960 })
961 } else {
962 None
963 };
964
965 let mut members = HashSet::new();
966 members.insert(self.identity.fingerprint().to_string());
967
968 self.active_rooms.lock().unwrap().insert(
969 room_id.to_string(),
970 ActiveRoom {
971 info: info.clone(),
972 crypto,
973 passphrase_key,
974 members,
975 typers: HashMap::new(),
976 read_only: false,
977 issued_codes: Vec::new(),
978 },
979 );
980 self.restorable_rooms.lock().unwrap().remove(room_id);
982
983 self.network.subscribe_room(room_id.to_string()).await;
984
985 let app = self.clone();
986 let rid = room_id.to_string();
987 tokio::spawn(async move {
988 tokio::time::sleep(Duration::from_millis(500)).await;
989 if let Err(e) = app.broadcast_member_announce(&rid).await {
990 warn!(%e, "broadcast member announce");
991 }
992 let req = RoomMessage::SessionKeyRequest {
994 requester_fingerprint: app.identity.fingerprint().to_string(),
995 };
996 if let Ok(bytes) = encode_wire(&req) {
997 app.network.publish_room_message(rid.clone(), bytes).await;
998 }
999 });
1000
1001 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
1002 room_id: room_id.to_string(),
1003 });
1004
1005 Ok(())
1006 }
1007
1008 async fn restore_rooms_from_db(&self) {
1013 let rooms = match repo::list_rooms(&self.db) {
1014 Ok(v) => v,
1015 Err(e) => {
1016 warn!(%e, "list rooms on restore");
1017 return;
1018 }
1019 };
1020 let our_fp = self.identity.fingerprint().to_string();
1021 let count = rooms.len();
1022 for info in rooms {
1023 if info.encrypted {
1024 self.restorable_rooms
1025 .lock()
1026 .unwrap()
1027 .insert(info.id.clone(), info);
1028 continue;
1029 }
1030 let mut members = HashSet::new();
1031 members.insert(our_fp.clone());
1032 if let Ok(stored_members) = repo::list_room_members(&self.db, &info.id) {
1033 for m in stored_members {
1034 members.insert(m.fingerprint);
1035 }
1036 }
1037 let member_count = members.len() as u32;
1038 self.active_rooms.lock().unwrap().insert(
1039 info.id.clone(),
1040 ActiveRoom {
1041 info: info.clone(),
1042 crypto: None,
1043 passphrase_key: None,
1044 members,
1045 typers: HashMap::new(),
1046 read_only: false,
1047 issued_codes: Vec::new(),
1048 },
1049 );
1050 self.network.subscribe_room(info.id.clone()).await;
1051 self.announce_room_now(&info, member_count).await;
1052 info!(room_id = %info.id, name = %info.name, "restored room");
1053 }
1054 if count > 0 {
1055 debug!(count, "restored rooms from db");
1056 }
1057 }
1058
1059 pub async fn leave_room(&self, room_id: &str) -> Result<bool> {
1064 let leave_msg = RoomMessage::MemberLeave {
1066 sender_fingerprint: self.identity.fingerprint().to_string(),
1067 };
1068 let dispatched = match encode_wire(&leave_msg) {
1069 Ok(bytes) => {
1070 self.network
1071 .publish_room_message(room_id.to_string(), bytes)
1072 .await;
1073 true
1074 }
1075 Err(e) => {
1076 warn!(%e, %room_id, "failed to encode MemberLeave notice");
1077 false
1078 }
1079 };
1080
1081 self.active_rooms.lock().unwrap().remove(room_id);
1082 self.network.unsubscribe_room(room_id.to_string()).await;
1083
1084 let _ = self.app_event_tx.send(AppEvent::RoomLeft {
1085 room_id: room_id.to_string(),
1086 });
1087 Ok(dispatched)
1088 }
1089
1090 pub async fn send_room_message(&self, room_id: &str, body: &str) -> Result<()> {
1091 let our_fp = self.identity.fingerprint().to_string();
1092 let msg = {
1093 let mut rooms = self.active_rooms.lock().unwrap();
1094 let room = rooms
1095 .get_mut(room_id)
1096 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
1097
1098 if room.read_only {
1099 return Err(HuddleError::Other(
1100 "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(),
1101 ));
1102 }
1103
1104 if room.info.encrypted {
1105 let crypto = room
1106 .crypto
1107 .as_mut()
1108 .ok_or_else(|| HuddleError::Session("encrypted room missing crypto".into()))?;
1109 let (session_id, ct_bytes) = crypto.encrypt(body.as_bytes())?;
1110 RoomMessage::Encrypted {
1111 sender_fingerprint: our_fp.clone(),
1112 session_id,
1113 ciphertext_b64: base64::Engine::encode(
1114 &base64::engine::general_purpose::STANDARD,
1115 &ct_bytes,
1116 ),
1117 }
1118 } else {
1119 RoomMessage::Plain {
1120 sender_fingerprint: our_fp.clone(),
1121 body: body.to_string(),
1122 }
1123 }
1124 };
1125
1126 let bytes = encode_wire(&msg)?;
1127 self.network
1128 .publish_room_message(room_id.to_string(), bytes)
1129 .await;
1130
1131 let now = now_unix();
1132 let msg_id =
1133 repo::insert_room_message(&self.db, room_id, &our_fp, "out", body, now)?;
1134 repo::update_room_last_active(&self.db, room_id, now)?;
1135
1136 let _ = self.app_event_tx.send(AppEvent::MessageSent {
1137 room_id: room_id.to_string(),
1138 body: body.to_string(),
1139 message_id: msg_id,
1140 });
1141
1142 Ok(())
1143 }
1144
1145 pub async fn shutdown(&self) {
1146 self.network.shutdown().await;
1147 }
1148
1149 pub async fn dial_by_id_or_username(&self, input: &str) -> Result<()> {
1176 let trimmed = input.trim();
1177 if trimmed.is_empty() {
1178 return Err(HuddleError::Other("input is empty".into()));
1179 }
1180 let target_fp = if let Some(fp) = normalize_to_fingerprint(trimmed) {
1181 fp
1182 } else {
1183 let matches = repo::find_peers_by_username(&self.db, trimmed)?;
1184 if matches.is_empty() {
1185 return Err(HuddleError::Other(format!(
1186 "no peer named `{}` known yet — paste their invite link instead",
1187 trimmed
1188 )));
1189 }
1190 if matches.len() > 1 {
1191 return Err(HuddleError::Other(format!(
1192 "username `{}` is ambiguous ({} peers share it) — use their HD- ID instead",
1193 trimmed,
1194 matches.len()
1195 )));
1196 }
1197 matches.into_iter().next().unwrap()
1198 };
1199 if target_fp == self.identity.fingerprint() {
1200 return Err(HuddleError::Other("that's your own ID".into()));
1201 }
1202 let candidates = self.resolve_dial_addrs(&target_fp);
1203 if candidates.is_empty() {
1204 return Err(HuddleError::Other(format!(
1205 "haven't seen `{}` on the network yet — ask them for an invite link",
1206 short_fp_for_msg(&target_fp)
1207 )));
1208 }
1209 let now = now_unix();
1214 for addr in &candidates {
1215 let _ = repo::upsert_known_peer(
1216 &self.db,
1217 &KnownPeer {
1218 address: addr.clone(),
1219 label: None,
1220 last_connected_at: None,
1221 last_attempt_at: Some(now),
1222 created_at: now,
1223 fingerprint: Some(target_fp.clone()),
1224 trusted: false,
1225 },
1226 );
1227 }
1228 let multiaddrs: Vec<Multiaddr> = candidates
1232 .iter()
1233 .filter_map(|s| s.parse::<Multiaddr>().ok())
1234 .collect();
1235 if multiaddrs.is_empty() {
1236 return Err(HuddleError::Other(
1237 "every known address for that peer is malformed".into(),
1238 ));
1239 }
1240 let _ = self.app_event_tx.send(AppEvent::Dialing {
1241 address: candidates[0].clone(),
1242 });
1243 info!(
1244 target_fp = %target_fp,
1245 n = multiaddrs.len(),
1246 "dialing peer with {} candidate addresses",
1247 multiaddrs.len()
1248 );
1249 {
1253 let mut pending = self.pending_auto_dm_addrs.lock().unwrap();
1254 for m in &multiaddrs {
1255 pending.insert(m.to_string());
1256 }
1257 }
1258 self.network.dial_addresses(multiaddrs).await;
1259 Ok(())
1260 }
1261
1262 fn resolve_dial_addrs(&self, fingerprint: &str) -> Vec<String> {
1270 let mut set: std::collections::HashSet<String> = std::collections::HashSet::new();
1271 for room in self.discovered_rooms.lock().unwrap().values() {
1272 if room.creator_fingerprint == fingerprint {
1273 for addr in &room.host_addrs {
1274 set.insert(addr.clone());
1275 }
1276 }
1277 }
1278 if let Ok(known) = repo::list_known_peers(&self.db) {
1279 for peer in known {
1280 if peer.fingerprint.as_deref() == Some(fingerprint) {
1281 set.insert(peer.address);
1282 }
1283 }
1284 }
1285 let mut v: Vec<String> = set.into_iter().collect();
1286 v.sort_by_key(|a| address_preference(a));
1287 v
1288 }
1289
1290 pub async fn dial(&self, input: &str) -> Result<()> {
1291 let multiaddr = parse_dial_address(input)?;
1292 let canonical = multiaddr.to_string();
1293 self.pending_auto_dm_addrs
1298 .lock()
1299 .unwrap()
1300 .insert(canonical.clone());
1301 self.dial_internal(canonical, multiaddr).await
1302 }
1303
1304 pub(crate) async fn dial_internal(
1310 &self,
1311 canonical: String,
1312 multiaddr: Multiaddr,
1313 ) -> Result<()> {
1314 info!(%canonical, "dialing");
1315 repo::upsert_known_peer(
1316 &self.db,
1317 &KnownPeer {
1318 address: canonical.clone(),
1319 label: None,
1320 last_connected_at: None,
1321 last_attempt_at: Some(now_unix()),
1322 created_at: now_unix(),
1323 fingerprint: None,
1327 trusted: false,
1328 },
1329 )?;
1330
1331 let _ = self.app_event_tx.send(AppEvent::Dialing {
1332 address: canonical.clone(),
1333 });
1334 self.network.dial(multiaddr).await;
1335 Ok(())
1336 }
1337
1338 pub fn nat_reachable_addrs(&self) -> Vec<String> {
1343 self.nat_reachable_addrs
1344 .lock()
1345 .unwrap()
1346 .iter()
1347 .cloned()
1348 .collect()
1349 }
1350
1351 pub fn dialable_addrs(&self) -> Vec<String> {
1359 let mut out: Vec<String> = self
1360 .relay_circuit_addrs
1361 .lock()
1362 .unwrap()
1363 .iter()
1364 .cloned()
1365 .collect();
1366 for a in self.nat_reachable_addrs.lock().unwrap().iter() {
1367 if !out.contains(a) {
1368 out.push(a.clone());
1369 }
1370 }
1371 out.truncate(4);
1372 out
1373 }
1374
1375 pub async fn dial_invite(&self, address: &str, claimed_fp: &str) -> Result<()> {
1388 let multiaddr = parse_dial_address(address)?;
1389 let canonical = multiaddr.to_string();
1390 self.pending_invite_dials
1391 .lock()
1392 .unwrap()
1393 .insert(canonical.clone(), claimed_fp.to_string());
1394 self.dial(address).await
1397 }
1398
1399 pub fn known_peers(&self) -> Vec<KnownPeerStatus> {
1400 let connected = self.connected_dial_addrs.lock().unwrap().clone();
1401 let stored = repo::list_known_peers(&self.db).unwrap_or_default();
1402 stored
1403 .into_iter()
1404 .map(|p| {
1405 let connected_peer = connected.get(&p.address).copied();
1406 KnownPeerStatus {
1407 address: p.address,
1408 label: p.label,
1409 last_connected_at: p.last_connected_at,
1410 connected_peer_id: connected_peer,
1411 fingerprint: p.fingerprint,
1412 }
1413 })
1414 .collect()
1415 }
1416
1417 pub async fn forget_peer(&self, address: &str) -> Result<()> {
1418 repo::forget_known_peer(&self.db, address)?;
1419 self.connected_dial_addrs.lock().unwrap().remove(address);
1420 Ok(())
1421 }
1422
1423 pub async fn redial(&self, address: &str) -> Result<()> {
1425 self.dial(address).await
1426 }
1427
1428 pub async fn accept_inbound(&self, peer_id: PeerId, address: &str) {
1433 self.network.accept_inbound(peer_id).await;
1434 self.connected_dial_addrs
1435 .lock()
1436 .unwrap()
1437 .insert(address.to_string(), peer_id);
1438 }
1439
1440 pub async fn reject_inbound(&self, peer_id: PeerId, fingerprint: &str) -> Result<()> {
1445 self.network.reject_inbound(peer_id).await;
1446 repo::block_peer(&self.db, fingerprint, now_unix())?;
1447 Ok(())
1448 }
1449
1450 pub async fn trust_inbound(
1453 &self,
1454 peer_id: PeerId,
1455 fingerprint: &str,
1456 address: &str,
1457 ) -> Result<()> {
1458 self.network.accept_inbound(peer_id).await;
1459 self.connected_dial_addrs
1460 .lock()
1461 .unwrap()
1462 .insert(address.to_string(), peer_id);
1463 repo::upsert_known_peer(
1467 &self.db,
1468 &KnownPeer {
1469 address: address.to_string(),
1470 label: None,
1471 last_connected_at: Some(now_unix()),
1472 last_attempt_at: Some(now_unix()),
1473 created_at: now_unix(),
1474 fingerprint: Some(fingerprint.to_string()),
1475 trusted: true,
1476 },
1477 )?;
1478 Ok(())
1479 }
1480
1481 pub fn list_pending_friend_requests(&self) -> Vec<repo::PendingFriendRequest> {
1489 repo::list_pending_friend_requests(&self.db).unwrap_or_default()
1490 }
1491
1492 pub fn spill_pending_friend_request(
1498 &self,
1499 peer_id: PeerId,
1500 fingerprint: &str,
1501 address: &str,
1502 ) -> Result<()> {
1503 repo::upsert_pending_friend_request(
1504 &self.db,
1505 &repo::PendingFriendRequest {
1506 fingerprint: fingerprint.to_string(),
1507 address: address.to_string(),
1508 peer_id: peer_id.to_string(),
1509 received_at: now_unix(),
1510 },
1511 )?;
1512 Ok(())
1513 }
1514
1515 pub async fn accept_pending_friend_request(&self, fingerprint: &str) -> Result<()> {
1522 let mut chosen_addr: Option<String> = None;
1523 for req in self.list_pending_friend_requests() {
1524 if req.fingerprint == fingerprint {
1525 chosen_addr = Some(req.address);
1526 break;
1527 }
1528 }
1529 repo::delete_pending_friend_requests_for_fp(&self.db, fingerprint)?;
1530 if let Some(addr) = chosen_addr {
1531 repo::upsert_known_peer(
1535 &self.db,
1536 &KnownPeer {
1537 address: addr.clone(),
1538 label: None,
1539 last_connected_at: None,
1540 last_attempt_at: Some(now_unix()),
1541 created_at: now_unix(),
1542 fingerprint: Some(fingerprint.to_string()),
1543 trusted: true,
1544 },
1545 )?;
1546 self.dial(&addr).await?;
1548 }
1549 Ok(())
1550 }
1551
1552 pub fn reject_pending_friend_request(&self, fingerprint: &str) -> Result<()> {
1557 repo::delete_pending_friend_requests_for_fp(&self.db, fingerprint)?;
1558 repo::block_peer(&self.db, fingerprint, now_unix())?;
1559 Ok(())
1560 }
1561
1562 pub async fn disconnect_peer(&self, peer_id: PeerId) {
1569 self.network.disconnect_peer(peer_id).await;
1570 }
1571
1572 fn spawn_known_peer_reconnector(&self) {
1573 let handle = self.clone();
1574 tokio::spawn(async move {
1575 tokio::time::sleep(Duration::from_millis(500)).await;
1577 let known = repo::list_known_peers(&handle.db).unwrap_or_default();
1578 for (i, peer) in known.into_iter().enumerate() {
1582 let handle = handle.clone();
1583 tokio::spawn(async move {
1584 let jitter = (peer.address.len() as u64 * 37) % 200;
1587 tokio::time::sleep(Duration::from_millis(150 * i as u64 + jitter)).await;
1588 let multiaddr = match peer.address.parse::<Multiaddr>() {
1593 Ok(m) => m,
1594 Err(_) => return,
1595 };
1596 if let Err(e) = handle.dial_internal(peer.address.clone(), multiaddr).await {
1597 debug!(%e, addr = %peer.address, "auto-reconnect failed");
1598 }
1599 });
1600 }
1601 });
1602 }
1603
1604 fn load_or_create_identity(db: &Db) -> Result<Identity> {
1609 if let Some(stored) = repo::load_identity(db)? {
1610 let mut bytes = [0u8; 32];
1611 bytes.copy_from_slice(&stored.ed25519_secret);
1612 Identity::from_secret_bytes(bytes)
1613 } else {
1614 let id = Identity::generate()?;
1615 repo::save_identity(db, &id.secret_bytes(), now_unix())?;
1616 Ok(id)
1617 }
1618 }
1619
1620 fn get_room_salt(&self, room_id: &str) -> Option<Vec<u8>> {
1621 self.active_rooms
1622 .lock()
1623 .unwrap()
1624 .get(room_id)
1625 .and_then(|r| r.info.passphrase_salt.clone())
1626 .or_else(|| {
1627 ROOM_SALT_CACHE
1629 .lock()
1630 .unwrap()
1631 .get(room_id)
1632 .cloned()
1633 })
1634 }
1635
1636 async fn announce_room_now(&self, info: &StoredRoom, member_count: u32) {
1637 let owner_fingerprints =
1638 repo::list_room_owners(&self.db, &info.id).unwrap_or_default();
1639 let verified_only = repo::get_room_verified_only(&self.db, &info.id).unwrap_or(false);
1640 let host_addrs = self.dialable_addrs();
1641 let ann = RoomAnnouncement {
1642 room_id: info.id.clone(),
1643 name: info.name.clone(),
1644 encrypted: info.encrypted,
1645 passphrase_salt: info.passphrase_salt.clone(),
1646 member_count,
1647 creator_fingerprint: info.creator_fingerprint.clone(),
1648 announced_at: now_unix(),
1649 owner_fingerprints,
1650 verified_only,
1651 host_addrs,
1652 kind: info.kind,
1653 };
1654 self.network.announce_room(ann).await;
1655 }
1656
1657 async fn broadcast_member_announce(&self, room_id: &str) -> Result<()> {
1658 let our_fp = self.identity.fingerprint().to_string();
1659 let wrapped = {
1660 let mut rooms = self.active_rooms.lock().unwrap();
1661 let room = rooms
1662 .get_mut(room_id)
1663 .ok_or_else(|| HuddleError::Other("not in room".into()))?;
1664 if room.info.encrypted {
1665 let crypto = room.crypto.as_mut().unwrap();
1666 let session_key = crypto.our_session_key_b64();
1667 match room.passphrase_key.as_ref() {
1668 Some(passphrase_key) => {
1669 Some(passphrase::wrap(session_key.as_bytes(), passphrase_key)?)
1670 }
1671 None if room.info.kind == RoomKind::Direct => {
1672 None
1682 }
1683 None => {
1684 return Err(HuddleError::Session("missing passphrase key".into()));
1685 }
1686 }
1687 } else {
1688 None
1689 }
1690 };
1691 let display_name = repo::get_display_name(&self.db).unwrap_or(None);
1692 let msg = RoomMessage::MemberAnnounce {
1693 sender_fingerprint: our_fp,
1694 wrapped_session_key: wrapped,
1695 display_name,
1696 sender_ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
1697 };
1698 let bytes = encode_wire(&msg)?;
1699 self.network
1700 .publish_room_message(room_id.to_string(), bytes)
1701 .await;
1702 Ok(())
1703 }
1704
1705 fn spawn_event_processor(&self, mut net_rx: tokio::sync::mpsc::Receiver<NetworkEvent>) {
1706 let handle = self.clone();
1707 tokio::spawn(async move {
1708 while let Some(event) = net_rx.recv().await {
1709 handle.process_network_event(event).await;
1710 }
1711 info!("event processor stopped");
1712 });
1713 }
1714
1715 fn spawn_announcement_ticker(&self) {
1716 let handle = self.clone();
1717 tokio::spawn(async move {
1718 let mut interval =
1719 tokio::time::interval(Duration::from_secs(ANNOUNCE_INTERVAL_SECS));
1720 interval.tick().await; loop {
1722 interval.tick().await;
1723 let snapshot: Vec<(StoredRoom, u32)> = {
1724 let active = handle.active_rooms.lock().unwrap();
1725 active
1726 .values()
1727 .map(|r| (r.info.clone(), r.members.len() as u32))
1728 .collect()
1729 };
1730 for (info, member_count) in snapshot {
1731 handle.announce_room_now(&info, member_count).await;
1732 }
1733 }
1734 });
1735 }
1736
1737 fn spawn_discovered_room_pruner(&self) {
1738 let handle = self.clone();
1739 tokio::spawn(async move {
1740 let mut interval = tokio::time::interval(Duration::from_secs(10));
1741 interval.tick().await;
1742 loop {
1743 interval.tick().await;
1744 let now = now_unix();
1745 let mut to_drop = Vec::new();
1746 {
1747 let mut map = handle.discovered_rooms.lock().unwrap();
1748 map.retain(|id, r| {
1749 if now - r.last_seen > DISCOVERED_TTL_SECS {
1750 to_drop.push(id.clone());
1751 false
1752 } else {
1753 true
1754 }
1755 });
1756 }
1757 for id in to_drop {
1758 let _ = handle.app_event_tx.send(AppEvent::RoomLost { room_id: id });
1759 }
1760 }
1761 });
1762 }
1763
1764 async fn process_network_event(&self, event: NetworkEvent) {
1765 match event {
1766 NetworkEvent::PeerDiscovered { peer_id } => {
1767 let _ = self.app_event_tx.send(AppEvent::PeerDiscovered { peer_id });
1768 }
1769 NetworkEvent::PeerExpired { peer_id } => {
1770 self.connected_dial_addrs
1776 .lock()
1777 .unwrap()
1778 .retain(|_addr, pid| *pid != peer_id);
1779 let _ = self.app_event_tx.send(AppEvent::PeerExpired { peer_id });
1780 }
1781 NetworkEvent::ListeningOn { address } => {
1782 let _ = self.app_event_tx.send(AppEvent::ListeningOn {
1783 address: address.to_string(),
1784 });
1785 }
1786 NetworkEvent::RoomAnnouncementReceived(ann) => {
1787 if let Some(salt) = &ann.passphrase_salt {
1789 ROOM_SALT_CACHE
1790 .lock()
1791 .unwrap()
1792 .insert(ann.room_id.clone(), salt.clone());
1793 }
1794 let our_fp_for_dial = self.identity.fingerprint().to_string();
1799 if ann.creator_fingerprint != our_fp_for_dial && !ann.host_addrs.is_empty() {
1800 let now = now_unix();
1801 let should_dial = {
1802 let mut attempts = self.host_addr_dial_attempts.lock().unwrap();
1803 match attempts.get(&ann.creator_fingerprint).copied() {
1804 Some(last) if now - last < HOST_ADDR_DIAL_BACKOFF_SECS => false,
1805 _ => {
1806 attempts.insert(ann.creator_fingerprint.clone(), now);
1807 true
1808 }
1809 }
1810 };
1811 if should_dial {
1812 if let Some(first) = ann.host_addrs.first() {
1813 info!(
1814 announcer = %ann.creator_fingerprint,
1815 addr = %first,
1816 "opportunistic dial via room announcement host_addrs"
1817 );
1818 if let Ok(multiaddr) = first.parse::<Multiaddr>() {
1823 let canonical = multiaddr.to_string();
1824 let _ = self.dial_internal(canonical, multiaddr).await;
1825 }
1826 }
1827 }
1828 }
1829 let discovered = DiscoveredRoom {
1830 room_id: ann.room_id.clone(),
1831 name: ann.name.clone(),
1832 encrypted: ann.encrypted,
1833 member_count: ann.member_count,
1834 creator_fingerprint: ann.creator_fingerprint.clone(),
1835 last_seen: now_unix(),
1836 restorable: false,
1837 host_addrs: ann.host_addrs.clone(),
1838 kind: ann.kind,
1839 };
1840 if self.active_rooms.lock().unwrap().contains_key(&ann.room_id) {
1845 self.discovered_rooms
1846 .lock()
1847 .unwrap()
1848 .insert(ann.room_id.clone(), discovered);
1849 return;
1850 }
1851 if ann.kind == RoomKind::Direct {
1861 let our_fp_for_filter = self.identity.fingerprint().to_string();
1862 if canonical_dm_room_id(&our_fp_for_filter, &ann.creator_fingerprint)
1863 != ann.room_id
1864 {
1865 debug!(
1866 announcer = %ann.creator_fingerprint,
1867 room_id = %ann.room_id,
1868 "dropping Direct announcement: not addressed to us"
1869 );
1870 return;
1871 }
1872 self.discovered_rooms
1877 .lock()
1878 .unwrap()
1879 .insert(ann.room_id.clone(), discovered.clone());
1880 let _ = self
1881 .app_event_tx
1882 .send(AppEvent::RoomDiscovered(discovered.clone()));
1883 let app = self.clone();
1884 let partner = ann.creator_fingerprint.clone();
1885 let rid = ann.room_id.clone();
1886 tokio::spawn(async move {
1887 if let Err(e) = app.start_direct(&partner).await {
1888 debug!(%e, room_id = %rid, "auto-bootstrap of inbound DM failed");
1889 }
1890 });
1891 return;
1892 }
1893 self.discovered_rooms
1894 .lock()
1895 .unwrap()
1896 .insert(ann.room_id.clone(), discovered.clone());
1897 let _ = self.app_event_tx.send(AppEvent::RoomDiscovered(discovered));
1898 }
1899 NetworkEvent::RoomMessageReceived {
1900 room_id,
1901 payload,
1902 from_peer: _,
1903 } => {
1904 let wire: WireMessage = match serde_json::from_slice(&payload) {
1911 Ok(w) => w,
1912 Err(e) => {
1913 warn!(%e, "bad wire envelope");
1914 return;
1915 }
1916 };
1917 let (msg, verified_signer) = match wire {
1918 WireMessage::Plain(m) => (m, None),
1919 WireMessage::Signed(env) => {
1920 let claimed_pubkey = env.ed25519_pubkey_b64.clone();
1921 match crate::crypto::verify_signed(&env) {
1922 Ok((m, fp)) => {
1923 match repo::get_member_ed25519_pubkey(
1930 &self.db, &room_id, &fp,
1931 ) {
1932 Ok(Some(known)) if known != claimed_pubkey => {
1933 warn!(
1934 %fp, %room_id,
1935 "pubkey mismatch vs stored; dropping signed message"
1936 );
1937 return;
1938 }
1939 _ => {}
1940 }
1941 (m, Some(fp))
1942 }
1943 Err(e) => {
1944 warn!(%e, fp = %env.fingerprint, "signed envelope verify failed");
1945 return;
1946 }
1947 }
1948 }
1949 };
1950 self.handle_room_message(&room_id, msg, verified_signer).await;
1951 }
1952 NetworkEvent::DialSucceeded { peer_id, address } => {
1953 let addr_s = address.to_string();
1954 self.connected_dial_addrs
1955 .lock()
1956 .unwrap()
1957 .insert(addr_s.clone(), peer_id);
1958 let _ = repo::upsert_known_peer(
1962 &self.db,
1963 &KnownPeer {
1964 address: addr_s.clone(),
1965 label: None,
1966 last_connected_at: Some(now_unix()),
1967 last_attempt_at: Some(now_unix()),
1968 created_at: now_unix(),
1969 fingerprint: None,
1970 trusted: false,
1971 },
1972 );
1973 let _ = self.app_event_tx.send(AppEvent::DialSucceeded {
1974 address: addr_s,
1975 peer_id,
1976 });
1977 }
1978 NetworkEvent::DialFailed { address, error } => {
1979 let addr_s = address.to_string();
1980 let _ = self.app_event_tx.send(AppEvent::DialFailed {
1981 address: addr_s,
1982 error,
1983 });
1984 }
1985 NetworkEvent::PeerIdentified { peer_id, fingerprint } => {
1986 let matched_addrs: Vec<String> = {
1992 let map = self.connected_dial_addrs.lock().unwrap();
1993 map.iter()
1994 .filter_map(|(addr, pid)| {
1995 if *pid == peer_id {
1996 Some(addr.clone())
1997 } else {
1998 None
1999 }
2000 })
2001 .collect()
2002 };
2003 let mismatch = {
2013 let mut map = self.pending_invite_dials.lock().unwrap();
2014 let mut found: Option<(String, String)> = None;
2015 for addr in &matched_addrs {
2016 if let Some(claimed) = map.remove(addr) {
2017 if claimed != fingerprint {
2018 found = Some((addr.clone(), claimed));
2019 break;
2020 }
2021 }
2022 }
2023 found
2024 };
2025 if let Some((addr, claimed)) = mismatch {
2026 warn!(
2027 %addr, %claimed, actual=%fingerprint,
2028 "invite fingerprint mismatch — disconnecting"
2029 );
2030 self.network.disconnect_peer(peer_id).await;
2031 let _ = self.app_event_tx.send(AppEvent::InviteFingerprintMismatch {
2032 address: addr,
2033 claimed,
2034 actual: fingerprint.clone(),
2035 });
2036 return;
2037 }
2038 let should_auto_dm = {
2045 let mut pending = self.pending_auto_dm_addrs.lock().unwrap();
2046 let mut any_matched = false;
2047 for addr in &matched_addrs {
2048 if pending.remove(addr) {
2049 any_matched = true;
2050 }
2051 }
2052 any_matched
2053 };
2054 for addr in matched_addrs {
2055 let _ = repo::upsert_known_peer(
2056 &self.db,
2057 &KnownPeer {
2058 address: addr,
2059 label: None,
2060 last_connected_at: Some(now_unix()),
2061 last_attempt_at: Some(now_unix()),
2062 created_at: now_unix(),
2063 fingerprint: Some(fingerprint.clone()),
2064 trusted: true,
2065 },
2066 );
2067 }
2068 if should_auto_dm && fingerprint != self.identity.fingerprint() {
2075 match self.start_direct(&fingerprint).await {
2076 Ok(room_id) => {
2077 let _ = self.app_event_tx.send(AppEvent::AutoOpenDm {
2078 room_id,
2079 fingerprint: fingerprint.clone(),
2080 });
2081 }
2082 Err(e) => {
2083 debug!(%e, fp = %fingerprint, "auto-DM after dial failed");
2084 }
2085 }
2086 }
2087 let our_username = repo::get_display_name(&self.db).unwrap_or(None);
2095 if our_username.is_some() {
2096 let now_ms = now_unix_ms();
2097 let should_send = {
2098 let mut last = self.last_profile_broadcast_at_ms.lock().unwrap();
2099 match last.get(&fingerprint) {
2100 Some(prev) if now_ms - prev < PROFILE_REBROADCAST_FLOOR_MS => false,
2101 _ => {
2102 last.insert(fingerprint.clone(), now_ms);
2103 true
2104 }
2105 }
2106 };
2107 if should_send {
2108 let msg = RoomMessage::ProfileUpdate {
2109 sender_fingerprint: self.identity.fingerprint().to_string(),
2110 username: our_username,
2111 updated_at: now_ms,
2112 };
2113 if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
2114 if let Ok(bytes) =
2115 crate::network::protocol::encode_wire_signed(&env)
2116 {
2117 let rooms: Vec<String> = self
2118 .active_rooms
2119 .lock()
2120 .unwrap()
2121 .keys()
2122 .cloned()
2123 .collect();
2124 for room_id in rooms {
2125 self.network
2126 .publish_room_message(room_id, bytes.clone())
2127 .await;
2128 }
2129 }
2130 }
2131 }
2132 }
2133 }
2134 NetworkEvent::RelayReservationEstablished { address } => {
2135 info!(addr = %address, "relay reservation established");
2140 self.relay_circuit_addrs
2141 .lock()
2142 .unwrap()
2143 .insert(address.to_string());
2144 let _ = self.app_event_tx.send(AppEvent::ListeningOn {
2145 address: address.to_string(),
2146 });
2147 }
2148 NetworkEvent::NatProbeResult {
2149 tested_addr,
2150 reachable,
2151 } => {
2152 let addr_s = tested_addr.to_string();
2153 let (transitioned, becomes_reachable) = {
2154 let mut set = self.nat_reachable_addrs.lock().unwrap();
2155 let was_empty = set.is_empty();
2156 if reachable {
2157 set.insert(addr_s.clone());
2158 } else {
2159 set.remove(&addr_s);
2160 }
2161 let is_empty = set.is_empty();
2162 (was_empty != is_empty, !is_empty)
2163 };
2164 if transitioned {
2165 let label = if becomes_reachable {
2166 "reachable".to_string()
2167 } else {
2168 "private".to_string()
2169 };
2170 info!(reachable = %becomes_reachable, "NAT reachability changed");
2171 let _ = self.app_event_tx.send(AppEvent::NatStatusChanged {
2172 label,
2173 reachable: becomes_reachable,
2174 });
2175 }
2176 }
2177 NetworkEvent::DcutrUpgrade {
2178 remote_peer,
2179 success,
2180 } => {
2181 if success {
2182 let s = remote_peer.to_base58();
2186 let tail: String = s.chars().rev().take(8).collect::<String>()
2187 .chars()
2188 .rev()
2189 .collect();
2190 let _ = self.app_event_tx.send(AppEvent::DcutrSucceeded {
2191 peer_label: tail,
2192 });
2193 }
2194 }
2195 NetworkEvent::InboundDial {
2196 peer_id,
2197 fingerprint,
2198 address,
2199 } => {
2200 if repo::is_peer_blocked(&self.db, &fingerprint).unwrap_or(false) {
2202 info!(%fingerprint, "inbound dial auto-rejected: peer is blocked");
2203 self.network.reject_inbound(peer_id).await;
2204 return;
2205 }
2206 let global_verified_only =
2211 repo::get_setting(&self.db, "verified_only_inbound")
2212 .ok()
2213 .flatten()
2214 .map(|v| v == "1")
2215 .unwrap_or(false);
2216 if global_verified_only {
2217 let is_verified =
2218 repo::is_globally_verified(&self.db, &fingerprint).unwrap_or(false)
2219 || repo::is_fingerprint_trusted(&self.db, &fingerprint)
2220 .unwrap_or(false);
2221 if !is_verified {
2222 info!(
2223 %fingerprint,
2224 "inbound dial auto-rejected: verified-only mode"
2225 );
2226 self.network.reject_inbound(peer_id).await;
2227 return;
2228 }
2229 }
2230 if repo::is_fingerprint_trusted(&self.db, &fingerprint).unwrap_or(false) {
2231 info!(%fingerprint, "inbound dial auto-accepted: peer is trusted");
2232 self.connected_dial_addrs
2235 .lock()
2236 .unwrap()
2237 .insert(address.to_string(), peer_id);
2238 let _ = repo::upsert_known_peer(
2239 &self.db,
2240 &KnownPeer {
2241 address: address.to_string(),
2242 label: None,
2243 last_connected_at: Some(now_unix()),
2244 last_attempt_at: Some(now_unix()),
2245 created_at: now_unix(),
2246 fingerprint: Some(fingerprint),
2247 trusted: true,
2248 },
2249 );
2250 self.network.accept_inbound(peer_id).await;
2251 return;
2252 }
2253 let _ = self.app_event_tx.send(AppEvent::InboundDial {
2255 peer_id,
2256 fingerprint,
2257 address: address.to_string(),
2258 });
2259 }
2260 }
2261 }
2262
2263 async fn handle_room_message(
2269 &self,
2270 room_id: &str,
2271 msg: RoomMessage,
2272 verified_signer: Option<String>,
2273 ) {
2274 let our_fp = self.identity.fingerprint().to_string();
2275 match msg {
2276 RoomMessage::MemberAnnounce {
2277 sender_fingerprint,
2278 wrapped_session_key,
2279 display_name,
2280 sender_ed25519_pubkey,
2281 } => {
2282 if sender_fingerprint == our_fp {
2283 return;
2284 }
2285 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2288 .unwrap_or(false)
2289 {
2290 info!(%sender_fingerprint, %room_id, "dropping MemberAnnounce from banned peer");
2291 return;
2292 }
2293 if repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
2300 && !repo::is_globally_verified(&self.db, &sender_fingerprint).unwrap_or(false)
2301 {
2302 info!(
2303 %sender_fingerprint, %room_id,
2304 "dropping MemberAnnounce: room is verified-only and joiner isn't verified"
2305 );
2306 let owners = repo::list_room_owners(&self.db, room_id).unwrap_or_default();
2307 let lowest_owner = owners.iter().min().cloned();
2308 if lowest_owner.as_deref() == Some(&our_fp) {
2309 let msg = RoomMessage::JoinRefused {
2310 room_id: room_id.to_string(),
2311 target_fingerprint: sender_fingerprint.clone(),
2312 reason: "room requires SAS verification — ask an existing member to verify you".into(),
2313 };
2314 if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
2315 if let Ok(bytes) =
2316 crate::network::protocol::encode_wire_signed(&env)
2317 {
2318 self.network
2319 .publish_room_message(room_id.to_string(), bytes)
2320 .await;
2321 }
2322 }
2323 }
2324 return;
2325 }
2326 let need_inbound = {
2327 let mut rooms = self.active_rooms.lock().unwrap();
2328 let room = match rooms.get_mut(room_id) {
2329 Some(r) => r,
2330 None => return,
2331 };
2332 if room.info.kind == RoomKind::Direct
2340 && !room.members.contains(&sender_fingerprint)
2341 && room.members.len() >= 2
2342 {
2343 info!(
2344 %sender_fingerprint, %room_id,
2345 "dropping MemberAnnounce on Direct room: already at 2-member cap"
2346 );
2347 return;
2348 }
2349 let newly_added = room.members.insert(sender_fingerprint.clone());
2350 if newly_added {
2351 let _ = self.app_event_tx.send(AppEvent::MemberJoined {
2352 room_id: room_id.to_string(),
2353 fingerprint: sender_fingerprint.clone(),
2354 });
2355 }
2356 let _ = repo::upsert_room_member(
2361 &self.db,
2362 &StoredRoomMember {
2363 room_id: room_id.to_string(),
2364 peer_id: String::new(), fingerprint: sender_fingerprint.clone(),
2366 last_seen: Some(now_unix()),
2367 verified: false,
2368 ed25519_pubkey: sender_ed25519_pubkey.clone(),
2369 role: "member".into(),
2375 },
2376 );
2377 if let Some(name) = display_name.as_deref() {
2378 let _ = repo::set_member_display_name(
2379 &self.db,
2380 room_id,
2381 &sender_fingerprint,
2382 Some(name),
2383 );
2384 }
2385 room.info.encrypted && wrapped_session_key.is_some()
2386 };
2387
2388 if matches!(
2395 self.active_rooms
2396 .lock()
2397 .unwrap()
2398 .get(room_id)
2399 .map(|r| (r.info.kind, r.passphrase_key.is_none())),
2400 Some((RoomKind::Direct, true))
2401 ) {
2402 if let Some(pubkey_b64) = sender_ed25519_pubkey.as_deref() {
2403 if let Some(key) =
2404 self.derive_dm_key_from_pubkey_b64(room_id, pubkey_b64)
2405 {
2406 let mut rooms = self.active_rooms.lock().unwrap();
2407 if let Some(room) = rooms.get_mut(room_id) {
2408 room.passphrase_key = Some(key);
2409 }
2410 drop(rooms);
2411 let app = self.clone();
2416 let rid = room_id.to_string();
2417 tokio::spawn(async move {
2418 if let Err(e) = app.broadcast_member_announce(&rid).await {
2419 warn!(%e, "re-broadcast DM announce after key derivation");
2420 }
2421 });
2422 }
2423 }
2424 }
2425
2426 if need_inbound {
2427 let wrapped = wrapped_session_key.unwrap();
2428 let result = {
2429 let mut rooms = self.active_rooms.lock().unwrap();
2430 let room = rooms.get_mut(room_id).unwrap();
2431 let passphrase_key = match &room.passphrase_key {
2432 Some(k) => k,
2433 None => {
2434 warn!("no passphrase key when receiving session key");
2435 return;
2436 }
2437 };
2438 match passphrase::unwrap(&wrapped, passphrase_key) {
2439 Ok(plain) => match String::from_utf8(plain) {
2440 Ok(key_b64) => {
2441 let crypto = room.crypto.as_mut().unwrap();
2442 crypto.add_inbound_session(&sender_fingerprint, &key_b64)
2443 }
2444 Err(e) => Err(HuddleError::Session(format!("utf8: {e}"))),
2445 },
2446 Err(e) => Err(e),
2447 }
2448 };
2449 if let Err(e) = result {
2450 error!(%e, "add inbound session failed");
2451 }
2452 }
2453 }
2454 RoomMessage::SessionKeyRequest {
2455 requester_fingerprint,
2456 } => {
2457 if requester_fingerprint == our_fp {
2458 return;
2459 }
2460 if let Err(e) = self.broadcast_member_announce(room_id).await {
2462 warn!(%e, "broadcast member announce on request");
2463 }
2464 }
2465 RoomMessage::Encrypted {
2466 sender_fingerprint,
2467 session_id,
2468 ciphertext_b64,
2469 } => {
2470 if sender_fingerprint == our_fp {
2471 return;
2472 }
2473 let ct_bytes = match base64::Engine::decode(
2474 &base64::engine::general_purpose::STANDARD,
2475 &ciphertext_b64,
2476 ) {
2477 Ok(b) => b,
2478 Err(e) => {
2479 warn!(%e, "bad base64 ciphertext");
2480 return;
2481 }
2482 };
2483 let plaintext = {
2484 let mut rooms = self.active_rooms.lock().unwrap();
2485 let room = match rooms.get_mut(room_id) {
2486 Some(r) => r,
2487 None => return,
2488 };
2489 let crypto = match room.crypto.as_mut() {
2490 Some(c) => c,
2491 None => return,
2492 };
2493 crypto.decrypt(&sender_fingerprint, &session_id, &ct_bytes)
2494 };
2495 match plaintext {
2496 Ok(pt) => {
2497 let body = String::from_utf8_lossy(&pt).to_string();
2498 let sent_at = now_unix();
2499 let _ = repo::insert_room_message(
2500 &self.db,
2501 room_id,
2502 &sender_fingerprint,
2503 "in",
2504 &body,
2505 sent_at,
2506 );
2507 let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
2508 self.maybe_emit_mention(room_id, &body);
2509 let _ = self.app_event_tx.send(AppEvent::MessageReceived {
2510 room_id: room_id.to_string(),
2511 sender_fingerprint,
2512 body,
2513 sent_at,
2514 });
2515 }
2516 Err(e) => {
2517 debug!(%e, "decrypt failed (probably missing session key)");
2518 }
2519 }
2520 }
2521 RoomMessage::Plain {
2522 sender_fingerprint,
2523 body,
2524 } => {
2525 if sender_fingerprint == our_fp {
2526 return;
2527 }
2528 let sent_at = now_unix();
2529 let _ = repo::insert_room_message(
2530 &self.db,
2531 room_id,
2532 &sender_fingerprint,
2533 "in",
2534 &body,
2535 sent_at,
2536 );
2537 let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
2538 self.maybe_emit_mention(room_id, &body);
2539 let _ = self.app_event_tx.send(AppEvent::MessageReceived {
2540 room_id: room_id.to_string(),
2541 sender_fingerprint,
2542 body,
2543 sent_at,
2544 });
2545 }
2546 RoomMessage::Typing { sender_fingerprint } => {
2547 if sender_fingerprint == our_fp {
2548 return;
2549 }
2550 let expiry = now_unix() + TYPING_TTL_SECS;
2551 let mut rooms = self.active_rooms.lock().unwrap();
2552 if let Some(room) = rooms.get_mut(room_id) {
2553 room.typers.insert(sender_fingerprint, expiry);
2554 }
2555 drop(rooms);
2556 let _ = self.app_event_tx.send(AppEvent::TypingChanged {
2557 room_id: room_id.to_string(),
2558 });
2559 }
2560 RoomMessage::RotateRoomKey {
2561 rotator_fingerprint,
2562 new_salt,
2563 } => {
2564 if rotator_fingerprint == our_fp {
2565 return;
2566 }
2567 let signer = match verified_signer {
2572 Some(fp) => fp,
2573 None => {
2574 warn!(%room_id, "RotateRoomKey arrived unsigned; dropping");
2575 return;
2576 }
2577 };
2578 if signer != rotator_fingerprint {
2579 warn!(
2580 %signer, %rotator_fingerprint, %room_id,
2581 "RotateRoomKey signer mismatch with claimed rotator; dropping"
2582 );
2583 return;
2584 }
2585 let _ = self.app_event_tx.send(AppEvent::RotationRequested {
2586 room_id: room_id.to_string(),
2587 rotator_fingerprint,
2588 new_salt,
2589 });
2590 }
2591 RoomMessage::MemberLeave { sender_fingerprint } => {
2592 if sender_fingerprint == our_fp {
2593 return;
2594 }
2595 let removed = {
2596 let mut rooms = self.active_rooms.lock().unwrap();
2597 if let Some(room) = rooms.get_mut(room_id) {
2598 room.members.remove(&sender_fingerprint)
2599 } else {
2600 false
2601 }
2602 };
2603 if removed {
2604 let _ = self.app_event_tx.send(AppEvent::MemberLeft {
2605 room_id: room_id.to_string(),
2606 fingerprint: sender_fingerprint,
2607 });
2608 }
2609 }
2610 RoomMessage::FileOffer {
2611 sender_fingerprint,
2612 file_id,
2613 name,
2614 size_bytes,
2615 mime,
2616 chunk_count,
2617 encrypted_meta,
2618 } => {
2619 if sender_fingerprint == our_fp {
2620 return; }
2622 self.handle_file_offer(
2623 room_id,
2624 sender_fingerprint,
2625 file_id,
2626 name,
2627 size_bytes,
2628 mime,
2629 chunk_count,
2630 encrypted_meta,
2631 );
2632 }
2633 RoomMessage::FileChunk {
2634 sender_fingerprint,
2635 file_id,
2636 chunk_index,
2637 total_chunks,
2638 data_b64,
2639 } => {
2640 if sender_fingerprint == our_fp {
2641 return;
2642 }
2643 self.handle_file_chunk(
2644 room_id,
2645 sender_fingerprint,
2646 file_id,
2647 chunk_index,
2648 total_chunks,
2649 data_b64,
2650 );
2651 }
2652 RoomMessage::OwnerGrant {
2653 room_id: announced_room_id,
2654 target_fingerprint,
2655 } => {
2656 if announced_room_id != room_id {
2661 warn!(payload_room = %announced_room_id, topic_room = %room_id, "OwnerGrant room mismatch");
2662 return;
2663 }
2664 let signer = match verified_signer {
2665 Some(fp) => fp,
2666 None => {
2667 warn!(%room_id, "OwnerGrant arrived unsigned; dropping");
2668 return;
2669 }
2670 };
2671 if !self.is_owner(room_id, &signer) {
2672 warn!(%signer, %room_id, "OwnerGrant signer isn't an owner; dropping");
2673 return;
2674 }
2675 info!(%signer, %target_fingerprint, %room_id, "OwnerGrant applied");
2676 if let Err(e) =
2677 repo::set_member_role(&self.db, room_id, &target_fingerprint, "owner")
2678 {
2679 warn!(%e, "OwnerGrant: set_member_role failed");
2680 }
2681 }
2682 RoomMessage::BanMember {
2683 room_id: announced_room_id,
2684 target_fingerprint,
2685 } => {
2686 if announced_room_id != room_id {
2687 warn!(payload_room = %announced_room_id, topic_room = %room_id, "BanMember room mismatch");
2688 return;
2689 }
2690 let signer = match verified_signer {
2691 Some(fp) => fp,
2692 None => {
2693 warn!(%room_id, "BanMember arrived unsigned; dropping");
2694 return;
2695 }
2696 };
2697 if !self.is_owner(room_id, &signer) {
2698 warn!(%signer, %room_id, "BanMember signer isn't an owner; dropping");
2699 return;
2700 }
2701 if target_fingerprint == our_fp {
2702 info!(%room_id, %signer, "we were kicked from this room");
2708 self.active_rooms.lock().unwrap().remove(room_id);
2709 let _ = self.app_event_tx.send(AppEvent::RoomLeft {
2710 room_id: room_id.to_string(),
2711 });
2712 return;
2713 }
2714 info!(%signer, %target_fingerprint, %room_id, "BanMember applied");
2715 if let Err(e) = repo::add_room_ban(
2716 &self.db,
2717 room_id,
2718 &target_fingerprint,
2719 &signer,
2720 "", now_unix(),
2722 ) {
2723 warn!(%e, "BanMember: add_room_ban failed");
2724 }
2725 self.evict_banned_member(room_id, &target_fingerprint);
2726 }
2727 RoomMessage::SasInit {
2728 tx_id,
2729 ephemeral_x25519_pubkey_b64,
2730 target_fingerprint,
2731 } => {
2732 if target_fingerprint != our_fp {
2733 return;
2738 }
2739 let signer = match verified_signer {
2740 Some(fp) => fp,
2741 None => {
2742 warn!("SasInit arrived unsigned; dropping");
2743 return;
2744 }
2745 };
2746 let their_pub =
2747 match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
2748 Ok(pk) => pk,
2749 Err(e) => {
2750 warn!(%e, "SasInit: bad x25519 pubkey");
2751 return;
2752 }
2753 };
2754 let tx_id_bytes = match B64.decode(&tx_id) {
2755 Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
2756 let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
2757 arr.copy_from_slice(&b);
2758 arr
2759 }
2760 _ => {
2761 warn!(%tx_id, "SasInit: bad tx_id length");
2762 return;
2763 }
2764 };
2765 let (_, our_secret, our_pub) = crate::crypto::sas::new_session();
2766 let sas_code =
2767 crate::crypto::sas::derive_sas_code(&our_secret, &their_pub, &tx_id_bytes);
2768 self.sas_flows.lock().unwrap().insert(
2769 tx_id.clone(),
2770 SasFlow {
2771 room_id: room_id.to_string(),
2772 partner_fingerprint: signer.clone(),
2773 our_secret,
2774 sas_code: Some(sas_code.clone()),
2775 our_confirmed: false,
2776 their_confirmed: false,
2777 },
2778 );
2779 let response = RoomMessage::SasResponse {
2782 tx_id: tx_id.clone(),
2783 ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2784 };
2785 if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
2786 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
2787 self.network
2788 .publish_room_message(room_id.to_string(), bytes)
2789 .await;
2790 }
2791 }
2792 let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
2793 room_id: room_id.to_string(),
2794 partner_fingerprint: signer,
2795 tx_id,
2796 emoji_string: sas_code.emoji_string(),
2797 emoji_labels: sas_code.emoji_labels(),
2798 decimal: sas_code.decimal,
2799 });
2800 }
2801 RoomMessage::SasResponse {
2802 tx_id,
2803 ephemeral_x25519_pubkey_b64,
2804 } => {
2805 let signer = match verified_signer {
2806 Some(fp) => fp,
2807 None => {
2808 warn!("SasResponse arrived unsigned; dropping");
2809 return;
2810 }
2811 };
2812 let their_pub =
2813 match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
2814 Ok(pk) => pk,
2815 Err(e) => {
2816 warn!(%e, "SasResponse: bad x25519 pubkey");
2817 return;
2818 }
2819 };
2820 let tx_id_bytes = match B64.decode(&tx_id) {
2821 Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
2822 let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
2823 arr.copy_from_slice(&b);
2824 arr
2825 }
2826 _ => return,
2827 };
2828 let emit = {
2829 let mut flows = self.sas_flows.lock().unwrap();
2830 let flow = match flows.get_mut(&tx_id) {
2831 Some(f) => f,
2832 None => {
2833 warn!(%tx_id, "SasResponse for unknown tx_id");
2834 return;
2835 }
2836 };
2837 if flow.partner_fingerprint != signer {
2838 warn!(
2839 expected = %flow.partner_fingerprint, got = %signer,
2840 "SasResponse signer doesn't match flow's partner; dropping"
2841 );
2842 return;
2843 }
2844 let code = crate::crypto::sas::derive_sas_code(
2845 &flow.our_secret,
2846 &their_pub,
2847 &tx_id_bytes,
2848 );
2849 flow.sas_code = Some(code.clone());
2850 code
2851 };
2852 let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
2853 room_id: room_id.to_string(),
2854 partner_fingerprint: signer,
2855 tx_id,
2856 emoji_string: emit.emoji_string(),
2857 emoji_labels: emit.emoji_labels(),
2858 decimal: emit.decimal,
2859 });
2860 }
2861 RoomMessage::CodeJoinRequest {
2862 room_id: announced_room_id,
2863 joiner_x25519_pubkey_b64,
2864 code,
2865 } => {
2866 if announced_room_id != room_id {
2867 return;
2868 }
2869 let joiner_fp = match verified_signer {
2870 Some(fp) => fp,
2871 None => {
2872 warn!("CodeJoinRequest unsigned; dropping");
2873 return;
2874 }
2875 };
2876 let our_fp = self.identity.fingerprint().to_string();
2880 if !self.is_owner(room_id, &our_fp) {
2881 return;
2882 }
2883 let now = now_unix();
2885 let (code_ok, our_session_id, wrap_input) = {
2886 let mut rooms = self.active_rooms.lock().unwrap();
2887 let room = match rooms.get_mut(room_id) {
2888 Some(r) => r,
2889 None => return,
2890 };
2891 if room.passphrase_key.is_none() {
2892 warn!("CodeJoinRequest: no passphrase key locally; can't respond");
2893 return;
2894 }
2895 let original_len = room.issued_codes.len();
2896 room.issued_codes.retain(|(c, exp)| !(c == &code && *exp > now));
2897 let matched = room.issued_codes.len() < original_len;
2898 if !matched {
2899 info!(%joiner_fp, "CodeJoinRequest: code invalid or expired; ignoring");
2900 return;
2901 }
2902 let crypto = room.crypto.as_ref().unwrap();
2903 (
2904 true,
2905 crypto.our_session_id(),
2906 crypto.our_session_key_b64(),
2907 )
2908 };
2909 let _ = code_ok;
2910 let their_pub = match crate::crypto::sas::parse_pubkey(&joiner_x25519_pubkey_b64) {
2912 Ok(pk) => pk,
2913 Err(e) => {
2914 warn!(%e, "CodeJoinRequest: bad pubkey");
2915 return;
2916 }
2917 };
2918 use x25519_dalek::{PublicKey, StaticSecret};
2919 let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
2920 let our_pub = PublicKey::from(&our_secret);
2921 let shared = our_secret.diffie_hellman(&their_pub);
2922 let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
2924 let mut wrap_key = [0u8; passphrase::KEY_LEN];
2925 hk.expand(b"huddle-code-join-v1", &mut wrap_key)
2926 .expect("32 bytes is within HKDF limits");
2927 let wrapped = match passphrase::wrap(wrap_input.as_bytes(), &wrap_key) {
2930 Ok(w) => w,
2931 Err(e) => {
2932 warn!(%e, "CodeJoinRequest: wrap failed");
2933 return;
2934 }
2935 };
2936 let response = RoomMessage::CodeJoinResponse {
2937 room_id: room_id.to_string(),
2938 target_fingerprint: joiner_fp.clone(),
2939 owner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2940 owner_session_id: our_session_id,
2941 wrapped_session_key_b64: wrapped,
2942 nonce_b64: String::new(), };
2944 if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
2945 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
2946 self.network
2947 .publish_room_message(room_id.to_string(), bytes)
2948 .await;
2949 }
2950 }
2951 info!(%joiner_fp, %room_id, "issued CodeJoinResponse");
2952 }
2953 RoomMessage::CodeJoinResponse {
2954 room_id: announced_room_id,
2955 target_fingerprint,
2956 owner_x25519_pubkey_b64,
2957 owner_session_id,
2958 wrapped_session_key_b64,
2959 nonce_b64: _,
2960 } => {
2961 if announced_room_id != room_id || target_fingerprint != our_fp {
2962 return;
2963 }
2964 let owner_fp = match verified_signer {
2965 Some(fp) => fp,
2966 None => {
2967 warn!("CodeJoinResponse unsigned; dropping");
2968 return;
2969 }
2970 };
2971 let our_secret = match self
2972 .pending_code_secrets
2973 .lock()
2974 .unwrap()
2975 .remove(&(room_id.to_string(), our_fp.clone()))
2976 {
2977 Some(s) => s,
2978 None => {
2979 warn!(%room_id, "CodeJoinResponse with no pending code-join state");
2980 return;
2981 }
2982 };
2983 let owner_pub = match crate::crypto::sas::parse_pubkey(&owner_x25519_pubkey_b64) {
2984 Ok(pk) => pk,
2985 Err(e) => {
2986 warn!(%e, "CodeJoinResponse: bad owner pubkey");
2987 return;
2988 }
2989 };
2990 let shared = our_secret.diffie_hellman(&owner_pub);
2991 let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
2992 let mut wrap_key = [0u8; passphrase::KEY_LEN];
2993 hk.expand(b"huddle-code-join-v1", &mut wrap_key)
2994 .expect("32 bytes within HKDF limits");
2995 let session_key_bytes =
2996 match passphrase::unwrap(&wrapped_session_key_b64, &wrap_key) {
2997 Ok(b) => b,
2998 Err(e) => {
2999 warn!(%e, "CodeJoinResponse: unwrap failed");
3000 return;
3001 }
3002 };
3003 let session_key_str = match String::from_utf8(session_key_bytes) {
3004 Ok(s) => s,
3005 Err(e) => {
3006 warn!(%e, "CodeJoinResponse: session key wasn't valid utf8");
3007 return;
3008 }
3009 };
3010 let mut rooms = self.active_rooms.lock().unwrap();
3012 if let Some(room) = rooms.get_mut(room_id) {
3013 if let Some(crypto) = room.crypto.as_mut() {
3014 if let Err(e) =
3015 crypto.add_inbound_session(&owner_fp, &session_key_str)
3016 {
3017 warn!(%e, "CodeJoinResponse: add_inbound_session failed");
3018 } else {
3019 info!(%room_id, %owner_fp, %owner_session_id, "code-join completed; can decrypt owner's messages");
3020 room.members.insert(owner_fp.clone());
3021 let _ = self.app_event_tx.send(AppEvent::MemberJoined {
3022 room_id: room_id.to_string(),
3023 fingerprint: owner_fp,
3024 });
3025 }
3026 }
3027 }
3028 }
3029 RoomMessage::JoinRefused {
3030 room_id: announced_room_id,
3031 target_fingerprint,
3032 reason,
3033 } => {
3034 if announced_room_id != room_id || target_fingerprint != our_fp {
3035 return;
3036 }
3037 let _ = self.app_event_tx.send(AppEvent::Error {
3041 description: format!("join refused: {reason}"),
3042 });
3043 }
3044 RoomMessage::SasConfirm { tx_id, matched } => {
3045 let signer = match verified_signer {
3046 Some(fp) => fp,
3047 None => return,
3048 };
3049 let (room_id_done, partner_fp_done, both_done) = {
3050 let mut flows = self.sas_flows.lock().unwrap();
3051 let flow = match flows.get_mut(&tx_id) {
3052 Some(f) => f,
3053 None => return,
3054 };
3055 if flow.partner_fingerprint != signer {
3056 return;
3057 }
3058 if !matched {
3059 let _ = flow;
3061 flows.remove(&tx_id);
3062 return;
3063 }
3064 flow.their_confirmed = true;
3065 if flow.our_confirmed && flow.their_confirmed {
3066 (
3067 Some(flow.room_id.clone()),
3068 Some(flow.partner_fingerprint.clone()),
3069 true,
3070 )
3071 } else {
3072 (None, None, false)
3073 }
3074 };
3075 if both_done {
3076 if let (Some(rid), Some(pfp)) = (room_id_done, partner_fp_done) {
3077 if let Err(e) = self.finish_sas(&tx_id, &rid, &pfp).await {
3078 warn!(%e, "finish_sas failed");
3079 }
3080 }
3081 }
3082 }
3083 RoomMessage::ProfileUpdate {
3084 sender_fingerprint,
3085 username,
3086 updated_at,
3087 } => {
3088 let signer = match verified_signer {
3094 Some(fp) => fp,
3095 None => {
3096 warn!(
3097 sender = %sender_fingerprint,
3098 "dropping unsigned ProfileUpdate"
3099 );
3100 return;
3101 }
3102 };
3103 if signer != sender_fingerprint {
3104 warn!(
3105 signer = %signer,
3106 claimed = %sender_fingerprint,
3107 "dropping ProfileUpdate with signer != sender"
3108 );
3109 return;
3110 }
3111 if let Err(e) = repo::upsert_peer_profile(
3112 &self.db,
3113 &sender_fingerprint,
3114 username.as_deref(),
3115 updated_at,
3116 ) {
3117 warn!(%e, "upsert_peer_profile failed");
3118 return;
3119 }
3120 let _ = self.app_event_tx.send(AppEvent::PeerProfileUpdated {
3121 fingerprint: sender_fingerprint,
3122 username,
3123 });
3124 }
3125 }
3126 }
3127
3128 pub async fn send_file(&self, room_id: &str, path: &Path) -> Result<String> {
3136 let bytes = std::fs::read(path)?;
3137 let name = path
3138 .file_name()
3139 .map(|n| n.to_string_lossy().to_string())
3140 .unwrap_or_else(|| "untitled".into());
3141 let mime = crate::files::guess_mime(&name);
3142 let original_path = path.to_path_buf();
3143
3144 let (room_encrypted, mut maybe_session_id, encrypted_meta_opt, wire_bytes) = {
3145 let mut rooms = self.active_rooms.lock().unwrap();
3146 let room = rooms
3147 .get_mut(room_id)
3148 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3149 if room.info.encrypted {
3150 let crypto = room
3151 .crypto
3152 .as_mut()
3153 .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
3154 let (ciphertext, meta) = file_encryption::encrypt_file(&bytes, crypto)?;
3155 (true, Some(meta.megolm_session_id.clone()), Some(meta), ciphertext)
3156 } else {
3157 (false, None, None, bytes)
3158 }
3159 };
3160 let _ = &mut maybe_session_id; let plan =
3163 self.file_manager
3164 .prepare_outgoing_from_bytes(&name, mime.clone(), wire_bytes)?;
3165 let file_id = plan.file_id.clone();
3166 let total = plan.chunks.len() as u32;
3167 let our_fp = self.identity.fingerprint().to_string();
3168
3169 let attachment = StoredAttachment {
3170 id: 0,
3171 room_id: room_id.to_string(),
3172 message_id: None,
3173 sender_fingerprint: our_fp.clone(),
3174 file_id: file_id.clone(),
3175 name: name.clone(),
3176 mime: mime.clone(),
3177 size_bytes: plan.size_bytes as i64,
3178 status: AttachmentStatus::Ready,
3179 cache_path: Some(self.file_manager.cache_path(&file_id).to_string_lossy().into()),
3180 saved_path: Some(original_path.to_string_lossy().into()),
3181 error: None,
3182 encrypted: room_encrypted,
3183 wrapped_key: encrypted_meta_opt.as_ref().map(|m| m.wrapped_key_b64.clone()),
3184 nonce: encrypted_meta_opt.as_ref().map(|m| m.nonce_b64.clone()),
3185 megolm_session_id: encrypted_meta_opt
3186 .as_ref()
3187 .map(|m| m.megolm_session_id.clone()),
3188 content_hash: encrypted_meta_opt.as_ref().map(|m| m.content_hash.clone()),
3189 created_at: now_unix(),
3190 };
3191 repo::upsert_attachment(&self.db, &attachment)?;
3192 let _ = self.app_event_tx.send(AppEvent::FileOffered {
3193 room_id: room_id.to_string(),
3194 file_id: file_id.clone(),
3195 name: name.clone(),
3196 size_bytes: plan.size_bytes,
3197 sender_fingerprint: our_fp.clone(),
3198 });
3199
3200 let offer = RoomMessage::FileOffer {
3202 sender_fingerprint: our_fp.clone(),
3203 file_id: file_id.clone(),
3204 name,
3205 size_bytes: plan.size_bytes,
3206 mime,
3207 chunk_count: total,
3208 encrypted_meta: encrypted_meta_opt,
3209 };
3210 if let Ok(bytes) = encode_wire(&offer) {
3211 self.network
3212 .publish_room_message(room_id.to_string(), bytes)
3213 .await;
3214 }
3215
3216 let net = self.network.clone();
3219 let room = room_id.to_string();
3220 let our = our_fp.clone();
3221 let fid = file_id.clone();
3222 let chunks = plan.chunks.clone();
3223 tokio::spawn(async move {
3224 for (i, data) in chunks.iter().enumerate() {
3225 let msg = RoomMessage::FileChunk {
3226 sender_fingerprint: our.clone(),
3227 file_id: fid.clone(),
3228 chunk_index: i as u32,
3229 total_chunks: total,
3230 data_b64: B64.encode(data),
3231 };
3232 if let Ok(bytes) = encode_wire(&msg) {
3233 net.publish_room_message(room.clone(), bytes).await;
3234 }
3235 tokio::time::sleep(Duration::from_millis(40)).await;
3236 }
3237 });
3238
3239 Ok(file_id)
3240 }
3241
3242 pub async fn save_to_downloads(&self, room_id: &str, file_id: &str) -> Result<PathBuf> {
3245 let attachment = repo::get_attachment(&self.db, room_id, file_id)?
3246 .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
3247 if !matches!(
3248 attachment.status,
3249 AttachmentStatus::Ready | AttachmentStatus::Saved
3250 ) {
3251 return Err(HuddleError::Other(format!(
3252 "attachment is not ready (status={})",
3253 attachment.status.as_str()
3254 )));
3255 }
3256 let plaintext = if attachment.encrypted
3261 && attachment.sender_fingerprint == self.identity.fingerprint()
3262 {
3263 match attachment
3264 .saved_path
3265 .as_deref()
3266 .filter(|p| Path::new(p).exists())
3267 {
3268 Some(src) => std::fs::read(src)?,
3269 None => {
3270 return Err(HuddleError::Other(
3271 "your original file has moved or been deleted — it can't be \
3272 recovered from the encrypted cache"
3273 .into(),
3274 ));
3275 }
3276 }
3277 } else {
3278 let cached = self.file_manager.read_cache(file_id)?;
3279 if attachment.encrypted {
3280 let meta = EncryptedFileMeta {
3281 megolm_session_id: attachment
3282 .megolm_session_id
3283 .clone()
3284 .ok_or_else(|| HuddleError::Other("missing megolm_session_id".into()))?,
3285 wrapped_key_b64: attachment
3286 .wrapped_key
3287 .clone()
3288 .ok_or_else(|| HuddleError::Other("missing wrapped_key".into()))?,
3289 nonce_b64: attachment
3290 .nonce
3291 .clone()
3292 .ok_or_else(|| HuddleError::Other("missing nonce".into()))?,
3293 content_hash: attachment
3294 .content_hash
3295 .clone()
3296 .ok_or_else(|| HuddleError::Other("missing content_hash".into()))?,
3297 };
3298 self.decrypt_attachment(
3299 room_id,
3300 &attachment.sender_fingerprint,
3301 &cached,
3302 &meta,
3303 )?
3304 } else {
3305 cached
3306 }
3307 };
3308 let saved = self.file_manager.write_to_downloads(&attachment.name, &plaintext)?;
3309 repo::update_attachment_paths(
3310 &self.db,
3311 room_id,
3312 file_id,
3313 None,
3314 Some(&saved.to_string_lossy()),
3315 )?;
3316 repo::update_attachment_status(&self.db, room_id, file_id, AttachmentStatus::Saved, None)?;
3317 let _ = self.app_event_tx.send(AppEvent::FileSaved {
3318 file_id: file_id.into(),
3319 path: saved.to_string_lossy().into(),
3320 });
3321 Ok(saved)
3322 }
3323
3324 pub async fn cancel_transfer(&self, room_id: &str, file_id: &str) -> Result<()> {
3326 self.file_manager.cancel_incoming(file_id);
3327 repo::update_attachment_status(
3328 &self.db,
3329 room_id,
3330 file_id,
3331 AttachmentStatus::Cancelled,
3332 None,
3333 )?;
3334 Ok(())
3335 }
3336
3337 pub fn open_saved(&self, room_id: &str, file_id: &str) -> Result<()> {
3339 let attachment = repo::get_attachment(&self.db, room_id, file_id)?
3340 .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
3341 let path = attachment
3342 .saved_path
3343 .ok_or_else(|| HuddleError::Other("not saved yet — press Enter to save first".into()))?;
3344 open_with_system(&path)
3345 }
3346
3347 pub fn list_room_attachments(&self, room_id: &str) -> Result<Vec<StoredAttachment>> {
3348 repo::list_room_attachments(&self.db, room_id)
3349 }
3350
3351 pub fn set_member_verified(
3355 &self,
3356 room_id: &str,
3357 fingerprint: &str,
3358 verified: bool,
3359 ) -> Result<()> {
3360 let members = repo::list_room_members(&self.db, room_id).unwrap_or_default();
3365 if !members.iter().any(|m| m.fingerprint == fingerprint) {
3366 repo::upsert_room_member(
3367 &self.db,
3368 &StoredRoomMember {
3369 room_id: room_id.to_string(),
3370 peer_id: String::new(),
3371 fingerprint: fingerprint.to_string(),
3372 last_seen: Some(now_unix()),
3373 verified,
3374 ed25519_pubkey: None,
3375 role: "member".into(),
3376 },
3377 )?;
3378 }
3379 repo::set_member_verified(&self.db, room_id, fingerprint, verified)
3380 }
3381
3382 pub fn verified_fingerprints(&self, room_id: &str) -> Vec<String> {
3383 repo::list_verified_fingerprints(&self.db, room_id).unwrap_or_default()
3384 }
3385
3386 pub fn is_owner(&self, room_id: &str, fingerprint: &str) -> bool {
3389 repo::list_room_owners(&self.db, room_id)
3390 .unwrap_or_default()
3391 .iter()
3392 .any(|fp| fp == fingerprint)
3393 }
3394
3395 pub fn we_are_owner(&self, room_id: &str) -> bool {
3396 self.is_owner(room_id, &self.identity.fingerprint().to_string())
3397 }
3398
3399 pub fn room_owners(&self, room_id: &str) -> Vec<String> {
3402 repo::list_room_owners(&self.db, room_id).unwrap_or_default()
3403 }
3404
3405 pub fn has_master_passphrase(&self) -> bool {
3411 self.session_persist_key != [0u8; 32]
3412 }
3413
3414 pub fn verified_only_inbound(&self) -> bool {
3417 repo::get_setting(&self.db, "verified_only_inbound")
3418 .unwrap_or(None)
3419 .map(|v| v == "1")
3420 .unwrap_or(false)
3421 }
3422
3423 pub fn set_verified_only_inbound(&self, on: bool) -> Result<()> {
3424 repo::set_setting(&self.db, "verified_only_inbound", if on { "1" } else { "0" })
3425 }
3426
3427 pub fn room_verified_only(&self, room_id: &str) -> bool {
3432 repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
3433 }
3434
3435 pub fn set_room_verified_only(&self, room_id: &str, on: bool) -> Result<()> {
3436 repo::set_room_verified_only(&self.db, room_id, on)
3437 }
3438
3439 pub fn onboarding_seen(&self) -> bool {
3441 repo::is_onboarding_seen(&self.db).unwrap_or(true)
3442 }
3443
3444 pub fn mark_onboarding_seen(&self) -> Result<()> {
3445 repo::mark_onboarding_seen(&self.db)
3446 }
3447
3448 pub fn last_seen_onboarding_version(&self) -> Option<String> {
3452 repo::get_last_seen_onboarding_version(&self.db).unwrap_or(None)
3453 }
3454
3455 pub fn set_last_seen_onboarding_version(&self, version: &str) -> Result<()> {
3456 repo::set_last_seen_onboarding_version(&self.db, version)
3457 }
3458
3459 pub fn update_check_enabled(&self) -> Option<bool> {
3462 repo::get_update_check_enabled(&self.db).unwrap_or(None)
3463 }
3464
3465 pub fn set_update_check_enabled(&self, enabled: bool) -> Result<()> {
3466 repo::set_update_check_enabled(&self.db, enabled)
3467 }
3468
3469 pub fn last_update_check_at(&self) -> i64 {
3472 repo::get_setting(&self.db, "last_update_check_at")
3473 .ok()
3474 .flatten()
3475 .and_then(|s| s.parse().ok())
3476 .unwrap_or(0)
3477 }
3478
3479 pub fn set_last_update_check_at(&self, ts: i64) -> Result<()> {
3480 repo::set_setting(&self.db, "last_update_check_at", &ts.to_string())
3481 }
3482
3483 pub fn last_known_remote_version(&self) -> Option<String> {
3487 repo::get_setting(&self.db, "last_known_remote_version")
3488 .ok()
3489 .flatten()
3490 }
3491
3492 pub fn set_last_known_remote_version(&self, v: &str) -> Result<()> {
3493 repo::set_setting(&self.db, "last_known_remote_version", v)
3494 }
3495
3496 pub async fn grant_owner(&self, room_id: &str, target_fingerprint: &str) -> Result<()> {
3500 let our_fp = self.identity.fingerprint().to_string();
3501 if !self.is_owner(room_id, &our_fp) {
3502 return Err(HuddleError::Other(
3503 "only an owner can grant owner".into(),
3504 ));
3505 }
3506 let msg = RoomMessage::OwnerGrant {
3507 room_id: room_id.to_string(),
3508 target_fingerprint: target_fingerprint.to_string(),
3509 };
3510 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3511 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3512 self.network
3513 .publish_room_message(room_id.to_string(), bytes)
3514 .await;
3515 repo::set_member_role(&self.db, room_id, target_fingerprint, "owner")?;
3517 Ok(())
3518 }
3519
3520 pub async fn kick_member(
3531 &self,
3532 room_id: &str,
3533 target_fingerprint: &str,
3534 ) -> Result<String> {
3535 let our_fp = self.identity.fingerprint().to_string();
3536 if !self.is_owner(room_id, &our_fp) {
3537 return Err(HuddleError::Other("only an owner can kick".into()));
3538 }
3539 if target_fingerprint == our_fp {
3540 return Err(HuddleError::Other("can't kick yourself".into()));
3541 }
3542 let info = self
3543 .active_rooms
3544 .lock()
3545 .unwrap()
3546 .get(room_id)
3547 .map(|r| r.info.clone())
3548 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3549 if !info.encrypted {
3550 let msg = RoomMessage::BanMember {
3554 room_id: room_id.to_string(),
3555 target_fingerprint: target_fingerprint.to_string(),
3556 };
3557 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3558 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3559 self.network
3560 .publish_room_message(room_id.to_string(), bytes)
3561 .await;
3562 repo::add_room_ban(
3563 &self.db,
3564 room_id,
3565 target_fingerprint,
3566 &our_fp,
3567 &env.signature_b64,
3568 now_unix(),
3569 )?;
3570 self.evict_banned_member(room_id, target_fingerprint);
3571 return Ok(String::new());
3572 }
3573 let new_passphrase = generate_join_passphrase();
3575 let msg = RoomMessage::BanMember {
3576 room_id: room_id.to_string(),
3577 target_fingerprint: target_fingerprint.to_string(),
3578 };
3579 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3580 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3581 self.network
3582 .publish_room_message(room_id.to_string(), bytes)
3583 .await;
3584 repo::add_room_ban(
3585 &self.db,
3586 room_id,
3587 target_fingerprint,
3588 &our_fp,
3589 &env.signature_b64,
3590 now_unix(),
3591 )?;
3592 self.evict_banned_member(room_id, target_fingerprint);
3593 self.rotate_room(room_id, &new_passphrase).await?;
3596 Ok(new_passphrase)
3597 }
3598
3599 pub fn generate_join_code(&self, room_id: &str) -> Result<String> {
3606 let our_fp = self.identity.fingerprint().to_string();
3607 if !self.is_owner(room_id, &our_fp) {
3608 return Err(HuddleError::Other(
3609 "only an owner can issue join codes".into(),
3610 ));
3611 }
3612 let code = generate_alphanumeric_code(8);
3613 let expires_at = now_unix() + 10 * 60;
3614 let mut rooms = self.active_rooms.lock().unwrap();
3615 let room = rooms
3616 .get_mut(room_id)
3617 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3618 let now = now_unix();
3620 room.issued_codes.retain(|(_, exp)| *exp > now);
3621 room.issued_codes.push((code.clone(), expires_at));
3622 Ok(code)
3623 }
3624
3625 pub async fn join_room_with_code(
3632 &self,
3633 room_id: &str,
3634 code: &str,
3635 ) -> Result<()> {
3636 let info = {
3638 let d = self.discovered_rooms.lock().unwrap().get(room_id).cloned();
3639 match d {
3640 Some(d) => StoredRoom {
3641 id: room_id.to_string(),
3642 name: d.name,
3643 creator_fingerprint: d.creator_fingerprint,
3644 encrypted: d.encrypted,
3645 passphrase_salt: None, created_at: now_unix(),
3647 last_active: Some(now_unix()),
3648 kind: d.kind,
3651 },
3652 None => {
3653 return Err(HuddleError::Other(format!(
3654 "room {room_id} not visible — wait for an announcement"
3655 )))
3656 }
3657 }
3658 };
3659 if !info.encrypted {
3660 return Err(HuddleError::Other(
3661 "code-join only applies to encrypted rooms".into(),
3662 ));
3663 }
3664 let our_fp = self.identity.fingerprint().to_string();
3665 use x25519_dalek::{PublicKey, StaticSecret};
3668 let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
3669 let our_pub = PublicKey::from(&our_secret);
3670 let key = (room_id.to_string(), our_fp.clone());
3675 self.pending_code_secrets
3676 .lock()
3677 .unwrap()
3678 .insert(key.clone(), our_secret);
3679 let map = self.pending_code_secrets.clone();
3684 let tx = self.app_event_tx.clone();
3685 let timeout_room = room_id.to_string();
3686 tokio::spawn(async move {
3687 tokio::time::sleep(std::time::Duration::from_secs(30)).await;
3688 let still_pending = map.lock().unwrap().remove(&key).is_some();
3689 if still_pending {
3690 let _ = tx.send(AppEvent::CodeJoinTimedOut {
3691 room_id: timeout_room,
3692 reason: "no response from owner — code may be wrong or expired".into(),
3693 });
3694 }
3695 });
3696 repo::insert_room(&self.db, &info)?;
3703 self.active_rooms.lock().unwrap().insert(
3706 room_id.to_string(),
3707 ActiveRoom {
3708 info: info.clone(),
3709 crypto: Some(RoomCrypto::new_for_room(
3710 self.db.clone(),
3711 room_id.to_string(),
3712 our_fp.clone(),
3713 self.session_persist_key,
3714 )?),
3715 passphrase_key: None,
3716 members: {
3717 let mut s = HashSet::new();
3718 s.insert(our_fp.clone());
3719 s
3720 },
3721 typers: HashMap::new(),
3722 read_only: true,
3723 issued_codes: Vec::new(),
3724 },
3725 );
3726 self.network.subscribe_room(room_id.to_string()).await;
3727 let req = RoomMessage::CodeJoinRequest {
3729 room_id: room_id.to_string(),
3730 joiner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
3731 code: code.to_string(),
3732 };
3733 let env = crate::crypto::sign_message(&self.identity, &req)?;
3734 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3735 self.network
3736 .publish_room_message(room_id.to_string(), bytes)
3737 .await;
3738 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
3741 room_id: room_id.to_string(),
3742 });
3743 Ok(())
3744 }
3745
3746 pub async fn sas_start(&self, room_id: &str, target_fingerprint: &str) -> Result<String> {
3752 let (tx_id_bytes, our_secret, our_pub) = crate::crypto::sas::new_session();
3753 let tx_id = B64.encode(tx_id_bytes);
3754 let msg = RoomMessage::SasInit {
3755 tx_id: tx_id.clone(),
3756 ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
3757 target_fingerprint: target_fingerprint.to_string(),
3758 };
3759 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3760 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3761 self.sas_flows.lock().unwrap().insert(
3762 tx_id.clone(),
3763 SasFlow {
3764 room_id: room_id.to_string(),
3765 partner_fingerprint: target_fingerprint.to_string(),
3766 our_secret,
3767 sas_code: None,
3768 our_confirmed: false,
3769 their_confirmed: false,
3770 },
3771 );
3772 self.network
3773 .publish_room_message(room_id.to_string(), bytes)
3774 .await;
3775 Ok(tx_id)
3776 }
3777
3778 pub async fn sas_match(&self, tx_id: &str) -> Result<()> {
3782 let (room_id, partner_fp, both_done) = {
3783 let mut flows = self.sas_flows.lock().unwrap();
3784 let flow = flows
3785 .get_mut(tx_id)
3786 .ok_or_else(|| HuddleError::Other("unknown SAS tx_id".into()))?;
3787 flow.our_confirmed = true;
3788 (
3789 flow.room_id.clone(),
3790 flow.partner_fingerprint.clone(),
3791 flow.our_confirmed && flow.their_confirmed,
3792 )
3793 };
3794 let msg = RoomMessage::SasConfirm {
3795 tx_id: tx_id.to_string(),
3796 matched: true,
3797 };
3798 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3799 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3800 self.network
3801 .publish_room_message(room_id.clone(), bytes)
3802 .await;
3803 if both_done {
3804 self.finish_sas(tx_id, &room_id, &partner_fp).await?;
3805 }
3806 Ok(())
3807 }
3808
3809 pub fn sas_cancel(&self, tx_id: &str) {
3813 self.sas_flows.lock().unwrap().remove(tx_id);
3814 }
3815
3816 async fn finish_sas(
3819 &self,
3820 tx_id: &str,
3821 room_id: &str,
3822 partner_fingerprint: &str,
3823 ) -> Result<()> {
3824 repo::set_member_verified(&self.db, room_id, partner_fingerprint, true)?;
3825 repo::add_verified_peer(&self.db, partner_fingerprint, now_unix())?;
3826 self.sas_flows.lock().unwrap().remove(tx_id);
3827 let _ = self.app_event_tx.send(AppEvent::SasVerified {
3828 room_id: room_id.to_string(),
3829 partner_fingerprint: partner_fingerprint.to_string(),
3830 });
3831 Ok(())
3832 }
3833
3834 fn evict_banned_member(&self, room_id: &str, fingerprint: &str) {
3839 if let Some(room) = self.active_rooms.lock().unwrap().get_mut(room_id) {
3840 room.members.remove(fingerprint);
3841 }
3842 let _ = self.app_event_tx.send(AppEvent::MemberLeft {
3843 room_id: room_id.to_string(),
3844 fingerprint: fingerprint.to_string(),
3845 });
3846 }
3847
3848 pub fn display_name(&self) -> Option<String> {
3849 repo::get_display_name(&self.db).unwrap_or(None)
3850 }
3851
3852 pub fn set_display_name(&self, name: Option<&str>) -> Result<()> {
3853 repo::set_display_name(&self.db, name)
3854 }
3855
3856 pub async fn set_username(&self, name: Option<&str>) -> Result<()> {
3862 repo::set_display_name(&self.db, name)?;
3863 let msg = RoomMessage::ProfileUpdate {
3864 sender_fingerprint: self.identity.fingerprint().to_string(),
3865 username: name.map(|s| s.to_string()),
3866 updated_at: now_unix_ms(),
3867 };
3868 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3869 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3870 let rooms: Vec<String> = self.active_rooms.lock().unwrap().keys().cloned().collect();
3871 for room_id in rooms {
3872 self.network
3873 .publish_room_message(room_id, bytes.clone())
3874 .await;
3875 }
3876 Ok(())
3877 }
3878
3879 pub fn lookup_username(&self, fingerprint: &str) -> Option<String> {
3884 repo::get_peer_username(&self.db, fingerprint).unwrap_or(None)
3885 }
3886
3887 pub fn lookup_member_display_name(&self, fingerprint: &str) -> Option<String> {
3891 self.lookup_username(fingerprint)
3892 }
3893
3894 pub fn is_room_muted(&self, room_id: &str) -> bool {
3895 repo::is_room_muted(&self.db, room_id).unwrap_or(false)
3896 }
3897
3898 pub fn list_room_bans(&self, room_id: &str) -> Vec<String> {
3903 repo::list_room_bans(&self.db, room_id).unwrap_or_default()
3904 }
3905
3906 pub fn list_verified_peers(&self) -> Vec<String> {
3912 repo::list_verified_peers(&self.db).unwrap_or_default()
3913 }
3914
3915 pub fn list_blocked_peers(&self) -> Vec<String> {
3916 repo::list_blocked_peers(&self.db).unwrap_or_default()
3917 }
3918
3919 pub fn unblock_peer(&self, fingerprint: &str) -> Result<()> {
3923 repo::unblock_peer(&self.db, fingerprint)
3924 }
3925
3926 pub fn block_peer(&self, fingerprint: &str) -> Result<()> {
3930 repo::block_peer(&self.db, fingerprint, now_unix())
3931 }
3932
3933 pub fn is_room_read_only(&self, room_id: &str) -> bool {
3939 self.active_rooms
3940 .lock()
3941 .unwrap()
3942 .get(room_id)
3943 .map(|r| r.read_only)
3944 .unwrap_or(false)
3945 }
3946
3947 pub fn set_room_muted(&self, room_id: &str, muted: bool) -> Result<()> {
3948 repo::set_room_muted(&self.db, room_id, muted)
3949 }
3950
3951 pub async fn broadcast_typing(&self, room_id: &str) {
3954 if !self.active_rooms.lock().unwrap().contains_key(room_id) {
3955 return;
3956 }
3957 let msg = RoomMessage::Typing {
3958 sender_fingerprint: self.identity.fingerprint().to_string(),
3959 };
3960 if let Ok(bytes) = encode_wire(&msg) {
3961 self.network
3962 .publish_room_message(room_id.to_string(), bytes)
3963 .await;
3964 }
3965 }
3966
3967 pub fn typers_in_room(&self, room_id: &str) -> Vec<String> {
3970 let now = now_unix();
3971 let mut rooms = self.active_rooms.lock().unwrap();
3972 let room = match rooms.get_mut(room_id) {
3973 Some(r) => r,
3974 None => return Vec::new(),
3975 };
3976 room.typers.retain(|_, exp| *exp > now);
3977 let mut v: Vec<String> = room.typers.keys().cloned().collect();
3978 v.sort();
3979 v
3980 }
3981
3982 pub async fn rotate_room(&self, room_id: &str, new_passphrase: &str) -> Result<()> {
3992 if new_passphrase.is_empty() {
3993 return Err(HuddleError::Other("new passphrase is empty".into()));
3994 }
3995 let new_salt = passphrase::random_salt();
3996 let new_key = passphrase::derive_key(new_passphrase, &new_salt)?;
3997
3998 let info = {
3999 let mut rooms = self.active_rooms.lock().unwrap();
4000 let room = rooms
4001 .get_mut(room_id)
4002 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
4003 if !room.info.encrypted {
4004 return Err(HuddleError::Other(
4005 "rotation only applies to encrypted rooms".into(),
4006 ));
4007 }
4008 let new_crypto = RoomCrypto::new_for_room(
4010 self.db.clone(),
4011 room_id.to_string(),
4012 self.identity.fingerprint().to_string(),
4013 self.session_persist_key,
4014 )?;
4015 room.crypto = Some(new_crypto);
4016 room.passphrase_key = Some(new_key);
4017 room.info.passphrase_salt = Some(new_salt.to_vec());
4018 room.info.clone()
4019 };
4020
4021 let rot = RoomMessage::RotateRoomKey {
4027 rotator_fingerprint: self.identity.fingerprint().to_string(),
4028 new_salt: new_salt.to_vec(),
4029 };
4030 if let Ok(env) = crate::crypto::sign_message(&self.identity, &rot) {
4034 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
4035 self.network
4036 .publish_room_message(room_id.to_string(), bytes)
4037 .await;
4038 }
4039 }
4040 if let Err(e) = self.broadcast_member_announce(room_id).await {
4042 warn!(%e, "rotate: broadcast announce failed");
4043 }
4044
4045 repo::insert_room(&self.db, &info)?;
4047 Ok(())
4048 }
4049
4050 pub async fn accept_rotation(
4054 &self,
4055 room_id: &str,
4056 new_salt: &[u8],
4057 new_passphrase: &str,
4058 ) -> Result<()> {
4059 let new_key = passphrase::derive_key(new_passphrase, new_salt)?;
4060 let info = {
4061 let mut rooms = self.active_rooms.lock().unwrap();
4062 let room = rooms
4063 .get_mut(room_id)
4064 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
4065 room.passphrase_key = Some(new_key);
4066 room.info.passphrase_salt = Some(new_salt.to_vec());
4067 room.info.clone()
4068 };
4069 let req = RoomMessage::SessionKeyRequest {
4073 requester_fingerprint: self.identity.fingerprint().to_string(),
4074 };
4075 if let Ok(bytes) = encode_wire(&req) {
4076 self.network
4077 .publish_room_message(room_id.to_string(), bytes)
4078 .await;
4079 }
4080 repo::insert_room(&self.db, &info)?;
4081 Ok(())
4082 }
4083
4084 #[allow(clippy::too_many_arguments)]
4089 fn handle_file_offer(
4090 &self,
4091 room_id: &str,
4092 sender_fingerprint: String,
4093 file_id: String,
4094 name: String,
4095 size_bytes: u64,
4096 mime: Option<String>,
4097 _chunk_count: u32,
4098 encrypted_meta: Option<EncryptedFileMeta>,
4099 ) {
4100 let encrypted = encrypted_meta.is_some();
4101 let attachment = StoredAttachment {
4102 id: 0,
4103 room_id: room_id.to_string(),
4104 message_id: None,
4105 sender_fingerprint: sender_fingerprint.clone(),
4106 file_id: file_id.clone(),
4107 name: name.clone(),
4108 mime,
4109 size_bytes: size_bytes as i64,
4110 status: AttachmentStatus::Offered,
4111 cache_path: None,
4112 saved_path: None,
4113 error: None,
4114 encrypted,
4115 wrapped_key: encrypted_meta.as_ref().map(|m| m.wrapped_key_b64.clone()),
4116 nonce: encrypted_meta.as_ref().map(|m| m.nonce_b64.clone()),
4117 megolm_session_id: encrypted_meta.as_ref().map(|m| m.megolm_session_id.clone()),
4118 content_hash: encrypted_meta.as_ref().map(|m| m.content_hash.clone()),
4119 created_at: now_unix(),
4120 };
4121 if let Err(e) = repo::upsert_attachment(&self.db, &attachment) {
4122 warn!(%e, "upsert attachment");
4123 return;
4124 }
4125 self.file_manager.set_expected_size(&file_id, size_bytes);
4128 let _ = self.app_event_tx.send(AppEvent::FileOffered {
4129 room_id: room_id.to_string(),
4130 file_id,
4131 name,
4132 size_bytes,
4133 sender_fingerprint,
4134 });
4135 }
4136
4137 fn handle_file_chunk(
4138 &self,
4139 room_id: &str,
4140 _sender_fingerprint: String,
4141 file_id: String,
4142 chunk_index: u32,
4143 total_chunks: u32,
4144 data_b64: String,
4145 ) {
4146 let data = match B64.decode(&data_b64) {
4147 Ok(d) => d,
4148 Err(e) => {
4149 warn!(%e, "bad chunk base64");
4150 return;
4151 }
4152 };
4153 let expected_size = match repo::get_attachment(&self.db, room_id, &file_id) {
4157 Ok(Some(a)) => {
4158 if matches!(
4159 a.status,
4160 AttachmentStatus::Cancelled | AttachmentStatus::Failed
4161 ) {
4162 return;
4163 }
4164 a.size_bytes as u64
4165 }
4166 Ok(None) => crate::files::MAX_FILE_SIZE,
4167 Err(e) => {
4168 warn!(%e, "get attachment for chunk");
4169 crate::files::MAX_FILE_SIZE
4170 }
4171 };
4172
4173 let result = self.file_manager.accept_chunk(
4174 &file_id,
4175 chunk_index,
4176 total_chunks,
4177 data,
4178 expected_size,
4179 );
4180 match result {
4181 Ok(None) => {
4182 let _ = repo::update_attachment_status(
4184 &self.db,
4185 room_id,
4186 &file_id,
4187 AttachmentStatus::Downloading,
4188 None,
4189 );
4190 let bytes_so_far = self
4193 .file_manager
4194 .progress(&file_id)
4195 .map(|(b, _)| b)
4196 .unwrap_or(0);
4197 let _ = self.app_event_tx.send(AppEvent::FileProgress {
4198 file_id: file_id.clone(),
4199 bytes_received: bytes_so_far,
4200 total_bytes: expected_size,
4201 });
4202 }
4203 Ok(Some(completed)) => {
4204 let _ = repo::update_attachment_paths(
4205 &self.db,
4206 room_id,
4207 &file_id,
4208 Some(&completed.cache_path.to_string_lossy()),
4209 None,
4210 );
4211 let _ = repo::update_attachment_status(
4212 &self.db,
4213 room_id,
4214 &file_id,
4215 AttachmentStatus::Ready,
4216 None,
4217 );
4218 let _ = self.app_event_tx.send(AppEvent::FileReady {
4219 file_id: file_id.clone(),
4220 });
4221 }
4222 Err(e) => {
4223 let msg = e.to_string();
4224 warn!(%msg, "chunk processing failed");
4225 let _ = repo::update_attachment_status(
4226 &self.db,
4227 room_id,
4228 &file_id,
4229 AttachmentStatus::Failed,
4230 Some(&msg),
4231 );
4232 let _ = self.app_event_tx.send(AppEvent::FileFailed {
4233 file_id: file_id.clone(),
4234 reason: msg,
4235 });
4236 }
4237 }
4238 }
4239
4240 fn maybe_emit_mention(&self, room_id: &str, body: &str) {
4243 let full = self.identity.fingerprint().to_lowercase();
4244 let short: &str = full.split('-').next().unwrap_or(&full);
4246 let lower = body.to_lowercase();
4247 let hit = lower.contains(full.as_str())
4251 || lower
4252 .split(|c: char| !c.is_ascii_hexdigit())
4253 .any(|tok| tok == short);
4254 if hit {
4255 let _ = self.app_event_tx.send(AppEvent::MentionReceived {
4256 room_id: room_id.to_string(),
4257 body: body.to_string(),
4258 });
4259 }
4260 }
4261
4262 fn decrypt_attachment(
4263 &self,
4264 room_id: &str,
4265 sender_fingerprint: &str,
4266 ciphertext: &[u8],
4267 meta: &EncryptedFileMeta,
4268 ) -> Result<Vec<u8>> {
4269 let mut rooms = self.active_rooms.lock().unwrap();
4270 let room = rooms
4271 .get_mut(room_id)
4272 .ok_or_else(|| HuddleError::Other("not in room".into()))?;
4273 let crypto = room
4274 .crypto
4275 .as_mut()
4276 .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
4277 file_encryption::decrypt_file(ciphertext, meta, crypto, sender_fingerprint)
4278 }
4279
4280 pub async fn go_dark(&self, master_passphrase: &str) -> Result<()> {
4292 let no_master = self.session_persist_key == [0u8; 32];
4293 if !no_master {
4294 let salt = storage::keychain::load_or_create_salt()?;
4295 let candidate_master =
4296 storage::keychain::derive_master_key(master_passphrase, &salt)?;
4297 let candidate_subkey =
4298 storage::keychain::derive_subkey(&candidate_master, b"megolm-persist");
4299 if !ct_eq_32(&candidate_subkey, &self.session_persist_key) {
4300 return Err(HuddleError::Other(
4301 "incorrect master passphrase".into(),
4302 ));
4303 }
4304 }
4305
4306 let room_ids: Vec<String> = self
4307 .active_rooms
4308 .lock()
4309 .unwrap()
4310 .keys()
4311 .cloned()
4312 .collect();
4313 let _ = tokio::time::timeout(Duration::from_secs(2), async {
4314 for room_id in &room_ids {
4315 if let Err(e) = self.leave_room(room_id).await {
4316 warn!(%room_id, %e, "go_dark: leave_room failed");
4317 }
4318 }
4319 })
4320 .await;
4321
4322 self.network.shutdown().await;
4323 tokio::time::sleep(Duration::from_millis(300)).await;
4324
4325 let data_dir = config::data_dir();
4326 let candidates = [
4327 "huddle.db",
4328 "huddle.db-shm",
4329 "huddle.db-wal",
4330 "keychain.salt",
4331 "huddle.log",
4332 "config.toml",
4333 ];
4334 for name in &candidates {
4335 let path = data_dir.join(name);
4336 wipe_file(&path);
4337 }
4338 if let Ok(read) = std::fs::read_dir(&data_dir) {
4339 for entry in read.flatten() {
4340 if let Some(name) = entry.file_name().to_str() {
4341 if name.starts_with("huddle.log.") {
4342 wipe_file(&entry.path());
4343 }
4344 }
4345 }
4346 }
4347 let files_dir = data_dir.join("files");
4351 if let Ok(read) = std::fs::read_dir(&files_dir) {
4352 for entry in read.flatten() {
4353 let path = entry.path();
4354 if path.is_file() {
4355 wipe_file(&path);
4356 } else if path.is_dir() {
4357 if let Ok(inner) = std::fs::read_dir(&path) {
4360 for inner_entry in inner.flatten() {
4361 if inner_entry.path().is_file() {
4362 wipe_file(&inner_entry.path());
4363 }
4364 }
4365 }
4366 let _ = std::fs::remove_dir(&path);
4367 }
4368 }
4369 }
4370 let _ = std::fs::remove_dir(&files_dir);
4371 let _ = std::fs::remove_dir(&data_dir);
4372
4373 let _ = self.app_event_tx.send(AppEvent::WentDark);
4374 Ok(())
4375 }
4376}
4377
4378pub fn normalize_to_fingerprint(input: &str) -> Option<String> {
4385 let s = input
4386 .trim()
4387 .trim_start_matches("HD-")
4388 .trim_start_matches("hd-")
4389 .to_string();
4390 let hex_only: String = s.chars().filter(|c| *c != '-').collect();
4391 if hex_only.len() != 24 || !hex_only.chars().all(|c| c.is_ascii_hexdigit()) {
4392 return None;
4393 }
4394 let lower = hex_only.to_ascii_lowercase();
4395 let chunks: Vec<String> = lower
4396 .as_bytes()
4397 .chunks(4)
4398 .map(|c| std::str::from_utf8(c).unwrap().to_string())
4399 .collect();
4400 Some(chunks.join("-"))
4401}
4402
4403fn address_preference(addr: &str) -> u8 {
4409 if addr.contains("/p2p-circuit") {
4410 return 9; }
4412 if let Some(rest) = addr.strip_prefix("/ip4/") {
4413 if let Some(ip_str) = rest.split('/').next() {
4414 if let Ok(ip) = ip_str.parse::<std::net::Ipv4Addr>() {
4415 if ip.is_loopback() {
4416 return 1; }
4418 if is_rfc1918(&ip) || ip.is_link_local() {
4419 return 0; }
4421 return 3; }
4423 }
4424 return 3;
4425 }
4426 if addr.starts_with("/ip6/") {
4427 return 4;
4428 }
4429 if addr.starts_with("/dns4/") || addr.starts_with("/dns6/") || addr.starts_with("/dnsaddr/") {
4430 return 5;
4431 }
4432 7
4433}
4434
4435fn is_rfc1918(ip: &std::net::Ipv4Addr) -> bool {
4439 let octets = ip.octets();
4440 octets[0] == 10
4441 || (octets[0] == 172 && (16..=31).contains(&octets[1]))
4442 || (octets[0] == 192 && octets[1] == 168)
4443}
4444
4445fn short_fp_for_msg(fingerprint: &str) -> String {
4449 let head: String = fingerprint
4450 .chars()
4451 .filter(|c| *c != '-')
4452 .take(4)
4453 .collect::<String>()
4454 .to_ascii_uppercase();
4455 format!("HD-{}…", head)
4456}
4457
4458fn ct_eq_32(a: &[u8; 32], b: &[u8; 32]) -> bool {
4462 let mut diff = 0u8;
4463 for i in 0..32 {
4464 diff |= a[i] ^ b[i];
4465 }
4466 diff == 0
4467}
4468
4469fn wipe_file(path: &Path) {
4473 use std::io::Write;
4474 if let Ok(meta) = std::fs::metadata(path) {
4475 if let Ok(mut f) = std::fs::OpenOptions::new().write(true).open(path) {
4476 let zeros = vec![0u8; meta.len() as usize];
4477 let _ = f.write_all(&zeros);
4478 let _ = f.sync_all();
4479 }
4480 }
4481 if let Err(e) = std::fs::remove_file(path) {
4482 if e.kind() != std::io::ErrorKind::NotFound {
4483 warn!(?path, %e, "wipe_file: remove failed");
4484 }
4485 }
4486}
4487
4488fn open_with_system(path: &str) -> Result<()> {
4490 #[cfg(target_os = "macos")]
4491 let cmd = "open";
4492 #[cfg(target_os = "linux")]
4493 let cmd = "xdg-open";
4494 #[cfg(target_os = "windows")]
4495 let cmd = "cmd";
4496 #[cfg(target_os = "windows")]
4497 let args = vec!["/C", "start", "", path];
4498 #[cfg(not(target_os = "windows"))]
4499 let args = vec![path];
4500
4501 std::process::Command::new(cmd)
4502 .args(args)
4503 .spawn()
4504 .map_err(|e| HuddleError::Other(format!("spawn opener: {e}")))?;
4505 Ok(())
4506}
4507
4508static ROOM_SALT_CACHE: std::sync::LazyLock<Mutex<HashMap<String, Vec<u8>>>> =
4511 std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
4512
4513pub fn salt_len() -> usize {
4518 SALT_LEN
4519}
4520
4521fn now_unix() -> i64 {
4522 SystemTime::now()
4523 .duration_since(UNIX_EPOCH)
4524 .unwrap()
4525 .as_secs() as i64
4526}
4527
4528fn now_unix_ms() -> i64 {
4529 SystemTime::now()
4530 .duration_since(UNIX_EPOCH)
4531 .unwrap()
4532 .as_millis() as i64
4533}
4534
4535fn generate_join_passphrase() -> String {
4541 use rand::RngCore;
4542 let mut bytes = [0u8; 16];
4543 rand::thread_rng().fill_bytes(&mut bytes);
4544 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
4547}
4548
4549fn generate_alphanumeric_code(len: usize) -> String {
4554 use rand::Rng;
4555 const ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ23456789";
4556 let mut rng = rand::thread_rng();
4557 let mut out = String::with_capacity(len + 1);
4558 for i in 0..len {
4559 if i == 4 && len == 8 {
4560 out.push('-'); }
4562 let idx = rng.gen_range(0..ALPHABET.len());
4563 out.push(ALPHABET[idx] as char);
4564 }
4565 out
4566}
4567
4568#[cfg(test)]
4569mod parser_tests {
4570 use super::parse_dial_address;
4571
4572 #[test]
4573 fn parses_ipv4_port() {
4574 let m = parse_dial_address("10.3.72.53:9027").unwrap();
4575 assert_eq!(m.to_string(), "/ip4/10.3.72.53/tcp/9027");
4576 }
4577
4578 #[test]
4579 fn parses_bracketed_ipv6() {
4580 let m = parse_dial_address("[::1]:9027").unwrap();
4581 assert_eq!(m.to_string(), "/ip6/::1/tcp/9027");
4582 }
4583
4584 #[test]
4585 fn rejects_unbracketed_ipv6() {
4586 let err = parse_dial_address("fe80::1:9027").unwrap_err();
4587 assert!(err.to_string().contains("brackets"));
4588 }
4589
4590 #[test]
4591 fn passes_through_raw_multiaddr() {
4592 let m = parse_dial_address("/ip4/1.2.3.4/tcp/9000").unwrap();
4593 assert_eq!(m.to_string(), "/ip4/1.2.3.4/tcp/9000");
4594 }
4595
4596 #[test]
4597 fn empty_address_is_error() {
4598 assert!(parse_dial_address(" ").is_err());
4599 }
4600
4601 #[test]
4602 fn rejects_bad_port() {
4603 assert!(parse_dial_address("1.2.3.4:notaport").is_err());
4604 }
4605}
4606
4607#[cfg(test)]
4608mod transport_preference_tests {
4609 use super::{address_preference, normalize_to_fingerprint};
4610
4611 #[test]
4612 fn lan_beats_public_beats_circuit() {
4613 let lan = address_preference("/ip4/192.168.1.5/tcp/9027");
4614 let pub_v4 = address_preference("/ip4/8.8.8.8/tcp/9027");
4615 let circuit = address_preference(
4616 "/ip4/1.2.3.4/tcp/4001/p2p/12D3Koo/p2p-circuit/p2p/12D3KooXYZ",
4617 );
4618 assert!(lan < pub_v4, "LAN {} should beat public {}", lan, pub_v4);
4619 assert!(
4620 pub_v4 < circuit,
4621 "public {} should beat circuit {}",
4622 pub_v4,
4623 circuit
4624 );
4625 }
4626
4627 #[test]
4628 fn all_rfc1918_ranges_are_lan() {
4629 assert_eq!(
4630 address_preference("/ip4/10.0.0.1/tcp/9027"),
4631 address_preference("/ip4/192.168.0.1/tcp/9027"),
4632 );
4633 assert_eq!(
4634 address_preference("/ip4/172.16.0.1/tcp/9027"),
4635 address_preference("/ip4/192.168.0.1/tcp/9027"),
4636 );
4637 assert!(
4639 address_preference("/ip4/172.32.0.1/tcp/9027")
4640 > address_preference("/ip4/172.16.0.1/tcp/9027")
4641 );
4642 }
4643
4644 #[test]
4645 fn normalize_id_accepts_branded_and_raw() {
4646 let canon = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4647 assert_eq!(
4648 normalize_to_fingerprint("HD-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF").as_deref(),
4649 Some(canon)
4650 );
4651 assert_eq!(
4652 normalize_to_fingerprint("aaaabbbbccccddddeeeeffff").as_deref(),
4653 Some(canon)
4654 );
4655 assert_eq!(normalize_to_fingerprint(canon).as_deref(), Some(canon));
4656 assert!(normalize_to_fingerprint("alice").is_none());
4657 assert!(normalize_to_fingerprint("HD-ZZZZ").is_none());
4658 }
4659}
4660
4661#[cfg(test)]
4662mod canonical_dm_room_id_tests {
4663 use super::canonical_dm_room_id;
4664
4665 #[test]
4666 fn dm_room_id_is_commutative() {
4667 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4670 let b = "1111-2222-3333-4444-5555-6666";
4671 assert_eq!(canonical_dm_room_id(a, b), canonical_dm_room_id(b, a));
4672 }
4673
4674 #[test]
4675 fn dm_room_id_differs_per_pair() {
4676 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4677 let b = "1111-2222-3333-4444-5555-6666";
4678 let c = "9999-8888-7777-6666-5555-4444";
4679 assert_ne!(canonical_dm_room_id(a, b), canonical_dm_room_id(a, c));
4680 assert_ne!(canonical_dm_room_id(a, b), canonical_dm_room_id(b, c));
4681 }
4682
4683 #[test]
4684 fn dm_room_id_is_stable() {
4685 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4689 let b = "1111-2222-3333-4444-5555-6666";
4690 let id1 = canonical_dm_room_id(a, b);
4691 let id2 = canonical_dm_room_id(a, b);
4692 assert_eq!(id1, id2);
4693 assert_eq!(id1.len(), 32);
4697 }
4698}