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}
41
42pub fn canonical_dm_room_id(a: &str, b: &str) -> String {
53 use sha2::{Digest, Sha256};
54 let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
55 let mut hasher = Sha256::new();
56 hasher.update(b"huddle-dm-v1\0");
57 hasher.update(lo.as_bytes());
58 hasher.update(b"\0");
59 hasher.update(hi.as_bytes());
60 hex::encode(&hasher.finalize()[..16])
61}
62
63pub fn parse_dial_address(input: &str) -> Result<Multiaddr> {
66 let trimmed = input.trim();
67 if trimmed.is_empty() {
68 return Err(HuddleError::Other("address is empty".into()));
69 }
70 if trimmed.starts_with('/') {
71 return trimmed
72 .parse::<Multiaddr>()
73 .map_err(|e| HuddleError::Other(format!("invalid multiaddr: {e}")));
74 }
75 if let Some(rest) = trimmed.strip_prefix('[') {
76 let (host, port) = rest
77 .split_once("]:")
78 .ok_or_else(|| HuddleError::Other(format!("expected [ipv6]:port, got {trimmed}")))?;
79 let port: u16 = port
80 .parse()
81 .map_err(|_| HuddleError::Other(format!("invalid port: {port}")))?;
82 return format!("/ip6/{}/tcp/{}", host, port)
83 .parse::<Multiaddr>()
84 .map_err(|e| HuddleError::Other(format!("invalid ipv6 address: {e}")));
85 }
86 let (host, port) = trimmed
87 .rsplit_once(':')
88 .ok_or_else(|| HuddleError::Other(format!("expected ip:port, got {trimmed}")))?;
89 if host.contains(':') {
90 return Err(HuddleError::Other(format!(
91 "ambiguous IPv6 address — wrap host in brackets: [{host}]:{port}"
92 )));
93 }
94 let port: u16 = port
95 .parse()
96 .map_err(|_| HuddleError::Other(format!("invalid port: {port}")))?;
97 format!("/ip4/{}/tcp/{}", host, port)
98 .parse::<Multiaddr>()
99 .map_err(|e| HuddleError::Other(format!("invalid address: {e}")))
100}
101
102struct ActiveRoom {
104 info: StoredRoom,
105 crypto: Option<RoomCrypto>,
106 passphrase_key: Option<[u8; KEY_LEN]>,
109 members: HashSet<String>,
111 typers: HashMap<String, i64>,
114 read_only: bool,
121 issued_codes: Vec<(String, i64)>,
125}
126
127const TYPING_TTL_SECS: i64 = 3;
128
129const DISCOVERED_TTL_SECS: i64 = 45;
132const ANNOUNCE_INTERVAL_SECS: u64 = 15;
133
134struct SasFlow {
138 room_id: String,
139 partner_fingerprint: String,
140 our_secret: x25519_dalek::StaticSecret,
141 sas_code: Option<crate::crypto::sas::SasCode>,
143 our_confirmed: bool,
144 their_confirmed: bool,
145}
146
147#[derive(Clone)]
148pub struct AppHandle {
149 identity: Arc<Identity>,
150 network: NetworkHandle,
151 mode: NetworkMode,
152 active_rooms: Arc<Mutex<HashMap<String, ActiveRoom>>>,
153 discovered_rooms: Arc<Mutex<HashMap<String, DiscoveredRoom>>>,
154 restorable_rooms: Arc<Mutex<HashMap<String, StoredRoom>>>,
158 connected_dial_addrs: Arc<Mutex<HashMap<String, PeerId>>>,
161 file_manager: Arc<FileManager>,
163 db: Db,
164 session_persist_key: [u8; 32],
168 sas_flows: Arc<Mutex<HashMap<String, SasFlow>>>,
171 pending_code_secrets:
178 Arc<Mutex<HashMap<(String, String), x25519_dalek::StaticSecret>>>,
179 pending_invite_dials: Arc<Mutex<HashMap<String, String>>>,
192 nat_reachable_addrs: Arc<Mutex<HashSet<String>>>,
198 relay_circuit_addrs: Arc<Mutex<HashSet<String>>>,
204 host_addr_dial_attempts: Arc<Mutex<HashMap<String, i64>>>,
209 last_profile_broadcast_at_ms: Arc<Mutex<HashMap<String, i64>>>,
216 app_event_tx: broadcast::Sender<AppEvent>,
217}
218
219const HOST_ADDR_DIAL_BACKOFF_SECS: i64 = 300;
222
223const PROFILE_REBROADCAST_FLOOR_MS: i64 = 60_000;
227
228impl AppHandle {
229 pub async fn start() -> Result<Self> {
230 Self::start_with_options(NetworkMode::Mdns, 0, None, Vec::new()).await
231 }
232
233 pub async fn start_with_options(
234 mode: NetworkMode,
235 port: u16,
236 master_key: Option<&[u8; 32]>,
237 relays: Vec<Multiaddr>,
238 ) -> Result<Self> {
239 config::ensure_data_dir()?;
240 let session_persist_key = match master_key {
245 Some(mk) => storage::keychain::derive_subkey(mk, b"megolm-persist"),
246 None => [0u8; 32],
247 };
248 let db = storage::open_db(&config::db_path(), master_key)?;
249 Self::start_with_db_and_options(db, mode, port, session_persist_key, relays).await
250 }
251
252 pub async fn start_with_db(db: Db) -> Result<Self> {
253 Self::start_with_db_and_options(db, NetworkMode::Mdns, 0, [0u8; 32], Vec::new()).await
254 }
255
256 pub async fn start_with_db_and_options(
257 db: Db,
258 mode: NetworkMode,
259 port: u16,
260 session_persist_key: [u8; 32],
261 relays: Vec<Multiaddr>,
262 ) -> Result<Self> {
263 let identity = Self::load_or_create_identity(&db)?;
264 let identity = Arc::new(identity);
265 info!(fingerprint = %identity.fingerprint(), peer_id = %identity.peer_id(), mode = %mode.as_str(), port, relay_count = relays.len(), "identity loaded");
266
267 let (net_event_tx, net_event_rx) = tokio::sync::mpsc::channel::<NetworkEvent>(256);
268 let (app_event_tx, _) = broadcast::channel::<AppEvent>(256);
269 let network =
270 network::start_network_with(&identity, net_event_tx, mode, port, relays)?;
271
272 let active_rooms = Arc::new(Mutex::new(HashMap::new()));
273 let discovered_rooms = Arc::new(Mutex::new(HashMap::new()));
274 let restorable_rooms = Arc::new(Mutex::new(HashMap::new()));
275 let connected_dial_addrs = Arc::new(Mutex::new(HashMap::new()));
276 let file_manager = Arc::new(FileManager::new(&config::data_dir())?);
277
278 let handle = Self {
279 identity,
280 network,
281 mode,
282 active_rooms,
283 discovered_rooms,
284 restorable_rooms,
285 connected_dial_addrs,
286 file_manager,
287 db,
288 session_persist_key,
289 sas_flows: Arc::new(Mutex::new(HashMap::new())),
290 pending_code_secrets: Arc::new(Mutex::new(HashMap::new())),
291 pending_invite_dials: Arc::new(Mutex::new(HashMap::new())),
292 nat_reachable_addrs: Arc::new(Mutex::new(HashSet::new())),
293 relay_circuit_addrs: Arc::new(Mutex::new(HashSet::new())),
294 host_addr_dial_attempts: Arc::new(Mutex::new(HashMap::new())),
295 last_profile_broadcast_at_ms: Arc::new(Mutex::new(HashMap::new())),
296 app_event_tx,
297 };
298
299 handle.spawn_event_processor(net_event_rx);
300 handle.spawn_announcement_ticker();
301 handle.spawn_discovered_room_pruner();
302 handle.spawn_known_peer_reconnector();
303 handle.restore_rooms_from_db().await;
304
305 Ok(handle)
306 }
307
308 pub fn mode(&self) -> NetworkMode {
309 self.mode
310 }
311
312 pub fn subscribe(&self) -> broadcast::Receiver<AppEvent> {
313 self.app_event_tx.subscribe()
314 }
315
316 pub fn fingerprint(&self) -> &str {
317 self.identity.fingerprint()
318 }
319
320 pub fn peer_id(&self) -> PeerId {
321 self.identity.peer_id()
322 }
323
324 pub fn discovered_rooms(&self) -> Vec<DiscoveredRoom> {
325 let now = now_unix();
326 let our_fp = self.identity.fingerprint().to_string();
327 let mut by_id: HashMap<String, DiscoveredRoom> = self
328 .discovered_rooms
329 .lock()
330 .unwrap()
331 .clone();
332
333 for room in self.active_rooms.lock().unwrap().values() {
337 let entry = DiscoveredRoom {
338 room_id: room.info.id.clone(),
339 name: room.info.name.clone(),
340 encrypted: room.info.encrypted,
341 member_count: room.members.len() as u32,
342 creator_fingerprint: room.info.creator_fingerprint.clone(),
343 last_seen: now,
344 restorable: false,
345 host_addrs: Vec::new(),
346 kind: room.info.kind,
347 };
348 by_id
349 .entry(room.info.id.clone())
350 .and_modify(|d| {
351 d.last_seen = now;
352 if entry.member_count > d.member_count {
353 d.member_count = entry.member_count;
354 }
355 d.restorable = false;
356 d.kind = entry.kind;
357 })
358 .or_insert(entry);
359 }
360
361 for (id, stored) in self.restorable_rooms.lock().unwrap().iter() {
365 if by_id.contains_key(id) {
366 continue;
367 }
368 by_id.insert(
369 id.clone(),
370 DiscoveredRoom {
371 room_id: id.clone(),
372 name: stored.name.clone(),
373 encrypted: stored.encrypted,
374 member_count: 0,
375 creator_fingerprint: stored.creator_fingerprint.clone(),
376 last_seen: stored.last_active.unwrap_or(stored.created_at),
377 restorable: true,
378 host_addrs: Vec::new(),
379 kind: stored.kind,
380 },
381 );
382 }
383
384 by_id.retain(|room_id, d| {
392 if d.kind != RoomKind::Direct {
393 return true;
394 }
395 if self
398 .active_rooms
399 .lock()
400 .unwrap()
401 .contains_key(room_id)
402 {
403 return true;
404 }
405 canonical_dm_room_id(&our_fp, &d.creator_fingerprint) == *room_id
408 });
409
410 let mut v: Vec<DiscoveredRoom> = by_id.into_values().collect();
411 v.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
412 v
413 }
414
415 pub fn dm_partner_fingerprint(&self, room_id: &str) -> Option<String> {
420 let our_fp = self.identity.fingerprint().to_string();
421 let rooms = self.active_rooms.lock().unwrap();
422 let room = rooms.get(room_id)?;
423 if room.info.kind != RoomKind::Direct {
424 return None;
425 }
426 room.members
427 .iter()
428 .find(|m| **m != our_fp)
429 .cloned()
430 }
431
432 pub fn active_room_ids(&self) -> Vec<String> {
433 self.active_rooms.lock().unwrap().keys().cloned().collect()
434 }
435
436 pub fn active_room_info(&self, room_id: &str) -> Option<StoredRoom> {
437 self.active_rooms
438 .lock()
439 .unwrap()
440 .get(room_id)
441 .map(|r| r.info.clone())
442 }
443
444 pub fn room_members(&self, room_id: &str) -> Vec<String> {
445 self.active_rooms
446 .lock()
447 .unwrap()
448 .get(room_id)
449 .map(|r| {
450 let mut m: Vec<String> = r.members.iter().cloned().collect();
451 m.sort();
452 m
453 })
454 .unwrap_or_default()
455 }
456
457 pub fn room_messages(&self, room_id: &str, limit: i64) -> Result<Vec<repo::StoredRoomMessage>> {
458 repo::get_room_messages(&self.db, room_id, limit)
459 }
460
461 pub fn search_room_messages(
462 &self,
463 room_id: &str,
464 query: &str,
465 limit: i64,
466 ) -> Result<Vec<repo::StoredRoomMessage>> {
467 repo::search_room_messages(&self.db, room_id, query, limit)
468 }
469
470 pub async fn start_room(
478 &self,
479 name: &str,
480 encrypted: bool,
481 passphrase: Option<&str>,
482 kind: RoomKind,
483 ) -> Result<String> {
484 if encrypted && passphrase.is_none() {
485 return Err(HuddleError::Other(
486 "encrypted room requires a passphrase".into(),
487 ));
488 }
489
490 let created_at = now_unix();
491 let creator_fp = self.identity.fingerprint().to_string();
492 let room_id = derive_room_id(&creator_fp, name, created_at);
493
494 let (passphrase_salt, passphrase_key) = if encrypted {
495 let salt = passphrase::random_salt();
496 let key = passphrase::derive_key(passphrase.unwrap(), &salt)?;
497 (Some(salt.to_vec()), Some(key))
498 } else {
499 (None, None)
500 };
501
502 let info = StoredRoom {
503 id: room_id.clone(),
504 name: name.to_string(),
505 creator_fingerprint: creator_fp.clone(),
506 encrypted,
507 passphrase_salt: passphrase_salt.clone(),
508 created_at,
509 last_active: Some(created_at),
510 kind,
511 };
512 repo::insert_room(&self.db, &info)?;
513
514 let crypto = if encrypted {
515 Some(RoomCrypto::new_for_room(
516 self.db.clone(),
517 room_id.clone(),
518 creator_fp.clone(),
519 self.session_persist_key,
520 )?)
521 } else {
522 None
523 };
524
525 let mut members = HashSet::new();
526 members.insert(creator_fp.clone());
527
528 repo::upsert_room_member(
532 &self.db,
533 &StoredRoomMember {
534 room_id: room_id.clone(),
535 peer_id: String::new(),
536 fingerprint: creator_fp.clone(),
537 last_seen: Some(created_at),
538 verified: true, ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
540 role: "owner".into(),
541 },
542 )?;
543
544 self.active_rooms.lock().unwrap().insert(
545 room_id.clone(),
546 ActiveRoom {
547 info: info.clone(),
548 crypto,
549 passphrase_key,
550 members,
551 typers: HashMap::new(),
552 read_only: false,
553 issued_codes: Vec::new(),
554 },
555 );
556
557 self.network.subscribe_room(room_id.clone()).await;
558 self.announce_room_now(&info, 1).await;
559
560 let app = self.clone();
563 let rid = room_id.clone();
564 tokio::spawn(async move {
565 tokio::time::sleep(Duration::from_millis(500)).await;
566 if let Err(e) = app.broadcast_member_announce(&rid).await {
567 warn!(%e, "broadcast member announce");
568 }
569 });
570
571 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
572 room_id: room_id.clone(),
573 });
574
575 Ok(room_id)
576 }
577
578 pub async fn start_direct(&self, partner_fingerprint: &str) -> Result<String> {
602 let our_fp = self.identity.fingerprint().to_string();
603 if partner_fingerprint == our_fp {
604 return Err(HuddleError::Other("cannot DM yourself".into()));
605 }
606 let room_id = canonical_dm_room_id(&our_fp, partner_fingerprint);
607
608 if self.active_rooms.lock().unwrap().contains_key(&room_id) {
613 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
614 room_id: room_id.clone(),
615 });
616 return Ok(room_id);
617 }
618 if repo::get_room(&self.db, &room_id)?.is_some() {
619 return self.bootstrap_direct_room(&room_id, partner_fingerprint).await;
621 }
622
623 let created_at = now_unix();
624 let name = format!("dm-{}", short_fp_for_msg(partner_fingerprint));
628
629 let dm_salt = hex::decode(&room_id).unwrap_or_else(|_| room_id.as_bytes().to_vec());
636 let info = StoredRoom {
637 id: room_id.clone(),
638 name,
639 creator_fingerprint: our_fp.clone(),
640 encrypted: true,
641 passphrase_salt: Some(dm_salt),
642 created_at,
643 last_active: Some(created_at),
644 kind: RoomKind::Direct,
645 };
646 repo::insert_room(&self.db, &info)?;
647
648 let mut members = HashSet::new();
649 members.insert(our_fp.clone());
650 repo::upsert_room_member(
651 &self.db,
652 &StoredRoomMember {
653 room_id: room_id.clone(),
654 peer_id: String::new(),
655 fingerprint: our_fp.clone(),
656 last_seen: Some(created_at),
657 verified: true,
658 ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
659 role: "member".into(),
660 },
661 )?;
662
663 let passphrase_key = self.try_derive_dm_key(&room_id, partner_fingerprint);
670
671 let crypto = Some(RoomCrypto::new_for_room(
676 self.db.clone(),
677 room_id.clone(),
678 our_fp.clone(),
679 self.session_persist_key,
680 )?);
681
682 self.active_rooms.lock().unwrap().insert(
683 room_id.clone(),
684 ActiveRoom {
685 info: info.clone(),
686 crypto,
687 passphrase_key,
688 members,
689 typers: HashMap::new(),
690 read_only: false,
691 issued_codes: Vec::new(),
692 },
693 );
694
695 self.network.subscribe_room(room_id.clone()).await;
696 self.announce_room_now(&info, 1).await;
697
698 let app = self.clone();
699 let rid = room_id.clone();
700 tokio::spawn(async move {
701 tokio::time::sleep(Duration::from_millis(500)).await;
702 if let Err(e) = app.broadcast_member_announce(&rid).await {
703 warn!(%e, "broadcast member announce for DM");
704 }
705 });
706
707 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
708 room_id: room_id.clone(),
709 });
710 Ok(room_id)
711 }
712
713 fn derive_dm_key_from_pubkey_b64(
718 &self,
719 room_id: &str,
720 pubkey_b64: &str,
721 ) -> Option<[u8; KEY_LEN]> {
722 let bytes = B64.decode(pubkey_b64).ok()?;
723 if bytes.len() != 32 {
724 return None;
725 }
726 let mut pubkey = [0u8; 32];
727 pubkey.copy_from_slice(&bytes);
728 let our_seed = self.identity.secret_bytes();
729 match crate::crypto::dm::derive_dm_key(&our_seed, &pubkey, room_id) {
730 Ok(k) => Some(k),
731 Err(e) => {
732 warn!(%e, "DM key derivation (from announce) failed");
733 None
734 }
735 }
736 }
737
738 fn try_derive_dm_key(
743 &self,
744 room_id: &str,
745 partner_fingerprint: &str,
746 ) -> Option<[u8; KEY_LEN]> {
747 let pubkey_b64 = repo::lookup_peer_ed25519_pubkey(&self.db, partner_fingerprint)
748 .ok()
749 .flatten()?;
750 let bytes = B64.decode(&pubkey_b64).ok()?;
751 if bytes.len() != 32 {
752 return None;
753 }
754 let mut pubkey = [0u8; 32];
755 pubkey.copy_from_slice(&bytes);
756 let our_seed = self.identity.secret_bytes();
757 match crate::crypto::dm::derive_dm_key(&our_seed, &pubkey, room_id) {
758 Ok(k) => Some(k),
759 Err(e) => {
760 warn!(%e, %partner_fingerprint, "DM key derivation failed");
761 None
762 }
763 }
764 }
765
766 async fn bootstrap_direct_room(
772 &self,
773 room_id: &str,
774 partner_fingerprint: &str,
775 ) -> Result<String> {
776 let our_fp = self.identity.fingerprint().to_string();
777 let info = repo::get_room(&self.db, room_id)?
778 .ok_or_else(|| HuddleError::Other(format!("DM room {room_id} not found on disk")))?;
779 let mut members = HashSet::new();
780 members.insert(our_fp.clone());
781 members.insert(partner_fingerprint.to_string());
782
783 if let Ok(stored_members) = repo::list_room_members(&self.db, room_id) {
785 for m in stored_members {
786 members.insert(m.fingerprint);
787 }
788 }
789
790 let (passphrase_key, crypto) = if info.encrypted {
798 let pk = self.try_derive_dm_key(room_id, partner_fingerprint);
799 let c = Some(RoomCrypto::load(
800 self.db.clone(),
801 room_id.to_string(),
802 our_fp.clone(),
803 self.session_persist_key,
804 )?
805 .unwrap_or_else(|| {
806 RoomCrypto::new_for_room(
807 self.db.clone(),
808 room_id.to_string(),
809 our_fp.clone(),
810 self.session_persist_key,
811 )
812 .expect("create RoomCrypto for DM re-bootstrap")
813 }));
814 (pk, c)
815 } else {
816 (None, None)
817 };
818
819 self.active_rooms.lock().unwrap().insert(
820 room_id.to_string(),
821 ActiveRoom {
822 info: info.clone(),
823 crypto,
824 passphrase_key,
825 members,
826 typers: HashMap::new(),
827 read_only: false,
828 issued_codes: Vec::new(),
829 },
830 );
831
832 self.network.subscribe_room(room_id.to_string()).await;
833 self.announce_room_now(&info, 2).await;
834
835 let app = self.clone();
836 let rid = room_id.to_string();
837 tokio::spawn(async move {
838 tokio::time::sleep(Duration::from_millis(500)).await;
839 if let Err(e) = app.broadcast_member_announce(&rid).await {
840 warn!(%e, "broadcast member announce on DM bootstrap");
841 }
842 });
843
844 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
845 room_id: room_id.to_string(),
846 });
847 Ok(room_id.to_string())
848 }
849
850 pub async fn join_room(&self, room_id: &str, passphrase: Option<&str>) -> Result<()> {
854 let (name, creator_fingerprint, encrypted, salt_opt) = {
856 if let Some(d) = self.discovered_rooms.lock().unwrap().get(room_id).cloned() {
857 let salt = self.get_room_salt(room_id);
858 (d.name, d.creator_fingerprint, d.encrypted, salt)
859 } else if let Some(stored) = self.restorable_rooms.lock().unwrap().get(room_id).cloned()
860 {
861 (
862 stored.name,
863 stored.creator_fingerprint,
864 stored.encrypted,
865 stored.passphrase_salt,
866 )
867 } else if let Some(stored) = repo::get_room(&self.db, room_id)? {
868 (
869 stored.name,
870 stored.creator_fingerprint,
871 stored.encrypted,
872 stored.passphrase_salt,
873 )
874 } else {
875 return Err(HuddleError::Other(format!("room {room_id} not found")));
876 }
877 };
878
879 if encrypted && passphrase.is_none() {
880 return Err(HuddleError::Other(
881 "encrypted room requires a passphrase".into(),
882 ));
883 }
884
885 let passphrase_key = if encrypted {
886 let salt = salt_opt
887 .clone()
888 .ok_or_else(|| HuddleError::Other("missing salt for encrypted room".into()))?;
889 Some(passphrase::derive_key(passphrase.unwrap(), &salt)?)
890 } else {
891 None
892 };
893
894 let kind = self
899 .discovered_rooms
900 .lock()
901 .unwrap()
902 .get(room_id)
903 .map(|d| d.kind)
904 .or_else(|| {
905 repo::get_room(&self.db, room_id)
906 .ok()
907 .flatten()
908 .map(|r| r.kind)
909 })
910 .unwrap_or_default();
911
912 let info = StoredRoom {
913 id: room_id.to_string(),
914 name,
915 creator_fingerprint,
916 encrypted,
917 passphrase_salt: salt_opt.clone(),
918 created_at: now_unix(),
919 last_active: Some(now_unix()),
920 kind,
921 };
922 repo::insert_room(&self.db, &info)?;
923
924 let crypto = if encrypted {
925 let our_fp = self.identity.fingerprint().to_string();
928 let existing = RoomCrypto::load(
929 self.db.clone(),
930 room_id.to_string(),
931 our_fp.clone(),
932 self.session_persist_key,
933 )?;
934 Some(match existing {
935 Some(c) => c,
936 None => RoomCrypto::new_for_room(
937 self.db.clone(),
938 room_id.to_string(),
939 our_fp,
940 self.session_persist_key,
941 )?,
942 })
943 } else {
944 None
945 };
946
947 let mut members = HashSet::new();
948 members.insert(self.identity.fingerprint().to_string());
949
950 self.active_rooms.lock().unwrap().insert(
951 room_id.to_string(),
952 ActiveRoom {
953 info: info.clone(),
954 crypto,
955 passphrase_key,
956 members,
957 typers: HashMap::new(),
958 read_only: false,
959 issued_codes: Vec::new(),
960 },
961 );
962 self.restorable_rooms.lock().unwrap().remove(room_id);
964
965 self.network.subscribe_room(room_id.to_string()).await;
966
967 let app = self.clone();
968 let rid = room_id.to_string();
969 tokio::spawn(async move {
970 tokio::time::sleep(Duration::from_millis(500)).await;
971 if let Err(e) = app.broadcast_member_announce(&rid).await {
972 warn!(%e, "broadcast member announce");
973 }
974 let req = RoomMessage::SessionKeyRequest {
976 requester_fingerprint: app.identity.fingerprint().to_string(),
977 };
978 if let Ok(bytes) = encode_wire(&req) {
979 app.network.publish_room_message(rid.clone(), bytes).await;
980 }
981 });
982
983 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
984 room_id: room_id.to_string(),
985 });
986
987 Ok(())
988 }
989
990 async fn restore_rooms_from_db(&self) {
995 let rooms = match repo::list_rooms(&self.db) {
996 Ok(v) => v,
997 Err(e) => {
998 warn!(%e, "list rooms on restore");
999 return;
1000 }
1001 };
1002 let our_fp = self.identity.fingerprint().to_string();
1003 let count = rooms.len();
1004 for info in rooms {
1005 if info.encrypted {
1006 self.restorable_rooms
1007 .lock()
1008 .unwrap()
1009 .insert(info.id.clone(), info);
1010 continue;
1011 }
1012 let mut members = HashSet::new();
1013 members.insert(our_fp.clone());
1014 if let Ok(stored_members) = repo::list_room_members(&self.db, &info.id) {
1015 for m in stored_members {
1016 members.insert(m.fingerprint);
1017 }
1018 }
1019 let member_count = members.len() as u32;
1020 self.active_rooms.lock().unwrap().insert(
1021 info.id.clone(),
1022 ActiveRoom {
1023 info: info.clone(),
1024 crypto: None,
1025 passphrase_key: None,
1026 members,
1027 typers: HashMap::new(),
1028 read_only: false,
1029 issued_codes: Vec::new(),
1030 },
1031 );
1032 self.network.subscribe_room(info.id.clone()).await;
1033 self.announce_room_now(&info, member_count).await;
1034 info!(room_id = %info.id, name = %info.name, "restored room");
1035 }
1036 if count > 0 {
1037 debug!(count, "restored rooms from db");
1038 }
1039 }
1040
1041 pub async fn leave_room(&self, room_id: &str) -> Result<bool> {
1046 let leave_msg = RoomMessage::MemberLeave {
1048 sender_fingerprint: self.identity.fingerprint().to_string(),
1049 };
1050 let dispatched = match encode_wire(&leave_msg) {
1051 Ok(bytes) => {
1052 self.network
1053 .publish_room_message(room_id.to_string(), bytes)
1054 .await;
1055 true
1056 }
1057 Err(e) => {
1058 warn!(%e, %room_id, "failed to encode MemberLeave notice");
1059 false
1060 }
1061 };
1062
1063 self.active_rooms.lock().unwrap().remove(room_id);
1064 self.network.unsubscribe_room(room_id.to_string()).await;
1065
1066 let _ = self.app_event_tx.send(AppEvent::RoomLeft {
1067 room_id: room_id.to_string(),
1068 });
1069 Ok(dispatched)
1070 }
1071
1072 pub async fn send_room_message(&self, room_id: &str, body: &str) -> Result<()> {
1073 let our_fp = self.identity.fingerprint().to_string();
1074 let msg = {
1075 let mut rooms = self.active_rooms.lock().unwrap();
1076 let room = rooms
1077 .get_mut(room_id)
1078 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
1079
1080 if room.read_only {
1081 return Err(HuddleError::Other(
1082 "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(),
1083 ));
1084 }
1085
1086 if room.info.encrypted {
1087 let crypto = room
1088 .crypto
1089 .as_mut()
1090 .ok_or_else(|| HuddleError::Session("encrypted room missing crypto".into()))?;
1091 let (session_id, ct_bytes) = crypto.encrypt(body.as_bytes())?;
1092 RoomMessage::Encrypted {
1093 sender_fingerprint: our_fp.clone(),
1094 session_id,
1095 ciphertext_b64: base64::Engine::encode(
1096 &base64::engine::general_purpose::STANDARD,
1097 &ct_bytes,
1098 ),
1099 }
1100 } else {
1101 RoomMessage::Plain {
1102 sender_fingerprint: our_fp.clone(),
1103 body: body.to_string(),
1104 }
1105 }
1106 };
1107
1108 let bytes = encode_wire(&msg)?;
1109 self.network
1110 .publish_room_message(room_id.to_string(), bytes)
1111 .await;
1112
1113 let now = now_unix();
1114 let msg_id =
1115 repo::insert_room_message(&self.db, room_id, &our_fp, "out", body, now)?;
1116 repo::update_room_last_active(&self.db, room_id, now)?;
1117
1118 let _ = self.app_event_tx.send(AppEvent::MessageSent {
1119 room_id: room_id.to_string(),
1120 body: body.to_string(),
1121 message_id: msg_id,
1122 });
1123
1124 Ok(())
1125 }
1126
1127 pub async fn shutdown(&self) {
1128 self.network.shutdown().await;
1129 }
1130
1131 pub async fn dial_by_id_or_username(&self, input: &str) -> Result<()> {
1158 let trimmed = input.trim();
1159 if trimmed.is_empty() {
1160 return Err(HuddleError::Other("input is empty".into()));
1161 }
1162 let target_fp = if let Some(fp) = normalize_to_fingerprint(trimmed) {
1163 fp
1164 } else {
1165 let matches = repo::find_peers_by_username(&self.db, trimmed)?;
1166 if matches.is_empty() {
1167 return Err(HuddleError::Other(format!(
1168 "no peer named `{}` known yet — paste their invite link instead",
1169 trimmed
1170 )));
1171 }
1172 if matches.len() > 1 {
1173 return Err(HuddleError::Other(format!(
1174 "username `{}` is ambiguous ({} peers share it) — use their HD- ID instead",
1175 trimmed,
1176 matches.len()
1177 )));
1178 }
1179 matches.into_iter().next().unwrap()
1180 };
1181 if target_fp == self.identity.fingerprint() {
1182 return Err(HuddleError::Other("that's your own ID".into()));
1183 }
1184 let candidates = self.resolve_dial_addrs(&target_fp);
1185 if candidates.is_empty() {
1186 return Err(HuddleError::Other(format!(
1187 "haven't seen `{}` on the network yet — ask them for an invite link",
1188 short_fp_for_msg(&target_fp)
1189 )));
1190 }
1191 let now = now_unix();
1196 for addr in &candidates {
1197 let _ = repo::upsert_known_peer(
1198 &self.db,
1199 &KnownPeer {
1200 address: addr.clone(),
1201 label: None,
1202 last_connected_at: None,
1203 last_attempt_at: Some(now),
1204 created_at: now,
1205 fingerprint: Some(target_fp.clone()),
1206 trusted: false,
1207 },
1208 );
1209 }
1210 let multiaddrs: Vec<Multiaddr> = candidates
1214 .iter()
1215 .filter_map(|s| s.parse::<Multiaddr>().ok())
1216 .collect();
1217 if multiaddrs.is_empty() {
1218 return Err(HuddleError::Other(
1219 "every known address for that peer is malformed".into(),
1220 ));
1221 }
1222 let _ = self.app_event_tx.send(AppEvent::Dialing {
1223 address: candidates[0].clone(),
1224 });
1225 info!(
1226 target_fp = %target_fp,
1227 n = multiaddrs.len(),
1228 "dialing peer with {} candidate addresses",
1229 multiaddrs.len()
1230 );
1231 self.network.dial_addresses(multiaddrs).await;
1232 Ok(())
1233 }
1234
1235 fn resolve_dial_addrs(&self, fingerprint: &str) -> Vec<String> {
1243 let mut set: std::collections::HashSet<String> = std::collections::HashSet::new();
1244 for room in self.discovered_rooms.lock().unwrap().values() {
1245 if room.creator_fingerprint == fingerprint {
1246 for addr in &room.host_addrs {
1247 set.insert(addr.clone());
1248 }
1249 }
1250 }
1251 if let Ok(known) = repo::list_known_peers(&self.db) {
1252 for peer in known {
1253 if peer.fingerprint.as_deref() == Some(fingerprint) {
1254 set.insert(peer.address);
1255 }
1256 }
1257 }
1258 let mut v: Vec<String> = set.into_iter().collect();
1259 v.sort_by_key(|a| address_preference(a));
1260 v
1261 }
1262
1263 pub async fn dial(&self, input: &str) -> Result<()> {
1264 let multiaddr = parse_dial_address(input)?;
1265 let canonical = multiaddr.to_string();
1266 info!(%canonical, "dialing");
1267
1268 repo::upsert_known_peer(
1269 &self.db,
1270 &KnownPeer {
1271 address: canonical.clone(),
1272 label: None,
1273 last_connected_at: None,
1274 last_attempt_at: Some(now_unix()),
1275 created_at: now_unix(),
1276 fingerprint: None,
1280 trusted: false,
1281 },
1282 )?;
1283
1284 let _ = self.app_event_tx.send(AppEvent::Dialing {
1285 address: canonical.clone(),
1286 });
1287 self.network.dial(multiaddr).await;
1288 Ok(())
1289 }
1290
1291 pub fn nat_reachable_addrs(&self) -> Vec<String> {
1296 self.nat_reachable_addrs
1297 .lock()
1298 .unwrap()
1299 .iter()
1300 .cloned()
1301 .collect()
1302 }
1303
1304 pub fn dialable_addrs(&self) -> Vec<String> {
1312 let mut out: Vec<String> = self
1313 .relay_circuit_addrs
1314 .lock()
1315 .unwrap()
1316 .iter()
1317 .cloned()
1318 .collect();
1319 for a in self.nat_reachable_addrs.lock().unwrap().iter() {
1320 if !out.contains(a) {
1321 out.push(a.clone());
1322 }
1323 }
1324 out.truncate(4);
1325 out
1326 }
1327
1328 pub async fn dial_invite(&self, address: &str, claimed_fp: &str) -> Result<()> {
1341 let multiaddr = parse_dial_address(address)?;
1342 let canonical = multiaddr.to_string();
1343 self.pending_invite_dials
1344 .lock()
1345 .unwrap()
1346 .insert(canonical.clone(), claimed_fp.to_string());
1347 self.dial(address).await
1350 }
1351
1352 pub fn known_peers(&self) -> Vec<KnownPeerStatus> {
1353 let connected = self.connected_dial_addrs.lock().unwrap().clone();
1354 let stored = repo::list_known_peers(&self.db).unwrap_or_default();
1355 stored
1356 .into_iter()
1357 .map(|p| {
1358 let connected_peer = connected.get(&p.address).copied();
1359 KnownPeerStatus {
1360 address: p.address,
1361 label: p.label,
1362 last_connected_at: p.last_connected_at,
1363 connected_peer_id: connected_peer,
1364 }
1365 })
1366 .collect()
1367 }
1368
1369 pub async fn forget_peer(&self, address: &str) -> Result<()> {
1370 repo::forget_known_peer(&self.db, address)?;
1371 self.connected_dial_addrs.lock().unwrap().remove(address);
1372 Ok(())
1373 }
1374
1375 pub async fn redial(&self, address: &str) -> Result<()> {
1377 self.dial(address).await
1378 }
1379
1380 pub async fn accept_inbound(&self, peer_id: PeerId, address: &str) {
1385 self.network.accept_inbound(peer_id).await;
1386 self.connected_dial_addrs
1387 .lock()
1388 .unwrap()
1389 .insert(address.to_string(), peer_id);
1390 }
1391
1392 pub async fn reject_inbound(&self, peer_id: PeerId, fingerprint: &str) -> Result<()> {
1397 self.network.reject_inbound(peer_id).await;
1398 repo::block_peer(&self.db, fingerprint, now_unix())?;
1399 Ok(())
1400 }
1401
1402 pub async fn trust_inbound(
1405 &self,
1406 peer_id: PeerId,
1407 fingerprint: &str,
1408 address: &str,
1409 ) -> Result<()> {
1410 self.network.accept_inbound(peer_id).await;
1411 self.connected_dial_addrs
1412 .lock()
1413 .unwrap()
1414 .insert(address.to_string(), peer_id);
1415 repo::upsert_known_peer(
1419 &self.db,
1420 &KnownPeer {
1421 address: address.to_string(),
1422 label: None,
1423 last_connected_at: Some(now_unix()),
1424 last_attempt_at: Some(now_unix()),
1425 created_at: now_unix(),
1426 fingerprint: Some(fingerprint.to_string()),
1427 trusted: true,
1428 },
1429 )?;
1430 Ok(())
1431 }
1432
1433 fn spawn_known_peer_reconnector(&self) {
1434 let handle = self.clone();
1435 tokio::spawn(async move {
1436 tokio::time::sleep(Duration::from_millis(500)).await;
1438 let known = repo::list_known_peers(&handle.db).unwrap_or_default();
1439 for (i, peer) in known.into_iter().enumerate() {
1443 let handle = handle.clone();
1444 tokio::spawn(async move {
1445 let jitter = (peer.address.len() as u64 * 37) % 200;
1448 tokio::time::sleep(Duration::from_millis(150 * i as u64 + jitter)).await;
1449 if let Err(e) = handle.dial(&peer.address).await {
1450 debug!(%e, addr = %peer.address, "auto-reconnect failed");
1451 }
1452 });
1453 }
1454 });
1455 }
1456
1457 fn load_or_create_identity(db: &Db) -> Result<Identity> {
1462 if let Some(stored) = repo::load_identity(db)? {
1463 let mut bytes = [0u8; 32];
1464 bytes.copy_from_slice(&stored.ed25519_secret);
1465 Identity::from_secret_bytes(bytes)
1466 } else {
1467 let id = Identity::generate()?;
1468 repo::save_identity(db, &id.secret_bytes(), now_unix())?;
1469 Ok(id)
1470 }
1471 }
1472
1473 fn get_room_salt(&self, room_id: &str) -> Option<Vec<u8>> {
1474 self.active_rooms
1475 .lock()
1476 .unwrap()
1477 .get(room_id)
1478 .and_then(|r| r.info.passphrase_salt.clone())
1479 .or_else(|| {
1480 ROOM_SALT_CACHE
1482 .lock()
1483 .unwrap()
1484 .get(room_id)
1485 .cloned()
1486 })
1487 }
1488
1489 async fn announce_room_now(&self, info: &StoredRoom, member_count: u32) {
1490 let owner_fingerprints =
1491 repo::list_room_owners(&self.db, &info.id).unwrap_or_default();
1492 let verified_only = repo::get_room_verified_only(&self.db, &info.id).unwrap_or(false);
1493 let host_addrs = self.dialable_addrs();
1494 let ann = RoomAnnouncement {
1495 room_id: info.id.clone(),
1496 name: info.name.clone(),
1497 encrypted: info.encrypted,
1498 passphrase_salt: info.passphrase_salt.clone(),
1499 member_count,
1500 creator_fingerprint: info.creator_fingerprint.clone(),
1501 announced_at: now_unix(),
1502 owner_fingerprints,
1503 verified_only,
1504 host_addrs,
1505 kind: info.kind,
1506 };
1507 self.network.announce_room(ann).await;
1508 }
1509
1510 async fn broadcast_member_announce(&self, room_id: &str) -> Result<()> {
1511 let our_fp = self.identity.fingerprint().to_string();
1512 let wrapped = {
1513 let mut rooms = self.active_rooms.lock().unwrap();
1514 let room = rooms
1515 .get_mut(room_id)
1516 .ok_or_else(|| HuddleError::Other("not in room".into()))?;
1517 if room.info.encrypted {
1518 let crypto = room.crypto.as_mut().unwrap();
1519 let session_key = crypto.our_session_key_b64();
1520 match room.passphrase_key.as_ref() {
1521 Some(passphrase_key) => {
1522 Some(passphrase::wrap(session_key.as_bytes(), passphrase_key)?)
1523 }
1524 None if room.info.kind == RoomKind::Direct => {
1525 None
1535 }
1536 None => {
1537 return Err(HuddleError::Session("missing passphrase key".into()));
1538 }
1539 }
1540 } else {
1541 None
1542 }
1543 };
1544 let display_name = repo::get_display_name(&self.db).unwrap_or(None);
1545 let msg = RoomMessage::MemberAnnounce {
1546 sender_fingerprint: our_fp,
1547 wrapped_session_key: wrapped,
1548 display_name,
1549 sender_ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
1550 };
1551 let bytes = encode_wire(&msg)?;
1552 self.network
1553 .publish_room_message(room_id.to_string(), bytes)
1554 .await;
1555 Ok(())
1556 }
1557
1558 fn spawn_event_processor(&self, mut net_rx: tokio::sync::mpsc::Receiver<NetworkEvent>) {
1559 let handle = self.clone();
1560 tokio::spawn(async move {
1561 while let Some(event) = net_rx.recv().await {
1562 handle.process_network_event(event).await;
1563 }
1564 info!("event processor stopped");
1565 });
1566 }
1567
1568 fn spawn_announcement_ticker(&self) {
1569 let handle = self.clone();
1570 tokio::spawn(async move {
1571 let mut interval =
1572 tokio::time::interval(Duration::from_secs(ANNOUNCE_INTERVAL_SECS));
1573 interval.tick().await; loop {
1575 interval.tick().await;
1576 let snapshot: Vec<(StoredRoom, u32)> = {
1577 let active = handle.active_rooms.lock().unwrap();
1578 active
1579 .values()
1580 .map(|r| (r.info.clone(), r.members.len() as u32))
1581 .collect()
1582 };
1583 for (info, member_count) in snapshot {
1584 handle.announce_room_now(&info, member_count).await;
1585 }
1586 }
1587 });
1588 }
1589
1590 fn spawn_discovered_room_pruner(&self) {
1591 let handle = self.clone();
1592 tokio::spawn(async move {
1593 let mut interval = tokio::time::interval(Duration::from_secs(10));
1594 interval.tick().await;
1595 loop {
1596 interval.tick().await;
1597 let now = now_unix();
1598 let mut to_drop = Vec::new();
1599 {
1600 let mut map = handle.discovered_rooms.lock().unwrap();
1601 map.retain(|id, r| {
1602 if now - r.last_seen > DISCOVERED_TTL_SECS {
1603 to_drop.push(id.clone());
1604 false
1605 } else {
1606 true
1607 }
1608 });
1609 }
1610 for id in to_drop {
1611 let _ = handle.app_event_tx.send(AppEvent::RoomLost { room_id: id });
1612 }
1613 }
1614 });
1615 }
1616
1617 async fn process_network_event(&self, event: NetworkEvent) {
1618 match event {
1619 NetworkEvent::PeerDiscovered { peer_id } => {
1620 let _ = self.app_event_tx.send(AppEvent::PeerDiscovered { peer_id });
1621 }
1622 NetworkEvent::PeerExpired { peer_id } => {
1623 self.connected_dial_addrs
1629 .lock()
1630 .unwrap()
1631 .retain(|_addr, pid| *pid != peer_id);
1632 let _ = self.app_event_tx.send(AppEvent::PeerExpired { peer_id });
1633 }
1634 NetworkEvent::ListeningOn { address } => {
1635 let _ = self.app_event_tx.send(AppEvent::ListeningOn {
1636 address: address.to_string(),
1637 });
1638 }
1639 NetworkEvent::RoomAnnouncementReceived(ann) => {
1640 if let Some(salt) = &ann.passphrase_salt {
1642 ROOM_SALT_CACHE
1643 .lock()
1644 .unwrap()
1645 .insert(ann.room_id.clone(), salt.clone());
1646 }
1647 let our_fp_for_dial = self.identity.fingerprint().to_string();
1652 if ann.creator_fingerprint != our_fp_for_dial && !ann.host_addrs.is_empty() {
1653 let now = now_unix();
1654 let should_dial = {
1655 let mut attempts = self.host_addr_dial_attempts.lock().unwrap();
1656 match attempts.get(&ann.creator_fingerprint).copied() {
1657 Some(last) if now - last < HOST_ADDR_DIAL_BACKOFF_SECS => false,
1658 _ => {
1659 attempts.insert(ann.creator_fingerprint.clone(), now);
1660 true
1661 }
1662 }
1663 };
1664 if should_dial {
1665 if let Some(first) = ann.host_addrs.first() {
1666 info!(
1667 announcer = %ann.creator_fingerprint,
1668 addr = %first,
1669 "opportunistic dial via room announcement host_addrs"
1670 );
1671 let _ = self.dial(first).await;
1674 }
1675 }
1676 }
1677 let discovered = DiscoveredRoom {
1678 room_id: ann.room_id.clone(),
1679 name: ann.name.clone(),
1680 encrypted: ann.encrypted,
1681 member_count: ann.member_count,
1682 creator_fingerprint: ann.creator_fingerprint.clone(),
1683 last_seen: now_unix(),
1684 restorable: false,
1685 host_addrs: ann.host_addrs.clone(),
1686 kind: ann.kind,
1687 };
1688 if self.active_rooms.lock().unwrap().contains_key(&ann.room_id) {
1693 self.discovered_rooms
1694 .lock()
1695 .unwrap()
1696 .insert(ann.room_id.clone(), discovered);
1697 return;
1698 }
1699 if ann.kind == RoomKind::Direct {
1709 let our_fp_for_filter = self.identity.fingerprint().to_string();
1710 if canonical_dm_room_id(&our_fp_for_filter, &ann.creator_fingerprint)
1711 != ann.room_id
1712 {
1713 debug!(
1714 announcer = %ann.creator_fingerprint,
1715 room_id = %ann.room_id,
1716 "dropping Direct announcement: not addressed to us"
1717 );
1718 return;
1719 }
1720 self.discovered_rooms
1725 .lock()
1726 .unwrap()
1727 .insert(ann.room_id.clone(), discovered.clone());
1728 let _ = self
1729 .app_event_tx
1730 .send(AppEvent::RoomDiscovered(discovered.clone()));
1731 let app = self.clone();
1732 let partner = ann.creator_fingerprint.clone();
1733 let rid = ann.room_id.clone();
1734 tokio::spawn(async move {
1735 if let Err(e) = app.start_direct(&partner).await {
1736 debug!(%e, room_id = %rid, "auto-bootstrap of inbound DM failed");
1737 }
1738 });
1739 return;
1740 }
1741 self.discovered_rooms
1742 .lock()
1743 .unwrap()
1744 .insert(ann.room_id.clone(), discovered.clone());
1745 let _ = self.app_event_tx.send(AppEvent::RoomDiscovered(discovered));
1746 }
1747 NetworkEvent::RoomMessageReceived {
1748 room_id,
1749 payload,
1750 from_peer: _,
1751 } => {
1752 let wire: WireMessage = match serde_json::from_slice(&payload) {
1759 Ok(w) => w,
1760 Err(e) => {
1761 warn!(%e, "bad wire envelope");
1762 return;
1763 }
1764 };
1765 let (msg, verified_signer) = match wire {
1766 WireMessage::Plain(m) => (m, None),
1767 WireMessage::Signed(env) => {
1768 let claimed_pubkey = env.ed25519_pubkey_b64.clone();
1769 match crate::crypto::verify_signed(&env) {
1770 Ok((m, fp)) => {
1771 match repo::get_member_ed25519_pubkey(
1778 &self.db, &room_id, &fp,
1779 ) {
1780 Ok(Some(known)) if known != claimed_pubkey => {
1781 warn!(
1782 %fp, %room_id,
1783 "pubkey mismatch vs stored; dropping signed message"
1784 );
1785 return;
1786 }
1787 _ => {}
1788 }
1789 (m, Some(fp))
1790 }
1791 Err(e) => {
1792 warn!(%e, fp = %env.fingerprint, "signed envelope verify failed");
1793 return;
1794 }
1795 }
1796 }
1797 };
1798 self.handle_room_message(&room_id, msg, verified_signer).await;
1799 }
1800 NetworkEvent::DialSucceeded { peer_id, address } => {
1801 let addr_s = address.to_string();
1802 self.connected_dial_addrs
1803 .lock()
1804 .unwrap()
1805 .insert(addr_s.clone(), peer_id);
1806 let _ = repo::upsert_known_peer(
1810 &self.db,
1811 &KnownPeer {
1812 address: addr_s.clone(),
1813 label: None,
1814 last_connected_at: Some(now_unix()),
1815 last_attempt_at: Some(now_unix()),
1816 created_at: now_unix(),
1817 fingerprint: None,
1818 trusted: false,
1819 },
1820 );
1821 let _ = self.app_event_tx.send(AppEvent::DialSucceeded {
1822 address: addr_s,
1823 peer_id,
1824 });
1825 }
1826 NetworkEvent::DialFailed { address, error } => {
1827 let addr_s = address.to_string();
1828 let _ = self.app_event_tx.send(AppEvent::DialFailed {
1829 address: addr_s,
1830 error,
1831 });
1832 }
1833 NetworkEvent::PeerIdentified { peer_id, fingerprint } => {
1834 let matched_addrs: Vec<String> = {
1840 let map = self.connected_dial_addrs.lock().unwrap();
1841 map.iter()
1842 .filter_map(|(addr, pid)| {
1843 if *pid == peer_id {
1844 Some(addr.clone())
1845 } else {
1846 None
1847 }
1848 })
1849 .collect()
1850 };
1851 let mismatch = {
1861 let mut map = self.pending_invite_dials.lock().unwrap();
1862 let mut found: Option<(String, String)> = None;
1863 for addr in &matched_addrs {
1864 if let Some(claimed) = map.remove(addr) {
1865 if claimed != fingerprint {
1866 found = Some((addr.clone(), claimed));
1867 break;
1868 }
1869 }
1870 }
1871 found
1872 };
1873 if let Some((addr, claimed)) = mismatch {
1874 warn!(
1875 %addr, %claimed, actual=%fingerprint,
1876 "invite fingerprint mismatch — disconnecting"
1877 );
1878 self.network.disconnect_peer(peer_id).await;
1879 let _ = self.app_event_tx.send(AppEvent::InviteFingerprintMismatch {
1880 address: addr,
1881 claimed,
1882 actual: fingerprint.clone(),
1883 });
1884 return;
1885 }
1886 for addr in matched_addrs {
1887 let _ = repo::upsert_known_peer(
1888 &self.db,
1889 &KnownPeer {
1890 address: addr,
1891 label: None,
1892 last_connected_at: Some(now_unix()),
1893 last_attempt_at: Some(now_unix()),
1894 created_at: now_unix(),
1895 fingerprint: Some(fingerprint.clone()),
1896 trusted: true,
1897 },
1898 );
1899 }
1900 let our_username = repo::get_display_name(&self.db).unwrap_or(None);
1908 if our_username.is_some() {
1909 let now_ms = now_unix_ms();
1910 let should_send = {
1911 let mut last = self.last_profile_broadcast_at_ms.lock().unwrap();
1912 match last.get(&fingerprint) {
1913 Some(prev) if now_ms - prev < PROFILE_REBROADCAST_FLOOR_MS => false,
1914 _ => {
1915 last.insert(fingerprint.clone(), now_ms);
1916 true
1917 }
1918 }
1919 };
1920 if should_send {
1921 let msg = RoomMessage::ProfileUpdate {
1922 sender_fingerprint: self.identity.fingerprint().to_string(),
1923 username: our_username,
1924 updated_at: now_ms,
1925 };
1926 if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
1927 if let Ok(bytes) =
1928 crate::network::protocol::encode_wire_signed(&env)
1929 {
1930 let rooms: Vec<String> = self
1931 .active_rooms
1932 .lock()
1933 .unwrap()
1934 .keys()
1935 .cloned()
1936 .collect();
1937 for room_id in rooms {
1938 self.network
1939 .publish_room_message(room_id, bytes.clone())
1940 .await;
1941 }
1942 }
1943 }
1944 }
1945 }
1946 }
1947 NetworkEvent::RelayReservationEstablished { address } => {
1948 info!(addr = %address, "relay reservation established");
1953 self.relay_circuit_addrs
1954 .lock()
1955 .unwrap()
1956 .insert(address.to_string());
1957 let _ = self.app_event_tx.send(AppEvent::ListeningOn {
1958 address: address.to_string(),
1959 });
1960 }
1961 NetworkEvent::NatProbeResult {
1962 tested_addr,
1963 reachable,
1964 } => {
1965 let addr_s = tested_addr.to_string();
1966 let (transitioned, becomes_reachable) = {
1967 let mut set = self.nat_reachable_addrs.lock().unwrap();
1968 let was_empty = set.is_empty();
1969 if reachable {
1970 set.insert(addr_s.clone());
1971 } else {
1972 set.remove(&addr_s);
1973 }
1974 let is_empty = set.is_empty();
1975 (was_empty != is_empty, !is_empty)
1976 };
1977 if transitioned {
1978 let label = if becomes_reachable {
1979 "reachable".to_string()
1980 } else {
1981 "private".to_string()
1982 };
1983 info!(reachable = %becomes_reachable, "NAT reachability changed");
1984 let _ = self.app_event_tx.send(AppEvent::NatStatusChanged {
1985 label,
1986 reachable: becomes_reachable,
1987 });
1988 }
1989 }
1990 NetworkEvent::DcutrUpgrade {
1991 remote_peer,
1992 success,
1993 } => {
1994 if success {
1995 let s = remote_peer.to_base58();
1999 let tail: String = s.chars().rev().take(8).collect::<String>()
2000 .chars()
2001 .rev()
2002 .collect();
2003 let _ = self.app_event_tx.send(AppEvent::DcutrSucceeded {
2004 peer_label: tail,
2005 });
2006 }
2007 }
2008 NetworkEvent::InboundDial {
2009 peer_id,
2010 fingerprint,
2011 address,
2012 } => {
2013 if repo::is_peer_blocked(&self.db, &fingerprint).unwrap_or(false) {
2015 info!(%fingerprint, "inbound dial auto-rejected: peer is blocked");
2016 self.network.reject_inbound(peer_id).await;
2017 return;
2018 }
2019 let global_verified_only =
2024 repo::get_setting(&self.db, "verified_only_inbound")
2025 .ok()
2026 .flatten()
2027 .map(|v| v == "1")
2028 .unwrap_or(false);
2029 if global_verified_only {
2030 let is_verified =
2031 repo::is_globally_verified(&self.db, &fingerprint).unwrap_or(false)
2032 || repo::is_fingerprint_trusted(&self.db, &fingerprint)
2033 .unwrap_or(false);
2034 if !is_verified {
2035 info!(
2036 %fingerprint,
2037 "inbound dial auto-rejected: verified-only mode"
2038 );
2039 self.network.reject_inbound(peer_id).await;
2040 return;
2041 }
2042 }
2043 if repo::is_fingerprint_trusted(&self.db, &fingerprint).unwrap_or(false) {
2044 info!(%fingerprint, "inbound dial auto-accepted: peer is trusted");
2045 self.connected_dial_addrs
2048 .lock()
2049 .unwrap()
2050 .insert(address.to_string(), peer_id);
2051 let _ = repo::upsert_known_peer(
2052 &self.db,
2053 &KnownPeer {
2054 address: address.to_string(),
2055 label: None,
2056 last_connected_at: Some(now_unix()),
2057 last_attempt_at: Some(now_unix()),
2058 created_at: now_unix(),
2059 fingerprint: Some(fingerprint),
2060 trusted: true,
2061 },
2062 );
2063 self.network.accept_inbound(peer_id).await;
2064 return;
2065 }
2066 let _ = self.app_event_tx.send(AppEvent::InboundDial {
2068 peer_id,
2069 fingerprint,
2070 address: address.to_string(),
2071 });
2072 }
2073 }
2074 }
2075
2076 async fn handle_room_message(
2082 &self,
2083 room_id: &str,
2084 msg: RoomMessage,
2085 verified_signer: Option<String>,
2086 ) {
2087 let our_fp = self.identity.fingerprint().to_string();
2088 match msg {
2089 RoomMessage::MemberAnnounce {
2090 sender_fingerprint,
2091 wrapped_session_key,
2092 display_name,
2093 sender_ed25519_pubkey,
2094 } => {
2095 if sender_fingerprint == our_fp {
2096 return;
2097 }
2098 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2101 .unwrap_or(false)
2102 {
2103 info!(%sender_fingerprint, %room_id, "dropping MemberAnnounce from banned peer");
2104 return;
2105 }
2106 if repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
2113 && !repo::is_globally_verified(&self.db, &sender_fingerprint).unwrap_or(false)
2114 {
2115 info!(
2116 %sender_fingerprint, %room_id,
2117 "dropping MemberAnnounce: room is verified-only and joiner isn't verified"
2118 );
2119 let owners = repo::list_room_owners(&self.db, room_id).unwrap_or_default();
2120 let lowest_owner = owners.iter().min().cloned();
2121 if lowest_owner.as_deref() == Some(&our_fp) {
2122 let msg = RoomMessage::JoinRefused {
2123 room_id: room_id.to_string(),
2124 target_fingerprint: sender_fingerprint.clone(),
2125 reason: "room requires SAS verification — ask an existing member to verify you".into(),
2126 };
2127 if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
2128 if let Ok(bytes) =
2129 crate::network::protocol::encode_wire_signed(&env)
2130 {
2131 self.network
2132 .publish_room_message(room_id.to_string(), bytes)
2133 .await;
2134 }
2135 }
2136 }
2137 return;
2138 }
2139 let need_inbound = {
2140 let mut rooms = self.active_rooms.lock().unwrap();
2141 let room = match rooms.get_mut(room_id) {
2142 Some(r) => r,
2143 None => return,
2144 };
2145 if room.info.kind == RoomKind::Direct
2153 && !room.members.contains(&sender_fingerprint)
2154 && room.members.len() >= 2
2155 {
2156 info!(
2157 %sender_fingerprint, %room_id,
2158 "dropping MemberAnnounce on Direct room: already at 2-member cap"
2159 );
2160 return;
2161 }
2162 let newly_added = room.members.insert(sender_fingerprint.clone());
2163 if newly_added {
2164 let _ = self.app_event_tx.send(AppEvent::MemberJoined {
2165 room_id: room_id.to_string(),
2166 fingerprint: sender_fingerprint.clone(),
2167 });
2168 }
2169 let _ = repo::upsert_room_member(
2174 &self.db,
2175 &StoredRoomMember {
2176 room_id: room_id.to_string(),
2177 peer_id: String::new(), fingerprint: sender_fingerprint.clone(),
2179 last_seen: Some(now_unix()),
2180 verified: false,
2181 ed25519_pubkey: sender_ed25519_pubkey.clone(),
2182 role: "member".into(),
2188 },
2189 );
2190 if let Some(name) = display_name.as_deref() {
2191 let _ = repo::set_member_display_name(
2192 &self.db,
2193 room_id,
2194 &sender_fingerprint,
2195 Some(name),
2196 );
2197 }
2198 room.info.encrypted && wrapped_session_key.is_some()
2199 };
2200
2201 if matches!(
2208 self.active_rooms
2209 .lock()
2210 .unwrap()
2211 .get(room_id)
2212 .map(|r| (r.info.kind, r.passphrase_key.is_none())),
2213 Some((RoomKind::Direct, true))
2214 ) {
2215 if let Some(pubkey_b64) = sender_ed25519_pubkey.as_deref() {
2216 if let Some(key) =
2217 self.derive_dm_key_from_pubkey_b64(room_id, pubkey_b64)
2218 {
2219 let mut rooms = self.active_rooms.lock().unwrap();
2220 if let Some(room) = rooms.get_mut(room_id) {
2221 room.passphrase_key = Some(key);
2222 }
2223 drop(rooms);
2224 let app = self.clone();
2229 let rid = room_id.to_string();
2230 tokio::spawn(async move {
2231 if let Err(e) = app.broadcast_member_announce(&rid).await {
2232 warn!(%e, "re-broadcast DM announce after key derivation");
2233 }
2234 });
2235 }
2236 }
2237 }
2238
2239 if need_inbound {
2240 let wrapped = wrapped_session_key.unwrap();
2241 let result = {
2242 let mut rooms = self.active_rooms.lock().unwrap();
2243 let room = rooms.get_mut(room_id).unwrap();
2244 let passphrase_key = match &room.passphrase_key {
2245 Some(k) => k,
2246 None => {
2247 warn!("no passphrase key when receiving session key");
2248 return;
2249 }
2250 };
2251 match passphrase::unwrap(&wrapped, passphrase_key) {
2252 Ok(plain) => match String::from_utf8(plain) {
2253 Ok(key_b64) => {
2254 let crypto = room.crypto.as_mut().unwrap();
2255 crypto.add_inbound_session(&sender_fingerprint, &key_b64)
2256 }
2257 Err(e) => Err(HuddleError::Session(format!("utf8: {e}"))),
2258 },
2259 Err(e) => Err(e),
2260 }
2261 };
2262 if let Err(e) = result {
2263 error!(%e, "add inbound session failed");
2264 }
2265 }
2266 }
2267 RoomMessage::SessionKeyRequest {
2268 requester_fingerprint,
2269 } => {
2270 if requester_fingerprint == our_fp {
2271 return;
2272 }
2273 if let Err(e) = self.broadcast_member_announce(room_id).await {
2275 warn!(%e, "broadcast member announce on request");
2276 }
2277 }
2278 RoomMessage::Encrypted {
2279 sender_fingerprint,
2280 session_id,
2281 ciphertext_b64,
2282 } => {
2283 if sender_fingerprint == our_fp {
2284 return;
2285 }
2286 let ct_bytes = match base64::Engine::decode(
2287 &base64::engine::general_purpose::STANDARD,
2288 &ciphertext_b64,
2289 ) {
2290 Ok(b) => b,
2291 Err(e) => {
2292 warn!(%e, "bad base64 ciphertext");
2293 return;
2294 }
2295 };
2296 let plaintext = {
2297 let mut rooms = self.active_rooms.lock().unwrap();
2298 let room = match rooms.get_mut(room_id) {
2299 Some(r) => r,
2300 None => return,
2301 };
2302 let crypto = match room.crypto.as_mut() {
2303 Some(c) => c,
2304 None => return,
2305 };
2306 crypto.decrypt(&sender_fingerprint, &session_id, &ct_bytes)
2307 };
2308 match plaintext {
2309 Ok(pt) => {
2310 let body = String::from_utf8_lossy(&pt).to_string();
2311 let sent_at = now_unix();
2312 let _ = repo::insert_room_message(
2313 &self.db,
2314 room_id,
2315 &sender_fingerprint,
2316 "in",
2317 &body,
2318 sent_at,
2319 );
2320 let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
2321 self.maybe_emit_mention(room_id, &body);
2322 let _ = self.app_event_tx.send(AppEvent::MessageReceived {
2323 room_id: room_id.to_string(),
2324 sender_fingerprint,
2325 body,
2326 sent_at,
2327 });
2328 }
2329 Err(e) => {
2330 debug!(%e, "decrypt failed (probably missing session key)");
2331 }
2332 }
2333 }
2334 RoomMessage::Plain {
2335 sender_fingerprint,
2336 body,
2337 } => {
2338 if sender_fingerprint == our_fp {
2339 return;
2340 }
2341 let sent_at = now_unix();
2342 let _ = repo::insert_room_message(
2343 &self.db,
2344 room_id,
2345 &sender_fingerprint,
2346 "in",
2347 &body,
2348 sent_at,
2349 );
2350 let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
2351 self.maybe_emit_mention(room_id, &body);
2352 let _ = self.app_event_tx.send(AppEvent::MessageReceived {
2353 room_id: room_id.to_string(),
2354 sender_fingerprint,
2355 body,
2356 sent_at,
2357 });
2358 }
2359 RoomMessage::Typing { sender_fingerprint } => {
2360 if sender_fingerprint == our_fp {
2361 return;
2362 }
2363 let expiry = now_unix() + TYPING_TTL_SECS;
2364 let mut rooms = self.active_rooms.lock().unwrap();
2365 if let Some(room) = rooms.get_mut(room_id) {
2366 room.typers.insert(sender_fingerprint, expiry);
2367 }
2368 drop(rooms);
2369 let _ = self.app_event_tx.send(AppEvent::TypingChanged {
2370 room_id: room_id.to_string(),
2371 });
2372 }
2373 RoomMessage::RotateRoomKey {
2374 rotator_fingerprint,
2375 new_salt,
2376 } => {
2377 if rotator_fingerprint == our_fp {
2378 return;
2379 }
2380 let signer = match verified_signer {
2385 Some(fp) => fp,
2386 None => {
2387 warn!(%room_id, "RotateRoomKey arrived unsigned; dropping");
2388 return;
2389 }
2390 };
2391 if signer != rotator_fingerprint {
2392 warn!(
2393 %signer, %rotator_fingerprint, %room_id,
2394 "RotateRoomKey signer mismatch with claimed rotator; dropping"
2395 );
2396 return;
2397 }
2398 let _ = self.app_event_tx.send(AppEvent::RotationRequested {
2399 room_id: room_id.to_string(),
2400 rotator_fingerprint,
2401 new_salt,
2402 });
2403 }
2404 RoomMessage::MemberLeave { sender_fingerprint } => {
2405 if sender_fingerprint == our_fp {
2406 return;
2407 }
2408 let removed = {
2409 let mut rooms = self.active_rooms.lock().unwrap();
2410 if let Some(room) = rooms.get_mut(room_id) {
2411 room.members.remove(&sender_fingerprint)
2412 } else {
2413 false
2414 }
2415 };
2416 if removed {
2417 let _ = self.app_event_tx.send(AppEvent::MemberLeft {
2418 room_id: room_id.to_string(),
2419 fingerprint: sender_fingerprint,
2420 });
2421 }
2422 }
2423 RoomMessage::FileOffer {
2424 sender_fingerprint,
2425 file_id,
2426 name,
2427 size_bytes,
2428 mime,
2429 chunk_count,
2430 encrypted_meta,
2431 } => {
2432 if sender_fingerprint == our_fp {
2433 return; }
2435 self.handle_file_offer(
2436 room_id,
2437 sender_fingerprint,
2438 file_id,
2439 name,
2440 size_bytes,
2441 mime,
2442 chunk_count,
2443 encrypted_meta,
2444 );
2445 }
2446 RoomMessage::FileChunk {
2447 sender_fingerprint,
2448 file_id,
2449 chunk_index,
2450 total_chunks,
2451 data_b64,
2452 } => {
2453 if sender_fingerprint == our_fp {
2454 return;
2455 }
2456 self.handle_file_chunk(
2457 room_id,
2458 sender_fingerprint,
2459 file_id,
2460 chunk_index,
2461 total_chunks,
2462 data_b64,
2463 );
2464 }
2465 RoomMessage::OwnerGrant {
2466 room_id: announced_room_id,
2467 target_fingerprint,
2468 } => {
2469 if announced_room_id != room_id {
2474 warn!(payload_room = %announced_room_id, topic_room = %room_id, "OwnerGrant room mismatch");
2475 return;
2476 }
2477 let signer = match verified_signer {
2478 Some(fp) => fp,
2479 None => {
2480 warn!(%room_id, "OwnerGrant arrived unsigned; dropping");
2481 return;
2482 }
2483 };
2484 if !self.is_owner(room_id, &signer) {
2485 warn!(%signer, %room_id, "OwnerGrant signer isn't an owner; dropping");
2486 return;
2487 }
2488 info!(%signer, %target_fingerprint, %room_id, "OwnerGrant applied");
2489 if let Err(e) =
2490 repo::set_member_role(&self.db, room_id, &target_fingerprint, "owner")
2491 {
2492 warn!(%e, "OwnerGrant: set_member_role failed");
2493 }
2494 }
2495 RoomMessage::BanMember {
2496 room_id: announced_room_id,
2497 target_fingerprint,
2498 } => {
2499 if announced_room_id != room_id {
2500 warn!(payload_room = %announced_room_id, topic_room = %room_id, "BanMember room mismatch");
2501 return;
2502 }
2503 let signer = match verified_signer {
2504 Some(fp) => fp,
2505 None => {
2506 warn!(%room_id, "BanMember arrived unsigned; dropping");
2507 return;
2508 }
2509 };
2510 if !self.is_owner(room_id, &signer) {
2511 warn!(%signer, %room_id, "BanMember signer isn't an owner; dropping");
2512 return;
2513 }
2514 if target_fingerprint == our_fp {
2515 info!(%room_id, %signer, "we were kicked from this room");
2521 self.active_rooms.lock().unwrap().remove(room_id);
2522 let _ = self.app_event_tx.send(AppEvent::RoomLeft {
2523 room_id: room_id.to_string(),
2524 });
2525 return;
2526 }
2527 info!(%signer, %target_fingerprint, %room_id, "BanMember applied");
2528 if let Err(e) = repo::add_room_ban(
2529 &self.db,
2530 room_id,
2531 &target_fingerprint,
2532 &signer,
2533 "", now_unix(),
2535 ) {
2536 warn!(%e, "BanMember: add_room_ban failed");
2537 }
2538 self.evict_banned_member(room_id, &target_fingerprint);
2539 }
2540 RoomMessage::SasInit {
2541 tx_id,
2542 ephemeral_x25519_pubkey_b64,
2543 target_fingerprint,
2544 } => {
2545 if target_fingerprint != our_fp {
2546 return;
2551 }
2552 let signer = match verified_signer {
2553 Some(fp) => fp,
2554 None => {
2555 warn!("SasInit arrived unsigned; dropping");
2556 return;
2557 }
2558 };
2559 let their_pub =
2560 match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
2561 Ok(pk) => pk,
2562 Err(e) => {
2563 warn!(%e, "SasInit: bad x25519 pubkey");
2564 return;
2565 }
2566 };
2567 let tx_id_bytes = match B64.decode(&tx_id) {
2568 Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
2569 let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
2570 arr.copy_from_slice(&b);
2571 arr
2572 }
2573 _ => {
2574 warn!(%tx_id, "SasInit: bad tx_id length");
2575 return;
2576 }
2577 };
2578 let (_, our_secret, our_pub) = crate::crypto::sas::new_session();
2579 let sas_code =
2580 crate::crypto::sas::derive_sas_code(&our_secret, &their_pub, &tx_id_bytes);
2581 self.sas_flows.lock().unwrap().insert(
2582 tx_id.clone(),
2583 SasFlow {
2584 room_id: room_id.to_string(),
2585 partner_fingerprint: signer.clone(),
2586 our_secret,
2587 sas_code: Some(sas_code.clone()),
2588 our_confirmed: false,
2589 their_confirmed: false,
2590 },
2591 );
2592 let response = RoomMessage::SasResponse {
2595 tx_id: tx_id.clone(),
2596 ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2597 };
2598 if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
2599 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
2600 self.network
2601 .publish_room_message(room_id.to_string(), bytes)
2602 .await;
2603 }
2604 }
2605 let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
2606 room_id: room_id.to_string(),
2607 partner_fingerprint: signer,
2608 tx_id,
2609 emoji_string: sas_code.emoji_string(),
2610 emoji_labels: sas_code.emoji_labels(),
2611 decimal: sas_code.decimal,
2612 });
2613 }
2614 RoomMessage::SasResponse {
2615 tx_id,
2616 ephemeral_x25519_pubkey_b64,
2617 } => {
2618 let signer = match verified_signer {
2619 Some(fp) => fp,
2620 None => {
2621 warn!("SasResponse arrived unsigned; dropping");
2622 return;
2623 }
2624 };
2625 let their_pub =
2626 match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
2627 Ok(pk) => pk,
2628 Err(e) => {
2629 warn!(%e, "SasResponse: bad x25519 pubkey");
2630 return;
2631 }
2632 };
2633 let tx_id_bytes = match B64.decode(&tx_id) {
2634 Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
2635 let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
2636 arr.copy_from_slice(&b);
2637 arr
2638 }
2639 _ => return,
2640 };
2641 let emit = {
2642 let mut flows = self.sas_flows.lock().unwrap();
2643 let flow = match flows.get_mut(&tx_id) {
2644 Some(f) => f,
2645 None => {
2646 warn!(%tx_id, "SasResponse for unknown tx_id");
2647 return;
2648 }
2649 };
2650 if flow.partner_fingerprint != signer {
2651 warn!(
2652 expected = %flow.partner_fingerprint, got = %signer,
2653 "SasResponse signer doesn't match flow's partner; dropping"
2654 );
2655 return;
2656 }
2657 let code = crate::crypto::sas::derive_sas_code(
2658 &flow.our_secret,
2659 &their_pub,
2660 &tx_id_bytes,
2661 );
2662 flow.sas_code = Some(code.clone());
2663 code
2664 };
2665 let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
2666 room_id: room_id.to_string(),
2667 partner_fingerprint: signer,
2668 tx_id,
2669 emoji_string: emit.emoji_string(),
2670 emoji_labels: emit.emoji_labels(),
2671 decimal: emit.decimal,
2672 });
2673 }
2674 RoomMessage::CodeJoinRequest {
2675 room_id: announced_room_id,
2676 joiner_x25519_pubkey_b64,
2677 code,
2678 } => {
2679 if announced_room_id != room_id {
2680 return;
2681 }
2682 let joiner_fp = match verified_signer {
2683 Some(fp) => fp,
2684 None => {
2685 warn!("CodeJoinRequest unsigned; dropping");
2686 return;
2687 }
2688 };
2689 let our_fp = self.identity.fingerprint().to_string();
2693 if !self.is_owner(room_id, &our_fp) {
2694 return;
2695 }
2696 let now = now_unix();
2698 let (code_ok, our_session_id, wrap_input) = {
2699 let mut rooms = self.active_rooms.lock().unwrap();
2700 let room = match rooms.get_mut(room_id) {
2701 Some(r) => r,
2702 None => return,
2703 };
2704 if room.passphrase_key.is_none() {
2705 warn!("CodeJoinRequest: no passphrase key locally; can't respond");
2706 return;
2707 }
2708 let original_len = room.issued_codes.len();
2709 room.issued_codes.retain(|(c, exp)| !(c == &code && *exp > now));
2710 let matched = room.issued_codes.len() < original_len;
2711 if !matched {
2712 info!(%joiner_fp, "CodeJoinRequest: code invalid or expired; ignoring");
2713 return;
2714 }
2715 let crypto = room.crypto.as_ref().unwrap();
2716 (
2717 true,
2718 crypto.our_session_id(),
2719 crypto.our_session_key_b64(),
2720 )
2721 };
2722 let _ = code_ok;
2723 let their_pub = match crate::crypto::sas::parse_pubkey(&joiner_x25519_pubkey_b64) {
2725 Ok(pk) => pk,
2726 Err(e) => {
2727 warn!(%e, "CodeJoinRequest: bad pubkey");
2728 return;
2729 }
2730 };
2731 use x25519_dalek::{PublicKey, StaticSecret};
2732 let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
2733 let our_pub = PublicKey::from(&our_secret);
2734 let shared = our_secret.diffie_hellman(&their_pub);
2735 let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
2737 let mut wrap_key = [0u8; passphrase::KEY_LEN];
2738 hk.expand(b"huddle-code-join-v1", &mut wrap_key)
2739 .expect("32 bytes is within HKDF limits");
2740 let wrapped = match passphrase::wrap(wrap_input.as_bytes(), &wrap_key) {
2743 Ok(w) => w,
2744 Err(e) => {
2745 warn!(%e, "CodeJoinRequest: wrap failed");
2746 return;
2747 }
2748 };
2749 let response = RoomMessage::CodeJoinResponse {
2750 room_id: room_id.to_string(),
2751 target_fingerprint: joiner_fp.clone(),
2752 owner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2753 owner_session_id: our_session_id,
2754 wrapped_session_key_b64: wrapped,
2755 nonce_b64: String::new(), };
2757 if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
2758 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
2759 self.network
2760 .publish_room_message(room_id.to_string(), bytes)
2761 .await;
2762 }
2763 }
2764 info!(%joiner_fp, %room_id, "issued CodeJoinResponse");
2765 }
2766 RoomMessage::CodeJoinResponse {
2767 room_id: announced_room_id,
2768 target_fingerprint,
2769 owner_x25519_pubkey_b64,
2770 owner_session_id,
2771 wrapped_session_key_b64,
2772 nonce_b64: _,
2773 } => {
2774 if announced_room_id != room_id || target_fingerprint != our_fp {
2775 return;
2776 }
2777 let owner_fp = match verified_signer {
2778 Some(fp) => fp,
2779 None => {
2780 warn!("CodeJoinResponse unsigned; dropping");
2781 return;
2782 }
2783 };
2784 let our_secret = match self
2785 .pending_code_secrets
2786 .lock()
2787 .unwrap()
2788 .remove(&(room_id.to_string(), our_fp.clone()))
2789 {
2790 Some(s) => s,
2791 None => {
2792 warn!(%room_id, "CodeJoinResponse with no pending code-join state");
2793 return;
2794 }
2795 };
2796 let owner_pub = match crate::crypto::sas::parse_pubkey(&owner_x25519_pubkey_b64) {
2797 Ok(pk) => pk,
2798 Err(e) => {
2799 warn!(%e, "CodeJoinResponse: bad owner pubkey");
2800 return;
2801 }
2802 };
2803 let shared = our_secret.diffie_hellman(&owner_pub);
2804 let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
2805 let mut wrap_key = [0u8; passphrase::KEY_LEN];
2806 hk.expand(b"huddle-code-join-v1", &mut wrap_key)
2807 .expect("32 bytes within HKDF limits");
2808 let session_key_bytes =
2809 match passphrase::unwrap(&wrapped_session_key_b64, &wrap_key) {
2810 Ok(b) => b,
2811 Err(e) => {
2812 warn!(%e, "CodeJoinResponse: unwrap failed");
2813 return;
2814 }
2815 };
2816 let session_key_str = match String::from_utf8(session_key_bytes) {
2817 Ok(s) => s,
2818 Err(e) => {
2819 warn!(%e, "CodeJoinResponse: session key wasn't valid utf8");
2820 return;
2821 }
2822 };
2823 let mut rooms = self.active_rooms.lock().unwrap();
2825 if let Some(room) = rooms.get_mut(room_id) {
2826 if let Some(crypto) = room.crypto.as_mut() {
2827 if let Err(e) =
2828 crypto.add_inbound_session(&owner_fp, &session_key_str)
2829 {
2830 warn!(%e, "CodeJoinResponse: add_inbound_session failed");
2831 } else {
2832 info!(%room_id, %owner_fp, %owner_session_id, "code-join completed; can decrypt owner's messages");
2833 room.members.insert(owner_fp.clone());
2834 let _ = self.app_event_tx.send(AppEvent::MemberJoined {
2835 room_id: room_id.to_string(),
2836 fingerprint: owner_fp,
2837 });
2838 }
2839 }
2840 }
2841 }
2842 RoomMessage::JoinRefused {
2843 room_id: announced_room_id,
2844 target_fingerprint,
2845 reason,
2846 } => {
2847 if announced_room_id != room_id || target_fingerprint != our_fp {
2848 return;
2849 }
2850 let _ = self.app_event_tx.send(AppEvent::Error {
2854 description: format!("join refused: {reason}"),
2855 });
2856 }
2857 RoomMessage::SasConfirm { tx_id, matched } => {
2858 let signer = match verified_signer {
2859 Some(fp) => fp,
2860 None => return,
2861 };
2862 let (room_id_done, partner_fp_done, both_done) = {
2863 let mut flows = self.sas_flows.lock().unwrap();
2864 let flow = match flows.get_mut(&tx_id) {
2865 Some(f) => f,
2866 None => return,
2867 };
2868 if flow.partner_fingerprint != signer {
2869 return;
2870 }
2871 if !matched {
2872 let _ = flow;
2874 flows.remove(&tx_id);
2875 return;
2876 }
2877 flow.their_confirmed = true;
2878 if flow.our_confirmed && flow.their_confirmed {
2879 (
2880 Some(flow.room_id.clone()),
2881 Some(flow.partner_fingerprint.clone()),
2882 true,
2883 )
2884 } else {
2885 (None, None, false)
2886 }
2887 };
2888 if both_done {
2889 if let (Some(rid), Some(pfp)) = (room_id_done, partner_fp_done) {
2890 if let Err(e) = self.finish_sas(&tx_id, &rid, &pfp).await {
2891 warn!(%e, "finish_sas failed");
2892 }
2893 }
2894 }
2895 }
2896 RoomMessage::ProfileUpdate {
2897 sender_fingerprint,
2898 username,
2899 updated_at,
2900 } => {
2901 let signer = match verified_signer {
2907 Some(fp) => fp,
2908 None => {
2909 warn!(
2910 sender = %sender_fingerprint,
2911 "dropping unsigned ProfileUpdate"
2912 );
2913 return;
2914 }
2915 };
2916 if signer != sender_fingerprint {
2917 warn!(
2918 signer = %signer,
2919 claimed = %sender_fingerprint,
2920 "dropping ProfileUpdate with signer != sender"
2921 );
2922 return;
2923 }
2924 if let Err(e) = repo::upsert_peer_profile(
2925 &self.db,
2926 &sender_fingerprint,
2927 username.as_deref(),
2928 updated_at,
2929 ) {
2930 warn!(%e, "upsert_peer_profile failed");
2931 return;
2932 }
2933 let _ = self.app_event_tx.send(AppEvent::PeerProfileUpdated {
2934 fingerprint: sender_fingerprint,
2935 username,
2936 });
2937 }
2938 }
2939 }
2940
2941 pub async fn send_file(&self, room_id: &str, path: &Path) -> Result<String> {
2949 let bytes = std::fs::read(path)?;
2950 let name = path
2951 .file_name()
2952 .map(|n| n.to_string_lossy().to_string())
2953 .unwrap_or_else(|| "untitled".into());
2954 let mime = crate::files::guess_mime(&name);
2955 let original_path = path.to_path_buf();
2956
2957 let (room_encrypted, mut maybe_session_id, encrypted_meta_opt, wire_bytes) = {
2958 let mut rooms = self.active_rooms.lock().unwrap();
2959 let room = rooms
2960 .get_mut(room_id)
2961 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
2962 if room.info.encrypted {
2963 let crypto = room
2964 .crypto
2965 .as_mut()
2966 .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
2967 let (ciphertext, meta) = file_encryption::encrypt_file(&bytes, crypto)?;
2968 (true, Some(meta.megolm_session_id.clone()), Some(meta), ciphertext)
2969 } else {
2970 (false, None, None, bytes)
2971 }
2972 };
2973 let _ = &mut maybe_session_id; let plan =
2976 self.file_manager
2977 .prepare_outgoing_from_bytes(&name, mime.clone(), wire_bytes)?;
2978 let file_id = plan.file_id.clone();
2979 let total = plan.chunks.len() as u32;
2980 let our_fp = self.identity.fingerprint().to_string();
2981
2982 let attachment = StoredAttachment {
2983 id: 0,
2984 room_id: room_id.to_string(),
2985 message_id: None,
2986 sender_fingerprint: our_fp.clone(),
2987 file_id: file_id.clone(),
2988 name: name.clone(),
2989 mime: mime.clone(),
2990 size_bytes: plan.size_bytes as i64,
2991 status: AttachmentStatus::Ready,
2992 cache_path: Some(self.file_manager.cache_path(&file_id).to_string_lossy().into()),
2993 saved_path: Some(original_path.to_string_lossy().into()),
2994 error: None,
2995 encrypted: room_encrypted,
2996 wrapped_key: encrypted_meta_opt.as_ref().map(|m| m.wrapped_key_b64.clone()),
2997 nonce: encrypted_meta_opt.as_ref().map(|m| m.nonce_b64.clone()),
2998 megolm_session_id: encrypted_meta_opt
2999 .as_ref()
3000 .map(|m| m.megolm_session_id.clone()),
3001 content_hash: encrypted_meta_opt.as_ref().map(|m| m.content_hash.clone()),
3002 created_at: now_unix(),
3003 };
3004 repo::upsert_attachment(&self.db, &attachment)?;
3005 let _ = self.app_event_tx.send(AppEvent::FileOffered {
3006 room_id: room_id.to_string(),
3007 file_id: file_id.clone(),
3008 name: name.clone(),
3009 size_bytes: plan.size_bytes,
3010 sender_fingerprint: our_fp.clone(),
3011 });
3012
3013 let offer = RoomMessage::FileOffer {
3015 sender_fingerprint: our_fp.clone(),
3016 file_id: file_id.clone(),
3017 name,
3018 size_bytes: plan.size_bytes,
3019 mime,
3020 chunk_count: total,
3021 encrypted_meta: encrypted_meta_opt,
3022 };
3023 if let Ok(bytes) = encode_wire(&offer) {
3024 self.network
3025 .publish_room_message(room_id.to_string(), bytes)
3026 .await;
3027 }
3028
3029 let net = self.network.clone();
3032 let room = room_id.to_string();
3033 let our = our_fp.clone();
3034 let fid = file_id.clone();
3035 let chunks = plan.chunks.clone();
3036 tokio::spawn(async move {
3037 for (i, data) in chunks.iter().enumerate() {
3038 let msg = RoomMessage::FileChunk {
3039 sender_fingerprint: our.clone(),
3040 file_id: fid.clone(),
3041 chunk_index: i as u32,
3042 total_chunks: total,
3043 data_b64: B64.encode(data),
3044 };
3045 if let Ok(bytes) = encode_wire(&msg) {
3046 net.publish_room_message(room.clone(), bytes).await;
3047 }
3048 tokio::time::sleep(Duration::from_millis(40)).await;
3049 }
3050 });
3051
3052 Ok(file_id)
3053 }
3054
3055 pub async fn save_to_downloads(&self, room_id: &str, file_id: &str) -> Result<PathBuf> {
3058 let attachment = repo::get_attachment(&self.db, room_id, file_id)?
3059 .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
3060 if !matches!(
3061 attachment.status,
3062 AttachmentStatus::Ready | AttachmentStatus::Saved
3063 ) {
3064 return Err(HuddleError::Other(format!(
3065 "attachment is not ready (status={})",
3066 attachment.status.as_str()
3067 )));
3068 }
3069 let plaintext = if attachment.encrypted
3074 && attachment.sender_fingerprint == self.identity.fingerprint()
3075 {
3076 match attachment
3077 .saved_path
3078 .as_deref()
3079 .filter(|p| Path::new(p).exists())
3080 {
3081 Some(src) => std::fs::read(src)?,
3082 None => {
3083 return Err(HuddleError::Other(
3084 "your original file has moved or been deleted — it can't be \
3085 recovered from the encrypted cache"
3086 .into(),
3087 ));
3088 }
3089 }
3090 } else {
3091 let cached = self.file_manager.read_cache(file_id)?;
3092 if attachment.encrypted {
3093 let meta = EncryptedFileMeta {
3094 megolm_session_id: attachment
3095 .megolm_session_id
3096 .clone()
3097 .ok_or_else(|| HuddleError::Other("missing megolm_session_id".into()))?,
3098 wrapped_key_b64: attachment
3099 .wrapped_key
3100 .clone()
3101 .ok_or_else(|| HuddleError::Other("missing wrapped_key".into()))?,
3102 nonce_b64: attachment
3103 .nonce
3104 .clone()
3105 .ok_or_else(|| HuddleError::Other("missing nonce".into()))?,
3106 content_hash: attachment
3107 .content_hash
3108 .clone()
3109 .ok_or_else(|| HuddleError::Other("missing content_hash".into()))?,
3110 };
3111 self.decrypt_attachment(
3112 room_id,
3113 &attachment.sender_fingerprint,
3114 &cached,
3115 &meta,
3116 )?
3117 } else {
3118 cached
3119 }
3120 };
3121 let saved = self.file_manager.write_to_downloads(&attachment.name, &plaintext)?;
3122 repo::update_attachment_paths(
3123 &self.db,
3124 room_id,
3125 file_id,
3126 None,
3127 Some(&saved.to_string_lossy()),
3128 )?;
3129 repo::update_attachment_status(&self.db, room_id, file_id, AttachmentStatus::Saved, None)?;
3130 let _ = self.app_event_tx.send(AppEvent::FileSaved {
3131 file_id: file_id.into(),
3132 path: saved.to_string_lossy().into(),
3133 });
3134 Ok(saved)
3135 }
3136
3137 pub async fn cancel_transfer(&self, room_id: &str, file_id: &str) -> Result<()> {
3139 self.file_manager.cancel_incoming(file_id);
3140 repo::update_attachment_status(
3141 &self.db,
3142 room_id,
3143 file_id,
3144 AttachmentStatus::Cancelled,
3145 None,
3146 )?;
3147 Ok(())
3148 }
3149
3150 pub fn open_saved(&self, room_id: &str, file_id: &str) -> Result<()> {
3152 let attachment = repo::get_attachment(&self.db, room_id, file_id)?
3153 .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
3154 let path = attachment
3155 .saved_path
3156 .ok_or_else(|| HuddleError::Other("not saved yet — press Enter to save first".into()))?;
3157 open_with_system(&path)
3158 }
3159
3160 pub fn list_room_attachments(&self, room_id: &str) -> Result<Vec<StoredAttachment>> {
3161 repo::list_room_attachments(&self.db, room_id)
3162 }
3163
3164 pub fn set_member_verified(
3168 &self,
3169 room_id: &str,
3170 fingerprint: &str,
3171 verified: bool,
3172 ) -> Result<()> {
3173 let members = repo::list_room_members(&self.db, room_id).unwrap_or_default();
3178 if !members.iter().any(|m| m.fingerprint == fingerprint) {
3179 repo::upsert_room_member(
3180 &self.db,
3181 &StoredRoomMember {
3182 room_id: room_id.to_string(),
3183 peer_id: String::new(),
3184 fingerprint: fingerprint.to_string(),
3185 last_seen: Some(now_unix()),
3186 verified,
3187 ed25519_pubkey: None,
3188 role: "member".into(),
3189 },
3190 )?;
3191 }
3192 repo::set_member_verified(&self.db, room_id, fingerprint, verified)
3193 }
3194
3195 pub fn verified_fingerprints(&self, room_id: &str) -> Vec<String> {
3196 repo::list_verified_fingerprints(&self.db, room_id).unwrap_or_default()
3197 }
3198
3199 pub fn is_owner(&self, room_id: &str, fingerprint: &str) -> bool {
3202 repo::list_room_owners(&self.db, room_id)
3203 .unwrap_or_default()
3204 .iter()
3205 .any(|fp| fp == fingerprint)
3206 }
3207
3208 pub fn we_are_owner(&self, room_id: &str) -> bool {
3209 self.is_owner(room_id, &self.identity.fingerprint().to_string())
3210 }
3211
3212 pub fn room_owners(&self, room_id: &str) -> Vec<String> {
3215 repo::list_room_owners(&self.db, room_id).unwrap_or_default()
3216 }
3217
3218 pub fn verified_only_inbound(&self) -> bool {
3221 repo::get_setting(&self.db, "verified_only_inbound")
3222 .unwrap_or(None)
3223 .map(|v| v == "1")
3224 .unwrap_or(false)
3225 }
3226
3227 pub fn set_verified_only_inbound(&self, on: bool) -> Result<()> {
3228 repo::set_setting(&self.db, "verified_only_inbound", if on { "1" } else { "0" })
3229 }
3230
3231 pub fn room_verified_only(&self, room_id: &str) -> bool {
3236 repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
3237 }
3238
3239 pub fn set_room_verified_only(&self, room_id: &str, on: bool) -> Result<()> {
3240 repo::set_room_verified_only(&self.db, room_id, on)
3241 }
3242
3243 pub fn onboarding_seen(&self) -> bool {
3245 repo::is_onboarding_seen(&self.db).unwrap_or(true)
3246 }
3247
3248 pub fn mark_onboarding_seen(&self) -> Result<()> {
3249 repo::mark_onboarding_seen(&self.db)
3250 }
3251
3252 pub fn last_seen_onboarding_version(&self) -> Option<String> {
3256 repo::get_last_seen_onboarding_version(&self.db).unwrap_or(None)
3257 }
3258
3259 pub fn set_last_seen_onboarding_version(&self, version: &str) -> Result<()> {
3260 repo::set_last_seen_onboarding_version(&self.db, version)
3261 }
3262
3263 pub fn update_check_enabled(&self) -> Option<bool> {
3266 repo::get_update_check_enabled(&self.db).unwrap_or(None)
3267 }
3268
3269 pub fn set_update_check_enabled(&self, enabled: bool) -> Result<()> {
3270 repo::set_update_check_enabled(&self.db, enabled)
3271 }
3272
3273 pub fn last_update_check_at(&self) -> i64 {
3276 repo::get_setting(&self.db, "last_update_check_at")
3277 .ok()
3278 .flatten()
3279 .and_then(|s| s.parse().ok())
3280 .unwrap_or(0)
3281 }
3282
3283 pub fn set_last_update_check_at(&self, ts: i64) -> Result<()> {
3284 repo::set_setting(&self.db, "last_update_check_at", &ts.to_string())
3285 }
3286
3287 pub fn last_known_remote_version(&self) -> Option<String> {
3291 repo::get_setting(&self.db, "last_known_remote_version")
3292 .ok()
3293 .flatten()
3294 }
3295
3296 pub fn set_last_known_remote_version(&self, v: &str) -> Result<()> {
3297 repo::set_setting(&self.db, "last_known_remote_version", v)
3298 }
3299
3300 pub async fn grant_owner(&self, room_id: &str, target_fingerprint: &str) -> Result<()> {
3304 let our_fp = self.identity.fingerprint().to_string();
3305 if !self.is_owner(room_id, &our_fp) {
3306 return Err(HuddleError::Other(
3307 "only an owner can grant owner".into(),
3308 ));
3309 }
3310 let msg = RoomMessage::OwnerGrant {
3311 room_id: room_id.to_string(),
3312 target_fingerprint: target_fingerprint.to_string(),
3313 };
3314 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3315 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3316 self.network
3317 .publish_room_message(room_id.to_string(), bytes)
3318 .await;
3319 repo::set_member_role(&self.db, room_id, target_fingerprint, "owner")?;
3321 Ok(())
3322 }
3323
3324 pub async fn kick_member(
3335 &self,
3336 room_id: &str,
3337 target_fingerprint: &str,
3338 ) -> Result<String> {
3339 let our_fp = self.identity.fingerprint().to_string();
3340 if !self.is_owner(room_id, &our_fp) {
3341 return Err(HuddleError::Other("only an owner can kick".into()));
3342 }
3343 if target_fingerprint == our_fp {
3344 return Err(HuddleError::Other("can't kick yourself".into()));
3345 }
3346 let info = self
3347 .active_rooms
3348 .lock()
3349 .unwrap()
3350 .get(room_id)
3351 .map(|r| r.info.clone())
3352 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3353 if !info.encrypted {
3354 let msg = RoomMessage::BanMember {
3358 room_id: room_id.to_string(),
3359 target_fingerprint: target_fingerprint.to_string(),
3360 };
3361 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3362 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3363 self.network
3364 .publish_room_message(room_id.to_string(), bytes)
3365 .await;
3366 repo::add_room_ban(
3367 &self.db,
3368 room_id,
3369 target_fingerprint,
3370 &our_fp,
3371 &env.signature_b64,
3372 now_unix(),
3373 )?;
3374 self.evict_banned_member(room_id, target_fingerprint);
3375 return Ok(String::new());
3376 }
3377 let new_passphrase = generate_join_passphrase();
3379 let msg = RoomMessage::BanMember {
3380 room_id: room_id.to_string(),
3381 target_fingerprint: target_fingerprint.to_string(),
3382 };
3383 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3384 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3385 self.network
3386 .publish_room_message(room_id.to_string(), bytes)
3387 .await;
3388 repo::add_room_ban(
3389 &self.db,
3390 room_id,
3391 target_fingerprint,
3392 &our_fp,
3393 &env.signature_b64,
3394 now_unix(),
3395 )?;
3396 self.evict_banned_member(room_id, target_fingerprint);
3397 self.rotate_room(room_id, &new_passphrase).await?;
3400 Ok(new_passphrase)
3401 }
3402
3403 pub fn generate_join_code(&self, room_id: &str) -> Result<String> {
3410 let our_fp = self.identity.fingerprint().to_string();
3411 if !self.is_owner(room_id, &our_fp) {
3412 return Err(HuddleError::Other(
3413 "only an owner can issue join codes".into(),
3414 ));
3415 }
3416 let code = generate_alphanumeric_code(8);
3417 let expires_at = now_unix() + 10 * 60;
3418 let mut rooms = self.active_rooms.lock().unwrap();
3419 let room = rooms
3420 .get_mut(room_id)
3421 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3422 let now = now_unix();
3424 room.issued_codes.retain(|(_, exp)| *exp > now);
3425 room.issued_codes.push((code.clone(), expires_at));
3426 Ok(code)
3427 }
3428
3429 pub async fn join_room_with_code(
3436 &self,
3437 room_id: &str,
3438 code: &str,
3439 ) -> Result<()> {
3440 let info = {
3442 let d = self.discovered_rooms.lock().unwrap().get(room_id).cloned();
3443 match d {
3444 Some(d) => StoredRoom {
3445 id: room_id.to_string(),
3446 name: d.name,
3447 creator_fingerprint: d.creator_fingerprint,
3448 encrypted: d.encrypted,
3449 passphrase_salt: None, created_at: now_unix(),
3451 last_active: Some(now_unix()),
3452 kind: d.kind,
3455 },
3456 None => {
3457 return Err(HuddleError::Other(format!(
3458 "room {room_id} not visible — wait for an announcement"
3459 )))
3460 }
3461 }
3462 };
3463 if !info.encrypted {
3464 return Err(HuddleError::Other(
3465 "code-join only applies to encrypted rooms".into(),
3466 ));
3467 }
3468 let our_fp = self.identity.fingerprint().to_string();
3469 use x25519_dalek::{PublicKey, StaticSecret};
3472 let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
3473 let our_pub = PublicKey::from(&our_secret);
3474 let key = (room_id.to_string(), our_fp.clone());
3479 self.pending_code_secrets
3480 .lock()
3481 .unwrap()
3482 .insert(key.clone(), our_secret);
3483 let map = self.pending_code_secrets.clone();
3488 let tx = self.app_event_tx.clone();
3489 let timeout_room = room_id.to_string();
3490 tokio::spawn(async move {
3491 tokio::time::sleep(std::time::Duration::from_secs(30)).await;
3492 let still_pending = map.lock().unwrap().remove(&key).is_some();
3493 if still_pending {
3494 let _ = tx.send(AppEvent::CodeJoinTimedOut {
3495 room_id: timeout_room,
3496 reason: "no response from owner — code may be wrong or expired".into(),
3497 });
3498 }
3499 });
3500 repo::insert_room(&self.db, &info)?;
3507 self.active_rooms.lock().unwrap().insert(
3510 room_id.to_string(),
3511 ActiveRoom {
3512 info: info.clone(),
3513 crypto: Some(RoomCrypto::new_for_room(
3514 self.db.clone(),
3515 room_id.to_string(),
3516 our_fp.clone(),
3517 self.session_persist_key,
3518 )?),
3519 passphrase_key: None,
3520 members: {
3521 let mut s = HashSet::new();
3522 s.insert(our_fp.clone());
3523 s
3524 },
3525 typers: HashMap::new(),
3526 read_only: true,
3527 issued_codes: Vec::new(),
3528 },
3529 );
3530 self.network.subscribe_room(room_id.to_string()).await;
3531 let req = RoomMessage::CodeJoinRequest {
3533 room_id: room_id.to_string(),
3534 joiner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
3535 code: code.to_string(),
3536 };
3537 let env = crate::crypto::sign_message(&self.identity, &req)?;
3538 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3539 self.network
3540 .publish_room_message(room_id.to_string(), bytes)
3541 .await;
3542 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
3545 room_id: room_id.to_string(),
3546 });
3547 Ok(())
3548 }
3549
3550 pub async fn sas_start(&self, room_id: &str, target_fingerprint: &str) -> Result<String> {
3556 let (tx_id_bytes, our_secret, our_pub) = crate::crypto::sas::new_session();
3557 let tx_id = B64.encode(tx_id_bytes);
3558 let msg = RoomMessage::SasInit {
3559 tx_id: tx_id.clone(),
3560 ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
3561 target_fingerprint: target_fingerprint.to_string(),
3562 };
3563 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3564 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3565 self.sas_flows.lock().unwrap().insert(
3566 tx_id.clone(),
3567 SasFlow {
3568 room_id: room_id.to_string(),
3569 partner_fingerprint: target_fingerprint.to_string(),
3570 our_secret,
3571 sas_code: None,
3572 our_confirmed: false,
3573 their_confirmed: false,
3574 },
3575 );
3576 self.network
3577 .publish_room_message(room_id.to_string(), bytes)
3578 .await;
3579 Ok(tx_id)
3580 }
3581
3582 pub async fn sas_match(&self, tx_id: &str) -> Result<()> {
3586 let (room_id, partner_fp, both_done) = {
3587 let mut flows = self.sas_flows.lock().unwrap();
3588 let flow = flows
3589 .get_mut(tx_id)
3590 .ok_or_else(|| HuddleError::Other("unknown SAS tx_id".into()))?;
3591 flow.our_confirmed = true;
3592 (
3593 flow.room_id.clone(),
3594 flow.partner_fingerprint.clone(),
3595 flow.our_confirmed && flow.their_confirmed,
3596 )
3597 };
3598 let msg = RoomMessage::SasConfirm {
3599 tx_id: tx_id.to_string(),
3600 matched: true,
3601 };
3602 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3603 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3604 self.network
3605 .publish_room_message(room_id.clone(), bytes)
3606 .await;
3607 if both_done {
3608 self.finish_sas(tx_id, &room_id, &partner_fp).await?;
3609 }
3610 Ok(())
3611 }
3612
3613 pub fn sas_cancel(&self, tx_id: &str) {
3617 self.sas_flows.lock().unwrap().remove(tx_id);
3618 }
3619
3620 async fn finish_sas(
3623 &self,
3624 tx_id: &str,
3625 room_id: &str,
3626 partner_fingerprint: &str,
3627 ) -> Result<()> {
3628 repo::set_member_verified(&self.db, room_id, partner_fingerprint, true)?;
3629 repo::add_verified_peer(&self.db, partner_fingerprint, now_unix())?;
3630 self.sas_flows.lock().unwrap().remove(tx_id);
3631 let _ = self.app_event_tx.send(AppEvent::SasVerified {
3632 room_id: room_id.to_string(),
3633 partner_fingerprint: partner_fingerprint.to_string(),
3634 });
3635 Ok(())
3636 }
3637
3638 fn evict_banned_member(&self, room_id: &str, fingerprint: &str) {
3643 if let Some(room) = self.active_rooms.lock().unwrap().get_mut(room_id) {
3644 room.members.remove(fingerprint);
3645 }
3646 let _ = self.app_event_tx.send(AppEvent::MemberLeft {
3647 room_id: room_id.to_string(),
3648 fingerprint: fingerprint.to_string(),
3649 });
3650 }
3651
3652 pub fn display_name(&self) -> Option<String> {
3653 repo::get_display_name(&self.db).unwrap_or(None)
3654 }
3655
3656 pub fn set_display_name(&self, name: Option<&str>) -> Result<()> {
3657 repo::set_display_name(&self.db, name)
3658 }
3659
3660 pub async fn set_username(&self, name: Option<&str>) -> Result<()> {
3666 repo::set_display_name(&self.db, name)?;
3667 let msg = RoomMessage::ProfileUpdate {
3668 sender_fingerprint: self.identity.fingerprint().to_string(),
3669 username: name.map(|s| s.to_string()),
3670 updated_at: now_unix_ms(),
3671 };
3672 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3673 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3674 let rooms: Vec<String> = self.active_rooms.lock().unwrap().keys().cloned().collect();
3675 for room_id in rooms {
3676 self.network
3677 .publish_room_message(room_id, bytes.clone())
3678 .await;
3679 }
3680 Ok(())
3681 }
3682
3683 pub fn lookup_username(&self, fingerprint: &str) -> Option<String> {
3688 repo::get_peer_username(&self.db, fingerprint).unwrap_or(None)
3689 }
3690
3691 pub fn lookup_member_display_name(&self, fingerprint: &str) -> Option<String> {
3695 self.lookup_username(fingerprint)
3696 }
3697
3698 pub fn is_room_muted(&self, room_id: &str) -> bool {
3699 repo::is_room_muted(&self.db, room_id).unwrap_or(false)
3700 }
3701
3702 pub fn list_room_bans(&self, room_id: &str) -> Vec<String> {
3707 repo::list_room_bans(&self.db, room_id).unwrap_or_default()
3708 }
3709
3710 pub fn list_verified_peers(&self) -> Vec<String> {
3716 repo::list_verified_peers(&self.db).unwrap_or_default()
3717 }
3718
3719 pub fn list_blocked_peers(&self) -> Vec<String> {
3720 repo::list_blocked_peers(&self.db).unwrap_or_default()
3721 }
3722
3723 pub fn unblock_peer(&self, fingerprint: &str) -> Result<()> {
3727 repo::unblock_peer(&self.db, fingerprint)
3728 }
3729
3730 pub fn block_peer(&self, fingerprint: &str) -> Result<()> {
3734 repo::block_peer(&self.db, fingerprint, now_unix())
3735 }
3736
3737 pub fn is_room_read_only(&self, room_id: &str) -> bool {
3743 self.active_rooms
3744 .lock()
3745 .unwrap()
3746 .get(room_id)
3747 .map(|r| r.read_only)
3748 .unwrap_or(false)
3749 }
3750
3751 pub fn set_room_muted(&self, room_id: &str, muted: bool) -> Result<()> {
3752 repo::set_room_muted(&self.db, room_id, muted)
3753 }
3754
3755 pub async fn broadcast_typing(&self, room_id: &str) {
3758 if !self.active_rooms.lock().unwrap().contains_key(room_id) {
3759 return;
3760 }
3761 let msg = RoomMessage::Typing {
3762 sender_fingerprint: self.identity.fingerprint().to_string(),
3763 };
3764 if let Ok(bytes) = encode_wire(&msg) {
3765 self.network
3766 .publish_room_message(room_id.to_string(), bytes)
3767 .await;
3768 }
3769 }
3770
3771 pub fn typers_in_room(&self, room_id: &str) -> Vec<String> {
3774 let now = now_unix();
3775 let mut rooms = self.active_rooms.lock().unwrap();
3776 let room = match rooms.get_mut(room_id) {
3777 Some(r) => r,
3778 None => return Vec::new(),
3779 };
3780 room.typers.retain(|_, exp| *exp > now);
3781 let mut v: Vec<String> = room.typers.keys().cloned().collect();
3782 v.sort();
3783 v
3784 }
3785
3786 pub async fn rotate_room(&self, room_id: &str, new_passphrase: &str) -> Result<()> {
3796 if new_passphrase.is_empty() {
3797 return Err(HuddleError::Other("new passphrase is empty".into()));
3798 }
3799 let new_salt = passphrase::random_salt();
3800 let new_key = passphrase::derive_key(new_passphrase, &new_salt)?;
3801
3802 let info = {
3803 let mut rooms = self.active_rooms.lock().unwrap();
3804 let room = rooms
3805 .get_mut(room_id)
3806 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3807 if !room.info.encrypted {
3808 return Err(HuddleError::Other(
3809 "rotation only applies to encrypted rooms".into(),
3810 ));
3811 }
3812 let new_crypto = RoomCrypto::new_for_room(
3814 self.db.clone(),
3815 room_id.to_string(),
3816 self.identity.fingerprint().to_string(),
3817 self.session_persist_key,
3818 )?;
3819 room.crypto = Some(new_crypto);
3820 room.passphrase_key = Some(new_key);
3821 room.info.passphrase_salt = Some(new_salt.to_vec());
3822 room.info.clone()
3823 };
3824
3825 let rot = RoomMessage::RotateRoomKey {
3831 rotator_fingerprint: self.identity.fingerprint().to_string(),
3832 new_salt: new_salt.to_vec(),
3833 };
3834 if let Ok(env) = crate::crypto::sign_message(&self.identity, &rot) {
3838 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
3839 self.network
3840 .publish_room_message(room_id.to_string(), bytes)
3841 .await;
3842 }
3843 }
3844 if let Err(e) = self.broadcast_member_announce(room_id).await {
3846 warn!(%e, "rotate: broadcast announce failed");
3847 }
3848
3849 repo::insert_room(&self.db, &info)?;
3851 Ok(())
3852 }
3853
3854 pub async fn accept_rotation(
3858 &self,
3859 room_id: &str,
3860 new_salt: &[u8],
3861 new_passphrase: &str,
3862 ) -> Result<()> {
3863 let new_key = passphrase::derive_key(new_passphrase, new_salt)?;
3864 let info = {
3865 let mut rooms = self.active_rooms.lock().unwrap();
3866 let room = rooms
3867 .get_mut(room_id)
3868 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3869 room.passphrase_key = Some(new_key);
3870 room.info.passphrase_salt = Some(new_salt.to_vec());
3871 room.info.clone()
3872 };
3873 let req = RoomMessage::SessionKeyRequest {
3877 requester_fingerprint: self.identity.fingerprint().to_string(),
3878 };
3879 if let Ok(bytes) = encode_wire(&req) {
3880 self.network
3881 .publish_room_message(room_id.to_string(), bytes)
3882 .await;
3883 }
3884 repo::insert_room(&self.db, &info)?;
3885 Ok(())
3886 }
3887
3888 #[allow(clippy::too_many_arguments)]
3893 fn handle_file_offer(
3894 &self,
3895 room_id: &str,
3896 sender_fingerprint: String,
3897 file_id: String,
3898 name: String,
3899 size_bytes: u64,
3900 mime: Option<String>,
3901 _chunk_count: u32,
3902 encrypted_meta: Option<EncryptedFileMeta>,
3903 ) {
3904 let encrypted = encrypted_meta.is_some();
3905 let attachment = StoredAttachment {
3906 id: 0,
3907 room_id: room_id.to_string(),
3908 message_id: None,
3909 sender_fingerprint: sender_fingerprint.clone(),
3910 file_id: file_id.clone(),
3911 name: name.clone(),
3912 mime,
3913 size_bytes: size_bytes as i64,
3914 status: AttachmentStatus::Offered,
3915 cache_path: None,
3916 saved_path: None,
3917 error: None,
3918 encrypted,
3919 wrapped_key: encrypted_meta.as_ref().map(|m| m.wrapped_key_b64.clone()),
3920 nonce: encrypted_meta.as_ref().map(|m| m.nonce_b64.clone()),
3921 megolm_session_id: encrypted_meta.as_ref().map(|m| m.megolm_session_id.clone()),
3922 content_hash: encrypted_meta.as_ref().map(|m| m.content_hash.clone()),
3923 created_at: now_unix(),
3924 };
3925 if let Err(e) = repo::upsert_attachment(&self.db, &attachment) {
3926 warn!(%e, "upsert attachment");
3927 return;
3928 }
3929 self.file_manager.set_expected_size(&file_id, size_bytes);
3932 let _ = self.app_event_tx.send(AppEvent::FileOffered {
3933 room_id: room_id.to_string(),
3934 file_id,
3935 name,
3936 size_bytes,
3937 sender_fingerprint,
3938 });
3939 }
3940
3941 fn handle_file_chunk(
3942 &self,
3943 room_id: &str,
3944 _sender_fingerprint: String,
3945 file_id: String,
3946 chunk_index: u32,
3947 total_chunks: u32,
3948 data_b64: String,
3949 ) {
3950 let data = match B64.decode(&data_b64) {
3951 Ok(d) => d,
3952 Err(e) => {
3953 warn!(%e, "bad chunk base64");
3954 return;
3955 }
3956 };
3957 let expected_size = match repo::get_attachment(&self.db, room_id, &file_id) {
3961 Ok(Some(a)) => {
3962 if matches!(
3963 a.status,
3964 AttachmentStatus::Cancelled | AttachmentStatus::Failed
3965 ) {
3966 return;
3967 }
3968 a.size_bytes as u64
3969 }
3970 Ok(None) => crate::files::MAX_FILE_SIZE,
3971 Err(e) => {
3972 warn!(%e, "get attachment for chunk");
3973 crate::files::MAX_FILE_SIZE
3974 }
3975 };
3976
3977 let result = self.file_manager.accept_chunk(
3978 &file_id,
3979 chunk_index,
3980 total_chunks,
3981 data,
3982 expected_size,
3983 );
3984 match result {
3985 Ok(None) => {
3986 let _ = repo::update_attachment_status(
3988 &self.db,
3989 room_id,
3990 &file_id,
3991 AttachmentStatus::Downloading,
3992 None,
3993 );
3994 let bytes_so_far = self
3997 .file_manager
3998 .progress(&file_id)
3999 .map(|(b, _)| b)
4000 .unwrap_or(0);
4001 let _ = self.app_event_tx.send(AppEvent::FileProgress {
4002 file_id: file_id.clone(),
4003 bytes_received: bytes_so_far,
4004 total_bytes: expected_size,
4005 });
4006 }
4007 Ok(Some(completed)) => {
4008 let _ = repo::update_attachment_paths(
4009 &self.db,
4010 room_id,
4011 &file_id,
4012 Some(&completed.cache_path.to_string_lossy()),
4013 None,
4014 );
4015 let _ = repo::update_attachment_status(
4016 &self.db,
4017 room_id,
4018 &file_id,
4019 AttachmentStatus::Ready,
4020 None,
4021 );
4022 let _ = self.app_event_tx.send(AppEvent::FileReady {
4023 file_id: file_id.clone(),
4024 });
4025 }
4026 Err(e) => {
4027 let msg = e.to_string();
4028 warn!(%msg, "chunk processing failed");
4029 let _ = repo::update_attachment_status(
4030 &self.db,
4031 room_id,
4032 &file_id,
4033 AttachmentStatus::Failed,
4034 Some(&msg),
4035 );
4036 let _ = self.app_event_tx.send(AppEvent::FileFailed {
4037 file_id: file_id.clone(),
4038 reason: msg,
4039 });
4040 }
4041 }
4042 }
4043
4044 fn maybe_emit_mention(&self, room_id: &str, body: &str) {
4047 let full = self.identity.fingerprint().to_lowercase();
4048 let short: &str = full.split('-').next().unwrap_or(&full);
4050 let lower = body.to_lowercase();
4051 let hit = lower.contains(full.as_str())
4055 || lower
4056 .split(|c: char| !c.is_ascii_hexdigit())
4057 .any(|tok| tok == short);
4058 if hit {
4059 let _ = self.app_event_tx.send(AppEvent::MentionReceived {
4060 room_id: room_id.to_string(),
4061 body: body.to_string(),
4062 });
4063 }
4064 }
4065
4066 fn decrypt_attachment(
4067 &self,
4068 room_id: &str,
4069 sender_fingerprint: &str,
4070 ciphertext: &[u8],
4071 meta: &EncryptedFileMeta,
4072 ) -> Result<Vec<u8>> {
4073 let mut rooms = self.active_rooms.lock().unwrap();
4074 let room = rooms
4075 .get_mut(room_id)
4076 .ok_or_else(|| HuddleError::Other("not in room".into()))?;
4077 let crypto = room
4078 .crypto
4079 .as_mut()
4080 .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
4081 file_encryption::decrypt_file(ciphertext, meta, crypto, sender_fingerprint)
4082 }
4083
4084 pub async fn go_dark(&self, master_passphrase: &str) -> Result<()> {
4096 let no_master = self.session_persist_key == [0u8; 32];
4097 if !no_master {
4098 let salt = storage::keychain::load_or_create_salt()?;
4099 let candidate_master =
4100 storage::keychain::derive_master_key(master_passphrase, &salt)?;
4101 let candidate_subkey =
4102 storage::keychain::derive_subkey(&candidate_master, b"megolm-persist");
4103 if !ct_eq_32(&candidate_subkey, &self.session_persist_key) {
4104 return Err(HuddleError::Other(
4105 "incorrect master passphrase".into(),
4106 ));
4107 }
4108 }
4109
4110 let room_ids: Vec<String> = self
4111 .active_rooms
4112 .lock()
4113 .unwrap()
4114 .keys()
4115 .cloned()
4116 .collect();
4117 let _ = tokio::time::timeout(Duration::from_secs(2), async {
4118 for room_id in &room_ids {
4119 if let Err(e) = self.leave_room(room_id).await {
4120 warn!(%room_id, %e, "go_dark: leave_room failed");
4121 }
4122 }
4123 })
4124 .await;
4125
4126 self.network.shutdown().await;
4127 tokio::time::sleep(Duration::from_millis(300)).await;
4128
4129 let data_dir = config::data_dir();
4130 let candidates = [
4131 "huddle.db",
4132 "huddle.db-shm",
4133 "huddle.db-wal",
4134 "keychain.salt",
4135 "huddle.log",
4136 "config.toml",
4137 ];
4138 for name in &candidates {
4139 let path = data_dir.join(name);
4140 wipe_file(&path);
4141 }
4142 if let Ok(read) = std::fs::read_dir(&data_dir) {
4143 for entry in read.flatten() {
4144 if let Some(name) = entry.file_name().to_str() {
4145 if name.starts_with("huddle.log.") {
4146 wipe_file(&entry.path());
4147 }
4148 }
4149 }
4150 }
4151 let files_dir = data_dir.join("files");
4155 if let Ok(read) = std::fs::read_dir(&files_dir) {
4156 for entry in read.flatten() {
4157 let path = entry.path();
4158 if path.is_file() {
4159 wipe_file(&path);
4160 } else if path.is_dir() {
4161 if let Ok(inner) = std::fs::read_dir(&path) {
4164 for inner_entry in inner.flatten() {
4165 if inner_entry.path().is_file() {
4166 wipe_file(&inner_entry.path());
4167 }
4168 }
4169 }
4170 let _ = std::fs::remove_dir(&path);
4171 }
4172 }
4173 }
4174 let _ = std::fs::remove_dir(&files_dir);
4175 let _ = std::fs::remove_dir(&data_dir);
4176
4177 let _ = self.app_event_tx.send(AppEvent::WentDark);
4178 Ok(())
4179 }
4180}
4181
4182pub fn normalize_to_fingerprint(input: &str) -> Option<String> {
4189 let s = input
4190 .trim()
4191 .trim_start_matches("HD-")
4192 .trim_start_matches("hd-")
4193 .to_string();
4194 let hex_only: String = s.chars().filter(|c| *c != '-').collect();
4195 if hex_only.len() != 24 || !hex_only.chars().all(|c| c.is_ascii_hexdigit()) {
4196 return None;
4197 }
4198 let lower = hex_only.to_ascii_lowercase();
4199 let chunks: Vec<String> = lower
4200 .as_bytes()
4201 .chunks(4)
4202 .map(|c| std::str::from_utf8(c).unwrap().to_string())
4203 .collect();
4204 Some(chunks.join("-"))
4205}
4206
4207fn address_preference(addr: &str) -> u8 {
4213 if addr.contains("/p2p-circuit") {
4214 return 9; }
4216 if let Some(rest) = addr.strip_prefix("/ip4/") {
4217 if let Some(ip_str) = rest.split('/').next() {
4218 if let Ok(ip) = ip_str.parse::<std::net::Ipv4Addr>() {
4219 if ip.is_loopback() {
4220 return 1; }
4222 if is_rfc1918(&ip) || ip.is_link_local() {
4223 return 0; }
4225 return 3; }
4227 }
4228 return 3;
4229 }
4230 if addr.starts_with("/ip6/") {
4231 return 4;
4232 }
4233 if addr.starts_with("/dns4/") || addr.starts_with("/dns6/") || addr.starts_with("/dnsaddr/") {
4234 return 5;
4235 }
4236 7
4237}
4238
4239fn is_rfc1918(ip: &std::net::Ipv4Addr) -> bool {
4243 let octets = ip.octets();
4244 octets[0] == 10
4245 || (octets[0] == 172 && (16..=31).contains(&octets[1]))
4246 || (octets[0] == 192 && octets[1] == 168)
4247}
4248
4249fn short_fp_for_msg(fingerprint: &str) -> String {
4253 let head: String = fingerprint
4254 .chars()
4255 .filter(|c| *c != '-')
4256 .take(4)
4257 .collect::<String>()
4258 .to_ascii_uppercase();
4259 format!("HD-{}…", head)
4260}
4261
4262fn ct_eq_32(a: &[u8; 32], b: &[u8; 32]) -> bool {
4266 let mut diff = 0u8;
4267 for i in 0..32 {
4268 diff |= a[i] ^ b[i];
4269 }
4270 diff == 0
4271}
4272
4273fn wipe_file(path: &Path) {
4277 use std::io::Write;
4278 if let Ok(meta) = std::fs::metadata(path) {
4279 if let Ok(mut f) = std::fs::OpenOptions::new().write(true).open(path) {
4280 let zeros = vec![0u8; meta.len() as usize];
4281 let _ = f.write_all(&zeros);
4282 let _ = f.sync_all();
4283 }
4284 }
4285 if let Err(e) = std::fs::remove_file(path) {
4286 if e.kind() != std::io::ErrorKind::NotFound {
4287 warn!(?path, %e, "wipe_file: remove failed");
4288 }
4289 }
4290}
4291
4292fn open_with_system(path: &str) -> Result<()> {
4294 #[cfg(target_os = "macos")]
4295 let cmd = "open";
4296 #[cfg(target_os = "linux")]
4297 let cmd = "xdg-open";
4298 #[cfg(target_os = "windows")]
4299 let cmd = "cmd";
4300 #[cfg(target_os = "windows")]
4301 let args = vec!["/C", "start", "", path];
4302 #[cfg(not(target_os = "windows"))]
4303 let args = vec![path];
4304
4305 std::process::Command::new(cmd)
4306 .args(args)
4307 .spawn()
4308 .map_err(|e| HuddleError::Other(format!("spawn opener: {e}")))?;
4309 Ok(())
4310}
4311
4312static ROOM_SALT_CACHE: std::sync::LazyLock<Mutex<HashMap<String, Vec<u8>>>> =
4315 std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
4316
4317pub fn salt_len() -> usize {
4322 SALT_LEN
4323}
4324
4325fn now_unix() -> i64 {
4326 SystemTime::now()
4327 .duration_since(UNIX_EPOCH)
4328 .unwrap()
4329 .as_secs() as i64
4330}
4331
4332fn now_unix_ms() -> i64 {
4333 SystemTime::now()
4334 .duration_since(UNIX_EPOCH)
4335 .unwrap()
4336 .as_millis() as i64
4337}
4338
4339fn generate_join_passphrase() -> String {
4345 use rand::RngCore;
4346 let mut bytes = [0u8; 16];
4347 rand::thread_rng().fill_bytes(&mut bytes);
4348 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
4351}
4352
4353fn generate_alphanumeric_code(len: usize) -> String {
4358 use rand::Rng;
4359 const ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ23456789";
4360 let mut rng = rand::thread_rng();
4361 let mut out = String::with_capacity(len + 1);
4362 for i in 0..len {
4363 if i == 4 && len == 8 {
4364 out.push('-'); }
4366 let idx = rng.gen_range(0..ALPHABET.len());
4367 out.push(ALPHABET[idx] as char);
4368 }
4369 out
4370}
4371
4372#[cfg(test)]
4373mod parser_tests {
4374 use super::parse_dial_address;
4375
4376 #[test]
4377 fn parses_ipv4_port() {
4378 let m = parse_dial_address("10.3.72.53:9027").unwrap();
4379 assert_eq!(m.to_string(), "/ip4/10.3.72.53/tcp/9027");
4380 }
4381
4382 #[test]
4383 fn parses_bracketed_ipv6() {
4384 let m = parse_dial_address("[::1]:9027").unwrap();
4385 assert_eq!(m.to_string(), "/ip6/::1/tcp/9027");
4386 }
4387
4388 #[test]
4389 fn rejects_unbracketed_ipv6() {
4390 let err = parse_dial_address("fe80::1:9027").unwrap_err();
4391 assert!(err.to_string().contains("brackets"));
4392 }
4393
4394 #[test]
4395 fn passes_through_raw_multiaddr() {
4396 let m = parse_dial_address("/ip4/1.2.3.4/tcp/9000").unwrap();
4397 assert_eq!(m.to_string(), "/ip4/1.2.3.4/tcp/9000");
4398 }
4399
4400 #[test]
4401 fn empty_address_is_error() {
4402 assert!(parse_dial_address(" ").is_err());
4403 }
4404
4405 #[test]
4406 fn rejects_bad_port() {
4407 assert!(parse_dial_address("1.2.3.4:notaport").is_err());
4408 }
4409}
4410
4411#[cfg(test)]
4412mod transport_preference_tests {
4413 use super::{address_preference, normalize_to_fingerprint};
4414
4415 #[test]
4416 fn lan_beats_public_beats_circuit() {
4417 let lan = address_preference("/ip4/192.168.1.5/tcp/9027");
4418 let pub_v4 = address_preference("/ip4/8.8.8.8/tcp/9027");
4419 let circuit = address_preference(
4420 "/ip4/1.2.3.4/tcp/4001/p2p/12D3Koo/p2p-circuit/p2p/12D3KooXYZ",
4421 );
4422 assert!(lan < pub_v4, "LAN {} should beat public {}", lan, pub_v4);
4423 assert!(
4424 pub_v4 < circuit,
4425 "public {} should beat circuit {}",
4426 pub_v4,
4427 circuit
4428 );
4429 }
4430
4431 #[test]
4432 fn all_rfc1918_ranges_are_lan() {
4433 assert_eq!(
4434 address_preference("/ip4/10.0.0.1/tcp/9027"),
4435 address_preference("/ip4/192.168.0.1/tcp/9027"),
4436 );
4437 assert_eq!(
4438 address_preference("/ip4/172.16.0.1/tcp/9027"),
4439 address_preference("/ip4/192.168.0.1/tcp/9027"),
4440 );
4441 assert!(
4443 address_preference("/ip4/172.32.0.1/tcp/9027")
4444 > address_preference("/ip4/172.16.0.1/tcp/9027")
4445 );
4446 }
4447
4448 #[test]
4449 fn normalize_id_accepts_branded_and_raw() {
4450 let canon = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4451 assert_eq!(
4452 normalize_to_fingerprint("HD-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF").as_deref(),
4453 Some(canon)
4454 );
4455 assert_eq!(
4456 normalize_to_fingerprint("aaaabbbbccccddddeeeeffff").as_deref(),
4457 Some(canon)
4458 );
4459 assert_eq!(normalize_to_fingerprint(canon).as_deref(), Some(canon));
4460 assert!(normalize_to_fingerprint("alice").is_none());
4461 assert!(normalize_to_fingerprint("HD-ZZZZ").is_none());
4462 }
4463}
4464
4465#[cfg(test)]
4466mod canonical_dm_room_id_tests {
4467 use super::canonical_dm_room_id;
4468
4469 #[test]
4470 fn dm_room_id_is_commutative() {
4471 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4474 let b = "1111-2222-3333-4444-5555-6666";
4475 assert_eq!(canonical_dm_room_id(a, b), canonical_dm_room_id(b, a));
4476 }
4477
4478 #[test]
4479 fn dm_room_id_differs_per_pair() {
4480 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4481 let b = "1111-2222-3333-4444-5555-6666";
4482 let c = "9999-8888-7777-6666-5555-4444";
4483 assert_ne!(canonical_dm_room_id(a, b), canonical_dm_room_id(a, c));
4484 assert_ne!(canonical_dm_room_id(a, b), canonical_dm_room_id(b, c));
4485 }
4486
4487 #[test]
4488 fn dm_room_id_is_stable() {
4489 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4493 let b = "1111-2222-3333-4444-5555-6666";
4494 let id1 = canonical_dm_room_id(a, b);
4495 let id2 = canonical_dm_room_id(a, b);
4496 assert_eq!(id1, id2);
4497 assert_eq!(id1.len(), 32);
4501 }
4502}