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 finalized: bool,
155}
156
157#[derive(Clone)]
158pub struct AppHandle {
159 identity: Arc<Identity>,
160 network: NetworkHandle,
161 mode: NetworkMode,
162 active_rooms: Arc<Mutex<HashMap<String, ActiveRoom>>>,
163 discovered_rooms: Arc<Mutex<HashMap<String, DiscoveredRoom>>>,
164 restorable_rooms: Arc<Mutex<HashMap<String, StoredRoom>>>,
168 connected_dial_addrs: Arc<Mutex<HashMap<String, PeerId>>>,
171 file_manager: Arc<FileManager>,
173 db: Db,
174 session_persist_key: [u8; 32],
178 sas_flows: Arc<Mutex<HashMap<String, SasFlow>>>,
181 pending_code_secrets:
188 Arc<Mutex<HashMap<(String, String), x25519_dalek::StaticSecret>>>,
189 pending_invite_dials: Arc<Mutex<HashMap<String, String>>>,
202 nat_reachable_addrs: Arc<Mutex<HashSet<String>>>,
208 relay_circuit_addrs: Arc<Mutex<HashSet<String>>>,
214 host_addr_dial_attempts: Arc<Mutex<HashMap<String, i64>>>,
219 last_profile_broadcast_at_ms: Arc<Mutex<HashMap<String, i64>>>,
226 pending_auto_dm_addrs: Arc<Mutex<HashSet<String>>>,
233 app_event_tx: broadcast::Sender<AppEvent>,
234}
235
236const HOST_ADDR_DIAL_BACKOFF_SECS: i64 = 300;
239
240const PROFILE_REBROADCAST_FLOOR_MS: i64 = 60_000;
244
245impl AppHandle {
246 pub async fn start() -> Result<Self> {
247 Self::start_with_options(NetworkMode::Mdns, 0, None, Vec::new()).await
248 }
249
250 pub fn peek_mdns_enabled(master_key: Option<&[u8; 32]>) -> Result<bool> {
258 config::ensure_data_dir()?;
259 let db = storage::open_db(&config::db_path(), master_key)?;
260 let v = repo::get_setting(&db, "mdns_enabled")?
261 .map(|s| s == "1")
262 .unwrap_or(true);
263 Ok(v)
264 }
265
266 pub async fn start_with_options(
267 mode: NetworkMode,
268 port: u16,
269 master_key: Option<&[u8; 32]>,
270 relays: Vec<Multiaddr>,
271 ) -> Result<Self> {
272 config::ensure_data_dir()?;
273 let session_persist_key = match master_key {
278 Some(mk) => storage::keychain::derive_subkey(mk, b"megolm-persist"),
279 None => [0u8; 32],
280 };
281 let db = storage::open_db(&config::db_path(), master_key)?;
282 Self::start_with_db_and_options(db, mode, port, session_persist_key, relays).await
283 }
284
285 pub async fn start_with_db(db: Db) -> Result<Self> {
286 Self::start_with_db_and_options(db, NetworkMode::Mdns, 0, [0u8; 32], Vec::new()).await
287 }
288
289 pub async fn start_with_db_and_options(
290 db: Db,
291 mode: NetworkMode,
292 port: u16,
293 session_persist_key: [u8; 32],
294 relays: Vec<Multiaddr>,
295 ) -> Result<Self> {
296 let identity = Self::load_or_create_identity(&db)?;
297 let identity = Arc::new(identity);
298 info!(fingerprint = %identity.fingerprint(), peer_id = %identity.peer_id(), mode = %mode.as_str(), port, relay_count = relays.len(), "identity loaded");
299
300 let (net_event_tx, net_event_rx) = tokio::sync::mpsc::channel::<NetworkEvent>(256);
301 let (app_event_tx, _) = broadcast::channel::<AppEvent>(256);
302 let network =
303 network::start_network_with(&identity, net_event_tx, mode, port, relays)?;
304
305 let active_rooms = Arc::new(Mutex::new(HashMap::new()));
306 let discovered_rooms = Arc::new(Mutex::new(HashMap::new()));
307 let restorable_rooms = Arc::new(Mutex::new(HashMap::new()));
308 let connected_dial_addrs = Arc::new(Mutex::new(HashMap::new()));
309 let file_manager = Arc::new(FileManager::new(&config::data_dir())?);
310
311 let handle = Self {
312 identity,
313 network,
314 mode,
315 active_rooms,
316 discovered_rooms,
317 restorable_rooms,
318 connected_dial_addrs,
319 file_manager,
320 db,
321 session_persist_key,
322 sas_flows: Arc::new(Mutex::new(HashMap::new())),
323 pending_code_secrets: Arc::new(Mutex::new(HashMap::new())),
324 pending_invite_dials: Arc::new(Mutex::new(HashMap::new())),
325 nat_reachable_addrs: Arc::new(Mutex::new(HashSet::new())),
326 relay_circuit_addrs: Arc::new(Mutex::new(HashSet::new())),
327 host_addr_dial_attempts: Arc::new(Mutex::new(HashMap::new())),
328 last_profile_broadcast_at_ms: Arc::new(Mutex::new(HashMap::new())),
329 pending_auto_dm_addrs: Arc::new(Mutex::new(HashSet::new())),
330 app_event_tx,
331 };
332
333 handle.spawn_event_processor(net_event_rx);
334 handle.spawn_announcement_ticker();
335 handle.spawn_discovered_room_pruner();
336 handle.spawn_known_peer_reconnector();
337 handle.restore_rooms_from_db().await;
338 if let Err(e) = repo::cleanup_expired_pending_friend_requests(&handle.db, now_unix()) {
342 warn!(%e, "failed to sweep expired pending friend requests");
343 }
344
345 Ok(handle)
346 }
347
348 pub fn mode(&self) -> NetworkMode {
349 self.mode
350 }
351
352 pub fn subscribe(&self) -> broadcast::Receiver<AppEvent> {
353 self.app_event_tx.subscribe()
354 }
355
356 pub fn fingerprint(&self) -> &str {
357 self.identity.fingerprint()
358 }
359
360 pub fn peer_id(&self) -> PeerId {
361 self.identity.peer_id()
362 }
363
364 pub fn sign_invite(&self, invite: crate::invite::InviteLink) -> Result<crate::invite::InviteLink> {
370 crate::invite::sign_invite(&self.identity, invite)
371 }
372
373 pub fn discovered_rooms(&self) -> Vec<DiscoveredRoom> {
374 let now = now_unix();
375 let our_fp = self.identity.fingerprint().to_string();
376 let mut by_id: HashMap<String, DiscoveredRoom> = self
377 .discovered_rooms
378 .lock()
379 .unwrap()
380 .clone();
381
382 for room in self.active_rooms.lock().unwrap().values() {
386 let entry = DiscoveredRoom {
387 room_id: room.info.id.clone(),
388 name: room.info.name.clone(),
389 encrypted: room.info.encrypted,
390 member_count: room.members.len() as u32,
391 creator_fingerprint: room.info.creator_fingerprint.clone(),
392 last_seen: now,
393 restorable: false,
394 host_addrs: Vec::new(),
395 kind: room.info.kind,
396 };
397 by_id
398 .entry(room.info.id.clone())
399 .and_modify(|d| {
400 d.last_seen = now;
401 if entry.member_count > d.member_count {
402 d.member_count = entry.member_count;
403 }
404 d.restorable = false;
405 d.kind = entry.kind;
406 })
407 .or_insert(entry);
408 }
409
410 for (id, stored) in self.restorable_rooms.lock().unwrap().iter() {
414 if by_id.contains_key(id) {
415 continue;
416 }
417 by_id.insert(
418 id.clone(),
419 DiscoveredRoom {
420 room_id: id.clone(),
421 name: stored.name.clone(),
422 encrypted: stored.encrypted,
423 member_count: 0,
424 creator_fingerprint: stored.creator_fingerprint.clone(),
425 last_seen: stored.last_active.unwrap_or(stored.created_at),
426 restorable: true,
427 host_addrs: Vec::new(),
428 kind: stored.kind,
429 },
430 );
431 }
432
433 by_id.retain(|room_id, d| {
441 if d.kind != RoomKind::Direct {
442 return true;
443 }
444 if self
447 .active_rooms
448 .lock()
449 .unwrap()
450 .contains_key(room_id)
451 {
452 return true;
453 }
454 canonical_dm_room_id(&our_fp, &d.creator_fingerprint) == *room_id
457 });
458
459 let mut v: Vec<DiscoveredRoom> = by_id.into_values().collect();
460 v.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
461 v
462 }
463
464 pub fn dm_partner_fingerprint(&self, room_id: &str) -> Option<String> {
469 let our_fp = self.identity.fingerprint().to_string();
470 let rooms = self.active_rooms.lock().unwrap();
471 let room = rooms.get(room_id)?;
472 if room.info.kind != RoomKind::Direct {
473 return None;
474 }
475 room.members
476 .iter()
477 .find(|m| **m != our_fp)
478 .cloned()
479 }
480
481 pub fn active_room_ids(&self) -> Vec<String> {
482 self.active_rooms.lock().unwrap().keys().cloned().collect()
483 }
484
485 pub fn active_room_info(&self, room_id: &str) -> Option<StoredRoom> {
486 self.active_rooms
487 .lock()
488 .unwrap()
489 .get(room_id)
490 .map(|r| r.info.clone())
491 }
492
493 pub fn room_members(&self, room_id: &str) -> Vec<String> {
494 self.active_rooms
495 .lock()
496 .unwrap()
497 .get(room_id)
498 .map(|r| {
499 let mut m: Vec<String> = r.members.iter().cloned().collect();
500 m.sort();
501 m
502 })
503 .unwrap_or_default()
504 }
505
506 pub fn room_messages(&self, room_id: &str, limit: i64) -> Result<Vec<repo::StoredRoomMessage>> {
507 repo::get_room_messages(&self.db, room_id, limit)
508 }
509
510 pub fn search_room_messages(
511 &self,
512 room_id: &str,
513 query: &str,
514 limit: i64,
515 ) -> Result<Vec<repo::StoredRoomMessage>> {
516 repo::search_room_messages(&self.db, room_id, query, limit)
517 }
518
519 pub async fn start_room(
527 &self,
528 name: &str,
529 encrypted: bool,
530 passphrase: Option<&str>,
531 kind: RoomKind,
532 ) -> Result<String> {
533 if encrypted && passphrase.is_none() {
534 return Err(HuddleError::Other(
535 "encrypted room requires a passphrase".into(),
536 ));
537 }
538
539 let created_at = now_unix();
540 let creator_fp = self.identity.fingerprint().to_string();
541 let room_id = derive_room_id(&creator_fp, name, created_at);
542
543 let (passphrase_salt, passphrase_key) = if encrypted {
544 let salt = passphrase::random_salt();
545 let key = passphrase::derive_key(passphrase.unwrap(), &salt)?;
546 (Some(salt.to_vec()), Some(key))
547 } else {
548 (None, None)
549 };
550
551 let info = StoredRoom {
552 id: room_id.clone(),
553 name: name.to_string(),
554 creator_fingerprint: creator_fp.clone(),
555 encrypted,
556 passphrase_salt: passphrase_salt.clone(),
557 created_at,
558 last_active: Some(created_at),
559 kind,
560 };
561 repo::insert_room(&self.db, &info)?;
562
563 let crypto = if encrypted {
564 Some(RoomCrypto::new_for_room(
565 self.db.clone(),
566 room_id.clone(),
567 creator_fp.clone(),
568 self.session_persist_key,
569 )?)
570 } else {
571 None
572 };
573
574 let mut members = HashSet::new();
575 members.insert(creator_fp.clone());
576
577 repo::upsert_room_member(
581 &self.db,
582 &StoredRoomMember {
583 room_id: room_id.clone(),
584 peer_id: String::new(),
585 fingerprint: creator_fp.clone(),
586 last_seen: Some(created_at),
587 verified: true, ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
589 role: "owner".into(),
590 },
591 )?;
592
593 self.active_rooms.lock().unwrap().insert(
594 room_id.clone(),
595 ActiveRoom {
596 info: info.clone(),
597 crypto,
598 passphrase_key,
599 members,
600 typers: HashMap::new(),
601 read_only: false,
602 issued_codes: Vec::new(),
603 },
604 );
605
606 self.network.subscribe_room(room_id.clone()).await;
607 self.announce_room_now(&info, 1).await;
608
609 let app = self.clone();
612 let rid = room_id.clone();
613 tokio::spawn(async move {
614 tokio::time::sleep(Duration::from_millis(500)).await;
615 if let Err(e) = app.broadcast_member_announce(&rid).await {
616 warn!(%e, "broadcast member announce");
617 }
618 });
619
620 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
621 room_id: room_id.clone(),
622 });
623
624 Ok(room_id)
625 }
626
627 pub async fn start_direct(&self, partner_fingerprint: &str) -> Result<String> {
651 let our_fp = self.identity.fingerprint().to_string();
652 if partner_fingerprint == our_fp {
653 return Err(HuddleError::Other("cannot DM yourself".into()));
654 }
655 let room_id = canonical_dm_room_id(&our_fp, partner_fingerprint);
656
657 if self.active_rooms.lock().unwrap().contains_key(&room_id) {
662 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
663 room_id: room_id.clone(),
664 });
665 return Ok(room_id);
666 }
667 if repo::get_room(&self.db, &room_id)?.is_some() {
668 return self.bootstrap_direct_room(&room_id, partner_fingerprint).await;
670 }
671
672 let created_at = now_unix();
673 let name = format!("dm-{}", short_fp_for_msg(partner_fingerprint));
677
678 let dm_salt = hex::decode(&room_id).unwrap_or_else(|_| room_id.as_bytes().to_vec());
685 let info = StoredRoom {
686 id: room_id.clone(),
687 name,
688 creator_fingerprint: our_fp.clone(),
689 encrypted: true,
690 passphrase_salt: Some(dm_salt),
691 created_at,
692 last_active: Some(created_at),
693 kind: RoomKind::Direct,
694 };
695 repo::insert_room(&self.db, &info)?;
696
697 let mut members = HashSet::new();
698 members.insert(our_fp.clone());
699 repo::upsert_room_member(
700 &self.db,
701 &StoredRoomMember {
702 room_id: room_id.clone(),
703 peer_id: String::new(),
704 fingerprint: our_fp.clone(),
705 last_seen: Some(created_at),
706 verified: true,
707 ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
708 role: "member".into(),
709 },
710 )?;
711
712 let passphrase_key = self.try_derive_dm_key(&room_id, partner_fingerprint);
719
720 let crypto = Some(RoomCrypto::new_for_room(
725 self.db.clone(),
726 room_id.clone(),
727 our_fp.clone(),
728 self.session_persist_key,
729 )?);
730
731 self.active_rooms.lock().unwrap().insert(
732 room_id.clone(),
733 ActiveRoom {
734 info: info.clone(),
735 crypto,
736 passphrase_key,
737 members,
738 typers: HashMap::new(),
739 read_only: false,
740 issued_codes: Vec::new(),
741 },
742 );
743
744 self.network.subscribe_room(room_id.clone()).await;
745 self.announce_room_now(&info, 1).await;
746
747 let app = self.clone();
748 let rid = room_id.clone();
749 tokio::spawn(async move {
750 tokio::time::sleep(Duration::from_millis(500)).await;
751 if let Err(e) = app.broadcast_member_announce(&rid).await {
752 warn!(%e, "broadcast member announce for DM");
753 }
754 });
755
756 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
757 room_id: room_id.clone(),
758 });
759 Ok(room_id)
760 }
761
762 fn derive_dm_key_from_pubkey_b64(
767 &self,
768 room_id: &str,
769 pubkey_b64: &str,
770 ) -> Option<[u8; KEY_LEN]> {
771 let bytes = B64.decode(pubkey_b64).ok()?;
772 if bytes.len() != 32 {
773 return None;
774 }
775 let mut pubkey = [0u8; 32];
776 pubkey.copy_from_slice(&bytes);
777 let our_seed = self.identity.secret_bytes();
778 match crate::crypto::dm::derive_dm_key(&our_seed, &pubkey, room_id) {
779 Ok(k) => Some(k),
780 Err(e) => {
781 warn!(%e, "DM key derivation (from announce) failed");
782 None
783 }
784 }
785 }
786
787 fn try_derive_dm_key(
792 &self,
793 room_id: &str,
794 partner_fingerprint: &str,
795 ) -> Option<[u8; KEY_LEN]> {
796 let pubkey_b64 = repo::lookup_peer_ed25519_pubkey(&self.db, partner_fingerprint)
797 .ok()
798 .flatten()?;
799 let bytes = B64.decode(&pubkey_b64).ok()?;
800 if bytes.len() != 32 {
801 return None;
802 }
803 let mut pubkey = [0u8; 32];
804 pubkey.copy_from_slice(&bytes);
805 let our_seed = self.identity.secret_bytes();
806 match crate::crypto::dm::derive_dm_key(&our_seed, &pubkey, room_id) {
807 Ok(k) => Some(k),
808 Err(e) => {
809 warn!(%e, %partner_fingerprint, "DM key derivation failed");
810 None
811 }
812 }
813 }
814
815 async fn bootstrap_direct_room(
821 &self,
822 room_id: &str,
823 partner_fingerprint: &str,
824 ) -> Result<String> {
825 let our_fp = self.identity.fingerprint().to_string();
826 let info = repo::get_room(&self.db, room_id)?
827 .ok_or_else(|| HuddleError::Other(format!("DM room {room_id} not found on disk")))?;
828 let mut members = HashSet::new();
829 members.insert(our_fp.clone());
830 members.insert(partner_fingerprint.to_string());
831
832 if let Ok(stored_members) = repo::list_room_members(&self.db, room_id) {
834 for m in stored_members {
835 members.insert(m.fingerprint);
836 }
837 }
838
839 let (passphrase_key, crypto) = if info.encrypted {
847 let pk = self.try_derive_dm_key(room_id, partner_fingerprint);
848 let c = match RoomCrypto::load(
853 self.db.clone(),
854 room_id.to_string(),
855 our_fp.clone(),
856 self.session_persist_key,
857 )? {
858 Some(c) => Some(c),
859 None => Some(RoomCrypto::new_for_room(
860 self.db.clone(),
861 room_id.to_string(),
862 our_fp.clone(),
863 self.session_persist_key,
864 )?),
865 };
866 (pk, c)
867 } else {
868 (None, None)
869 };
870
871 self.active_rooms.lock().unwrap().insert(
872 room_id.to_string(),
873 ActiveRoom {
874 info: info.clone(),
875 crypto,
876 passphrase_key,
877 members,
878 typers: HashMap::new(),
879 read_only: false,
880 issued_codes: Vec::new(),
881 },
882 );
883
884 self.network.subscribe_room(room_id.to_string()).await;
885 self.announce_room_now(&info, 2).await;
886
887 let app = self.clone();
888 let rid = room_id.to_string();
889 tokio::spawn(async move {
890 tokio::time::sleep(Duration::from_millis(500)).await;
891 if let Err(e) = app.broadcast_member_announce(&rid).await {
892 warn!(%e, "broadcast member announce on DM bootstrap");
893 }
894 });
895
896 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
897 room_id: room_id.to_string(),
898 });
899 Ok(room_id.to_string())
900 }
901
902 pub async fn join_room(&self, room_id: &str, passphrase: Option<&str>) -> Result<()> {
906 let (name, creator_fingerprint, encrypted, salt_opt) = {
908 if let Some(d) = self.discovered_rooms.lock().unwrap().get(room_id).cloned() {
909 let salt = self.get_room_salt(room_id);
910 (d.name, d.creator_fingerprint, d.encrypted, salt)
911 } else if let Some(stored) = self.restorable_rooms.lock().unwrap().get(room_id).cloned()
912 {
913 (
914 stored.name,
915 stored.creator_fingerprint,
916 stored.encrypted,
917 stored.passphrase_salt,
918 )
919 } else if let Some(stored) = repo::get_room(&self.db, room_id)? {
920 (
921 stored.name,
922 stored.creator_fingerprint,
923 stored.encrypted,
924 stored.passphrase_salt,
925 )
926 } else {
927 return Err(HuddleError::Other(format!("room {room_id} not found")));
928 }
929 };
930
931 if encrypted && passphrase.is_none() {
932 return Err(HuddleError::Other(
933 "encrypted room requires a passphrase".into(),
934 ));
935 }
936
937 let passphrase_key = if encrypted {
938 let salt = salt_opt
939 .clone()
940 .ok_or_else(|| HuddleError::Other("missing salt for encrypted room".into()))?;
941 Some(passphrase::derive_key(passphrase.unwrap(), &salt)?)
942 } else {
943 None
944 };
945
946 let kind = self
951 .discovered_rooms
952 .lock()
953 .unwrap()
954 .get(room_id)
955 .map(|d| d.kind)
956 .or_else(|| {
957 repo::get_room(&self.db, room_id)
958 .ok()
959 .flatten()
960 .map(|r| r.kind)
961 })
962 .unwrap_or_default();
963
964 let info = StoredRoom {
965 id: room_id.to_string(),
966 name,
967 creator_fingerprint,
968 encrypted,
969 passphrase_salt: salt_opt.clone(),
970 created_at: now_unix(),
971 last_active: Some(now_unix()),
972 kind,
973 };
974 repo::insert_room(&self.db, &info)?;
975
976 let crypto = if encrypted {
977 let our_fp = self.identity.fingerprint().to_string();
980 let existing = RoomCrypto::load(
981 self.db.clone(),
982 room_id.to_string(),
983 our_fp.clone(),
984 self.session_persist_key,
985 )?;
986 Some(match existing {
987 Some(c) => c,
988 None => RoomCrypto::new_for_room(
989 self.db.clone(),
990 room_id.to_string(),
991 our_fp,
992 self.session_persist_key,
993 )?,
994 })
995 } else {
996 None
997 };
998
999 let mut members = HashSet::new();
1000 members.insert(self.identity.fingerprint().to_string());
1001
1002 self.active_rooms.lock().unwrap().insert(
1003 room_id.to_string(),
1004 ActiveRoom {
1005 info: info.clone(),
1006 crypto,
1007 passphrase_key,
1008 members,
1009 typers: HashMap::new(),
1010 read_only: false,
1011 issued_codes: Vec::new(),
1012 },
1013 );
1014 self.restorable_rooms.lock().unwrap().remove(room_id);
1016
1017 self.network.subscribe_room(room_id.to_string()).await;
1018
1019 let app = self.clone();
1020 let rid = room_id.to_string();
1021 tokio::spawn(async move {
1022 tokio::time::sleep(Duration::from_millis(500)).await;
1023 if let Err(e) = app.broadcast_member_announce(&rid).await {
1024 warn!(%e, "broadcast member announce");
1025 }
1026 let req = RoomMessage::SessionKeyRequest {
1028 requester_fingerprint: app.identity.fingerprint().to_string(),
1029 };
1030 if let Ok(bytes) = encode_wire(&req) {
1031 app.network.publish_room_message(rid.clone(), bytes).await;
1032 }
1033 });
1034
1035 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
1036 room_id: room_id.to_string(),
1037 });
1038
1039 Ok(())
1040 }
1041
1042 async fn restore_rooms_from_db(&self) {
1047 let rooms = match repo::list_rooms(&self.db) {
1048 Ok(v) => v,
1049 Err(e) => {
1050 warn!(%e, "list rooms on restore");
1051 return;
1052 }
1053 };
1054 let our_fp = self.identity.fingerprint().to_string();
1055 let count = rooms.len();
1056 for info in rooms {
1057 if info.encrypted {
1058 self.restorable_rooms
1059 .lock()
1060 .unwrap()
1061 .insert(info.id.clone(), info);
1062 continue;
1063 }
1064 let mut members = HashSet::new();
1065 members.insert(our_fp.clone());
1066 if let Ok(stored_members) = repo::list_room_members(&self.db, &info.id) {
1067 for m in stored_members {
1068 members.insert(m.fingerprint);
1069 }
1070 }
1071 let member_count = members.len() as u32;
1072 self.active_rooms.lock().unwrap().insert(
1073 info.id.clone(),
1074 ActiveRoom {
1075 info: info.clone(),
1076 crypto: None,
1077 passphrase_key: None,
1078 members,
1079 typers: HashMap::new(),
1080 read_only: false,
1081 issued_codes: Vec::new(),
1082 },
1083 );
1084 self.network.subscribe_room(info.id.clone()).await;
1085 self.announce_room_now(&info, member_count).await;
1086 info!(room_id = %info.id, name = %info.name, "restored room");
1087 }
1088 if count > 0 {
1089 debug!(count, "restored rooms from db");
1090 }
1091 }
1092
1093 pub async fn leave_room(&self, room_id: &str) -> Result<bool> {
1098 let leave_msg = RoomMessage::MemberLeave {
1102 sender_fingerprint: self.identity.fingerprint().to_string(),
1103 };
1104 let dispatched = match crate::crypto::sign_message(&self.identity, &leave_msg)
1105 .and_then(|env| {
1106 crate::network::protocol::encode_wire_signed(&env)
1107 .map_err(|e| HuddleError::Session(format!("encode signed leave: {e}")))
1108 }) {
1109 Ok(bytes) => {
1110 self.network
1111 .publish_room_message(room_id.to_string(), bytes)
1112 .await;
1113 true
1114 }
1115 Err(e) => {
1116 warn!(%e, %room_id, "failed to sign+encode MemberLeave notice");
1117 false
1118 }
1119 };
1120
1121 self.active_rooms.lock().unwrap().remove(room_id);
1122 self.network.unsubscribe_room(room_id.to_string()).await;
1123
1124 let _ = self.app_event_tx.send(AppEvent::RoomLeft {
1125 room_id: room_id.to_string(),
1126 });
1127 Ok(dispatched)
1128 }
1129
1130 pub async fn send_room_message(&self, room_id: &str, body: &str) -> Result<()> {
1131 let our_fp = self.identity.fingerprint().to_string();
1132 let msg = {
1133 let mut rooms = self.active_rooms.lock().unwrap();
1134 let room = rooms
1135 .get_mut(room_id)
1136 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
1137
1138 if room.read_only {
1139 return Err(HuddleError::Other(
1140 "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(),
1141 ));
1142 }
1143
1144 if room.info.encrypted {
1145 let crypto = room
1146 .crypto
1147 .as_mut()
1148 .ok_or_else(|| HuddleError::Session("encrypted room missing crypto".into()))?;
1149 let (session_id, ct_bytes) = crypto.encrypt(body.as_bytes())?;
1150 RoomMessage::Encrypted {
1151 sender_fingerprint: our_fp.clone(),
1152 session_id,
1153 ciphertext_b64: base64::Engine::encode(
1154 &base64::engine::general_purpose::STANDARD,
1155 &ct_bytes,
1156 ),
1157 }
1158 } else {
1159 RoomMessage::Plain {
1160 sender_fingerprint: our_fp.clone(),
1161 body: body.to_string(),
1162 }
1163 }
1164 };
1165
1166 let bytes = encode_wire(&msg)?;
1167 self.network
1168 .publish_room_message(room_id.to_string(), bytes)
1169 .await;
1170
1171 let now = now_unix();
1172 let msg_id =
1173 repo::insert_room_message(&self.db, room_id, &our_fp, "out", body, now)?;
1174 repo::update_room_last_active(&self.db, room_id, now)?;
1175
1176 let _ = self.app_event_tx.send(AppEvent::MessageSent {
1177 room_id: room_id.to_string(),
1178 body: body.to_string(),
1179 message_id: msg_id,
1180 });
1181
1182 Ok(())
1183 }
1184
1185 pub async fn shutdown(&self) {
1186 self.network.shutdown().await;
1187 }
1188
1189 pub async fn dial_by_id_or_username(&self, input: &str) -> Result<()> {
1216 let trimmed = input.trim();
1217 if trimmed.is_empty() {
1218 return Err(HuddleError::Other("input is empty".into()));
1219 }
1220 let target_fp = if let Some(fp) = normalize_to_fingerprint(trimmed) {
1221 fp
1222 } else {
1223 let matches = repo::find_peers_by_username(&self.db, trimmed)?;
1224 if matches.is_empty() {
1225 return Err(HuddleError::Other(format!(
1226 "no peer named `{}` known yet — paste their invite link instead",
1227 trimmed
1228 )));
1229 }
1230 if matches.len() > 1 {
1231 return Err(HuddleError::Other(format!(
1232 "username `{}` is ambiguous ({} peers share it) — use their HD- ID instead",
1233 trimmed,
1234 matches.len()
1235 )));
1236 }
1237 matches.into_iter().next().unwrap()
1238 };
1239 if target_fp == self.identity.fingerprint() {
1240 return Err(HuddleError::Other("that's your own ID".into()));
1241 }
1242 let candidates = self.resolve_dial_addrs(&target_fp);
1243 if candidates.is_empty() {
1244 return Err(HuddleError::Other(format!(
1245 "haven't seen `{}` on the network yet — ask them for an invite link",
1246 short_fp_for_msg(&target_fp)
1247 )));
1248 }
1249 let now = now_unix();
1254 for addr in &candidates {
1255 let _ = repo::upsert_known_peer(
1256 &self.db,
1257 &KnownPeer {
1258 address: addr.clone(),
1259 label: None,
1260 last_connected_at: None,
1261 last_attempt_at: Some(now),
1262 created_at: now,
1263 fingerprint: Some(target_fp.clone()),
1264 trusted: false,
1265 },
1266 );
1267 }
1268 let multiaddrs: Vec<Multiaddr> = candidates
1272 .iter()
1273 .filter_map(|s| s.parse::<Multiaddr>().ok())
1274 .collect();
1275 if multiaddrs.is_empty() {
1276 return Err(HuddleError::Other(
1277 "every known address for that peer is malformed".into(),
1278 ));
1279 }
1280 let _ = self.app_event_tx.send(AppEvent::Dialing {
1281 address: candidates[0].clone(),
1282 });
1283 info!(
1284 target_fp = %target_fp,
1285 n = multiaddrs.len(),
1286 "dialing peer with {} candidate addresses",
1287 multiaddrs.len()
1288 );
1289 {
1293 let mut pending = self.pending_auto_dm_addrs.lock().unwrap();
1294 for m in &multiaddrs {
1295 pending.insert(m.to_string());
1296 }
1297 }
1298 self.network.dial_addresses(multiaddrs).await;
1299 Ok(())
1300 }
1301
1302 fn resolve_dial_addrs(&self, fingerprint: &str) -> Vec<String> {
1310 let mut set: std::collections::HashSet<String> = std::collections::HashSet::new();
1311 for room in self.discovered_rooms.lock().unwrap().values() {
1312 if room.creator_fingerprint == fingerprint {
1313 for addr in &room.host_addrs {
1314 set.insert(addr.clone());
1315 }
1316 }
1317 }
1318 if let Ok(known) = repo::list_known_peers(&self.db) {
1319 for peer in known {
1320 if peer.fingerprint.as_deref() == Some(fingerprint) {
1321 set.insert(peer.address);
1322 }
1323 }
1324 }
1325 let mut v: Vec<String> = set.into_iter().collect();
1326 v.sort_by_key(|a| address_preference(a));
1327 v
1328 }
1329
1330 pub async fn dial(&self, input: &str) -> Result<()> {
1331 let multiaddr = parse_dial_address(input)?;
1332 let canonical = multiaddr.to_string();
1333 self.pending_auto_dm_addrs
1338 .lock()
1339 .unwrap()
1340 .insert(canonical.clone());
1341 self.dial_internal(canonical, multiaddr).await
1342 }
1343
1344 pub(crate) async fn dial_internal(
1350 &self,
1351 canonical: String,
1352 multiaddr: Multiaddr,
1353 ) -> Result<()> {
1354 info!(%canonical, "dialing");
1355 repo::upsert_known_peer(
1356 &self.db,
1357 &KnownPeer {
1358 address: canonical.clone(),
1359 label: None,
1360 last_connected_at: None,
1361 last_attempt_at: Some(now_unix()),
1362 created_at: now_unix(),
1363 fingerprint: None,
1367 trusted: false,
1368 },
1369 )?;
1370
1371 let _ = self.app_event_tx.send(AppEvent::Dialing {
1372 address: canonical.clone(),
1373 });
1374 self.network.dial(multiaddr).await;
1375 Ok(())
1376 }
1377
1378 pub fn nat_reachable_addrs(&self) -> Vec<String> {
1383 self.nat_reachable_addrs
1384 .lock()
1385 .unwrap()
1386 .iter()
1387 .cloned()
1388 .collect()
1389 }
1390
1391 pub fn dialable_addrs(&self) -> Vec<String> {
1399 let mut out: Vec<String> = self
1400 .relay_circuit_addrs
1401 .lock()
1402 .unwrap()
1403 .iter()
1404 .cloned()
1405 .collect();
1406 for a in self.nat_reachable_addrs.lock().unwrap().iter() {
1407 if !out.contains(a) {
1408 out.push(a.clone());
1409 }
1410 }
1411 out.truncate(4);
1412 out
1413 }
1414
1415 pub async fn dial_invite(&self, address: &str, claimed_fp: &str) -> Result<()> {
1428 let multiaddr = parse_dial_address(address)?;
1429 let canonical = multiaddr.to_string();
1430 self.pending_invite_dials
1431 .lock()
1432 .unwrap()
1433 .insert(canonical.clone(), claimed_fp.to_string());
1434 self.dial(address).await
1437 }
1438
1439 pub fn seed_invite_room(&self, room: &crate::invite::InviteRoom) {
1452 if let Some(salt) = room.salt_b64.as_deref().and_then(|b| B64.decode(b).ok()) {
1453 ROOM_SALT_CACHE
1454 .lock()
1455 .unwrap()
1456 .insert(room.id.clone(), salt);
1457 }
1458 let discovered = DiscoveredRoom {
1459 room_id: room.id.clone(),
1460 name: room.name.clone(),
1461 encrypted: room.encrypted,
1462 member_count: 0,
1463 creator_fingerprint: room.creator_fingerprint.clone(),
1464 last_seen: now_unix(),
1465 restorable: false,
1466 host_addrs: Vec::new(),
1467 kind: RoomKind::Group,
1469 };
1470 self.discovered_rooms
1471 .lock()
1472 .unwrap()
1473 .insert(room.id.clone(), discovered);
1474 }
1475
1476 pub fn known_peers(&self) -> Vec<KnownPeerStatus> {
1477 let connected = self.connected_dial_addrs.lock().unwrap().clone();
1478 let stored = repo::list_known_peers(&self.db).unwrap_or_default();
1479 stored
1480 .into_iter()
1481 .map(|p| {
1482 let connected_peer = connected.get(&p.address).copied();
1483 KnownPeerStatus {
1484 address: p.address,
1485 label: p.label,
1486 last_connected_at: p.last_connected_at,
1487 connected_peer_id: connected_peer,
1488 fingerprint: p.fingerprint,
1489 }
1490 })
1491 .collect()
1492 }
1493
1494 pub async fn forget_peer(&self, address: &str) -> Result<()> {
1495 repo::forget_known_peer(&self.db, address)?;
1496 self.connected_dial_addrs.lock().unwrap().remove(address);
1497 Ok(())
1498 }
1499
1500 pub async fn redial(&self, address: &str) -> Result<()> {
1502 self.dial(address).await
1503 }
1504
1505 pub async fn accept_inbound(&self, peer_id: PeerId, address: &str) {
1510 self.network.accept_inbound(peer_id).await;
1511 self.connected_dial_addrs
1512 .lock()
1513 .unwrap()
1514 .insert(address.to_string(), peer_id);
1515 }
1516
1517 pub async fn reject_inbound(&self, peer_id: PeerId, fingerprint: &str) -> Result<()> {
1522 self.network.reject_inbound(peer_id).await;
1523 repo::block_peer(&self.db, fingerprint, now_unix())?;
1524 Ok(())
1525 }
1526
1527 pub async fn trust_inbound(
1530 &self,
1531 peer_id: PeerId,
1532 fingerprint: &str,
1533 address: &str,
1534 ) -> Result<()> {
1535 self.network.accept_inbound(peer_id).await;
1536 self.connected_dial_addrs
1537 .lock()
1538 .unwrap()
1539 .insert(address.to_string(), peer_id);
1540 repo::upsert_known_peer(
1544 &self.db,
1545 &KnownPeer {
1546 address: address.to_string(),
1547 label: None,
1548 last_connected_at: Some(now_unix()),
1549 last_attempt_at: Some(now_unix()),
1550 created_at: now_unix(),
1551 fingerprint: Some(fingerprint.to_string()),
1552 trusted: true,
1553 },
1554 )?;
1555 Ok(())
1556 }
1557
1558 pub fn list_pending_friend_requests(&self) -> Vec<repo::PendingFriendRequest> {
1566 repo::list_pending_friend_requests(&self.db).unwrap_or_default()
1567 }
1568
1569 pub fn spill_pending_friend_request(
1575 &self,
1576 peer_id: PeerId,
1577 fingerprint: &str,
1578 address: &str,
1579 ) -> Result<()> {
1580 repo::upsert_pending_friend_request(
1581 &self.db,
1582 &repo::PendingFriendRequest {
1583 fingerprint: fingerprint.to_string(),
1584 address: address.to_string(),
1585 peer_id: peer_id.to_string(),
1586 received_at: now_unix(),
1587 },
1588 )?;
1589 Ok(())
1590 }
1591
1592 pub async fn accept_pending_friend_request(&self, fingerprint: &str) -> Result<()> {
1599 let mut chosen_addr: Option<String> = None;
1600 for req in self.list_pending_friend_requests() {
1601 if req.fingerprint == fingerprint {
1602 chosen_addr = Some(req.address);
1603 break;
1604 }
1605 }
1606 repo::delete_pending_friend_requests_for_fp(&self.db, fingerprint)?;
1607 if let Some(addr) = chosen_addr {
1608 repo::upsert_known_peer(
1612 &self.db,
1613 &KnownPeer {
1614 address: addr.clone(),
1615 label: None,
1616 last_connected_at: None,
1617 last_attempt_at: Some(now_unix()),
1618 created_at: now_unix(),
1619 fingerprint: Some(fingerprint.to_string()),
1620 trusted: true,
1621 },
1622 )?;
1623 self.dial(&addr).await?;
1625 }
1626 Ok(())
1627 }
1628
1629 pub fn reject_pending_friend_request(&self, fingerprint: &str) -> Result<()> {
1634 repo::delete_pending_friend_requests_for_fp(&self.db, fingerprint)?;
1635 repo::block_peer(&self.db, fingerprint, now_unix())?;
1636 Ok(())
1637 }
1638
1639 pub async fn disconnect_peer(&self, peer_id: PeerId) {
1646 self.network.disconnect_peer(peer_id).await;
1647 }
1648
1649 fn spawn_known_peer_reconnector(&self) {
1650 let handle = self.clone();
1651 tokio::spawn(async move {
1652 tokio::time::sleep(Duration::from_millis(500)).await;
1654 let known = repo::list_known_peers(&handle.db).unwrap_or_default();
1655 for (i, peer) in known.into_iter().enumerate() {
1659 let handle = handle.clone();
1660 tokio::spawn(async move {
1661 let jitter = (peer.address.len() as u64 * 37) % 200;
1664 tokio::time::sleep(Duration::from_millis(150 * i as u64 + jitter)).await;
1665 let multiaddr = match peer.address.parse::<Multiaddr>() {
1670 Ok(m) => m,
1671 Err(_) => return,
1672 };
1673 if let Err(e) = handle.dial_internal(peer.address.clone(), multiaddr).await {
1674 debug!(%e, addr = %peer.address, "auto-reconnect failed");
1675 }
1676 });
1677 }
1678 });
1679 }
1680
1681 fn load_or_create_identity(db: &Db) -> Result<Identity> {
1686 if let Some(stored) = repo::load_identity(db)? {
1687 let mut bytes = [0u8; 32];
1688 bytes.copy_from_slice(&stored.ed25519_secret);
1689 Identity::from_secret_bytes(bytes)
1690 } else {
1691 let id = Identity::generate()?;
1692 repo::save_identity(db, &id.secret_bytes(), now_unix())?;
1693 Ok(id)
1694 }
1695 }
1696
1697 fn get_room_salt(&self, room_id: &str) -> Option<Vec<u8>> {
1698 self.active_rooms
1699 .lock()
1700 .unwrap()
1701 .get(room_id)
1702 .and_then(|r| r.info.passphrase_salt.clone())
1703 .or_else(|| {
1704 ROOM_SALT_CACHE
1706 .lock()
1707 .unwrap()
1708 .get(room_id)
1709 .cloned()
1710 })
1711 }
1712
1713 async fn announce_room_now(&self, info: &StoredRoom, member_count: u32) {
1714 let owner_fingerprints =
1715 repo::list_room_owners(&self.db, &info.id).unwrap_or_default();
1716 let verified_only = repo::get_room_verified_only(&self.db, &info.id).unwrap_or(false);
1717 let host_addrs = self.dialable_addrs();
1718 let ann = RoomAnnouncement {
1719 room_id: info.id.clone(),
1720 name: info.name.clone(),
1721 encrypted: info.encrypted,
1722 passphrase_salt: info.passphrase_salt.clone(),
1723 member_count,
1724 creator_fingerprint: info.creator_fingerprint.clone(),
1725 announced_at: now_unix(),
1726 owner_fingerprints,
1727 verified_only,
1728 host_addrs,
1729 kind: info.kind,
1730 };
1731 self.network.announce_room(ann).await;
1732 }
1733
1734 async fn broadcast_member_announce(&self, room_id: &str) -> Result<()> {
1735 let our_fp = self.identity.fingerprint().to_string();
1736 let wrapped = {
1737 let mut rooms = self.active_rooms.lock().unwrap();
1738 let room = rooms
1739 .get_mut(room_id)
1740 .ok_or_else(|| HuddleError::Other("not in room".into()))?;
1741 if room.info.encrypted {
1742 let crypto = room.crypto.as_mut().unwrap();
1743 let session_key = crypto.our_session_key_b64();
1744 match room.passphrase_key.as_ref() {
1745 Some(passphrase_key) => {
1746 Some(passphrase::wrap(session_key.as_bytes(), passphrase_key)?)
1747 }
1748 None if room.info.kind == RoomKind::Direct => {
1749 None
1759 }
1760 None => {
1761 return Err(HuddleError::Session("missing passphrase key".into()));
1762 }
1763 }
1764 } else {
1765 None
1766 }
1767 };
1768 let display_name = repo::get_display_name(&self.db).unwrap_or(None);
1769 let msg = RoomMessage::MemberAnnounce {
1770 sender_fingerprint: our_fp,
1771 wrapped_session_key: wrapped,
1772 display_name,
1773 sender_ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
1774 };
1775 let env = crate::crypto::sign_message(&self.identity, &msg)?;
1780 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
1781 self.network
1782 .publish_room_message(room_id.to_string(), bytes)
1783 .await;
1784 Ok(())
1785 }
1786
1787 fn spawn_event_processor(&self, mut net_rx: tokio::sync::mpsc::Receiver<NetworkEvent>) {
1788 let handle = self.clone();
1789 tokio::spawn(async move {
1790 while let Some(event) = net_rx.recv().await {
1791 handle.process_network_event(event).await;
1792 }
1793 info!("event processor stopped");
1794 });
1795 }
1796
1797 fn spawn_announcement_ticker(&self) {
1798 let handle = self.clone();
1799 tokio::spawn(async move {
1800 let mut interval =
1801 tokio::time::interval(Duration::from_secs(ANNOUNCE_INTERVAL_SECS));
1802 interval.tick().await; loop {
1804 interval.tick().await;
1805 let snapshot: Vec<(StoredRoom, u32)> = {
1806 let active = handle.active_rooms.lock().unwrap();
1807 active
1808 .values()
1809 .map(|r| (r.info.clone(), r.members.len() as u32))
1810 .collect()
1811 };
1812 for (info, member_count) in snapshot {
1813 handle.announce_room_now(&info, member_count).await;
1814 }
1815 }
1816 });
1817 }
1818
1819 fn spawn_discovered_room_pruner(&self) {
1820 let handle = self.clone();
1821 tokio::spawn(async move {
1822 let mut interval = tokio::time::interval(Duration::from_secs(10));
1823 interval.tick().await;
1824 loop {
1825 interval.tick().await;
1826 let now = now_unix();
1827 let mut to_drop = Vec::new();
1828 {
1829 let mut map = handle.discovered_rooms.lock().unwrap();
1830 map.retain(|id, r| {
1831 if now - r.last_seen > DISCOVERED_TTL_SECS {
1832 to_drop.push(id.clone());
1833 false
1834 } else {
1835 true
1836 }
1837 });
1838 }
1839 for id in to_drop {
1840 let _ = handle.app_event_tx.send(AppEvent::RoomLost { room_id: id });
1841 }
1842 }
1843 });
1844 }
1845
1846 async fn process_network_event(&self, event: NetworkEvent) {
1847 match event {
1848 NetworkEvent::PeerDiscovered { peer_id } => {
1849 let _ = self.app_event_tx.send(AppEvent::PeerDiscovered { peer_id });
1850 }
1851 NetworkEvent::PeerExpired { peer_id } => {
1852 self.connected_dial_addrs
1858 .lock()
1859 .unwrap()
1860 .retain(|_addr, pid| *pid != peer_id);
1861 let _ = self.app_event_tx.send(AppEvent::PeerExpired { peer_id });
1862 }
1863 NetworkEvent::PeerDisconnected { peer_id } => {
1864 self.connected_dial_addrs
1870 .lock()
1871 .unwrap()
1872 .retain(|_addr, pid| *pid != peer_id);
1873 let _ = self.app_event_tx.send(AppEvent::PeerExpired { peer_id });
1874 }
1875 NetworkEvent::ListeningOn { address } => {
1882 let _ = self.app_event_tx.send(AppEvent::ListeningOn {
1883 address: address.to_string(),
1884 });
1885 }
1886 NetworkEvent::RoomAnnouncementReceived(ann) => {
1887 if let Some(salt) = &ann.passphrase_salt {
1889 ROOM_SALT_CACHE
1890 .lock()
1891 .unwrap()
1892 .insert(ann.room_id.clone(), salt.clone());
1893 }
1894 let our_fp_for_dial = self.identity.fingerprint().to_string();
1899 if ann.creator_fingerprint != our_fp_for_dial && !ann.host_addrs.is_empty() {
1900 let now = now_unix();
1901 let should_dial = {
1902 let mut attempts = self.host_addr_dial_attempts.lock().unwrap();
1903 match attempts.get(&ann.creator_fingerprint).copied() {
1904 Some(last) if now - last < HOST_ADDR_DIAL_BACKOFF_SECS => false,
1905 _ => {
1906 attempts.insert(ann.creator_fingerprint.clone(), now);
1907 true
1908 }
1909 }
1910 };
1911 if should_dial {
1912 if let Some(first) = ann.host_addrs.first() {
1913 info!(
1914 announcer = %ann.creator_fingerprint,
1915 addr = %first,
1916 "opportunistic dial via room announcement host_addrs"
1917 );
1918 if let Ok(multiaddr) = first.parse::<Multiaddr>() {
1923 let canonical = multiaddr.to_string();
1924 let _ = self.dial_internal(canonical, multiaddr).await;
1925 }
1926 }
1927 }
1928 }
1929 let discovered = DiscoveredRoom {
1930 room_id: ann.room_id.clone(),
1931 name: ann.name.clone(),
1932 encrypted: ann.encrypted,
1933 member_count: ann.member_count,
1934 creator_fingerprint: ann.creator_fingerprint.clone(),
1935 last_seen: now_unix(),
1936 restorable: false,
1937 host_addrs: ann.host_addrs.clone(),
1938 kind: ann.kind,
1939 };
1940 if self.active_rooms.lock().unwrap().contains_key(&ann.room_id) {
1945 self.discovered_rooms
1946 .lock()
1947 .unwrap()
1948 .insert(ann.room_id.clone(), discovered);
1949 return;
1950 }
1951 if ann.kind == RoomKind::Direct {
1961 let our_fp_for_filter = self.identity.fingerprint().to_string();
1962 if canonical_dm_room_id(&our_fp_for_filter, &ann.creator_fingerprint)
1963 != ann.room_id
1964 {
1965 debug!(
1966 announcer = %ann.creator_fingerprint,
1967 room_id = %ann.room_id,
1968 "dropping Direct announcement: not addressed to us"
1969 );
1970 return;
1971 }
1972 if repo::is_peer_blocked(&self.db, &ann.creator_fingerprint).unwrap_or(false)
1984 {
1985 debug!(
1986 partner = %ann.creator_fingerprint,
1987 "ignoring Direct announcement from blocked peer"
1988 );
1989 return;
1990 }
1991 self.discovered_rooms
1992 .lock()
1993 .unwrap()
1994 .insert(ann.room_id.clone(), discovered.clone());
1995 let _ = self
1996 .app_event_tx
1997 .send(AppEvent::RoomDiscovered(discovered.clone()));
1998 let app = self.clone();
1999 let partner = ann.creator_fingerprint.clone();
2000 let rid = ann.room_id.clone();
2001 tokio::spawn(async move {
2002 if let Err(e) = app.start_direct(&partner).await {
2003 debug!(%e, room_id = %rid, "auto-bootstrap of inbound DM failed");
2004 }
2005 });
2006 return;
2007 }
2008 self.discovered_rooms
2009 .lock()
2010 .unwrap()
2011 .insert(ann.room_id.clone(), discovered.clone());
2012 let _ = self.app_event_tx.send(AppEvent::RoomDiscovered(discovered));
2013 }
2014 NetworkEvent::RoomMessageReceived {
2015 room_id,
2016 payload,
2017 from_peer: _,
2018 } => {
2019 let wire: WireMessage = match serde_json::from_slice(&payload) {
2026 Ok(w) => w,
2027 Err(e) => {
2028 warn!(%e, "bad wire envelope");
2029 return;
2030 }
2031 };
2032 let (msg, verified_signer) = match wire {
2033 WireMessage::Plain(m) => (m, None),
2034 WireMessage::Signed(env) => {
2035 let claimed_pubkey = env.ed25519_pubkey_b64.clone();
2036 match crate::crypto::verify_signed(&env) {
2037 Ok((m, fp)) => {
2038 match repo::get_member_ed25519_pubkey(
2045 &self.db, &room_id, &fp,
2046 ) {
2047 Ok(Some(known)) if known != claimed_pubkey => {
2048 warn!(
2049 %fp, %room_id,
2050 "pubkey mismatch vs stored; dropping signed message"
2051 );
2052 return;
2053 }
2054 _ => {}
2055 }
2056 (m, Some(fp))
2057 }
2058 Err(e) => {
2059 warn!(%e, fp = %env.fingerprint, "signed envelope verify failed");
2060 return;
2061 }
2062 }
2063 }
2064 };
2065 self.handle_room_message(&room_id, msg, verified_signer).await;
2066 }
2067 NetworkEvent::DialSucceeded { peer_id, address } => {
2068 let addr_s = address.to_string();
2069 self.connected_dial_addrs
2070 .lock()
2071 .unwrap()
2072 .insert(addr_s.clone(), peer_id);
2073 let _ = repo::upsert_known_peer(
2077 &self.db,
2078 &KnownPeer {
2079 address: addr_s.clone(),
2080 label: None,
2081 last_connected_at: Some(now_unix()),
2082 last_attempt_at: Some(now_unix()),
2083 created_at: now_unix(),
2084 fingerprint: None,
2085 trusted: false,
2086 },
2087 );
2088 let _ = self.app_event_tx.send(AppEvent::DialSucceeded {
2089 address: addr_s,
2090 peer_id,
2091 });
2092 }
2093 NetworkEvent::DialFailed { address, error } => {
2094 let addr_s = address.to_string();
2095 let _ = self.app_event_tx.send(AppEvent::DialFailed {
2096 address: addr_s,
2097 error,
2098 });
2099 }
2100 NetworkEvent::PeerIdentified { peer_id, fingerprint } => {
2101 let matched_addrs: Vec<String> = {
2107 let map = self.connected_dial_addrs.lock().unwrap();
2108 map.iter()
2109 .filter_map(|(addr, pid)| {
2110 if *pid == peer_id {
2111 Some(addr.clone())
2112 } else {
2113 None
2114 }
2115 })
2116 .collect()
2117 };
2118 let mismatch = {
2128 let mut map = self.pending_invite_dials.lock().unwrap();
2129 let mut found: Option<(String, String)> = None;
2130 for addr in &matched_addrs {
2131 if let Some(claimed) = map.remove(addr) {
2132 if claimed != fingerprint {
2133 found = Some((addr.clone(), claimed));
2134 break;
2135 }
2136 }
2137 }
2138 found
2139 };
2140 if let Some((addr, claimed)) = mismatch {
2141 warn!(
2142 %addr, %claimed, actual=%fingerprint,
2143 "invite fingerprint mismatch — disconnecting"
2144 );
2145 self.network.disconnect_peer(peer_id).await;
2146 let _ = self.app_event_tx.send(AppEvent::InviteFingerprintMismatch {
2147 address: addr,
2148 claimed,
2149 actual: fingerprint.clone(),
2150 });
2151 return;
2152 }
2153 let should_auto_dm = {
2160 let mut pending = self.pending_auto_dm_addrs.lock().unwrap();
2161 let mut any_matched = false;
2162 for addr in &matched_addrs {
2163 if pending.remove(addr) {
2164 any_matched = true;
2165 }
2166 }
2167 any_matched
2168 };
2169 for addr in matched_addrs {
2170 let _ = repo::upsert_known_peer(
2171 &self.db,
2172 &KnownPeer {
2173 address: addr,
2174 label: None,
2175 last_connected_at: Some(now_unix()),
2176 last_attempt_at: Some(now_unix()),
2177 created_at: now_unix(),
2178 fingerprint: Some(fingerprint.clone()),
2179 trusted: true,
2180 },
2181 );
2182 }
2183 let blocked = repo::is_peer_blocked(&self.db, &fingerprint).unwrap_or(false);
2196 if should_auto_dm && !blocked && fingerprint != self.identity.fingerprint() {
2197 match self.start_direct(&fingerprint).await {
2198 Ok(room_id) => {
2199 let _ = self.app_event_tx.send(AppEvent::AutoOpenDm {
2200 room_id,
2201 fingerprint: fingerprint.clone(),
2202 });
2203 }
2204 Err(e) => {
2205 debug!(%e, fp = %fingerprint, "auto-DM after dial failed");
2206 }
2207 }
2208 }
2209 let our_username = repo::get_display_name(&self.db).unwrap_or(None);
2217 if our_username.is_some() {
2218 let now_ms = now_unix_ms();
2219 let should_send = {
2220 let mut last = self.last_profile_broadcast_at_ms.lock().unwrap();
2221 match last.get(&fingerprint) {
2222 Some(prev) if now_ms - prev < PROFILE_REBROADCAST_FLOOR_MS => false,
2223 _ => {
2224 last.insert(fingerprint.clone(), now_ms);
2225 true
2226 }
2227 }
2228 };
2229 if should_send {
2230 let msg = RoomMessage::ProfileUpdate {
2231 sender_fingerprint: self.identity.fingerprint().to_string(),
2232 username: our_username,
2233 updated_at: now_ms,
2234 };
2235 if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
2236 if let Ok(bytes) =
2237 crate::network::protocol::encode_wire_signed(&env)
2238 {
2239 let rooms: Vec<String> = self
2240 .active_rooms
2241 .lock()
2242 .unwrap()
2243 .keys()
2244 .cloned()
2245 .collect();
2246 for room_id in rooms {
2247 self.network
2248 .publish_room_message(room_id, bytes.clone())
2249 .await;
2250 }
2251 }
2252 }
2253 }
2254 }
2255 }
2256 NetworkEvent::RelayReservationEstablished { address } => {
2257 info!(addr = %address, "relay reservation established");
2262 self.relay_circuit_addrs
2263 .lock()
2264 .unwrap()
2265 .insert(address.to_string());
2266 let _ = self.app_event_tx.send(AppEvent::ListeningOn {
2267 address: address.to_string(),
2268 });
2269 }
2270 NetworkEvent::NatProbeResult {
2271 tested_addr,
2272 reachable,
2273 } => {
2274 let addr_s = tested_addr.to_string();
2275 let (transitioned, becomes_reachable) = {
2276 let mut set = self.nat_reachable_addrs.lock().unwrap();
2277 let was_empty = set.is_empty();
2278 if reachable {
2279 set.insert(addr_s.clone());
2280 } else {
2281 set.remove(&addr_s);
2282 }
2283 let is_empty = set.is_empty();
2284 (was_empty != is_empty, !is_empty)
2285 };
2286 if transitioned {
2287 let label = if becomes_reachable {
2288 "reachable".to_string()
2289 } else {
2290 "private".to_string()
2291 };
2292 info!(reachable = %becomes_reachable, "NAT reachability changed");
2293 let _ = self.app_event_tx.send(AppEvent::NatStatusChanged {
2294 label,
2295 reachable: becomes_reachable,
2296 });
2297 }
2298 }
2299 NetworkEvent::DcutrUpgrade {
2300 remote_peer,
2301 success,
2302 } => {
2303 if success {
2304 let s = remote_peer.to_base58();
2308 let tail: String = s.chars().rev().take(8).collect::<String>()
2309 .chars()
2310 .rev()
2311 .collect();
2312 let _ = self.app_event_tx.send(AppEvent::DcutrSucceeded {
2313 peer_label: tail,
2314 });
2315 }
2316 }
2317 NetworkEvent::InboundDial {
2318 peer_id,
2319 fingerprint,
2320 address,
2321 } => {
2322 if repo::is_peer_blocked(&self.db, &fingerprint).unwrap_or(false) {
2324 info!(%fingerprint, "inbound dial auto-rejected: peer is blocked");
2325 self.network.reject_inbound(peer_id).await;
2326 return;
2327 }
2328 let global_verified_only =
2333 repo::get_setting(&self.db, "verified_only_inbound")
2334 .ok()
2335 .flatten()
2336 .map(|v| v == "1")
2337 .unwrap_or(false);
2338 if global_verified_only {
2339 let is_verified =
2340 repo::is_globally_verified(&self.db, &fingerprint).unwrap_or(false)
2341 || repo::is_fingerprint_trusted(&self.db, &fingerprint)
2342 .unwrap_or(false);
2343 if !is_verified {
2344 info!(
2345 %fingerprint,
2346 "inbound dial auto-rejected: verified-only mode"
2347 );
2348 self.network.reject_inbound(peer_id).await;
2349 return;
2350 }
2351 }
2352 if repo::is_fingerprint_trusted(&self.db, &fingerprint).unwrap_or(false) {
2353 info!(%fingerprint, "inbound dial auto-accepted: peer is trusted");
2354 self.connected_dial_addrs
2357 .lock()
2358 .unwrap()
2359 .insert(address.to_string(), peer_id);
2360 let _ = repo::upsert_known_peer(
2361 &self.db,
2362 &KnownPeer {
2363 address: address.to_string(),
2364 label: None,
2365 last_connected_at: Some(now_unix()),
2366 last_attempt_at: Some(now_unix()),
2367 created_at: now_unix(),
2368 fingerprint: Some(fingerprint),
2369 trusted: true,
2370 },
2371 );
2372 self.network.accept_inbound(peer_id).await;
2373 return;
2374 }
2375 let _ = self.app_event_tx.send(AppEvent::InboundDial {
2377 peer_id,
2378 fingerprint,
2379 address: address.to_string(),
2380 });
2381 }
2382 }
2383 }
2384
2385 async fn handle_room_message(
2391 &self,
2392 room_id: &str,
2393 msg: RoomMessage,
2394 verified_signer: Option<String>,
2395 ) {
2396 let our_fp = self.identity.fingerprint().to_string();
2397 match msg {
2398 RoomMessage::MemberAnnounce {
2399 sender_fingerprint,
2400 wrapped_session_key,
2401 display_name,
2402 sender_ed25519_pubkey,
2403 } => {
2404 if sender_fingerprint == our_fp {
2405 return;
2406 }
2407 let signer = match verified_signer {
2417 Some(fp) => fp,
2418 None => {
2419 warn!(%sender_fingerprint, %room_id, "MemberAnnounce arrived unsigned; dropping");
2420 return;
2421 }
2422 };
2423 if signer != sender_fingerprint {
2424 warn!(%signer, %sender_fingerprint, %room_id, "MemberAnnounce signer mismatch; dropping");
2425 return;
2426 }
2427 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2430 .unwrap_or(false)
2431 {
2432 info!(%sender_fingerprint, %room_id, "dropping MemberAnnounce from banned peer");
2433 return;
2434 }
2435 if repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
2442 && !repo::is_globally_verified(&self.db, &sender_fingerprint).unwrap_or(false)
2443 {
2444 info!(
2445 %sender_fingerprint, %room_id,
2446 "dropping MemberAnnounce: room is verified-only and joiner isn't verified"
2447 );
2448 let owners = repo::list_room_owners(&self.db, room_id).unwrap_or_default();
2449 let lowest_owner = owners.iter().min().cloned();
2450 if lowest_owner.as_deref() == Some(&our_fp) {
2451 let msg = RoomMessage::JoinRefused {
2452 room_id: room_id.to_string(),
2453 target_fingerprint: sender_fingerprint.clone(),
2454 reason: "room requires SAS verification — ask an existing member to verify you".into(),
2455 };
2456 if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
2457 if let Ok(bytes) =
2458 crate::network::protocol::encode_wire_signed(&env)
2459 {
2460 self.network
2461 .publish_room_message(room_id.to_string(), bytes)
2462 .await;
2463 }
2464 }
2465 }
2466 return;
2467 }
2468 let need_inbound = {
2469 let mut rooms = self.active_rooms.lock().unwrap();
2470 let room = match rooms.get_mut(room_id) {
2471 Some(r) => r,
2472 None => return,
2473 };
2474 if room.info.kind == RoomKind::Direct
2482 && !room.members.contains(&sender_fingerprint)
2483 && room.members.len() >= 2
2484 {
2485 info!(
2486 %sender_fingerprint, %room_id,
2487 "dropping MemberAnnounce on Direct room: already at 2-member cap"
2488 );
2489 return;
2490 }
2491 let newly_added = room.members.insert(sender_fingerprint.clone());
2492 if newly_added {
2493 let _ = self.app_event_tx.send(AppEvent::MemberJoined {
2494 room_id: room_id.to_string(),
2495 fingerprint: sender_fingerprint.clone(),
2496 });
2497 }
2498 let _ = repo::upsert_room_member(
2503 &self.db,
2504 &StoredRoomMember {
2505 room_id: room_id.to_string(),
2506 peer_id: String::new(), fingerprint: sender_fingerprint.clone(),
2508 last_seen: Some(now_unix()),
2509 verified: false,
2510 ed25519_pubkey: sender_ed25519_pubkey.clone(),
2511 role: "member".into(),
2517 },
2518 );
2519 if let Some(name) = display_name.as_deref() {
2520 let _ = repo::set_member_display_name(
2521 &self.db,
2522 room_id,
2523 &sender_fingerprint,
2524 Some(name),
2525 );
2526 }
2527 room.info.encrypted && wrapped_session_key.is_some()
2528 };
2529
2530 if matches!(
2537 self.active_rooms
2538 .lock()
2539 .unwrap()
2540 .get(room_id)
2541 .map(|r| (r.info.kind, r.passphrase_key.is_none())),
2542 Some((RoomKind::Direct, true))
2543 ) {
2544 if let Some(pubkey_b64) = sender_ed25519_pubkey.as_deref() {
2545 if let Some(key) =
2546 self.derive_dm_key_from_pubkey_b64(room_id, pubkey_b64)
2547 {
2548 let mut rooms = self.active_rooms.lock().unwrap();
2549 if let Some(room) = rooms.get_mut(room_id) {
2550 room.passphrase_key = Some(key);
2551 }
2552 drop(rooms);
2553 let app = self.clone();
2558 let rid = room_id.to_string();
2559 tokio::spawn(async move {
2560 if let Err(e) = app.broadcast_member_announce(&rid).await {
2561 warn!(%e, "re-broadcast DM announce after key derivation");
2562 }
2563 });
2564 }
2565 }
2566 }
2567
2568 if need_inbound {
2569 let wrapped = wrapped_session_key.unwrap();
2570 let result = {
2571 let mut rooms = self.active_rooms.lock().unwrap();
2572 let room = rooms.get_mut(room_id).unwrap();
2573 let passphrase_key = match &room.passphrase_key {
2574 Some(k) => k,
2575 None => {
2576 warn!("no passphrase key when receiving session key");
2577 return;
2578 }
2579 };
2580 match passphrase::unwrap(&wrapped, passphrase_key) {
2581 Ok(plain) => match String::from_utf8(plain) {
2582 Ok(key_b64) => {
2583 let crypto = room.crypto.as_mut().unwrap();
2584 crypto.add_inbound_session(&sender_fingerprint, &key_b64)
2585 }
2586 Err(e) => Err(HuddleError::Session(format!("utf8: {e}"))),
2587 },
2588 Err(e) => Err(e),
2589 }
2590 };
2591 if let Err(e) = result {
2592 error!(%e, "add inbound session failed");
2593 }
2594 }
2595 }
2596 RoomMessage::SessionKeyRequest {
2597 requester_fingerprint,
2598 } => {
2599 if requester_fingerprint == our_fp {
2600 return;
2601 }
2602 if let Err(e) = self.broadcast_member_announce(room_id).await {
2604 warn!(%e, "broadcast member announce on request");
2605 }
2606 }
2607 RoomMessage::Encrypted {
2608 sender_fingerprint,
2609 session_id,
2610 ciphertext_b64,
2611 } => {
2612 if sender_fingerprint == our_fp {
2613 return;
2614 }
2615 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2623 .unwrap_or(false)
2624 {
2625 debug!(%sender_fingerprint, %room_id, "dropping Encrypted from banned peer");
2626 return;
2627 }
2628 let ct_bytes = match base64::Engine::decode(
2629 &base64::engine::general_purpose::STANDARD,
2630 &ciphertext_b64,
2631 ) {
2632 Ok(b) => b,
2633 Err(e) => {
2634 warn!(%e, "bad base64 ciphertext");
2635 return;
2636 }
2637 };
2638 let plaintext = {
2639 let mut rooms = self.active_rooms.lock().unwrap();
2640 let room = match rooms.get_mut(room_id) {
2641 Some(r) => r,
2642 None => return,
2643 };
2644 let crypto = match room.crypto.as_mut() {
2645 Some(c) => c,
2646 None => return,
2647 };
2648 crypto.decrypt(&sender_fingerprint, &session_id, &ct_bytes)
2649 };
2650 match plaintext {
2651 Ok(pt) => {
2652 let body = String::from_utf8_lossy(&pt).to_string();
2653 let sent_at = now_unix();
2654 let _ = repo::insert_room_message(
2655 &self.db,
2656 room_id,
2657 &sender_fingerprint,
2658 "in",
2659 &body,
2660 sent_at,
2661 );
2662 let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
2663 self.maybe_emit_mention(room_id, &body);
2664 let _ = self.app_event_tx.send(AppEvent::MessageReceived {
2665 room_id: room_id.to_string(),
2666 sender_fingerprint,
2667 body,
2668 sent_at,
2669 });
2670 }
2671 Err(e) => {
2672 debug!(%e, "decrypt failed (probably missing session key)");
2673 }
2674 }
2675 }
2676 RoomMessage::Plain {
2677 sender_fingerprint,
2678 body,
2679 } => {
2680 if sender_fingerprint == our_fp {
2681 return;
2682 }
2683 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2684 .unwrap_or(false)
2685 {
2686 debug!(%sender_fingerprint, %room_id, "dropping Plain from banned peer");
2687 return;
2688 }
2689 let sent_at = now_unix();
2690 let _ = repo::insert_room_message(
2691 &self.db,
2692 room_id,
2693 &sender_fingerprint,
2694 "in",
2695 &body,
2696 sent_at,
2697 );
2698 let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
2699 self.maybe_emit_mention(room_id, &body);
2700 let _ = self.app_event_tx.send(AppEvent::MessageReceived {
2701 room_id: room_id.to_string(),
2702 sender_fingerprint,
2703 body,
2704 sent_at,
2705 });
2706 }
2707 RoomMessage::Typing { sender_fingerprint } => {
2708 if sender_fingerprint == our_fp {
2709 return;
2710 }
2711 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2712 .unwrap_or(false)
2713 {
2714 return;
2715 }
2716 let expiry = now_unix() + TYPING_TTL_SECS;
2717 let mut rooms = self.active_rooms.lock().unwrap();
2718 if let Some(room) = rooms.get_mut(room_id) {
2719 room.typers.insert(sender_fingerprint, expiry);
2720 }
2721 drop(rooms);
2722 let _ = self.app_event_tx.send(AppEvent::TypingChanged {
2723 room_id: room_id.to_string(),
2724 });
2725 }
2726 RoomMessage::RotateRoomKey {
2727 rotator_fingerprint,
2728 new_salt,
2729 } => {
2730 if rotator_fingerprint == our_fp {
2731 return;
2732 }
2733 let signer = match verified_signer {
2738 Some(fp) => fp,
2739 None => {
2740 warn!(%room_id, "RotateRoomKey arrived unsigned; dropping");
2741 return;
2742 }
2743 };
2744 if signer != rotator_fingerprint {
2745 warn!(
2746 %signer, %rotator_fingerprint, %room_id,
2747 "RotateRoomKey signer mismatch with claimed rotator; dropping"
2748 );
2749 return;
2750 }
2751 let _ = self.app_event_tx.send(AppEvent::RotationRequested {
2752 room_id: room_id.to_string(),
2753 rotator_fingerprint,
2754 new_salt,
2755 });
2756 }
2757 RoomMessage::MemberLeave { sender_fingerprint } => {
2758 if sender_fingerprint == our_fp {
2759 return;
2760 }
2761 let signer = match verified_signer {
2765 Some(fp) => fp,
2766 None => {
2767 warn!(%sender_fingerprint, %room_id, "MemberLeave arrived unsigned; dropping");
2768 return;
2769 }
2770 };
2771 if signer != sender_fingerprint {
2772 warn!(%signer, %sender_fingerprint, %room_id, "MemberLeave signer mismatch; dropping");
2773 return;
2774 }
2775 let removed = {
2776 let mut rooms = self.active_rooms.lock().unwrap();
2777 if let Some(room) = rooms.get_mut(room_id) {
2778 room.members.remove(&sender_fingerprint)
2779 } else {
2780 false
2781 }
2782 };
2783 if removed {
2784 let _ = self.app_event_tx.send(AppEvent::MemberLeft {
2785 room_id: room_id.to_string(),
2786 fingerprint: sender_fingerprint,
2787 });
2788 }
2789 }
2790 RoomMessage::FileOffer {
2791 sender_fingerprint,
2792 file_id,
2793 name,
2794 size_bytes,
2795 mime,
2796 chunk_count,
2797 encrypted_meta,
2798 } => {
2799 if sender_fingerprint == our_fp {
2800 return; }
2802 let signer = match verified_signer {
2807 Some(fp) => fp,
2808 None => {
2809 warn!(%sender_fingerprint, %room_id, %file_id, "FileOffer arrived unsigned; dropping");
2810 return;
2811 }
2812 };
2813 if signer != sender_fingerprint {
2814 warn!(%signer, %sender_fingerprint, %room_id, %file_id, "FileOffer signer mismatch; dropping");
2815 return;
2816 }
2817 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2820 .unwrap_or(false)
2821 {
2822 info!(%sender_fingerprint, %room_id, %file_id, "dropping FileOffer from banned peer");
2823 return;
2824 }
2825 self.handle_file_offer(
2826 room_id,
2827 sender_fingerprint,
2828 file_id,
2829 name,
2830 size_bytes,
2831 mime,
2832 chunk_count,
2833 encrypted_meta,
2834 );
2835 }
2836 RoomMessage::FileChunk {
2837 sender_fingerprint,
2838 file_id,
2839 chunk_index,
2840 total_chunks,
2841 data_b64,
2842 } => {
2843 if sender_fingerprint == our_fp {
2844 return;
2845 }
2846 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2847 .unwrap_or(false)
2848 {
2849 return;
2850 }
2851 self.handle_file_chunk(
2852 room_id,
2853 sender_fingerprint,
2854 file_id,
2855 chunk_index,
2856 total_chunks,
2857 data_b64,
2858 );
2859 }
2860 RoomMessage::OwnerGrant {
2861 room_id: announced_room_id,
2862 target_fingerprint,
2863 } => {
2864 if announced_room_id != room_id {
2869 warn!(payload_room = %announced_room_id, topic_room = %room_id, "OwnerGrant room mismatch");
2870 return;
2871 }
2872 let signer = match verified_signer {
2873 Some(fp) => fp,
2874 None => {
2875 warn!(%room_id, "OwnerGrant arrived unsigned; dropping");
2876 return;
2877 }
2878 };
2879 if !self.is_owner(room_id, &signer) {
2880 warn!(%signer, %room_id, "OwnerGrant signer isn't an owner; dropping");
2881 return;
2882 }
2883 info!(%signer, %target_fingerprint, %room_id, "OwnerGrant applied");
2884 if let Err(e) =
2885 repo::set_member_role(&self.db, room_id, &target_fingerprint, "owner")
2886 {
2887 warn!(%e, "OwnerGrant: set_member_role failed");
2888 }
2889 }
2890 RoomMessage::BanMember {
2891 room_id: announced_room_id,
2892 target_fingerprint,
2893 } => {
2894 if announced_room_id != room_id {
2895 warn!(payload_room = %announced_room_id, topic_room = %room_id, "BanMember room mismatch");
2896 return;
2897 }
2898 let signer = match verified_signer {
2899 Some(fp) => fp,
2900 None => {
2901 warn!(%room_id, "BanMember arrived unsigned; dropping");
2902 return;
2903 }
2904 };
2905 if !self.is_owner(room_id, &signer) {
2906 warn!(%signer, %room_id, "BanMember signer isn't an owner; dropping");
2907 return;
2908 }
2909 if target_fingerprint == our_fp {
2910 info!(%room_id, %signer, "we were kicked from this room");
2916 self.active_rooms.lock().unwrap().remove(room_id);
2917 let _ = self.app_event_tx.send(AppEvent::RoomLeft {
2918 room_id: room_id.to_string(),
2919 });
2920 return;
2921 }
2922 info!(%signer, %target_fingerprint, %room_id, "BanMember applied");
2923 if let Err(e) = repo::add_room_ban(
2924 &self.db,
2925 room_id,
2926 &target_fingerprint,
2927 &signer,
2928 "", now_unix(),
2930 ) {
2931 warn!(%e, "BanMember: add_room_ban failed");
2932 }
2933 self.evict_banned_member(room_id, &target_fingerprint);
2934 }
2935 RoomMessage::SasInit {
2936 tx_id,
2937 ephemeral_x25519_pubkey_b64,
2938 target_fingerprint,
2939 } => {
2940 if target_fingerprint != our_fp {
2941 return;
2946 }
2947 let signer = match verified_signer {
2948 Some(fp) => fp,
2949 None => {
2950 warn!("SasInit arrived unsigned; dropping");
2951 return;
2952 }
2953 };
2954 let their_pub =
2955 match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
2956 Ok(pk) => pk,
2957 Err(e) => {
2958 warn!(%e, "SasInit: bad x25519 pubkey");
2959 return;
2960 }
2961 };
2962 let tx_id_bytes = match B64.decode(&tx_id) {
2963 Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
2964 let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
2965 arr.copy_from_slice(&b);
2966 arr
2967 }
2968 _ => {
2969 warn!(%tx_id, "SasInit: bad tx_id length");
2970 return;
2971 }
2972 };
2973 let (_, our_secret, our_pub) = crate::crypto::sas::new_session();
2974 let sas_code =
2975 crate::crypto::sas::derive_sas_code(&our_secret, &their_pub, &tx_id_bytes);
2976 self.sas_flows.lock().unwrap().insert(
2977 tx_id.clone(),
2978 SasFlow {
2979 room_id: room_id.to_string(),
2980 partner_fingerprint: signer.clone(),
2981 our_secret,
2982 sas_code: Some(sas_code.clone()),
2983 our_confirmed: false,
2984 their_confirmed: false,
2985 finalized: false,
2986 },
2987 );
2988 let response = RoomMessage::SasResponse {
2991 tx_id: tx_id.clone(),
2992 ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2993 };
2994 if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
2995 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
2996 self.network
2997 .publish_room_message(room_id.to_string(), bytes)
2998 .await;
2999 }
3000 }
3001 let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
3002 room_id: room_id.to_string(),
3003 partner_fingerprint: signer,
3004 tx_id,
3005 emoji_string: sas_code.emoji_string(),
3006 emoji_labels: sas_code.emoji_labels(),
3007 decimal: sas_code.decimal,
3008 });
3009 }
3010 RoomMessage::SasResponse {
3011 tx_id,
3012 ephemeral_x25519_pubkey_b64,
3013 } => {
3014 let signer = match verified_signer {
3015 Some(fp) => fp,
3016 None => {
3017 warn!("SasResponse arrived unsigned; dropping");
3018 return;
3019 }
3020 };
3021 let their_pub =
3022 match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
3023 Ok(pk) => pk,
3024 Err(e) => {
3025 warn!(%e, "SasResponse: bad x25519 pubkey");
3026 return;
3027 }
3028 };
3029 let tx_id_bytes = match B64.decode(&tx_id) {
3030 Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
3031 let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
3032 arr.copy_from_slice(&b);
3033 arr
3034 }
3035 _ => return,
3036 };
3037 let emit = {
3038 let mut flows = self.sas_flows.lock().unwrap();
3039 let flow = match flows.get_mut(&tx_id) {
3040 Some(f) => f,
3041 None => {
3042 warn!(%tx_id, "SasResponse for unknown tx_id");
3043 return;
3044 }
3045 };
3046 if flow.partner_fingerprint != signer {
3047 warn!(
3048 expected = %flow.partner_fingerprint, got = %signer,
3049 "SasResponse signer doesn't match flow's partner; dropping"
3050 );
3051 return;
3052 }
3053 let code = crate::crypto::sas::derive_sas_code(
3054 &flow.our_secret,
3055 &their_pub,
3056 &tx_id_bytes,
3057 );
3058 flow.sas_code = Some(code.clone());
3059 code
3060 };
3061 let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
3062 room_id: room_id.to_string(),
3063 partner_fingerprint: signer,
3064 tx_id,
3065 emoji_string: emit.emoji_string(),
3066 emoji_labels: emit.emoji_labels(),
3067 decimal: emit.decimal,
3068 });
3069 }
3070 RoomMessage::CodeJoinRequest {
3071 room_id: announced_room_id,
3072 joiner_x25519_pubkey_b64,
3073 code,
3074 } => {
3075 if announced_room_id != room_id {
3076 return;
3077 }
3078 let joiner_fp = match verified_signer {
3079 Some(fp) => fp,
3080 None => {
3081 warn!("CodeJoinRequest unsigned; dropping");
3082 return;
3083 }
3084 };
3085 let our_fp = self.identity.fingerprint().to_string();
3089 if !self.is_owner(room_id, &our_fp) {
3090 return;
3091 }
3092 let now = now_unix();
3094 let (code_ok, our_session_id, wrap_input) = {
3095 let mut rooms = self.active_rooms.lock().unwrap();
3096 let room = match rooms.get_mut(room_id) {
3097 Some(r) => r,
3098 None => return,
3099 };
3100 if room.passphrase_key.is_none() {
3101 warn!("CodeJoinRequest: no passphrase key locally; can't respond");
3102 return;
3103 }
3104 let original_len = room.issued_codes.len();
3105 room.issued_codes.retain(|(c, exp)| !(c == &code && *exp > now));
3106 let matched = room.issued_codes.len() < original_len;
3107 if !matched {
3108 info!(%joiner_fp, "CodeJoinRequest: code invalid or expired; ignoring");
3109 return;
3110 }
3111 let crypto = room.crypto.as_ref().unwrap();
3112 (
3113 true,
3114 crypto.our_session_id(),
3115 crypto.our_session_key_b64(),
3116 )
3117 };
3118 let _ = code_ok;
3119 let their_pub = match crate::crypto::sas::parse_pubkey(&joiner_x25519_pubkey_b64) {
3121 Ok(pk) => pk,
3122 Err(e) => {
3123 warn!(%e, "CodeJoinRequest: bad pubkey");
3124 return;
3125 }
3126 };
3127 use x25519_dalek::{PublicKey, StaticSecret};
3128 let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
3129 let our_pub = PublicKey::from(&our_secret);
3130 let shared = our_secret.diffie_hellman(&their_pub);
3131 let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
3133 let mut wrap_key = [0u8; passphrase::KEY_LEN];
3134 hk.expand(b"huddle-code-join-v1", &mut wrap_key)
3135 .expect("32 bytes is within HKDF limits");
3136 let wrapped = match passphrase::wrap(wrap_input.as_bytes(), &wrap_key) {
3139 Ok(w) => w,
3140 Err(e) => {
3141 warn!(%e, "CodeJoinRequest: wrap failed");
3142 return;
3143 }
3144 };
3145 let response = RoomMessage::CodeJoinResponse {
3146 room_id: room_id.to_string(),
3147 target_fingerprint: joiner_fp.clone(),
3148 owner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
3149 owner_session_id: our_session_id,
3150 wrapped_session_key_b64: wrapped,
3151 nonce_b64: String::new(), };
3153 if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
3154 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
3155 self.network
3156 .publish_room_message(room_id.to_string(), bytes)
3157 .await;
3158 }
3159 }
3160 info!(%joiner_fp, %room_id, "issued CodeJoinResponse");
3161 }
3162 RoomMessage::CodeJoinResponse {
3163 room_id: announced_room_id,
3164 target_fingerprint,
3165 owner_x25519_pubkey_b64,
3166 owner_session_id,
3167 wrapped_session_key_b64,
3168 nonce_b64: _,
3169 } => {
3170 if announced_room_id != room_id || target_fingerprint != our_fp {
3171 return;
3172 }
3173 let owner_fp = match verified_signer {
3174 Some(fp) => fp,
3175 None => {
3176 warn!("CodeJoinResponse unsigned; dropping");
3177 return;
3178 }
3179 };
3180 let our_secret = match self
3181 .pending_code_secrets
3182 .lock()
3183 .unwrap()
3184 .remove(&(room_id.to_string(), our_fp.clone()))
3185 {
3186 Some(s) => s,
3187 None => {
3188 warn!(%room_id, "CodeJoinResponse with no pending code-join state");
3189 return;
3190 }
3191 };
3192 let owner_pub = match crate::crypto::sas::parse_pubkey(&owner_x25519_pubkey_b64) {
3193 Ok(pk) => pk,
3194 Err(e) => {
3195 warn!(%e, "CodeJoinResponse: bad owner pubkey");
3196 return;
3197 }
3198 };
3199 let shared = our_secret.diffie_hellman(&owner_pub);
3200 let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
3201 let mut wrap_key = [0u8; passphrase::KEY_LEN];
3202 hk.expand(b"huddle-code-join-v1", &mut wrap_key)
3203 .expect("32 bytes within HKDF limits");
3204 let session_key_bytes =
3205 match passphrase::unwrap(&wrapped_session_key_b64, &wrap_key) {
3206 Ok(b) => b,
3207 Err(e) => {
3208 warn!(%e, "CodeJoinResponse: unwrap failed");
3209 return;
3210 }
3211 };
3212 let session_key_str = match String::from_utf8(session_key_bytes) {
3213 Ok(s) => s,
3214 Err(e) => {
3215 warn!(%e, "CodeJoinResponse: session key wasn't valid utf8");
3216 return;
3217 }
3218 };
3219 let mut rooms = self.active_rooms.lock().unwrap();
3221 if let Some(room) = rooms.get_mut(room_id) {
3222 if let Some(crypto) = room.crypto.as_mut() {
3223 if let Err(e) =
3224 crypto.add_inbound_session(&owner_fp, &session_key_str)
3225 {
3226 warn!(%e, "CodeJoinResponse: add_inbound_session failed");
3227 } else {
3228 info!(%room_id, %owner_fp, %owner_session_id, "code-join completed; can decrypt owner's messages");
3229 room.members.insert(owner_fp.clone());
3230 let _ = self.app_event_tx.send(AppEvent::MemberJoined {
3231 room_id: room_id.to_string(),
3232 fingerprint: owner_fp,
3233 });
3234 }
3235 }
3236 }
3237 }
3238 RoomMessage::JoinRefused {
3239 room_id: announced_room_id,
3240 target_fingerprint,
3241 reason,
3242 } => {
3243 if announced_room_id != room_id || target_fingerprint != our_fp {
3244 return;
3245 }
3246 let _ = self.app_event_tx.send(AppEvent::Error {
3250 description: format!("join refused: {reason}"),
3251 });
3252 }
3253 RoomMessage::SasConfirm { tx_id, matched } => {
3254 let signer = match verified_signer {
3255 Some(fp) => fp,
3256 None => return,
3257 };
3258 let (room_id_done, partner_fp_done, both_done) = {
3259 let mut flows = self.sas_flows.lock().unwrap();
3260 let flow = match flows.get_mut(&tx_id) {
3261 Some(f) => f,
3262 None => return,
3263 };
3264 if flow.partner_fingerprint != signer {
3265 return;
3266 }
3267 if !matched {
3268 let _ = flow;
3270 flows.remove(&tx_id);
3271 return;
3272 }
3273 flow.their_confirmed = true;
3274 if flow.our_confirmed && flow.their_confirmed && !flow.finalized {
3281 flow.finalized = true;
3282 (
3283 Some(flow.room_id.clone()),
3284 Some(flow.partner_fingerprint.clone()),
3285 true,
3286 )
3287 } else {
3288 (None, None, false)
3289 }
3290 };
3291 if both_done {
3292 if let (Some(rid), Some(pfp)) = (room_id_done, partner_fp_done) {
3293 if let Err(e) = self.finish_sas(&tx_id, &rid, &pfp).await {
3294 warn!(%e, "finish_sas failed");
3295 }
3296 }
3297 }
3298 }
3299 RoomMessage::ProfileUpdate {
3300 sender_fingerprint,
3301 username,
3302 updated_at,
3303 } => {
3304 let signer = match verified_signer {
3310 Some(fp) => fp,
3311 None => {
3312 warn!(
3313 sender = %sender_fingerprint,
3314 "dropping unsigned ProfileUpdate"
3315 );
3316 return;
3317 }
3318 };
3319 if signer != sender_fingerprint {
3320 warn!(
3321 signer = %signer,
3322 claimed = %sender_fingerprint,
3323 "dropping ProfileUpdate with signer != sender"
3324 );
3325 return;
3326 }
3327 if let Err(e) = repo::upsert_peer_profile(
3328 &self.db,
3329 &sender_fingerprint,
3330 username.as_deref(),
3331 updated_at,
3332 ) {
3333 warn!(%e, "upsert_peer_profile failed");
3334 return;
3335 }
3336 let _ = self.app_event_tx.send(AppEvent::PeerProfileUpdated {
3337 fingerprint: sender_fingerprint,
3338 username,
3339 });
3340 }
3341 }
3342 }
3343
3344 pub async fn send_file(&self, room_id: &str, path: &Path) -> Result<String> {
3352 let bytes = std::fs::read(path)?;
3353 let name = path
3354 .file_name()
3355 .map(|n| n.to_string_lossy().to_string())
3356 .unwrap_or_else(|| "untitled".into());
3357 let mime = crate::files::guess_mime(&name);
3358 let original_path = path.to_path_buf();
3359
3360 let (room_encrypted, mut maybe_session_id, encrypted_meta_opt, wire_bytes) = {
3361 let mut rooms = self.active_rooms.lock().unwrap();
3362 let room = rooms
3363 .get_mut(room_id)
3364 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3365 if room.read_only {
3370 return Err(HuddleError::Other(
3371 "this room is read-only — you can't send files".into(),
3372 ));
3373 }
3374 if room.info.encrypted {
3375 let crypto = room
3376 .crypto
3377 .as_mut()
3378 .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
3379 let (ciphertext, meta) = file_encryption::encrypt_file(&bytes, crypto)?;
3380 (true, Some(meta.megolm_session_id.clone()), Some(meta), ciphertext)
3381 } else {
3382 (false, None, None, bytes)
3383 }
3384 };
3385 let _ = &mut maybe_session_id; let plan =
3388 self.file_manager
3389 .prepare_outgoing_from_bytes(&name, mime.clone(), wire_bytes)?;
3390 let file_id = plan.file_id.clone();
3391 let total = plan.chunks.len() as u32;
3392 let our_fp = self.identity.fingerprint().to_string();
3393
3394 let attachment = StoredAttachment {
3395 id: 0,
3396 room_id: room_id.to_string(),
3397 message_id: None,
3398 sender_fingerprint: our_fp.clone(),
3399 file_id: file_id.clone(),
3400 name: name.clone(),
3401 mime: mime.clone(),
3402 size_bytes: plan.size_bytes as i64,
3403 status: AttachmentStatus::Ready,
3404 cache_path: Some(self.file_manager.cache_path(&file_id).to_string_lossy().into()),
3405 saved_path: Some(original_path.to_string_lossy().into()),
3406 error: None,
3407 encrypted: room_encrypted,
3408 wrapped_key: encrypted_meta_opt.as_ref().map(|m| m.wrapped_key_b64.clone()),
3409 nonce: encrypted_meta_opt.as_ref().map(|m| m.nonce_b64.clone()),
3410 megolm_session_id: encrypted_meta_opt
3411 .as_ref()
3412 .map(|m| m.megolm_session_id.clone()),
3413 content_hash: encrypted_meta_opt.as_ref().map(|m| m.content_hash.clone()),
3414 created_at: now_unix(),
3415 };
3416 repo::upsert_attachment(&self.db, &attachment)?;
3417 let _ = self.app_event_tx.send(AppEvent::FileOffered {
3418 room_id: room_id.to_string(),
3419 file_id: file_id.clone(),
3420 name: name.clone(),
3421 size_bytes: plan.size_bytes,
3422 sender_fingerprint: our_fp.clone(),
3423 });
3424
3425 let offer = RoomMessage::FileOffer {
3432 sender_fingerprint: our_fp.clone(),
3433 file_id: file_id.clone(),
3434 name,
3435 size_bytes: plan.size_bytes,
3436 mime,
3437 chunk_count: total,
3438 encrypted_meta: encrypted_meta_opt,
3439 };
3440 if let Ok(env) = crate::crypto::sign_message(&self.identity, &offer) {
3441 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
3442 self.network
3443 .publish_room_message(room_id.to_string(), bytes)
3444 .await;
3445 }
3446 }
3447
3448 let net = self.network.clone();
3451 let room = room_id.to_string();
3452 let our = our_fp.clone();
3453 let fid = file_id.clone();
3454 let chunks = plan.chunks.clone();
3455 tokio::spawn(async move {
3456 for (i, data) in chunks.iter().enumerate() {
3457 let msg = RoomMessage::FileChunk {
3458 sender_fingerprint: our.clone(),
3459 file_id: fid.clone(),
3460 chunk_index: i as u32,
3461 total_chunks: total,
3462 data_b64: B64.encode(data),
3463 };
3464 if let Ok(bytes) = encode_wire(&msg) {
3465 net.publish_room_message(room.clone(), bytes).await;
3466 }
3467 tokio::time::sleep(Duration::from_millis(40)).await;
3468 }
3469 });
3470
3471 Ok(file_id)
3472 }
3473
3474 pub async fn save_to_downloads(&self, room_id: &str, file_id: &str) -> Result<PathBuf> {
3477 let attachment = repo::get_attachment(&self.db, room_id, file_id)?
3478 .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
3479 if !matches!(
3480 attachment.status,
3481 AttachmentStatus::Ready | AttachmentStatus::Saved
3482 ) {
3483 return Err(HuddleError::Other(format!(
3484 "attachment is not ready (status={})",
3485 attachment.status.as_str()
3486 )));
3487 }
3488 let plaintext = if attachment.encrypted
3493 && attachment.sender_fingerprint == self.identity.fingerprint()
3494 {
3495 match attachment
3496 .saved_path
3497 .as_deref()
3498 .filter(|p| Path::new(p).exists())
3499 {
3500 Some(src) => std::fs::read(src)?,
3501 None => {
3502 return Err(HuddleError::Other(
3503 "your original file has moved or been deleted — it can't be \
3504 recovered from the encrypted cache"
3505 .into(),
3506 ));
3507 }
3508 }
3509 } else {
3510 let cached = self.file_manager.read_cache(file_id)?;
3511 if attachment.encrypted {
3512 let meta = EncryptedFileMeta {
3513 megolm_session_id: attachment
3514 .megolm_session_id
3515 .clone()
3516 .ok_or_else(|| HuddleError::Other("missing megolm_session_id".into()))?,
3517 wrapped_key_b64: attachment
3518 .wrapped_key
3519 .clone()
3520 .ok_or_else(|| HuddleError::Other("missing wrapped_key".into()))?,
3521 nonce_b64: attachment
3522 .nonce
3523 .clone()
3524 .ok_or_else(|| HuddleError::Other("missing nonce".into()))?,
3525 content_hash: attachment
3526 .content_hash
3527 .clone()
3528 .ok_or_else(|| HuddleError::Other("missing content_hash".into()))?,
3529 };
3530 self.decrypt_attachment(
3531 room_id,
3532 &attachment.sender_fingerprint,
3533 &cached,
3534 &meta,
3535 )?
3536 } else {
3537 cached
3538 }
3539 };
3540 let saved = self.file_manager.write_to_downloads(&attachment.name, &plaintext)?;
3541 repo::update_attachment_paths(
3542 &self.db,
3543 room_id,
3544 file_id,
3545 None,
3546 Some(&saved.to_string_lossy()),
3547 )?;
3548 repo::update_attachment_status(&self.db, room_id, file_id, AttachmentStatus::Saved, None)?;
3549 let _ = self.app_event_tx.send(AppEvent::FileSaved {
3550 file_id: file_id.into(),
3551 path: saved.to_string_lossy().into(),
3552 });
3553 Ok(saved)
3554 }
3555
3556 pub async fn cancel_transfer(&self, room_id: &str, file_id: &str) -> Result<()> {
3558 self.file_manager.cancel_incoming(file_id);
3559 repo::update_attachment_status(
3560 &self.db,
3561 room_id,
3562 file_id,
3563 AttachmentStatus::Cancelled,
3564 None,
3565 )?;
3566 Ok(())
3567 }
3568
3569 pub fn open_saved(&self, room_id: &str, file_id: &str) -> Result<()> {
3571 let attachment = repo::get_attachment(&self.db, room_id, file_id)?
3572 .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
3573 let path = attachment
3574 .saved_path
3575 .ok_or_else(|| HuddleError::Other("not saved yet — press Enter to save first".into()))?;
3576 open_with_system(&path)
3577 }
3578
3579 pub fn list_room_attachments(&self, room_id: &str) -> Result<Vec<StoredAttachment>> {
3580 repo::list_room_attachments(&self.db, room_id)
3581 }
3582
3583 pub fn set_member_verified(
3587 &self,
3588 room_id: &str,
3589 fingerprint: &str,
3590 verified: bool,
3591 ) -> Result<()> {
3592 let members = repo::list_room_members(&self.db, room_id).unwrap_or_default();
3597 if !members.iter().any(|m| m.fingerprint == fingerprint) {
3598 repo::upsert_room_member(
3599 &self.db,
3600 &StoredRoomMember {
3601 room_id: room_id.to_string(),
3602 peer_id: String::new(),
3603 fingerprint: fingerprint.to_string(),
3604 last_seen: Some(now_unix()),
3605 verified,
3606 ed25519_pubkey: None,
3607 role: "member".into(),
3608 },
3609 )?;
3610 }
3611 repo::set_member_verified(&self.db, room_id, fingerprint, verified)
3612 }
3613
3614 pub fn verified_fingerprints(&self, room_id: &str) -> Vec<String> {
3615 repo::list_verified_fingerprints(&self.db, room_id).unwrap_or_default()
3616 }
3617
3618 pub fn is_owner(&self, room_id: &str, fingerprint: &str) -> bool {
3621 repo::list_room_owners(&self.db, room_id)
3622 .unwrap_or_default()
3623 .iter()
3624 .any(|fp| fp == fingerprint)
3625 }
3626
3627 pub fn we_are_owner(&self, room_id: &str) -> bool {
3628 self.is_owner(room_id, &self.identity.fingerprint().to_string())
3629 }
3630
3631 pub fn room_owners(&self, room_id: &str) -> Vec<String> {
3634 repo::list_room_owners(&self.db, room_id).unwrap_or_default()
3635 }
3636
3637 pub fn has_master_passphrase(&self) -> bool {
3643 self.session_persist_key != [0u8; 32]
3644 }
3645
3646 pub fn verified_only_inbound(&self) -> bool {
3649 repo::get_setting(&self.db, "verified_only_inbound")
3650 .unwrap_or(None)
3651 .map(|v| v == "1")
3652 .unwrap_or(false)
3653 }
3654
3655 pub fn set_verified_only_inbound(&self, on: bool) -> Result<()> {
3656 repo::set_setting(&self.db, "verified_only_inbound", if on { "1" } else { "0" })
3657 }
3658
3659 pub fn mdns_enabled(&self) -> bool {
3669 repo::get_setting(&self.db, "mdns_enabled")
3670 .unwrap_or(None)
3671 .map(|v| v == "1")
3672 .unwrap_or(true)
3673 }
3674
3675 pub fn set_mdns_enabled(&self, on: bool) -> Result<()> {
3676 repo::set_setting(&self.db, "mdns_enabled", if on { "1" } else { "0" })
3677 }
3678
3679 pub fn notifications_enabled(&self) -> bool {
3685 repo::get_setting(&self.db, "notifications_enabled")
3686 .unwrap_or(None)
3687 .map(|v| v == "1")
3688 .unwrap_or(true)
3689 }
3690
3691 pub fn set_notifications_enabled(&self, on: bool) -> Result<()> {
3692 repo::set_setting(
3693 &self.db,
3694 "notifications_enabled",
3695 if on { "1" } else { "0" },
3696 )
3697 }
3698
3699 pub fn safety_code(&self) -> String {
3704 crate::identity::safety_code(&self.identity.public_bytes())
3705 }
3706
3707 pub fn room_verified_only(&self, room_id: &str) -> bool {
3712 repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
3713 }
3714
3715 pub fn set_room_verified_only(&self, room_id: &str, on: bool) -> Result<()> {
3716 repo::set_room_verified_only(&self.db, room_id, on)
3717 }
3718
3719 pub fn onboarding_seen(&self) -> bool {
3721 repo::is_onboarding_seen(&self.db).unwrap_or(true)
3722 }
3723
3724 pub fn mark_onboarding_seen(&self) -> Result<()> {
3725 repo::mark_onboarding_seen(&self.db)
3726 }
3727
3728 pub fn last_seen_onboarding_version(&self) -> Option<String> {
3732 repo::get_last_seen_onboarding_version(&self.db).unwrap_or(None)
3733 }
3734
3735 pub fn set_last_seen_onboarding_version(&self, version: &str) -> Result<()> {
3736 repo::set_last_seen_onboarding_version(&self.db, version)
3737 }
3738
3739 pub fn update_check_enabled(&self) -> Option<bool> {
3742 repo::get_update_check_enabled(&self.db).unwrap_or(None)
3743 }
3744
3745 pub fn set_update_check_enabled(&self, enabled: bool) -> Result<()> {
3746 repo::set_update_check_enabled(&self.db, enabled)
3747 }
3748
3749 pub fn last_update_check_at(&self) -> i64 {
3752 repo::get_setting(&self.db, "last_update_check_at")
3753 .ok()
3754 .flatten()
3755 .and_then(|s| s.parse().ok())
3756 .unwrap_or(0)
3757 }
3758
3759 pub fn set_last_update_check_at(&self, ts: i64) -> Result<()> {
3760 repo::set_setting(&self.db, "last_update_check_at", &ts.to_string())
3761 }
3762
3763 pub fn last_known_remote_version(&self) -> Option<String> {
3767 repo::get_setting(&self.db, "last_known_remote_version")
3768 .ok()
3769 .flatten()
3770 }
3771
3772 pub fn set_last_known_remote_version(&self, v: &str) -> Result<()> {
3773 repo::set_setting(&self.db, "last_known_remote_version", v)
3774 }
3775
3776 pub async fn grant_owner(&self, room_id: &str, target_fingerprint: &str) -> Result<()> {
3780 let our_fp = self.identity.fingerprint().to_string();
3781 if !self.is_owner(room_id, &our_fp) {
3782 return Err(HuddleError::Other(
3783 "only an owner can grant owner".into(),
3784 ));
3785 }
3786 let msg = RoomMessage::OwnerGrant {
3787 room_id: room_id.to_string(),
3788 target_fingerprint: target_fingerprint.to_string(),
3789 };
3790 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3791 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3792 self.network
3793 .publish_room_message(room_id.to_string(), bytes)
3794 .await;
3795 repo::set_member_role(&self.db, room_id, target_fingerprint, "owner")?;
3797 Ok(())
3798 }
3799
3800 pub async fn kick_member(
3811 &self,
3812 room_id: &str,
3813 target_fingerprint: &str,
3814 ) -> Result<String> {
3815 let our_fp = self.identity.fingerprint().to_string();
3816 if !self.is_owner(room_id, &our_fp) {
3817 return Err(HuddleError::Other("only an owner can kick".into()));
3818 }
3819 if target_fingerprint == our_fp {
3820 return Err(HuddleError::Other("can't kick yourself".into()));
3821 }
3822 let info = self
3823 .active_rooms
3824 .lock()
3825 .unwrap()
3826 .get(room_id)
3827 .map(|r| r.info.clone())
3828 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3829 if !info.encrypted {
3830 let msg = RoomMessage::BanMember {
3834 room_id: room_id.to_string(),
3835 target_fingerprint: target_fingerprint.to_string(),
3836 };
3837 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3838 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3839 self.network
3840 .publish_room_message(room_id.to_string(), bytes)
3841 .await;
3842 repo::add_room_ban(
3843 &self.db,
3844 room_id,
3845 target_fingerprint,
3846 &our_fp,
3847 &env.signature_b64,
3848 now_unix(),
3849 )?;
3850 self.evict_banned_member(room_id, target_fingerprint);
3851 return Ok(String::new());
3852 }
3853 let new_passphrase = generate_join_passphrase();
3855 let msg = RoomMessage::BanMember {
3856 room_id: room_id.to_string(),
3857 target_fingerprint: target_fingerprint.to_string(),
3858 };
3859 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3860 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3861 self.network
3862 .publish_room_message(room_id.to_string(), bytes)
3863 .await;
3864 repo::add_room_ban(
3865 &self.db,
3866 room_id,
3867 target_fingerprint,
3868 &our_fp,
3869 &env.signature_b64,
3870 now_unix(),
3871 )?;
3872 self.evict_banned_member(room_id, target_fingerprint);
3873 self.rotate_room(room_id, &new_passphrase).await?;
3876 Ok(new_passphrase)
3877 }
3878
3879 pub fn generate_join_code(&self, room_id: &str) -> Result<String> {
3886 let our_fp = self.identity.fingerprint().to_string();
3887 if !self.is_owner(room_id, &our_fp) {
3888 return Err(HuddleError::Other(
3889 "only an owner can issue join codes".into(),
3890 ));
3891 }
3892 let code = generate_alphanumeric_code(8);
3893 let expires_at = now_unix() + 10 * 60;
3894 let mut rooms = self.active_rooms.lock().unwrap();
3895 let room = rooms
3896 .get_mut(room_id)
3897 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3898 let now = now_unix();
3900 room.issued_codes.retain(|(_, exp)| *exp > now);
3901 room.issued_codes.push((code.clone(), expires_at));
3902 Ok(code)
3903 }
3904
3905 pub async fn join_room_with_code(
3912 &self,
3913 room_id: &str,
3914 code: &str,
3915 ) -> Result<()> {
3916 let info = {
3918 let d = self.discovered_rooms.lock().unwrap().get(room_id).cloned();
3919 match d {
3920 Some(d) => StoredRoom {
3921 id: room_id.to_string(),
3922 name: d.name,
3923 creator_fingerprint: d.creator_fingerprint,
3924 encrypted: d.encrypted,
3925 passphrase_salt: None, created_at: now_unix(),
3927 last_active: Some(now_unix()),
3928 kind: d.kind,
3931 },
3932 None => {
3933 return Err(HuddleError::Other(format!(
3934 "room {room_id} not visible — wait for an announcement"
3935 )))
3936 }
3937 }
3938 };
3939 if !info.encrypted {
3940 return Err(HuddleError::Other(
3941 "code-join only applies to encrypted rooms".into(),
3942 ));
3943 }
3944 let our_fp = self.identity.fingerprint().to_string();
3945 use x25519_dalek::{PublicKey, StaticSecret};
3948 let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
3949 let our_pub = PublicKey::from(&our_secret);
3950 let key = (room_id.to_string(), our_fp.clone());
3955 self.pending_code_secrets
3956 .lock()
3957 .unwrap()
3958 .insert(key.clone(), our_secret);
3959 let map = self.pending_code_secrets.clone();
3964 let tx = self.app_event_tx.clone();
3965 let timeout_room = room_id.to_string();
3966 tokio::spawn(async move {
3967 tokio::time::sleep(std::time::Duration::from_secs(30)).await;
3968 let still_pending = map.lock().unwrap().remove(&key).is_some();
3969 if still_pending {
3970 let _ = tx.send(AppEvent::CodeJoinTimedOut {
3971 room_id: timeout_room,
3972 reason: "no response from owner — code may be wrong or expired".into(),
3973 });
3974 }
3975 });
3976 repo::insert_room(&self.db, &info)?;
3983 self.active_rooms.lock().unwrap().insert(
3986 room_id.to_string(),
3987 ActiveRoom {
3988 info: info.clone(),
3989 crypto: Some(RoomCrypto::new_for_room(
3990 self.db.clone(),
3991 room_id.to_string(),
3992 our_fp.clone(),
3993 self.session_persist_key,
3994 )?),
3995 passphrase_key: None,
3996 members: {
3997 let mut s = HashSet::new();
3998 s.insert(our_fp.clone());
3999 s
4000 },
4001 typers: HashMap::new(),
4002 read_only: true,
4003 issued_codes: Vec::new(),
4004 },
4005 );
4006 self.network.subscribe_room(room_id.to_string()).await;
4007 let req = RoomMessage::CodeJoinRequest {
4009 room_id: room_id.to_string(),
4010 joiner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
4011 code: code.to_string(),
4012 };
4013 let env = crate::crypto::sign_message(&self.identity, &req)?;
4014 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
4015 self.network
4016 .publish_room_message(room_id.to_string(), bytes)
4017 .await;
4018 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
4021 room_id: room_id.to_string(),
4022 });
4023 Ok(())
4024 }
4025
4026 pub async fn sas_start(&self, room_id: &str, target_fingerprint: &str) -> Result<String> {
4032 let (tx_id_bytes, our_secret, our_pub) = crate::crypto::sas::new_session();
4033 let tx_id = B64.encode(tx_id_bytes);
4034 let msg = RoomMessage::SasInit {
4035 tx_id: tx_id.clone(),
4036 ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
4037 target_fingerprint: target_fingerprint.to_string(),
4038 };
4039 let env = crate::crypto::sign_message(&self.identity, &msg)?;
4040 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
4041 self.sas_flows.lock().unwrap().insert(
4042 tx_id.clone(),
4043 SasFlow {
4044 room_id: room_id.to_string(),
4045 partner_fingerprint: target_fingerprint.to_string(),
4046 our_secret,
4047 sas_code: None,
4048 our_confirmed: false,
4049 their_confirmed: false,
4050 finalized: false,
4051 },
4052 );
4053 self.network
4054 .publish_room_message(room_id.to_string(), bytes)
4055 .await;
4056 Ok(tx_id)
4057 }
4058
4059 pub async fn sas_match(&self, tx_id: &str) -> Result<()> {
4063 let (room_id, partner_fp, both_done) = {
4064 let mut flows = self.sas_flows.lock().unwrap();
4065 let flow = flows
4066 .get_mut(tx_id)
4067 .ok_or_else(|| HuddleError::Other("unknown SAS tx_id".into()))?;
4068 flow.our_confirmed = true;
4069 let do_finish = flow.our_confirmed && flow.their_confirmed && !flow.finalized;
4073 if do_finish {
4074 flow.finalized = true;
4075 }
4076 (
4077 flow.room_id.clone(),
4078 flow.partner_fingerprint.clone(),
4079 do_finish,
4080 )
4081 };
4082 let msg = RoomMessage::SasConfirm {
4083 tx_id: tx_id.to_string(),
4084 matched: true,
4085 };
4086 let env = crate::crypto::sign_message(&self.identity, &msg)?;
4087 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
4088 self.network
4089 .publish_room_message(room_id.clone(), bytes)
4090 .await;
4091 if both_done {
4092 self.finish_sas(tx_id, &room_id, &partner_fp).await?;
4093 }
4094 Ok(())
4095 }
4096
4097 pub fn sas_cancel(&self, tx_id: &str) {
4101 self.sas_flows.lock().unwrap().remove(tx_id);
4102 }
4103
4104 async fn finish_sas(
4107 &self,
4108 tx_id: &str,
4109 room_id: &str,
4110 partner_fingerprint: &str,
4111 ) -> Result<()> {
4112 repo::set_member_verified(&self.db, room_id, partner_fingerprint, true)?;
4113 repo::add_verified_peer(&self.db, partner_fingerprint, now_unix())?;
4114 self.sas_flows.lock().unwrap().remove(tx_id);
4115 let _ = self.app_event_tx.send(AppEvent::SasVerified {
4116 room_id: room_id.to_string(),
4117 partner_fingerprint: partner_fingerprint.to_string(),
4118 });
4119 Ok(())
4120 }
4121
4122 fn evict_banned_member(&self, room_id: &str, fingerprint: &str) {
4127 if let Some(room) = self.active_rooms.lock().unwrap().get_mut(room_id) {
4128 room.members.remove(fingerprint);
4129 }
4130 let _ = self.app_event_tx.send(AppEvent::MemberLeft {
4131 room_id: room_id.to_string(),
4132 fingerprint: fingerprint.to_string(),
4133 });
4134 }
4135
4136 pub fn display_name(&self) -> Option<String> {
4137 repo::get_display_name(&self.db).unwrap_or(None)
4138 }
4139
4140 pub fn set_display_name(&self, name: Option<&str>) -> Result<()> {
4141 repo::set_display_name(&self.db, name)
4142 }
4143
4144 pub async fn set_username(&self, name: Option<&str>) -> Result<()> {
4150 repo::set_display_name(&self.db, name)?;
4151 let msg = RoomMessage::ProfileUpdate {
4152 sender_fingerprint: self.identity.fingerprint().to_string(),
4153 username: name.map(|s| s.to_string()),
4154 updated_at: now_unix_ms(),
4155 };
4156 let env = crate::crypto::sign_message(&self.identity, &msg)?;
4157 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
4158 let rooms: Vec<String> = self.active_rooms.lock().unwrap().keys().cloned().collect();
4159 for room_id in rooms {
4160 self.network
4161 .publish_room_message(room_id, bytes.clone())
4162 .await;
4163 }
4164 Ok(())
4165 }
4166
4167 pub fn lookup_username(&self, fingerprint: &str) -> Option<String> {
4172 repo::get_peer_username(&self.db, fingerprint).unwrap_or(None)
4173 }
4174
4175 pub fn lookup_member_display_name(&self, fingerprint: &str) -> Option<String> {
4179 self.lookup_username(fingerprint)
4180 }
4181
4182 pub fn peers_with_username(&self, username: &str) -> Vec<String> {
4190 repo::find_peers_by_username(&self.db, username).unwrap_or_default()
4191 }
4192
4193 pub fn is_room_muted(&self, room_id: &str) -> bool {
4194 repo::is_room_muted(&self.db, room_id).unwrap_or(false)
4195 }
4196
4197 pub fn list_room_bans(&self, room_id: &str) -> Vec<String> {
4202 repo::list_room_bans(&self.db, room_id).unwrap_or_default()
4203 }
4204
4205 pub fn list_verified_peers(&self) -> Vec<String> {
4211 repo::list_verified_peers(&self.db).unwrap_or_default()
4212 }
4213
4214 pub fn list_blocked_peers(&self) -> Vec<String> {
4215 repo::list_blocked_peers(&self.db).unwrap_or_default()
4216 }
4217
4218 pub fn unblock_peer(&self, fingerprint: &str) -> Result<()> {
4222 repo::unblock_peer(&self.db, fingerprint)
4223 }
4224
4225 pub fn block_peer(&self, fingerprint: &str) -> Result<()> {
4229 repo::block_peer(&self.db, fingerprint, now_unix())
4230 }
4231
4232 pub fn is_room_read_only(&self, room_id: &str) -> bool {
4238 self.active_rooms
4239 .lock()
4240 .unwrap()
4241 .get(room_id)
4242 .map(|r| r.read_only)
4243 .unwrap_or(false)
4244 }
4245
4246 pub fn set_room_muted(&self, room_id: &str, muted: bool) -> Result<()> {
4247 repo::set_room_muted(&self.db, room_id, muted)
4248 }
4249
4250 pub async fn broadcast_typing(&self, room_id: &str) {
4253 if !self.active_rooms.lock().unwrap().contains_key(room_id) {
4254 return;
4255 }
4256 let msg = RoomMessage::Typing {
4257 sender_fingerprint: self.identity.fingerprint().to_string(),
4258 };
4259 if let Ok(bytes) = encode_wire(&msg) {
4260 self.network
4261 .publish_room_message(room_id.to_string(), bytes)
4262 .await;
4263 }
4264 }
4265
4266 pub fn typers_in_room(&self, room_id: &str) -> Vec<String> {
4269 let now = now_unix();
4270 let mut rooms = self.active_rooms.lock().unwrap();
4271 let room = match rooms.get_mut(room_id) {
4272 Some(r) => r,
4273 None => return Vec::new(),
4274 };
4275 room.typers.retain(|_, exp| *exp > now);
4276 let mut v: Vec<String> = room.typers.keys().cloned().collect();
4277 v.sort();
4278 v
4279 }
4280
4281 pub async fn rotate_room(&self, room_id: &str, new_passphrase: &str) -> Result<()> {
4291 if new_passphrase.is_empty() {
4292 return Err(HuddleError::Other("new passphrase is empty".into()));
4293 }
4294 let new_salt = passphrase::random_salt();
4295 let new_key = passphrase::derive_key(new_passphrase, &new_salt)?;
4296
4297 let info = {
4298 let mut rooms = self.active_rooms.lock().unwrap();
4299 let room = rooms
4300 .get_mut(room_id)
4301 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
4302 if !room.info.encrypted {
4303 return Err(HuddleError::Other(
4304 "rotation only applies to encrypted rooms".into(),
4305 ));
4306 }
4307 let new_crypto = RoomCrypto::new_for_room(
4309 self.db.clone(),
4310 room_id.to_string(),
4311 self.identity.fingerprint().to_string(),
4312 self.session_persist_key,
4313 )?;
4314 room.crypto = Some(new_crypto);
4315 room.passphrase_key = Some(new_key);
4316 room.info.passphrase_salt = Some(new_salt.to_vec());
4317 room.info.clone()
4318 };
4319
4320 let rot = RoomMessage::RotateRoomKey {
4326 rotator_fingerprint: self.identity.fingerprint().to_string(),
4327 new_salt: new_salt.to_vec(),
4328 };
4329 if let Ok(env) = crate::crypto::sign_message(&self.identity, &rot) {
4333 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
4334 self.network
4335 .publish_room_message(room_id.to_string(), bytes)
4336 .await;
4337 }
4338 }
4339 if let Err(e) = self.broadcast_member_announce(room_id).await {
4341 warn!(%e, "rotate: broadcast announce failed");
4342 }
4343
4344 repo::insert_room(&self.db, &info)?;
4346 Ok(())
4347 }
4348
4349 pub async fn accept_rotation(
4353 &self,
4354 room_id: &str,
4355 new_salt: &[u8],
4356 new_passphrase: &str,
4357 ) -> Result<()> {
4358 let new_key = passphrase::derive_key(new_passphrase, new_salt)?;
4359 let info = {
4360 let mut rooms = self.active_rooms.lock().unwrap();
4361 let room = rooms
4362 .get_mut(room_id)
4363 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
4364 room.passphrase_key = Some(new_key);
4365 room.info.passphrase_salt = Some(new_salt.to_vec());
4366 room.info.clone()
4367 };
4368 let req = RoomMessage::SessionKeyRequest {
4372 requester_fingerprint: self.identity.fingerprint().to_string(),
4373 };
4374 if let Ok(bytes) = encode_wire(&req) {
4375 self.network
4376 .publish_room_message(room_id.to_string(), bytes)
4377 .await;
4378 }
4379 repo::insert_room(&self.db, &info)?;
4380 Ok(())
4381 }
4382
4383 #[allow(clippy::too_many_arguments)]
4388 fn handle_file_offer(
4389 &self,
4390 room_id: &str,
4391 sender_fingerprint: String,
4392 file_id: String,
4393 name: String,
4394 size_bytes: u64,
4395 mime: Option<String>,
4396 _chunk_count: u32,
4397 encrypted_meta: Option<EncryptedFileMeta>,
4398 ) {
4399 let encrypted = encrypted_meta.is_some();
4400 let attachment = StoredAttachment {
4401 id: 0,
4402 room_id: room_id.to_string(),
4403 message_id: None,
4404 sender_fingerprint: sender_fingerprint.clone(),
4405 file_id: file_id.clone(),
4406 name: name.clone(),
4407 mime,
4408 size_bytes: size_bytes as i64,
4409 status: AttachmentStatus::Offered,
4410 cache_path: None,
4411 saved_path: None,
4412 error: None,
4413 encrypted,
4414 wrapped_key: encrypted_meta.as_ref().map(|m| m.wrapped_key_b64.clone()),
4415 nonce: encrypted_meta.as_ref().map(|m| m.nonce_b64.clone()),
4416 megolm_session_id: encrypted_meta.as_ref().map(|m| m.megolm_session_id.clone()),
4417 content_hash: encrypted_meta.as_ref().map(|m| m.content_hash.clone()),
4418 created_at: now_unix(),
4419 };
4420 if let Err(e) = repo::upsert_attachment(&self.db, &attachment) {
4421 warn!(%e, "upsert attachment");
4422 return;
4423 }
4424 self.file_manager.set_expected_size(&file_id, size_bytes);
4427 let _ = self.app_event_tx.send(AppEvent::FileOffered {
4428 room_id: room_id.to_string(),
4429 file_id,
4430 name,
4431 size_bytes,
4432 sender_fingerprint,
4433 });
4434 }
4435
4436 fn handle_file_chunk(
4437 &self,
4438 room_id: &str,
4439 _sender_fingerprint: String,
4440 file_id: String,
4441 chunk_index: u32,
4442 total_chunks: u32,
4443 data_b64: String,
4444 ) {
4445 let data = match B64.decode(&data_b64) {
4446 Ok(d) => d,
4447 Err(e) => {
4448 warn!(%e, "bad chunk base64");
4449 return;
4450 }
4451 };
4452 let expected_size = match repo::get_attachment(&self.db, room_id, &file_id) {
4456 Ok(Some(a)) => {
4457 if matches!(
4458 a.status,
4459 AttachmentStatus::Cancelled | AttachmentStatus::Failed
4460 ) {
4461 return;
4462 }
4463 a.size_bytes as u64
4464 }
4465 Ok(None) => crate::files::MAX_FILE_SIZE,
4466 Err(e) => {
4467 warn!(%e, "get attachment for chunk");
4468 crate::files::MAX_FILE_SIZE
4469 }
4470 };
4471
4472 let result = self.file_manager.accept_chunk(
4473 &file_id,
4474 chunk_index,
4475 total_chunks,
4476 data,
4477 expected_size,
4478 );
4479 match result {
4480 Ok(None) => {
4481 let _ = repo::update_attachment_status(
4483 &self.db,
4484 room_id,
4485 &file_id,
4486 AttachmentStatus::Downloading,
4487 None,
4488 );
4489 let bytes_so_far = self
4492 .file_manager
4493 .progress(&file_id)
4494 .map(|(b, _)| b)
4495 .unwrap_or(0);
4496 let _ = self.app_event_tx.send(AppEvent::FileProgress {
4497 file_id: file_id.clone(),
4498 bytes_received: bytes_so_far,
4499 total_bytes: expected_size,
4500 });
4501 }
4502 Ok(Some(completed)) => {
4503 let _ = repo::update_attachment_paths(
4504 &self.db,
4505 room_id,
4506 &file_id,
4507 Some(&completed.cache_path.to_string_lossy()),
4508 None,
4509 );
4510 let _ = repo::update_attachment_status(
4511 &self.db,
4512 room_id,
4513 &file_id,
4514 AttachmentStatus::Ready,
4515 None,
4516 );
4517 let _ = self.app_event_tx.send(AppEvent::FileReady {
4518 file_id: file_id.clone(),
4519 });
4520 }
4521 Err(e) => {
4522 let msg = e.to_string();
4523 warn!(%msg, "chunk processing failed");
4524 let _ = repo::update_attachment_status(
4525 &self.db,
4526 room_id,
4527 &file_id,
4528 AttachmentStatus::Failed,
4529 Some(&msg),
4530 );
4531 let _ = self.app_event_tx.send(AppEvent::FileFailed {
4532 file_id: file_id.clone(),
4533 reason: msg,
4534 });
4535 }
4536 }
4537 }
4538
4539 fn maybe_emit_mention(&self, room_id: &str, body: &str) {
4551 let full = self.identity.fingerprint().to_lowercase();
4552 let short: String = full.chars().filter(|c| c.is_ascii_hexdigit()).take(8).collect();
4555 let lower = body.to_lowercase();
4556 let hit = lower.contains(full.as_str())
4557 || lower
4558 .split(|c: char| !c.is_ascii_hexdigit())
4559 .any(|tok| tok == short);
4560 if hit {
4561 let _ = self.app_event_tx.send(AppEvent::MentionReceived {
4562 room_id: room_id.to_string(),
4563 body: body.to_string(),
4564 });
4565 }
4566 }
4567
4568 fn decrypt_attachment(
4569 &self,
4570 room_id: &str,
4571 sender_fingerprint: &str,
4572 ciphertext: &[u8],
4573 meta: &EncryptedFileMeta,
4574 ) -> Result<Vec<u8>> {
4575 let mut rooms = self.active_rooms.lock().unwrap();
4576 let room = rooms
4577 .get_mut(room_id)
4578 .ok_or_else(|| HuddleError::Other("not in room".into()))?;
4579 let crypto = room
4580 .crypto
4581 .as_mut()
4582 .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
4583 file_encryption::decrypt_file(ciphertext, meta, crypto, sender_fingerprint)
4584 }
4585
4586 pub async fn go_dark(&self, master_passphrase: &str) -> Result<()> {
4598 let no_master = self.session_persist_key == [0u8; 32];
4599 if !no_master {
4600 let salt = storage::keychain::load_or_create_salt()?;
4601 let candidate_master =
4602 storage::keychain::derive_master_key(master_passphrase, &salt)?;
4603 let candidate_subkey =
4604 storage::keychain::derive_subkey(&candidate_master, b"megolm-persist");
4605 if !ct_eq_32(&candidate_subkey, &self.session_persist_key) {
4606 return Err(HuddleError::Other(
4607 "incorrect master passphrase".into(),
4608 ));
4609 }
4610 }
4611
4612 let room_ids: Vec<String> = self
4613 .active_rooms
4614 .lock()
4615 .unwrap()
4616 .keys()
4617 .cloned()
4618 .collect();
4619 let _ = tokio::time::timeout(Duration::from_secs(2), async {
4620 for room_id in &room_ids {
4621 if let Err(e) = self.leave_room(room_id).await {
4622 warn!(%room_id, %e, "go_dark: leave_room failed");
4623 }
4624 }
4625 })
4626 .await;
4627
4628 self.network.shutdown().await;
4629 tokio::time::sleep(Duration::from_millis(300)).await;
4630
4631 let data_dir = config::data_dir();
4632 let candidates = [
4633 "huddle.db",
4634 "huddle.db-shm",
4635 "huddle.db-wal",
4636 "keychain.salt",
4637 "huddle.log",
4638 "config.toml",
4639 ];
4640 for name in &candidates {
4641 let path = data_dir.join(name);
4642 wipe_file(&path);
4643 }
4644 if let Ok(read) = std::fs::read_dir(&data_dir) {
4645 for entry in read.flatten() {
4646 if let Some(name) = entry.file_name().to_str() {
4647 if name.starts_with("huddle.log.") {
4648 wipe_file(&entry.path());
4649 }
4650 }
4651 }
4652 }
4653 let files_dir = data_dir.join("files");
4657 if let Ok(read) = std::fs::read_dir(&files_dir) {
4658 for entry in read.flatten() {
4659 let path = entry.path();
4660 if path.is_file() {
4661 wipe_file(&path);
4662 } else if path.is_dir() {
4663 if let Ok(inner) = std::fs::read_dir(&path) {
4666 for inner_entry in inner.flatten() {
4667 if inner_entry.path().is_file() {
4668 wipe_file(&inner_entry.path());
4669 }
4670 }
4671 }
4672 let _ = std::fs::remove_dir(&path);
4673 }
4674 }
4675 }
4676 let _ = std::fs::remove_dir(&files_dir);
4677 let _ = std::fs::remove_dir(&data_dir);
4678
4679 let _ = self.app_event_tx.send(AppEvent::WentDark);
4680 Ok(())
4681 }
4682}
4683
4684pub fn normalize_to_fingerprint(input: &str) -> Option<String> {
4691 let s = input
4692 .trim()
4693 .trim_start_matches("HD-")
4694 .trim_start_matches("hd-")
4695 .to_string();
4696 let hex_only: String = s.chars().filter(|c| *c != '-').collect();
4697 if hex_only.len() != 24 || !hex_only.chars().all(|c| c.is_ascii_hexdigit()) {
4698 return None;
4699 }
4700 let lower = hex_only.to_ascii_lowercase();
4701 let chunks: Vec<String> = lower
4702 .as_bytes()
4703 .chunks(4)
4704 .map(|c| std::str::from_utf8(c).unwrap().to_string())
4705 .collect();
4706 Some(chunks.join("-"))
4707}
4708
4709fn address_preference(addr: &str) -> u8 {
4715 if addr.contains("/p2p-circuit") {
4716 return 9; }
4718 if let Some(rest) = addr.strip_prefix("/ip4/") {
4719 if let Some(ip_str) = rest.split('/').next() {
4720 if let Ok(ip) = ip_str.parse::<std::net::Ipv4Addr>() {
4721 if ip.is_loopback() {
4722 return 1; }
4724 if is_rfc1918(&ip) || ip.is_link_local() {
4725 return 0; }
4727 return 3; }
4729 }
4730 return 3;
4731 }
4732 if addr.starts_with("/ip6/") {
4733 return 4;
4734 }
4735 if addr.starts_with("/dns4/") || addr.starts_with("/dns6/") || addr.starts_with("/dnsaddr/") {
4736 return 5;
4737 }
4738 7
4739}
4740
4741fn is_rfc1918(ip: &std::net::Ipv4Addr) -> bool {
4745 let octets = ip.octets();
4746 octets[0] == 10
4747 || (octets[0] == 172 && (16..=31).contains(&octets[1]))
4748 || (octets[0] == 192 && octets[1] == 168)
4749}
4750
4751fn short_fp_for_msg(fingerprint: &str) -> String {
4755 let head: String = fingerprint
4756 .chars()
4757 .filter(|c| *c != '-')
4758 .take(4)
4759 .collect::<String>()
4760 .to_ascii_uppercase();
4761 format!("HD-{}…", head)
4762}
4763
4764fn ct_eq_32(a: &[u8; 32], b: &[u8; 32]) -> bool {
4768 let mut diff = 0u8;
4769 for i in 0..32 {
4770 diff |= a[i] ^ b[i];
4771 }
4772 diff == 0
4773}
4774
4775fn wipe_file(path: &Path) {
4779 use std::io::Write;
4780 const SCRATCH: usize = 64 * 1024;
4786 if let Ok(meta) = std::fs::metadata(path) {
4787 if let Ok(mut f) = std::fs::OpenOptions::new().write(true).open(path) {
4788 let zeros = [0u8; SCRATCH];
4789 let mut remaining = meta.len();
4790 while remaining > 0 {
4791 let n = remaining.min(SCRATCH as u64) as usize;
4792 if f.write_all(&zeros[..n]).is_err() {
4793 break;
4794 }
4795 remaining -= n as u64;
4796 }
4797 let _ = f.sync_all();
4798 }
4799 }
4800 if let Err(e) = std::fs::remove_file(path) {
4801 if e.kind() != std::io::ErrorKind::NotFound {
4802 warn!(?path, %e, "wipe_file: remove failed");
4803 }
4804 }
4805}
4806
4807fn open_with_system(path: &str) -> Result<()> {
4809 #[cfg(target_os = "macos")]
4810 let cmd = "open";
4811 #[cfg(target_os = "linux")]
4812 let cmd = "xdg-open";
4813 #[cfg(target_os = "windows")]
4814 let cmd = "cmd";
4815 #[cfg(target_os = "windows")]
4816 let args = vec!["/C", "start", "", path];
4817 #[cfg(not(target_os = "windows"))]
4818 let args = vec![path];
4819
4820 std::process::Command::new(cmd)
4821 .args(args)
4822 .spawn()
4823 .map_err(|e| HuddleError::Other(format!("spawn opener: {e}")))?;
4824 Ok(())
4825}
4826
4827static ROOM_SALT_CACHE: std::sync::LazyLock<Mutex<HashMap<String, Vec<u8>>>> =
4830 std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
4831
4832pub fn salt_len() -> usize {
4837 SALT_LEN
4838}
4839
4840fn now_unix() -> i64 {
4841 SystemTime::now()
4842 .duration_since(UNIX_EPOCH)
4843 .unwrap()
4844 .as_secs() as i64
4845}
4846
4847fn now_unix_ms() -> i64 {
4848 SystemTime::now()
4849 .duration_since(UNIX_EPOCH)
4850 .unwrap()
4851 .as_millis() as i64
4852}
4853
4854fn generate_join_passphrase() -> String {
4860 use rand::RngCore;
4861 let mut bytes = [0u8; 16];
4862 rand::thread_rng().fill_bytes(&mut bytes);
4863 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
4866}
4867
4868fn generate_alphanumeric_code(len: usize) -> String {
4877 use rand::Rng;
4878 const ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ23456789";
4879 let mut rng = rand::thread_rng();
4880 let mut out = String::with_capacity(len + 1);
4881 for i in 0..len {
4882 if i == 4 && len == 8 {
4883 out.push('-'); }
4885 let idx = rng.gen_range(0..ALPHABET.len());
4886 out.push(ALPHABET[idx] as char);
4887 }
4888 out
4889}
4890
4891#[cfg(test)]
4892mod parser_tests {
4893 use super::parse_dial_address;
4894
4895 #[test]
4896 fn parses_ipv4_port() {
4897 let m = parse_dial_address("10.3.72.53:9027").unwrap();
4898 assert_eq!(m.to_string(), "/ip4/10.3.72.53/tcp/9027");
4899 }
4900
4901 #[test]
4902 fn parses_bracketed_ipv6() {
4903 let m = parse_dial_address("[::1]:9027").unwrap();
4904 assert_eq!(m.to_string(), "/ip6/::1/tcp/9027");
4905 }
4906
4907 #[test]
4908 fn rejects_unbracketed_ipv6() {
4909 let err = parse_dial_address("fe80::1:9027").unwrap_err();
4910 assert!(err.to_string().contains("brackets"));
4911 }
4912
4913 #[test]
4914 fn passes_through_raw_multiaddr() {
4915 let m = parse_dial_address("/ip4/1.2.3.4/tcp/9000").unwrap();
4916 assert_eq!(m.to_string(), "/ip4/1.2.3.4/tcp/9000");
4917 }
4918
4919 #[test]
4920 fn empty_address_is_error() {
4921 assert!(parse_dial_address(" ").is_err());
4922 }
4923
4924 #[test]
4925 fn rejects_bad_port() {
4926 assert!(parse_dial_address("1.2.3.4:notaport").is_err());
4927 }
4928}
4929
4930#[cfg(test)]
4931mod transport_preference_tests {
4932 use super::{address_preference, normalize_to_fingerprint};
4933
4934 #[test]
4935 fn lan_beats_public_beats_circuit() {
4936 let lan = address_preference("/ip4/192.168.1.5/tcp/9027");
4937 let pub_v4 = address_preference("/ip4/8.8.8.8/tcp/9027");
4938 let circuit = address_preference(
4939 "/ip4/1.2.3.4/tcp/4001/p2p/12D3Koo/p2p-circuit/p2p/12D3KooXYZ",
4940 );
4941 assert!(lan < pub_v4, "LAN {} should beat public {}", lan, pub_v4);
4942 assert!(
4943 pub_v4 < circuit,
4944 "public {} should beat circuit {}",
4945 pub_v4,
4946 circuit
4947 );
4948 }
4949
4950 #[test]
4951 fn all_rfc1918_ranges_are_lan() {
4952 assert_eq!(
4953 address_preference("/ip4/10.0.0.1/tcp/9027"),
4954 address_preference("/ip4/192.168.0.1/tcp/9027"),
4955 );
4956 assert_eq!(
4957 address_preference("/ip4/172.16.0.1/tcp/9027"),
4958 address_preference("/ip4/192.168.0.1/tcp/9027"),
4959 );
4960 assert!(
4962 address_preference("/ip4/172.32.0.1/tcp/9027")
4963 > address_preference("/ip4/172.16.0.1/tcp/9027")
4964 );
4965 }
4966
4967 #[test]
4968 fn normalize_id_accepts_branded_and_raw() {
4969 let canon = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4970 assert_eq!(
4971 normalize_to_fingerprint("HD-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF").as_deref(),
4972 Some(canon)
4973 );
4974 assert_eq!(
4975 normalize_to_fingerprint("aaaabbbbccccddddeeeeffff").as_deref(),
4976 Some(canon)
4977 );
4978 assert_eq!(normalize_to_fingerprint(canon).as_deref(), Some(canon));
4979 assert!(normalize_to_fingerprint("alice").is_none());
4980 assert!(normalize_to_fingerprint("HD-ZZZZ").is_none());
4981 }
4982}
4983
4984#[cfg(test)]
4985mod canonical_dm_room_id_tests {
4986 use super::canonical_dm_room_id;
4987
4988 #[test]
4989 fn dm_room_id_is_commutative() {
4990 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4993 let b = "1111-2222-3333-4444-5555-6666";
4994 assert_eq!(canonical_dm_room_id(a, b), canonical_dm_room_id(b, a));
4995 }
4996
4997 #[test]
4998 fn dm_room_id_differs_per_pair() {
4999 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
5000 let b = "1111-2222-3333-4444-5555-6666";
5001 let c = "9999-8888-7777-6666-5555-4444";
5002 assert_ne!(canonical_dm_room_id(a, b), canonical_dm_room_id(a, c));
5003 assert_ne!(canonical_dm_room_id(a, b), canonical_dm_room_id(b, c));
5004 }
5005
5006 #[test]
5007 fn dm_room_id_is_stable() {
5008 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
5012 let b = "1111-2222-3333-4444-5555-6666";
5013 let id1 = canonical_dm_room_id(a, b);
5014 let id2 = canonical_dm_room_id(a, b);
5015 assert_eq!(id1, id2);
5016 assert_eq!(id1.len(), 32);
5020 }
5021}