1pub mod events;
2
3use std::collections::{HashMap, HashSet};
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use base64::engine::general_purpose::STANDARD as B64;
9use base64::Engine;
10use libp2p::{Multiaddr, PeerId};
11use tokio::sync::broadcast;
12use tracing::{debug, error, info, warn};
13
14use crate::config;
15use crate::crypto::passphrase::{self, KEY_LEN, SALT_LEN};
16use crate::crypto::RoomCrypto;
17use crate::error::{HuddleError, Result};
18use crate::files::encryption::{self as file_encryption, EncryptedFileMeta};
19use crate::files::FileManager;
20use crate::identity::Identity;
21use crate::network::events::NetworkEvent;
22use crate::network::protocol::{encode_wire, RoomAnnouncement, RoomMessage, WireMessage};
23use crate::network::{self, NetworkHandle, NetworkMode};
24use crate::storage::repo::{
25 self, derive_room_id, AttachmentStatus, KnownPeer, RoomKind, StoredAttachment, StoredRoom,
26 StoredRoomMember,
27};
28use crate::storage::{self, Db};
29
30pub use self::events::{AppEvent, DiscoveredRoom};
31
32#[derive(Debug, Clone)]
35pub struct KnownPeerStatus {
36 pub address: String,
37 pub label: Option<String>,
38 pub last_connected_at: Option<i64>,
39 pub connected_peer_id: Option<PeerId>,
40 pub fingerprint: Option<String>,
44}
45
46pub fn canonical_dm_room_id(a: &str, b: &str) -> String {
57 use sha2::{Digest, Sha256};
58 let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
59 let mut hasher = Sha256::new();
60 hasher.update(b"huddle-dm-v1\0");
61 hasher.update(lo.as_bytes());
62 hasher.update(b"\0");
63 hasher.update(hi.as_bytes());
64 hex::encode(&hasher.finalize()[..16])
65}
66
67pub fn parse_dial_address(input: &str) -> Result<Multiaddr> {
70 let trimmed = input.trim();
71 if trimmed.is_empty() {
72 return Err(HuddleError::Other("address is empty".into()));
73 }
74 if trimmed.starts_with('/') {
75 return trimmed
76 .parse::<Multiaddr>()
77 .map_err(|e| HuddleError::Other(format!("invalid multiaddr: {e}")));
78 }
79 if let Some(rest) = trimmed.strip_prefix('[') {
80 let (host, port) = rest
81 .split_once("]:")
82 .ok_or_else(|| HuddleError::Other(format!("expected [ipv6]:port, got {trimmed}")))?;
83 let port: u16 = port
84 .parse()
85 .map_err(|_| HuddleError::Other(format!("invalid port: {port}")))?;
86 return format!("/ip6/{}/tcp/{}", host, port)
87 .parse::<Multiaddr>()
88 .map_err(|e| HuddleError::Other(format!("invalid ipv6 address: {e}")));
89 }
90 let (host, port) = trimmed
91 .rsplit_once(':')
92 .ok_or_else(|| HuddleError::Other(format!("expected ip:port, got {trimmed}")))?;
93 if host.contains(':') {
94 return Err(HuddleError::Other(format!(
95 "ambiguous IPv6 address — wrap host in brackets: [{host}]:{port}"
96 )));
97 }
98 let port: u16 = port
99 .parse()
100 .map_err(|_| HuddleError::Other(format!("invalid port: {port}")))?;
101 format!("/ip4/{}/tcp/{}", host, port)
102 .parse::<Multiaddr>()
103 .map_err(|e| HuddleError::Other(format!("invalid address: {e}")))
104}
105
106struct ActiveRoom {
108 info: StoredRoom,
109 crypto: Option<RoomCrypto>,
110 passphrase_key: Option<[u8; KEY_LEN]>,
113 members: HashSet<String>,
115 typers: HashMap<String, i64>,
118 read_only: bool,
125 issued_codes: Vec<(String, i64)>,
129}
130
131const TYPING_TTL_SECS: i64 = 3;
132
133const DISCOVERED_TTL_SECS: i64 = 45;
136const ANNOUNCE_INTERVAL_SECS: u64 = 15;
137
138struct SasFlow {
142 room_id: String,
143 partner_fingerprint: String,
144 our_secret: x25519_dalek::StaticSecret,
145 sas_code: Option<crate::crypto::sas::SasCode>,
147 our_confirmed: bool,
148 their_confirmed: bool,
149 finalized: bool,
155}
156
157#[derive(Clone)]
158pub struct AppHandle {
159 identity: Arc<Identity>,
160 network: NetworkHandle,
161 mode: NetworkMode,
162 active_rooms: Arc<Mutex<HashMap<String, ActiveRoom>>>,
163 discovered_rooms: Arc<Mutex<HashMap<String, DiscoveredRoom>>>,
164 restorable_rooms: Arc<Mutex<HashMap<String, StoredRoom>>>,
168 connected_dial_addrs: Arc<Mutex<HashMap<String, PeerId>>>,
171 file_manager: Arc<FileManager>,
173 db: Db,
174 session_persist_key: [u8; 32],
178 sas_flows: Arc<Mutex<HashMap<String, SasFlow>>>,
181 pending_code_secrets:
188 Arc<Mutex<HashMap<(String, String), x25519_dalek::StaticSecret>>>,
189 pending_invite_dials: Arc<Mutex<HashMap<String, String>>>,
202 nat_reachable_addrs: Arc<Mutex<HashSet<String>>>,
208 relay_circuit_addrs: Arc<Mutex<HashSet<String>>>,
214 host_addr_dial_attempts: Arc<Mutex<HashMap<String, i64>>>,
219 last_profile_broadcast_at_ms: Arc<Mutex<HashMap<String, i64>>>,
226 pending_auto_dm_addrs: Arc<Mutex<HashSet<String>>>,
233 app_event_tx: broadcast::Sender<AppEvent>,
234}
235
236const HOST_ADDR_DIAL_BACKOFF_SECS: i64 = 300;
239
240const PROFILE_REBROADCAST_FLOOR_MS: i64 = 60_000;
244
245impl AppHandle {
246 pub async fn start() -> Result<Self> {
247 Self::start_with_options(NetworkMode::Mdns, 0, None, Vec::new()).await
248 }
249
250 pub fn peek_mdns_enabled(master_key: Option<&[u8; 32]>) -> Result<bool> {
258 config::ensure_data_dir()?;
259 let db = storage::open_db(&config::db_path(), master_key)?;
260 let v = repo::get_setting(&db, "mdns_enabled")?
261 .map(|s| s == "1")
262 .unwrap_or(true);
263 Ok(v)
264 }
265
266 pub async fn start_with_options(
267 mode: NetworkMode,
268 port: u16,
269 master_key: Option<&[u8; 32]>,
270 relays: Vec<Multiaddr>,
271 ) -> Result<Self> {
272 config::ensure_data_dir()?;
273 let session_persist_key = match master_key {
278 Some(mk) => storage::keychain::derive_subkey(mk, b"megolm-persist"),
279 None => [0u8; 32],
280 };
281 let db = storage::open_db(&config::db_path(), master_key)?;
282 Self::start_with_db_and_options(db, mode, port, session_persist_key, relays).await
283 }
284
285 pub async fn start_with_db(db: Db) -> Result<Self> {
286 Self::start_with_db_and_options(db, NetworkMode::Mdns, 0, [0u8; 32], Vec::new()).await
287 }
288
289 pub async fn start_with_db_and_options(
290 db: Db,
291 mode: NetworkMode,
292 port: u16,
293 session_persist_key: [u8; 32],
294 relays: Vec<Multiaddr>,
295 ) -> Result<Self> {
296 let identity = Self::load_or_create_identity(&db)?;
297 let identity = Arc::new(identity);
298 info!(fingerprint = %identity.fingerprint(), peer_id = %identity.peer_id(), mode = %mode.as_str(), port, relay_count = relays.len(), "identity loaded");
299
300 let (net_event_tx, net_event_rx) = tokio::sync::mpsc::channel::<NetworkEvent>(256);
301 let (app_event_tx, _) = broadcast::channel::<AppEvent>(256);
302 let network =
303 network::start_network_with(&identity, net_event_tx, mode, port, relays)?;
304
305 let active_rooms = Arc::new(Mutex::new(HashMap::new()));
306 let discovered_rooms = Arc::new(Mutex::new(HashMap::new()));
307 let restorable_rooms = Arc::new(Mutex::new(HashMap::new()));
308 let connected_dial_addrs = Arc::new(Mutex::new(HashMap::new()));
309 let file_manager = Arc::new(FileManager::new(&config::data_dir())?);
310
311 let handle = Self {
312 identity,
313 network,
314 mode,
315 active_rooms,
316 discovered_rooms,
317 restorable_rooms,
318 connected_dial_addrs,
319 file_manager,
320 db,
321 session_persist_key,
322 sas_flows: Arc::new(Mutex::new(HashMap::new())),
323 pending_code_secrets: Arc::new(Mutex::new(HashMap::new())),
324 pending_invite_dials: Arc::new(Mutex::new(HashMap::new())),
325 nat_reachable_addrs: Arc::new(Mutex::new(HashSet::new())),
326 relay_circuit_addrs: Arc::new(Mutex::new(HashSet::new())),
327 host_addr_dial_attempts: Arc::new(Mutex::new(HashMap::new())),
328 last_profile_broadcast_at_ms: Arc::new(Mutex::new(HashMap::new())),
329 pending_auto_dm_addrs: Arc::new(Mutex::new(HashSet::new())),
330 app_event_tx,
331 };
332
333 handle.spawn_event_processor(net_event_rx);
334 handle.spawn_announcement_ticker();
335 handle.spawn_discovered_room_pruner();
336 handle.spawn_known_peer_reconnector();
337 handle.restore_rooms_from_db().await;
338 if let Err(e) = repo::cleanup_expired_pending_friend_requests(&handle.db, now_unix()) {
342 warn!(%e, "failed to sweep expired pending friend requests");
343 }
344
345 Ok(handle)
346 }
347
348 pub fn mode(&self) -> NetworkMode {
349 self.mode
350 }
351
352 pub fn subscribe(&self) -> broadcast::Receiver<AppEvent> {
353 self.app_event_tx.subscribe()
354 }
355
356 pub fn fingerprint(&self) -> &str {
357 self.identity.fingerprint()
358 }
359
360 pub fn peer_id(&self) -> PeerId {
361 self.identity.peer_id()
362 }
363
364 pub fn sign_invite(&self, invite: crate::invite::InviteLink) -> Result<crate::invite::InviteLink> {
370 crate::invite::sign_invite(&self.identity, invite)
371 }
372
373 pub fn discovered_rooms(&self) -> Vec<DiscoveredRoom> {
374 let now = now_unix();
375 let our_fp = self.identity.fingerprint().to_string();
376 let mut by_id: HashMap<String, DiscoveredRoom> = self
377 .discovered_rooms
378 .lock()
379 .unwrap()
380 .clone();
381
382 for room in self.active_rooms.lock().unwrap().values() {
386 let entry = DiscoveredRoom {
387 room_id: room.info.id.clone(),
388 name: room.info.name.clone(),
389 encrypted: room.info.encrypted,
390 member_count: room.members.len() as u32,
391 creator_fingerprint: room.info.creator_fingerprint.clone(),
392 last_seen: now,
393 restorable: false,
394 host_addrs: Vec::new(),
395 kind: room.info.kind,
396 };
397 by_id
398 .entry(room.info.id.clone())
399 .and_modify(|d| {
400 d.last_seen = now;
401 if entry.member_count > d.member_count {
402 d.member_count = entry.member_count;
403 }
404 d.restorable = false;
405 d.kind = entry.kind;
406 })
407 .or_insert(entry);
408 }
409
410 for (id, stored) in self.restorable_rooms.lock().unwrap().iter() {
414 if by_id.contains_key(id) {
415 continue;
416 }
417 by_id.insert(
418 id.clone(),
419 DiscoveredRoom {
420 room_id: id.clone(),
421 name: stored.name.clone(),
422 encrypted: stored.encrypted,
423 member_count: 0,
424 creator_fingerprint: stored.creator_fingerprint.clone(),
425 last_seen: stored.last_active.unwrap_or(stored.created_at),
426 restorable: true,
427 host_addrs: Vec::new(),
428 kind: stored.kind,
429 },
430 );
431 }
432
433 by_id.retain(|room_id, d| {
441 if d.kind != RoomKind::Direct {
442 return true;
443 }
444 if self
447 .active_rooms
448 .lock()
449 .unwrap()
450 .contains_key(room_id)
451 {
452 return true;
453 }
454 canonical_dm_room_id(&our_fp, &d.creator_fingerprint) == *room_id
457 });
458
459 let mut v: Vec<DiscoveredRoom> = by_id.into_values().collect();
460 v.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
461 v
462 }
463
464 pub fn dm_partner_fingerprint(&self, room_id: &str) -> Option<String> {
469 let our_fp = self.identity.fingerprint().to_string();
470 let rooms = self.active_rooms.lock().unwrap();
471 let room = rooms.get(room_id)?;
472 if room.info.kind != RoomKind::Direct {
473 return None;
474 }
475 room.members
476 .iter()
477 .find(|m| **m != our_fp)
478 .cloned()
479 }
480
481 pub fn active_room_ids(&self) -> Vec<String> {
482 self.active_rooms.lock().unwrap().keys().cloned().collect()
483 }
484
485 pub fn active_room_info(&self, room_id: &str) -> Option<StoredRoom> {
486 self.active_rooms
487 .lock()
488 .unwrap()
489 .get(room_id)
490 .map(|r| r.info.clone())
491 }
492
493 pub fn room_members(&self, room_id: &str) -> Vec<String> {
494 self.active_rooms
495 .lock()
496 .unwrap()
497 .get(room_id)
498 .map(|r| {
499 let mut m: Vec<String> = r.members.iter().cloned().collect();
500 m.sort();
501 m
502 })
503 .unwrap_or_default()
504 }
505
506 pub fn room_messages(&self, room_id: &str, limit: i64) -> Result<Vec<repo::StoredRoomMessage>> {
507 repo::get_room_messages(&self.db, room_id, limit)
508 }
509
510 pub fn search_room_messages(
511 &self,
512 room_id: &str,
513 query: &str,
514 limit: i64,
515 ) -> Result<Vec<repo::StoredRoomMessage>> {
516 repo::search_room_messages(&self.db, room_id, query, limit)
517 }
518
519 pub async fn start_room(
527 &self,
528 name: &str,
529 encrypted: bool,
530 passphrase: Option<&str>,
531 kind: RoomKind,
532 ) -> Result<String> {
533 if encrypted && passphrase.is_none() {
534 return Err(HuddleError::Other(
535 "encrypted room requires a passphrase".into(),
536 ));
537 }
538
539 let created_at = now_unix();
540 let creator_fp = self.identity.fingerprint().to_string();
541 let room_id = derive_room_id(&creator_fp, name, created_at);
542
543 let (passphrase_salt, passphrase_key) = if encrypted {
544 let salt = passphrase::random_salt();
545 let key = passphrase::derive_key(passphrase.unwrap(), &salt)?;
546 (Some(salt.to_vec()), Some(key))
547 } else {
548 (None, None)
549 };
550
551 let info = StoredRoom {
552 id: room_id.clone(),
553 name: name.to_string(),
554 creator_fingerprint: creator_fp.clone(),
555 encrypted,
556 passphrase_salt: passphrase_salt.clone(),
557 created_at,
558 last_active: Some(created_at),
559 kind,
560 };
561 repo::insert_room(&self.db, &info)?;
562
563 let crypto = if encrypted {
564 Some(RoomCrypto::new_for_room(
565 self.db.clone(),
566 room_id.clone(),
567 creator_fp.clone(),
568 self.session_persist_key,
569 )?)
570 } else {
571 None
572 };
573
574 let mut members = HashSet::new();
575 members.insert(creator_fp.clone());
576
577 repo::upsert_room_member(
581 &self.db,
582 &StoredRoomMember {
583 room_id: room_id.clone(),
584 peer_id: String::new(),
585 fingerprint: creator_fp.clone(),
586 last_seen: Some(created_at),
587 verified: true, ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
589 role: "owner".into(),
590 },
591 )?;
592
593 self.active_rooms.lock().unwrap().insert(
594 room_id.clone(),
595 ActiveRoom {
596 info: info.clone(),
597 crypto,
598 passphrase_key,
599 members,
600 typers: HashMap::new(),
601 read_only: false,
602 issued_codes: Vec::new(),
603 },
604 );
605
606 self.network.subscribe_room(room_id.clone()).await;
607 self.announce_room_now(&info, 1).await;
608
609 let app = self.clone();
612 let rid = room_id.clone();
613 tokio::spawn(async move {
614 tokio::time::sleep(Duration::from_millis(500)).await;
615 if let Err(e) = app.broadcast_member_announce(&rid).await {
616 warn!(%e, "broadcast member announce");
617 }
618 });
619
620 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
621 room_id: room_id.clone(),
622 });
623
624 Ok(room_id)
625 }
626
627 pub async fn start_direct(&self, partner_fingerprint: &str) -> Result<String> {
651 let our_fp = self.identity.fingerprint().to_string();
652 if partner_fingerprint == our_fp {
653 return Err(HuddleError::Other("cannot DM yourself".into()));
654 }
655 let room_id = canonical_dm_room_id(&our_fp, partner_fingerprint);
656
657 if self.active_rooms.lock().unwrap().contains_key(&room_id) {
662 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
663 room_id: room_id.clone(),
664 });
665 return Ok(room_id);
666 }
667 if repo::get_room(&self.db, &room_id)?.is_some() {
668 return self.bootstrap_direct_room(&room_id, partner_fingerprint).await;
670 }
671
672 let created_at = now_unix();
673 let name = format!("dm-{}", short_fp_for_msg(partner_fingerprint));
677
678 let dm_salt = hex::decode(&room_id).unwrap_or_else(|_| room_id.as_bytes().to_vec());
685 let info = StoredRoom {
686 id: room_id.clone(),
687 name,
688 creator_fingerprint: our_fp.clone(),
689 encrypted: true,
690 passphrase_salt: Some(dm_salt),
691 created_at,
692 last_active: Some(created_at),
693 kind: RoomKind::Direct,
694 };
695 repo::insert_room(&self.db, &info)?;
696
697 let mut members = HashSet::new();
698 members.insert(our_fp.clone());
699 repo::upsert_room_member(
700 &self.db,
701 &StoredRoomMember {
702 room_id: room_id.clone(),
703 peer_id: String::new(),
704 fingerprint: our_fp.clone(),
705 last_seen: Some(created_at),
706 verified: true,
707 ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
708 role: "member".into(),
709 },
710 )?;
711
712 let passphrase_key = self.try_derive_dm_key(&room_id, partner_fingerprint);
719
720 let crypto = Some(RoomCrypto::new_for_room(
725 self.db.clone(),
726 room_id.clone(),
727 our_fp.clone(),
728 self.session_persist_key,
729 )?);
730
731 self.active_rooms.lock().unwrap().insert(
732 room_id.clone(),
733 ActiveRoom {
734 info: info.clone(),
735 crypto,
736 passphrase_key,
737 members,
738 typers: HashMap::new(),
739 read_only: false,
740 issued_codes: Vec::new(),
741 },
742 );
743
744 self.network.subscribe_room(room_id.clone()).await;
745 self.announce_room_now(&info, 1).await;
746
747 let app = self.clone();
748 let rid = room_id.clone();
749 tokio::spawn(async move {
750 tokio::time::sleep(Duration::from_millis(500)).await;
751 if let Err(e) = app.broadcast_member_announce(&rid).await {
752 warn!(%e, "broadcast member announce for DM");
753 }
754 });
755
756 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
757 room_id: room_id.clone(),
758 });
759 Ok(room_id)
760 }
761
762 fn derive_dm_key_from_pubkey_b64(
767 &self,
768 room_id: &str,
769 pubkey_b64: &str,
770 ) -> Option<[u8; KEY_LEN]> {
771 let bytes = B64.decode(pubkey_b64).ok()?;
772 if bytes.len() != 32 {
773 return None;
774 }
775 let mut pubkey = [0u8; 32];
776 pubkey.copy_from_slice(&bytes);
777 let our_seed = self.identity.secret_bytes();
778 match crate::crypto::dm::derive_dm_key(&our_seed, &pubkey, room_id) {
779 Ok(k) => Some(k),
780 Err(e) => {
781 warn!(%e, "DM key derivation (from announce) failed");
782 None
783 }
784 }
785 }
786
787 fn try_derive_dm_key(
792 &self,
793 room_id: &str,
794 partner_fingerprint: &str,
795 ) -> Option<[u8; KEY_LEN]> {
796 let pubkey_b64 = repo::lookup_peer_ed25519_pubkey(&self.db, partner_fingerprint)
797 .ok()
798 .flatten()?;
799 let bytes = B64.decode(&pubkey_b64).ok()?;
800 if bytes.len() != 32 {
801 return None;
802 }
803 let mut pubkey = [0u8; 32];
804 pubkey.copy_from_slice(&bytes);
805 let our_seed = self.identity.secret_bytes();
806 match crate::crypto::dm::derive_dm_key(&our_seed, &pubkey, room_id) {
807 Ok(k) => Some(k),
808 Err(e) => {
809 warn!(%e, %partner_fingerprint, "DM key derivation failed");
810 None
811 }
812 }
813 }
814
815 async fn bootstrap_direct_room(
821 &self,
822 room_id: &str,
823 partner_fingerprint: &str,
824 ) -> Result<String> {
825 let our_fp = self.identity.fingerprint().to_string();
826 let info = repo::get_room(&self.db, room_id)?
827 .ok_or_else(|| HuddleError::Other(format!("DM room {room_id} not found on disk")))?;
828 let mut members = HashSet::new();
829 members.insert(our_fp.clone());
830 members.insert(partner_fingerprint.to_string());
831
832 if let Ok(stored_members) = repo::list_room_members(&self.db, room_id) {
834 for m in stored_members {
835 members.insert(m.fingerprint);
836 }
837 }
838
839 let (passphrase_key, crypto) = if info.encrypted {
847 let pk = self.try_derive_dm_key(room_id, partner_fingerprint);
848 let c = match RoomCrypto::load(
853 self.db.clone(),
854 room_id.to_string(),
855 our_fp.clone(),
856 self.session_persist_key,
857 )? {
858 Some(c) => Some(c),
859 None => Some(RoomCrypto::new_for_room(
860 self.db.clone(),
861 room_id.to_string(),
862 our_fp.clone(),
863 self.session_persist_key,
864 )?),
865 };
866 (pk, c)
867 } else {
868 (None, None)
869 };
870
871 self.active_rooms.lock().unwrap().insert(
872 room_id.to_string(),
873 ActiveRoom {
874 info: info.clone(),
875 crypto,
876 passphrase_key,
877 members,
878 typers: HashMap::new(),
879 read_only: false,
880 issued_codes: Vec::new(),
881 },
882 );
883
884 self.network.subscribe_room(room_id.to_string()).await;
885 self.announce_room_now(&info, 2).await;
886
887 let app = self.clone();
888 let rid = room_id.to_string();
889 tokio::spawn(async move {
890 tokio::time::sleep(Duration::from_millis(500)).await;
891 if let Err(e) = app.broadcast_member_announce(&rid).await {
892 warn!(%e, "broadcast member announce on DM bootstrap");
893 }
894 });
895
896 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
897 room_id: room_id.to_string(),
898 });
899 Ok(room_id.to_string())
900 }
901
902 pub async fn join_room(&self, room_id: &str, passphrase: Option<&str>) -> Result<()> {
906 let (name, creator_fingerprint, encrypted, salt_opt) = {
908 if let Some(d) = self.discovered_rooms.lock().unwrap().get(room_id).cloned() {
909 let salt = self.get_room_salt(room_id);
910 (d.name, d.creator_fingerprint, d.encrypted, salt)
911 } else if let Some(stored) = self.restorable_rooms.lock().unwrap().get(room_id).cloned()
912 {
913 (
914 stored.name,
915 stored.creator_fingerprint,
916 stored.encrypted,
917 stored.passphrase_salt,
918 )
919 } else if let Some(stored) = repo::get_room(&self.db, room_id)? {
920 (
921 stored.name,
922 stored.creator_fingerprint,
923 stored.encrypted,
924 stored.passphrase_salt,
925 )
926 } else {
927 return Err(HuddleError::Other(format!("room {room_id} not found")));
928 }
929 };
930
931 if encrypted && passphrase.is_none() {
932 return Err(HuddleError::Other(
933 "encrypted room requires a passphrase".into(),
934 ));
935 }
936
937 let passphrase_key = if encrypted {
938 let salt = salt_opt
939 .clone()
940 .ok_or_else(|| HuddleError::Other("missing salt for encrypted room".into()))?;
941 Some(passphrase::derive_key(passphrase.unwrap(), &salt)?)
942 } else {
943 None
944 };
945
946 let kind = self
951 .discovered_rooms
952 .lock()
953 .unwrap()
954 .get(room_id)
955 .map(|d| d.kind)
956 .or_else(|| {
957 repo::get_room(&self.db, room_id)
958 .ok()
959 .flatten()
960 .map(|r| r.kind)
961 })
962 .unwrap_or_default();
963
964 let info = StoredRoom {
965 id: room_id.to_string(),
966 name,
967 creator_fingerprint,
968 encrypted,
969 passphrase_salt: salt_opt.clone(),
970 created_at: now_unix(),
971 last_active: Some(now_unix()),
972 kind,
973 };
974 repo::insert_room(&self.db, &info)?;
975
976 let crypto = if encrypted {
977 let our_fp = self.identity.fingerprint().to_string();
980 let existing = RoomCrypto::load(
981 self.db.clone(),
982 room_id.to_string(),
983 our_fp.clone(),
984 self.session_persist_key,
985 )?;
986 Some(match existing {
987 Some(c) => c,
988 None => RoomCrypto::new_for_room(
989 self.db.clone(),
990 room_id.to_string(),
991 our_fp,
992 self.session_persist_key,
993 )?,
994 })
995 } else {
996 None
997 };
998
999 let mut members = HashSet::new();
1000 members.insert(self.identity.fingerprint().to_string());
1001
1002 self.active_rooms.lock().unwrap().insert(
1003 room_id.to_string(),
1004 ActiveRoom {
1005 info: info.clone(),
1006 crypto,
1007 passphrase_key,
1008 members,
1009 typers: HashMap::new(),
1010 read_only: false,
1011 issued_codes: Vec::new(),
1012 },
1013 );
1014 self.restorable_rooms.lock().unwrap().remove(room_id);
1016
1017 self.network.subscribe_room(room_id.to_string()).await;
1018
1019 let app = self.clone();
1020 let rid = room_id.to_string();
1021 tokio::spawn(async move {
1022 tokio::time::sleep(Duration::from_millis(500)).await;
1023 if let Err(e) = app.broadcast_member_announce(&rid).await {
1024 warn!(%e, "broadcast member announce");
1025 }
1026 let req = RoomMessage::SessionKeyRequest {
1028 requester_fingerprint: app.identity.fingerprint().to_string(),
1029 };
1030 if let Ok(bytes) = encode_wire(&req) {
1031 app.network.publish_room_message(rid.clone(), bytes).await;
1032 }
1033 });
1034
1035 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
1036 room_id: room_id.to_string(),
1037 });
1038
1039 Ok(())
1040 }
1041
1042 async fn restore_rooms_from_db(&self) {
1047 let rooms = match repo::list_rooms(&self.db) {
1048 Ok(v) => v,
1049 Err(e) => {
1050 warn!(%e, "list rooms on restore");
1051 return;
1052 }
1053 };
1054 let our_fp = self.identity.fingerprint().to_string();
1055 let count = rooms.len();
1056 for info in rooms {
1057 if info.encrypted {
1058 self.restorable_rooms
1059 .lock()
1060 .unwrap()
1061 .insert(info.id.clone(), info);
1062 continue;
1063 }
1064 let mut members = HashSet::new();
1065 members.insert(our_fp.clone());
1066 if let Ok(stored_members) = repo::list_room_members(&self.db, &info.id) {
1067 for m in stored_members {
1068 members.insert(m.fingerprint);
1069 }
1070 }
1071 let member_count = members.len() as u32;
1072 self.active_rooms.lock().unwrap().insert(
1073 info.id.clone(),
1074 ActiveRoom {
1075 info: info.clone(),
1076 crypto: None,
1077 passphrase_key: None,
1078 members,
1079 typers: HashMap::new(),
1080 read_only: false,
1081 issued_codes: Vec::new(),
1082 },
1083 );
1084 self.network.subscribe_room(info.id.clone()).await;
1085 self.announce_room_now(&info, member_count).await;
1086 info!(room_id = %info.id, name = %info.name, "restored room");
1087 }
1088 if count > 0 {
1089 debug!(count, "restored rooms from db");
1090 }
1091 }
1092
1093 pub async fn leave_room(&self, room_id: &str) -> Result<bool> {
1098 let leave_msg = RoomMessage::MemberLeave {
1102 sender_fingerprint: self.identity.fingerprint().to_string(),
1103 };
1104 let dispatched = match crate::crypto::sign_message(&self.identity, &leave_msg)
1105 .and_then(|env| {
1106 crate::network::protocol::encode_wire_signed(&env)
1107 .map_err(|e| HuddleError::Session(format!("encode signed leave: {e}")))
1108 }) {
1109 Ok(bytes) => {
1110 self.network
1111 .publish_room_message(room_id.to_string(), bytes)
1112 .await;
1113 true
1114 }
1115 Err(e) => {
1116 warn!(%e, %room_id, "failed to sign+encode MemberLeave notice");
1117 false
1118 }
1119 };
1120
1121 self.active_rooms.lock().unwrap().remove(room_id);
1122 self.network.unsubscribe_room(room_id.to_string()).await;
1123
1124 let _ = self.app_event_tx.send(AppEvent::RoomLeft {
1125 room_id: room_id.to_string(),
1126 });
1127 Ok(dispatched)
1128 }
1129
1130 pub async fn send_room_message(&self, room_id: &str, body: &str) -> Result<()> {
1131 let our_fp = self.identity.fingerprint().to_string();
1132 let msg = {
1133 let mut rooms = self.active_rooms.lock().unwrap();
1134 let room = rooms
1135 .get_mut(room_id)
1136 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
1137
1138 if room.read_only {
1139 return Err(HuddleError::Other(
1140 "this room is read-only — you joined via code without the passphrase. Ask an owner for the passphrase or wait for a key rotation that includes you.".into(),
1141 ));
1142 }
1143
1144 if room.info.encrypted {
1145 let crypto = room
1146 .crypto
1147 .as_mut()
1148 .ok_or_else(|| HuddleError::Session("encrypted room missing crypto".into()))?;
1149 let (session_id, ct_bytes) = crypto.encrypt(body.as_bytes())?;
1150 RoomMessage::Encrypted {
1151 sender_fingerprint: our_fp.clone(),
1152 session_id,
1153 ciphertext_b64: base64::Engine::encode(
1154 &base64::engine::general_purpose::STANDARD,
1155 &ct_bytes,
1156 ),
1157 }
1158 } else {
1159 RoomMessage::Plain {
1160 sender_fingerprint: our_fp.clone(),
1161 body: body.to_string(),
1162 }
1163 }
1164 };
1165
1166 let bytes = encode_wire(&msg)?;
1167 self.network
1168 .publish_room_message(room_id.to_string(), bytes)
1169 .await;
1170
1171 let now = now_unix();
1172 let msg_id =
1173 repo::insert_room_message(&self.db, room_id, &our_fp, "out", body, now)?;
1174 repo::update_room_last_active(&self.db, room_id, now)?;
1175
1176 let _ = self.app_event_tx.send(AppEvent::MessageSent {
1177 room_id: room_id.to_string(),
1178 body: body.to_string(),
1179 message_id: msg_id,
1180 });
1181
1182 Ok(())
1183 }
1184
1185 pub async fn shutdown(&self) {
1186 self.network.shutdown().await;
1187 }
1188
1189 pub async fn dial_by_id_or_username(&self, input: &str) -> Result<()> {
1216 let trimmed = input.trim();
1217 if trimmed.is_empty() {
1218 return Err(HuddleError::Other("input is empty".into()));
1219 }
1220 let target_fp = if let Some(fp) = normalize_to_fingerprint(trimmed) {
1221 fp
1222 } else {
1223 let matches = repo::find_peers_by_username(&self.db, trimmed)?;
1224 if matches.is_empty() {
1225 return Err(HuddleError::Other(format!(
1226 "no peer named `{}` known yet — paste their invite link instead",
1227 trimmed
1228 )));
1229 }
1230 if matches.len() > 1 {
1231 return Err(HuddleError::Other(format!(
1232 "username `{}` is ambiguous ({} peers share it) — use their HD- ID instead",
1233 trimmed,
1234 matches.len()
1235 )));
1236 }
1237 matches.into_iter().next().unwrap()
1238 };
1239 if target_fp == self.identity.fingerprint() {
1240 return Err(HuddleError::Other("that's your own ID".into()));
1241 }
1242 let candidates = self.resolve_dial_addrs(&target_fp);
1243 if candidates.is_empty() {
1244 return Err(HuddleError::Other(format!(
1245 "haven't seen `{}` on the network yet — ask them for an invite link",
1246 short_fp_for_msg(&target_fp)
1247 )));
1248 }
1249 let now = now_unix();
1254 for addr in &candidates {
1255 let _ = repo::upsert_known_peer(
1256 &self.db,
1257 &KnownPeer {
1258 address: addr.clone(),
1259 label: None,
1260 last_connected_at: None,
1261 last_attempt_at: Some(now),
1262 created_at: now,
1263 fingerprint: Some(target_fp.clone()),
1264 trusted: false,
1265 },
1266 );
1267 }
1268 let multiaddrs: Vec<Multiaddr> = candidates
1272 .iter()
1273 .filter_map(|s| s.parse::<Multiaddr>().ok())
1274 .collect();
1275 if multiaddrs.is_empty() {
1276 return Err(HuddleError::Other(
1277 "every known address for that peer is malformed".into(),
1278 ));
1279 }
1280 let _ = self.app_event_tx.send(AppEvent::Dialing {
1281 address: candidates[0].clone(),
1282 });
1283 info!(
1284 target_fp = %target_fp,
1285 n = multiaddrs.len(),
1286 "dialing peer with {} candidate addresses",
1287 multiaddrs.len()
1288 );
1289 {
1293 let mut pending = self.pending_auto_dm_addrs.lock().unwrap();
1294 for m in &multiaddrs {
1295 pending.insert(m.to_string());
1296 }
1297 }
1298 self.network.dial_addresses(multiaddrs).await;
1299 Ok(())
1300 }
1301
1302 fn resolve_dial_addrs(&self, fingerprint: &str) -> Vec<String> {
1310 let mut set: std::collections::HashSet<String> = std::collections::HashSet::new();
1311 for room in self.discovered_rooms.lock().unwrap().values() {
1312 if room.creator_fingerprint == fingerprint {
1313 for addr in &room.host_addrs {
1314 set.insert(addr.clone());
1315 }
1316 }
1317 }
1318 if let Ok(known) = repo::list_known_peers(&self.db) {
1319 for peer in known {
1320 if peer.fingerprint.as_deref() == Some(fingerprint) {
1321 set.insert(peer.address);
1322 }
1323 }
1324 }
1325 let mut v: Vec<String> = set.into_iter().collect();
1326 v.sort_by_key(|a| address_preference(a));
1327 v
1328 }
1329
1330 pub async fn dial(&self, input: &str) -> Result<()> {
1331 let multiaddr = parse_dial_address(input)?;
1332 let canonical = multiaddr.to_string();
1333 self.pending_auto_dm_addrs
1338 .lock()
1339 .unwrap()
1340 .insert(canonical.clone());
1341 self.dial_internal(canonical, multiaddr).await
1342 }
1343
1344 pub(crate) async fn dial_internal(
1350 &self,
1351 canonical: String,
1352 multiaddr: Multiaddr,
1353 ) -> Result<()> {
1354 info!(%canonical, "dialing");
1355 repo::upsert_known_peer(
1356 &self.db,
1357 &KnownPeer {
1358 address: canonical.clone(),
1359 label: None,
1360 last_connected_at: None,
1361 last_attempt_at: Some(now_unix()),
1362 created_at: now_unix(),
1363 fingerprint: None,
1367 trusted: false,
1368 },
1369 )?;
1370
1371 let _ = self.app_event_tx.send(AppEvent::Dialing {
1372 address: canonical.clone(),
1373 });
1374 self.network.dial(multiaddr).await;
1375 Ok(())
1376 }
1377
1378 pub fn nat_reachable_addrs(&self) -> Vec<String> {
1383 self.nat_reachable_addrs
1384 .lock()
1385 .unwrap()
1386 .iter()
1387 .cloned()
1388 .collect()
1389 }
1390
1391 pub fn dialable_addrs(&self) -> Vec<String> {
1399 let mut out: Vec<String> = self
1400 .relay_circuit_addrs
1401 .lock()
1402 .unwrap()
1403 .iter()
1404 .cloned()
1405 .collect();
1406 for a in self.nat_reachable_addrs.lock().unwrap().iter() {
1407 if !out.contains(a) {
1408 out.push(a.clone());
1409 }
1410 }
1411 out.truncate(4);
1412 out
1413 }
1414
1415 pub async fn dial_invite(&self, address: &str, claimed_fp: &str) -> Result<()> {
1428 let multiaddr = parse_dial_address(address)?;
1429 let canonical = multiaddr.to_string();
1430 self.pending_invite_dials
1431 .lock()
1432 .unwrap()
1433 .insert(canonical.clone(), claimed_fp.to_string());
1434 self.dial(address).await
1437 }
1438
1439 pub fn known_peers(&self) -> Vec<KnownPeerStatus> {
1440 let connected = self.connected_dial_addrs.lock().unwrap().clone();
1441 let stored = repo::list_known_peers(&self.db).unwrap_or_default();
1442 stored
1443 .into_iter()
1444 .map(|p| {
1445 let connected_peer = connected.get(&p.address).copied();
1446 KnownPeerStatus {
1447 address: p.address,
1448 label: p.label,
1449 last_connected_at: p.last_connected_at,
1450 connected_peer_id: connected_peer,
1451 fingerprint: p.fingerprint,
1452 }
1453 })
1454 .collect()
1455 }
1456
1457 pub async fn forget_peer(&self, address: &str) -> Result<()> {
1458 repo::forget_known_peer(&self.db, address)?;
1459 self.connected_dial_addrs.lock().unwrap().remove(address);
1460 Ok(())
1461 }
1462
1463 pub async fn redial(&self, address: &str) -> Result<()> {
1465 self.dial(address).await
1466 }
1467
1468 pub async fn accept_inbound(&self, peer_id: PeerId, address: &str) {
1473 self.network.accept_inbound(peer_id).await;
1474 self.connected_dial_addrs
1475 .lock()
1476 .unwrap()
1477 .insert(address.to_string(), peer_id);
1478 }
1479
1480 pub async fn reject_inbound(&self, peer_id: PeerId, fingerprint: &str) -> Result<()> {
1485 self.network.reject_inbound(peer_id).await;
1486 repo::block_peer(&self.db, fingerprint, now_unix())?;
1487 Ok(())
1488 }
1489
1490 pub async fn trust_inbound(
1493 &self,
1494 peer_id: PeerId,
1495 fingerprint: &str,
1496 address: &str,
1497 ) -> Result<()> {
1498 self.network.accept_inbound(peer_id).await;
1499 self.connected_dial_addrs
1500 .lock()
1501 .unwrap()
1502 .insert(address.to_string(), peer_id);
1503 repo::upsert_known_peer(
1507 &self.db,
1508 &KnownPeer {
1509 address: address.to_string(),
1510 label: None,
1511 last_connected_at: Some(now_unix()),
1512 last_attempt_at: Some(now_unix()),
1513 created_at: now_unix(),
1514 fingerprint: Some(fingerprint.to_string()),
1515 trusted: true,
1516 },
1517 )?;
1518 Ok(())
1519 }
1520
1521 pub fn list_pending_friend_requests(&self) -> Vec<repo::PendingFriendRequest> {
1529 repo::list_pending_friend_requests(&self.db).unwrap_or_default()
1530 }
1531
1532 pub fn spill_pending_friend_request(
1538 &self,
1539 peer_id: PeerId,
1540 fingerprint: &str,
1541 address: &str,
1542 ) -> Result<()> {
1543 repo::upsert_pending_friend_request(
1544 &self.db,
1545 &repo::PendingFriendRequest {
1546 fingerprint: fingerprint.to_string(),
1547 address: address.to_string(),
1548 peer_id: peer_id.to_string(),
1549 received_at: now_unix(),
1550 },
1551 )?;
1552 Ok(())
1553 }
1554
1555 pub async fn accept_pending_friend_request(&self, fingerprint: &str) -> Result<()> {
1562 let mut chosen_addr: Option<String> = None;
1563 for req in self.list_pending_friend_requests() {
1564 if req.fingerprint == fingerprint {
1565 chosen_addr = Some(req.address);
1566 break;
1567 }
1568 }
1569 repo::delete_pending_friend_requests_for_fp(&self.db, fingerprint)?;
1570 if let Some(addr) = chosen_addr {
1571 repo::upsert_known_peer(
1575 &self.db,
1576 &KnownPeer {
1577 address: addr.clone(),
1578 label: None,
1579 last_connected_at: None,
1580 last_attempt_at: Some(now_unix()),
1581 created_at: now_unix(),
1582 fingerprint: Some(fingerprint.to_string()),
1583 trusted: true,
1584 },
1585 )?;
1586 self.dial(&addr).await?;
1588 }
1589 Ok(())
1590 }
1591
1592 pub fn reject_pending_friend_request(&self, fingerprint: &str) -> Result<()> {
1597 repo::delete_pending_friend_requests_for_fp(&self.db, fingerprint)?;
1598 repo::block_peer(&self.db, fingerprint, now_unix())?;
1599 Ok(())
1600 }
1601
1602 pub async fn disconnect_peer(&self, peer_id: PeerId) {
1609 self.network.disconnect_peer(peer_id).await;
1610 }
1611
1612 fn spawn_known_peer_reconnector(&self) {
1613 let handle = self.clone();
1614 tokio::spawn(async move {
1615 tokio::time::sleep(Duration::from_millis(500)).await;
1617 let known = repo::list_known_peers(&handle.db).unwrap_or_default();
1618 for (i, peer) in known.into_iter().enumerate() {
1622 let handle = handle.clone();
1623 tokio::spawn(async move {
1624 let jitter = (peer.address.len() as u64 * 37) % 200;
1627 tokio::time::sleep(Duration::from_millis(150 * i as u64 + jitter)).await;
1628 let multiaddr = match peer.address.parse::<Multiaddr>() {
1633 Ok(m) => m,
1634 Err(_) => return,
1635 };
1636 if let Err(e) = handle.dial_internal(peer.address.clone(), multiaddr).await {
1637 debug!(%e, addr = %peer.address, "auto-reconnect failed");
1638 }
1639 });
1640 }
1641 });
1642 }
1643
1644 fn load_or_create_identity(db: &Db) -> Result<Identity> {
1649 if let Some(stored) = repo::load_identity(db)? {
1650 let mut bytes = [0u8; 32];
1651 bytes.copy_from_slice(&stored.ed25519_secret);
1652 Identity::from_secret_bytes(bytes)
1653 } else {
1654 let id = Identity::generate()?;
1655 repo::save_identity(db, &id.secret_bytes(), now_unix())?;
1656 Ok(id)
1657 }
1658 }
1659
1660 fn get_room_salt(&self, room_id: &str) -> Option<Vec<u8>> {
1661 self.active_rooms
1662 .lock()
1663 .unwrap()
1664 .get(room_id)
1665 .and_then(|r| r.info.passphrase_salt.clone())
1666 .or_else(|| {
1667 ROOM_SALT_CACHE
1669 .lock()
1670 .unwrap()
1671 .get(room_id)
1672 .cloned()
1673 })
1674 }
1675
1676 async fn announce_room_now(&self, info: &StoredRoom, member_count: u32) {
1677 let owner_fingerprints =
1678 repo::list_room_owners(&self.db, &info.id).unwrap_or_default();
1679 let verified_only = repo::get_room_verified_only(&self.db, &info.id).unwrap_or(false);
1680 let host_addrs = self.dialable_addrs();
1681 let ann = RoomAnnouncement {
1682 room_id: info.id.clone(),
1683 name: info.name.clone(),
1684 encrypted: info.encrypted,
1685 passphrase_salt: info.passphrase_salt.clone(),
1686 member_count,
1687 creator_fingerprint: info.creator_fingerprint.clone(),
1688 announced_at: now_unix(),
1689 owner_fingerprints,
1690 verified_only,
1691 host_addrs,
1692 kind: info.kind,
1693 };
1694 self.network.announce_room(ann).await;
1695 }
1696
1697 async fn broadcast_member_announce(&self, room_id: &str) -> Result<()> {
1698 let our_fp = self.identity.fingerprint().to_string();
1699 let wrapped = {
1700 let mut rooms = self.active_rooms.lock().unwrap();
1701 let room = rooms
1702 .get_mut(room_id)
1703 .ok_or_else(|| HuddleError::Other("not in room".into()))?;
1704 if room.info.encrypted {
1705 let crypto = room.crypto.as_mut().unwrap();
1706 let session_key = crypto.our_session_key_b64();
1707 match room.passphrase_key.as_ref() {
1708 Some(passphrase_key) => {
1709 Some(passphrase::wrap(session_key.as_bytes(), passphrase_key)?)
1710 }
1711 None if room.info.kind == RoomKind::Direct => {
1712 None
1722 }
1723 None => {
1724 return Err(HuddleError::Session("missing passphrase key".into()));
1725 }
1726 }
1727 } else {
1728 None
1729 }
1730 };
1731 let display_name = repo::get_display_name(&self.db).unwrap_or(None);
1732 let msg = RoomMessage::MemberAnnounce {
1733 sender_fingerprint: our_fp,
1734 wrapped_session_key: wrapped,
1735 display_name,
1736 sender_ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
1737 };
1738 let env = crate::crypto::sign_message(&self.identity, &msg)?;
1743 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
1744 self.network
1745 .publish_room_message(room_id.to_string(), bytes)
1746 .await;
1747 Ok(())
1748 }
1749
1750 fn spawn_event_processor(&self, mut net_rx: tokio::sync::mpsc::Receiver<NetworkEvent>) {
1751 let handle = self.clone();
1752 tokio::spawn(async move {
1753 while let Some(event) = net_rx.recv().await {
1754 handle.process_network_event(event).await;
1755 }
1756 info!("event processor stopped");
1757 });
1758 }
1759
1760 fn spawn_announcement_ticker(&self) {
1761 let handle = self.clone();
1762 tokio::spawn(async move {
1763 let mut interval =
1764 tokio::time::interval(Duration::from_secs(ANNOUNCE_INTERVAL_SECS));
1765 interval.tick().await; loop {
1767 interval.tick().await;
1768 let snapshot: Vec<(StoredRoom, u32)> = {
1769 let active = handle.active_rooms.lock().unwrap();
1770 active
1771 .values()
1772 .map(|r| (r.info.clone(), r.members.len() as u32))
1773 .collect()
1774 };
1775 for (info, member_count) in snapshot {
1776 handle.announce_room_now(&info, member_count).await;
1777 }
1778 }
1779 });
1780 }
1781
1782 fn spawn_discovered_room_pruner(&self) {
1783 let handle = self.clone();
1784 tokio::spawn(async move {
1785 let mut interval = tokio::time::interval(Duration::from_secs(10));
1786 interval.tick().await;
1787 loop {
1788 interval.tick().await;
1789 let now = now_unix();
1790 let mut to_drop = Vec::new();
1791 {
1792 let mut map = handle.discovered_rooms.lock().unwrap();
1793 map.retain(|id, r| {
1794 if now - r.last_seen > DISCOVERED_TTL_SECS {
1795 to_drop.push(id.clone());
1796 false
1797 } else {
1798 true
1799 }
1800 });
1801 }
1802 for id in to_drop {
1803 let _ = handle.app_event_tx.send(AppEvent::RoomLost { room_id: id });
1804 }
1805 }
1806 });
1807 }
1808
1809 async fn process_network_event(&self, event: NetworkEvent) {
1810 match event {
1811 NetworkEvent::PeerDiscovered { peer_id } => {
1812 let _ = self.app_event_tx.send(AppEvent::PeerDiscovered { peer_id });
1813 }
1814 NetworkEvent::PeerExpired { peer_id } => {
1815 self.connected_dial_addrs
1821 .lock()
1822 .unwrap()
1823 .retain(|_addr, pid| *pid != peer_id);
1824 let _ = self.app_event_tx.send(AppEvent::PeerExpired { peer_id });
1825 }
1826 NetworkEvent::PeerDisconnected { peer_id } => {
1827 self.connected_dial_addrs
1833 .lock()
1834 .unwrap()
1835 .retain(|_addr, pid| *pid != peer_id);
1836 let _ = self.app_event_tx.send(AppEvent::PeerExpired { peer_id });
1837 }
1838 NetworkEvent::ListeningOn { address } => {
1845 let _ = self.app_event_tx.send(AppEvent::ListeningOn {
1846 address: address.to_string(),
1847 });
1848 }
1849 NetworkEvent::RoomAnnouncementReceived(ann) => {
1850 if let Some(salt) = &ann.passphrase_salt {
1852 ROOM_SALT_CACHE
1853 .lock()
1854 .unwrap()
1855 .insert(ann.room_id.clone(), salt.clone());
1856 }
1857 let our_fp_for_dial = self.identity.fingerprint().to_string();
1862 if ann.creator_fingerprint != our_fp_for_dial && !ann.host_addrs.is_empty() {
1863 let now = now_unix();
1864 let should_dial = {
1865 let mut attempts = self.host_addr_dial_attempts.lock().unwrap();
1866 match attempts.get(&ann.creator_fingerprint).copied() {
1867 Some(last) if now - last < HOST_ADDR_DIAL_BACKOFF_SECS => false,
1868 _ => {
1869 attempts.insert(ann.creator_fingerprint.clone(), now);
1870 true
1871 }
1872 }
1873 };
1874 if should_dial {
1875 if let Some(first) = ann.host_addrs.first() {
1876 info!(
1877 announcer = %ann.creator_fingerprint,
1878 addr = %first,
1879 "opportunistic dial via room announcement host_addrs"
1880 );
1881 if let Ok(multiaddr) = first.parse::<Multiaddr>() {
1886 let canonical = multiaddr.to_string();
1887 let _ = self.dial_internal(canonical, multiaddr).await;
1888 }
1889 }
1890 }
1891 }
1892 let discovered = DiscoveredRoom {
1893 room_id: ann.room_id.clone(),
1894 name: ann.name.clone(),
1895 encrypted: ann.encrypted,
1896 member_count: ann.member_count,
1897 creator_fingerprint: ann.creator_fingerprint.clone(),
1898 last_seen: now_unix(),
1899 restorable: false,
1900 host_addrs: ann.host_addrs.clone(),
1901 kind: ann.kind,
1902 };
1903 if self.active_rooms.lock().unwrap().contains_key(&ann.room_id) {
1908 self.discovered_rooms
1909 .lock()
1910 .unwrap()
1911 .insert(ann.room_id.clone(), discovered);
1912 return;
1913 }
1914 if ann.kind == RoomKind::Direct {
1924 let our_fp_for_filter = self.identity.fingerprint().to_string();
1925 if canonical_dm_room_id(&our_fp_for_filter, &ann.creator_fingerprint)
1926 != ann.room_id
1927 {
1928 debug!(
1929 announcer = %ann.creator_fingerprint,
1930 room_id = %ann.room_id,
1931 "dropping Direct announcement: not addressed to us"
1932 );
1933 return;
1934 }
1935 if repo::is_peer_blocked(&self.db, &ann.creator_fingerprint).unwrap_or(false)
1947 {
1948 debug!(
1949 partner = %ann.creator_fingerprint,
1950 "ignoring Direct announcement from blocked peer"
1951 );
1952 return;
1953 }
1954 self.discovered_rooms
1955 .lock()
1956 .unwrap()
1957 .insert(ann.room_id.clone(), discovered.clone());
1958 let _ = self
1959 .app_event_tx
1960 .send(AppEvent::RoomDiscovered(discovered.clone()));
1961 let app = self.clone();
1962 let partner = ann.creator_fingerprint.clone();
1963 let rid = ann.room_id.clone();
1964 tokio::spawn(async move {
1965 if let Err(e) = app.start_direct(&partner).await {
1966 debug!(%e, room_id = %rid, "auto-bootstrap of inbound DM failed");
1967 }
1968 });
1969 return;
1970 }
1971 self.discovered_rooms
1972 .lock()
1973 .unwrap()
1974 .insert(ann.room_id.clone(), discovered.clone());
1975 let _ = self.app_event_tx.send(AppEvent::RoomDiscovered(discovered));
1976 }
1977 NetworkEvent::RoomMessageReceived {
1978 room_id,
1979 payload,
1980 from_peer: _,
1981 } => {
1982 let wire: WireMessage = match serde_json::from_slice(&payload) {
1989 Ok(w) => w,
1990 Err(e) => {
1991 warn!(%e, "bad wire envelope");
1992 return;
1993 }
1994 };
1995 let (msg, verified_signer) = match wire {
1996 WireMessage::Plain(m) => (m, None),
1997 WireMessage::Signed(env) => {
1998 let claimed_pubkey = env.ed25519_pubkey_b64.clone();
1999 match crate::crypto::verify_signed(&env) {
2000 Ok((m, fp)) => {
2001 match repo::get_member_ed25519_pubkey(
2008 &self.db, &room_id, &fp,
2009 ) {
2010 Ok(Some(known)) if known != claimed_pubkey => {
2011 warn!(
2012 %fp, %room_id,
2013 "pubkey mismatch vs stored; dropping signed message"
2014 );
2015 return;
2016 }
2017 _ => {}
2018 }
2019 (m, Some(fp))
2020 }
2021 Err(e) => {
2022 warn!(%e, fp = %env.fingerprint, "signed envelope verify failed");
2023 return;
2024 }
2025 }
2026 }
2027 };
2028 self.handle_room_message(&room_id, msg, verified_signer).await;
2029 }
2030 NetworkEvent::DialSucceeded { peer_id, address } => {
2031 let addr_s = address.to_string();
2032 self.connected_dial_addrs
2033 .lock()
2034 .unwrap()
2035 .insert(addr_s.clone(), peer_id);
2036 let _ = repo::upsert_known_peer(
2040 &self.db,
2041 &KnownPeer {
2042 address: addr_s.clone(),
2043 label: None,
2044 last_connected_at: Some(now_unix()),
2045 last_attempt_at: Some(now_unix()),
2046 created_at: now_unix(),
2047 fingerprint: None,
2048 trusted: false,
2049 },
2050 );
2051 let _ = self.app_event_tx.send(AppEvent::DialSucceeded {
2052 address: addr_s,
2053 peer_id,
2054 });
2055 }
2056 NetworkEvent::DialFailed { address, error } => {
2057 let addr_s = address.to_string();
2058 let _ = self.app_event_tx.send(AppEvent::DialFailed {
2059 address: addr_s,
2060 error,
2061 });
2062 }
2063 NetworkEvent::PeerIdentified { peer_id, fingerprint } => {
2064 let matched_addrs: Vec<String> = {
2070 let map = self.connected_dial_addrs.lock().unwrap();
2071 map.iter()
2072 .filter_map(|(addr, pid)| {
2073 if *pid == peer_id {
2074 Some(addr.clone())
2075 } else {
2076 None
2077 }
2078 })
2079 .collect()
2080 };
2081 let mismatch = {
2091 let mut map = self.pending_invite_dials.lock().unwrap();
2092 let mut found: Option<(String, String)> = None;
2093 for addr in &matched_addrs {
2094 if let Some(claimed) = map.remove(addr) {
2095 if claimed != fingerprint {
2096 found = Some((addr.clone(), claimed));
2097 break;
2098 }
2099 }
2100 }
2101 found
2102 };
2103 if let Some((addr, claimed)) = mismatch {
2104 warn!(
2105 %addr, %claimed, actual=%fingerprint,
2106 "invite fingerprint mismatch — disconnecting"
2107 );
2108 self.network.disconnect_peer(peer_id).await;
2109 let _ = self.app_event_tx.send(AppEvent::InviteFingerprintMismatch {
2110 address: addr,
2111 claimed,
2112 actual: fingerprint.clone(),
2113 });
2114 return;
2115 }
2116 let should_auto_dm = {
2123 let mut pending = self.pending_auto_dm_addrs.lock().unwrap();
2124 let mut any_matched = false;
2125 for addr in &matched_addrs {
2126 if pending.remove(addr) {
2127 any_matched = true;
2128 }
2129 }
2130 any_matched
2131 };
2132 for addr in matched_addrs {
2133 let _ = repo::upsert_known_peer(
2134 &self.db,
2135 &KnownPeer {
2136 address: addr,
2137 label: None,
2138 last_connected_at: Some(now_unix()),
2139 last_attempt_at: Some(now_unix()),
2140 created_at: now_unix(),
2141 fingerprint: Some(fingerprint.clone()),
2142 trusted: true,
2143 },
2144 );
2145 }
2146 let blocked = repo::is_peer_blocked(&self.db, &fingerprint).unwrap_or(false);
2159 if should_auto_dm && !blocked && fingerprint != self.identity.fingerprint() {
2160 match self.start_direct(&fingerprint).await {
2161 Ok(room_id) => {
2162 let _ = self.app_event_tx.send(AppEvent::AutoOpenDm {
2163 room_id,
2164 fingerprint: fingerprint.clone(),
2165 });
2166 }
2167 Err(e) => {
2168 debug!(%e, fp = %fingerprint, "auto-DM after dial failed");
2169 }
2170 }
2171 }
2172 let our_username = repo::get_display_name(&self.db).unwrap_or(None);
2180 if our_username.is_some() {
2181 let now_ms = now_unix_ms();
2182 let should_send = {
2183 let mut last = self.last_profile_broadcast_at_ms.lock().unwrap();
2184 match last.get(&fingerprint) {
2185 Some(prev) if now_ms - prev < PROFILE_REBROADCAST_FLOOR_MS => false,
2186 _ => {
2187 last.insert(fingerprint.clone(), now_ms);
2188 true
2189 }
2190 }
2191 };
2192 if should_send {
2193 let msg = RoomMessage::ProfileUpdate {
2194 sender_fingerprint: self.identity.fingerprint().to_string(),
2195 username: our_username,
2196 updated_at: now_ms,
2197 };
2198 if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
2199 if let Ok(bytes) =
2200 crate::network::protocol::encode_wire_signed(&env)
2201 {
2202 let rooms: Vec<String> = self
2203 .active_rooms
2204 .lock()
2205 .unwrap()
2206 .keys()
2207 .cloned()
2208 .collect();
2209 for room_id in rooms {
2210 self.network
2211 .publish_room_message(room_id, bytes.clone())
2212 .await;
2213 }
2214 }
2215 }
2216 }
2217 }
2218 }
2219 NetworkEvent::RelayReservationEstablished { address } => {
2220 info!(addr = %address, "relay reservation established");
2225 self.relay_circuit_addrs
2226 .lock()
2227 .unwrap()
2228 .insert(address.to_string());
2229 let _ = self.app_event_tx.send(AppEvent::ListeningOn {
2230 address: address.to_string(),
2231 });
2232 }
2233 NetworkEvent::NatProbeResult {
2234 tested_addr,
2235 reachable,
2236 } => {
2237 let addr_s = tested_addr.to_string();
2238 let (transitioned, becomes_reachable) = {
2239 let mut set = self.nat_reachable_addrs.lock().unwrap();
2240 let was_empty = set.is_empty();
2241 if reachable {
2242 set.insert(addr_s.clone());
2243 } else {
2244 set.remove(&addr_s);
2245 }
2246 let is_empty = set.is_empty();
2247 (was_empty != is_empty, !is_empty)
2248 };
2249 if transitioned {
2250 let label = if becomes_reachable {
2251 "reachable".to_string()
2252 } else {
2253 "private".to_string()
2254 };
2255 info!(reachable = %becomes_reachable, "NAT reachability changed");
2256 let _ = self.app_event_tx.send(AppEvent::NatStatusChanged {
2257 label,
2258 reachable: becomes_reachable,
2259 });
2260 }
2261 }
2262 NetworkEvent::DcutrUpgrade {
2263 remote_peer,
2264 success,
2265 } => {
2266 if success {
2267 let s = remote_peer.to_base58();
2271 let tail: String = s.chars().rev().take(8).collect::<String>()
2272 .chars()
2273 .rev()
2274 .collect();
2275 let _ = self.app_event_tx.send(AppEvent::DcutrSucceeded {
2276 peer_label: tail,
2277 });
2278 }
2279 }
2280 NetworkEvent::InboundDial {
2281 peer_id,
2282 fingerprint,
2283 address,
2284 } => {
2285 if repo::is_peer_blocked(&self.db, &fingerprint).unwrap_or(false) {
2287 info!(%fingerprint, "inbound dial auto-rejected: peer is blocked");
2288 self.network.reject_inbound(peer_id).await;
2289 return;
2290 }
2291 let global_verified_only =
2296 repo::get_setting(&self.db, "verified_only_inbound")
2297 .ok()
2298 .flatten()
2299 .map(|v| v == "1")
2300 .unwrap_or(false);
2301 if global_verified_only {
2302 let is_verified =
2303 repo::is_globally_verified(&self.db, &fingerprint).unwrap_or(false)
2304 || repo::is_fingerprint_trusted(&self.db, &fingerprint)
2305 .unwrap_or(false);
2306 if !is_verified {
2307 info!(
2308 %fingerprint,
2309 "inbound dial auto-rejected: verified-only mode"
2310 );
2311 self.network.reject_inbound(peer_id).await;
2312 return;
2313 }
2314 }
2315 if repo::is_fingerprint_trusted(&self.db, &fingerprint).unwrap_or(false) {
2316 info!(%fingerprint, "inbound dial auto-accepted: peer is trusted");
2317 self.connected_dial_addrs
2320 .lock()
2321 .unwrap()
2322 .insert(address.to_string(), peer_id);
2323 let _ = repo::upsert_known_peer(
2324 &self.db,
2325 &KnownPeer {
2326 address: address.to_string(),
2327 label: None,
2328 last_connected_at: Some(now_unix()),
2329 last_attempt_at: Some(now_unix()),
2330 created_at: now_unix(),
2331 fingerprint: Some(fingerprint),
2332 trusted: true,
2333 },
2334 );
2335 self.network.accept_inbound(peer_id).await;
2336 return;
2337 }
2338 let _ = self.app_event_tx.send(AppEvent::InboundDial {
2340 peer_id,
2341 fingerprint,
2342 address: address.to_string(),
2343 });
2344 }
2345 }
2346 }
2347
2348 async fn handle_room_message(
2354 &self,
2355 room_id: &str,
2356 msg: RoomMessage,
2357 verified_signer: Option<String>,
2358 ) {
2359 let our_fp = self.identity.fingerprint().to_string();
2360 match msg {
2361 RoomMessage::MemberAnnounce {
2362 sender_fingerprint,
2363 wrapped_session_key,
2364 display_name,
2365 sender_ed25519_pubkey,
2366 } => {
2367 if sender_fingerprint == our_fp {
2368 return;
2369 }
2370 let signer = match verified_signer {
2380 Some(fp) => fp,
2381 None => {
2382 warn!(%sender_fingerprint, %room_id, "MemberAnnounce arrived unsigned; dropping");
2383 return;
2384 }
2385 };
2386 if signer != sender_fingerprint {
2387 warn!(%signer, %sender_fingerprint, %room_id, "MemberAnnounce signer mismatch; dropping");
2388 return;
2389 }
2390 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2393 .unwrap_or(false)
2394 {
2395 info!(%sender_fingerprint, %room_id, "dropping MemberAnnounce from banned peer");
2396 return;
2397 }
2398 if repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
2405 && !repo::is_globally_verified(&self.db, &sender_fingerprint).unwrap_or(false)
2406 {
2407 info!(
2408 %sender_fingerprint, %room_id,
2409 "dropping MemberAnnounce: room is verified-only and joiner isn't verified"
2410 );
2411 let owners = repo::list_room_owners(&self.db, room_id).unwrap_or_default();
2412 let lowest_owner = owners.iter().min().cloned();
2413 if lowest_owner.as_deref() == Some(&our_fp) {
2414 let msg = RoomMessage::JoinRefused {
2415 room_id: room_id.to_string(),
2416 target_fingerprint: sender_fingerprint.clone(),
2417 reason: "room requires SAS verification — ask an existing member to verify you".into(),
2418 };
2419 if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
2420 if let Ok(bytes) =
2421 crate::network::protocol::encode_wire_signed(&env)
2422 {
2423 self.network
2424 .publish_room_message(room_id.to_string(), bytes)
2425 .await;
2426 }
2427 }
2428 }
2429 return;
2430 }
2431 let need_inbound = {
2432 let mut rooms = self.active_rooms.lock().unwrap();
2433 let room = match rooms.get_mut(room_id) {
2434 Some(r) => r,
2435 None => return,
2436 };
2437 if room.info.kind == RoomKind::Direct
2445 && !room.members.contains(&sender_fingerprint)
2446 && room.members.len() >= 2
2447 {
2448 info!(
2449 %sender_fingerprint, %room_id,
2450 "dropping MemberAnnounce on Direct room: already at 2-member cap"
2451 );
2452 return;
2453 }
2454 let newly_added = room.members.insert(sender_fingerprint.clone());
2455 if newly_added {
2456 let _ = self.app_event_tx.send(AppEvent::MemberJoined {
2457 room_id: room_id.to_string(),
2458 fingerprint: sender_fingerprint.clone(),
2459 });
2460 }
2461 let _ = repo::upsert_room_member(
2466 &self.db,
2467 &StoredRoomMember {
2468 room_id: room_id.to_string(),
2469 peer_id: String::new(), fingerprint: sender_fingerprint.clone(),
2471 last_seen: Some(now_unix()),
2472 verified: false,
2473 ed25519_pubkey: sender_ed25519_pubkey.clone(),
2474 role: "member".into(),
2480 },
2481 );
2482 if let Some(name) = display_name.as_deref() {
2483 let _ = repo::set_member_display_name(
2484 &self.db,
2485 room_id,
2486 &sender_fingerprint,
2487 Some(name),
2488 );
2489 }
2490 room.info.encrypted && wrapped_session_key.is_some()
2491 };
2492
2493 if matches!(
2500 self.active_rooms
2501 .lock()
2502 .unwrap()
2503 .get(room_id)
2504 .map(|r| (r.info.kind, r.passphrase_key.is_none())),
2505 Some((RoomKind::Direct, true))
2506 ) {
2507 if let Some(pubkey_b64) = sender_ed25519_pubkey.as_deref() {
2508 if let Some(key) =
2509 self.derive_dm_key_from_pubkey_b64(room_id, pubkey_b64)
2510 {
2511 let mut rooms = self.active_rooms.lock().unwrap();
2512 if let Some(room) = rooms.get_mut(room_id) {
2513 room.passphrase_key = Some(key);
2514 }
2515 drop(rooms);
2516 let app = self.clone();
2521 let rid = room_id.to_string();
2522 tokio::spawn(async move {
2523 if let Err(e) = app.broadcast_member_announce(&rid).await {
2524 warn!(%e, "re-broadcast DM announce after key derivation");
2525 }
2526 });
2527 }
2528 }
2529 }
2530
2531 if need_inbound {
2532 let wrapped = wrapped_session_key.unwrap();
2533 let result = {
2534 let mut rooms = self.active_rooms.lock().unwrap();
2535 let room = rooms.get_mut(room_id).unwrap();
2536 let passphrase_key = match &room.passphrase_key {
2537 Some(k) => k,
2538 None => {
2539 warn!("no passphrase key when receiving session key");
2540 return;
2541 }
2542 };
2543 match passphrase::unwrap(&wrapped, passphrase_key) {
2544 Ok(plain) => match String::from_utf8(plain) {
2545 Ok(key_b64) => {
2546 let crypto = room.crypto.as_mut().unwrap();
2547 crypto.add_inbound_session(&sender_fingerprint, &key_b64)
2548 }
2549 Err(e) => Err(HuddleError::Session(format!("utf8: {e}"))),
2550 },
2551 Err(e) => Err(e),
2552 }
2553 };
2554 if let Err(e) = result {
2555 error!(%e, "add inbound session failed");
2556 }
2557 }
2558 }
2559 RoomMessage::SessionKeyRequest {
2560 requester_fingerprint,
2561 } => {
2562 if requester_fingerprint == our_fp {
2563 return;
2564 }
2565 if let Err(e) = self.broadcast_member_announce(room_id).await {
2567 warn!(%e, "broadcast member announce on request");
2568 }
2569 }
2570 RoomMessage::Encrypted {
2571 sender_fingerprint,
2572 session_id,
2573 ciphertext_b64,
2574 } => {
2575 if sender_fingerprint == our_fp {
2576 return;
2577 }
2578 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2586 .unwrap_or(false)
2587 {
2588 debug!(%sender_fingerprint, %room_id, "dropping Encrypted from banned peer");
2589 return;
2590 }
2591 let ct_bytes = match base64::Engine::decode(
2592 &base64::engine::general_purpose::STANDARD,
2593 &ciphertext_b64,
2594 ) {
2595 Ok(b) => b,
2596 Err(e) => {
2597 warn!(%e, "bad base64 ciphertext");
2598 return;
2599 }
2600 };
2601 let plaintext = {
2602 let mut rooms = self.active_rooms.lock().unwrap();
2603 let room = match rooms.get_mut(room_id) {
2604 Some(r) => r,
2605 None => return,
2606 };
2607 let crypto = match room.crypto.as_mut() {
2608 Some(c) => c,
2609 None => return,
2610 };
2611 crypto.decrypt(&sender_fingerprint, &session_id, &ct_bytes)
2612 };
2613 match plaintext {
2614 Ok(pt) => {
2615 let body = String::from_utf8_lossy(&pt).to_string();
2616 let sent_at = now_unix();
2617 let _ = repo::insert_room_message(
2618 &self.db,
2619 room_id,
2620 &sender_fingerprint,
2621 "in",
2622 &body,
2623 sent_at,
2624 );
2625 let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
2626 self.maybe_emit_mention(room_id, &body);
2627 let _ = self.app_event_tx.send(AppEvent::MessageReceived {
2628 room_id: room_id.to_string(),
2629 sender_fingerprint,
2630 body,
2631 sent_at,
2632 });
2633 }
2634 Err(e) => {
2635 debug!(%e, "decrypt failed (probably missing session key)");
2636 }
2637 }
2638 }
2639 RoomMessage::Plain {
2640 sender_fingerprint,
2641 body,
2642 } => {
2643 if sender_fingerprint == our_fp {
2644 return;
2645 }
2646 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2647 .unwrap_or(false)
2648 {
2649 debug!(%sender_fingerprint, %room_id, "dropping Plain from banned peer");
2650 return;
2651 }
2652 let sent_at = now_unix();
2653 let _ = repo::insert_room_message(
2654 &self.db,
2655 room_id,
2656 &sender_fingerprint,
2657 "in",
2658 &body,
2659 sent_at,
2660 );
2661 let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
2662 self.maybe_emit_mention(room_id, &body);
2663 let _ = self.app_event_tx.send(AppEvent::MessageReceived {
2664 room_id: room_id.to_string(),
2665 sender_fingerprint,
2666 body,
2667 sent_at,
2668 });
2669 }
2670 RoomMessage::Typing { sender_fingerprint } => {
2671 if sender_fingerprint == our_fp {
2672 return;
2673 }
2674 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2675 .unwrap_or(false)
2676 {
2677 return;
2678 }
2679 let expiry = now_unix() + TYPING_TTL_SECS;
2680 let mut rooms = self.active_rooms.lock().unwrap();
2681 if let Some(room) = rooms.get_mut(room_id) {
2682 room.typers.insert(sender_fingerprint, expiry);
2683 }
2684 drop(rooms);
2685 let _ = self.app_event_tx.send(AppEvent::TypingChanged {
2686 room_id: room_id.to_string(),
2687 });
2688 }
2689 RoomMessage::RotateRoomKey {
2690 rotator_fingerprint,
2691 new_salt,
2692 } => {
2693 if rotator_fingerprint == our_fp {
2694 return;
2695 }
2696 let signer = match verified_signer {
2701 Some(fp) => fp,
2702 None => {
2703 warn!(%room_id, "RotateRoomKey arrived unsigned; dropping");
2704 return;
2705 }
2706 };
2707 if signer != rotator_fingerprint {
2708 warn!(
2709 %signer, %rotator_fingerprint, %room_id,
2710 "RotateRoomKey signer mismatch with claimed rotator; dropping"
2711 );
2712 return;
2713 }
2714 let _ = self.app_event_tx.send(AppEvent::RotationRequested {
2715 room_id: room_id.to_string(),
2716 rotator_fingerprint,
2717 new_salt,
2718 });
2719 }
2720 RoomMessage::MemberLeave { sender_fingerprint } => {
2721 if sender_fingerprint == our_fp {
2722 return;
2723 }
2724 let signer = match verified_signer {
2728 Some(fp) => fp,
2729 None => {
2730 warn!(%sender_fingerprint, %room_id, "MemberLeave arrived unsigned; dropping");
2731 return;
2732 }
2733 };
2734 if signer != sender_fingerprint {
2735 warn!(%signer, %sender_fingerprint, %room_id, "MemberLeave signer mismatch; dropping");
2736 return;
2737 }
2738 let removed = {
2739 let mut rooms = self.active_rooms.lock().unwrap();
2740 if let Some(room) = rooms.get_mut(room_id) {
2741 room.members.remove(&sender_fingerprint)
2742 } else {
2743 false
2744 }
2745 };
2746 if removed {
2747 let _ = self.app_event_tx.send(AppEvent::MemberLeft {
2748 room_id: room_id.to_string(),
2749 fingerprint: sender_fingerprint,
2750 });
2751 }
2752 }
2753 RoomMessage::FileOffer {
2754 sender_fingerprint,
2755 file_id,
2756 name,
2757 size_bytes,
2758 mime,
2759 chunk_count,
2760 encrypted_meta,
2761 } => {
2762 if sender_fingerprint == our_fp {
2763 return; }
2765 let signer = match verified_signer {
2770 Some(fp) => fp,
2771 None => {
2772 warn!(%sender_fingerprint, %room_id, %file_id, "FileOffer arrived unsigned; dropping");
2773 return;
2774 }
2775 };
2776 if signer != sender_fingerprint {
2777 warn!(%signer, %sender_fingerprint, %room_id, %file_id, "FileOffer signer mismatch; dropping");
2778 return;
2779 }
2780 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2783 .unwrap_or(false)
2784 {
2785 info!(%sender_fingerprint, %room_id, %file_id, "dropping FileOffer from banned peer");
2786 return;
2787 }
2788 self.handle_file_offer(
2789 room_id,
2790 sender_fingerprint,
2791 file_id,
2792 name,
2793 size_bytes,
2794 mime,
2795 chunk_count,
2796 encrypted_meta,
2797 );
2798 }
2799 RoomMessage::FileChunk {
2800 sender_fingerprint,
2801 file_id,
2802 chunk_index,
2803 total_chunks,
2804 data_b64,
2805 } => {
2806 if sender_fingerprint == our_fp {
2807 return;
2808 }
2809 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2810 .unwrap_or(false)
2811 {
2812 return;
2813 }
2814 self.handle_file_chunk(
2815 room_id,
2816 sender_fingerprint,
2817 file_id,
2818 chunk_index,
2819 total_chunks,
2820 data_b64,
2821 );
2822 }
2823 RoomMessage::OwnerGrant {
2824 room_id: announced_room_id,
2825 target_fingerprint,
2826 } => {
2827 if announced_room_id != room_id {
2832 warn!(payload_room = %announced_room_id, topic_room = %room_id, "OwnerGrant room mismatch");
2833 return;
2834 }
2835 let signer = match verified_signer {
2836 Some(fp) => fp,
2837 None => {
2838 warn!(%room_id, "OwnerGrant arrived unsigned; dropping");
2839 return;
2840 }
2841 };
2842 if !self.is_owner(room_id, &signer) {
2843 warn!(%signer, %room_id, "OwnerGrant signer isn't an owner; dropping");
2844 return;
2845 }
2846 info!(%signer, %target_fingerprint, %room_id, "OwnerGrant applied");
2847 if let Err(e) =
2848 repo::set_member_role(&self.db, room_id, &target_fingerprint, "owner")
2849 {
2850 warn!(%e, "OwnerGrant: set_member_role failed");
2851 }
2852 }
2853 RoomMessage::BanMember {
2854 room_id: announced_room_id,
2855 target_fingerprint,
2856 } => {
2857 if announced_room_id != room_id {
2858 warn!(payload_room = %announced_room_id, topic_room = %room_id, "BanMember room mismatch");
2859 return;
2860 }
2861 let signer = match verified_signer {
2862 Some(fp) => fp,
2863 None => {
2864 warn!(%room_id, "BanMember arrived unsigned; dropping");
2865 return;
2866 }
2867 };
2868 if !self.is_owner(room_id, &signer) {
2869 warn!(%signer, %room_id, "BanMember signer isn't an owner; dropping");
2870 return;
2871 }
2872 if target_fingerprint == our_fp {
2873 info!(%room_id, %signer, "we were kicked from this room");
2879 self.active_rooms.lock().unwrap().remove(room_id);
2880 let _ = self.app_event_tx.send(AppEvent::RoomLeft {
2881 room_id: room_id.to_string(),
2882 });
2883 return;
2884 }
2885 info!(%signer, %target_fingerprint, %room_id, "BanMember applied");
2886 if let Err(e) = repo::add_room_ban(
2887 &self.db,
2888 room_id,
2889 &target_fingerprint,
2890 &signer,
2891 "", now_unix(),
2893 ) {
2894 warn!(%e, "BanMember: add_room_ban failed");
2895 }
2896 self.evict_banned_member(room_id, &target_fingerprint);
2897 }
2898 RoomMessage::SasInit {
2899 tx_id,
2900 ephemeral_x25519_pubkey_b64,
2901 target_fingerprint,
2902 } => {
2903 if target_fingerprint != our_fp {
2904 return;
2909 }
2910 let signer = match verified_signer {
2911 Some(fp) => fp,
2912 None => {
2913 warn!("SasInit arrived unsigned; dropping");
2914 return;
2915 }
2916 };
2917 let their_pub =
2918 match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
2919 Ok(pk) => pk,
2920 Err(e) => {
2921 warn!(%e, "SasInit: bad x25519 pubkey");
2922 return;
2923 }
2924 };
2925 let tx_id_bytes = match B64.decode(&tx_id) {
2926 Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
2927 let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
2928 arr.copy_from_slice(&b);
2929 arr
2930 }
2931 _ => {
2932 warn!(%tx_id, "SasInit: bad tx_id length");
2933 return;
2934 }
2935 };
2936 let (_, our_secret, our_pub) = crate::crypto::sas::new_session();
2937 let sas_code =
2938 crate::crypto::sas::derive_sas_code(&our_secret, &their_pub, &tx_id_bytes);
2939 self.sas_flows.lock().unwrap().insert(
2940 tx_id.clone(),
2941 SasFlow {
2942 room_id: room_id.to_string(),
2943 partner_fingerprint: signer.clone(),
2944 our_secret,
2945 sas_code: Some(sas_code.clone()),
2946 our_confirmed: false,
2947 their_confirmed: false,
2948 finalized: false,
2949 },
2950 );
2951 let response = RoomMessage::SasResponse {
2954 tx_id: tx_id.clone(),
2955 ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2956 };
2957 if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
2958 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
2959 self.network
2960 .publish_room_message(room_id.to_string(), bytes)
2961 .await;
2962 }
2963 }
2964 let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
2965 room_id: room_id.to_string(),
2966 partner_fingerprint: signer,
2967 tx_id,
2968 emoji_string: sas_code.emoji_string(),
2969 emoji_labels: sas_code.emoji_labels(),
2970 decimal: sas_code.decimal,
2971 });
2972 }
2973 RoomMessage::SasResponse {
2974 tx_id,
2975 ephemeral_x25519_pubkey_b64,
2976 } => {
2977 let signer = match verified_signer {
2978 Some(fp) => fp,
2979 None => {
2980 warn!("SasResponse arrived unsigned; dropping");
2981 return;
2982 }
2983 };
2984 let their_pub =
2985 match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
2986 Ok(pk) => pk,
2987 Err(e) => {
2988 warn!(%e, "SasResponse: bad x25519 pubkey");
2989 return;
2990 }
2991 };
2992 let tx_id_bytes = match B64.decode(&tx_id) {
2993 Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
2994 let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
2995 arr.copy_from_slice(&b);
2996 arr
2997 }
2998 _ => return,
2999 };
3000 let emit = {
3001 let mut flows = self.sas_flows.lock().unwrap();
3002 let flow = match flows.get_mut(&tx_id) {
3003 Some(f) => f,
3004 None => {
3005 warn!(%tx_id, "SasResponse for unknown tx_id");
3006 return;
3007 }
3008 };
3009 if flow.partner_fingerprint != signer {
3010 warn!(
3011 expected = %flow.partner_fingerprint, got = %signer,
3012 "SasResponse signer doesn't match flow's partner; dropping"
3013 );
3014 return;
3015 }
3016 let code = crate::crypto::sas::derive_sas_code(
3017 &flow.our_secret,
3018 &their_pub,
3019 &tx_id_bytes,
3020 );
3021 flow.sas_code = Some(code.clone());
3022 code
3023 };
3024 let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
3025 room_id: room_id.to_string(),
3026 partner_fingerprint: signer,
3027 tx_id,
3028 emoji_string: emit.emoji_string(),
3029 emoji_labels: emit.emoji_labels(),
3030 decimal: emit.decimal,
3031 });
3032 }
3033 RoomMessage::CodeJoinRequest {
3034 room_id: announced_room_id,
3035 joiner_x25519_pubkey_b64,
3036 code,
3037 } => {
3038 if announced_room_id != room_id {
3039 return;
3040 }
3041 let joiner_fp = match verified_signer {
3042 Some(fp) => fp,
3043 None => {
3044 warn!("CodeJoinRequest unsigned; dropping");
3045 return;
3046 }
3047 };
3048 let our_fp = self.identity.fingerprint().to_string();
3052 if !self.is_owner(room_id, &our_fp) {
3053 return;
3054 }
3055 let now = now_unix();
3057 let (code_ok, our_session_id, wrap_input) = {
3058 let mut rooms = self.active_rooms.lock().unwrap();
3059 let room = match rooms.get_mut(room_id) {
3060 Some(r) => r,
3061 None => return,
3062 };
3063 if room.passphrase_key.is_none() {
3064 warn!("CodeJoinRequest: no passphrase key locally; can't respond");
3065 return;
3066 }
3067 let original_len = room.issued_codes.len();
3068 room.issued_codes.retain(|(c, exp)| !(c == &code && *exp > now));
3069 let matched = room.issued_codes.len() < original_len;
3070 if !matched {
3071 info!(%joiner_fp, "CodeJoinRequest: code invalid or expired; ignoring");
3072 return;
3073 }
3074 let crypto = room.crypto.as_ref().unwrap();
3075 (
3076 true,
3077 crypto.our_session_id(),
3078 crypto.our_session_key_b64(),
3079 )
3080 };
3081 let _ = code_ok;
3082 let their_pub = match crate::crypto::sas::parse_pubkey(&joiner_x25519_pubkey_b64) {
3084 Ok(pk) => pk,
3085 Err(e) => {
3086 warn!(%e, "CodeJoinRequest: bad pubkey");
3087 return;
3088 }
3089 };
3090 use x25519_dalek::{PublicKey, StaticSecret};
3091 let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
3092 let our_pub = PublicKey::from(&our_secret);
3093 let shared = our_secret.diffie_hellman(&their_pub);
3094 let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
3096 let mut wrap_key = [0u8; passphrase::KEY_LEN];
3097 hk.expand(b"huddle-code-join-v1", &mut wrap_key)
3098 .expect("32 bytes is within HKDF limits");
3099 let wrapped = match passphrase::wrap(wrap_input.as_bytes(), &wrap_key) {
3102 Ok(w) => w,
3103 Err(e) => {
3104 warn!(%e, "CodeJoinRequest: wrap failed");
3105 return;
3106 }
3107 };
3108 let response = RoomMessage::CodeJoinResponse {
3109 room_id: room_id.to_string(),
3110 target_fingerprint: joiner_fp.clone(),
3111 owner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
3112 owner_session_id: our_session_id,
3113 wrapped_session_key_b64: wrapped,
3114 nonce_b64: String::new(), };
3116 if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
3117 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
3118 self.network
3119 .publish_room_message(room_id.to_string(), bytes)
3120 .await;
3121 }
3122 }
3123 info!(%joiner_fp, %room_id, "issued CodeJoinResponse");
3124 }
3125 RoomMessage::CodeJoinResponse {
3126 room_id: announced_room_id,
3127 target_fingerprint,
3128 owner_x25519_pubkey_b64,
3129 owner_session_id,
3130 wrapped_session_key_b64,
3131 nonce_b64: _,
3132 } => {
3133 if announced_room_id != room_id || target_fingerprint != our_fp {
3134 return;
3135 }
3136 let owner_fp = match verified_signer {
3137 Some(fp) => fp,
3138 None => {
3139 warn!("CodeJoinResponse unsigned; dropping");
3140 return;
3141 }
3142 };
3143 let our_secret = match self
3144 .pending_code_secrets
3145 .lock()
3146 .unwrap()
3147 .remove(&(room_id.to_string(), our_fp.clone()))
3148 {
3149 Some(s) => s,
3150 None => {
3151 warn!(%room_id, "CodeJoinResponse with no pending code-join state");
3152 return;
3153 }
3154 };
3155 let owner_pub = match crate::crypto::sas::parse_pubkey(&owner_x25519_pubkey_b64) {
3156 Ok(pk) => pk,
3157 Err(e) => {
3158 warn!(%e, "CodeJoinResponse: bad owner pubkey");
3159 return;
3160 }
3161 };
3162 let shared = our_secret.diffie_hellman(&owner_pub);
3163 let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
3164 let mut wrap_key = [0u8; passphrase::KEY_LEN];
3165 hk.expand(b"huddle-code-join-v1", &mut wrap_key)
3166 .expect("32 bytes within HKDF limits");
3167 let session_key_bytes =
3168 match passphrase::unwrap(&wrapped_session_key_b64, &wrap_key) {
3169 Ok(b) => b,
3170 Err(e) => {
3171 warn!(%e, "CodeJoinResponse: unwrap failed");
3172 return;
3173 }
3174 };
3175 let session_key_str = match String::from_utf8(session_key_bytes) {
3176 Ok(s) => s,
3177 Err(e) => {
3178 warn!(%e, "CodeJoinResponse: session key wasn't valid utf8");
3179 return;
3180 }
3181 };
3182 let mut rooms = self.active_rooms.lock().unwrap();
3184 if let Some(room) = rooms.get_mut(room_id) {
3185 if let Some(crypto) = room.crypto.as_mut() {
3186 if let Err(e) =
3187 crypto.add_inbound_session(&owner_fp, &session_key_str)
3188 {
3189 warn!(%e, "CodeJoinResponse: add_inbound_session failed");
3190 } else {
3191 info!(%room_id, %owner_fp, %owner_session_id, "code-join completed; can decrypt owner's messages");
3192 room.members.insert(owner_fp.clone());
3193 let _ = self.app_event_tx.send(AppEvent::MemberJoined {
3194 room_id: room_id.to_string(),
3195 fingerprint: owner_fp,
3196 });
3197 }
3198 }
3199 }
3200 }
3201 RoomMessage::JoinRefused {
3202 room_id: announced_room_id,
3203 target_fingerprint,
3204 reason,
3205 } => {
3206 if announced_room_id != room_id || target_fingerprint != our_fp {
3207 return;
3208 }
3209 let _ = self.app_event_tx.send(AppEvent::Error {
3213 description: format!("join refused: {reason}"),
3214 });
3215 }
3216 RoomMessage::SasConfirm { tx_id, matched } => {
3217 let signer = match verified_signer {
3218 Some(fp) => fp,
3219 None => return,
3220 };
3221 let (room_id_done, partner_fp_done, both_done) = {
3222 let mut flows = self.sas_flows.lock().unwrap();
3223 let flow = match flows.get_mut(&tx_id) {
3224 Some(f) => f,
3225 None => return,
3226 };
3227 if flow.partner_fingerprint != signer {
3228 return;
3229 }
3230 if !matched {
3231 let _ = flow;
3233 flows.remove(&tx_id);
3234 return;
3235 }
3236 flow.their_confirmed = true;
3237 if flow.our_confirmed && flow.their_confirmed && !flow.finalized {
3244 flow.finalized = true;
3245 (
3246 Some(flow.room_id.clone()),
3247 Some(flow.partner_fingerprint.clone()),
3248 true,
3249 )
3250 } else {
3251 (None, None, false)
3252 }
3253 };
3254 if both_done {
3255 if let (Some(rid), Some(pfp)) = (room_id_done, partner_fp_done) {
3256 if let Err(e) = self.finish_sas(&tx_id, &rid, &pfp).await {
3257 warn!(%e, "finish_sas failed");
3258 }
3259 }
3260 }
3261 }
3262 RoomMessage::ProfileUpdate {
3263 sender_fingerprint,
3264 username,
3265 updated_at,
3266 } => {
3267 let signer = match verified_signer {
3273 Some(fp) => fp,
3274 None => {
3275 warn!(
3276 sender = %sender_fingerprint,
3277 "dropping unsigned ProfileUpdate"
3278 );
3279 return;
3280 }
3281 };
3282 if signer != sender_fingerprint {
3283 warn!(
3284 signer = %signer,
3285 claimed = %sender_fingerprint,
3286 "dropping ProfileUpdate with signer != sender"
3287 );
3288 return;
3289 }
3290 if let Err(e) = repo::upsert_peer_profile(
3291 &self.db,
3292 &sender_fingerprint,
3293 username.as_deref(),
3294 updated_at,
3295 ) {
3296 warn!(%e, "upsert_peer_profile failed");
3297 return;
3298 }
3299 let _ = self.app_event_tx.send(AppEvent::PeerProfileUpdated {
3300 fingerprint: sender_fingerprint,
3301 username,
3302 });
3303 }
3304 }
3305 }
3306
3307 pub async fn send_file(&self, room_id: &str, path: &Path) -> Result<String> {
3315 let bytes = std::fs::read(path)?;
3316 let name = path
3317 .file_name()
3318 .map(|n| n.to_string_lossy().to_string())
3319 .unwrap_or_else(|| "untitled".into());
3320 let mime = crate::files::guess_mime(&name);
3321 let original_path = path.to_path_buf();
3322
3323 let (room_encrypted, mut maybe_session_id, encrypted_meta_opt, wire_bytes) = {
3324 let mut rooms = self.active_rooms.lock().unwrap();
3325 let room = rooms
3326 .get_mut(room_id)
3327 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3328 if room.read_only {
3333 return Err(HuddleError::Other(
3334 "this room is read-only — you can't send files".into(),
3335 ));
3336 }
3337 if room.info.encrypted {
3338 let crypto = room
3339 .crypto
3340 .as_mut()
3341 .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
3342 let (ciphertext, meta) = file_encryption::encrypt_file(&bytes, crypto)?;
3343 (true, Some(meta.megolm_session_id.clone()), Some(meta), ciphertext)
3344 } else {
3345 (false, None, None, bytes)
3346 }
3347 };
3348 let _ = &mut maybe_session_id; let plan =
3351 self.file_manager
3352 .prepare_outgoing_from_bytes(&name, mime.clone(), wire_bytes)?;
3353 let file_id = plan.file_id.clone();
3354 let total = plan.chunks.len() as u32;
3355 let our_fp = self.identity.fingerprint().to_string();
3356
3357 let attachment = StoredAttachment {
3358 id: 0,
3359 room_id: room_id.to_string(),
3360 message_id: None,
3361 sender_fingerprint: our_fp.clone(),
3362 file_id: file_id.clone(),
3363 name: name.clone(),
3364 mime: mime.clone(),
3365 size_bytes: plan.size_bytes as i64,
3366 status: AttachmentStatus::Ready,
3367 cache_path: Some(self.file_manager.cache_path(&file_id).to_string_lossy().into()),
3368 saved_path: Some(original_path.to_string_lossy().into()),
3369 error: None,
3370 encrypted: room_encrypted,
3371 wrapped_key: encrypted_meta_opt.as_ref().map(|m| m.wrapped_key_b64.clone()),
3372 nonce: encrypted_meta_opt.as_ref().map(|m| m.nonce_b64.clone()),
3373 megolm_session_id: encrypted_meta_opt
3374 .as_ref()
3375 .map(|m| m.megolm_session_id.clone()),
3376 content_hash: encrypted_meta_opt.as_ref().map(|m| m.content_hash.clone()),
3377 created_at: now_unix(),
3378 };
3379 repo::upsert_attachment(&self.db, &attachment)?;
3380 let _ = self.app_event_tx.send(AppEvent::FileOffered {
3381 room_id: room_id.to_string(),
3382 file_id: file_id.clone(),
3383 name: name.clone(),
3384 size_bytes: plan.size_bytes,
3385 sender_fingerprint: our_fp.clone(),
3386 });
3387
3388 let offer = RoomMessage::FileOffer {
3395 sender_fingerprint: our_fp.clone(),
3396 file_id: file_id.clone(),
3397 name,
3398 size_bytes: plan.size_bytes,
3399 mime,
3400 chunk_count: total,
3401 encrypted_meta: encrypted_meta_opt,
3402 };
3403 if let Ok(env) = crate::crypto::sign_message(&self.identity, &offer) {
3404 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
3405 self.network
3406 .publish_room_message(room_id.to_string(), bytes)
3407 .await;
3408 }
3409 }
3410
3411 let net = self.network.clone();
3414 let room = room_id.to_string();
3415 let our = our_fp.clone();
3416 let fid = file_id.clone();
3417 let chunks = plan.chunks.clone();
3418 tokio::spawn(async move {
3419 for (i, data) in chunks.iter().enumerate() {
3420 let msg = RoomMessage::FileChunk {
3421 sender_fingerprint: our.clone(),
3422 file_id: fid.clone(),
3423 chunk_index: i as u32,
3424 total_chunks: total,
3425 data_b64: B64.encode(data),
3426 };
3427 if let Ok(bytes) = encode_wire(&msg) {
3428 net.publish_room_message(room.clone(), bytes).await;
3429 }
3430 tokio::time::sleep(Duration::from_millis(40)).await;
3431 }
3432 });
3433
3434 Ok(file_id)
3435 }
3436
3437 pub async fn save_to_downloads(&self, room_id: &str, file_id: &str) -> Result<PathBuf> {
3440 let attachment = repo::get_attachment(&self.db, room_id, file_id)?
3441 .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
3442 if !matches!(
3443 attachment.status,
3444 AttachmentStatus::Ready | AttachmentStatus::Saved
3445 ) {
3446 return Err(HuddleError::Other(format!(
3447 "attachment is not ready (status={})",
3448 attachment.status.as_str()
3449 )));
3450 }
3451 let plaintext = if attachment.encrypted
3456 && attachment.sender_fingerprint == self.identity.fingerprint()
3457 {
3458 match attachment
3459 .saved_path
3460 .as_deref()
3461 .filter(|p| Path::new(p).exists())
3462 {
3463 Some(src) => std::fs::read(src)?,
3464 None => {
3465 return Err(HuddleError::Other(
3466 "your original file has moved or been deleted — it can't be \
3467 recovered from the encrypted cache"
3468 .into(),
3469 ));
3470 }
3471 }
3472 } else {
3473 let cached = self.file_manager.read_cache(file_id)?;
3474 if attachment.encrypted {
3475 let meta = EncryptedFileMeta {
3476 megolm_session_id: attachment
3477 .megolm_session_id
3478 .clone()
3479 .ok_or_else(|| HuddleError::Other("missing megolm_session_id".into()))?,
3480 wrapped_key_b64: attachment
3481 .wrapped_key
3482 .clone()
3483 .ok_or_else(|| HuddleError::Other("missing wrapped_key".into()))?,
3484 nonce_b64: attachment
3485 .nonce
3486 .clone()
3487 .ok_or_else(|| HuddleError::Other("missing nonce".into()))?,
3488 content_hash: attachment
3489 .content_hash
3490 .clone()
3491 .ok_or_else(|| HuddleError::Other("missing content_hash".into()))?,
3492 };
3493 self.decrypt_attachment(
3494 room_id,
3495 &attachment.sender_fingerprint,
3496 &cached,
3497 &meta,
3498 )?
3499 } else {
3500 cached
3501 }
3502 };
3503 let saved = self.file_manager.write_to_downloads(&attachment.name, &plaintext)?;
3504 repo::update_attachment_paths(
3505 &self.db,
3506 room_id,
3507 file_id,
3508 None,
3509 Some(&saved.to_string_lossy()),
3510 )?;
3511 repo::update_attachment_status(&self.db, room_id, file_id, AttachmentStatus::Saved, None)?;
3512 let _ = self.app_event_tx.send(AppEvent::FileSaved {
3513 file_id: file_id.into(),
3514 path: saved.to_string_lossy().into(),
3515 });
3516 Ok(saved)
3517 }
3518
3519 pub async fn cancel_transfer(&self, room_id: &str, file_id: &str) -> Result<()> {
3521 self.file_manager.cancel_incoming(file_id);
3522 repo::update_attachment_status(
3523 &self.db,
3524 room_id,
3525 file_id,
3526 AttachmentStatus::Cancelled,
3527 None,
3528 )?;
3529 Ok(())
3530 }
3531
3532 pub fn open_saved(&self, room_id: &str, file_id: &str) -> Result<()> {
3534 let attachment = repo::get_attachment(&self.db, room_id, file_id)?
3535 .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
3536 let path = attachment
3537 .saved_path
3538 .ok_or_else(|| HuddleError::Other("not saved yet — press Enter to save first".into()))?;
3539 open_with_system(&path)
3540 }
3541
3542 pub fn list_room_attachments(&self, room_id: &str) -> Result<Vec<StoredAttachment>> {
3543 repo::list_room_attachments(&self.db, room_id)
3544 }
3545
3546 pub fn set_member_verified(
3550 &self,
3551 room_id: &str,
3552 fingerprint: &str,
3553 verified: bool,
3554 ) -> Result<()> {
3555 let members = repo::list_room_members(&self.db, room_id).unwrap_or_default();
3560 if !members.iter().any(|m| m.fingerprint == fingerprint) {
3561 repo::upsert_room_member(
3562 &self.db,
3563 &StoredRoomMember {
3564 room_id: room_id.to_string(),
3565 peer_id: String::new(),
3566 fingerprint: fingerprint.to_string(),
3567 last_seen: Some(now_unix()),
3568 verified,
3569 ed25519_pubkey: None,
3570 role: "member".into(),
3571 },
3572 )?;
3573 }
3574 repo::set_member_verified(&self.db, room_id, fingerprint, verified)
3575 }
3576
3577 pub fn verified_fingerprints(&self, room_id: &str) -> Vec<String> {
3578 repo::list_verified_fingerprints(&self.db, room_id).unwrap_or_default()
3579 }
3580
3581 pub fn is_owner(&self, room_id: &str, fingerprint: &str) -> bool {
3584 repo::list_room_owners(&self.db, room_id)
3585 .unwrap_or_default()
3586 .iter()
3587 .any(|fp| fp == fingerprint)
3588 }
3589
3590 pub fn we_are_owner(&self, room_id: &str) -> bool {
3591 self.is_owner(room_id, &self.identity.fingerprint().to_string())
3592 }
3593
3594 pub fn room_owners(&self, room_id: &str) -> Vec<String> {
3597 repo::list_room_owners(&self.db, room_id).unwrap_or_default()
3598 }
3599
3600 pub fn has_master_passphrase(&self) -> bool {
3606 self.session_persist_key != [0u8; 32]
3607 }
3608
3609 pub fn verified_only_inbound(&self) -> bool {
3612 repo::get_setting(&self.db, "verified_only_inbound")
3613 .unwrap_or(None)
3614 .map(|v| v == "1")
3615 .unwrap_or(false)
3616 }
3617
3618 pub fn set_verified_only_inbound(&self, on: bool) -> Result<()> {
3619 repo::set_setting(&self.db, "verified_only_inbound", if on { "1" } else { "0" })
3620 }
3621
3622 pub fn mdns_enabled(&self) -> bool {
3632 repo::get_setting(&self.db, "mdns_enabled")
3633 .unwrap_or(None)
3634 .map(|v| v == "1")
3635 .unwrap_or(true)
3636 }
3637
3638 pub fn set_mdns_enabled(&self, on: bool) -> Result<()> {
3639 repo::set_setting(&self.db, "mdns_enabled", if on { "1" } else { "0" })
3640 }
3641
3642 pub fn notifications_enabled(&self) -> bool {
3648 repo::get_setting(&self.db, "notifications_enabled")
3649 .unwrap_or(None)
3650 .map(|v| v == "1")
3651 .unwrap_or(true)
3652 }
3653
3654 pub fn set_notifications_enabled(&self, on: bool) -> Result<()> {
3655 repo::set_setting(
3656 &self.db,
3657 "notifications_enabled",
3658 if on { "1" } else { "0" },
3659 )
3660 }
3661
3662 pub fn safety_code(&self) -> String {
3667 crate::identity::safety_code(&self.identity.public_bytes())
3668 }
3669
3670 pub fn room_verified_only(&self, room_id: &str) -> bool {
3675 repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
3676 }
3677
3678 pub fn set_room_verified_only(&self, room_id: &str, on: bool) -> Result<()> {
3679 repo::set_room_verified_only(&self.db, room_id, on)
3680 }
3681
3682 pub fn onboarding_seen(&self) -> bool {
3684 repo::is_onboarding_seen(&self.db).unwrap_or(true)
3685 }
3686
3687 pub fn mark_onboarding_seen(&self) -> Result<()> {
3688 repo::mark_onboarding_seen(&self.db)
3689 }
3690
3691 pub fn last_seen_onboarding_version(&self) -> Option<String> {
3695 repo::get_last_seen_onboarding_version(&self.db).unwrap_or(None)
3696 }
3697
3698 pub fn set_last_seen_onboarding_version(&self, version: &str) -> Result<()> {
3699 repo::set_last_seen_onboarding_version(&self.db, version)
3700 }
3701
3702 pub fn update_check_enabled(&self) -> Option<bool> {
3705 repo::get_update_check_enabled(&self.db).unwrap_or(None)
3706 }
3707
3708 pub fn set_update_check_enabled(&self, enabled: bool) -> Result<()> {
3709 repo::set_update_check_enabled(&self.db, enabled)
3710 }
3711
3712 pub fn last_update_check_at(&self) -> i64 {
3715 repo::get_setting(&self.db, "last_update_check_at")
3716 .ok()
3717 .flatten()
3718 .and_then(|s| s.parse().ok())
3719 .unwrap_or(0)
3720 }
3721
3722 pub fn set_last_update_check_at(&self, ts: i64) -> Result<()> {
3723 repo::set_setting(&self.db, "last_update_check_at", &ts.to_string())
3724 }
3725
3726 pub fn last_known_remote_version(&self) -> Option<String> {
3730 repo::get_setting(&self.db, "last_known_remote_version")
3731 .ok()
3732 .flatten()
3733 }
3734
3735 pub fn set_last_known_remote_version(&self, v: &str) -> Result<()> {
3736 repo::set_setting(&self.db, "last_known_remote_version", v)
3737 }
3738
3739 pub async fn grant_owner(&self, room_id: &str, target_fingerprint: &str) -> Result<()> {
3743 let our_fp = self.identity.fingerprint().to_string();
3744 if !self.is_owner(room_id, &our_fp) {
3745 return Err(HuddleError::Other(
3746 "only an owner can grant owner".into(),
3747 ));
3748 }
3749 let msg = RoomMessage::OwnerGrant {
3750 room_id: room_id.to_string(),
3751 target_fingerprint: target_fingerprint.to_string(),
3752 };
3753 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3754 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3755 self.network
3756 .publish_room_message(room_id.to_string(), bytes)
3757 .await;
3758 repo::set_member_role(&self.db, room_id, target_fingerprint, "owner")?;
3760 Ok(())
3761 }
3762
3763 pub async fn kick_member(
3774 &self,
3775 room_id: &str,
3776 target_fingerprint: &str,
3777 ) -> Result<String> {
3778 let our_fp = self.identity.fingerprint().to_string();
3779 if !self.is_owner(room_id, &our_fp) {
3780 return Err(HuddleError::Other("only an owner can kick".into()));
3781 }
3782 if target_fingerprint == our_fp {
3783 return Err(HuddleError::Other("can't kick yourself".into()));
3784 }
3785 let info = self
3786 .active_rooms
3787 .lock()
3788 .unwrap()
3789 .get(room_id)
3790 .map(|r| r.info.clone())
3791 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3792 if !info.encrypted {
3793 let msg = RoomMessage::BanMember {
3797 room_id: room_id.to_string(),
3798 target_fingerprint: target_fingerprint.to_string(),
3799 };
3800 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3801 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3802 self.network
3803 .publish_room_message(room_id.to_string(), bytes)
3804 .await;
3805 repo::add_room_ban(
3806 &self.db,
3807 room_id,
3808 target_fingerprint,
3809 &our_fp,
3810 &env.signature_b64,
3811 now_unix(),
3812 )?;
3813 self.evict_banned_member(room_id, target_fingerprint);
3814 return Ok(String::new());
3815 }
3816 let new_passphrase = generate_join_passphrase();
3818 let msg = RoomMessage::BanMember {
3819 room_id: room_id.to_string(),
3820 target_fingerprint: target_fingerprint.to_string(),
3821 };
3822 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3823 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3824 self.network
3825 .publish_room_message(room_id.to_string(), bytes)
3826 .await;
3827 repo::add_room_ban(
3828 &self.db,
3829 room_id,
3830 target_fingerprint,
3831 &our_fp,
3832 &env.signature_b64,
3833 now_unix(),
3834 )?;
3835 self.evict_banned_member(room_id, target_fingerprint);
3836 self.rotate_room(room_id, &new_passphrase).await?;
3839 Ok(new_passphrase)
3840 }
3841
3842 pub fn generate_join_code(&self, room_id: &str) -> Result<String> {
3849 let our_fp = self.identity.fingerprint().to_string();
3850 if !self.is_owner(room_id, &our_fp) {
3851 return Err(HuddleError::Other(
3852 "only an owner can issue join codes".into(),
3853 ));
3854 }
3855 let code = generate_alphanumeric_code(8);
3856 let expires_at = now_unix() + 10 * 60;
3857 let mut rooms = self.active_rooms.lock().unwrap();
3858 let room = rooms
3859 .get_mut(room_id)
3860 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3861 let now = now_unix();
3863 room.issued_codes.retain(|(_, exp)| *exp > now);
3864 room.issued_codes.push((code.clone(), expires_at));
3865 Ok(code)
3866 }
3867
3868 pub async fn join_room_with_code(
3875 &self,
3876 room_id: &str,
3877 code: &str,
3878 ) -> Result<()> {
3879 let info = {
3881 let d = self.discovered_rooms.lock().unwrap().get(room_id).cloned();
3882 match d {
3883 Some(d) => StoredRoom {
3884 id: room_id.to_string(),
3885 name: d.name,
3886 creator_fingerprint: d.creator_fingerprint,
3887 encrypted: d.encrypted,
3888 passphrase_salt: None, created_at: now_unix(),
3890 last_active: Some(now_unix()),
3891 kind: d.kind,
3894 },
3895 None => {
3896 return Err(HuddleError::Other(format!(
3897 "room {room_id} not visible — wait for an announcement"
3898 )))
3899 }
3900 }
3901 };
3902 if !info.encrypted {
3903 return Err(HuddleError::Other(
3904 "code-join only applies to encrypted rooms".into(),
3905 ));
3906 }
3907 let our_fp = self.identity.fingerprint().to_string();
3908 use x25519_dalek::{PublicKey, StaticSecret};
3911 let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
3912 let our_pub = PublicKey::from(&our_secret);
3913 let key = (room_id.to_string(), our_fp.clone());
3918 self.pending_code_secrets
3919 .lock()
3920 .unwrap()
3921 .insert(key.clone(), our_secret);
3922 let map = self.pending_code_secrets.clone();
3927 let tx = self.app_event_tx.clone();
3928 let timeout_room = room_id.to_string();
3929 tokio::spawn(async move {
3930 tokio::time::sleep(std::time::Duration::from_secs(30)).await;
3931 let still_pending = map.lock().unwrap().remove(&key).is_some();
3932 if still_pending {
3933 let _ = tx.send(AppEvent::CodeJoinTimedOut {
3934 room_id: timeout_room,
3935 reason: "no response from owner — code may be wrong or expired".into(),
3936 });
3937 }
3938 });
3939 repo::insert_room(&self.db, &info)?;
3946 self.active_rooms.lock().unwrap().insert(
3949 room_id.to_string(),
3950 ActiveRoom {
3951 info: info.clone(),
3952 crypto: Some(RoomCrypto::new_for_room(
3953 self.db.clone(),
3954 room_id.to_string(),
3955 our_fp.clone(),
3956 self.session_persist_key,
3957 )?),
3958 passphrase_key: None,
3959 members: {
3960 let mut s = HashSet::new();
3961 s.insert(our_fp.clone());
3962 s
3963 },
3964 typers: HashMap::new(),
3965 read_only: true,
3966 issued_codes: Vec::new(),
3967 },
3968 );
3969 self.network.subscribe_room(room_id.to_string()).await;
3970 let req = RoomMessage::CodeJoinRequest {
3972 room_id: room_id.to_string(),
3973 joiner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
3974 code: code.to_string(),
3975 };
3976 let env = crate::crypto::sign_message(&self.identity, &req)?;
3977 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3978 self.network
3979 .publish_room_message(room_id.to_string(), bytes)
3980 .await;
3981 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
3984 room_id: room_id.to_string(),
3985 });
3986 Ok(())
3987 }
3988
3989 pub async fn sas_start(&self, room_id: &str, target_fingerprint: &str) -> Result<String> {
3995 let (tx_id_bytes, our_secret, our_pub) = crate::crypto::sas::new_session();
3996 let tx_id = B64.encode(tx_id_bytes);
3997 let msg = RoomMessage::SasInit {
3998 tx_id: tx_id.clone(),
3999 ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
4000 target_fingerprint: target_fingerprint.to_string(),
4001 };
4002 let env = crate::crypto::sign_message(&self.identity, &msg)?;
4003 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
4004 self.sas_flows.lock().unwrap().insert(
4005 tx_id.clone(),
4006 SasFlow {
4007 room_id: room_id.to_string(),
4008 partner_fingerprint: target_fingerprint.to_string(),
4009 our_secret,
4010 sas_code: None,
4011 our_confirmed: false,
4012 their_confirmed: false,
4013 finalized: false,
4014 },
4015 );
4016 self.network
4017 .publish_room_message(room_id.to_string(), bytes)
4018 .await;
4019 Ok(tx_id)
4020 }
4021
4022 pub async fn sas_match(&self, tx_id: &str) -> Result<()> {
4026 let (room_id, partner_fp, both_done) = {
4027 let mut flows = self.sas_flows.lock().unwrap();
4028 let flow = flows
4029 .get_mut(tx_id)
4030 .ok_or_else(|| HuddleError::Other("unknown SAS tx_id".into()))?;
4031 flow.our_confirmed = true;
4032 let do_finish = flow.our_confirmed && flow.their_confirmed && !flow.finalized;
4036 if do_finish {
4037 flow.finalized = true;
4038 }
4039 (
4040 flow.room_id.clone(),
4041 flow.partner_fingerprint.clone(),
4042 do_finish,
4043 )
4044 };
4045 let msg = RoomMessage::SasConfirm {
4046 tx_id: tx_id.to_string(),
4047 matched: true,
4048 };
4049 let env = crate::crypto::sign_message(&self.identity, &msg)?;
4050 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
4051 self.network
4052 .publish_room_message(room_id.clone(), bytes)
4053 .await;
4054 if both_done {
4055 self.finish_sas(tx_id, &room_id, &partner_fp).await?;
4056 }
4057 Ok(())
4058 }
4059
4060 pub fn sas_cancel(&self, tx_id: &str) {
4064 self.sas_flows.lock().unwrap().remove(tx_id);
4065 }
4066
4067 async fn finish_sas(
4070 &self,
4071 tx_id: &str,
4072 room_id: &str,
4073 partner_fingerprint: &str,
4074 ) -> Result<()> {
4075 repo::set_member_verified(&self.db, room_id, partner_fingerprint, true)?;
4076 repo::add_verified_peer(&self.db, partner_fingerprint, now_unix())?;
4077 self.sas_flows.lock().unwrap().remove(tx_id);
4078 let _ = self.app_event_tx.send(AppEvent::SasVerified {
4079 room_id: room_id.to_string(),
4080 partner_fingerprint: partner_fingerprint.to_string(),
4081 });
4082 Ok(())
4083 }
4084
4085 fn evict_banned_member(&self, room_id: &str, fingerprint: &str) {
4090 if let Some(room) = self.active_rooms.lock().unwrap().get_mut(room_id) {
4091 room.members.remove(fingerprint);
4092 }
4093 let _ = self.app_event_tx.send(AppEvent::MemberLeft {
4094 room_id: room_id.to_string(),
4095 fingerprint: fingerprint.to_string(),
4096 });
4097 }
4098
4099 pub fn display_name(&self) -> Option<String> {
4100 repo::get_display_name(&self.db).unwrap_or(None)
4101 }
4102
4103 pub fn set_display_name(&self, name: Option<&str>) -> Result<()> {
4104 repo::set_display_name(&self.db, name)
4105 }
4106
4107 pub async fn set_username(&self, name: Option<&str>) -> Result<()> {
4113 repo::set_display_name(&self.db, name)?;
4114 let msg = RoomMessage::ProfileUpdate {
4115 sender_fingerprint: self.identity.fingerprint().to_string(),
4116 username: name.map(|s| s.to_string()),
4117 updated_at: now_unix_ms(),
4118 };
4119 let env = crate::crypto::sign_message(&self.identity, &msg)?;
4120 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
4121 let rooms: Vec<String> = self.active_rooms.lock().unwrap().keys().cloned().collect();
4122 for room_id in rooms {
4123 self.network
4124 .publish_room_message(room_id, bytes.clone())
4125 .await;
4126 }
4127 Ok(())
4128 }
4129
4130 pub fn lookup_username(&self, fingerprint: &str) -> Option<String> {
4135 repo::get_peer_username(&self.db, fingerprint).unwrap_or(None)
4136 }
4137
4138 pub fn lookup_member_display_name(&self, fingerprint: &str) -> Option<String> {
4142 self.lookup_username(fingerprint)
4143 }
4144
4145 pub fn is_room_muted(&self, room_id: &str) -> bool {
4146 repo::is_room_muted(&self.db, room_id).unwrap_or(false)
4147 }
4148
4149 pub fn list_room_bans(&self, room_id: &str) -> Vec<String> {
4154 repo::list_room_bans(&self.db, room_id).unwrap_or_default()
4155 }
4156
4157 pub fn list_verified_peers(&self) -> Vec<String> {
4163 repo::list_verified_peers(&self.db).unwrap_or_default()
4164 }
4165
4166 pub fn list_blocked_peers(&self) -> Vec<String> {
4167 repo::list_blocked_peers(&self.db).unwrap_or_default()
4168 }
4169
4170 pub fn unblock_peer(&self, fingerprint: &str) -> Result<()> {
4174 repo::unblock_peer(&self.db, fingerprint)
4175 }
4176
4177 pub fn block_peer(&self, fingerprint: &str) -> Result<()> {
4181 repo::block_peer(&self.db, fingerprint, now_unix())
4182 }
4183
4184 pub fn is_room_read_only(&self, room_id: &str) -> bool {
4190 self.active_rooms
4191 .lock()
4192 .unwrap()
4193 .get(room_id)
4194 .map(|r| r.read_only)
4195 .unwrap_or(false)
4196 }
4197
4198 pub fn set_room_muted(&self, room_id: &str, muted: bool) -> Result<()> {
4199 repo::set_room_muted(&self.db, room_id, muted)
4200 }
4201
4202 pub async fn broadcast_typing(&self, room_id: &str) {
4205 if !self.active_rooms.lock().unwrap().contains_key(room_id) {
4206 return;
4207 }
4208 let msg = RoomMessage::Typing {
4209 sender_fingerprint: self.identity.fingerprint().to_string(),
4210 };
4211 if let Ok(bytes) = encode_wire(&msg) {
4212 self.network
4213 .publish_room_message(room_id.to_string(), bytes)
4214 .await;
4215 }
4216 }
4217
4218 pub fn typers_in_room(&self, room_id: &str) -> Vec<String> {
4221 let now = now_unix();
4222 let mut rooms = self.active_rooms.lock().unwrap();
4223 let room = match rooms.get_mut(room_id) {
4224 Some(r) => r,
4225 None => return Vec::new(),
4226 };
4227 room.typers.retain(|_, exp| *exp > now);
4228 let mut v: Vec<String> = room.typers.keys().cloned().collect();
4229 v.sort();
4230 v
4231 }
4232
4233 pub async fn rotate_room(&self, room_id: &str, new_passphrase: &str) -> Result<()> {
4243 if new_passphrase.is_empty() {
4244 return Err(HuddleError::Other("new passphrase is empty".into()));
4245 }
4246 let new_salt = passphrase::random_salt();
4247 let new_key = passphrase::derive_key(new_passphrase, &new_salt)?;
4248
4249 let info = {
4250 let mut rooms = self.active_rooms.lock().unwrap();
4251 let room = rooms
4252 .get_mut(room_id)
4253 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
4254 if !room.info.encrypted {
4255 return Err(HuddleError::Other(
4256 "rotation only applies to encrypted rooms".into(),
4257 ));
4258 }
4259 let new_crypto = RoomCrypto::new_for_room(
4261 self.db.clone(),
4262 room_id.to_string(),
4263 self.identity.fingerprint().to_string(),
4264 self.session_persist_key,
4265 )?;
4266 room.crypto = Some(new_crypto);
4267 room.passphrase_key = Some(new_key);
4268 room.info.passphrase_salt = Some(new_salt.to_vec());
4269 room.info.clone()
4270 };
4271
4272 let rot = RoomMessage::RotateRoomKey {
4278 rotator_fingerprint: self.identity.fingerprint().to_string(),
4279 new_salt: new_salt.to_vec(),
4280 };
4281 if let Ok(env) = crate::crypto::sign_message(&self.identity, &rot) {
4285 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
4286 self.network
4287 .publish_room_message(room_id.to_string(), bytes)
4288 .await;
4289 }
4290 }
4291 if let Err(e) = self.broadcast_member_announce(room_id).await {
4293 warn!(%e, "rotate: broadcast announce failed");
4294 }
4295
4296 repo::insert_room(&self.db, &info)?;
4298 Ok(())
4299 }
4300
4301 pub async fn accept_rotation(
4305 &self,
4306 room_id: &str,
4307 new_salt: &[u8],
4308 new_passphrase: &str,
4309 ) -> Result<()> {
4310 let new_key = passphrase::derive_key(new_passphrase, new_salt)?;
4311 let info = {
4312 let mut rooms = self.active_rooms.lock().unwrap();
4313 let room = rooms
4314 .get_mut(room_id)
4315 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
4316 room.passphrase_key = Some(new_key);
4317 room.info.passphrase_salt = Some(new_salt.to_vec());
4318 room.info.clone()
4319 };
4320 let req = RoomMessage::SessionKeyRequest {
4324 requester_fingerprint: self.identity.fingerprint().to_string(),
4325 };
4326 if let Ok(bytes) = encode_wire(&req) {
4327 self.network
4328 .publish_room_message(room_id.to_string(), bytes)
4329 .await;
4330 }
4331 repo::insert_room(&self.db, &info)?;
4332 Ok(())
4333 }
4334
4335 #[allow(clippy::too_many_arguments)]
4340 fn handle_file_offer(
4341 &self,
4342 room_id: &str,
4343 sender_fingerprint: String,
4344 file_id: String,
4345 name: String,
4346 size_bytes: u64,
4347 mime: Option<String>,
4348 _chunk_count: u32,
4349 encrypted_meta: Option<EncryptedFileMeta>,
4350 ) {
4351 let encrypted = encrypted_meta.is_some();
4352 let attachment = StoredAttachment {
4353 id: 0,
4354 room_id: room_id.to_string(),
4355 message_id: None,
4356 sender_fingerprint: sender_fingerprint.clone(),
4357 file_id: file_id.clone(),
4358 name: name.clone(),
4359 mime,
4360 size_bytes: size_bytes as i64,
4361 status: AttachmentStatus::Offered,
4362 cache_path: None,
4363 saved_path: None,
4364 error: None,
4365 encrypted,
4366 wrapped_key: encrypted_meta.as_ref().map(|m| m.wrapped_key_b64.clone()),
4367 nonce: encrypted_meta.as_ref().map(|m| m.nonce_b64.clone()),
4368 megolm_session_id: encrypted_meta.as_ref().map(|m| m.megolm_session_id.clone()),
4369 content_hash: encrypted_meta.as_ref().map(|m| m.content_hash.clone()),
4370 created_at: now_unix(),
4371 };
4372 if let Err(e) = repo::upsert_attachment(&self.db, &attachment) {
4373 warn!(%e, "upsert attachment");
4374 return;
4375 }
4376 self.file_manager.set_expected_size(&file_id, size_bytes);
4379 let _ = self.app_event_tx.send(AppEvent::FileOffered {
4380 room_id: room_id.to_string(),
4381 file_id,
4382 name,
4383 size_bytes,
4384 sender_fingerprint,
4385 });
4386 }
4387
4388 fn handle_file_chunk(
4389 &self,
4390 room_id: &str,
4391 _sender_fingerprint: String,
4392 file_id: String,
4393 chunk_index: u32,
4394 total_chunks: u32,
4395 data_b64: String,
4396 ) {
4397 let data = match B64.decode(&data_b64) {
4398 Ok(d) => d,
4399 Err(e) => {
4400 warn!(%e, "bad chunk base64");
4401 return;
4402 }
4403 };
4404 let expected_size = match repo::get_attachment(&self.db, room_id, &file_id) {
4408 Ok(Some(a)) => {
4409 if matches!(
4410 a.status,
4411 AttachmentStatus::Cancelled | AttachmentStatus::Failed
4412 ) {
4413 return;
4414 }
4415 a.size_bytes as u64
4416 }
4417 Ok(None) => crate::files::MAX_FILE_SIZE,
4418 Err(e) => {
4419 warn!(%e, "get attachment for chunk");
4420 crate::files::MAX_FILE_SIZE
4421 }
4422 };
4423
4424 let result = self.file_manager.accept_chunk(
4425 &file_id,
4426 chunk_index,
4427 total_chunks,
4428 data,
4429 expected_size,
4430 );
4431 match result {
4432 Ok(None) => {
4433 let _ = repo::update_attachment_status(
4435 &self.db,
4436 room_id,
4437 &file_id,
4438 AttachmentStatus::Downloading,
4439 None,
4440 );
4441 let bytes_so_far = self
4444 .file_manager
4445 .progress(&file_id)
4446 .map(|(b, _)| b)
4447 .unwrap_or(0);
4448 let _ = self.app_event_tx.send(AppEvent::FileProgress {
4449 file_id: file_id.clone(),
4450 bytes_received: bytes_so_far,
4451 total_bytes: expected_size,
4452 });
4453 }
4454 Ok(Some(completed)) => {
4455 let _ = repo::update_attachment_paths(
4456 &self.db,
4457 room_id,
4458 &file_id,
4459 Some(&completed.cache_path.to_string_lossy()),
4460 None,
4461 );
4462 let _ = repo::update_attachment_status(
4463 &self.db,
4464 room_id,
4465 &file_id,
4466 AttachmentStatus::Ready,
4467 None,
4468 );
4469 let _ = self.app_event_tx.send(AppEvent::FileReady {
4470 file_id: file_id.clone(),
4471 });
4472 }
4473 Err(e) => {
4474 let msg = e.to_string();
4475 warn!(%msg, "chunk processing failed");
4476 let _ = repo::update_attachment_status(
4477 &self.db,
4478 room_id,
4479 &file_id,
4480 AttachmentStatus::Failed,
4481 Some(&msg),
4482 );
4483 let _ = self.app_event_tx.send(AppEvent::FileFailed {
4484 file_id: file_id.clone(),
4485 reason: msg,
4486 });
4487 }
4488 }
4489 }
4490
4491 fn maybe_emit_mention(&self, room_id: &str, body: &str) {
4503 let full = self.identity.fingerprint().to_lowercase();
4504 let short: String = full.chars().filter(|c| c.is_ascii_hexdigit()).take(8).collect();
4507 let lower = body.to_lowercase();
4508 let hit = lower.contains(full.as_str())
4509 || lower
4510 .split(|c: char| !c.is_ascii_hexdigit())
4511 .any(|tok| tok == short);
4512 if hit {
4513 let _ = self.app_event_tx.send(AppEvent::MentionReceived {
4514 room_id: room_id.to_string(),
4515 body: body.to_string(),
4516 });
4517 }
4518 }
4519
4520 fn decrypt_attachment(
4521 &self,
4522 room_id: &str,
4523 sender_fingerprint: &str,
4524 ciphertext: &[u8],
4525 meta: &EncryptedFileMeta,
4526 ) -> Result<Vec<u8>> {
4527 let mut rooms = self.active_rooms.lock().unwrap();
4528 let room = rooms
4529 .get_mut(room_id)
4530 .ok_or_else(|| HuddleError::Other("not in room".into()))?;
4531 let crypto = room
4532 .crypto
4533 .as_mut()
4534 .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
4535 file_encryption::decrypt_file(ciphertext, meta, crypto, sender_fingerprint)
4536 }
4537
4538 pub async fn go_dark(&self, master_passphrase: &str) -> Result<()> {
4550 let no_master = self.session_persist_key == [0u8; 32];
4551 if !no_master {
4552 let salt = storage::keychain::load_or_create_salt()?;
4553 let candidate_master =
4554 storage::keychain::derive_master_key(master_passphrase, &salt)?;
4555 let candidate_subkey =
4556 storage::keychain::derive_subkey(&candidate_master, b"megolm-persist");
4557 if !ct_eq_32(&candidate_subkey, &self.session_persist_key) {
4558 return Err(HuddleError::Other(
4559 "incorrect master passphrase".into(),
4560 ));
4561 }
4562 }
4563
4564 let room_ids: Vec<String> = self
4565 .active_rooms
4566 .lock()
4567 .unwrap()
4568 .keys()
4569 .cloned()
4570 .collect();
4571 let _ = tokio::time::timeout(Duration::from_secs(2), async {
4572 for room_id in &room_ids {
4573 if let Err(e) = self.leave_room(room_id).await {
4574 warn!(%room_id, %e, "go_dark: leave_room failed");
4575 }
4576 }
4577 })
4578 .await;
4579
4580 self.network.shutdown().await;
4581 tokio::time::sleep(Duration::from_millis(300)).await;
4582
4583 let data_dir = config::data_dir();
4584 let candidates = [
4585 "huddle.db",
4586 "huddle.db-shm",
4587 "huddle.db-wal",
4588 "keychain.salt",
4589 "huddle.log",
4590 "config.toml",
4591 ];
4592 for name in &candidates {
4593 let path = data_dir.join(name);
4594 wipe_file(&path);
4595 }
4596 if let Ok(read) = std::fs::read_dir(&data_dir) {
4597 for entry in read.flatten() {
4598 if let Some(name) = entry.file_name().to_str() {
4599 if name.starts_with("huddle.log.") {
4600 wipe_file(&entry.path());
4601 }
4602 }
4603 }
4604 }
4605 let files_dir = data_dir.join("files");
4609 if let Ok(read) = std::fs::read_dir(&files_dir) {
4610 for entry in read.flatten() {
4611 let path = entry.path();
4612 if path.is_file() {
4613 wipe_file(&path);
4614 } else if path.is_dir() {
4615 if let Ok(inner) = std::fs::read_dir(&path) {
4618 for inner_entry in inner.flatten() {
4619 if inner_entry.path().is_file() {
4620 wipe_file(&inner_entry.path());
4621 }
4622 }
4623 }
4624 let _ = std::fs::remove_dir(&path);
4625 }
4626 }
4627 }
4628 let _ = std::fs::remove_dir(&files_dir);
4629 let _ = std::fs::remove_dir(&data_dir);
4630
4631 let _ = self.app_event_tx.send(AppEvent::WentDark);
4632 Ok(())
4633 }
4634}
4635
4636pub fn normalize_to_fingerprint(input: &str) -> Option<String> {
4643 let s = input
4644 .trim()
4645 .trim_start_matches("HD-")
4646 .trim_start_matches("hd-")
4647 .to_string();
4648 let hex_only: String = s.chars().filter(|c| *c != '-').collect();
4649 if hex_only.len() != 24 || !hex_only.chars().all(|c| c.is_ascii_hexdigit()) {
4650 return None;
4651 }
4652 let lower = hex_only.to_ascii_lowercase();
4653 let chunks: Vec<String> = lower
4654 .as_bytes()
4655 .chunks(4)
4656 .map(|c| std::str::from_utf8(c).unwrap().to_string())
4657 .collect();
4658 Some(chunks.join("-"))
4659}
4660
4661fn address_preference(addr: &str) -> u8 {
4667 if addr.contains("/p2p-circuit") {
4668 return 9; }
4670 if let Some(rest) = addr.strip_prefix("/ip4/") {
4671 if let Some(ip_str) = rest.split('/').next() {
4672 if let Ok(ip) = ip_str.parse::<std::net::Ipv4Addr>() {
4673 if ip.is_loopback() {
4674 return 1; }
4676 if is_rfc1918(&ip) || ip.is_link_local() {
4677 return 0; }
4679 return 3; }
4681 }
4682 return 3;
4683 }
4684 if addr.starts_with("/ip6/") {
4685 return 4;
4686 }
4687 if addr.starts_with("/dns4/") || addr.starts_with("/dns6/") || addr.starts_with("/dnsaddr/") {
4688 return 5;
4689 }
4690 7
4691}
4692
4693fn is_rfc1918(ip: &std::net::Ipv4Addr) -> bool {
4697 let octets = ip.octets();
4698 octets[0] == 10
4699 || (octets[0] == 172 && (16..=31).contains(&octets[1]))
4700 || (octets[0] == 192 && octets[1] == 168)
4701}
4702
4703fn short_fp_for_msg(fingerprint: &str) -> String {
4707 let head: String = fingerprint
4708 .chars()
4709 .filter(|c| *c != '-')
4710 .take(4)
4711 .collect::<String>()
4712 .to_ascii_uppercase();
4713 format!("HD-{}…", head)
4714}
4715
4716fn ct_eq_32(a: &[u8; 32], b: &[u8; 32]) -> bool {
4720 let mut diff = 0u8;
4721 for i in 0..32 {
4722 diff |= a[i] ^ b[i];
4723 }
4724 diff == 0
4725}
4726
4727fn wipe_file(path: &Path) {
4731 use std::io::Write;
4732 const SCRATCH: usize = 64 * 1024;
4738 if let Ok(meta) = std::fs::metadata(path) {
4739 if let Ok(mut f) = std::fs::OpenOptions::new().write(true).open(path) {
4740 let zeros = [0u8; SCRATCH];
4741 let mut remaining = meta.len();
4742 while remaining > 0 {
4743 let n = remaining.min(SCRATCH as u64) as usize;
4744 if f.write_all(&zeros[..n]).is_err() {
4745 break;
4746 }
4747 remaining -= n as u64;
4748 }
4749 let _ = f.sync_all();
4750 }
4751 }
4752 if let Err(e) = std::fs::remove_file(path) {
4753 if e.kind() != std::io::ErrorKind::NotFound {
4754 warn!(?path, %e, "wipe_file: remove failed");
4755 }
4756 }
4757}
4758
4759fn open_with_system(path: &str) -> Result<()> {
4761 #[cfg(target_os = "macos")]
4762 let cmd = "open";
4763 #[cfg(target_os = "linux")]
4764 let cmd = "xdg-open";
4765 #[cfg(target_os = "windows")]
4766 let cmd = "cmd";
4767 #[cfg(target_os = "windows")]
4768 let args = vec!["/C", "start", "", path];
4769 #[cfg(not(target_os = "windows"))]
4770 let args = vec![path];
4771
4772 std::process::Command::new(cmd)
4773 .args(args)
4774 .spawn()
4775 .map_err(|e| HuddleError::Other(format!("spawn opener: {e}")))?;
4776 Ok(())
4777}
4778
4779static ROOM_SALT_CACHE: std::sync::LazyLock<Mutex<HashMap<String, Vec<u8>>>> =
4782 std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
4783
4784pub fn salt_len() -> usize {
4789 SALT_LEN
4790}
4791
4792fn now_unix() -> i64 {
4793 SystemTime::now()
4794 .duration_since(UNIX_EPOCH)
4795 .unwrap()
4796 .as_secs() as i64
4797}
4798
4799fn now_unix_ms() -> i64 {
4800 SystemTime::now()
4801 .duration_since(UNIX_EPOCH)
4802 .unwrap()
4803 .as_millis() as i64
4804}
4805
4806fn generate_join_passphrase() -> String {
4812 use rand::RngCore;
4813 let mut bytes = [0u8; 16];
4814 rand::thread_rng().fill_bytes(&mut bytes);
4815 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
4818}
4819
4820fn generate_alphanumeric_code(len: usize) -> String {
4829 use rand::Rng;
4830 const ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ23456789";
4831 let mut rng = rand::thread_rng();
4832 let mut out = String::with_capacity(len + 1);
4833 for i in 0..len {
4834 if i == 4 && len == 8 {
4835 out.push('-'); }
4837 let idx = rng.gen_range(0..ALPHABET.len());
4838 out.push(ALPHABET[idx] as char);
4839 }
4840 out
4841}
4842
4843#[cfg(test)]
4844mod parser_tests {
4845 use super::parse_dial_address;
4846
4847 #[test]
4848 fn parses_ipv4_port() {
4849 let m = parse_dial_address("10.3.72.53:9027").unwrap();
4850 assert_eq!(m.to_string(), "/ip4/10.3.72.53/tcp/9027");
4851 }
4852
4853 #[test]
4854 fn parses_bracketed_ipv6() {
4855 let m = parse_dial_address("[::1]:9027").unwrap();
4856 assert_eq!(m.to_string(), "/ip6/::1/tcp/9027");
4857 }
4858
4859 #[test]
4860 fn rejects_unbracketed_ipv6() {
4861 let err = parse_dial_address("fe80::1:9027").unwrap_err();
4862 assert!(err.to_string().contains("brackets"));
4863 }
4864
4865 #[test]
4866 fn passes_through_raw_multiaddr() {
4867 let m = parse_dial_address("/ip4/1.2.3.4/tcp/9000").unwrap();
4868 assert_eq!(m.to_string(), "/ip4/1.2.3.4/tcp/9000");
4869 }
4870
4871 #[test]
4872 fn empty_address_is_error() {
4873 assert!(parse_dial_address(" ").is_err());
4874 }
4875
4876 #[test]
4877 fn rejects_bad_port() {
4878 assert!(parse_dial_address("1.2.3.4:notaport").is_err());
4879 }
4880}
4881
4882#[cfg(test)]
4883mod transport_preference_tests {
4884 use super::{address_preference, normalize_to_fingerprint};
4885
4886 #[test]
4887 fn lan_beats_public_beats_circuit() {
4888 let lan = address_preference("/ip4/192.168.1.5/tcp/9027");
4889 let pub_v4 = address_preference("/ip4/8.8.8.8/tcp/9027");
4890 let circuit = address_preference(
4891 "/ip4/1.2.3.4/tcp/4001/p2p/12D3Koo/p2p-circuit/p2p/12D3KooXYZ",
4892 );
4893 assert!(lan < pub_v4, "LAN {} should beat public {}", lan, pub_v4);
4894 assert!(
4895 pub_v4 < circuit,
4896 "public {} should beat circuit {}",
4897 pub_v4,
4898 circuit
4899 );
4900 }
4901
4902 #[test]
4903 fn all_rfc1918_ranges_are_lan() {
4904 assert_eq!(
4905 address_preference("/ip4/10.0.0.1/tcp/9027"),
4906 address_preference("/ip4/192.168.0.1/tcp/9027"),
4907 );
4908 assert_eq!(
4909 address_preference("/ip4/172.16.0.1/tcp/9027"),
4910 address_preference("/ip4/192.168.0.1/tcp/9027"),
4911 );
4912 assert!(
4914 address_preference("/ip4/172.32.0.1/tcp/9027")
4915 > address_preference("/ip4/172.16.0.1/tcp/9027")
4916 );
4917 }
4918
4919 #[test]
4920 fn normalize_id_accepts_branded_and_raw() {
4921 let canon = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4922 assert_eq!(
4923 normalize_to_fingerprint("HD-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF").as_deref(),
4924 Some(canon)
4925 );
4926 assert_eq!(
4927 normalize_to_fingerprint("aaaabbbbccccddddeeeeffff").as_deref(),
4928 Some(canon)
4929 );
4930 assert_eq!(normalize_to_fingerprint(canon).as_deref(), Some(canon));
4931 assert!(normalize_to_fingerprint("alice").is_none());
4932 assert!(normalize_to_fingerprint("HD-ZZZZ").is_none());
4933 }
4934}
4935
4936#[cfg(test)]
4937mod canonical_dm_room_id_tests {
4938 use super::canonical_dm_room_id;
4939
4940 #[test]
4941 fn dm_room_id_is_commutative() {
4942 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4945 let b = "1111-2222-3333-4444-5555-6666";
4946 assert_eq!(canonical_dm_room_id(a, b), canonical_dm_room_id(b, a));
4947 }
4948
4949 #[test]
4950 fn dm_room_id_differs_per_pair() {
4951 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4952 let b = "1111-2222-3333-4444-5555-6666";
4953 let c = "9999-8888-7777-6666-5555-4444";
4954 assert_ne!(canonical_dm_room_id(a, b), canonical_dm_room_id(a, c));
4955 assert_ne!(canonical_dm_room_id(a, b), canonical_dm_room_id(b, c));
4956 }
4957
4958 #[test]
4959 fn dm_room_id_is_stable() {
4960 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4964 let b = "1111-2222-3333-4444-5555-6666";
4965 let id1 = canonical_dm_room_id(a, b);
4966 let id2 = canonical_dm_room_id(a, b);
4967 assert_eq!(id1, id2);
4968 assert_eq!(id1.len(), 32);
4972 }
4973}