1pub mod events;
2
3use std::collections::{HashMap, HashSet};
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use base64::engine::general_purpose::STANDARD as B64;
9use base64::Engine;
10use libp2p::{Multiaddr, PeerId};
11use tokio::sync::broadcast;
12use tracing::{debug, error, info, warn};
13
14use crate::config;
15use crate::crypto::passphrase::{self, KEY_LEN, SALT_LEN};
16use crate::crypto::RoomCrypto;
17use crate::error::{HuddleError, Result};
18use crate::files::encryption::{self as file_encryption, EncryptedFileMeta};
19use crate::files::FileManager;
20use crate::identity::Identity;
21use crate::network::events::NetworkEvent;
22use crate::network::protocol::{encode_wire, RoomAnnouncement, RoomMessage, WireMessage};
23use crate::network::{self, NetworkHandle, NetworkMode};
24use crate::storage::repo::{
25 self, derive_room_id, AttachmentStatus, KnownPeer, RoomKind, StoredAttachment, StoredRoom,
26 StoredRoomMember,
27};
28use crate::storage::{self, Db};
29
30pub use self::events::{AppEvent, DiscoveredRoom};
31
32#[derive(Debug, Clone)]
35pub struct KnownPeerStatus {
36 pub address: String,
37 pub label: Option<String>,
38 pub last_connected_at: Option<i64>,
39 pub connected_peer_id: Option<PeerId>,
40 pub fingerprint: Option<String>,
44}
45
46pub fn canonical_dm_room_id(a: &str, b: &str) -> String {
57 use sha2::{Digest, Sha256};
58 let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
59 let mut hasher = Sha256::new();
60 hasher.update(b"huddle-dm-v1\0");
61 hasher.update(lo.as_bytes());
62 hasher.update(b"\0");
63 hasher.update(hi.as_bytes());
64 hex::encode(&hasher.finalize()[..16])
65}
66
67pub fn parse_dial_address(input: &str) -> Result<Multiaddr> {
70 let trimmed = input.trim();
71 if trimmed.is_empty() {
72 return Err(HuddleError::Other("address is empty".into()));
73 }
74 if trimmed.starts_with('/') {
75 return trimmed
76 .parse::<Multiaddr>()
77 .map_err(|e| HuddleError::Other(format!("invalid multiaddr: {e}")));
78 }
79 if let Some(rest) = trimmed.strip_prefix('[') {
80 let (host, port) = rest
81 .split_once("]:")
82 .ok_or_else(|| HuddleError::Other(format!("expected [ipv6]:port, got {trimmed}")))?;
83 let port: u16 = port
84 .parse()
85 .map_err(|_| HuddleError::Other(format!("invalid port: {port}")))?;
86 return format!("/ip6/{}/tcp/{}", host, port)
87 .parse::<Multiaddr>()
88 .map_err(|e| HuddleError::Other(format!("invalid ipv6 address: {e}")));
89 }
90 let (host, port) = trimmed
91 .rsplit_once(':')
92 .ok_or_else(|| HuddleError::Other(format!("expected ip:port, got {trimmed}")))?;
93 if host.contains(':') {
94 return Err(HuddleError::Other(format!(
95 "ambiguous IPv6 address — wrap host in brackets: [{host}]:{port}"
96 )));
97 }
98 let port: u16 = port
99 .parse()
100 .map_err(|_| HuddleError::Other(format!("invalid port: {port}")))?;
101 format!("/ip4/{}/tcp/{}", host, port)
102 .parse::<Multiaddr>()
103 .map_err(|e| HuddleError::Other(format!("invalid address: {e}")))
104}
105
106struct ActiveRoom {
108 info: StoredRoom,
109 crypto: Option<RoomCrypto>,
110 passphrase_key: Option<[u8; KEY_LEN]>,
113 members: HashSet<String>,
115 typers: HashMap<String, i64>,
118 read_only: bool,
125 issued_codes: Vec<(String, i64)>,
129}
130
131const TYPING_TTL_SECS: i64 = 3;
132
133const DISCOVERED_TTL_SECS: i64 = 45;
136const ANNOUNCE_INTERVAL_SECS: u64 = 15;
137
138struct SasFlow {
142 room_id: String,
143 partner_fingerprint: String,
144 our_secret: x25519_dalek::StaticSecret,
145 sas_code: Option<crate::crypto::sas::SasCode>,
147 our_confirmed: bool,
148 their_confirmed: bool,
149}
150
151#[derive(Clone)]
152pub struct AppHandle {
153 identity: Arc<Identity>,
154 network: NetworkHandle,
155 mode: NetworkMode,
156 active_rooms: Arc<Mutex<HashMap<String, ActiveRoom>>>,
157 discovered_rooms: Arc<Mutex<HashMap<String, DiscoveredRoom>>>,
158 restorable_rooms: Arc<Mutex<HashMap<String, StoredRoom>>>,
162 connected_dial_addrs: Arc<Mutex<HashMap<String, PeerId>>>,
165 file_manager: Arc<FileManager>,
167 db: Db,
168 session_persist_key: [u8; 32],
172 sas_flows: Arc<Mutex<HashMap<String, SasFlow>>>,
175 pending_code_secrets:
182 Arc<Mutex<HashMap<(String, String), x25519_dalek::StaticSecret>>>,
183 pending_invite_dials: Arc<Mutex<HashMap<String, String>>>,
196 nat_reachable_addrs: Arc<Mutex<HashSet<String>>>,
202 relay_circuit_addrs: Arc<Mutex<HashSet<String>>>,
208 host_addr_dial_attempts: Arc<Mutex<HashMap<String, i64>>>,
213 last_profile_broadcast_at_ms: Arc<Mutex<HashMap<String, i64>>>,
220 pending_auto_dm_addrs: Arc<Mutex<HashSet<String>>>,
227 app_event_tx: broadcast::Sender<AppEvent>,
228}
229
230const HOST_ADDR_DIAL_BACKOFF_SECS: i64 = 300;
233
234const PROFILE_REBROADCAST_FLOOR_MS: i64 = 60_000;
238
239impl AppHandle {
240 pub async fn start() -> Result<Self> {
241 Self::start_with_options(NetworkMode::Mdns, 0, None, Vec::new()).await
242 }
243
244 pub fn peek_mdns_enabled(master_key: Option<&[u8; 32]>) -> Result<bool> {
252 config::ensure_data_dir()?;
253 let db = storage::open_db(&config::db_path(), master_key)?;
254 let v = repo::get_setting(&db, "mdns_enabled")?
255 .map(|s| s == "1")
256 .unwrap_or(true);
257 Ok(v)
258 }
259
260 pub async fn start_with_options(
261 mode: NetworkMode,
262 port: u16,
263 master_key: Option<&[u8; 32]>,
264 relays: Vec<Multiaddr>,
265 ) -> Result<Self> {
266 config::ensure_data_dir()?;
267 let session_persist_key = match master_key {
272 Some(mk) => storage::keychain::derive_subkey(mk, b"megolm-persist"),
273 None => [0u8; 32],
274 };
275 let db = storage::open_db(&config::db_path(), master_key)?;
276 Self::start_with_db_and_options(db, mode, port, session_persist_key, relays).await
277 }
278
279 pub async fn start_with_db(db: Db) -> Result<Self> {
280 Self::start_with_db_and_options(db, NetworkMode::Mdns, 0, [0u8; 32], Vec::new()).await
281 }
282
283 pub async fn start_with_db_and_options(
284 db: Db,
285 mode: NetworkMode,
286 port: u16,
287 session_persist_key: [u8; 32],
288 relays: Vec<Multiaddr>,
289 ) -> Result<Self> {
290 let identity = Self::load_or_create_identity(&db)?;
291 let identity = Arc::new(identity);
292 info!(fingerprint = %identity.fingerprint(), peer_id = %identity.peer_id(), mode = %mode.as_str(), port, relay_count = relays.len(), "identity loaded");
293
294 let (net_event_tx, net_event_rx) = tokio::sync::mpsc::channel::<NetworkEvent>(256);
295 let (app_event_tx, _) = broadcast::channel::<AppEvent>(256);
296 let network =
297 network::start_network_with(&identity, net_event_tx, mode, port, relays)?;
298
299 let active_rooms = Arc::new(Mutex::new(HashMap::new()));
300 let discovered_rooms = Arc::new(Mutex::new(HashMap::new()));
301 let restorable_rooms = Arc::new(Mutex::new(HashMap::new()));
302 let connected_dial_addrs = Arc::new(Mutex::new(HashMap::new()));
303 let file_manager = Arc::new(FileManager::new(&config::data_dir())?);
304
305 let handle = Self {
306 identity,
307 network,
308 mode,
309 active_rooms,
310 discovered_rooms,
311 restorable_rooms,
312 connected_dial_addrs,
313 file_manager,
314 db,
315 session_persist_key,
316 sas_flows: Arc::new(Mutex::new(HashMap::new())),
317 pending_code_secrets: Arc::new(Mutex::new(HashMap::new())),
318 pending_invite_dials: Arc::new(Mutex::new(HashMap::new())),
319 nat_reachable_addrs: Arc::new(Mutex::new(HashSet::new())),
320 relay_circuit_addrs: Arc::new(Mutex::new(HashSet::new())),
321 host_addr_dial_attempts: Arc::new(Mutex::new(HashMap::new())),
322 last_profile_broadcast_at_ms: Arc::new(Mutex::new(HashMap::new())),
323 pending_auto_dm_addrs: Arc::new(Mutex::new(HashSet::new())),
324 app_event_tx,
325 };
326
327 handle.spawn_event_processor(net_event_rx);
328 handle.spawn_announcement_ticker();
329 handle.spawn_discovered_room_pruner();
330 handle.spawn_known_peer_reconnector();
331 handle.restore_rooms_from_db().await;
332 if let Err(e) = repo::cleanup_expired_pending_friend_requests(&handle.db, now_unix()) {
336 warn!(%e, "failed to sweep expired pending friend requests");
337 }
338
339 Ok(handle)
340 }
341
342 pub fn mode(&self) -> NetworkMode {
343 self.mode
344 }
345
346 pub fn subscribe(&self) -> broadcast::Receiver<AppEvent> {
347 self.app_event_tx.subscribe()
348 }
349
350 pub fn fingerprint(&self) -> &str {
351 self.identity.fingerprint()
352 }
353
354 pub fn peer_id(&self) -> PeerId {
355 self.identity.peer_id()
356 }
357
358 pub fn discovered_rooms(&self) -> Vec<DiscoveredRoom> {
359 let now = now_unix();
360 let our_fp = self.identity.fingerprint().to_string();
361 let mut by_id: HashMap<String, DiscoveredRoom> = self
362 .discovered_rooms
363 .lock()
364 .unwrap()
365 .clone();
366
367 for room in self.active_rooms.lock().unwrap().values() {
371 let entry = DiscoveredRoom {
372 room_id: room.info.id.clone(),
373 name: room.info.name.clone(),
374 encrypted: room.info.encrypted,
375 member_count: room.members.len() as u32,
376 creator_fingerprint: room.info.creator_fingerprint.clone(),
377 last_seen: now,
378 restorable: false,
379 host_addrs: Vec::new(),
380 kind: room.info.kind,
381 };
382 by_id
383 .entry(room.info.id.clone())
384 .and_modify(|d| {
385 d.last_seen = now;
386 if entry.member_count > d.member_count {
387 d.member_count = entry.member_count;
388 }
389 d.restorable = false;
390 d.kind = entry.kind;
391 })
392 .or_insert(entry);
393 }
394
395 for (id, stored) in self.restorable_rooms.lock().unwrap().iter() {
399 if by_id.contains_key(id) {
400 continue;
401 }
402 by_id.insert(
403 id.clone(),
404 DiscoveredRoom {
405 room_id: id.clone(),
406 name: stored.name.clone(),
407 encrypted: stored.encrypted,
408 member_count: 0,
409 creator_fingerprint: stored.creator_fingerprint.clone(),
410 last_seen: stored.last_active.unwrap_or(stored.created_at),
411 restorable: true,
412 host_addrs: Vec::new(),
413 kind: stored.kind,
414 },
415 );
416 }
417
418 by_id.retain(|room_id, d| {
426 if d.kind != RoomKind::Direct {
427 return true;
428 }
429 if self
432 .active_rooms
433 .lock()
434 .unwrap()
435 .contains_key(room_id)
436 {
437 return true;
438 }
439 canonical_dm_room_id(&our_fp, &d.creator_fingerprint) == *room_id
442 });
443
444 let mut v: Vec<DiscoveredRoom> = by_id.into_values().collect();
445 v.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
446 v
447 }
448
449 pub fn dm_partner_fingerprint(&self, room_id: &str) -> Option<String> {
454 let our_fp = self.identity.fingerprint().to_string();
455 let rooms = self.active_rooms.lock().unwrap();
456 let room = rooms.get(room_id)?;
457 if room.info.kind != RoomKind::Direct {
458 return None;
459 }
460 room.members
461 .iter()
462 .find(|m| **m != our_fp)
463 .cloned()
464 }
465
466 pub fn active_room_ids(&self) -> Vec<String> {
467 self.active_rooms.lock().unwrap().keys().cloned().collect()
468 }
469
470 pub fn active_room_info(&self, room_id: &str) -> Option<StoredRoom> {
471 self.active_rooms
472 .lock()
473 .unwrap()
474 .get(room_id)
475 .map(|r| r.info.clone())
476 }
477
478 pub fn room_members(&self, room_id: &str) -> Vec<String> {
479 self.active_rooms
480 .lock()
481 .unwrap()
482 .get(room_id)
483 .map(|r| {
484 let mut m: Vec<String> = r.members.iter().cloned().collect();
485 m.sort();
486 m
487 })
488 .unwrap_or_default()
489 }
490
491 pub fn room_messages(&self, room_id: &str, limit: i64) -> Result<Vec<repo::StoredRoomMessage>> {
492 repo::get_room_messages(&self.db, room_id, limit)
493 }
494
495 pub fn search_room_messages(
496 &self,
497 room_id: &str,
498 query: &str,
499 limit: i64,
500 ) -> Result<Vec<repo::StoredRoomMessage>> {
501 repo::search_room_messages(&self.db, room_id, query, limit)
502 }
503
504 pub async fn start_room(
512 &self,
513 name: &str,
514 encrypted: bool,
515 passphrase: Option<&str>,
516 kind: RoomKind,
517 ) -> Result<String> {
518 if encrypted && passphrase.is_none() {
519 return Err(HuddleError::Other(
520 "encrypted room requires a passphrase".into(),
521 ));
522 }
523
524 let created_at = now_unix();
525 let creator_fp = self.identity.fingerprint().to_string();
526 let room_id = derive_room_id(&creator_fp, name, created_at);
527
528 let (passphrase_salt, passphrase_key) = if encrypted {
529 let salt = passphrase::random_salt();
530 let key = passphrase::derive_key(passphrase.unwrap(), &salt)?;
531 (Some(salt.to_vec()), Some(key))
532 } else {
533 (None, None)
534 };
535
536 let info = StoredRoom {
537 id: room_id.clone(),
538 name: name.to_string(),
539 creator_fingerprint: creator_fp.clone(),
540 encrypted,
541 passphrase_salt: passphrase_salt.clone(),
542 created_at,
543 last_active: Some(created_at),
544 kind,
545 };
546 repo::insert_room(&self.db, &info)?;
547
548 let crypto = if encrypted {
549 Some(RoomCrypto::new_for_room(
550 self.db.clone(),
551 room_id.clone(),
552 creator_fp.clone(),
553 self.session_persist_key,
554 )?)
555 } else {
556 None
557 };
558
559 let mut members = HashSet::new();
560 members.insert(creator_fp.clone());
561
562 repo::upsert_room_member(
566 &self.db,
567 &StoredRoomMember {
568 room_id: room_id.clone(),
569 peer_id: String::new(),
570 fingerprint: creator_fp.clone(),
571 last_seen: Some(created_at),
572 verified: true, ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
574 role: "owner".into(),
575 },
576 )?;
577
578 self.active_rooms.lock().unwrap().insert(
579 room_id.clone(),
580 ActiveRoom {
581 info: info.clone(),
582 crypto,
583 passphrase_key,
584 members,
585 typers: HashMap::new(),
586 read_only: false,
587 issued_codes: Vec::new(),
588 },
589 );
590
591 self.network.subscribe_room(room_id.clone()).await;
592 self.announce_room_now(&info, 1).await;
593
594 let app = self.clone();
597 let rid = room_id.clone();
598 tokio::spawn(async move {
599 tokio::time::sleep(Duration::from_millis(500)).await;
600 if let Err(e) = app.broadcast_member_announce(&rid).await {
601 warn!(%e, "broadcast member announce");
602 }
603 });
604
605 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
606 room_id: room_id.clone(),
607 });
608
609 Ok(room_id)
610 }
611
612 pub async fn start_direct(&self, partner_fingerprint: &str) -> Result<String> {
636 let our_fp = self.identity.fingerprint().to_string();
637 if partner_fingerprint == our_fp {
638 return Err(HuddleError::Other("cannot DM yourself".into()));
639 }
640 let room_id = canonical_dm_room_id(&our_fp, partner_fingerprint);
641
642 if self.active_rooms.lock().unwrap().contains_key(&room_id) {
647 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
648 room_id: room_id.clone(),
649 });
650 return Ok(room_id);
651 }
652 if repo::get_room(&self.db, &room_id)?.is_some() {
653 return self.bootstrap_direct_room(&room_id, partner_fingerprint).await;
655 }
656
657 let created_at = now_unix();
658 let name = format!("dm-{}", short_fp_for_msg(partner_fingerprint));
662
663 let dm_salt = hex::decode(&room_id).unwrap_or_else(|_| room_id.as_bytes().to_vec());
670 let info = StoredRoom {
671 id: room_id.clone(),
672 name,
673 creator_fingerprint: our_fp.clone(),
674 encrypted: true,
675 passphrase_salt: Some(dm_salt),
676 created_at,
677 last_active: Some(created_at),
678 kind: RoomKind::Direct,
679 };
680 repo::insert_room(&self.db, &info)?;
681
682 let mut members = HashSet::new();
683 members.insert(our_fp.clone());
684 repo::upsert_room_member(
685 &self.db,
686 &StoredRoomMember {
687 room_id: room_id.clone(),
688 peer_id: String::new(),
689 fingerprint: our_fp.clone(),
690 last_seen: Some(created_at),
691 verified: true,
692 ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
693 role: "member".into(),
694 },
695 )?;
696
697 let passphrase_key = self.try_derive_dm_key(&room_id, partner_fingerprint);
704
705 let crypto = Some(RoomCrypto::new_for_room(
710 self.db.clone(),
711 room_id.clone(),
712 our_fp.clone(),
713 self.session_persist_key,
714 )?);
715
716 self.active_rooms.lock().unwrap().insert(
717 room_id.clone(),
718 ActiveRoom {
719 info: info.clone(),
720 crypto,
721 passphrase_key,
722 members,
723 typers: HashMap::new(),
724 read_only: false,
725 issued_codes: Vec::new(),
726 },
727 );
728
729 self.network.subscribe_room(room_id.clone()).await;
730 self.announce_room_now(&info, 1).await;
731
732 let app = self.clone();
733 let rid = room_id.clone();
734 tokio::spawn(async move {
735 tokio::time::sleep(Duration::from_millis(500)).await;
736 if let Err(e) = app.broadcast_member_announce(&rid).await {
737 warn!(%e, "broadcast member announce for DM");
738 }
739 });
740
741 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
742 room_id: room_id.clone(),
743 });
744 Ok(room_id)
745 }
746
747 fn derive_dm_key_from_pubkey_b64(
752 &self,
753 room_id: &str,
754 pubkey_b64: &str,
755 ) -> Option<[u8; KEY_LEN]> {
756 let bytes = B64.decode(pubkey_b64).ok()?;
757 if bytes.len() != 32 {
758 return None;
759 }
760 let mut pubkey = [0u8; 32];
761 pubkey.copy_from_slice(&bytes);
762 let our_seed = self.identity.secret_bytes();
763 match crate::crypto::dm::derive_dm_key(&our_seed, &pubkey, room_id) {
764 Ok(k) => Some(k),
765 Err(e) => {
766 warn!(%e, "DM key derivation (from announce) failed");
767 None
768 }
769 }
770 }
771
772 fn try_derive_dm_key(
777 &self,
778 room_id: &str,
779 partner_fingerprint: &str,
780 ) -> Option<[u8; KEY_LEN]> {
781 let pubkey_b64 = repo::lookup_peer_ed25519_pubkey(&self.db, partner_fingerprint)
782 .ok()
783 .flatten()?;
784 let bytes = B64.decode(&pubkey_b64).ok()?;
785 if bytes.len() != 32 {
786 return None;
787 }
788 let mut pubkey = [0u8; 32];
789 pubkey.copy_from_slice(&bytes);
790 let our_seed = self.identity.secret_bytes();
791 match crate::crypto::dm::derive_dm_key(&our_seed, &pubkey, room_id) {
792 Ok(k) => Some(k),
793 Err(e) => {
794 warn!(%e, %partner_fingerprint, "DM key derivation failed");
795 None
796 }
797 }
798 }
799
800 async fn bootstrap_direct_room(
806 &self,
807 room_id: &str,
808 partner_fingerprint: &str,
809 ) -> Result<String> {
810 let our_fp = self.identity.fingerprint().to_string();
811 let info = repo::get_room(&self.db, room_id)?
812 .ok_or_else(|| HuddleError::Other(format!("DM room {room_id} not found on disk")))?;
813 let mut members = HashSet::new();
814 members.insert(our_fp.clone());
815 members.insert(partner_fingerprint.to_string());
816
817 if let Ok(stored_members) = repo::list_room_members(&self.db, room_id) {
819 for m in stored_members {
820 members.insert(m.fingerprint);
821 }
822 }
823
824 let (passphrase_key, crypto) = if info.encrypted {
832 let pk = self.try_derive_dm_key(room_id, partner_fingerprint);
833 let c = Some(RoomCrypto::load(
834 self.db.clone(),
835 room_id.to_string(),
836 our_fp.clone(),
837 self.session_persist_key,
838 )?
839 .unwrap_or_else(|| {
840 RoomCrypto::new_for_room(
841 self.db.clone(),
842 room_id.to_string(),
843 our_fp.clone(),
844 self.session_persist_key,
845 )
846 .expect("create RoomCrypto for DM re-bootstrap")
847 }));
848 (pk, c)
849 } else {
850 (None, None)
851 };
852
853 self.active_rooms.lock().unwrap().insert(
854 room_id.to_string(),
855 ActiveRoom {
856 info: info.clone(),
857 crypto,
858 passphrase_key,
859 members,
860 typers: HashMap::new(),
861 read_only: false,
862 issued_codes: Vec::new(),
863 },
864 );
865
866 self.network.subscribe_room(room_id.to_string()).await;
867 self.announce_room_now(&info, 2).await;
868
869 let app = self.clone();
870 let rid = room_id.to_string();
871 tokio::spawn(async move {
872 tokio::time::sleep(Duration::from_millis(500)).await;
873 if let Err(e) = app.broadcast_member_announce(&rid).await {
874 warn!(%e, "broadcast member announce on DM bootstrap");
875 }
876 });
877
878 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
879 room_id: room_id.to_string(),
880 });
881 Ok(room_id.to_string())
882 }
883
884 pub async fn join_room(&self, room_id: &str, passphrase: Option<&str>) -> Result<()> {
888 let (name, creator_fingerprint, encrypted, salt_opt) = {
890 if let Some(d) = self.discovered_rooms.lock().unwrap().get(room_id).cloned() {
891 let salt = self.get_room_salt(room_id);
892 (d.name, d.creator_fingerprint, d.encrypted, salt)
893 } else if let Some(stored) = self.restorable_rooms.lock().unwrap().get(room_id).cloned()
894 {
895 (
896 stored.name,
897 stored.creator_fingerprint,
898 stored.encrypted,
899 stored.passphrase_salt,
900 )
901 } else if let Some(stored) = repo::get_room(&self.db, room_id)? {
902 (
903 stored.name,
904 stored.creator_fingerprint,
905 stored.encrypted,
906 stored.passphrase_salt,
907 )
908 } else {
909 return Err(HuddleError::Other(format!("room {room_id} not found")));
910 }
911 };
912
913 if encrypted && passphrase.is_none() {
914 return Err(HuddleError::Other(
915 "encrypted room requires a passphrase".into(),
916 ));
917 }
918
919 let passphrase_key = if encrypted {
920 let salt = salt_opt
921 .clone()
922 .ok_or_else(|| HuddleError::Other("missing salt for encrypted room".into()))?;
923 Some(passphrase::derive_key(passphrase.unwrap(), &salt)?)
924 } else {
925 None
926 };
927
928 let kind = self
933 .discovered_rooms
934 .lock()
935 .unwrap()
936 .get(room_id)
937 .map(|d| d.kind)
938 .or_else(|| {
939 repo::get_room(&self.db, room_id)
940 .ok()
941 .flatten()
942 .map(|r| r.kind)
943 })
944 .unwrap_or_default();
945
946 let info = StoredRoom {
947 id: room_id.to_string(),
948 name,
949 creator_fingerprint,
950 encrypted,
951 passphrase_salt: salt_opt.clone(),
952 created_at: now_unix(),
953 last_active: Some(now_unix()),
954 kind,
955 };
956 repo::insert_room(&self.db, &info)?;
957
958 let crypto = if encrypted {
959 let our_fp = self.identity.fingerprint().to_string();
962 let existing = RoomCrypto::load(
963 self.db.clone(),
964 room_id.to_string(),
965 our_fp.clone(),
966 self.session_persist_key,
967 )?;
968 Some(match existing {
969 Some(c) => c,
970 None => RoomCrypto::new_for_room(
971 self.db.clone(),
972 room_id.to_string(),
973 our_fp,
974 self.session_persist_key,
975 )?,
976 })
977 } else {
978 None
979 };
980
981 let mut members = HashSet::new();
982 members.insert(self.identity.fingerprint().to_string());
983
984 self.active_rooms.lock().unwrap().insert(
985 room_id.to_string(),
986 ActiveRoom {
987 info: info.clone(),
988 crypto,
989 passphrase_key,
990 members,
991 typers: HashMap::new(),
992 read_only: false,
993 issued_codes: Vec::new(),
994 },
995 );
996 self.restorable_rooms.lock().unwrap().remove(room_id);
998
999 self.network.subscribe_room(room_id.to_string()).await;
1000
1001 let app = self.clone();
1002 let rid = room_id.to_string();
1003 tokio::spawn(async move {
1004 tokio::time::sleep(Duration::from_millis(500)).await;
1005 if let Err(e) = app.broadcast_member_announce(&rid).await {
1006 warn!(%e, "broadcast member announce");
1007 }
1008 let req = RoomMessage::SessionKeyRequest {
1010 requester_fingerprint: app.identity.fingerprint().to_string(),
1011 };
1012 if let Ok(bytes) = encode_wire(&req) {
1013 app.network.publish_room_message(rid.clone(), bytes).await;
1014 }
1015 });
1016
1017 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
1018 room_id: room_id.to_string(),
1019 });
1020
1021 Ok(())
1022 }
1023
1024 async fn restore_rooms_from_db(&self) {
1029 let rooms = match repo::list_rooms(&self.db) {
1030 Ok(v) => v,
1031 Err(e) => {
1032 warn!(%e, "list rooms on restore");
1033 return;
1034 }
1035 };
1036 let our_fp = self.identity.fingerprint().to_string();
1037 let count = rooms.len();
1038 for info in rooms {
1039 if info.encrypted {
1040 self.restorable_rooms
1041 .lock()
1042 .unwrap()
1043 .insert(info.id.clone(), info);
1044 continue;
1045 }
1046 let mut members = HashSet::new();
1047 members.insert(our_fp.clone());
1048 if let Ok(stored_members) = repo::list_room_members(&self.db, &info.id) {
1049 for m in stored_members {
1050 members.insert(m.fingerprint);
1051 }
1052 }
1053 let member_count = members.len() as u32;
1054 self.active_rooms.lock().unwrap().insert(
1055 info.id.clone(),
1056 ActiveRoom {
1057 info: info.clone(),
1058 crypto: None,
1059 passphrase_key: None,
1060 members,
1061 typers: HashMap::new(),
1062 read_only: false,
1063 issued_codes: Vec::new(),
1064 },
1065 );
1066 self.network.subscribe_room(info.id.clone()).await;
1067 self.announce_room_now(&info, member_count).await;
1068 info!(room_id = %info.id, name = %info.name, "restored room");
1069 }
1070 if count > 0 {
1071 debug!(count, "restored rooms from db");
1072 }
1073 }
1074
1075 pub async fn leave_room(&self, room_id: &str) -> Result<bool> {
1080 let leave_msg = RoomMessage::MemberLeave {
1082 sender_fingerprint: self.identity.fingerprint().to_string(),
1083 };
1084 let dispatched = match encode_wire(&leave_msg) {
1085 Ok(bytes) => {
1086 self.network
1087 .publish_room_message(room_id.to_string(), bytes)
1088 .await;
1089 true
1090 }
1091 Err(e) => {
1092 warn!(%e, %room_id, "failed to encode MemberLeave notice");
1093 false
1094 }
1095 };
1096
1097 self.active_rooms.lock().unwrap().remove(room_id);
1098 self.network.unsubscribe_room(room_id.to_string()).await;
1099
1100 let _ = self.app_event_tx.send(AppEvent::RoomLeft {
1101 room_id: room_id.to_string(),
1102 });
1103 Ok(dispatched)
1104 }
1105
1106 pub async fn send_room_message(&self, room_id: &str, body: &str) -> Result<()> {
1107 let our_fp = self.identity.fingerprint().to_string();
1108 let msg = {
1109 let mut rooms = self.active_rooms.lock().unwrap();
1110 let room = rooms
1111 .get_mut(room_id)
1112 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
1113
1114 if room.read_only {
1115 return Err(HuddleError::Other(
1116 "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(),
1117 ));
1118 }
1119
1120 if room.info.encrypted {
1121 let crypto = room
1122 .crypto
1123 .as_mut()
1124 .ok_or_else(|| HuddleError::Session("encrypted room missing crypto".into()))?;
1125 let (session_id, ct_bytes) = crypto.encrypt(body.as_bytes())?;
1126 RoomMessage::Encrypted {
1127 sender_fingerprint: our_fp.clone(),
1128 session_id,
1129 ciphertext_b64: base64::Engine::encode(
1130 &base64::engine::general_purpose::STANDARD,
1131 &ct_bytes,
1132 ),
1133 }
1134 } else {
1135 RoomMessage::Plain {
1136 sender_fingerprint: our_fp.clone(),
1137 body: body.to_string(),
1138 }
1139 }
1140 };
1141
1142 let bytes = encode_wire(&msg)?;
1143 self.network
1144 .publish_room_message(room_id.to_string(), bytes)
1145 .await;
1146
1147 let now = now_unix();
1148 let msg_id =
1149 repo::insert_room_message(&self.db, room_id, &our_fp, "out", body, now)?;
1150 repo::update_room_last_active(&self.db, room_id, now)?;
1151
1152 let _ = self.app_event_tx.send(AppEvent::MessageSent {
1153 room_id: room_id.to_string(),
1154 body: body.to_string(),
1155 message_id: msg_id,
1156 });
1157
1158 Ok(())
1159 }
1160
1161 pub async fn shutdown(&self) {
1162 self.network.shutdown().await;
1163 }
1164
1165 pub async fn dial_by_id_or_username(&self, input: &str) -> Result<()> {
1192 let trimmed = input.trim();
1193 if trimmed.is_empty() {
1194 return Err(HuddleError::Other("input is empty".into()));
1195 }
1196 let target_fp = if let Some(fp) = normalize_to_fingerprint(trimmed) {
1197 fp
1198 } else {
1199 let matches = repo::find_peers_by_username(&self.db, trimmed)?;
1200 if matches.is_empty() {
1201 return Err(HuddleError::Other(format!(
1202 "no peer named `{}` known yet — paste their invite link instead",
1203 trimmed
1204 )));
1205 }
1206 if matches.len() > 1 {
1207 return Err(HuddleError::Other(format!(
1208 "username `{}` is ambiguous ({} peers share it) — use their HD- ID instead",
1209 trimmed,
1210 matches.len()
1211 )));
1212 }
1213 matches.into_iter().next().unwrap()
1214 };
1215 if target_fp == self.identity.fingerprint() {
1216 return Err(HuddleError::Other("that's your own ID".into()));
1217 }
1218 let candidates = self.resolve_dial_addrs(&target_fp);
1219 if candidates.is_empty() {
1220 return Err(HuddleError::Other(format!(
1221 "haven't seen `{}` on the network yet — ask them for an invite link",
1222 short_fp_for_msg(&target_fp)
1223 )));
1224 }
1225 let now = now_unix();
1230 for addr in &candidates {
1231 let _ = repo::upsert_known_peer(
1232 &self.db,
1233 &KnownPeer {
1234 address: addr.clone(),
1235 label: None,
1236 last_connected_at: None,
1237 last_attempt_at: Some(now),
1238 created_at: now,
1239 fingerprint: Some(target_fp.clone()),
1240 trusted: false,
1241 },
1242 );
1243 }
1244 let multiaddrs: Vec<Multiaddr> = candidates
1248 .iter()
1249 .filter_map(|s| s.parse::<Multiaddr>().ok())
1250 .collect();
1251 if multiaddrs.is_empty() {
1252 return Err(HuddleError::Other(
1253 "every known address for that peer is malformed".into(),
1254 ));
1255 }
1256 let _ = self.app_event_tx.send(AppEvent::Dialing {
1257 address: candidates[0].clone(),
1258 });
1259 info!(
1260 target_fp = %target_fp,
1261 n = multiaddrs.len(),
1262 "dialing peer with {} candidate addresses",
1263 multiaddrs.len()
1264 );
1265 {
1269 let mut pending = self.pending_auto_dm_addrs.lock().unwrap();
1270 for m in &multiaddrs {
1271 pending.insert(m.to_string());
1272 }
1273 }
1274 self.network.dial_addresses(multiaddrs).await;
1275 Ok(())
1276 }
1277
1278 fn resolve_dial_addrs(&self, fingerprint: &str) -> Vec<String> {
1286 let mut set: std::collections::HashSet<String> = std::collections::HashSet::new();
1287 for room in self.discovered_rooms.lock().unwrap().values() {
1288 if room.creator_fingerprint == fingerprint {
1289 for addr in &room.host_addrs {
1290 set.insert(addr.clone());
1291 }
1292 }
1293 }
1294 if let Ok(known) = repo::list_known_peers(&self.db) {
1295 for peer in known {
1296 if peer.fingerprint.as_deref() == Some(fingerprint) {
1297 set.insert(peer.address);
1298 }
1299 }
1300 }
1301 let mut v: Vec<String> = set.into_iter().collect();
1302 v.sort_by_key(|a| address_preference(a));
1303 v
1304 }
1305
1306 pub async fn dial(&self, input: &str) -> Result<()> {
1307 let multiaddr = parse_dial_address(input)?;
1308 let canonical = multiaddr.to_string();
1309 self.pending_auto_dm_addrs
1314 .lock()
1315 .unwrap()
1316 .insert(canonical.clone());
1317 self.dial_internal(canonical, multiaddr).await
1318 }
1319
1320 pub(crate) async fn dial_internal(
1326 &self,
1327 canonical: String,
1328 multiaddr: Multiaddr,
1329 ) -> Result<()> {
1330 info!(%canonical, "dialing");
1331 repo::upsert_known_peer(
1332 &self.db,
1333 &KnownPeer {
1334 address: canonical.clone(),
1335 label: None,
1336 last_connected_at: None,
1337 last_attempt_at: Some(now_unix()),
1338 created_at: now_unix(),
1339 fingerprint: None,
1343 trusted: false,
1344 },
1345 )?;
1346
1347 let _ = self.app_event_tx.send(AppEvent::Dialing {
1348 address: canonical.clone(),
1349 });
1350 self.network.dial(multiaddr).await;
1351 Ok(())
1352 }
1353
1354 pub fn nat_reachable_addrs(&self) -> Vec<String> {
1359 self.nat_reachable_addrs
1360 .lock()
1361 .unwrap()
1362 .iter()
1363 .cloned()
1364 .collect()
1365 }
1366
1367 pub fn dialable_addrs(&self) -> Vec<String> {
1375 let mut out: Vec<String> = self
1376 .relay_circuit_addrs
1377 .lock()
1378 .unwrap()
1379 .iter()
1380 .cloned()
1381 .collect();
1382 for a in self.nat_reachable_addrs.lock().unwrap().iter() {
1383 if !out.contains(a) {
1384 out.push(a.clone());
1385 }
1386 }
1387 out.truncate(4);
1388 out
1389 }
1390
1391 pub async fn dial_invite(&self, address: &str, claimed_fp: &str) -> Result<()> {
1404 let multiaddr = parse_dial_address(address)?;
1405 let canonical = multiaddr.to_string();
1406 self.pending_invite_dials
1407 .lock()
1408 .unwrap()
1409 .insert(canonical.clone(), claimed_fp.to_string());
1410 self.dial(address).await
1413 }
1414
1415 pub fn known_peers(&self) -> Vec<KnownPeerStatus> {
1416 let connected = self.connected_dial_addrs.lock().unwrap().clone();
1417 let stored = repo::list_known_peers(&self.db).unwrap_or_default();
1418 stored
1419 .into_iter()
1420 .map(|p| {
1421 let connected_peer = connected.get(&p.address).copied();
1422 KnownPeerStatus {
1423 address: p.address,
1424 label: p.label,
1425 last_connected_at: p.last_connected_at,
1426 connected_peer_id: connected_peer,
1427 fingerprint: p.fingerprint,
1428 }
1429 })
1430 .collect()
1431 }
1432
1433 pub async fn forget_peer(&self, address: &str) -> Result<()> {
1434 repo::forget_known_peer(&self.db, address)?;
1435 self.connected_dial_addrs.lock().unwrap().remove(address);
1436 Ok(())
1437 }
1438
1439 pub async fn redial(&self, address: &str) -> Result<()> {
1441 self.dial(address).await
1442 }
1443
1444 pub async fn accept_inbound(&self, peer_id: PeerId, address: &str) {
1449 self.network.accept_inbound(peer_id).await;
1450 self.connected_dial_addrs
1451 .lock()
1452 .unwrap()
1453 .insert(address.to_string(), peer_id);
1454 }
1455
1456 pub async fn reject_inbound(&self, peer_id: PeerId, fingerprint: &str) -> Result<()> {
1461 self.network.reject_inbound(peer_id).await;
1462 repo::block_peer(&self.db, fingerprint, now_unix())?;
1463 Ok(())
1464 }
1465
1466 pub async fn trust_inbound(
1469 &self,
1470 peer_id: PeerId,
1471 fingerprint: &str,
1472 address: &str,
1473 ) -> Result<()> {
1474 self.network.accept_inbound(peer_id).await;
1475 self.connected_dial_addrs
1476 .lock()
1477 .unwrap()
1478 .insert(address.to_string(), peer_id);
1479 repo::upsert_known_peer(
1483 &self.db,
1484 &KnownPeer {
1485 address: address.to_string(),
1486 label: None,
1487 last_connected_at: Some(now_unix()),
1488 last_attempt_at: Some(now_unix()),
1489 created_at: now_unix(),
1490 fingerprint: Some(fingerprint.to_string()),
1491 trusted: true,
1492 },
1493 )?;
1494 Ok(())
1495 }
1496
1497 pub fn list_pending_friend_requests(&self) -> Vec<repo::PendingFriendRequest> {
1505 repo::list_pending_friend_requests(&self.db).unwrap_or_default()
1506 }
1507
1508 pub fn spill_pending_friend_request(
1514 &self,
1515 peer_id: PeerId,
1516 fingerprint: &str,
1517 address: &str,
1518 ) -> Result<()> {
1519 repo::upsert_pending_friend_request(
1520 &self.db,
1521 &repo::PendingFriendRequest {
1522 fingerprint: fingerprint.to_string(),
1523 address: address.to_string(),
1524 peer_id: peer_id.to_string(),
1525 received_at: now_unix(),
1526 },
1527 )?;
1528 Ok(())
1529 }
1530
1531 pub async fn accept_pending_friend_request(&self, fingerprint: &str) -> Result<()> {
1538 let mut chosen_addr: Option<String> = None;
1539 for req in self.list_pending_friend_requests() {
1540 if req.fingerprint == fingerprint {
1541 chosen_addr = Some(req.address);
1542 break;
1543 }
1544 }
1545 repo::delete_pending_friend_requests_for_fp(&self.db, fingerprint)?;
1546 if let Some(addr) = chosen_addr {
1547 repo::upsert_known_peer(
1551 &self.db,
1552 &KnownPeer {
1553 address: addr.clone(),
1554 label: None,
1555 last_connected_at: None,
1556 last_attempt_at: Some(now_unix()),
1557 created_at: now_unix(),
1558 fingerprint: Some(fingerprint.to_string()),
1559 trusted: true,
1560 },
1561 )?;
1562 self.dial(&addr).await?;
1564 }
1565 Ok(())
1566 }
1567
1568 pub fn reject_pending_friend_request(&self, fingerprint: &str) -> Result<()> {
1573 repo::delete_pending_friend_requests_for_fp(&self.db, fingerprint)?;
1574 repo::block_peer(&self.db, fingerprint, now_unix())?;
1575 Ok(())
1576 }
1577
1578 pub async fn disconnect_peer(&self, peer_id: PeerId) {
1585 self.network.disconnect_peer(peer_id).await;
1586 }
1587
1588 fn spawn_known_peer_reconnector(&self) {
1589 let handle = self.clone();
1590 tokio::spawn(async move {
1591 tokio::time::sleep(Duration::from_millis(500)).await;
1593 let known = repo::list_known_peers(&handle.db).unwrap_or_default();
1594 for (i, peer) in known.into_iter().enumerate() {
1598 let handle = handle.clone();
1599 tokio::spawn(async move {
1600 let jitter = (peer.address.len() as u64 * 37) % 200;
1603 tokio::time::sleep(Duration::from_millis(150 * i as u64 + jitter)).await;
1604 let multiaddr = match peer.address.parse::<Multiaddr>() {
1609 Ok(m) => m,
1610 Err(_) => return,
1611 };
1612 if let Err(e) = handle.dial_internal(peer.address.clone(), multiaddr).await {
1613 debug!(%e, addr = %peer.address, "auto-reconnect failed");
1614 }
1615 });
1616 }
1617 });
1618 }
1619
1620 fn load_or_create_identity(db: &Db) -> Result<Identity> {
1625 if let Some(stored) = repo::load_identity(db)? {
1626 let mut bytes = [0u8; 32];
1627 bytes.copy_from_slice(&stored.ed25519_secret);
1628 Identity::from_secret_bytes(bytes)
1629 } else {
1630 let id = Identity::generate()?;
1631 repo::save_identity(db, &id.secret_bytes(), now_unix())?;
1632 Ok(id)
1633 }
1634 }
1635
1636 fn get_room_salt(&self, room_id: &str) -> Option<Vec<u8>> {
1637 self.active_rooms
1638 .lock()
1639 .unwrap()
1640 .get(room_id)
1641 .and_then(|r| r.info.passphrase_salt.clone())
1642 .or_else(|| {
1643 ROOM_SALT_CACHE
1645 .lock()
1646 .unwrap()
1647 .get(room_id)
1648 .cloned()
1649 })
1650 }
1651
1652 async fn announce_room_now(&self, info: &StoredRoom, member_count: u32) {
1653 let owner_fingerprints =
1654 repo::list_room_owners(&self.db, &info.id).unwrap_or_default();
1655 let verified_only = repo::get_room_verified_only(&self.db, &info.id).unwrap_or(false);
1656 let host_addrs = self.dialable_addrs();
1657 let ann = RoomAnnouncement {
1658 room_id: info.id.clone(),
1659 name: info.name.clone(),
1660 encrypted: info.encrypted,
1661 passphrase_salt: info.passphrase_salt.clone(),
1662 member_count,
1663 creator_fingerprint: info.creator_fingerprint.clone(),
1664 announced_at: now_unix(),
1665 owner_fingerprints,
1666 verified_only,
1667 host_addrs,
1668 kind: info.kind,
1669 };
1670 self.network.announce_room(ann).await;
1671 }
1672
1673 async fn broadcast_member_announce(&self, room_id: &str) -> Result<()> {
1674 let our_fp = self.identity.fingerprint().to_string();
1675 let wrapped = {
1676 let mut rooms = self.active_rooms.lock().unwrap();
1677 let room = rooms
1678 .get_mut(room_id)
1679 .ok_or_else(|| HuddleError::Other("not in room".into()))?;
1680 if room.info.encrypted {
1681 let crypto = room.crypto.as_mut().unwrap();
1682 let session_key = crypto.our_session_key_b64();
1683 match room.passphrase_key.as_ref() {
1684 Some(passphrase_key) => {
1685 Some(passphrase::wrap(session_key.as_bytes(), passphrase_key)?)
1686 }
1687 None if room.info.kind == RoomKind::Direct => {
1688 None
1698 }
1699 None => {
1700 return Err(HuddleError::Session("missing passphrase key".into()));
1701 }
1702 }
1703 } else {
1704 None
1705 }
1706 };
1707 let display_name = repo::get_display_name(&self.db).unwrap_or(None);
1708 let msg = RoomMessage::MemberAnnounce {
1709 sender_fingerprint: our_fp,
1710 wrapped_session_key: wrapped,
1711 display_name,
1712 sender_ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
1713 };
1714 let bytes = encode_wire(&msg)?;
1715 self.network
1716 .publish_room_message(room_id.to_string(), bytes)
1717 .await;
1718 Ok(())
1719 }
1720
1721 fn spawn_event_processor(&self, mut net_rx: tokio::sync::mpsc::Receiver<NetworkEvent>) {
1722 let handle = self.clone();
1723 tokio::spawn(async move {
1724 while let Some(event) = net_rx.recv().await {
1725 handle.process_network_event(event).await;
1726 }
1727 info!("event processor stopped");
1728 });
1729 }
1730
1731 fn spawn_announcement_ticker(&self) {
1732 let handle = self.clone();
1733 tokio::spawn(async move {
1734 let mut interval =
1735 tokio::time::interval(Duration::from_secs(ANNOUNCE_INTERVAL_SECS));
1736 interval.tick().await; loop {
1738 interval.tick().await;
1739 let snapshot: Vec<(StoredRoom, u32)> = {
1740 let active = handle.active_rooms.lock().unwrap();
1741 active
1742 .values()
1743 .map(|r| (r.info.clone(), r.members.len() as u32))
1744 .collect()
1745 };
1746 for (info, member_count) in snapshot {
1747 handle.announce_room_now(&info, member_count).await;
1748 }
1749 }
1750 });
1751 }
1752
1753 fn spawn_discovered_room_pruner(&self) {
1754 let handle = self.clone();
1755 tokio::spawn(async move {
1756 let mut interval = tokio::time::interval(Duration::from_secs(10));
1757 interval.tick().await;
1758 loop {
1759 interval.tick().await;
1760 let now = now_unix();
1761 let mut to_drop = Vec::new();
1762 {
1763 let mut map = handle.discovered_rooms.lock().unwrap();
1764 map.retain(|id, r| {
1765 if now - r.last_seen > DISCOVERED_TTL_SECS {
1766 to_drop.push(id.clone());
1767 false
1768 } else {
1769 true
1770 }
1771 });
1772 }
1773 for id in to_drop {
1774 let _ = handle.app_event_tx.send(AppEvent::RoomLost { room_id: id });
1775 }
1776 }
1777 });
1778 }
1779
1780 async fn process_network_event(&self, event: NetworkEvent) {
1781 match event {
1782 NetworkEvent::PeerDiscovered { peer_id } => {
1783 let _ = self.app_event_tx.send(AppEvent::PeerDiscovered { peer_id });
1784 }
1785 NetworkEvent::PeerExpired { peer_id } => {
1786 self.connected_dial_addrs
1792 .lock()
1793 .unwrap()
1794 .retain(|_addr, pid| *pid != peer_id);
1795 let _ = self.app_event_tx.send(AppEvent::PeerExpired { peer_id });
1796 }
1797 NetworkEvent::ListeningOn { address } => {
1798 let _ = self.app_event_tx.send(AppEvent::ListeningOn {
1799 address: address.to_string(),
1800 });
1801 }
1802 NetworkEvent::RoomAnnouncementReceived(ann) => {
1803 if let Some(salt) = &ann.passphrase_salt {
1805 ROOM_SALT_CACHE
1806 .lock()
1807 .unwrap()
1808 .insert(ann.room_id.clone(), salt.clone());
1809 }
1810 let our_fp_for_dial = self.identity.fingerprint().to_string();
1815 if ann.creator_fingerprint != our_fp_for_dial && !ann.host_addrs.is_empty() {
1816 let now = now_unix();
1817 let should_dial = {
1818 let mut attempts = self.host_addr_dial_attempts.lock().unwrap();
1819 match attempts.get(&ann.creator_fingerprint).copied() {
1820 Some(last) if now - last < HOST_ADDR_DIAL_BACKOFF_SECS => false,
1821 _ => {
1822 attempts.insert(ann.creator_fingerprint.clone(), now);
1823 true
1824 }
1825 }
1826 };
1827 if should_dial {
1828 if let Some(first) = ann.host_addrs.first() {
1829 info!(
1830 announcer = %ann.creator_fingerprint,
1831 addr = %first,
1832 "opportunistic dial via room announcement host_addrs"
1833 );
1834 if let Ok(multiaddr) = first.parse::<Multiaddr>() {
1839 let canonical = multiaddr.to_string();
1840 let _ = self.dial_internal(canonical, multiaddr).await;
1841 }
1842 }
1843 }
1844 }
1845 let discovered = DiscoveredRoom {
1846 room_id: ann.room_id.clone(),
1847 name: ann.name.clone(),
1848 encrypted: ann.encrypted,
1849 member_count: ann.member_count,
1850 creator_fingerprint: ann.creator_fingerprint.clone(),
1851 last_seen: now_unix(),
1852 restorable: false,
1853 host_addrs: ann.host_addrs.clone(),
1854 kind: ann.kind,
1855 };
1856 if self.active_rooms.lock().unwrap().contains_key(&ann.room_id) {
1861 self.discovered_rooms
1862 .lock()
1863 .unwrap()
1864 .insert(ann.room_id.clone(), discovered);
1865 return;
1866 }
1867 if ann.kind == RoomKind::Direct {
1877 let our_fp_for_filter = self.identity.fingerprint().to_string();
1878 if canonical_dm_room_id(&our_fp_for_filter, &ann.creator_fingerprint)
1879 != ann.room_id
1880 {
1881 debug!(
1882 announcer = %ann.creator_fingerprint,
1883 room_id = %ann.room_id,
1884 "dropping Direct announcement: not addressed to us"
1885 );
1886 return;
1887 }
1888 self.discovered_rooms
1893 .lock()
1894 .unwrap()
1895 .insert(ann.room_id.clone(), discovered.clone());
1896 let _ = self
1897 .app_event_tx
1898 .send(AppEvent::RoomDiscovered(discovered.clone()));
1899 let app = self.clone();
1900 let partner = ann.creator_fingerprint.clone();
1901 let rid = ann.room_id.clone();
1902 tokio::spawn(async move {
1903 if let Err(e) = app.start_direct(&partner).await {
1904 debug!(%e, room_id = %rid, "auto-bootstrap of inbound DM failed");
1905 }
1906 });
1907 return;
1908 }
1909 self.discovered_rooms
1910 .lock()
1911 .unwrap()
1912 .insert(ann.room_id.clone(), discovered.clone());
1913 let _ = self.app_event_tx.send(AppEvent::RoomDiscovered(discovered));
1914 }
1915 NetworkEvent::RoomMessageReceived {
1916 room_id,
1917 payload,
1918 from_peer: _,
1919 } => {
1920 let wire: WireMessage = match serde_json::from_slice(&payload) {
1927 Ok(w) => w,
1928 Err(e) => {
1929 warn!(%e, "bad wire envelope");
1930 return;
1931 }
1932 };
1933 let (msg, verified_signer) = match wire {
1934 WireMessage::Plain(m) => (m, None),
1935 WireMessage::Signed(env) => {
1936 let claimed_pubkey = env.ed25519_pubkey_b64.clone();
1937 match crate::crypto::verify_signed(&env) {
1938 Ok((m, fp)) => {
1939 match repo::get_member_ed25519_pubkey(
1946 &self.db, &room_id, &fp,
1947 ) {
1948 Ok(Some(known)) if known != claimed_pubkey => {
1949 warn!(
1950 %fp, %room_id,
1951 "pubkey mismatch vs stored; dropping signed message"
1952 );
1953 return;
1954 }
1955 _ => {}
1956 }
1957 (m, Some(fp))
1958 }
1959 Err(e) => {
1960 warn!(%e, fp = %env.fingerprint, "signed envelope verify failed");
1961 return;
1962 }
1963 }
1964 }
1965 };
1966 self.handle_room_message(&room_id, msg, verified_signer).await;
1967 }
1968 NetworkEvent::DialSucceeded { peer_id, address } => {
1969 let addr_s = address.to_string();
1970 self.connected_dial_addrs
1971 .lock()
1972 .unwrap()
1973 .insert(addr_s.clone(), peer_id);
1974 let _ = repo::upsert_known_peer(
1978 &self.db,
1979 &KnownPeer {
1980 address: addr_s.clone(),
1981 label: None,
1982 last_connected_at: Some(now_unix()),
1983 last_attempt_at: Some(now_unix()),
1984 created_at: now_unix(),
1985 fingerprint: None,
1986 trusted: false,
1987 },
1988 );
1989 let _ = self.app_event_tx.send(AppEvent::DialSucceeded {
1990 address: addr_s,
1991 peer_id,
1992 });
1993 }
1994 NetworkEvent::DialFailed { address, error } => {
1995 let addr_s = address.to_string();
1996 let _ = self.app_event_tx.send(AppEvent::DialFailed {
1997 address: addr_s,
1998 error,
1999 });
2000 }
2001 NetworkEvent::PeerIdentified { peer_id, fingerprint } => {
2002 let matched_addrs: Vec<String> = {
2008 let map = self.connected_dial_addrs.lock().unwrap();
2009 map.iter()
2010 .filter_map(|(addr, pid)| {
2011 if *pid == peer_id {
2012 Some(addr.clone())
2013 } else {
2014 None
2015 }
2016 })
2017 .collect()
2018 };
2019 let mismatch = {
2029 let mut map = self.pending_invite_dials.lock().unwrap();
2030 let mut found: Option<(String, String)> = None;
2031 for addr in &matched_addrs {
2032 if let Some(claimed) = map.remove(addr) {
2033 if claimed != fingerprint {
2034 found = Some((addr.clone(), claimed));
2035 break;
2036 }
2037 }
2038 }
2039 found
2040 };
2041 if let Some((addr, claimed)) = mismatch {
2042 warn!(
2043 %addr, %claimed, actual=%fingerprint,
2044 "invite fingerprint mismatch — disconnecting"
2045 );
2046 self.network.disconnect_peer(peer_id).await;
2047 let _ = self.app_event_tx.send(AppEvent::InviteFingerprintMismatch {
2048 address: addr,
2049 claimed,
2050 actual: fingerprint.clone(),
2051 });
2052 return;
2053 }
2054 let should_auto_dm = {
2061 let mut pending = self.pending_auto_dm_addrs.lock().unwrap();
2062 let mut any_matched = false;
2063 for addr in &matched_addrs {
2064 if pending.remove(addr) {
2065 any_matched = true;
2066 }
2067 }
2068 any_matched
2069 };
2070 for addr in matched_addrs {
2071 let _ = repo::upsert_known_peer(
2072 &self.db,
2073 &KnownPeer {
2074 address: addr,
2075 label: None,
2076 last_connected_at: Some(now_unix()),
2077 last_attempt_at: Some(now_unix()),
2078 created_at: now_unix(),
2079 fingerprint: Some(fingerprint.clone()),
2080 trusted: true,
2081 },
2082 );
2083 }
2084 if should_auto_dm && fingerprint != self.identity.fingerprint() {
2091 match self.start_direct(&fingerprint).await {
2092 Ok(room_id) => {
2093 let _ = self.app_event_tx.send(AppEvent::AutoOpenDm {
2094 room_id,
2095 fingerprint: fingerprint.clone(),
2096 });
2097 }
2098 Err(e) => {
2099 debug!(%e, fp = %fingerprint, "auto-DM after dial failed");
2100 }
2101 }
2102 }
2103 let our_username = repo::get_display_name(&self.db).unwrap_or(None);
2111 if our_username.is_some() {
2112 let now_ms = now_unix_ms();
2113 let should_send = {
2114 let mut last = self.last_profile_broadcast_at_ms.lock().unwrap();
2115 match last.get(&fingerprint) {
2116 Some(prev) if now_ms - prev < PROFILE_REBROADCAST_FLOOR_MS => false,
2117 _ => {
2118 last.insert(fingerprint.clone(), now_ms);
2119 true
2120 }
2121 }
2122 };
2123 if should_send {
2124 let msg = RoomMessage::ProfileUpdate {
2125 sender_fingerprint: self.identity.fingerprint().to_string(),
2126 username: our_username,
2127 updated_at: now_ms,
2128 };
2129 if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
2130 if let Ok(bytes) =
2131 crate::network::protocol::encode_wire_signed(&env)
2132 {
2133 let rooms: Vec<String> = self
2134 .active_rooms
2135 .lock()
2136 .unwrap()
2137 .keys()
2138 .cloned()
2139 .collect();
2140 for room_id in rooms {
2141 self.network
2142 .publish_room_message(room_id, bytes.clone())
2143 .await;
2144 }
2145 }
2146 }
2147 }
2148 }
2149 }
2150 NetworkEvent::RelayReservationEstablished { address } => {
2151 info!(addr = %address, "relay reservation established");
2156 self.relay_circuit_addrs
2157 .lock()
2158 .unwrap()
2159 .insert(address.to_string());
2160 let _ = self.app_event_tx.send(AppEvent::ListeningOn {
2161 address: address.to_string(),
2162 });
2163 }
2164 NetworkEvent::NatProbeResult {
2165 tested_addr,
2166 reachable,
2167 } => {
2168 let addr_s = tested_addr.to_string();
2169 let (transitioned, becomes_reachable) = {
2170 let mut set = self.nat_reachable_addrs.lock().unwrap();
2171 let was_empty = set.is_empty();
2172 if reachable {
2173 set.insert(addr_s.clone());
2174 } else {
2175 set.remove(&addr_s);
2176 }
2177 let is_empty = set.is_empty();
2178 (was_empty != is_empty, !is_empty)
2179 };
2180 if transitioned {
2181 let label = if becomes_reachable {
2182 "reachable".to_string()
2183 } else {
2184 "private".to_string()
2185 };
2186 info!(reachable = %becomes_reachable, "NAT reachability changed");
2187 let _ = self.app_event_tx.send(AppEvent::NatStatusChanged {
2188 label,
2189 reachable: becomes_reachable,
2190 });
2191 }
2192 }
2193 NetworkEvent::DcutrUpgrade {
2194 remote_peer,
2195 success,
2196 } => {
2197 if success {
2198 let s = remote_peer.to_base58();
2202 let tail: String = s.chars().rev().take(8).collect::<String>()
2203 .chars()
2204 .rev()
2205 .collect();
2206 let _ = self.app_event_tx.send(AppEvent::DcutrSucceeded {
2207 peer_label: tail,
2208 });
2209 }
2210 }
2211 NetworkEvent::InboundDial {
2212 peer_id,
2213 fingerprint,
2214 address,
2215 } => {
2216 if repo::is_peer_blocked(&self.db, &fingerprint).unwrap_or(false) {
2218 info!(%fingerprint, "inbound dial auto-rejected: peer is blocked");
2219 self.network.reject_inbound(peer_id).await;
2220 return;
2221 }
2222 let global_verified_only =
2227 repo::get_setting(&self.db, "verified_only_inbound")
2228 .ok()
2229 .flatten()
2230 .map(|v| v == "1")
2231 .unwrap_or(false);
2232 if global_verified_only {
2233 let is_verified =
2234 repo::is_globally_verified(&self.db, &fingerprint).unwrap_or(false)
2235 || repo::is_fingerprint_trusted(&self.db, &fingerprint)
2236 .unwrap_or(false);
2237 if !is_verified {
2238 info!(
2239 %fingerprint,
2240 "inbound dial auto-rejected: verified-only mode"
2241 );
2242 self.network.reject_inbound(peer_id).await;
2243 return;
2244 }
2245 }
2246 if repo::is_fingerprint_trusted(&self.db, &fingerprint).unwrap_or(false) {
2247 info!(%fingerprint, "inbound dial auto-accepted: peer is trusted");
2248 self.connected_dial_addrs
2251 .lock()
2252 .unwrap()
2253 .insert(address.to_string(), peer_id);
2254 let _ = repo::upsert_known_peer(
2255 &self.db,
2256 &KnownPeer {
2257 address: address.to_string(),
2258 label: None,
2259 last_connected_at: Some(now_unix()),
2260 last_attempt_at: Some(now_unix()),
2261 created_at: now_unix(),
2262 fingerprint: Some(fingerprint),
2263 trusted: true,
2264 },
2265 );
2266 self.network.accept_inbound(peer_id).await;
2267 return;
2268 }
2269 let _ = self.app_event_tx.send(AppEvent::InboundDial {
2271 peer_id,
2272 fingerprint,
2273 address: address.to_string(),
2274 });
2275 }
2276 }
2277 }
2278
2279 async fn handle_room_message(
2285 &self,
2286 room_id: &str,
2287 msg: RoomMessage,
2288 verified_signer: Option<String>,
2289 ) {
2290 let our_fp = self.identity.fingerprint().to_string();
2291 match msg {
2292 RoomMessage::MemberAnnounce {
2293 sender_fingerprint,
2294 wrapped_session_key,
2295 display_name,
2296 sender_ed25519_pubkey,
2297 } => {
2298 if sender_fingerprint == our_fp {
2299 return;
2300 }
2301 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2304 .unwrap_or(false)
2305 {
2306 info!(%sender_fingerprint, %room_id, "dropping MemberAnnounce from banned peer");
2307 return;
2308 }
2309 if repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
2316 && !repo::is_globally_verified(&self.db, &sender_fingerprint).unwrap_or(false)
2317 {
2318 info!(
2319 %sender_fingerprint, %room_id,
2320 "dropping MemberAnnounce: room is verified-only and joiner isn't verified"
2321 );
2322 let owners = repo::list_room_owners(&self.db, room_id).unwrap_or_default();
2323 let lowest_owner = owners.iter().min().cloned();
2324 if lowest_owner.as_deref() == Some(&our_fp) {
2325 let msg = RoomMessage::JoinRefused {
2326 room_id: room_id.to_string(),
2327 target_fingerprint: sender_fingerprint.clone(),
2328 reason: "room requires SAS verification — ask an existing member to verify you".into(),
2329 };
2330 if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
2331 if let Ok(bytes) =
2332 crate::network::protocol::encode_wire_signed(&env)
2333 {
2334 self.network
2335 .publish_room_message(room_id.to_string(), bytes)
2336 .await;
2337 }
2338 }
2339 }
2340 return;
2341 }
2342 let need_inbound = {
2343 let mut rooms = self.active_rooms.lock().unwrap();
2344 let room = match rooms.get_mut(room_id) {
2345 Some(r) => r,
2346 None => return,
2347 };
2348 if room.info.kind == RoomKind::Direct
2356 && !room.members.contains(&sender_fingerprint)
2357 && room.members.len() >= 2
2358 {
2359 info!(
2360 %sender_fingerprint, %room_id,
2361 "dropping MemberAnnounce on Direct room: already at 2-member cap"
2362 );
2363 return;
2364 }
2365 let newly_added = room.members.insert(sender_fingerprint.clone());
2366 if newly_added {
2367 let _ = self.app_event_tx.send(AppEvent::MemberJoined {
2368 room_id: room_id.to_string(),
2369 fingerprint: sender_fingerprint.clone(),
2370 });
2371 }
2372 let _ = repo::upsert_room_member(
2377 &self.db,
2378 &StoredRoomMember {
2379 room_id: room_id.to_string(),
2380 peer_id: String::new(), fingerprint: sender_fingerprint.clone(),
2382 last_seen: Some(now_unix()),
2383 verified: false,
2384 ed25519_pubkey: sender_ed25519_pubkey.clone(),
2385 role: "member".into(),
2391 },
2392 );
2393 if let Some(name) = display_name.as_deref() {
2394 let _ = repo::set_member_display_name(
2395 &self.db,
2396 room_id,
2397 &sender_fingerprint,
2398 Some(name),
2399 );
2400 }
2401 room.info.encrypted && wrapped_session_key.is_some()
2402 };
2403
2404 if matches!(
2411 self.active_rooms
2412 .lock()
2413 .unwrap()
2414 .get(room_id)
2415 .map(|r| (r.info.kind, r.passphrase_key.is_none())),
2416 Some((RoomKind::Direct, true))
2417 ) {
2418 if let Some(pubkey_b64) = sender_ed25519_pubkey.as_deref() {
2419 if let Some(key) =
2420 self.derive_dm_key_from_pubkey_b64(room_id, pubkey_b64)
2421 {
2422 let mut rooms = self.active_rooms.lock().unwrap();
2423 if let Some(room) = rooms.get_mut(room_id) {
2424 room.passphrase_key = Some(key);
2425 }
2426 drop(rooms);
2427 let app = self.clone();
2432 let rid = room_id.to_string();
2433 tokio::spawn(async move {
2434 if let Err(e) = app.broadcast_member_announce(&rid).await {
2435 warn!(%e, "re-broadcast DM announce after key derivation");
2436 }
2437 });
2438 }
2439 }
2440 }
2441
2442 if need_inbound {
2443 let wrapped = wrapped_session_key.unwrap();
2444 let result = {
2445 let mut rooms = self.active_rooms.lock().unwrap();
2446 let room = rooms.get_mut(room_id).unwrap();
2447 let passphrase_key = match &room.passphrase_key {
2448 Some(k) => k,
2449 None => {
2450 warn!("no passphrase key when receiving session key");
2451 return;
2452 }
2453 };
2454 match passphrase::unwrap(&wrapped, passphrase_key) {
2455 Ok(plain) => match String::from_utf8(plain) {
2456 Ok(key_b64) => {
2457 let crypto = room.crypto.as_mut().unwrap();
2458 crypto.add_inbound_session(&sender_fingerprint, &key_b64)
2459 }
2460 Err(e) => Err(HuddleError::Session(format!("utf8: {e}"))),
2461 },
2462 Err(e) => Err(e),
2463 }
2464 };
2465 if let Err(e) = result {
2466 error!(%e, "add inbound session failed");
2467 }
2468 }
2469 }
2470 RoomMessage::SessionKeyRequest {
2471 requester_fingerprint,
2472 } => {
2473 if requester_fingerprint == our_fp {
2474 return;
2475 }
2476 if let Err(e) = self.broadcast_member_announce(room_id).await {
2478 warn!(%e, "broadcast member announce on request");
2479 }
2480 }
2481 RoomMessage::Encrypted {
2482 sender_fingerprint,
2483 session_id,
2484 ciphertext_b64,
2485 } => {
2486 if sender_fingerprint == our_fp {
2487 return;
2488 }
2489 let ct_bytes = match base64::Engine::decode(
2490 &base64::engine::general_purpose::STANDARD,
2491 &ciphertext_b64,
2492 ) {
2493 Ok(b) => b,
2494 Err(e) => {
2495 warn!(%e, "bad base64 ciphertext");
2496 return;
2497 }
2498 };
2499 let plaintext = {
2500 let mut rooms = self.active_rooms.lock().unwrap();
2501 let room = match rooms.get_mut(room_id) {
2502 Some(r) => r,
2503 None => return,
2504 };
2505 let crypto = match room.crypto.as_mut() {
2506 Some(c) => c,
2507 None => return,
2508 };
2509 crypto.decrypt(&sender_fingerprint, &session_id, &ct_bytes)
2510 };
2511 match plaintext {
2512 Ok(pt) => {
2513 let body = String::from_utf8_lossy(&pt).to_string();
2514 let sent_at = now_unix();
2515 let _ = repo::insert_room_message(
2516 &self.db,
2517 room_id,
2518 &sender_fingerprint,
2519 "in",
2520 &body,
2521 sent_at,
2522 );
2523 let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
2524 self.maybe_emit_mention(room_id, &body);
2525 let _ = self.app_event_tx.send(AppEvent::MessageReceived {
2526 room_id: room_id.to_string(),
2527 sender_fingerprint,
2528 body,
2529 sent_at,
2530 });
2531 }
2532 Err(e) => {
2533 debug!(%e, "decrypt failed (probably missing session key)");
2534 }
2535 }
2536 }
2537 RoomMessage::Plain {
2538 sender_fingerprint,
2539 body,
2540 } => {
2541 if sender_fingerprint == our_fp {
2542 return;
2543 }
2544 let sent_at = now_unix();
2545 let _ = repo::insert_room_message(
2546 &self.db,
2547 room_id,
2548 &sender_fingerprint,
2549 "in",
2550 &body,
2551 sent_at,
2552 );
2553 let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
2554 self.maybe_emit_mention(room_id, &body);
2555 let _ = self.app_event_tx.send(AppEvent::MessageReceived {
2556 room_id: room_id.to_string(),
2557 sender_fingerprint,
2558 body,
2559 sent_at,
2560 });
2561 }
2562 RoomMessage::Typing { sender_fingerprint } => {
2563 if sender_fingerprint == our_fp {
2564 return;
2565 }
2566 let expiry = now_unix() + TYPING_TTL_SECS;
2567 let mut rooms = self.active_rooms.lock().unwrap();
2568 if let Some(room) = rooms.get_mut(room_id) {
2569 room.typers.insert(sender_fingerprint, expiry);
2570 }
2571 drop(rooms);
2572 let _ = self.app_event_tx.send(AppEvent::TypingChanged {
2573 room_id: room_id.to_string(),
2574 });
2575 }
2576 RoomMessage::RotateRoomKey {
2577 rotator_fingerprint,
2578 new_salt,
2579 } => {
2580 if rotator_fingerprint == our_fp {
2581 return;
2582 }
2583 let signer = match verified_signer {
2588 Some(fp) => fp,
2589 None => {
2590 warn!(%room_id, "RotateRoomKey arrived unsigned; dropping");
2591 return;
2592 }
2593 };
2594 if signer != rotator_fingerprint {
2595 warn!(
2596 %signer, %rotator_fingerprint, %room_id,
2597 "RotateRoomKey signer mismatch with claimed rotator; dropping"
2598 );
2599 return;
2600 }
2601 let _ = self.app_event_tx.send(AppEvent::RotationRequested {
2602 room_id: room_id.to_string(),
2603 rotator_fingerprint,
2604 new_salt,
2605 });
2606 }
2607 RoomMessage::MemberLeave { sender_fingerprint } => {
2608 if sender_fingerprint == our_fp {
2609 return;
2610 }
2611 let removed = {
2612 let mut rooms = self.active_rooms.lock().unwrap();
2613 if let Some(room) = rooms.get_mut(room_id) {
2614 room.members.remove(&sender_fingerprint)
2615 } else {
2616 false
2617 }
2618 };
2619 if removed {
2620 let _ = self.app_event_tx.send(AppEvent::MemberLeft {
2621 room_id: room_id.to_string(),
2622 fingerprint: sender_fingerprint,
2623 });
2624 }
2625 }
2626 RoomMessage::FileOffer {
2627 sender_fingerprint,
2628 file_id,
2629 name,
2630 size_bytes,
2631 mime,
2632 chunk_count,
2633 encrypted_meta,
2634 } => {
2635 if sender_fingerprint == our_fp {
2636 return; }
2638 self.handle_file_offer(
2639 room_id,
2640 sender_fingerprint,
2641 file_id,
2642 name,
2643 size_bytes,
2644 mime,
2645 chunk_count,
2646 encrypted_meta,
2647 );
2648 }
2649 RoomMessage::FileChunk {
2650 sender_fingerprint,
2651 file_id,
2652 chunk_index,
2653 total_chunks,
2654 data_b64,
2655 } => {
2656 if sender_fingerprint == our_fp {
2657 return;
2658 }
2659 self.handle_file_chunk(
2660 room_id,
2661 sender_fingerprint,
2662 file_id,
2663 chunk_index,
2664 total_chunks,
2665 data_b64,
2666 );
2667 }
2668 RoomMessage::OwnerGrant {
2669 room_id: announced_room_id,
2670 target_fingerprint,
2671 } => {
2672 if announced_room_id != room_id {
2677 warn!(payload_room = %announced_room_id, topic_room = %room_id, "OwnerGrant room mismatch");
2678 return;
2679 }
2680 let signer = match verified_signer {
2681 Some(fp) => fp,
2682 None => {
2683 warn!(%room_id, "OwnerGrant arrived unsigned; dropping");
2684 return;
2685 }
2686 };
2687 if !self.is_owner(room_id, &signer) {
2688 warn!(%signer, %room_id, "OwnerGrant signer isn't an owner; dropping");
2689 return;
2690 }
2691 info!(%signer, %target_fingerprint, %room_id, "OwnerGrant applied");
2692 if let Err(e) =
2693 repo::set_member_role(&self.db, room_id, &target_fingerprint, "owner")
2694 {
2695 warn!(%e, "OwnerGrant: set_member_role failed");
2696 }
2697 }
2698 RoomMessage::BanMember {
2699 room_id: announced_room_id,
2700 target_fingerprint,
2701 } => {
2702 if announced_room_id != room_id {
2703 warn!(payload_room = %announced_room_id, topic_room = %room_id, "BanMember room mismatch");
2704 return;
2705 }
2706 let signer = match verified_signer {
2707 Some(fp) => fp,
2708 None => {
2709 warn!(%room_id, "BanMember arrived unsigned; dropping");
2710 return;
2711 }
2712 };
2713 if !self.is_owner(room_id, &signer) {
2714 warn!(%signer, %room_id, "BanMember signer isn't an owner; dropping");
2715 return;
2716 }
2717 if target_fingerprint == our_fp {
2718 info!(%room_id, %signer, "we were kicked from this room");
2724 self.active_rooms.lock().unwrap().remove(room_id);
2725 let _ = self.app_event_tx.send(AppEvent::RoomLeft {
2726 room_id: room_id.to_string(),
2727 });
2728 return;
2729 }
2730 info!(%signer, %target_fingerprint, %room_id, "BanMember applied");
2731 if let Err(e) = repo::add_room_ban(
2732 &self.db,
2733 room_id,
2734 &target_fingerprint,
2735 &signer,
2736 "", now_unix(),
2738 ) {
2739 warn!(%e, "BanMember: add_room_ban failed");
2740 }
2741 self.evict_banned_member(room_id, &target_fingerprint);
2742 }
2743 RoomMessage::SasInit {
2744 tx_id,
2745 ephemeral_x25519_pubkey_b64,
2746 target_fingerprint,
2747 } => {
2748 if target_fingerprint != our_fp {
2749 return;
2754 }
2755 let signer = match verified_signer {
2756 Some(fp) => fp,
2757 None => {
2758 warn!("SasInit arrived unsigned; dropping");
2759 return;
2760 }
2761 };
2762 let their_pub =
2763 match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
2764 Ok(pk) => pk,
2765 Err(e) => {
2766 warn!(%e, "SasInit: bad x25519 pubkey");
2767 return;
2768 }
2769 };
2770 let tx_id_bytes = match B64.decode(&tx_id) {
2771 Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
2772 let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
2773 arr.copy_from_slice(&b);
2774 arr
2775 }
2776 _ => {
2777 warn!(%tx_id, "SasInit: bad tx_id length");
2778 return;
2779 }
2780 };
2781 let (_, our_secret, our_pub) = crate::crypto::sas::new_session();
2782 let sas_code =
2783 crate::crypto::sas::derive_sas_code(&our_secret, &their_pub, &tx_id_bytes);
2784 self.sas_flows.lock().unwrap().insert(
2785 tx_id.clone(),
2786 SasFlow {
2787 room_id: room_id.to_string(),
2788 partner_fingerprint: signer.clone(),
2789 our_secret,
2790 sas_code: Some(sas_code.clone()),
2791 our_confirmed: false,
2792 their_confirmed: false,
2793 },
2794 );
2795 let response = RoomMessage::SasResponse {
2798 tx_id: tx_id.clone(),
2799 ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2800 };
2801 if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
2802 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
2803 self.network
2804 .publish_room_message(room_id.to_string(), bytes)
2805 .await;
2806 }
2807 }
2808 let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
2809 room_id: room_id.to_string(),
2810 partner_fingerprint: signer,
2811 tx_id,
2812 emoji_string: sas_code.emoji_string(),
2813 emoji_labels: sas_code.emoji_labels(),
2814 decimal: sas_code.decimal,
2815 });
2816 }
2817 RoomMessage::SasResponse {
2818 tx_id,
2819 ephemeral_x25519_pubkey_b64,
2820 } => {
2821 let signer = match verified_signer {
2822 Some(fp) => fp,
2823 None => {
2824 warn!("SasResponse arrived unsigned; dropping");
2825 return;
2826 }
2827 };
2828 let their_pub =
2829 match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
2830 Ok(pk) => pk,
2831 Err(e) => {
2832 warn!(%e, "SasResponse: bad x25519 pubkey");
2833 return;
2834 }
2835 };
2836 let tx_id_bytes = match B64.decode(&tx_id) {
2837 Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
2838 let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
2839 arr.copy_from_slice(&b);
2840 arr
2841 }
2842 _ => return,
2843 };
2844 let emit = {
2845 let mut flows = self.sas_flows.lock().unwrap();
2846 let flow = match flows.get_mut(&tx_id) {
2847 Some(f) => f,
2848 None => {
2849 warn!(%tx_id, "SasResponse for unknown tx_id");
2850 return;
2851 }
2852 };
2853 if flow.partner_fingerprint != signer {
2854 warn!(
2855 expected = %flow.partner_fingerprint, got = %signer,
2856 "SasResponse signer doesn't match flow's partner; dropping"
2857 );
2858 return;
2859 }
2860 let code = crate::crypto::sas::derive_sas_code(
2861 &flow.our_secret,
2862 &their_pub,
2863 &tx_id_bytes,
2864 );
2865 flow.sas_code = Some(code.clone());
2866 code
2867 };
2868 let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
2869 room_id: room_id.to_string(),
2870 partner_fingerprint: signer,
2871 tx_id,
2872 emoji_string: emit.emoji_string(),
2873 emoji_labels: emit.emoji_labels(),
2874 decimal: emit.decimal,
2875 });
2876 }
2877 RoomMessage::CodeJoinRequest {
2878 room_id: announced_room_id,
2879 joiner_x25519_pubkey_b64,
2880 code,
2881 } => {
2882 if announced_room_id != room_id {
2883 return;
2884 }
2885 let joiner_fp = match verified_signer {
2886 Some(fp) => fp,
2887 None => {
2888 warn!("CodeJoinRequest unsigned; dropping");
2889 return;
2890 }
2891 };
2892 let our_fp = self.identity.fingerprint().to_string();
2896 if !self.is_owner(room_id, &our_fp) {
2897 return;
2898 }
2899 let now = now_unix();
2901 let (code_ok, our_session_id, wrap_input) = {
2902 let mut rooms = self.active_rooms.lock().unwrap();
2903 let room = match rooms.get_mut(room_id) {
2904 Some(r) => r,
2905 None => return,
2906 };
2907 if room.passphrase_key.is_none() {
2908 warn!("CodeJoinRequest: no passphrase key locally; can't respond");
2909 return;
2910 }
2911 let original_len = room.issued_codes.len();
2912 room.issued_codes.retain(|(c, exp)| !(c == &code && *exp > now));
2913 let matched = room.issued_codes.len() < original_len;
2914 if !matched {
2915 info!(%joiner_fp, "CodeJoinRequest: code invalid or expired; ignoring");
2916 return;
2917 }
2918 let crypto = room.crypto.as_ref().unwrap();
2919 (
2920 true,
2921 crypto.our_session_id(),
2922 crypto.our_session_key_b64(),
2923 )
2924 };
2925 let _ = code_ok;
2926 let their_pub = match crate::crypto::sas::parse_pubkey(&joiner_x25519_pubkey_b64) {
2928 Ok(pk) => pk,
2929 Err(e) => {
2930 warn!(%e, "CodeJoinRequest: bad pubkey");
2931 return;
2932 }
2933 };
2934 use x25519_dalek::{PublicKey, StaticSecret};
2935 let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
2936 let our_pub = PublicKey::from(&our_secret);
2937 let shared = our_secret.diffie_hellman(&their_pub);
2938 let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
2940 let mut wrap_key = [0u8; passphrase::KEY_LEN];
2941 hk.expand(b"huddle-code-join-v1", &mut wrap_key)
2942 .expect("32 bytes is within HKDF limits");
2943 let wrapped = match passphrase::wrap(wrap_input.as_bytes(), &wrap_key) {
2946 Ok(w) => w,
2947 Err(e) => {
2948 warn!(%e, "CodeJoinRequest: wrap failed");
2949 return;
2950 }
2951 };
2952 let response = RoomMessage::CodeJoinResponse {
2953 room_id: room_id.to_string(),
2954 target_fingerprint: joiner_fp.clone(),
2955 owner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2956 owner_session_id: our_session_id,
2957 wrapped_session_key_b64: wrapped,
2958 nonce_b64: String::new(), };
2960 if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
2961 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
2962 self.network
2963 .publish_room_message(room_id.to_string(), bytes)
2964 .await;
2965 }
2966 }
2967 info!(%joiner_fp, %room_id, "issued CodeJoinResponse");
2968 }
2969 RoomMessage::CodeJoinResponse {
2970 room_id: announced_room_id,
2971 target_fingerprint,
2972 owner_x25519_pubkey_b64,
2973 owner_session_id,
2974 wrapped_session_key_b64,
2975 nonce_b64: _,
2976 } => {
2977 if announced_room_id != room_id || target_fingerprint != our_fp {
2978 return;
2979 }
2980 let owner_fp = match verified_signer {
2981 Some(fp) => fp,
2982 None => {
2983 warn!("CodeJoinResponse unsigned; dropping");
2984 return;
2985 }
2986 };
2987 let our_secret = match self
2988 .pending_code_secrets
2989 .lock()
2990 .unwrap()
2991 .remove(&(room_id.to_string(), our_fp.clone()))
2992 {
2993 Some(s) => s,
2994 None => {
2995 warn!(%room_id, "CodeJoinResponse with no pending code-join state");
2996 return;
2997 }
2998 };
2999 let owner_pub = match crate::crypto::sas::parse_pubkey(&owner_x25519_pubkey_b64) {
3000 Ok(pk) => pk,
3001 Err(e) => {
3002 warn!(%e, "CodeJoinResponse: bad owner pubkey");
3003 return;
3004 }
3005 };
3006 let shared = our_secret.diffie_hellman(&owner_pub);
3007 let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
3008 let mut wrap_key = [0u8; passphrase::KEY_LEN];
3009 hk.expand(b"huddle-code-join-v1", &mut wrap_key)
3010 .expect("32 bytes within HKDF limits");
3011 let session_key_bytes =
3012 match passphrase::unwrap(&wrapped_session_key_b64, &wrap_key) {
3013 Ok(b) => b,
3014 Err(e) => {
3015 warn!(%e, "CodeJoinResponse: unwrap failed");
3016 return;
3017 }
3018 };
3019 let session_key_str = match String::from_utf8(session_key_bytes) {
3020 Ok(s) => s,
3021 Err(e) => {
3022 warn!(%e, "CodeJoinResponse: session key wasn't valid utf8");
3023 return;
3024 }
3025 };
3026 let mut rooms = self.active_rooms.lock().unwrap();
3028 if let Some(room) = rooms.get_mut(room_id) {
3029 if let Some(crypto) = room.crypto.as_mut() {
3030 if let Err(e) =
3031 crypto.add_inbound_session(&owner_fp, &session_key_str)
3032 {
3033 warn!(%e, "CodeJoinResponse: add_inbound_session failed");
3034 } else {
3035 info!(%room_id, %owner_fp, %owner_session_id, "code-join completed; can decrypt owner's messages");
3036 room.members.insert(owner_fp.clone());
3037 let _ = self.app_event_tx.send(AppEvent::MemberJoined {
3038 room_id: room_id.to_string(),
3039 fingerprint: owner_fp,
3040 });
3041 }
3042 }
3043 }
3044 }
3045 RoomMessage::JoinRefused {
3046 room_id: announced_room_id,
3047 target_fingerprint,
3048 reason,
3049 } => {
3050 if announced_room_id != room_id || target_fingerprint != our_fp {
3051 return;
3052 }
3053 let _ = self.app_event_tx.send(AppEvent::Error {
3057 description: format!("join refused: {reason}"),
3058 });
3059 }
3060 RoomMessage::SasConfirm { tx_id, matched } => {
3061 let signer = match verified_signer {
3062 Some(fp) => fp,
3063 None => return,
3064 };
3065 let (room_id_done, partner_fp_done, both_done) = {
3066 let mut flows = self.sas_flows.lock().unwrap();
3067 let flow = match flows.get_mut(&tx_id) {
3068 Some(f) => f,
3069 None => return,
3070 };
3071 if flow.partner_fingerprint != signer {
3072 return;
3073 }
3074 if !matched {
3075 let _ = flow;
3077 flows.remove(&tx_id);
3078 return;
3079 }
3080 flow.their_confirmed = true;
3081 if flow.our_confirmed && flow.their_confirmed {
3082 (
3083 Some(flow.room_id.clone()),
3084 Some(flow.partner_fingerprint.clone()),
3085 true,
3086 )
3087 } else {
3088 (None, None, false)
3089 }
3090 };
3091 if both_done {
3092 if let (Some(rid), Some(pfp)) = (room_id_done, partner_fp_done) {
3093 if let Err(e) = self.finish_sas(&tx_id, &rid, &pfp).await {
3094 warn!(%e, "finish_sas failed");
3095 }
3096 }
3097 }
3098 }
3099 RoomMessage::ProfileUpdate {
3100 sender_fingerprint,
3101 username,
3102 updated_at,
3103 } => {
3104 let signer = match verified_signer {
3110 Some(fp) => fp,
3111 None => {
3112 warn!(
3113 sender = %sender_fingerprint,
3114 "dropping unsigned ProfileUpdate"
3115 );
3116 return;
3117 }
3118 };
3119 if signer != sender_fingerprint {
3120 warn!(
3121 signer = %signer,
3122 claimed = %sender_fingerprint,
3123 "dropping ProfileUpdate with signer != sender"
3124 );
3125 return;
3126 }
3127 if let Err(e) = repo::upsert_peer_profile(
3128 &self.db,
3129 &sender_fingerprint,
3130 username.as_deref(),
3131 updated_at,
3132 ) {
3133 warn!(%e, "upsert_peer_profile failed");
3134 return;
3135 }
3136 let _ = self.app_event_tx.send(AppEvent::PeerProfileUpdated {
3137 fingerprint: sender_fingerprint,
3138 username,
3139 });
3140 }
3141 }
3142 }
3143
3144 pub async fn send_file(&self, room_id: &str, path: &Path) -> Result<String> {
3152 let bytes = std::fs::read(path)?;
3153 let name = path
3154 .file_name()
3155 .map(|n| n.to_string_lossy().to_string())
3156 .unwrap_or_else(|| "untitled".into());
3157 let mime = crate::files::guess_mime(&name);
3158 let original_path = path.to_path_buf();
3159
3160 let (room_encrypted, mut maybe_session_id, encrypted_meta_opt, wire_bytes) = {
3161 let mut rooms = self.active_rooms.lock().unwrap();
3162 let room = rooms
3163 .get_mut(room_id)
3164 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3165 if room.info.encrypted {
3166 let crypto = room
3167 .crypto
3168 .as_mut()
3169 .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
3170 let (ciphertext, meta) = file_encryption::encrypt_file(&bytes, crypto)?;
3171 (true, Some(meta.megolm_session_id.clone()), Some(meta), ciphertext)
3172 } else {
3173 (false, None, None, bytes)
3174 }
3175 };
3176 let _ = &mut maybe_session_id; let plan =
3179 self.file_manager
3180 .prepare_outgoing_from_bytes(&name, mime.clone(), wire_bytes)?;
3181 let file_id = plan.file_id.clone();
3182 let total = plan.chunks.len() as u32;
3183 let our_fp = self.identity.fingerprint().to_string();
3184
3185 let attachment = StoredAttachment {
3186 id: 0,
3187 room_id: room_id.to_string(),
3188 message_id: None,
3189 sender_fingerprint: our_fp.clone(),
3190 file_id: file_id.clone(),
3191 name: name.clone(),
3192 mime: mime.clone(),
3193 size_bytes: plan.size_bytes as i64,
3194 status: AttachmentStatus::Ready,
3195 cache_path: Some(self.file_manager.cache_path(&file_id).to_string_lossy().into()),
3196 saved_path: Some(original_path.to_string_lossy().into()),
3197 error: None,
3198 encrypted: room_encrypted,
3199 wrapped_key: encrypted_meta_opt.as_ref().map(|m| m.wrapped_key_b64.clone()),
3200 nonce: encrypted_meta_opt.as_ref().map(|m| m.nonce_b64.clone()),
3201 megolm_session_id: encrypted_meta_opt
3202 .as_ref()
3203 .map(|m| m.megolm_session_id.clone()),
3204 content_hash: encrypted_meta_opt.as_ref().map(|m| m.content_hash.clone()),
3205 created_at: now_unix(),
3206 };
3207 repo::upsert_attachment(&self.db, &attachment)?;
3208 let _ = self.app_event_tx.send(AppEvent::FileOffered {
3209 room_id: room_id.to_string(),
3210 file_id: file_id.clone(),
3211 name: name.clone(),
3212 size_bytes: plan.size_bytes,
3213 sender_fingerprint: our_fp.clone(),
3214 });
3215
3216 let offer = RoomMessage::FileOffer {
3218 sender_fingerprint: our_fp.clone(),
3219 file_id: file_id.clone(),
3220 name,
3221 size_bytes: plan.size_bytes,
3222 mime,
3223 chunk_count: total,
3224 encrypted_meta: encrypted_meta_opt,
3225 };
3226 if let Ok(bytes) = encode_wire(&offer) {
3227 self.network
3228 .publish_room_message(room_id.to_string(), bytes)
3229 .await;
3230 }
3231
3232 let net = self.network.clone();
3235 let room = room_id.to_string();
3236 let our = our_fp.clone();
3237 let fid = file_id.clone();
3238 let chunks = plan.chunks.clone();
3239 tokio::spawn(async move {
3240 for (i, data) in chunks.iter().enumerate() {
3241 let msg = RoomMessage::FileChunk {
3242 sender_fingerprint: our.clone(),
3243 file_id: fid.clone(),
3244 chunk_index: i as u32,
3245 total_chunks: total,
3246 data_b64: B64.encode(data),
3247 };
3248 if let Ok(bytes) = encode_wire(&msg) {
3249 net.publish_room_message(room.clone(), bytes).await;
3250 }
3251 tokio::time::sleep(Duration::from_millis(40)).await;
3252 }
3253 });
3254
3255 Ok(file_id)
3256 }
3257
3258 pub async fn save_to_downloads(&self, room_id: &str, file_id: &str) -> Result<PathBuf> {
3261 let attachment = repo::get_attachment(&self.db, room_id, file_id)?
3262 .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
3263 if !matches!(
3264 attachment.status,
3265 AttachmentStatus::Ready | AttachmentStatus::Saved
3266 ) {
3267 return Err(HuddleError::Other(format!(
3268 "attachment is not ready (status={})",
3269 attachment.status.as_str()
3270 )));
3271 }
3272 let plaintext = if attachment.encrypted
3277 && attachment.sender_fingerprint == self.identity.fingerprint()
3278 {
3279 match attachment
3280 .saved_path
3281 .as_deref()
3282 .filter(|p| Path::new(p).exists())
3283 {
3284 Some(src) => std::fs::read(src)?,
3285 None => {
3286 return Err(HuddleError::Other(
3287 "your original file has moved or been deleted — it can't be \
3288 recovered from the encrypted cache"
3289 .into(),
3290 ));
3291 }
3292 }
3293 } else {
3294 let cached = self.file_manager.read_cache(file_id)?;
3295 if attachment.encrypted {
3296 let meta = EncryptedFileMeta {
3297 megolm_session_id: attachment
3298 .megolm_session_id
3299 .clone()
3300 .ok_or_else(|| HuddleError::Other("missing megolm_session_id".into()))?,
3301 wrapped_key_b64: attachment
3302 .wrapped_key
3303 .clone()
3304 .ok_or_else(|| HuddleError::Other("missing wrapped_key".into()))?,
3305 nonce_b64: attachment
3306 .nonce
3307 .clone()
3308 .ok_or_else(|| HuddleError::Other("missing nonce".into()))?,
3309 content_hash: attachment
3310 .content_hash
3311 .clone()
3312 .ok_or_else(|| HuddleError::Other("missing content_hash".into()))?,
3313 };
3314 self.decrypt_attachment(
3315 room_id,
3316 &attachment.sender_fingerprint,
3317 &cached,
3318 &meta,
3319 )?
3320 } else {
3321 cached
3322 }
3323 };
3324 let saved = self.file_manager.write_to_downloads(&attachment.name, &plaintext)?;
3325 repo::update_attachment_paths(
3326 &self.db,
3327 room_id,
3328 file_id,
3329 None,
3330 Some(&saved.to_string_lossy()),
3331 )?;
3332 repo::update_attachment_status(&self.db, room_id, file_id, AttachmentStatus::Saved, None)?;
3333 let _ = self.app_event_tx.send(AppEvent::FileSaved {
3334 file_id: file_id.into(),
3335 path: saved.to_string_lossy().into(),
3336 });
3337 Ok(saved)
3338 }
3339
3340 pub async fn cancel_transfer(&self, room_id: &str, file_id: &str) -> Result<()> {
3342 self.file_manager.cancel_incoming(file_id);
3343 repo::update_attachment_status(
3344 &self.db,
3345 room_id,
3346 file_id,
3347 AttachmentStatus::Cancelled,
3348 None,
3349 )?;
3350 Ok(())
3351 }
3352
3353 pub fn open_saved(&self, room_id: &str, file_id: &str) -> Result<()> {
3355 let attachment = repo::get_attachment(&self.db, room_id, file_id)?
3356 .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
3357 let path = attachment
3358 .saved_path
3359 .ok_or_else(|| HuddleError::Other("not saved yet — press Enter to save first".into()))?;
3360 open_with_system(&path)
3361 }
3362
3363 pub fn list_room_attachments(&self, room_id: &str) -> Result<Vec<StoredAttachment>> {
3364 repo::list_room_attachments(&self.db, room_id)
3365 }
3366
3367 pub fn set_member_verified(
3371 &self,
3372 room_id: &str,
3373 fingerprint: &str,
3374 verified: bool,
3375 ) -> Result<()> {
3376 let members = repo::list_room_members(&self.db, room_id).unwrap_or_default();
3381 if !members.iter().any(|m| m.fingerprint == fingerprint) {
3382 repo::upsert_room_member(
3383 &self.db,
3384 &StoredRoomMember {
3385 room_id: room_id.to_string(),
3386 peer_id: String::new(),
3387 fingerprint: fingerprint.to_string(),
3388 last_seen: Some(now_unix()),
3389 verified,
3390 ed25519_pubkey: None,
3391 role: "member".into(),
3392 },
3393 )?;
3394 }
3395 repo::set_member_verified(&self.db, room_id, fingerprint, verified)
3396 }
3397
3398 pub fn verified_fingerprints(&self, room_id: &str) -> Vec<String> {
3399 repo::list_verified_fingerprints(&self.db, room_id).unwrap_or_default()
3400 }
3401
3402 pub fn is_owner(&self, room_id: &str, fingerprint: &str) -> bool {
3405 repo::list_room_owners(&self.db, room_id)
3406 .unwrap_or_default()
3407 .iter()
3408 .any(|fp| fp == fingerprint)
3409 }
3410
3411 pub fn we_are_owner(&self, room_id: &str) -> bool {
3412 self.is_owner(room_id, &self.identity.fingerprint().to_string())
3413 }
3414
3415 pub fn room_owners(&self, room_id: &str) -> Vec<String> {
3418 repo::list_room_owners(&self.db, room_id).unwrap_or_default()
3419 }
3420
3421 pub fn has_master_passphrase(&self) -> bool {
3427 self.session_persist_key != [0u8; 32]
3428 }
3429
3430 pub fn verified_only_inbound(&self) -> bool {
3433 repo::get_setting(&self.db, "verified_only_inbound")
3434 .unwrap_or(None)
3435 .map(|v| v == "1")
3436 .unwrap_or(false)
3437 }
3438
3439 pub fn set_verified_only_inbound(&self, on: bool) -> Result<()> {
3440 repo::set_setting(&self.db, "verified_only_inbound", if on { "1" } else { "0" })
3441 }
3442
3443 pub fn mdns_enabled(&self) -> bool {
3453 repo::get_setting(&self.db, "mdns_enabled")
3454 .unwrap_or(None)
3455 .map(|v| v == "1")
3456 .unwrap_or(true)
3457 }
3458
3459 pub fn set_mdns_enabled(&self, on: bool) -> Result<()> {
3460 repo::set_setting(&self.db, "mdns_enabled", if on { "1" } else { "0" })
3461 }
3462
3463 pub fn notifications_enabled(&self) -> bool {
3469 repo::get_setting(&self.db, "notifications_enabled")
3470 .unwrap_or(None)
3471 .map(|v| v == "1")
3472 .unwrap_or(true)
3473 }
3474
3475 pub fn set_notifications_enabled(&self, on: bool) -> Result<()> {
3476 repo::set_setting(
3477 &self.db,
3478 "notifications_enabled",
3479 if on { "1" } else { "0" },
3480 )
3481 }
3482
3483 pub fn safety_code(&self) -> String {
3488 crate::identity::safety_code(&self.identity.public_bytes())
3489 }
3490
3491 pub fn room_verified_only(&self, room_id: &str) -> bool {
3496 repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
3497 }
3498
3499 pub fn set_room_verified_only(&self, room_id: &str, on: bool) -> Result<()> {
3500 repo::set_room_verified_only(&self.db, room_id, on)
3501 }
3502
3503 pub fn onboarding_seen(&self) -> bool {
3505 repo::is_onboarding_seen(&self.db).unwrap_or(true)
3506 }
3507
3508 pub fn mark_onboarding_seen(&self) -> Result<()> {
3509 repo::mark_onboarding_seen(&self.db)
3510 }
3511
3512 pub fn last_seen_onboarding_version(&self) -> Option<String> {
3516 repo::get_last_seen_onboarding_version(&self.db).unwrap_or(None)
3517 }
3518
3519 pub fn set_last_seen_onboarding_version(&self, version: &str) -> Result<()> {
3520 repo::set_last_seen_onboarding_version(&self.db, version)
3521 }
3522
3523 pub fn update_check_enabled(&self) -> Option<bool> {
3526 repo::get_update_check_enabled(&self.db).unwrap_or(None)
3527 }
3528
3529 pub fn set_update_check_enabled(&self, enabled: bool) -> Result<()> {
3530 repo::set_update_check_enabled(&self.db, enabled)
3531 }
3532
3533 pub fn last_update_check_at(&self) -> i64 {
3536 repo::get_setting(&self.db, "last_update_check_at")
3537 .ok()
3538 .flatten()
3539 .and_then(|s| s.parse().ok())
3540 .unwrap_or(0)
3541 }
3542
3543 pub fn set_last_update_check_at(&self, ts: i64) -> Result<()> {
3544 repo::set_setting(&self.db, "last_update_check_at", &ts.to_string())
3545 }
3546
3547 pub fn last_known_remote_version(&self) -> Option<String> {
3551 repo::get_setting(&self.db, "last_known_remote_version")
3552 .ok()
3553 .flatten()
3554 }
3555
3556 pub fn set_last_known_remote_version(&self, v: &str) -> Result<()> {
3557 repo::set_setting(&self.db, "last_known_remote_version", v)
3558 }
3559
3560 pub async fn grant_owner(&self, room_id: &str, target_fingerprint: &str) -> Result<()> {
3564 let our_fp = self.identity.fingerprint().to_string();
3565 if !self.is_owner(room_id, &our_fp) {
3566 return Err(HuddleError::Other(
3567 "only an owner can grant owner".into(),
3568 ));
3569 }
3570 let msg = RoomMessage::OwnerGrant {
3571 room_id: room_id.to_string(),
3572 target_fingerprint: target_fingerprint.to_string(),
3573 };
3574 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3575 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3576 self.network
3577 .publish_room_message(room_id.to_string(), bytes)
3578 .await;
3579 repo::set_member_role(&self.db, room_id, target_fingerprint, "owner")?;
3581 Ok(())
3582 }
3583
3584 pub async fn kick_member(
3595 &self,
3596 room_id: &str,
3597 target_fingerprint: &str,
3598 ) -> Result<String> {
3599 let our_fp = self.identity.fingerprint().to_string();
3600 if !self.is_owner(room_id, &our_fp) {
3601 return Err(HuddleError::Other("only an owner can kick".into()));
3602 }
3603 if target_fingerprint == our_fp {
3604 return Err(HuddleError::Other("can't kick yourself".into()));
3605 }
3606 let info = self
3607 .active_rooms
3608 .lock()
3609 .unwrap()
3610 .get(room_id)
3611 .map(|r| r.info.clone())
3612 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3613 if !info.encrypted {
3614 let msg = RoomMessage::BanMember {
3618 room_id: room_id.to_string(),
3619 target_fingerprint: target_fingerprint.to_string(),
3620 };
3621 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3622 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3623 self.network
3624 .publish_room_message(room_id.to_string(), bytes)
3625 .await;
3626 repo::add_room_ban(
3627 &self.db,
3628 room_id,
3629 target_fingerprint,
3630 &our_fp,
3631 &env.signature_b64,
3632 now_unix(),
3633 )?;
3634 self.evict_banned_member(room_id, target_fingerprint);
3635 return Ok(String::new());
3636 }
3637 let new_passphrase = generate_join_passphrase();
3639 let msg = RoomMessage::BanMember {
3640 room_id: room_id.to_string(),
3641 target_fingerprint: target_fingerprint.to_string(),
3642 };
3643 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3644 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3645 self.network
3646 .publish_room_message(room_id.to_string(), bytes)
3647 .await;
3648 repo::add_room_ban(
3649 &self.db,
3650 room_id,
3651 target_fingerprint,
3652 &our_fp,
3653 &env.signature_b64,
3654 now_unix(),
3655 )?;
3656 self.evict_banned_member(room_id, target_fingerprint);
3657 self.rotate_room(room_id, &new_passphrase).await?;
3660 Ok(new_passphrase)
3661 }
3662
3663 pub fn generate_join_code(&self, room_id: &str) -> Result<String> {
3670 let our_fp = self.identity.fingerprint().to_string();
3671 if !self.is_owner(room_id, &our_fp) {
3672 return Err(HuddleError::Other(
3673 "only an owner can issue join codes".into(),
3674 ));
3675 }
3676 let code = generate_alphanumeric_code(8);
3677 let expires_at = now_unix() + 10 * 60;
3678 let mut rooms = self.active_rooms.lock().unwrap();
3679 let room = rooms
3680 .get_mut(room_id)
3681 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3682 let now = now_unix();
3684 room.issued_codes.retain(|(_, exp)| *exp > now);
3685 room.issued_codes.push((code.clone(), expires_at));
3686 Ok(code)
3687 }
3688
3689 pub async fn join_room_with_code(
3696 &self,
3697 room_id: &str,
3698 code: &str,
3699 ) -> Result<()> {
3700 let info = {
3702 let d = self.discovered_rooms.lock().unwrap().get(room_id).cloned();
3703 match d {
3704 Some(d) => StoredRoom {
3705 id: room_id.to_string(),
3706 name: d.name,
3707 creator_fingerprint: d.creator_fingerprint,
3708 encrypted: d.encrypted,
3709 passphrase_salt: None, created_at: now_unix(),
3711 last_active: Some(now_unix()),
3712 kind: d.kind,
3715 },
3716 None => {
3717 return Err(HuddleError::Other(format!(
3718 "room {room_id} not visible — wait for an announcement"
3719 )))
3720 }
3721 }
3722 };
3723 if !info.encrypted {
3724 return Err(HuddleError::Other(
3725 "code-join only applies to encrypted rooms".into(),
3726 ));
3727 }
3728 let our_fp = self.identity.fingerprint().to_string();
3729 use x25519_dalek::{PublicKey, StaticSecret};
3732 let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
3733 let our_pub = PublicKey::from(&our_secret);
3734 let key = (room_id.to_string(), our_fp.clone());
3739 self.pending_code_secrets
3740 .lock()
3741 .unwrap()
3742 .insert(key.clone(), our_secret);
3743 let map = self.pending_code_secrets.clone();
3748 let tx = self.app_event_tx.clone();
3749 let timeout_room = room_id.to_string();
3750 tokio::spawn(async move {
3751 tokio::time::sleep(std::time::Duration::from_secs(30)).await;
3752 let still_pending = map.lock().unwrap().remove(&key).is_some();
3753 if still_pending {
3754 let _ = tx.send(AppEvent::CodeJoinTimedOut {
3755 room_id: timeout_room,
3756 reason: "no response from owner — code may be wrong or expired".into(),
3757 });
3758 }
3759 });
3760 repo::insert_room(&self.db, &info)?;
3767 self.active_rooms.lock().unwrap().insert(
3770 room_id.to_string(),
3771 ActiveRoom {
3772 info: info.clone(),
3773 crypto: Some(RoomCrypto::new_for_room(
3774 self.db.clone(),
3775 room_id.to_string(),
3776 our_fp.clone(),
3777 self.session_persist_key,
3778 )?),
3779 passphrase_key: None,
3780 members: {
3781 let mut s = HashSet::new();
3782 s.insert(our_fp.clone());
3783 s
3784 },
3785 typers: HashMap::new(),
3786 read_only: true,
3787 issued_codes: Vec::new(),
3788 },
3789 );
3790 self.network.subscribe_room(room_id.to_string()).await;
3791 let req = RoomMessage::CodeJoinRequest {
3793 room_id: room_id.to_string(),
3794 joiner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
3795 code: code.to_string(),
3796 };
3797 let env = crate::crypto::sign_message(&self.identity, &req)?;
3798 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3799 self.network
3800 .publish_room_message(room_id.to_string(), bytes)
3801 .await;
3802 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
3805 room_id: room_id.to_string(),
3806 });
3807 Ok(())
3808 }
3809
3810 pub async fn sas_start(&self, room_id: &str, target_fingerprint: &str) -> Result<String> {
3816 let (tx_id_bytes, our_secret, our_pub) = crate::crypto::sas::new_session();
3817 let tx_id = B64.encode(tx_id_bytes);
3818 let msg = RoomMessage::SasInit {
3819 tx_id: tx_id.clone(),
3820 ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
3821 target_fingerprint: target_fingerprint.to_string(),
3822 };
3823 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3824 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3825 self.sas_flows.lock().unwrap().insert(
3826 tx_id.clone(),
3827 SasFlow {
3828 room_id: room_id.to_string(),
3829 partner_fingerprint: target_fingerprint.to_string(),
3830 our_secret,
3831 sas_code: None,
3832 our_confirmed: false,
3833 their_confirmed: false,
3834 },
3835 );
3836 self.network
3837 .publish_room_message(room_id.to_string(), bytes)
3838 .await;
3839 Ok(tx_id)
3840 }
3841
3842 pub async fn sas_match(&self, tx_id: &str) -> Result<()> {
3846 let (room_id, partner_fp, both_done) = {
3847 let mut flows = self.sas_flows.lock().unwrap();
3848 let flow = flows
3849 .get_mut(tx_id)
3850 .ok_or_else(|| HuddleError::Other("unknown SAS tx_id".into()))?;
3851 flow.our_confirmed = true;
3852 (
3853 flow.room_id.clone(),
3854 flow.partner_fingerprint.clone(),
3855 flow.our_confirmed && flow.their_confirmed,
3856 )
3857 };
3858 let msg = RoomMessage::SasConfirm {
3859 tx_id: tx_id.to_string(),
3860 matched: true,
3861 };
3862 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3863 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3864 self.network
3865 .publish_room_message(room_id.clone(), bytes)
3866 .await;
3867 if both_done {
3868 self.finish_sas(tx_id, &room_id, &partner_fp).await?;
3869 }
3870 Ok(())
3871 }
3872
3873 pub fn sas_cancel(&self, tx_id: &str) {
3877 self.sas_flows.lock().unwrap().remove(tx_id);
3878 }
3879
3880 async fn finish_sas(
3883 &self,
3884 tx_id: &str,
3885 room_id: &str,
3886 partner_fingerprint: &str,
3887 ) -> Result<()> {
3888 repo::set_member_verified(&self.db, room_id, partner_fingerprint, true)?;
3889 repo::add_verified_peer(&self.db, partner_fingerprint, now_unix())?;
3890 self.sas_flows.lock().unwrap().remove(tx_id);
3891 let _ = self.app_event_tx.send(AppEvent::SasVerified {
3892 room_id: room_id.to_string(),
3893 partner_fingerprint: partner_fingerprint.to_string(),
3894 });
3895 Ok(())
3896 }
3897
3898 fn evict_banned_member(&self, room_id: &str, fingerprint: &str) {
3903 if let Some(room) = self.active_rooms.lock().unwrap().get_mut(room_id) {
3904 room.members.remove(fingerprint);
3905 }
3906 let _ = self.app_event_tx.send(AppEvent::MemberLeft {
3907 room_id: room_id.to_string(),
3908 fingerprint: fingerprint.to_string(),
3909 });
3910 }
3911
3912 pub fn display_name(&self) -> Option<String> {
3913 repo::get_display_name(&self.db).unwrap_or(None)
3914 }
3915
3916 pub fn set_display_name(&self, name: Option<&str>) -> Result<()> {
3917 repo::set_display_name(&self.db, name)
3918 }
3919
3920 pub async fn set_username(&self, name: Option<&str>) -> Result<()> {
3926 repo::set_display_name(&self.db, name)?;
3927 let msg = RoomMessage::ProfileUpdate {
3928 sender_fingerprint: self.identity.fingerprint().to_string(),
3929 username: name.map(|s| s.to_string()),
3930 updated_at: now_unix_ms(),
3931 };
3932 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3933 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3934 let rooms: Vec<String> = self.active_rooms.lock().unwrap().keys().cloned().collect();
3935 for room_id in rooms {
3936 self.network
3937 .publish_room_message(room_id, bytes.clone())
3938 .await;
3939 }
3940 Ok(())
3941 }
3942
3943 pub fn lookup_username(&self, fingerprint: &str) -> Option<String> {
3948 repo::get_peer_username(&self.db, fingerprint).unwrap_or(None)
3949 }
3950
3951 pub fn lookup_member_display_name(&self, fingerprint: &str) -> Option<String> {
3955 self.lookup_username(fingerprint)
3956 }
3957
3958 pub fn is_room_muted(&self, room_id: &str) -> bool {
3959 repo::is_room_muted(&self.db, room_id).unwrap_or(false)
3960 }
3961
3962 pub fn list_room_bans(&self, room_id: &str) -> Vec<String> {
3967 repo::list_room_bans(&self.db, room_id).unwrap_or_default()
3968 }
3969
3970 pub fn list_verified_peers(&self) -> Vec<String> {
3976 repo::list_verified_peers(&self.db).unwrap_or_default()
3977 }
3978
3979 pub fn list_blocked_peers(&self) -> Vec<String> {
3980 repo::list_blocked_peers(&self.db).unwrap_or_default()
3981 }
3982
3983 pub fn unblock_peer(&self, fingerprint: &str) -> Result<()> {
3987 repo::unblock_peer(&self.db, fingerprint)
3988 }
3989
3990 pub fn block_peer(&self, fingerprint: &str) -> Result<()> {
3994 repo::block_peer(&self.db, fingerprint, now_unix())
3995 }
3996
3997 pub fn is_room_read_only(&self, room_id: &str) -> bool {
4003 self.active_rooms
4004 .lock()
4005 .unwrap()
4006 .get(room_id)
4007 .map(|r| r.read_only)
4008 .unwrap_or(false)
4009 }
4010
4011 pub fn set_room_muted(&self, room_id: &str, muted: bool) -> Result<()> {
4012 repo::set_room_muted(&self.db, room_id, muted)
4013 }
4014
4015 pub async fn broadcast_typing(&self, room_id: &str) {
4018 if !self.active_rooms.lock().unwrap().contains_key(room_id) {
4019 return;
4020 }
4021 let msg = RoomMessage::Typing {
4022 sender_fingerprint: self.identity.fingerprint().to_string(),
4023 };
4024 if let Ok(bytes) = encode_wire(&msg) {
4025 self.network
4026 .publish_room_message(room_id.to_string(), bytes)
4027 .await;
4028 }
4029 }
4030
4031 pub fn typers_in_room(&self, room_id: &str) -> Vec<String> {
4034 let now = now_unix();
4035 let mut rooms = self.active_rooms.lock().unwrap();
4036 let room = match rooms.get_mut(room_id) {
4037 Some(r) => r,
4038 None => return Vec::new(),
4039 };
4040 room.typers.retain(|_, exp| *exp > now);
4041 let mut v: Vec<String> = room.typers.keys().cloned().collect();
4042 v.sort();
4043 v
4044 }
4045
4046 pub async fn rotate_room(&self, room_id: &str, new_passphrase: &str) -> Result<()> {
4056 if new_passphrase.is_empty() {
4057 return Err(HuddleError::Other("new passphrase is empty".into()));
4058 }
4059 let new_salt = passphrase::random_salt();
4060 let new_key = passphrase::derive_key(new_passphrase, &new_salt)?;
4061
4062 let info = {
4063 let mut rooms = self.active_rooms.lock().unwrap();
4064 let room = rooms
4065 .get_mut(room_id)
4066 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
4067 if !room.info.encrypted {
4068 return Err(HuddleError::Other(
4069 "rotation only applies to encrypted rooms".into(),
4070 ));
4071 }
4072 let new_crypto = RoomCrypto::new_for_room(
4074 self.db.clone(),
4075 room_id.to_string(),
4076 self.identity.fingerprint().to_string(),
4077 self.session_persist_key,
4078 )?;
4079 room.crypto = Some(new_crypto);
4080 room.passphrase_key = Some(new_key);
4081 room.info.passphrase_salt = Some(new_salt.to_vec());
4082 room.info.clone()
4083 };
4084
4085 let rot = RoomMessage::RotateRoomKey {
4091 rotator_fingerprint: self.identity.fingerprint().to_string(),
4092 new_salt: new_salt.to_vec(),
4093 };
4094 if let Ok(env) = crate::crypto::sign_message(&self.identity, &rot) {
4098 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
4099 self.network
4100 .publish_room_message(room_id.to_string(), bytes)
4101 .await;
4102 }
4103 }
4104 if let Err(e) = self.broadcast_member_announce(room_id).await {
4106 warn!(%e, "rotate: broadcast announce failed");
4107 }
4108
4109 repo::insert_room(&self.db, &info)?;
4111 Ok(())
4112 }
4113
4114 pub async fn accept_rotation(
4118 &self,
4119 room_id: &str,
4120 new_salt: &[u8],
4121 new_passphrase: &str,
4122 ) -> Result<()> {
4123 let new_key = passphrase::derive_key(new_passphrase, new_salt)?;
4124 let info = {
4125 let mut rooms = self.active_rooms.lock().unwrap();
4126 let room = rooms
4127 .get_mut(room_id)
4128 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
4129 room.passphrase_key = Some(new_key);
4130 room.info.passphrase_salt = Some(new_salt.to_vec());
4131 room.info.clone()
4132 };
4133 let req = RoomMessage::SessionKeyRequest {
4137 requester_fingerprint: self.identity.fingerprint().to_string(),
4138 };
4139 if let Ok(bytes) = encode_wire(&req) {
4140 self.network
4141 .publish_room_message(room_id.to_string(), bytes)
4142 .await;
4143 }
4144 repo::insert_room(&self.db, &info)?;
4145 Ok(())
4146 }
4147
4148 #[allow(clippy::too_many_arguments)]
4153 fn handle_file_offer(
4154 &self,
4155 room_id: &str,
4156 sender_fingerprint: String,
4157 file_id: String,
4158 name: String,
4159 size_bytes: u64,
4160 mime: Option<String>,
4161 _chunk_count: u32,
4162 encrypted_meta: Option<EncryptedFileMeta>,
4163 ) {
4164 let encrypted = encrypted_meta.is_some();
4165 let attachment = StoredAttachment {
4166 id: 0,
4167 room_id: room_id.to_string(),
4168 message_id: None,
4169 sender_fingerprint: sender_fingerprint.clone(),
4170 file_id: file_id.clone(),
4171 name: name.clone(),
4172 mime,
4173 size_bytes: size_bytes as i64,
4174 status: AttachmentStatus::Offered,
4175 cache_path: None,
4176 saved_path: None,
4177 error: None,
4178 encrypted,
4179 wrapped_key: encrypted_meta.as_ref().map(|m| m.wrapped_key_b64.clone()),
4180 nonce: encrypted_meta.as_ref().map(|m| m.nonce_b64.clone()),
4181 megolm_session_id: encrypted_meta.as_ref().map(|m| m.megolm_session_id.clone()),
4182 content_hash: encrypted_meta.as_ref().map(|m| m.content_hash.clone()),
4183 created_at: now_unix(),
4184 };
4185 if let Err(e) = repo::upsert_attachment(&self.db, &attachment) {
4186 warn!(%e, "upsert attachment");
4187 return;
4188 }
4189 self.file_manager.set_expected_size(&file_id, size_bytes);
4192 let _ = self.app_event_tx.send(AppEvent::FileOffered {
4193 room_id: room_id.to_string(),
4194 file_id,
4195 name,
4196 size_bytes,
4197 sender_fingerprint,
4198 });
4199 }
4200
4201 fn handle_file_chunk(
4202 &self,
4203 room_id: &str,
4204 _sender_fingerprint: String,
4205 file_id: String,
4206 chunk_index: u32,
4207 total_chunks: u32,
4208 data_b64: String,
4209 ) {
4210 let data = match B64.decode(&data_b64) {
4211 Ok(d) => d,
4212 Err(e) => {
4213 warn!(%e, "bad chunk base64");
4214 return;
4215 }
4216 };
4217 let expected_size = match repo::get_attachment(&self.db, room_id, &file_id) {
4221 Ok(Some(a)) => {
4222 if matches!(
4223 a.status,
4224 AttachmentStatus::Cancelled | AttachmentStatus::Failed
4225 ) {
4226 return;
4227 }
4228 a.size_bytes as u64
4229 }
4230 Ok(None) => crate::files::MAX_FILE_SIZE,
4231 Err(e) => {
4232 warn!(%e, "get attachment for chunk");
4233 crate::files::MAX_FILE_SIZE
4234 }
4235 };
4236
4237 let result = self.file_manager.accept_chunk(
4238 &file_id,
4239 chunk_index,
4240 total_chunks,
4241 data,
4242 expected_size,
4243 );
4244 match result {
4245 Ok(None) => {
4246 let _ = repo::update_attachment_status(
4248 &self.db,
4249 room_id,
4250 &file_id,
4251 AttachmentStatus::Downloading,
4252 None,
4253 );
4254 let bytes_so_far = self
4257 .file_manager
4258 .progress(&file_id)
4259 .map(|(b, _)| b)
4260 .unwrap_or(0);
4261 let _ = self.app_event_tx.send(AppEvent::FileProgress {
4262 file_id: file_id.clone(),
4263 bytes_received: bytes_so_far,
4264 total_bytes: expected_size,
4265 });
4266 }
4267 Ok(Some(completed)) => {
4268 let _ = repo::update_attachment_paths(
4269 &self.db,
4270 room_id,
4271 &file_id,
4272 Some(&completed.cache_path.to_string_lossy()),
4273 None,
4274 );
4275 let _ = repo::update_attachment_status(
4276 &self.db,
4277 room_id,
4278 &file_id,
4279 AttachmentStatus::Ready,
4280 None,
4281 );
4282 let _ = self.app_event_tx.send(AppEvent::FileReady {
4283 file_id: file_id.clone(),
4284 });
4285 }
4286 Err(e) => {
4287 let msg = e.to_string();
4288 warn!(%msg, "chunk processing failed");
4289 let _ = repo::update_attachment_status(
4290 &self.db,
4291 room_id,
4292 &file_id,
4293 AttachmentStatus::Failed,
4294 Some(&msg),
4295 );
4296 let _ = self.app_event_tx.send(AppEvent::FileFailed {
4297 file_id: file_id.clone(),
4298 reason: msg,
4299 });
4300 }
4301 }
4302 }
4303
4304 fn maybe_emit_mention(&self, room_id: &str, body: &str) {
4307 let full = self.identity.fingerprint().to_lowercase();
4308 let short: &str = full.split('-').next().unwrap_or(&full);
4310 let lower = body.to_lowercase();
4311 let hit = lower.contains(full.as_str())
4315 || lower
4316 .split(|c: char| !c.is_ascii_hexdigit())
4317 .any(|tok| tok == short);
4318 if hit {
4319 let _ = self.app_event_tx.send(AppEvent::MentionReceived {
4320 room_id: room_id.to_string(),
4321 body: body.to_string(),
4322 });
4323 }
4324 }
4325
4326 fn decrypt_attachment(
4327 &self,
4328 room_id: &str,
4329 sender_fingerprint: &str,
4330 ciphertext: &[u8],
4331 meta: &EncryptedFileMeta,
4332 ) -> Result<Vec<u8>> {
4333 let mut rooms = self.active_rooms.lock().unwrap();
4334 let room = rooms
4335 .get_mut(room_id)
4336 .ok_or_else(|| HuddleError::Other("not in room".into()))?;
4337 let crypto = room
4338 .crypto
4339 .as_mut()
4340 .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
4341 file_encryption::decrypt_file(ciphertext, meta, crypto, sender_fingerprint)
4342 }
4343
4344 pub async fn go_dark(&self, master_passphrase: &str) -> Result<()> {
4356 let no_master = self.session_persist_key == [0u8; 32];
4357 if !no_master {
4358 let salt = storage::keychain::load_or_create_salt()?;
4359 let candidate_master =
4360 storage::keychain::derive_master_key(master_passphrase, &salt)?;
4361 let candidate_subkey =
4362 storage::keychain::derive_subkey(&candidate_master, b"megolm-persist");
4363 if !ct_eq_32(&candidate_subkey, &self.session_persist_key) {
4364 return Err(HuddleError::Other(
4365 "incorrect master passphrase".into(),
4366 ));
4367 }
4368 }
4369
4370 let room_ids: Vec<String> = self
4371 .active_rooms
4372 .lock()
4373 .unwrap()
4374 .keys()
4375 .cloned()
4376 .collect();
4377 let _ = tokio::time::timeout(Duration::from_secs(2), async {
4378 for room_id in &room_ids {
4379 if let Err(e) = self.leave_room(room_id).await {
4380 warn!(%room_id, %e, "go_dark: leave_room failed");
4381 }
4382 }
4383 })
4384 .await;
4385
4386 self.network.shutdown().await;
4387 tokio::time::sleep(Duration::from_millis(300)).await;
4388
4389 let data_dir = config::data_dir();
4390 let candidates = [
4391 "huddle.db",
4392 "huddle.db-shm",
4393 "huddle.db-wal",
4394 "keychain.salt",
4395 "huddle.log",
4396 "config.toml",
4397 ];
4398 for name in &candidates {
4399 let path = data_dir.join(name);
4400 wipe_file(&path);
4401 }
4402 if let Ok(read) = std::fs::read_dir(&data_dir) {
4403 for entry in read.flatten() {
4404 if let Some(name) = entry.file_name().to_str() {
4405 if name.starts_with("huddle.log.") {
4406 wipe_file(&entry.path());
4407 }
4408 }
4409 }
4410 }
4411 let files_dir = data_dir.join("files");
4415 if let Ok(read) = std::fs::read_dir(&files_dir) {
4416 for entry in read.flatten() {
4417 let path = entry.path();
4418 if path.is_file() {
4419 wipe_file(&path);
4420 } else if path.is_dir() {
4421 if let Ok(inner) = std::fs::read_dir(&path) {
4424 for inner_entry in inner.flatten() {
4425 if inner_entry.path().is_file() {
4426 wipe_file(&inner_entry.path());
4427 }
4428 }
4429 }
4430 let _ = std::fs::remove_dir(&path);
4431 }
4432 }
4433 }
4434 let _ = std::fs::remove_dir(&files_dir);
4435 let _ = std::fs::remove_dir(&data_dir);
4436
4437 let _ = self.app_event_tx.send(AppEvent::WentDark);
4438 Ok(())
4439 }
4440}
4441
4442pub fn normalize_to_fingerprint(input: &str) -> Option<String> {
4449 let s = input
4450 .trim()
4451 .trim_start_matches("HD-")
4452 .trim_start_matches("hd-")
4453 .to_string();
4454 let hex_only: String = s.chars().filter(|c| *c != '-').collect();
4455 if hex_only.len() != 24 || !hex_only.chars().all(|c| c.is_ascii_hexdigit()) {
4456 return None;
4457 }
4458 let lower = hex_only.to_ascii_lowercase();
4459 let chunks: Vec<String> = lower
4460 .as_bytes()
4461 .chunks(4)
4462 .map(|c| std::str::from_utf8(c).unwrap().to_string())
4463 .collect();
4464 Some(chunks.join("-"))
4465}
4466
4467fn address_preference(addr: &str) -> u8 {
4473 if addr.contains("/p2p-circuit") {
4474 return 9; }
4476 if let Some(rest) = addr.strip_prefix("/ip4/") {
4477 if let Some(ip_str) = rest.split('/').next() {
4478 if let Ok(ip) = ip_str.parse::<std::net::Ipv4Addr>() {
4479 if ip.is_loopback() {
4480 return 1; }
4482 if is_rfc1918(&ip) || ip.is_link_local() {
4483 return 0; }
4485 return 3; }
4487 }
4488 return 3;
4489 }
4490 if addr.starts_with("/ip6/") {
4491 return 4;
4492 }
4493 if addr.starts_with("/dns4/") || addr.starts_with("/dns6/") || addr.starts_with("/dnsaddr/") {
4494 return 5;
4495 }
4496 7
4497}
4498
4499fn is_rfc1918(ip: &std::net::Ipv4Addr) -> bool {
4503 let octets = ip.octets();
4504 octets[0] == 10
4505 || (octets[0] == 172 && (16..=31).contains(&octets[1]))
4506 || (octets[0] == 192 && octets[1] == 168)
4507}
4508
4509fn short_fp_for_msg(fingerprint: &str) -> String {
4513 let head: String = fingerprint
4514 .chars()
4515 .filter(|c| *c != '-')
4516 .take(4)
4517 .collect::<String>()
4518 .to_ascii_uppercase();
4519 format!("HD-{}…", head)
4520}
4521
4522fn ct_eq_32(a: &[u8; 32], b: &[u8; 32]) -> bool {
4526 let mut diff = 0u8;
4527 for i in 0..32 {
4528 diff |= a[i] ^ b[i];
4529 }
4530 diff == 0
4531}
4532
4533fn wipe_file(path: &Path) {
4537 use std::io::Write;
4538 if let Ok(meta) = std::fs::metadata(path) {
4539 if let Ok(mut f) = std::fs::OpenOptions::new().write(true).open(path) {
4540 let zeros = vec![0u8; meta.len() as usize];
4541 let _ = f.write_all(&zeros);
4542 let _ = f.sync_all();
4543 }
4544 }
4545 if let Err(e) = std::fs::remove_file(path) {
4546 if e.kind() != std::io::ErrorKind::NotFound {
4547 warn!(?path, %e, "wipe_file: remove failed");
4548 }
4549 }
4550}
4551
4552fn open_with_system(path: &str) -> Result<()> {
4554 #[cfg(target_os = "macos")]
4555 let cmd = "open";
4556 #[cfg(target_os = "linux")]
4557 let cmd = "xdg-open";
4558 #[cfg(target_os = "windows")]
4559 let cmd = "cmd";
4560 #[cfg(target_os = "windows")]
4561 let args = vec!["/C", "start", "", path];
4562 #[cfg(not(target_os = "windows"))]
4563 let args = vec![path];
4564
4565 std::process::Command::new(cmd)
4566 .args(args)
4567 .spawn()
4568 .map_err(|e| HuddleError::Other(format!("spawn opener: {e}")))?;
4569 Ok(())
4570}
4571
4572static ROOM_SALT_CACHE: std::sync::LazyLock<Mutex<HashMap<String, Vec<u8>>>> =
4575 std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
4576
4577pub fn salt_len() -> usize {
4582 SALT_LEN
4583}
4584
4585fn now_unix() -> i64 {
4586 SystemTime::now()
4587 .duration_since(UNIX_EPOCH)
4588 .unwrap()
4589 .as_secs() as i64
4590}
4591
4592fn now_unix_ms() -> i64 {
4593 SystemTime::now()
4594 .duration_since(UNIX_EPOCH)
4595 .unwrap()
4596 .as_millis() as i64
4597}
4598
4599fn generate_join_passphrase() -> String {
4605 use rand::RngCore;
4606 let mut bytes = [0u8; 16];
4607 rand::thread_rng().fill_bytes(&mut bytes);
4608 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
4611}
4612
4613fn generate_alphanumeric_code(len: usize) -> String {
4618 use rand::Rng;
4619 const ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ23456789";
4620 let mut rng = rand::thread_rng();
4621 let mut out = String::with_capacity(len + 1);
4622 for i in 0..len {
4623 if i == 4 && len == 8 {
4624 out.push('-'); }
4626 let idx = rng.gen_range(0..ALPHABET.len());
4627 out.push(ALPHABET[idx] as char);
4628 }
4629 out
4630}
4631
4632#[cfg(test)]
4633mod parser_tests {
4634 use super::parse_dial_address;
4635
4636 #[test]
4637 fn parses_ipv4_port() {
4638 let m = parse_dial_address("10.3.72.53:9027").unwrap();
4639 assert_eq!(m.to_string(), "/ip4/10.3.72.53/tcp/9027");
4640 }
4641
4642 #[test]
4643 fn parses_bracketed_ipv6() {
4644 let m = parse_dial_address("[::1]:9027").unwrap();
4645 assert_eq!(m.to_string(), "/ip6/::1/tcp/9027");
4646 }
4647
4648 #[test]
4649 fn rejects_unbracketed_ipv6() {
4650 let err = parse_dial_address("fe80::1:9027").unwrap_err();
4651 assert!(err.to_string().contains("brackets"));
4652 }
4653
4654 #[test]
4655 fn passes_through_raw_multiaddr() {
4656 let m = parse_dial_address("/ip4/1.2.3.4/tcp/9000").unwrap();
4657 assert_eq!(m.to_string(), "/ip4/1.2.3.4/tcp/9000");
4658 }
4659
4660 #[test]
4661 fn empty_address_is_error() {
4662 assert!(parse_dial_address(" ").is_err());
4663 }
4664
4665 #[test]
4666 fn rejects_bad_port() {
4667 assert!(parse_dial_address("1.2.3.4:notaport").is_err());
4668 }
4669}
4670
4671#[cfg(test)]
4672mod transport_preference_tests {
4673 use super::{address_preference, normalize_to_fingerprint};
4674
4675 #[test]
4676 fn lan_beats_public_beats_circuit() {
4677 let lan = address_preference("/ip4/192.168.1.5/tcp/9027");
4678 let pub_v4 = address_preference("/ip4/8.8.8.8/tcp/9027");
4679 let circuit = address_preference(
4680 "/ip4/1.2.3.4/tcp/4001/p2p/12D3Koo/p2p-circuit/p2p/12D3KooXYZ",
4681 );
4682 assert!(lan < pub_v4, "LAN {} should beat public {}", lan, pub_v4);
4683 assert!(
4684 pub_v4 < circuit,
4685 "public {} should beat circuit {}",
4686 pub_v4,
4687 circuit
4688 );
4689 }
4690
4691 #[test]
4692 fn all_rfc1918_ranges_are_lan() {
4693 assert_eq!(
4694 address_preference("/ip4/10.0.0.1/tcp/9027"),
4695 address_preference("/ip4/192.168.0.1/tcp/9027"),
4696 );
4697 assert_eq!(
4698 address_preference("/ip4/172.16.0.1/tcp/9027"),
4699 address_preference("/ip4/192.168.0.1/tcp/9027"),
4700 );
4701 assert!(
4703 address_preference("/ip4/172.32.0.1/tcp/9027")
4704 > address_preference("/ip4/172.16.0.1/tcp/9027")
4705 );
4706 }
4707
4708 #[test]
4709 fn normalize_id_accepts_branded_and_raw() {
4710 let canon = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4711 assert_eq!(
4712 normalize_to_fingerprint("HD-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF").as_deref(),
4713 Some(canon)
4714 );
4715 assert_eq!(
4716 normalize_to_fingerprint("aaaabbbbccccddddeeeeffff").as_deref(),
4717 Some(canon)
4718 );
4719 assert_eq!(normalize_to_fingerprint(canon).as_deref(), Some(canon));
4720 assert!(normalize_to_fingerprint("alice").is_none());
4721 assert!(normalize_to_fingerprint("HD-ZZZZ").is_none());
4722 }
4723}
4724
4725#[cfg(test)]
4726mod canonical_dm_room_id_tests {
4727 use super::canonical_dm_room_id;
4728
4729 #[test]
4730 fn dm_room_id_is_commutative() {
4731 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4734 let b = "1111-2222-3333-4444-5555-6666";
4735 assert_eq!(canonical_dm_room_id(a, b), canonical_dm_room_id(b, a));
4736 }
4737
4738 #[test]
4739 fn dm_room_id_differs_per_pair() {
4740 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4741 let b = "1111-2222-3333-4444-5555-6666";
4742 let c = "9999-8888-7777-6666-5555-4444";
4743 assert_ne!(canonical_dm_room_id(a, b), canonical_dm_room_id(a, c));
4744 assert_ne!(canonical_dm_room_id(a, b), canonical_dm_room_id(b, c));
4745 }
4746
4747 #[test]
4748 fn dm_room_id_is_stable() {
4749 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4753 let b = "1111-2222-3333-4444-5555-6666";
4754 let id1 = canonical_dm_room_id(a, b);
4755 let id2 = canonical_dm_room_id(a, b);
4756 assert_eq!(id1, id2);
4757 assert_eq!(id1.len(), 32);
4761 }
4762}