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::RelayReservationLost { relay_peer } => {
1839 warn!(%relay_peer, "relay reservation lost; reachability may degrade");
1840 }
1845 NetworkEvent::ListeningOn { address } => {
1846 let _ = self.app_event_tx.send(AppEvent::ListeningOn {
1847 address: address.to_string(),
1848 });
1849 }
1850 NetworkEvent::RoomAnnouncementReceived(ann) => {
1851 if let Some(salt) = &ann.passphrase_salt {
1853 ROOM_SALT_CACHE
1854 .lock()
1855 .unwrap()
1856 .insert(ann.room_id.clone(), salt.clone());
1857 }
1858 let our_fp_for_dial = self.identity.fingerprint().to_string();
1863 if ann.creator_fingerprint != our_fp_for_dial && !ann.host_addrs.is_empty() {
1864 let now = now_unix();
1865 let should_dial = {
1866 let mut attempts = self.host_addr_dial_attempts.lock().unwrap();
1867 match attempts.get(&ann.creator_fingerprint).copied() {
1868 Some(last) if now - last < HOST_ADDR_DIAL_BACKOFF_SECS => false,
1869 _ => {
1870 attempts.insert(ann.creator_fingerprint.clone(), now);
1871 true
1872 }
1873 }
1874 };
1875 if should_dial {
1876 if let Some(first) = ann.host_addrs.first() {
1877 info!(
1878 announcer = %ann.creator_fingerprint,
1879 addr = %first,
1880 "opportunistic dial via room announcement host_addrs"
1881 );
1882 if let Ok(multiaddr) = first.parse::<Multiaddr>() {
1887 let canonical = multiaddr.to_string();
1888 let _ = self.dial_internal(canonical, multiaddr).await;
1889 }
1890 }
1891 }
1892 }
1893 let discovered = DiscoveredRoom {
1894 room_id: ann.room_id.clone(),
1895 name: ann.name.clone(),
1896 encrypted: ann.encrypted,
1897 member_count: ann.member_count,
1898 creator_fingerprint: ann.creator_fingerprint.clone(),
1899 last_seen: now_unix(),
1900 restorable: false,
1901 host_addrs: ann.host_addrs.clone(),
1902 kind: ann.kind,
1903 };
1904 if self.active_rooms.lock().unwrap().contains_key(&ann.room_id) {
1909 self.discovered_rooms
1910 .lock()
1911 .unwrap()
1912 .insert(ann.room_id.clone(), discovered);
1913 return;
1914 }
1915 if ann.kind == RoomKind::Direct {
1925 let our_fp_for_filter = self.identity.fingerprint().to_string();
1926 if canonical_dm_room_id(&our_fp_for_filter, &ann.creator_fingerprint)
1927 != ann.room_id
1928 {
1929 debug!(
1930 announcer = %ann.creator_fingerprint,
1931 room_id = %ann.room_id,
1932 "dropping Direct announcement: not addressed to us"
1933 );
1934 return;
1935 }
1936 if repo::is_peer_blocked(&self.db, &ann.creator_fingerprint).unwrap_or(false)
1948 {
1949 debug!(
1950 partner = %ann.creator_fingerprint,
1951 "ignoring Direct announcement from blocked peer"
1952 );
1953 return;
1954 }
1955 self.discovered_rooms
1956 .lock()
1957 .unwrap()
1958 .insert(ann.room_id.clone(), discovered.clone());
1959 let _ = self
1960 .app_event_tx
1961 .send(AppEvent::RoomDiscovered(discovered.clone()));
1962 let app = self.clone();
1963 let partner = ann.creator_fingerprint.clone();
1964 let rid = ann.room_id.clone();
1965 tokio::spawn(async move {
1966 if let Err(e) = app.start_direct(&partner).await {
1967 debug!(%e, room_id = %rid, "auto-bootstrap of inbound DM failed");
1968 }
1969 });
1970 return;
1971 }
1972 self.discovered_rooms
1973 .lock()
1974 .unwrap()
1975 .insert(ann.room_id.clone(), discovered.clone());
1976 let _ = self.app_event_tx.send(AppEvent::RoomDiscovered(discovered));
1977 }
1978 NetworkEvent::RoomMessageReceived {
1979 room_id,
1980 payload,
1981 from_peer: _,
1982 } => {
1983 let wire: WireMessage = match serde_json::from_slice(&payload) {
1990 Ok(w) => w,
1991 Err(e) => {
1992 warn!(%e, "bad wire envelope");
1993 return;
1994 }
1995 };
1996 let (msg, verified_signer) = match wire {
1997 WireMessage::Plain(m) => (m, None),
1998 WireMessage::Signed(env) => {
1999 let claimed_pubkey = env.ed25519_pubkey_b64.clone();
2000 match crate::crypto::verify_signed(&env) {
2001 Ok((m, fp)) => {
2002 match repo::get_member_ed25519_pubkey(
2009 &self.db, &room_id, &fp,
2010 ) {
2011 Ok(Some(known)) if known != claimed_pubkey => {
2012 warn!(
2013 %fp, %room_id,
2014 "pubkey mismatch vs stored; dropping signed message"
2015 );
2016 return;
2017 }
2018 _ => {}
2019 }
2020 (m, Some(fp))
2021 }
2022 Err(e) => {
2023 warn!(%e, fp = %env.fingerprint, "signed envelope verify failed");
2024 return;
2025 }
2026 }
2027 }
2028 };
2029 self.handle_room_message(&room_id, msg, verified_signer).await;
2030 }
2031 NetworkEvent::DialSucceeded { peer_id, address } => {
2032 let addr_s = address.to_string();
2033 self.connected_dial_addrs
2034 .lock()
2035 .unwrap()
2036 .insert(addr_s.clone(), peer_id);
2037 let _ = repo::upsert_known_peer(
2041 &self.db,
2042 &KnownPeer {
2043 address: addr_s.clone(),
2044 label: None,
2045 last_connected_at: Some(now_unix()),
2046 last_attempt_at: Some(now_unix()),
2047 created_at: now_unix(),
2048 fingerprint: None,
2049 trusted: false,
2050 },
2051 );
2052 let _ = self.app_event_tx.send(AppEvent::DialSucceeded {
2053 address: addr_s,
2054 peer_id,
2055 });
2056 }
2057 NetworkEvent::DialFailed { address, error } => {
2058 let addr_s = address.to_string();
2059 let _ = self.app_event_tx.send(AppEvent::DialFailed {
2060 address: addr_s,
2061 error,
2062 });
2063 }
2064 NetworkEvent::PeerIdentified { peer_id, fingerprint } => {
2065 let matched_addrs: Vec<String> = {
2071 let map = self.connected_dial_addrs.lock().unwrap();
2072 map.iter()
2073 .filter_map(|(addr, pid)| {
2074 if *pid == peer_id {
2075 Some(addr.clone())
2076 } else {
2077 None
2078 }
2079 })
2080 .collect()
2081 };
2082 let mismatch = {
2092 let mut map = self.pending_invite_dials.lock().unwrap();
2093 let mut found: Option<(String, String)> = None;
2094 for addr in &matched_addrs {
2095 if let Some(claimed) = map.remove(addr) {
2096 if claimed != fingerprint {
2097 found = Some((addr.clone(), claimed));
2098 break;
2099 }
2100 }
2101 }
2102 found
2103 };
2104 if let Some((addr, claimed)) = mismatch {
2105 warn!(
2106 %addr, %claimed, actual=%fingerprint,
2107 "invite fingerprint mismatch — disconnecting"
2108 );
2109 self.network.disconnect_peer(peer_id).await;
2110 let _ = self.app_event_tx.send(AppEvent::InviteFingerprintMismatch {
2111 address: addr,
2112 claimed,
2113 actual: fingerprint.clone(),
2114 });
2115 return;
2116 }
2117 let should_auto_dm = {
2124 let mut pending = self.pending_auto_dm_addrs.lock().unwrap();
2125 let mut any_matched = false;
2126 for addr in &matched_addrs {
2127 if pending.remove(addr) {
2128 any_matched = true;
2129 }
2130 }
2131 any_matched
2132 };
2133 for addr in matched_addrs {
2134 let _ = repo::upsert_known_peer(
2135 &self.db,
2136 &KnownPeer {
2137 address: addr,
2138 label: None,
2139 last_connected_at: Some(now_unix()),
2140 last_attempt_at: Some(now_unix()),
2141 created_at: now_unix(),
2142 fingerprint: Some(fingerprint.clone()),
2143 trusted: true,
2144 },
2145 );
2146 }
2147 let blocked = repo::is_peer_blocked(&self.db, &fingerprint).unwrap_or(false);
2160 if should_auto_dm && !blocked && fingerprint != self.identity.fingerprint() {
2161 match self.start_direct(&fingerprint).await {
2162 Ok(room_id) => {
2163 let _ = self.app_event_tx.send(AppEvent::AutoOpenDm {
2164 room_id,
2165 fingerprint: fingerprint.clone(),
2166 });
2167 }
2168 Err(e) => {
2169 debug!(%e, fp = %fingerprint, "auto-DM after dial failed");
2170 }
2171 }
2172 }
2173 let our_username = repo::get_display_name(&self.db).unwrap_or(None);
2181 if our_username.is_some() {
2182 let now_ms = now_unix_ms();
2183 let should_send = {
2184 let mut last = self.last_profile_broadcast_at_ms.lock().unwrap();
2185 match last.get(&fingerprint) {
2186 Some(prev) if now_ms - prev < PROFILE_REBROADCAST_FLOOR_MS => false,
2187 _ => {
2188 last.insert(fingerprint.clone(), now_ms);
2189 true
2190 }
2191 }
2192 };
2193 if should_send {
2194 let msg = RoomMessage::ProfileUpdate {
2195 sender_fingerprint: self.identity.fingerprint().to_string(),
2196 username: our_username,
2197 updated_at: now_ms,
2198 };
2199 if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
2200 if let Ok(bytes) =
2201 crate::network::protocol::encode_wire_signed(&env)
2202 {
2203 let rooms: Vec<String> = self
2204 .active_rooms
2205 .lock()
2206 .unwrap()
2207 .keys()
2208 .cloned()
2209 .collect();
2210 for room_id in rooms {
2211 self.network
2212 .publish_room_message(room_id, bytes.clone())
2213 .await;
2214 }
2215 }
2216 }
2217 }
2218 }
2219 }
2220 NetworkEvent::RelayReservationEstablished { address } => {
2221 info!(addr = %address, "relay reservation established");
2226 self.relay_circuit_addrs
2227 .lock()
2228 .unwrap()
2229 .insert(address.to_string());
2230 let _ = self.app_event_tx.send(AppEvent::ListeningOn {
2231 address: address.to_string(),
2232 });
2233 }
2234 NetworkEvent::NatProbeResult {
2235 tested_addr,
2236 reachable,
2237 } => {
2238 let addr_s = tested_addr.to_string();
2239 let (transitioned, becomes_reachable) = {
2240 let mut set = self.nat_reachable_addrs.lock().unwrap();
2241 let was_empty = set.is_empty();
2242 if reachable {
2243 set.insert(addr_s.clone());
2244 } else {
2245 set.remove(&addr_s);
2246 }
2247 let is_empty = set.is_empty();
2248 (was_empty != is_empty, !is_empty)
2249 };
2250 if transitioned {
2251 let label = if becomes_reachable {
2252 "reachable".to_string()
2253 } else {
2254 "private".to_string()
2255 };
2256 info!(reachable = %becomes_reachable, "NAT reachability changed");
2257 let _ = self.app_event_tx.send(AppEvent::NatStatusChanged {
2258 label,
2259 reachable: becomes_reachable,
2260 });
2261 }
2262 }
2263 NetworkEvent::DcutrUpgrade {
2264 remote_peer,
2265 success,
2266 } => {
2267 if success {
2268 let s = remote_peer.to_base58();
2272 let tail: String = s.chars().rev().take(8).collect::<String>()
2273 .chars()
2274 .rev()
2275 .collect();
2276 let _ = self.app_event_tx.send(AppEvent::DcutrSucceeded {
2277 peer_label: tail,
2278 });
2279 }
2280 }
2281 NetworkEvent::InboundDial {
2282 peer_id,
2283 fingerprint,
2284 address,
2285 } => {
2286 if repo::is_peer_blocked(&self.db, &fingerprint).unwrap_or(false) {
2288 info!(%fingerprint, "inbound dial auto-rejected: peer is blocked");
2289 self.network.reject_inbound(peer_id).await;
2290 return;
2291 }
2292 let global_verified_only =
2297 repo::get_setting(&self.db, "verified_only_inbound")
2298 .ok()
2299 .flatten()
2300 .map(|v| v == "1")
2301 .unwrap_or(false);
2302 if global_verified_only {
2303 let is_verified =
2304 repo::is_globally_verified(&self.db, &fingerprint).unwrap_or(false)
2305 || repo::is_fingerprint_trusted(&self.db, &fingerprint)
2306 .unwrap_or(false);
2307 if !is_verified {
2308 info!(
2309 %fingerprint,
2310 "inbound dial auto-rejected: verified-only mode"
2311 );
2312 self.network.reject_inbound(peer_id).await;
2313 return;
2314 }
2315 }
2316 if repo::is_fingerprint_trusted(&self.db, &fingerprint).unwrap_or(false) {
2317 info!(%fingerprint, "inbound dial auto-accepted: peer is trusted");
2318 self.connected_dial_addrs
2321 .lock()
2322 .unwrap()
2323 .insert(address.to_string(), peer_id);
2324 let _ = repo::upsert_known_peer(
2325 &self.db,
2326 &KnownPeer {
2327 address: address.to_string(),
2328 label: None,
2329 last_connected_at: Some(now_unix()),
2330 last_attempt_at: Some(now_unix()),
2331 created_at: now_unix(),
2332 fingerprint: Some(fingerprint),
2333 trusted: true,
2334 },
2335 );
2336 self.network.accept_inbound(peer_id).await;
2337 return;
2338 }
2339 let _ = self.app_event_tx.send(AppEvent::InboundDial {
2341 peer_id,
2342 fingerprint,
2343 address: address.to_string(),
2344 });
2345 }
2346 }
2347 }
2348
2349 async fn handle_room_message(
2355 &self,
2356 room_id: &str,
2357 msg: RoomMessage,
2358 verified_signer: Option<String>,
2359 ) {
2360 let our_fp = self.identity.fingerprint().to_string();
2361 match msg {
2362 RoomMessage::MemberAnnounce {
2363 sender_fingerprint,
2364 wrapped_session_key,
2365 display_name,
2366 sender_ed25519_pubkey,
2367 } => {
2368 if sender_fingerprint == our_fp {
2369 return;
2370 }
2371 let signer = match verified_signer {
2381 Some(fp) => fp,
2382 None => {
2383 warn!(%sender_fingerprint, %room_id, "MemberAnnounce arrived unsigned; dropping");
2384 return;
2385 }
2386 };
2387 if signer != sender_fingerprint {
2388 warn!(%signer, %sender_fingerprint, %room_id, "MemberAnnounce signer mismatch; dropping");
2389 return;
2390 }
2391 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2394 .unwrap_or(false)
2395 {
2396 info!(%sender_fingerprint, %room_id, "dropping MemberAnnounce from banned peer");
2397 return;
2398 }
2399 if repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
2406 && !repo::is_globally_verified(&self.db, &sender_fingerprint).unwrap_or(false)
2407 {
2408 info!(
2409 %sender_fingerprint, %room_id,
2410 "dropping MemberAnnounce: room is verified-only and joiner isn't verified"
2411 );
2412 let owners = repo::list_room_owners(&self.db, room_id).unwrap_or_default();
2413 let lowest_owner = owners.iter().min().cloned();
2414 if lowest_owner.as_deref() == Some(&our_fp) {
2415 let msg = RoomMessage::JoinRefused {
2416 room_id: room_id.to_string(),
2417 target_fingerprint: sender_fingerprint.clone(),
2418 reason: "room requires SAS verification — ask an existing member to verify you".into(),
2419 };
2420 if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
2421 if let Ok(bytes) =
2422 crate::network::protocol::encode_wire_signed(&env)
2423 {
2424 self.network
2425 .publish_room_message(room_id.to_string(), bytes)
2426 .await;
2427 }
2428 }
2429 }
2430 return;
2431 }
2432 let need_inbound = {
2433 let mut rooms = self.active_rooms.lock().unwrap();
2434 let room = match rooms.get_mut(room_id) {
2435 Some(r) => r,
2436 None => return,
2437 };
2438 if room.info.kind == RoomKind::Direct
2446 && !room.members.contains(&sender_fingerprint)
2447 && room.members.len() >= 2
2448 {
2449 info!(
2450 %sender_fingerprint, %room_id,
2451 "dropping MemberAnnounce on Direct room: already at 2-member cap"
2452 );
2453 return;
2454 }
2455 let newly_added = room.members.insert(sender_fingerprint.clone());
2456 if newly_added {
2457 let _ = self.app_event_tx.send(AppEvent::MemberJoined {
2458 room_id: room_id.to_string(),
2459 fingerprint: sender_fingerprint.clone(),
2460 });
2461 }
2462 let _ = repo::upsert_room_member(
2467 &self.db,
2468 &StoredRoomMember {
2469 room_id: room_id.to_string(),
2470 peer_id: String::new(), fingerprint: sender_fingerprint.clone(),
2472 last_seen: Some(now_unix()),
2473 verified: false,
2474 ed25519_pubkey: sender_ed25519_pubkey.clone(),
2475 role: "member".into(),
2481 },
2482 );
2483 if let Some(name) = display_name.as_deref() {
2484 let _ = repo::set_member_display_name(
2485 &self.db,
2486 room_id,
2487 &sender_fingerprint,
2488 Some(name),
2489 );
2490 }
2491 room.info.encrypted && wrapped_session_key.is_some()
2492 };
2493
2494 if matches!(
2501 self.active_rooms
2502 .lock()
2503 .unwrap()
2504 .get(room_id)
2505 .map(|r| (r.info.kind, r.passphrase_key.is_none())),
2506 Some((RoomKind::Direct, true))
2507 ) {
2508 if let Some(pubkey_b64) = sender_ed25519_pubkey.as_deref() {
2509 if let Some(key) =
2510 self.derive_dm_key_from_pubkey_b64(room_id, pubkey_b64)
2511 {
2512 let mut rooms = self.active_rooms.lock().unwrap();
2513 if let Some(room) = rooms.get_mut(room_id) {
2514 room.passphrase_key = Some(key);
2515 }
2516 drop(rooms);
2517 let app = self.clone();
2522 let rid = room_id.to_string();
2523 tokio::spawn(async move {
2524 if let Err(e) = app.broadcast_member_announce(&rid).await {
2525 warn!(%e, "re-broadcast DM announce after key derivation");
2526 }
2527 });
2528 }
2529 }
2530 }
2531
2532 if need_inbound {
2533 let wrapped = wrapped_session_key.unwrap();
2534 let result = {
2535 let mut rooms = self.active_rooms.lock().unwrap();
2536 let room = rooms.get_mut(room_id).unwrap();
2537 let passphrase_key = match &room.passphrase_key {
2538 Some(k) => k,
2539 None => {
2540 warn!("no passphrase key when receiving session key");
2541 return;
2542 }
2543 };
2544 match passphrase::unwrap(&wrapped, passphrase_key) {
2545 Ok(plain) => match String::from_utf8(plain) {
2546 Ok(key_b64) => {
2547 let crypto = room.crypto.as_mut().unwrap();
2548 crypto.add_inbound_session(&sender_fingerprint, &key_b64)
2549 }
2550 Err(e) => Err(HuddleError::Session(format!("utf8: {e}"))),
2551 },
2552 Err(e) => Err(e),
2553 }
2554 };
2555 if let Err(e) = result {
2556 error!(%e, "add inbound session failed");
2557 }
2558 }
2559 }
2560 RoomMessage::SessionKeyRequest {
2561 requester_fingerprint,
2562 } => {
2563 if requester_fingerprint == our_fp {
2564 return;
2565 }
2566 if let Err(e) = self.broadcast_member_announce(room_id).await {
2568 warn!(%e, "broadcast member announce on request");
2569 }
2570 }
2571 RoomMessage::Encrypted {
2572 sender_fingerprint,
2573 session_id,
2574 ciphertext_b64,
2575 } => {
2576 if sender_fingerprint == our_fp {
2577 return;
2578 }
2579 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2587 .unwrap_or(false)
2588 {
2589 debug!(%sender_fingerprint, %room_id, "dropping Encrypted from banned peer");
2590 return;
2591 }
2592 let ct_bytes = match base64::Engine::decode(
2593 &base64::engine::general_purpose::STANDARD,
2594 &ciphertext_b64,
2595 ) {
2596 Ok(b) => b,
2597 Err(e) => {
2598 warn!(%e, "bad base64 ciphertext");
2599 return;
2600 }
2601 };
2602 let plaintext = {
2603 let mut rooms = self.active_rooms.lock().unwrap();
2604 let room = match rooms.get_mut(room_id) {
2605 Some(r) => r,
2606 None => return,
2607 };
2608 let crypto = match room.crypto.as_mut() {
2609 Some(c) => c,
2610 None => return,
2611 };
2612 crypto.decrypt(&sender_fingerprint, &session_id, &ct_bytes)
2613 };
2614 match plaintext {
2615 Ok(pt) => {
2616 let body = String::from_utf8_lossy(&pt).to_string();
2617 let sent_at = now_unix();
2618 let _ = repo::insert_room_message(
2619 &self.db,
2620 room_id,
2621 &sender_fingerprint,
2622 "in",
2623 &body,
2624 sent_at,
2625 );
2626 let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
2627 self.maybe_emit_mention(room_id, &body);
2628 let _ = self.app_event_tx.send(AppEvent::MessageReceived {
2629 room_id: room_id.to_string(),
2630 sender_fingerprint,
2631 body,
2632 sent_at,
2633 });
2634 }
2635 Err(e) => {
2636 debug!(%e, "decrypt failed (probably missing session key)");
2637 }
2638 }
2639 }
2640 RoomMessage::Plain {
2641 sender_fingerprint,
2642 body,
2643 } => {
2644 if sender_fingerprint == our_fp {
2645 return;
2646 }
2647 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2648 .unwrap_or(false)
2649 {
2650 debug!(%sender_fingerprint, %room_id, "dropping Plain from banned peer");
2651 return;
2652 }
2653 let sent_at = now_unix();
2654 let _ = repo::insert_room_message(
2655 &self.db,
2656 room_id,
2657 &sender_fingerprint,
2658 "in",
2659 &body,
2660 sent_at,
2661 );
2662 let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
2663 self.maybe_emit_mention(room_id, &body);
2664 let _ = self.app_event_tx.send(AppEvent::MessageReceived {
2665 room_id: room_id.to_string(),
2666 sender_fingerprint,
2667 body,
2668 sent_at,
2669 });
2670 }
2671 RoomMessage::Typing { sender_fingerprint } => {
2672 if sender_fingerprint == our_fp {
2673 return;
2674 }
2675 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2676 .unwrap_or(false)
2677 {
2678 return;
2679 }
2680 let expiry = now_unix() + TYPING_TTL_SECS;
2681 let mut rooms = self.active_rooms.lock().unwrap();
2682 if let Some(room) = rooms.get_mut(room_id) {
2683 room.typers.insert(sender_fingerprint, expiry);
2684 }
2685 drop(rooms);
2686 let _ = self.app_event_tx.send(AppEvent::TypingChanged {
2687 room_id: room_id.to_string(),
2688 });
2689 }
2690 RoomMessage::RotateRoomKey {
2691 rotator_fingerprint,
2692 new_salt,
2693 } => {
2694 if rotator_fingerprint == our_fp {
2695 return;
2696 }
2697 let signer = match verified_signer {
2702 Some(fp) => fp,
2703 None => {
2704 warn!(%room_id, "RotateRoomKey arrived unsigned; dropping");
2705 return;
2706 }
2707 };
2708 if signer != rotator_fingerprint {
2709 warn!(
2710 %signer, %rotator_fingerprint, %room_id,
2711 "RotateRoomKey signer mismatch with claimed rotator; dropping"
2712 );
2713 return;
2714 }
2715 let _ = self.app_event_tx.send(AppEvent::RotationRequested {
2716 room_id: room_id.to_string(),
2717 rotator_fingerprint,
2718 new_salt,
2719 });
2720 }
2721 RoomMessage::MemberLeave { sender_fingerprint } => {
2722 if sender_fingerprint == our_fp {
2723 return;
2724 }
2725 let signer = match verified_signer {
2729 Some(fp) => fp,
2730 None => {
2731 warn!(%sender_fingerprint, %room_id, "MemberLeave arrived unsigned; dropping");
2732 return;
2733 }
2734 };
2735 if signer != sender_fingerprint {
2736 warn!(%signer, %sender_fingerprint, %room_id, "MemberLeave signer mismatch; dropping");
2737 return;
2738 }
2739 let removed = {
2740 let mut rooms = self.active_rooms.lock().unwrap();
2741 if let Some(room) = rooms.get_mut(room_id) {
2742 room.members.remove(&sender_fingerprint)
2743 } else {
2744 false
2745 }
2746 };
2747 if removed {
2748 let _ = self.app_event_tx.send(AppEvent::MemberLeft {
2749 room_id: room_id.to_string(),
2750 fingerprint: sender_fingerprint,
2751 });
2752 }
2753 }
2754 RoomMessage::FileOffer {
2755 sender_fingerprint,
2756 file_id,
2757 name,
2758 size_bytes,
2759 mime,
2760 chunk_count,
2761 encrypted_meta,
2762 } => {
2763 if sender_fingerprint == our_fp {
2764 return; }
2766 let signer = match verified_signer {
2771 Some(fp) => fp,
2772 None => {
2773 warn!(%sender_fingerprint, %room_id, %file_id, "FileOffer arrived unsigned; dropping");
2774 return;
2775 }
2776 };
2777 if signer != sender_fingerprint {
2778 warn!(%signer, %sender_fingerprint, %room_id, %file_id, "FileOffer signer mismatch; dropping");
2779 return;
2780 }
2781 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2784 .unwrap_or(false)
2785 {
2786 info!(%sender_fingerprint, %room_id, %file_id, "dropping FileOffer from banned peer");
2787 return;
2788 }
2789 self.handle_file_offer(
2790 room_id,
2791 sender_fingerprint,
2792 file_id,
2793 name,
2794 size_bytes,
2795 mime,
2796 chunk_count,
2797 encrypted_meta,
2798 );
2799 }
2800 RoomMessage::FileChunk {
2801 sender_fingerprint,
2802 file_id,
2803 chunk_index,
2804 total_chunks,
2805 data_b64,
2806 } => {
2807 if sender_fingerprint == our_fp {
2808 return;
2809 }
2810 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2811 .unwrap_or(false)
2812 {
2813 return;
2814 }
2815 self.handle_file_chunk(
2816 room_id,
2817 sender_fingerprint,
2818 file_id,
2819 chunk_index,
2820 total_chunks,
2821 data_b64,
2822 );
2823 }
2824 RoomMessage::OwnerGrant {
2825 room_id: announced_room_id,
2826 target_fingerprint,
2827 } => {
2828 if announced_room_id != room_id {
2833 warn!(payload_room = %announced_room_id, topic_room = %room_id, "OwnerGrant room mismatch");
2834 return;
2835 }
2836 let signer = match verified_signer {
2837 Some(fp) => fp,
2838 None => {
2839 warn!(%room_id, "OwnerGrant arrived unsigned; dropping");
2840 return;
2841 }
2842 };
2843 if !self.is_owner(room_id, &signer) {
2844 warn!(%signer, %room_id, "OwnerGrant signer isn't an owner; dropping");
2845 return;
2846 }
2847 info!(%signer, %target_fingerprint, %room_id, "OwnerGrant applied");
2848 if let Err(e) =
2849 repo::set_member_role(&self.db, room_id, &target_fingerprint, "owner")
2850 {
2851 warn!(%e, "OwnerGrant: set_member_role failed");
2852 }
2853 }
2854 RoomMessage::BanMember {
2855 room_id: announced_room_id,
2856 target_fingerprint,
2857 } => {
2858 if announced_room_id != room_id {
2859 warn!(payload_room = %announced_room_id, topic_room = %room_id, "BanMember room mismatch");
2860 return;
2861 }
2862 let signer = match verified_signer {
2863 Some(fp) => fp,
2864 None => {
2865 warn!(%room_id, "BanMember arrived unsigned; dropping");
2866 return;
2867 }
2868 };
2869 if !self.is_owner(room_id, &signer) {
2870 warn!(%signer, %room_id, "BanMember signer isn't an owner; dropping");
2871 return;
2872 }
2873 if target_fingerprint == our_fp {
2874 info!(%room_id, %signer, "we were kicked from this room");
2880 self.active_rooms.lock().unwrap().remove(room_id);
2881 let _ = self.app_event_tx.send(AppEvent::RoomLeft {
2882 room_id: room_id.to_string(),
2883 });
2884 return;
2885 }
2886 info!(%signer, %target_fingerprint, %room_id, "BanMember applied");
2887 if let Err(e) = repo::add_room_ban(
2888 &self.db,
2889 room_id,
2890 &target_fingerprint,
2891 &signer,
2892 "", now_unix(),
2894 ) {
2895 warn!(%e, "BanMember: add_room_ban failed");
2896 }
2897 self.evict_banned_member(room_id, &target_fingerprint);
2898 }
2899 RoomMessage::SasInit {
2900 tx_id,
2901 ephemeral_x25519_pubkey_b64,
2902 target_fingerprint,
2903 } => {
2904 if target_fingerprint != our_fp {
2905 return;
2910 }
2911 let signer = match verified_signer {
2912 Some(fp) => fp,
2913 None => {
2914 warn!("SasInit arrived unsigned; dropping");
2915 return;
2916 }
2917 };
2918 let their_pub =
2919 match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
2920 Ok(pk) => pk,
2921 Err(e) => {
2922 warn!(%e, "SasInit: bad x25519 pubkey");
2923 return;
2924 }
2925 };
2926 let tx_id_bytes = match B64.decode(&tx_id) {
2927 Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
2928 let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
2929 arr.copy_from_slice(&b);
2930 arr
2931 }
2932 _ => {
2933 warn!(%tx_id, "SasInit: bad tx_id length");
2934 return;
2935 }
2936 };
2937 let (_, our_secret, our_pub) = crate::crypto::sas::new_session();
2938 let sas_code =
2939 crate::crypto::sas::derive_sas_code(&our_secret, &their_pub, &tx_id_bytes);
2940 self.sas_flows.lock().unwrap().insert(
2941 tx_id.clone(),
2942 SasFlow {
2943 room_id: room_id.to_string(),
2944 partner_fingerprint: signer.clone(),
2945 our_secret,
2946 sas_code: Some(sas_code.clone()),
2947 our_confirmed: false,
2948 their_confirmed: false,
2949 finalized: false,
2950 },
2951 );
2952 let response = RoomMessage::SasResponse {
2955 tx_id: tx_id.clone(),
2956 ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2957 };
2958 if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
2959 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
2960 self.network
2961 .publish_room_message(room_id.to_string(), bytes)
2962 .await;
2963 }
2964 }
2965 let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
2966 room_id: room_id.to_string(),
2967 partner_fingerprint: signer,
2968 tx_id,
2969 emoji_string: sas_code.emoji_string(),
2970 emoji_labels: sas_code.emoji_labels(),
2971 decimal: sas_code.decimal,
2972 });
2973 }
2974 RoomMessage::SasResponse {
2975 tx_id,
2976 ephemeral_x25519_pubkey_b64,
2977 } => {
2978 let signer = match verified_signer {
2979 Some(fp) => fp,
2980 None => {
2981 warn!("SasResponse arrived unsigned; dropping");
2982 return;
2983 }
2984 };
2985 let their_pub =
2986 match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
2987 Ok(pk) => pk,
2988 Err(e) => {
2989 warn!(%e, "SasResponse: bad x25519 pubkey");
2990 return;
2991 }
2992 };
2993 let tx_id_bytes = match B64.decode(&tx_id) {
2994 Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
2995 let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
2996 arr.copy_from_slice(&b);
2997 arr
2998 }
2999 _ => return,
3000 };
3001 let emit = {
3002 let mut flows = self.sas_flows.lock().unwrap();
3003 let flow = match flows.get_mut(&tx_id) {
3004 Some(f) => f,
3005 None => {
3006 warn!(%tx_id, "SasResponse for unknown tx_id");
3007 return;
3008 }
3009 };
3010 if flow.partner_fingerprint != signer {
3011 warn!(
3012 expected = %flow.partner_fingerprint, got = %signer,
3013 "SasResponse signer doesn't match flow's partner; dropping"
3014 );
3015 return;
3016 }
3017 let code = crate::crypto::sas::derive_sas_code(
3018 &flow.our_secret,
3019 &their_pub,
3020 &tx_id_bytes,
3021 );
3022 flow.sas_code = Some(code.clone());
3023 code
3024 };
3025 let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
3026 room_id: room_id.to_string(),
3027 partner_fingerprint: signer,
3028 tx_id,
3029 emoji_string: emit.emoji_string(),
3030 emoji_labels: emit.emoji_labels(),
3031 decimal: emit.decimal,
3032 });
3033 }
3034 RoomMessage::CodeJoinRequest {
3035 room_id: announced_room_id,
3036 joiner_x25519_pubkey_b64,
3037 code,
3038 } => {
3039 if announced_room_id != room_id {
3040 return;
3041 }
3042 let joiner_fp = match verified_signer {
3043 Some(fp) => fp,
3044 None => {
3045 warn!("CodeJoinRequest unsigned; dropping");
3046 return;
3047 }
3048 };
3049 let our_fp = self.identity.fingerprint().to_string();
3053 if !self.is_owner(room_id, &our_fp) {
3054 return;
3055 }
3056 let now = now_unix();
3058 let (code_ok, our_session_id, wrap_input) = {
3059 let mut rooms = self.active_rooms.lock().unwrap();
3060 let room = match rooms.get_mut(room_id) {
3061 Some(r) => r,
3062 None => return,
3063 };
3064 if room.passphrase_key.is_none() {
3065 warn!("CodeJoinRequest: no passphrase key locally; can't respond");
3066 return;
3067 }
3068 let original_len = room.issued_codes.len();
3069 room.issued_codes.retain(|(c, exp)| !(c == &code && *exp > now));
3070 let matched = room.issued_codes.len() < original_len;
3071 if !matched {
3072 info!(%joiner_fp, "CodeJoinRequest: code invalid or expired; ignoring");
3073 return;
3074 }
3075 let crypto = room.crypto.as_ref().unwrap();
3076 (
3077 true,
3078 crypto.our_session_id(),
3079 crypto.our_session_key_b64(),
3080 )
3081 };
3082 let _ = code_ok;
3083 let their_pub = match crate::crypto::sas::parse_pubkey(&joiner_x25519_pubkey_b64) {
3085 Ok(pk) => pk,
3086 Err(e) => {
3087 warn!(%e, "CodeJoinRequest: bad pubkey");
3088 return;
3089 }
3090 };
3091 use x25519_dalek::{PublicKey, StaticSecret};
3092 let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
3093 let our_pub = PublicKey::from(&our_secret);
3094 let shared = our_secret.diffie_hellman(&their_pub);
3095 let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
3097 let mut wrap_key = [0u8; passphrase::KEY_LEN];
3098 hk.expand(b"huddle-code-join-v1", &mut wrap_key)
3099 .expect("32 bytes is within HKDF limits");
3100 let wrapped = match passphrase::wrap(wrap_input.as_bytes(), &wrap_key) {
3103 Ok(w) => w,
3104 Err(e) => {
3105 warn!(%e, "CodeJoinRequest: wrap failed");
3106 return;
3107 }
3108 };
3109 let response = RoomMessage::CodeJoinResponse {
3110 room_id: room_id.to_string(),
3111 target_fingerprint: joiner_fp.clone(),
3112 owner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
3113 owner_session_id: our_session_id,
3114 wrapped_session_key_b64: wrapped,
3115 nonce_b64: String::new(), };
3117 if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
3118 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
3119 self.network
3120 .publish_room_message(room_id.to_string(), bytes)
3121 .await;
3122 }
3123 }
3124 info!(%joiner_fp, %room_id, "issued CodeJoinResponse");
3125 }
3126 RoomMessage::CodeJoinResponse {
3127 room_id: announced_room_id,
3128 target_fingerprint,
3129 owner_x25519_pubkey_b64,
3130 owner_session_id,
3131 wrapped_session_key_b64,
3132 nonce_b64: _,
3133 } => {
3134 if announced_room_id != room_id || target_fingerprint != our_fp {
3135 return;
3136 }
3137 let owner_fp = match verified_signer {
3138 Some(fp) => fp,
3139 None => {
3140 warn!("CodeJoinResponse unsigned; dropping");
3141 return;
3142 }
3143 };
3144 let our_secret = match self
3145 .pending_code_secrets
3146 .lock()
3147 .unwrap()
3148 .remove(&(room_id.to_string(), our_fp.clone()))
3149 {
3150 Some(s) => s,
3151 None => {
3152 warn!(%room_id, "CodeJoinResponse with no pending code-join state");
3153 return;
3154 }
3155 };
3156 let owner_pub = match crate::crypto::sas::parse_pubkey(&owner_x25519_pubkey_b64) {
3157 Ok(pk) => pk,
3158 Err(e) => {
3159 warn!(%e, "CodeJoinResponse: bad owner pubkey");
3160 return;
3161 }
3162 };
3163 let shared = our_secret.diffie_hellman(&owner_pub);
3164 let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
3165 let mut wrap_key = [0u8; passphrase::KEY_LEN];
3166 hk.expand(b"huddle-code-join-v1", &mut wrap_key)
3167 .expect("32 bytes within HKDF limits");
3168 let session_key_bytes =
3169 match passphrase::unwrap(&wrapped_session_key_b64, &wrap_key) {
3170 Ok(b) => b,
3171 Err(e) => {
3172 warn!(%e, "CodeJoinResponse: unwrap failed");
3173 return;
3174 }
3175 };
3176 let session_key_str = match String::from_utf8(session_key_bytes) {
3177 Ok(s) => s,
3178 Err(e) => {
3179 warn!(%e, "CodeJoinResponse: session key wasn't valid utf8");
3180 return;
3181 }
3182 };
3183 let mut rooms = self.active_rooms.lock().unwrap();
3185 if let Some(room) = rooms.get_mut(room_id) {
3186 if let Some(crypto) = room.crypto.as_mut() {
3187 if let Err(e) =
3188 crypto.add_inbound_session(&owner_fp, &session_key_str)
3189 {
3190 warn!(%e, "CodeJoinResponse: add_inbound_session failed");
3191 } else {
3192 info!(%room_id, %owner_fp, %owner_session_id, "code-join completed; can decrypt owner's messages");
3193 room.members.insert(owner_fp.clone());
3194 let _ = self.app_event_tx.send(AppEvent::MemberJoined {
3195 room_id: room_id.to_string(),
3196 fingerprint: owner_fp,
3197 });
3198 }
3199 }
3200 }
3201 }
3202 RoomMessage::JoinRefused {
3203 room_id: announced_room_id,
3204 target_fingerprint,
3205 reason,
3206 } => {
3207 if announced_room_id != room_id || target_fingerprint != our_fp {
3208 return;
3209 }
3210 let _ = self.app_event_tx.send(AppEvent::Error {
3214 description: format!("join refused: {reason}"),
3215 });
3216 }
3217 RoomMessage::SasConfirm { tx_id, matched } => {
3218 let signer = match verified_signer {
3219 Some(fp) => fp,
3220 None => return,
3221 };
3222 let (room_id_done, partner_fp_done, both_done) = {
3223 let mut flows = self.sas_flows.lock().unwrap();
3224 let flow = match flows.get_mut(&tx_id) {
3225 Some(f) => f,
3226 None => return,
3227 };
3228 if flow.partner_fingerprint != signer {
3229 return;
3230 }
3231 if !matched {
3232 let _ = flow;
3234 flows.remove(&tx_id);
3235 return;
3236 }
3237 flow.their_confirmed = true;
3238 if flow.our_confirmed && flow.their_confirmed && !flow.finalized {
3245 flow.finalized = true;
3246 (
3247 Some(flow.room_id.clone()),
3248 Some(flow.partner_fingerprint.clone()),
3249 true,
3250 )
3251 } else {
3252 (None, None, false)
3253 }
3254 };
3255 if both_done {
3256 if let (Some(rid), Some(pfp)) = (room_id_done, partner_fp_done) {
3257 if let Err(e) = self.finish_sas(&tx_id, &rid, &pfp).await {
3258 warn!(%e, "finish_sas failed");
3259 }
3260 }
3261 }
3262 }
3263 RoomMessage::ProfileUpdate {
3264 sender_fingerprint,
3265 username,
3266 updated_at,
3267 } => {
3268 let signer = match verified_signer {
3274 Some(fp) => fp,
3275 None => {
3276 warn!(
3277 sender = %sender_fingerprint,
3278 "dropping unsigned ProfileUpdate"
3279 );
3280 return;
3281 }
3282 };
3283 if signer != sender_fingerprint {
3284 warn!(
3285 signer = %signer,
3286 claimed = %sender_fingerprint,
3287 "dropping ProfileUpdate with signer != sender"
3288 );
3289 return;
3290 }
3291 if let Err(e) = repo::upsert_peer_profile(
3292 &self.db,
3293 &sender_fingerprint,
3294 username.as_deref(),
3295 updated_at,
3296 ) {
3297 warn!(%e, "upsert_peer_profile failed");
3298 return;
3299 }
3300 let _ = self.app_event_tx.send(AppEvent::PeerProfileUpdated {
3301 fingerprint: sender_fingerprint,
3302 username,
3303 });
3304 }
3305 }
3306 }
3307
3308 pub async fn send_file(&self, room_id: &str, path: &Path) -> Result<String> {
3316 let bytes = std::fs::read(path)?;
3317 let name = path
3318 .file_name()
3319 .map(|n| n.to_string_lossy().to_string())
3320 .unwrap_or_else(|| "untitled".into());
3321 let mime = crate::files::guess_mime(&name);
3322 let original_path = path.to_path_buf();
3323
3324 let (room_encrypted, mut maybe_session_id, encrypted_meta_opt, wire_bytes) = {
3325 let mut rooms = self.active_rooms.lock().unwrap();
3326 let room = rooms
3327 .get_mut(room_id)
3328 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3329 if room.read_only {
3334 return Err(HuddleError::Other(
3335 "this room is read-only — you can't send files".into(),
3336 ));
3337 }
3338 if room.info.encrypted {
3339 let crypto = room
3340 .crypto
3341 .as_mut()
3342 .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
3343 let (ciphertext, meta) = file_encryption::encrypt_file(&bytes, crypto)?;
3344 (true, Some(meta.megolm_session_id.clone()), Some(meta), ciphertext)
3345 } else {
3346 (false, None, None, bytes)
3347 }
3348 };
3349 let _ = &mut maybe_session_id; let plan =
3352 self.file_manager
3353 .prepare_outgoing_from_bytes(&name, mime.clone(), wire_bytes)?;
3354 let file_id = plan.file_id.clone();
3355 let total = plan.chunks.len() as u32;
3356 let our_fp = self.identity.fingerprint().to_string();
3357
3358 let attachment = StoredAttachment {
3359 id: 0,
3360 room_id: room_id.to_string(),
3361 message_id: None,
3362 sender_fingerprint: our_fp.clone(),
3363 file_id: file_id.clone(),
3364 name: name.clone(),
3365 mime: mime.clone(),
3366 size_bytes: plan.size_bytes as i64,
3367 status: AttachmentStatus::Ready,
3368 cache_path: Some(self.file_manager.cache_path(&file_id).to_string_lossy().into()),
3369 saved_path: Some(original_path.to_string_lossy().into()),
3370 error: None,
3371 encrypted: room_encrypted,
3372 wrapped_key: encrypted_meta_opt.as_ref().map(|m| m.wrapped_key_b64.clone()),
3373 nonce: encrypted_meta_opt.as_ref().map(|m| m.nonce_b64.clone()),
3374 megolm_session_id: encrypted_meta_opt
3375 .as_ref()
3376 .map(|m| m.megolm_session_id.clone()),
3377 content_hash: encrypted_meta_opt.as_ref().map(|m| m.content_hash.clone()),
3378 created_at: now_unix(),
3379 };
3380 repo::upsert_attachment(&self.db, &attachment)?;
3381 let _ = self.app_event_tx.send(AppEvent::FileOffered {
3382 room_id: room_id.to_string(),
3383 file_id: file_id.clone(),
3384 name: name.clone(),
3385 size_bytes: plan.size_bytes,
3386 sender_fingerprint: our_fp.clone(),
3387 });
3388
3389 let offer = RoomMessage::FileOffer {
3396 sender_fingerprint: our_fp.clone(),
3397 file_id: file_id.clone(),
3398 name,
3399 size_bytes: plan.size_bytes,
3400 mime,
3401 chunk_count: total,
3402 encrypted_meta: encrypted_meta_opt,
3403 };
3404 if let Ok(env) = crate::crypto::sign_message(&self.identity, &offer) {
3405 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
3406 self.network
3407 .publish_room_message(room_id.to_string(), bytes)
3408 .await;
3409 }
3410 }
3411
3412 let net = self.network.clone();
3415 let room = room_id.to_string();
3416 let our = our_fp.clone();
3417 let fid = file_id.clone();
3418 let chunks = plan.chunks.clone();
3419 tokio::spawn(async move {
3420 for (i, data) in chunks.iter().enumerate() {
3421 let msg = RoomMessage::FileChunk {
3422 sender_fingerprint: our.clone(),
3423 file_id: fid.clone(),
3424 chunk_index: i as u32,
3425 total_chunks: total,
3426 data_b64: B64.encode(data),
3427 };
3428 if let Ok(bytes) = encode_wire(&msg) {
3429 net.publish_room_message(room.clone(), bytes).await;
3430 }
3431 tokio::time::sleep(Duration::from_millis(40)).await;
3432 }
3433 });
3434
3435 Ok(file_id)
3436 }
3437
3438 pub async fn save_to_downloads(&self, room_id: &str, file_id: &str) -> Result<PathBuf> {
3441 let attachment = repo::get_attachment(&self.db, room_id, file_id)?
3442 .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
3443 if !matches!(
3444 attachment.status,
3445 AttachmentStatus::Ready | AttachmentStatus::Saved
3446 ) {
3447 return Err(HuddleError::Other(format!(
3448 "attachment is not ready (status={})",
3449 attachment.status.as_str()
3450 )));
3451 }
3452 let plaintext = if attachment.encrypted
3457 && attachment.sender_fingerprint == self.identity.fingerprint()
3458 {
3459 match attachment
3460 .saved_path
3461 .as_deref()
3462 .filter(|p| Path::new(p).exists())
3463 {
3464 Some(src) => std::fs::read(src)?,
3465 None => {
3466 return Err(HuddleError::Other(
3467 "your original file has moved or been deleted — it can't be \
3468 recovered from the encrypted cache"
3469 .into(),
3470 ));
3471 }
3472 }
3473 } else {
3474 let cached = self.file_manager.read_cache(file_id)?;
3475 if attachment.encrypted {
3476 let meta = EncryptedFileMeta {
3477 megolm_session_id: attachment
3478 .megolm_session_id
3479 .clone()
3480 .ok_or_else(|| HuddleError::Other("missing megolm_session_id".into()))?,
3481 wrapped_key_b64: attachment
3482 .wrapped_key
3483 .clone()
3484 .ok_or_else(|| HuddleError::Other("missing wrapped_key".into()))?,
3485 nonce_b64: attachment
3486 .nonce
3487 .clone()
3488 .ok_or_else(|| HuddleError::Other("missing nonce".into()))?,
3489 content_hash: attachment
3490 .content_hash
3491 .clone()
3492 .ok_or_else(|| HuddleError::Other("missing content_hash".into()))?,
3493 };
3494 self.decrypt_attachment(
3495 room_id,
3496 &attachment.sender_fingerprint,
3497 &cached,
3498 &meta,
3499 )?
3500 } else {
3501 cached
3502 }
3503 };
3504 let saved = self.file_manager.write_to_downloads(&attachment.name, &plaintext)?;
3505 repo::update_attachment_paths(
3506 &self.db,
3507 room_id,
3508 file_id,
3509 None,
3510 Some(&saved.to_string_lossy()),
3511 )?;
3512 repo::update_attachment_status(&self.db, room_id, file_id, AttachmentStatus::Saved, None)?;
3513 let _ = self.app_event_tx.send(AppEvent::FileSaved {
3514 file_id: file_id.into(),
3515 path: saved.to_string_lossy().into(),
3516 });
3517 Ok(saved)
3518 }
3519
3520 pub async fn cancel_transfer(&self, room_id: &str, file_id: &str) -> Result<()> {
3522 self.file_manager.cancel_incoming(file_id);
3523 repo::update_attachment_status(
3524 &self.db,
3525 room_id,
3526 file_id,
3527 AttachmentStatus::Cancelled,
3528 None,
3529 )?;
3530 Ok(())
3531 }
3532
3533 pub fn open_saved(&self, room_id: &str, file_id: &str) -> Result<()> {
3535 let attachment = repo::get_attachment(&self.db, room_id, file_id)?
3536 .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
3537 let path = attachment
3538 .saved_path
3539 .ok_or_else(|| HuddleError::Other("not saved yet — press Enter to save first".into()))?;
3540 open_with_system(&path)
3541 }
3542
3543 pub fn list_room_attachments(&self, room_id: &str) -> Result<Vec<StoredAttachment>> {
3544 repo::list_room_attachments(&self.db, room_id)
3545 }
3546
3547 pub fn set_member_verified(
3551 &self,
3552 room_id: &str,
3553 fingerprint: &str,
3554 verified: bool,
3555 ) -> Result<()> {
3556 let members = repo::list_room_members(&self.db, room_id).unwrap_or_default();
3561 if !members.iter().any(|m| m.fingerprint == fingerprint) {
3562 repo::upsert_room_member(
3563 &self.db,
3564 &StoredRoomMember {
3565 room_id: room_id.to_string(),
3566 peer_id: String::new(),
3567 fingerprint: fingerprint.to_string(),
3568 last_seen: Some(now_unix()),
3569 verified,
3570 ed25519_pubkey: None,
3571 role: "member".into(),
3572 },
3573 )?;
3574 }
3575 repo::set_member_verified(&self.db, room_id, fingerprint, verified)
3576 }
3577
3578 pub fn verified_fingerprints(&self, room_id: &str) -> Vec<String> {
3579 repo::list_verified_fingerprints(&self.db, room_id).unwrap_or_default()
3580 }
3581
3582 pub fn is_owner(&self, room_id: &str, fingerprint: &str) -> bool {
3585 repo::list_room_owners(&self.db, room_id)
3586 .unwrap_or_default()
3587 .iter()
3588 .any(|fp| fp == fingerprint)
3589 }
3590
3591 pub fn we_are_owner(&self, room_id: &str) -> bool {
3592 self.is_owner(room_id, &self.identity.fingerprint().to_string())
3593 }
3594
3595 pub fn room_owners(&self, room_id: &str) -> Vec<String> {
3598 repo::list_room_owners(&self.db, room_id).unwrap_or_default()
3599 }
3600
3601 pub fn has_master_passphrase(&self) -> bool {
3607 self.session_persist_key != [0u8; 32]
3608 }
3609
3610 pub fn verified_only_inbound(&self) -> bool {
3613 repo::get_setting(&self.db, "verified_only_inbound")
3614 .unwrap_or(None)
3615 .map(|v| v == "1")
3616 .unwrap_or(false)
3617 }
3618
3619 pub fn set_verified_only_inbound(&self, on: bool) -> Result<()> {
3620 repo::set_setting(&self.db, "verified_only_inbound", if on { "1" } else { "0" })
3621 }
3622
3623 pub fn mdns_enabled(&self) -> bool {
3633 repo::get_setting(&self.db, "mdns_enabled")
3634 .unwrap_or(None)
3635 .map(|v| v == "1")
3636 .unwrap_or(true)
3637 }
3638
3639 pub fn set_mdns_enabled(&self, on: bool) -> Result<()> {
3640 repo::set_setting(&self.db, "mdns_enabled", if on { "1" } else { "0" })
3641 }
3642
3643 pub fn notifications_enabled(&self) -> bool {
3649 repo::get_setting(&self.db, "notifications_enabled")
3650 .unwrap_or(None)
3651 .map(|v| v == "1")
3652 .unwrap_or(true)
3653 }
3654
3655 pub fn set_notifications_enabled(&self, on: bool) -> Result<()> {
3656 repo::set_setting(
3657 &self.db,
3658 "notifications_enabled",
3659 if on { "1" } else { "0" },
3660 )
3661 }
3662
3663 pub fn safety_code(&self) -> String {
3668 crate::identity::safety_code(&self.identity.public_bytes())
3669 }
3670
3671 pub fn room_verified_only(&self, room_id: &str) -> bool {
3676 repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
3677 }
3678
3679 pub fn set_room_verified_only(&self, room_id: &str, on: bool) -> Result<()> {
3680 repo::set_room_verified_only(&self.db, room_id, on)
3681 }
3682
3683 pub fn onboarding_seen(&self) -> bool {
3685 repo::is_onboarding_seen(&self.db).unwrap_or(true)
3686 }
3687
3688 pub fn mark_onboarding_seen(&self) -> Result<()> {
3689 repo::mark_onboarding_seen(&self.db)
3690 }
3691
3692 pub fn last_seen_onboarding_version(&self) -> Option<String> {
3696 repo::get_last_seen_onboarding_version(&self.db).unwrap_or(None)
3697 }
3698
3699 pub fn set_last_seen_onboarding_version(&self, version: &str) -> Result<()> {
3700 repo::set_last_seen_onboarding_version(&self.db, version)
3701 }
3702
3703 pub fn update_check_enabled(&self) -> Option<bool> {
3706 repo::get_update_check_enabled(&self.db).unwrap_or(None)
3707 }
3708
3709 pub fn set_update_check_enabled(&self, enabled: bool) -> Result<()> {
3710 repo::set_update_check_enabled(&self.db, enabled)
3711 }
3712
3713 pub fn last_update_check_at(&self) -> i64 {
3716 repo::get_setting(&self.db, "last_update_check_at")
3717 .ok()
3718 .flatten()
3719 .and_then(|s| s.parse().ok())
3720 .unwrap_or(0)
3721 }
3722
3723 pub fn set_last_update_check_at(&self, ts: i64) -> Result<()> {
3724 repo::set_setting(&self.db, "last_update_check_at", &ts.to_string())
3725 }
3726
3727 pub fn last_known_remote_version(&self) -> Option<String> {
3731 repo::get_setting(&self.db, "last_known_remote_version")
3732 .ok()
3733 .flatten()
3734 }
3735
3736 pub fn set_last_known_remote_version(&self, v: &str) -> Result<()> {
3737 repo::set_setting(&self.db, "last_known_remote_version", v)
3738 }
3739
3740 pub async fn grant_owner(&self, room_id: &str, target_fingerprint: &str) -> Result<()> {
3744 let our_fp = self.identity.fingerprint().to_string();
3745 if !self.is_owner(room_id, &our_fp) {
3746 return Err(HuddleError::Other(
3747 "only an owner can grant owner".into(),
3748 ));
3749 }
3750 let msg = RoomMessage::OwnerGrant {
3751 room_id: room_id.to_string(),
3752 target_fingerprint: target_fingerprint.to_string(),
3753 };
3754 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3755 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3756 self.network
3757 .publish_room_message(room_id.to_string(), bytes)
3758 .await;
3759 repo::set_member_role(&self.db, room_id, target_fingerprint, "owner")?;
3761 Ok(())
3762 }
3763
3764 pub async fn kick_member(
3775 &self,
3776 room_id: &str,
3777 target_fingerprint: &str,
3778 ) -> Result<String> {
3779 let our_fp = self.identity.fingerprint().to_string();
3780 if !self.is_owner(room_id, &our_fp) {
3781 return Err(HuddleError::Other("only an owner can kick".into()));
3782 }
3783 if target_fingerprint == our_fp {
3784 return Err(HuddleError::Other("can't kick yourself".into()));
3785 }
3786 let info = self
3787 .active_rooms
3788 .lock()
3789 .unwrap()
3790 .get(room_id)
3791 .map(|r| r.info.clone())
3792 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3793 if !info.encrypted {
3794 let msg = RoomMessage::BanMember {
3798 room_id: room_id.to_string(),
3799 target_fingerprint: target_fingerprint.to_string(),
3800 };
3801 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3802 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3803 self.network
3804 .publish_room_message(room_id.to_string(), bytes)
3805 .await;
3806 repo::add_room_ban(
3807 &self.db,
3808 room_id,
3809 target_fingerprint,
3810 &our_fp,
3811 &env.signature_b64,
3812 now_unix(),
3813 )?;
3814 self.evict_banned_member(room_id, target_fingerprint);
3815 return Ok(String::new());
3816 }
3817 let new_passphrase = generate_join_passphrase();
3819 let msg = RoomMessage::BanMember {
3820 room_id: room_id.to_string(),
3821 target_fingerprint: target_fingerprint.to_string(),
3822 };
3823 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3824 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3825 self.network
3826 .publish_room_message(room_id.to_string(), bytes)
3827 .await;
3828 repo::add_room_ban(
3829 &self.db,
3830 room_id,
3831 target_fingerprint,
3832 &our_fp,
3833 &env.signature_b64,
3834 now_unix(),
3835 )?;
3836 self.evict_banned_member(room_id, target_fingerprint);
3837 self.rotate_room(room_id, &new_passphrase).await?;
3840 Ok(new_passphrase)
3841 }
3842
3843 pub fn generate_join_code(&self, room_id: &str) -> Result<String> {
3850 let our_fp = self.identity.fingerprint().to_string();
3851 if !self.is_owner(room_id, &our_fp) {
3852 return Err(HuddleError::Other(
3853 "only an owner can issue join codes".into(),
3854 ));
3855 }
3856 let code = generate_alphanumeric_code(8);
3857 let expires_at = now_unix() + 10 * 60;
3858 let mut rooms = self.active_rooms.lock().unwrap();
3859 let room = rooms
3860 .get_mut(room_id)
3861 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3862 let now = now_unix();
3864 room.issued_codes.retain(|(_, exp)| *exp > now);
3865 room.issued_codes.push((code.clone(), expires_at));
3866 Ok(code)
3867 }
3868
3869 pub async fn join_room_with_code(
3876 &self,
3877 room_id: &str,
3878 code: &str,
3879 ) -> Result<()> {
3880 let info = {
3882 let d = self.discovered_rooms.lock().unwrap().get(room_id).cloned();
3883 match d {
3884 Some(d) => StoredRoom {
3885 id: room_id.to_string(),
3886 name: d.name,
3887 creator_fingerprint: d.creator_fingerprint,
3888 encrypted: d.encrypted,
3889 passphrase_salt: None, created_at: now_unix(),
3891 last_active: Some(now_unix()),
3892 kind: d.kind,
3895 },
3896 None => {
3897 return Err(HuddleError::Other(format!(
3898 "room {room_id} not visible — wait for an announcement"
3899 )))
3900 }
3901 }
3902 };
3903 if !info.encrypted {
3904 return Err(HuddleError::Other(
3905 "code-join only applies to encrypted rooms".into(),
3906 ));
3907 }
3908 let our_fp = self.identity.fingerprint().to_string();
3909 use x25519_dalek::{PublicKey, StaticSecret};
3912 let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
3913 let our_pub = PublicKey::from(&our_secret);
3914 let key = (room_id.to_string(), our_fp.clone());
3919 self.pending_code_secrets
3920 .lock()
3921 .unwrap()
3922 .insert(key.clone(), our_secret);
3923 let map = self.pending_code_secrets.clone();
3928 let tx = self.app_event_tx.clone();
3929 let timeout_room = room_id.to_string();
3930 tokio::spawn(async move {
3931 tokio::time::sleep(std::time::Duration::from_secs(30)).await;
3932 let still_pending = map.lock().unwrap().remove(&key).is_some();
3933 if still_pending {
3934 let _ = tx.send(AppEvent::CodeJoinTimedOut {
3935 room_id: timeout_room,
3936 reason: "no response from owner — code may be wrong or expired".into(),
3937 });
3938 }
3939 });
3940 repo::insert_room(&self.db, &info)?;
3947 self.active_rooms.lock().unwrap().insert(
3950 room_id.to_string(),
3951 ActiveRoom {
3952 info: info.clone(),
3953 crypto: Some(RoomCrypto::new_for_room(
3954 self.db.clone(),
3955 room_id.to_string(),
3956 our_fp.clone(),
3957 self.session_persist_key,
3958 )?),
3959 passphrase_key: None,
3960 members: {
3961 let mut s = HashSet::new();
3962 s.insert(our_fp.clone());
3963 s
3964 },
3965 typers: HashMap::new(),
3966 read_only: true,
3967 issued_codes: Vec::new(),
3968 },
3969 );
3970 self.network.subscribe_room(room_id.to_string()).await;
3971 let req = RoomMessage::CodeJoinRequest {
3973 room_id: room_id.to_string(),
3974 joiner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
3975 code: code.to_string(),
3976 };
3977 let env = crate::crypto::sign_message(&self.identity, &req)?;
3978 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3979 self.network
3980 .publish_room_message(room_id.to_string(), bytes)
3981 .await;
3982 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
3985 room_id: room_id.to_string(),
3986 });
3987 Ok(())
3988 }
3989
3990 pub async fn sas_start(&self, room_id: &str, target_fingerprint: &str) -> Result<String> {
3996 let (tx_id_bytes, our_secret, our_pub) = crate::crypto::sas::new_session();
3997 let tx_id = B64.encode(tx_id_bytes);
3998 let msg = RoomMessage::SasInit {
3999 tx_id: tx_id.clone(),
4000 ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
4001 target_fingerprint: target_fingerprint.to_string(),
4002 };
4003 let env = crate::crypto::sign_message(&self.identity, &msg)?;
4004 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
4005 self.sas_flows.lock().unwrap().insert(
4006 tx_id.clone(),
4007 SasFlow {
4008 room_id: room_id.to_string(),
4009 partner_fingerprint: target_fingerprint.to_string(),
4010 our_secret,
4011 sas_code: None,
4012 our_confirmed: false,
4013 their_confirmed: false,
4014 finalized: false,
4015 },
4016 );
4017 self.network
4018 .publish_room_message(room_id.to_string(), bytes)
4019 .await;
4020 Ok(tx_id)
4021 }
4022
4023 pub async fn sas_match(&self, tx_id: &str) -> Result<()> {
4027 let (room_id, partner_fp, both_done) = {
4028 let mut flows = self.sas_flows.lock().unwrap();
4029 let flow = flows
4030 .get_mut(tx_id)
4031 .ok_or_else(|| HuddleError::Other("unknown SAS tx_id".into()))?;
4032 flow.our_confirmed = true;
4033 let do_finish = flow.our_confirmed && flow.their_confirmed && !flow.finalized;
4037 if do_finish {
4038 flow.finalized = true;
4039 }
4040 (
4041 flow.room_id.clone(),
4042 flow.partner_fingerprint.clone(),
4043 do_finish,
4044 )
4045 };
4046 let msg = RoomMessage::SasConfirm {
4047 tx_id: tx_id.to_string(),
4048 matched: true,
4049 };
4050 let env = crate::crypto::sign_message(&self.identity, &msg)?;
4051 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
4052 self.network
4053 .publish_room_message(room_id.clone(), bytes)
4054 .await;
4055 if both_done {
4056 self.finish_sas(tx_id, &room_id, &partner_fp).await?;
4057 }
4058 Ok(())
4059 }
4060
4061 pub fn sas_cancel(&self, tx_id: &str) {
4065 self.sas_flows.lock().unwrap().remove(tx_id);
4066 }
4067
4068 async fn finish_sas(
4071 &self,
4072 tx_id: &str,
4073 room_id: &str,
4074 partner_fingerprint: &str,
4075 ) -> Result<()> {
4076 repo::set_member_verified(&self.db, room_id, partner_fingerprint, true)?;
4077 repo::add_verified_peer(&self.db, partner_fingerprint, now_unix())?;
4078 self.sas_flows.lock().unwrap().remove(tx_id);
4079 let _ = self.app_event_tx.send(AppEvent::SasVerified {
4080 room_id: room_id.to_string(),
4081 partner_fingerprint: partner_fingerprint.to_string(),
4082 });
4083 Ok(())
4084 }
4085
4086 fn evict_banned_member(&self, room_id: &str, fingerprint: &str) {
4091 if let Some(room) = self.active_rooms.lock().unwrap().get_mut(room_id) {
4092 room.members.remove(fingerprint);
4093 }
4094 let _ = self.app_event_tx.send(AppEvent::MemberLeft {
4095 room_id: room_id.to_string(),
4096 fingerprint: fingerprint.to_string(),
4097 });
4098 }
4099
4100 pub fn display_name(&self) -> Option<String> {
4101 repo::get_display_name(&self.db).unwrap_or(None)
4102 }
4103
4104 pub fn set_display_name(&self, name: Option<&str>) -> Result<()> {
4105 repo::set_display_name(&self.db, name)
4106 }
4107
4108 pub async fn set_username(&self, name: Option<&str>) -> Result<()> {
4114 repo::set_display_name(&self.db, name)?;
4115 let msg = RoomMessage::ProfileUpdate {
4116 sender_fingerprint: self.identity.fingerprint().to_string(),
4117 username: name.map(|s| s.to_string()),
4118 updated_at: now_unix_ms(),
4119 };
4120 let env = crate::crypto::sign_message(&self.identity, &msg)?;
4121 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
4122 let rooms: Vec<String> = self.active_rooms.lock().unwrap().keys().cloned().collect();
4123 for room_id in rooms {
4124 self.network
4125 .publish_room_message(room_id, bytes.clone())
4126 .await;
4127 }
4128 Ok(())
4129 }
4130
4131 pub fn lookup_username(&self, fingerprint: &str) -> Option<String> {
4136 repo::get_peer_username(&self.db, fingerprint).unwrap_or(None)
4137 }
4138
4139 pub fn lookup_member_display_name(&self, fingerprint: &str) -> Option<String> {
4143 self.lookup_username(fingerprint)
4144 }
4145
4146 pub fn is_room_muted(&self, room_id: &str) -> bool {
4147 repo::is_room_muted(&self.db, room_id).unwrap_or(false)
4148 }
4149
4150 pub fn list_room_bans(&self, room_id: &str) -> Vec<String> {
4155 repo::list_room_bans(&self.db, room_id).unwrap_or_default()
4156 }
4157
4158 pub fn list_verified_peers(&self) -> Vec<String> {
4164 repo::list_verified_peers(&self.db).unwrap_or_default()
4165 }
4166
4167 pub fn list_blocked_peers(&self) -> Vec<String> {
4168 repo::list_blocked_peers(&self.db).unwrap_or_default()
4169 }
4170
4171 pub fn unblock_peer(&self, fingerprint: &str) -> Result<()> {
4175 repo::unblock_peer(&self.db, fingerprint)
4176 }
4177
4178 pub fn block_peer(&self, fingerprint: &str) -> Result<()> {
4182 repo::block_peer(&self.db, fingerprint, now_unix())
4183 }
4184
4185 pub fn is_room_read_only(&self, room_id: &str) -> bool {
4191 self.active_rooms
4192 .lock()
4193 .unwrap()
4194 .get(room_id)
4195 .map(|r| r.read_only)
4196 .unwrap_or(false)
4197 }
4198
4199 pub fn set_room_muted(&self, room_id: &str, muted: bool) -> Result<()> {
4200 repo::set_room_muted(&self.db, room_id, muted)
4201 }
4202
4203 pub async fn broadcast_typing(&self, room_id: &str) {
4206 if !self.active_rooms.lock().unwrap().contains_key(room_id) {
4207 return;
4208 }
4209 let msg = RoomMessage::Typing {
4210 sender_fingerprint: self.identity.fingerprint().to_string(),
4211 };
4212 if let Ok(bytes) = encode_wire(&msg) {
4213 self.network
4214 .publish_room_message(room_id.to_string(), bytes)
4215 .await;
4216 }
4217 }
4218
4219 pub fn typers_in_room(&self, room_id: &str) -> Vec<String> {
4222 let now = now_unix();
4223 let mut rooms = self.active_rooms.lock().unwrap();
4224 let room = match rooms.get_mut(room_id) {
4225 Some(r) => r,
4226 None => return Vec::new(),
4227 };
4228 room.typers.retain(|_, exp| *exp > now);
4229 let mut v: Vec<String> = room.typers.keys().cloned().collect();
4230 v.sort();
4231 v
4232 }
4233
4234 pub async fn rotate_room(&self, room_id: &str, new_passphrase: &str) -> Result<()> {
4244 if new_passphrase.is_empty() {
4245 return Err(HuddleError::Other("new passphrase is empty".into()));
4246 }
4247 let new_salt = passphrase::random_salt();
4248 let new_key = passphrase::derive_key(new_passphrase, &new_salt)?;
4249
4250 let info = {
4251 let mut rooms = self.active_rooms.lock().unwrap();
4252 let room = rooms
4253 .get_mut(room_id)
4254 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
4255 if !room.info.encrypted {
4256 return Err(HuddleError::Other(
4257 "rotation only applies to encrypted rooms".into(),
4258 ));
4259 }
4260 let new_crypto = RoomCrypto::new_for_room(
4262 self.db.clone(),
4263 room_id.to_string(),
4264 self.identity.fingerprint().to_string(),
4265 self.session_persist_key,
4266 )?;
4267 room.crypto = Some(new_crypto);
4268 room.passphrase_key = Some(new_key);
4269 room.info.passphrase_salt = Some(new_salt.to_vec());
4270 room.info.clone()
4271 };
4272
4273 let rot = RoomMessage::RotateRoomKey {
4279 rotator_fingerprint: self.identity.fingerprint().to_string(),
4280 new_salt: new_salt.to_vec(),
4281 };
4282 if let Ok(env) = crate::crypto::sign_message(&self.identity, &rot) {
4286 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
4287 self.network
4288 .publish_room_message(room_id.to_string(), bytes)
4289 .await;
4290 }
4291 }
4292 if let Err(e) = self.broadcast_member_announce(room_id).await {
4294 warn!(%e, "rotate: broadcast announce failed");
4295 }
4296
4297 repo::insert_room(&self.db, &info)?;
4299 Ok(())
4300 }
4301
4302 pub async fn accept_rotation(
4306 &self,
4307 room_id: &str,
4308 new_salt: &[u8],
4309 new_passphrase: &str,
4310 ) -> Result<()> {
4311 let new_key = passphrase::derive_key(new_passphrase, new_salt)?;
4312 let info = {
4313 let mut rooms = self.active_rooms.lock().unwrap();
4314 let room = rooms
4315 .get_mut(room_id)
4316 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
4317 room.passphrase_key = Some(new_key);
4318 room.info.passphrase_salt = Some(new_salt.to_vec());
4319 room.info.clone()
4320 };
4321 let req = RoomMessage::SessionKeyRequest {
4325 requester_fingerprint: self.identity.fingerprint().to_string(),
4326 };
4327 if let Ok(bytes) = encode_wire(&req) {
4328 self.network
4329 .publish_room_message(room_id.to_string(), bytes)
4330 .await;
4331 }
4332 repo::insert_room(&self.db, &info)?;
4333 Ok(())
4334 }
4335
4336 #[allow(clippy::too_many_arguments)]
4341 fn handle_file_offer(
4342 &self,
4343 room_id: &str,
4344 sender_fingerprint: String,
4345 file_id: String,
4346 name: String,
4347 size_bytes: u64,
4348 mime: Option<String>,
4349 _chunk_count: u32,
4350 encrypted_meta: Option<EncryptedFileMeta>,
4351 ) {
4352 let encrypted = encrypted_meta.is_some();
4353 let attachment = StoredAttachment {
4354 id: 0,
4355 room_id: room_id.to_string(),
4356 message_id: None,
4357 sender_fingerprint: sender_fingerprint.clone(),
4358 file_id: file_id.clone(),
4359 name: name.clone(),
4360 mime,
4361 size_bytes: size_bytes as i64,
4362 status: AttachmentStatus::Offered,
4363 cache_path: None,
4364 saved_path: None,
4365 error: None,
4366 encrypted,
4367 wrapped_key: encrypted_meta.as_ref().map(|m| m.wrapped_key_b64.clone()),
4368 nonce: encrypted_meta.as_ref().map(|m| m.nonce_b64.clone()),
4369 megolm_session_id: encrypted_meta.as_ref().map(|m| m.megolm_session_id.clone()),
4370 content_hash: encrypted_meta.as_ref().map(|m| m.content_hash.clone()),
4371 created_at: now_unix(),
4372 };
4373 if let Err(e) = repo::upsert_attachment(&self.db, &attachment) {
4374 warn!(%e, "upsert attachment");
4375 return;
4376 }
4377 self.file_manager.set_expected_size(&file_id, size_bytes);
4380 let _ = self.app_event_tx.send(AppEvent::FileOffered {
4381 room_id: room_id.to_string(),
4382 file_id,
4383 name,
4384 size_bytes,
4385 sender_fingerprint,
4386 });
4387 }
4388
4389 fn handle_file_chunk(
4390 &self,
4391 room_id: &str,
4392 _sender_fingerprint: String,
4393 file_id: String,
4394 chunk_index: u32,
4395 total_chunks: u32,
4396 data_b64: String,
4397 ) {
4398 let data = match B64.decode(&data_b64) {
4399 Ok(d) => d,
4400 Err(e) => {
4401 warn!(%e, "bad chunk base64");
4402 return;
4403 }
4404 };
4405 let expected_size = match repo::get_attachment(&self.db, room_id, &file_id) {
4409 Ok(Some(a)) => {
4410 if matches!(
4411 a.status,
4412 AttachmentStatus::Cancelled | AttachmentStatus::Failed
4413 ) {
4414 return;
4415 }
4416 a.size_bytes as u64
4417 }
4418 Ok(None) => crate::files::MAX_FILE_SIZE,
4419 Err(e) => {
4420 warn!(%e, "get attachment for chunk");
4421 crate::files::MAX_FILE_SIZE
4422 }
4423 };
4424
4425 let result = self.file_manager.accept_chunk(
4426 &file_id,
4427 chunk_index,
4428 total_chunks,
4429 data,
4430 expected_size,
4431 );
4432 match result {
4433 Ok(None) => {
4434 let _ = repo::update_attachment_status(
4436 &self.db,
4437 room_id,
4438 &file_id,
4439 AttachmentStatus::Downloading,
4440 None,
4441 );
4442 let bytes_so_far = self
4445 .file_manager
4446 .progress(&file_id)
4447 .map(|(b, _)| b)
4448 .unwrap_or(0);
4449 let _ = self.app_event_tx.send(AppEvent::FileProgress {
4450 file_id: file_id.clone(),
4451 bytes_received: bytes_so_far,
4452 total_bytes: expected_size,
4453 });
4454 }
4455 Ok(Some(completed)) => {
4456 let _ = repo::update_attachment_paths(
4457 &self.db,
4458 room_id,
4459 &file_id,
4460 Some(&completed.cache_path.to_string_lossy()),
4461 None,
4462 );
4463 let _ = repo::update_attachment_status(
4464 &self.db,
4465 room_id,
4466 &file_id,
4467 AttachmentStatus::Ready,
4468 None,
4469 );
4470 let _ = self.app_event_tx.send(AppEvent::FileReady {
4471 file_id: file_id.clone(),
4472 });
4473 }
4474 Err(e) => {
4475 let msg = e.to_string();
4476 warn!(%msg, "chunk processing failed");
4477 let _ = repo::update_attachment_status(
4478 &self.db,
4479 room_id,
4480 &file_id,
4481 AttachmentStatus::Failed,
4482 Some(&msg),
4483 );
4484 let _ = self.app_event_tx.send(AppEvent::FileFailed {
4485 file_id: file_id.clone(),
4486 reason: msg,
4487 });
4488 }
4489 }
4490 }
4491
4492 fn maybe_emit_mention(&self, room_id: &str, body: &str) {
4504 let full = self.identity.fingerprint().to_lowercase();
4505 let short: String = full.chars().filter(|c| c.is_ascii_hexdigit()).take(8).collect();
4508 let lower = body.to_lowercase();
4509 let hit = lower.contains(full.as_str())
4510 || lower
4511 .split(|c: char| !c.is_ascii_hexdigit())
4512 .any(|tok| tok == short);
4513 if hit {
4514 let _ = self.app_event_tx.send(AppEvent::MentionReceived {
4515 room_id: room_id.to_string(),
4516 body: body.to_string(),
4517 });
4518 }
4519 }
4520
4521 fn decrypt_attachment(
4522 &self,
4523 room_id: &str,
4524 sender_fingerprint: &str,
4525 ciphertext: &[u8],
4526 meta: &EncryptedFileMeta,
4527 ) -> Result<Vec<u8>> {
4528 let mut rooms = self.active_rooms.lock().unwrap();
4529 let room = rooms
4530 .get_mut(room_id)
4531 .ok_or_else(|| HuddleError::Other("not in room".into()))?;
4532 let crypto = room
4533 .crypto
4534 .as_mut()
4535 .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
4536 file_encryption::decrypt_file(ciphertext, meta, crypto, sender_fingerprint)
4537 }
4538
4539 pub async fn go_dark(&self, master_passphrase: &str) -> Result<()> {
4551 let no_master = self.session_persist_key == [0u8; 32];
4552 if !no_master {
4553 let salt = storage::keychain::load_or_create_salt()?;
4554 let candidate_master =
4555 storage::keychain::derive_master_key(master_passphrase, &salt)?;
4556 let candidate_subkey =
4557 storage::keychain::derive_subkey(&candidate_master, b"megolm-persist");
4558 if !ct_eq_32(&candidate_subkey, &self.session_persist_key) {
4559 return Err(HuddleError::Other(
4560 "incorrect master passphrase".into(),
4561 ));
4562 }
4563 }
4564
4565 let room_ids: Vec<String> = self
4566 .active_rooms
4567 .lock()
4568 .unwrap()
4569 .keys()
4570 .cloned()
4571 .collect();
4572 let _ = tokio::time::timeout(Duration::from_secs(2), async {
4573 for room_id in &room_ids {
4574 if let Err(e) = self.leave_room(room_id).await {
4575 warn!(%room_id, %e, "go_dark: leave_room failed");
4576 }
4577 }
4578 })
4579 .await;
4580
4581 self.network.shutdown().await;
4582 tokio::time::sleep(Duration::from_millis(300)).await;
4583
4584 let data_dir = config::data_dir();
4585 let candidates = [
4586 "huddle.db",
4587 "huddle.db-shm",
4588 "huddle.db-wal",
4589 "keychain.salt",
4590 "huddle.log",
4591 "config.toml",
4592 ];
4593 for name in &candidates {
4594 let path = data_dir.join(name);
4595 wipe_file(&path);
4596 }
4597 if let Ok(read) = std::fs::read_dir(&data_dir) {
4598 for entry in read.flatten() {
4599 if let Some(name) = entry.file_name().to_str() {
4600 if name.starts_with("huddle.log.") {
4601 wipe_file(&entry.path());
4602 }
4603 }
4604 }
4605 }
4606 let files_dir = data_dir.join("files");
4610 if let Ok(read) = std::fs::read_dir(&files_dir) {
4611 for entry in read.flatten() {
4612 let path = entry.path();
4613 if path.is_file() {
4614 wipe_file(&path);
4615 } else if path.is_dir() {
4616 if let Ok(inner) = std::fs::read_dir(&path) {
4619 for inner_entry in inner.flatten() {
4620 if inner_entry.path().is_file() {
4621 wipe_file(&inner_entry.path());
4622 }
4623 }
4624 }
4625 let _ = std::fs::remove_dir(&path);
4626 }
4627 }
4628 }
4629 let _ = std::fs::remove_dir(&files_dir);
4630 let _ = std::fs::remove_dir(&data_dir);
4631
4632 let _ = self.app_event_tx.send(AppEvent::WentDark);
4633 Ok(())
4634 }
4635}
4636
4637pub fn normalize_to_fingerprint(input: &str) -> Option<String> {
4644 let s = input
4645 .trim()
4646 .trim_start_matches("HD-")
4647 .trim_start_matches("hd-")
4648 .to_string();
4649 let hex_only: String = s.chars().filter(|c| *c != '-').collect();
4650 if hex_only.len() != 24 || !hex_only.chars().all(|c| c.is_ascii_hexdigit()) {
4651 return None;
4652 }
4653 let lower = hex_only.to_ascii_lowercase();
4654 let chunks: Vec<String> = lower
4655 .as_bytes()
4656 .chunks(4)
4657 .map(|c| std::str::from_utf8(c).unwrap().to_string())
4658 .collect();
4659 Some(chunks.join("-"))
4660}
4661
4662fn address_preference(addr: &str) -> u8 {
4668 if addr.contains("/p2p-circuit") {
4669 return 9; }
4671 if let Some(rest) = addr.strip_prefix("/ip4/") {
4672 if let Some(ip_str) = rest.split('/').next() {
4673 if let Ok(ip) = ip_str.parse::<std::net::Ipv4Addr>() {
4674 if ip.is_loopback() {
4675 return 1; }
4677 if is_rfc1918(&ip) || ip.is_link_local() {
4678 return 0; }
4680 return 3; }
4682 }
4683 return 3;
4684 }
4685 if addr.starts_with("/ip6/") {
4686 return 4;
4687 }
4688 if addr.starts_with("/dns4/") || addr.starts_with("/dns6/") || addr.starts_with("/dnsaddr/") {
4689 return 5;
4690 }
4691 7
4692}
4693
4694fn is_rfc1918(ip: &std::net::Ipv4Addr) -> bool {
4698 let octets = ip.octets();
4699 octets[0] == 10
4700 || (octets[0] == 172 && (16..=31).contains(&octets[1]))
4701 || (octets[0] == 192 && octets[1] == 168)
4702}
4703
4704fn short_fp_for_msg(fingerprint: &str) -> String {
4708 let head: String = fingerprint
4709 .chars()
4710 .filter(|c| *c != '-')
4711 .take(4)
4712 .collect::<String>()
4713 .to_ascii_uppercase();
4714 format!("HD-{}…", head)
4715}
4716
4717fn ct_eq_32(a: &[u8; 32], b: &[u8; 32]) -> bool {
4721 let mut diff = 0u8;
4722 for i in 0..32 {
4723 diff |= a[i] ^ b[i];
4724 }
4725 diff == 0
4726}
4727
4728fn wipe_file(path: &Path) {
4732 use std::io::Write;
4733 const SCRATCH: usize = 64 * 1024;
4739 if let Ok(meta) = std::fs::metadata(path) {
4740 if let Ok(mut f) = std::fs::OpenOptions::new().write(true).open(path) {
4741 let zeros = [0u8; SCRATCH];
4742 let mut remaining = meta.len();
4743 while remaining > 0 {
4744 let n = remaining.min(SCRATCH as u64) as usize;
4745 if f.write_all(&zeros[..n]).is_err() {
4746 break;
4747 }
4748 remaining -= n as u64;
4749 }
4750 let _ = f.sync_all();
4751 }
4752 }
4753 if let Err(e) = std::fs::remove_file(path) {
4754 if e.kind() != std::io::ErrorKind::NotFound {
4755 warn!(?path, %e, "wipe_file: remove failed");
4756 }
4757 }
4758}
4759
4760fn open_with_system(path: &str) -> Result<()> {
4762 #[cfg(target_os = "macos")]
4763 let cmd = "open";
4764 #[cfg(target_os = "linux")]
4765 let cmd = "xdg-open";
4766 #[cfg(target_os = "windows")]
4767 let cmd = "cmd";
4768 #[cfg(target_os = "windows")]
4769 let args = vec!["/C", "start", "", path];
4770 #[cfg(not(target_os = "windows"))]
4771 let args = vec![path];
4772
4773 std::process::Command::new(cmd)
4774 .args(args)
4775 .spawn()
4776 .map_err(|e| HuddleError::Other(format!("spawn opener: {e}")))?;
4777 Ok(())
4778}
4779
4780static ROOM_SALT_CACHE: std::sync::LazyLock<Mutex<HashMap<String, Vec<u8>>>> =
4783 std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
4784
4785pub fn salt_len() -> usize {
4790 SALT_LEN
4791}
4792
4793fn now_unix() -> i64 {
4794 SystemTime::now()
4795 .duration_since(UNIX_EPOCH)
4796 .unwrap()
4797 .as_secs() as i64
4798}
4799
4800fn now_unix_ms() -> i64 {
4801 SystemTime::now()
4802 .duration_since(UNIX_EPOCH)
4803 .unwrap()
4804 .as_millis() as i64
4805}
4806
4807fn generate_join_passphrase() -> String {
4813 use rand::RngCore;
4814 let mut bytes = [0u8; 16];
4815 rand::thread_rng().fill_bytes(&mut bytes);
4816 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
4819}
4820
4821fn generate_alphanumeric_code(len: usize) -> String {
4830 use rand::Rng;
4831 const ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ23456789";
4832 let mut rng = rand::thread_rng();
4833 let mut out = String::with_capacity(len + 1);
4834 for i in 0..len {
4835 if i == 4 && len == 8 {
4836 out.push('-'); }
4838 let idx = rng.gen_range(0..ALPHABET.len());
4839 out.push(ALPHABET[idx] as char);
4840 }
4841 out
4842}
4843
4844#[cfg(test)]
4845mod parser_tests {
4846 use super::parse_dial_address;
4847
4848 #[test]
4849 fn parses_ipv4_port() {
4850 let m = parse_dial_address("10.3.72.53:9027").unwrap();
4851 assert_eq!(m.to_string(), "/ip4/10.3.72.53/tcp/9027");
4852 }
4853
4854 #[test]
4855 fn parses_bracketed_ipv6() {
4856 let m = parse_dial_address("[::1]:9027").unwrap();
4857 assert_eq!(m.to_string(), "/ip6/::1/tcp/9027");
4858 }
4859
4860 #[test]
4861 fn rejects_unbracketed_ipv6() {
4862 let err = parse_dial_address("fe80::1:9027").unwrap_err();
4863 assert!(err.to_string().contains("brackets"));
4864 }
4865
4866 #[test]
4867 fn passes_through_raw_multiaddr() {
4868 let m = parse_dial_address("/ip4/1.2.3.4/tcp/9000").unwrap();
4869 assert_eq!(m.to_string(), "/ip4/1.2.3.4/tcp/9000");
4870 }
4871
4872 #[test]
4873 fn empty_address_is_error() {
4874 assert!(parse_dial_address(" ").is_err());
4875 }
4876
4877 #[test]
4878 fn rejects_bad_port() {
4879 assert!(parse_dial_address("1.2.3.4:notaport").is_err());
4880 }
4881}
4882
4883#[cfg(test)]
4884mod transport_preference_tests {
4885 use super::{address_preference, normalize_to_fingerprint};
4886
4887 #[test]
4888 fn lan_beats_public_beats_circuit() {
4889 let lan = address_preference("/ip4/192.168.1.5/tcp/9027");
4890 let pub_v4 = address_preference("/ip4/8.8.8.8/tcp/9027");
4891 let circuit = address_preference(
4892 "/ip4/1.2.3.4/tcp/4001/p2p/12D3Koo/p2p-circuit/p2p/12D3KooXYZ",
4893 );
4894 assert!(lan < pub_v4, "LAN {} should beat public {}", lan, pub_v4);
4895 assert!(
4896 pub_v4 < circuit,
4897 "public {} should beat circuit {}",
4898 pub_v4,
4899 circuit
4900 );
4901 }
4902
4903 #[test]
4904 fn all_rfc1918_ranges_are_lan() {
4905 assert_eq!(
4906 address_preference("/ip4/10.0.0.1/tcp/9027"),
4907 address_preference("/ip4/192.168.0.1/tcp/9027"),
4908 );
4909 assert_eq!(
4910 address_preference("/ip4/172.16.0.1/tcp/9027"),
4911 address_preference("/ip4/192.168.0.1/tcp/9027"),
4912 );
4913 assert!(
4915 address_preference("/ip4/172.32.0.1/tcp/9027")
4916 > address_preference("/ip4/172.16.0.1/tcp/9027")
4917 );
4918 }
4919
4920 #[test]
4921 fn normalize_id_accepts_branded_and_raw() {
4922 let canon = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4923 assert_eq!(
4924 normalize_to_fingerprint("HD-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF").as_deref(),
4925 Some(canon)
4926 );
4927 assert_eq!(
4928 normalize_to_fingerprint("aaaabbbbccccddddeeeeffff").as_deref(),
4929 Some(canon)
4930 );
4931 assert_eq!(normalize_to_fingerprint(canon).as_deref(), Some(canon));
4932 assert!(normalize_to_fingerprint("alice").is_none());
4933 assert!(normalize_to_fingerprint("HD-ZZZZ").is_none());
4934 }
4935}
4936
4937#[cfg(test)]
4938mod canonical_dm_room_id_tests {
4939 use super::canonical_dm_room_id;
4940
4941 #[test]
4942 fn dm_room_id_is_commutative() {
4943 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4946 let b = "1111-2222-3333-4444-5555-6666";
4947 assert_eq!(canonical_dm_room_id(a, b), canonical_dm_room_id(b, a));
4948 }
4949
4950 #[test]
4951 fn dm_room_id_differs_per_pair() {
4952 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4953 let b = "1111-2222-3333-4444-5555-6666";
4954 let c = "9999-8888-7777-6666-5555-4444";
4955 assert_ne!(canonical_dm_room_id(a, b), canonical_dm_room_id(a, c));
4956 assert_ne!(canonical_dm_room_id(a, b), canonical_dm_room_id(b, c));
4957 }
4958
4959 #[test]
4960 fn dm_room_id_is_stable() {
4961 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4965 let b = "1111-2222-3333-4444-5555-6666";
4966 let id1 = canonical_dm_room_id(a, b);
4967 let id2 = canonical_dm_room_id(a, b);
4968 assert_eq!(id1, id2);
4969 assert_eq!(id1.len(), 32);
4973 }
4974}