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, 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 parse_dial_address(input: &str) -> Result<Multiaddr> {
45 let trimmed = input.trim();
46 if trimmed.is_empty() {
47 return Err(HuddleError::Other("address is empty".into()));
48 }
49 if trimmed.starts_with('/') {
50 return trimmed
51 .parse::<Multiaddr>()
52 .map_err(|e| HuddleError::Other(format!("invalid multiaddr: {e}")));
53 }
54 if let Some(rest) = trimmed.strip_prefix('[') {
55 let (host, port) = rest
56 .split_once("]:")
57 .ok_or_else(|| HuddleError::Other(format!("expected [ipv6]:port, got {trimmed}")))?;
58 let port: u16 = port
59 .parse()
60 .map_err(|_| HuddleError::Other(format!("invalid port: {port}")))?;
61 return format!("/ip6/{}/tcp/{}", host, port)
62 .parse::<Multiaddr>()
63 .map_err(|e| HuddleError::Other(format!("invalid ipv6 address: {e}")));
64 }
65 let (host, port) = trimmed
66 .rsplit_once(':')
67 .ok_or_else(|| HuddleError::Other(format!("expected ip:port, got {trimmed}")))?;
68 if host.contains(':') {
69 return Err(HuddleError::Other(format!(
70 "ambiguous IPv6 address — wrap host in brackets: [{host}]:{port}"
71 )));
72 }
73 let port: u16 = port
74 .parse()
75 .map_err(|_| HuddleError::Other(format!("invalid port: {port}")))?;
76 format!("/ip4/{}/tcp/{}", host, port)
77 .parse::<Multiaddr>()
78 .map_err(|e| HuddleError::Other(format!("invalid address: {e}")))
79}
80
81struct ActiveRoom {
83 info: StoredRoom,
84 crypto: Option<RoomCrypto>,
85 passphrase_key: Option<[u8; KEY_LEN]>,
88 members: HashSet<String>,
90 typers: HashMap<String, i64>,
93 read_only: bool,
100 issued_codes: Vec<(String, i64)>,
104}
105
106const TYPING_TTL_SECS: i64 = 3;
107
108const DISCOVERED_TTL_SECS: i64 = 45;
111const ANNOUNCE_INTERVAL_SECS: u64 = 15;
112
113struct SasFlow {
117 room_id: String,
118 partner_fingerprint: String,
119 our_secret: x25519_dalek::StaticSecret,
120 sas_code: Option<crate::crypto::sas::SasCode>,
122 our_confirmed: bool,
123 their_confirmed: bool,
124}
125
126#[derive(Clone)]
127pub struct AppHandle {
128 identity: Arc<Identity>,
129 network: NetworkHandle,
130 mode: NetworkMode,
131 active_rooms: Arc<Mutex<HashMap<String, ActiveRoom>>>,
132 discovered_rooms: Arc<Mutex<HashMap<String, DiscoveredRoom>>>,
133 restorable_rooms: Arc<Mutex<HashMap<String, StoredRoom>>>,
137 connected_dial_addrs: Arc<Mutex<HashMap<String, PeerId>>>,
140 file_manager: Arc<FileManager>,
142 db: Db,
143 session_persist_key: [u8; 32],
147 sas_flows: Arc<Mutex<HashMap<String, SasFlow>>>,
150 pending_code_secrets:
157 Arc<Mutex<HashMap<(String, String), x25519_dalek::StaticSecret>>>,
158 pending_invite_dials: Arc<Mutex<HashMap<String, String>>>,
171 nat_reachable_addrs: Arc<Mutex<HashSet<String>>>,
177 relay_circuit_addrs: Arc<Mutex<HashSet<String>>>,
183 host_addr_dial_attempts: Arc<Mutex<HashMap<String, i64>>>,
188 app_event_tx: broadcast::Sender<AppEvent>,
189}
190
191const HOST_ADDR_DIAL_BACKOFF_SECS: i64 = 300;
194
195impl AppHandle {
196 pub async fn start() -> Result<Self> {
197 Self::start_with_options(NetworkMode::Mdns, 0, None, Vec::new()).await
198 }
199
200 pub async fn start_with_options(
201 mode: NetworkMode,
202 port: u16,
203 master_key: Option<&[u8; 32]>,
204 relays: Vec<Multiaddr>,
205 ) -> Result<Self> {
206 config::ensure_data_dir()?;
207 let session_persist_key = match master_key {
212 Some(mk) => storage::keychain::derive_subkey(mk, b"megolm-persist"),
213 None => [0u8; 32],
214 };
215 let db = storage::open_db(&config::db_path(), master_key)?;
216 Self::start_with_db_and_options(db, mode, port, session_persist_key, relays).await
217 }
218
219 pub async fn start_with_db(db: Db) -> Result<Self> {
220 Self::start_with_db_and_options(db, NetworkMode::Mdns, 0, [0u8; 32], Vec::new()).await
221 }
222
223 pub async fn start_with_db_and_options(
224 db: Db,
225 mode: NetworkMode,
226 port: u16,
227 session_persist_key: [u8; 32],
228 relays: Vec<Multiaddr>,
229 ) -> Result<Self> {
230 let identity = Self::load_or_create_identity(&db)?;
231 let identity = Arc::new(identity);
232 info!(fingerprint = %identity.fingerprint(), peer_id = %identity.peer_id(), mode = %mode.as_str(), port, relay_count = relays.len(), "identity loaded");
233
234 let (net_event_tx, net_event_rx) = tokio::sync::mpsc::channel::<NetworkEvent>(256);
235 let (app_event_tx, _) = broadcast::channel::<AppEvent>(256);
236 let network =
237 network::start_network_with(&identity, net_event_tx, mode, port, relays)?;
238
239 let active_rooms = Arc::new(Mutex::new(HashMap::new()));
240 let discovered_rooms = Arc::new(Mutex::new(HashMap::new()));
241 let restorable_rooms = Arc::new(Mutex::new(HashMap::new()));
242 let connected_dial_addrs = Arc::new(Mutex::new(HashMap::new()));
243 let file_manager = Arc::new(FileManager::new(&config::data_dir())?);
244
245 let handle = Self {
246 identity,
247 network,
248 mode,
249 active_rooms,
250 discovered_rooms,
251 restorable_rooms,
252 connected_dial_addrs,
253 file_manager,
254 db,
255 session_persist_key,
256 sas_flows: Arc::new(Mutex::new(HashMap::new())),
257 pending_code_secrets: Arc::new(Mutex::new(HashMap::new())),
258 pending_invite_dials: Arc::new(Mutex::new(HashMap::new())),
259 nat_reachable_addrs: Arc::new(Mutex::new(HashSet::new())),
260 relay_circuit_addrs: Arc::new(Mutex::new(HashSet::new())),
261 host_addr_dial_attempts: Arc::new(Mutex::new(HashMap::new())),
262 app_event_tx,
263 };
264
265 handle.spawn_event_processor(net_event_rx);
266 handle.spawn_announcement_ticker();
267 handle.spawn_discovered_room_pruner();
268 handle.spawn_known_peer_reconnector();
269 handle.restore_rooms_from_db().await;
270
271 Ok(handle)
272 }
273
274 pub fn mode(&self) -> NetworkMode {
275 self.mode
276 }
277
278 pub fn subscribe(&self) -> broadcast::Receiver<AppEvent> {
279 self.app_event_tx.subscribe()
280 }
281
282 pub fn fingerprint(&self) -> &str {
283 self.identity.fingerprint()
284 }
285
286 pub fn peer_id(&self) -> PeerId {
287 self.identity.peer_id()
288 }
289
290 pub fn discovered_rooms(&self) -> Vec<DiscoveredRoom> {
291 let now = now_unix();
292 let mut by_id: HashMap<String, DiscoveredRoom> = self
293 .discovered_rooms
294 .lock()
295 .unwrap()
296 .clone();
297
298 for room in self.active_rooms.lock().unwrap().values() {
302 let entry = DiscoveredRoom {
303 room_id: room.info.id.clone(),
304 name: room.info.name.clone(),
305 encrypted: room.info.encrypted,
306 member_count: room.members.len() as u32,
307 creator_fingerprint: room.info.creator_fingerprint.clone(),
308 last_seen: now,
309 restorable: false,
310 };
311 by_id
312 .entry(room.info.id.clone())
313 .and_modify(|d| {
314 d.last_seen = now;
315 if entry.member_count > d.member_count {
316 d.member_count = entry.member_count;
317 }
318 d.restorable = false;
319 })
320 .or_insert(entry);
321 }
322
323 for (id, stored) in self.restorable_rooms.lock().unwrap().iter() {
327 if by_id.contains_key(id) {
328 continue;
329 }
330 by_id.insert(
331 id.clone(),
332 DiscoveredRoom {
333 room_id: id.clone(),
334 name: stored.name.clone(),
335 encrypted: stored.encrypted,
336 member_count: 0,
337 creator_fingerprint: stored.creator_fingerprint.clone(),
338 last_seen: stored.last_active.unwrap_or(stored.created_at),
339 restorable: true,
340 },
341 );
342 }
343
344 let mut v: Vec<DiscoveredRoom> = by_id.into_values().collect();
345 v.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
346 v
347 }
348
349 pub fn active_room_ids(&self) -> Vec<String> {
350 self.active_rooms.lock().unwrap().keys().cloned().collect()
351 }
352
353 pub fn active_room_info(&self, room_id: &str) -> Option<StoredRoom> {
354 self.active_rooms
355 .lock()
356 .unwrap()
357 .get(room_id)
358 .map(|r| r.info.clone())
359 }
360
361 pub fn room_members(&self, room_id: &str) -> Vec<String> {
362 self.active_rooms
363 .lock()
364 .unwrap()
365 .get(room_id)
366 .map(|r| {
367 let mut m: Vec<String> = r.members.iter().cloned().collect();
368 m.sort();
369 m
370 })
371 .unwrap_or_default()
372 }
373
374 pub fn room_messages(&self, room_id: &str, limit: i64) -> Result<Vec<repo::StoredRoomMessage>> {
375 repo::get_room_messages(&self.db, room_id, limit)
376 }
377
378 pub fn search_room_messages(
379 &self,
380 room_id: &str,
381 query: &str,
382 limit: i64,
383 ) -> Result<Vec<repo::StoredRoomMessage>> {
384 repo::search_room_messages(&self.db, room_id, query, limit)
385 }
386
387 pub async fn start_room(
389 &self,
390 name: &str,
391 encrypted: bool,
392 passphrase: Option<&str>,
393 ) -> Result<String> {
394 if encrypted && passphrase.is_none() {
395 return Err(HuddleError::Other(
396 "encrypted room requires a passphrase".into(),
397 ));
398 }
399
400 let created_at = now_unix();
401 let creator_fp = self.identity.fingerprint().to_string();
402 let room_id = derive_room_id(&creator_fp, name, created_at);
403
404 let (passphrase_salt, passphrase_key) = if encrypted {
405 let salt = passphrase::random_salt();
406 let key = passphrase::derive_key(passphrase.unwrap(), &salt)?;
407 (Some(salt.to_vec()), Some(key))
408 } else {
409 (None, None)
410 };
411
412 let info = StoredRoom {
413 id: room_id.clone(),
414 name: name.to_string(),
415 creator_fingerprint: creator_fp.clone(),
416 encrypted,
417 passphrase_salt: passphrase_salt.clone(),
418 created_at,
419 last_active: Some(created_at),
420 };
421 repo::insert_room(&self.db, &info)?;
422
423 let crypto = if encrypted {
424 Some(RoomCrypto::new_for_room(
425 self.db.clone(),
426 room_id.clone(),
427 creator_fp.clone(),
428 self.session_persist_key,
429 )?)
430 } else {
431 None
432 };
433
434 let mut members = HashSet::new();
435 members.insert(creator_fp.clone());
436
437 repo::upsert_room_member(
441 &self.db,
442 &StoredRoomMember {
443 room_id: room_id.clone(),
444 peer_id: String::new(),
445 fingerprint: creator_fp.clone(),
446 last_seen: Some(created_at),
447 verified: true, ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
449 role: "owner".into(),
450 },
451 )?;
452
453 self.active_rooms.lock().unwrap().insert(
454 room_id.clone(),
455 ActiveRoom {
456 info: info.clone(),
457 crypto,
458 passphrase_key,
459 members,
460 typers: HashMap::new(),
461 read_only: false,
462 issued_codes: Vec::new(),
463 },
464 );
465
466 self.network.subscribe_room(room_id.clone()).await;
467 self.announce_room_now(&info, 1).await;
468
469 let app = self.clone();
472 let rid = room_id.clone();
473 tokio::spawn(async move {
474 tokio::time::sleep(Duration::from_millis(500)).await;
475 if let Err(e) = app.broadcast_member_announce(&rid).await {
476 warn!(%e, "broadcast member announce");
477 }
478 });
479
480 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
481 room_id: room_id.clone(),
482 });
483
484 Ok(room_id)
485 }
486
487 pub async fn join_room(&self, room_id: &str, passphrase: Option<&str>) -> Result<()> {
491 let (name, creator_fingerprint, encrypted, salt_opt) = {
493 if let Some(d) = self.discovered_rooms.lock().unwrap().get(room_id).cloned() {
494 let salt = self.get_room_salt(room_id);
495 (d.name, d.creator_fingerprint, d.encrypted, salt)
496 } else if let Some(stored) = self.restorable_rooms.lock().unwrap().get(room_id).cloned()
497 {
498 (
499 stored.name,
500 stored.creator_fingerprint,
501 stored.encrypted,
502 stored.passphrase_salt,
503 )
504 } else if let Some(stored) = repo::get_room(&self.db, room_id)? {
505 (
506 stored.name,
507 stored.creator_fingerprint,
508 stored.encrypted,
509 stored.passphrase_salt,
510 )
511 } else {
512 return Err(HuddleError::Other(format!("room {room_id} not found")));
513 }
514 };
515
516 if encrypted && passphrase.is_none() {
517 return Err(HuddleError::Other(
518 "encrypted room requires a passphrase".into(),
519 ));
520 }
521
522 let passphrase_key = if encrypted {
523 let salt = salt_opt
524 .clone()
525 .ok_or_else(|| HuddleError::Other("missing salt for encrypted room".into()))?;
526 Some(passphrase::derive_key(passphrase.unwrap(), &salt)?)
527 } else {
528 None
529 };
530
531 let info = StoredRoom {
532 id: room_id.to_string(),
533 name,
534 creator_fingerprint,
535 encrypted,
536 passphrase_salt: salt_opt.clone(),
537 created_at: now_unix(),
538 last_active: Some(now_unix()),
539 };
540 repo::insert_room(&self.db, &info)?;
541
542 let crypto = if encrypted {
543 let our_fp = self.identity.fingerprint().to_string();
546 let existing = RoomCrypto::load(
547 self.db.clone(),
548 room_id.to_string(),
549 our_fp.clone(),
550 self.session_persist_key,
551 )?;
552 Some(match existing {
553 Some(c) => c,
554 None => RoomCrypto::new_for_room(
555 self.db.clone(),
556 room_id.to_string(),
557 our_fp,
558 self.session_persist_key,
559 )?,
560 })
561 } else {
562 None
563 };
564
565 let mut members = HashSet::new();
566 members.insert(self.identity.fingerprint().to_string());
567
568 self.active_rooms.lock().unwrap().insert(
569 room_id.to_string(),
570 ActiveRoom {
571 info: info.clone(),
572 crypto,
573 passphrase_key,
574 members,
575 typers: HashMap::new(),
576 read_only: false,
577 issued_codes: Vec::new(),
578 },
579 );
580 self.restorable_rooms.lock().unwrap().remove(room_id);
582
583 self.network.subscribe_room(room_id.to_string()).await;
584
585 let app = self.clone();
586 let rid = room_id.to_string();
587 tokio::spawn(async move {
588 tokio::time::sleep(Duration::from_millis(500)).await;
589 if let Err(e) = app.broadcast_member_announce(&rid).await {
590 warn!(%e, "broadcast member announce");
591 }
592 let req = RoomMessage::SessionKeyRequest {
594 requester_fingerprint: app.identity.fingerprint().to_string(),
595 };
596 if let Ok(bytes) = encode_wire(&req) {
597 app.network.publish_room_message(rid.clone(), bytes).await;
598 }
599 });
600
601 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
602 room_id: room_id.to_string(),
603 });
604
605 Ok(())
606 }
607
608 async fn restore_rooms_from_db(&self) {
613 let rooms = match repo::list_rooms(&self.db) {
614 Ok(v) => v,
615 Err(e) => {
616 warn!(%e, "list rooms on restore");
617 return;
618 }
619 };
620 let our_fp = self.identity.fingerprint().to_string();
621 let count = rooms.len();
622 for info in rooms {
623 if info.encrypted {
624 self.restorable_rooms
625 .lock()
626 .unwrap()
627 .insert(info.id.clone(), info);
628 continue;
629 }
630 let mut members = HashSet::new();
631 members.insert(our_fp.clone());
632 if let Ok(stored_members) = repo::list_room_members(&self.db, &info.id) {
633 for m in stored_members {
634 members.insert(m.fingerprint);
635 }
636 }
637 let member_count = members.len() as u32;
638 self.active_rooms.lock().unwrap().insert(
639 info.id.clone(),
640 ActiveRoom {
641 info: info.clone(),
642 crypto: None,
643 passphrase_key: None,
644 members,
645 typers: HashMap::new(),
646 read_only: false,
647 issued_codes: Vec::new(),
648 },
649 );
650 self.network.subscribe_room(info.id.clone()).await;
651 self.announce_room_now(&info, member_count).await;
652 info!(room_id = %info.id, name = %info.name, "restored room");
653 }
654 if count > 0 {
655 debug!(count, "restored rooms from db");
656 }
657 }
658
659 pub async fn leave_room(&self, room_id: &str) -> Result<bool> {
664 let leave_msg = RoomMessage::MemberLeave {
666 sender_fingerprint: self.identity.fingerprint().to_string(),
667 };
668 let dispatched = match encode_wire(&leave_msg) {
669 Ok(bytes) => {
670 self.network
671 .publish_room_message(room_id.to_string(), bytes)
672 .await;
673 true
674 }
675 Err(e) => {
676 warn!(%e, %room_id, "failed to encode MemberLeave notice");
677 false
678 }
679 };
680
681 self.active_rooms.lock().unwrap().remove(room_id);
682 self.network.unsubscribe_room(room_id.to_string()).await;
683
684 let _ = self.app_event_tx.send(AppEvent::RoomLeft {
685 room_id: room_id.to_string(),
686 });
687 Ok(dispatched)
688 }
689
690 pub async fn send_room_message(&self, room_id: &str, body: &str) -> Result<()> {
691 let our_fp = self.identity.fingerprint().to_string();
692 let msg = {
693 let mut rooms = self.active_rooms.lock().unwrap();
694 let room = rooms
695 .get_mut(room_id)
696 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
697
698 if room.read_only {
699 return Err(HuddleError::Other(
700 "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(),
701 ));
702 }
703
704 if room.info.encrypted {
705 let crypto = room
706 .crypto
707 .as_mut()
708 .ok_or_else(|| HuddleError::Session("encrypted room missing crypto".into()))?;
709 let (session_id, ct_bytes) = crypto.encrypt(body.as_bytes())?;
710 RoomMessage::Encrypted {
711 sender_fingerprint: our_fp.clone(),
712 session_id,
713 ciphertext_b64: base64::Engine::encode(
714 &base64::engine::general_purpose::STANDARD,
715 &ct_bytes,
716 ),
717 }
718 } else {
719 RoomMessage::Plain {
720 sender_fingerprint: our_fp.clone(),
721 body: body.to_string(),
722 }
723 }
724 };
725
726 let bytes = encode_wire(&msg)?;
727 self.network
728 .publish_room_message(room_id.to_string(), bytes)
729 .await;
730
731 let now = now_unix();
732 let msg_id =
733 repo::insert_room_message(&self.db, room_id, &our_fp, "out", body, now)?;
734 repo::update_room_last_active(&self.db, room_id, now)?;
735
736 let _ = self.app_event_tx.send(AppEvent::MessageSent {
737 room_id: room_id.to_string(),
738 body: body.to_string(),
739 message_id: msg_id,
740 });
741
742 Ok(())
743 }
744
745 pub async fn shutdown(&self) {
746 self.network.shutdown().await;
747 }
748
749 pub async fn dial(&self, input: &str) -> Result<()> {
758 let multiaddr = parse_dial_address(input)?;
759 let canonical = multiaddr.to_string();
760 info!(%canonical, "dialing");
761
762 repo::upsert_known_peer(
763 &self.db,
764 &KnownPeer {
765 address: canonical.clone(),
766 label: None,
767 last_connected_at: None,
768 last_attempt_at: Some(now_unix()),
769 created_at: now_unix(),
770 fingerprint: None,
774 trusted: false,
775 },
776 )?;
777
778 let _ = self.app_event_tx.send(AppEvent::Dialing {
779 address: canonical.clone(),
780 });
781 self.network.dial(multiaddr).await;
782 Ok(())
783 }
784
785 pub fn nat_reachable_addrs(&self) -> Vec<String> {
790 self.nat_reachable_addrs
791 .lock()
792 .unwrap()
793 .iter()
794 .cloned()
795 .collect()
796 }
797
798 pub fn dialable_addrs(&self) -> Vec<String> {
806 let mut out: Vec<String> = self
807 .relay_circuit_addrs
808 .lock()
809 .unwrap()
810 .iter()
811 .cloned()
812 .collect();
813 for a in self.nat_reachable_addrs.lock().unwrap().iter() {
814 if !out.contains(a) {
815 out.push(a.clone());
816 }
817 }
818 out.truncate(4);
819 out
820 }
821
822 pub async fn dial_invite(&self, address: &str, claimed_fp: &str) -> Result<()> {
835 let multiaddr = parse_dial_address(address)?;
836 let canonical = multiaddr.to_string();
837 self.pending_invite_dials
838 .lock()
839 .unwrap()
840 .insert(canonical.clone(), claimed_fp.to_string());
841 self.dial(address).await
844 }
845
846 pub fn known_peers(&self) -> Vec<KnownPeerStatus> {
847 let connected = self.connected_dial_addrs.lock().unwrap().clone();
848 let stored = repo::list_known_peers(&self.db).unwrap_or_default();
849 stored
850 .into_iter()
851 .map(|p| {
852 let connected_peer = connected.get(&p.address).copied();
853 KnownPeerStatus {
854 address: p.address,
855 label: p.label,
856 last_connected_at: p.last_connected_at,
857 connected_peer_id: connected_peer,
858 }
859 })
860 .collect()
861 }
862
863 pub async fn forget_peer(&self, address: &str) -> Result<()> {
864 repo::forget_known_peer(&self.db, address)?;
865 self.connected_dial_addrs.lock().unwrap().remove(address);
866 Ok(())
867 }
868
869 pub async fn redial(&self, address: &str) -> Result<()> {
871 self.dial(address).await
872 }
873
874 pub async fn accept_inbound(&self, peer_id: PeerId, address: &str) {
879 self.network.accept_inbound(peer_id).await;
880 self.connected_dial_addrs
881 .lock()
882 .unwrap()
883 .insert(address.to_string(), peer_id);
884 }
885
886 pub async fn reject_inbound(&self, peer_id: PeerId, fingerprint: &str) -> Result<()> {
891 self.network.reject_inbound(peer_id).await;
892 repo::block_peer(&self.db, fingerprint, now_unix())?;
893 Ok(())
894 }
895
896 pub async fn trust_inbound(
899 &self,
900 peer_id: PeerId,
901 fingerprint: &str,
902 address: &str,
903 ) -> Result<()> {
904 self.network.accept_inbound(peer_id).await;
905 self.connected_dial_addrs
906 .lock()
907 .unwrap()
908 .insert(address.to_string(), peer_id);
909 repo::upsert_known_peer(
913 &self.db,
914 &KnownPeer {
915 address: address.to_string(),
916 label: None,
917 last_connected_at: Some(now_unix()),
918 last_attempt_at: Some(now_unix()),
919 created_at: now_unix(),
920 fingerprint: Some(fingerprint.to_string()),
921 trusted: true,
922 },
923 )?;
924 Ok(())
925 }
926
927 fn spawn_known_peer_reconnector(&self) {
928 let handle = self.clone();
929 tokio::spawn(async move {
930 tokio::time::sleep(Duration::from_millis(500)).await;
932 let known = repo::list_known_peers(&handle.db).unwrap_or_default();
933 for (i, peer) in known.into_iter().enumerate() {
937 let handle = handle.clone();
938 tokio::spawn(async move {
939 let jitter = (peer.address.len() as u64 * 37) % 200;
942 tokio::time::sleep(Duration::from_millis(150 * i as u64 + jitter)).await;
943 if let Err(e) = handle.dial(&peer.address).await {
944 debug!(%e, addr = %peer.address, "auto-reconnect failed");
945 }
946 });
947 }
948 });
949 }
950
951 fn load_or_create_identity(db: &Db) -> Result<Identity> {
956 if let Some(stored) = repo::load_identity(db)? {
957 let mut bytes = [0u8; 32];
958 bytes.copy_from_slice(&stored.ed25519_secret);
959 Identity::from_secret_bytes(bytes)
960 } else {
961 let id = Identity::generate()?;
962 repo::save_identity(db, &id.secret_bytes(), now_unix())?;
963 Ok(id)
964 }
965 }
966
967 fn get_room_salt(&self, room_id: &str) -> Option<Vec<u8>> {
968 self.active_rooms
969 .lock()
970 .unwrap()
971 .get(room_id)
972 .and_then(|r| r.info.passphrase_salt.clone())
973 .or_else(|| {
974 ROOM_SALT_CACHE
976 .lock()
977 .unwrap()
978 .get(room_id)
979 .cloned()
980 })
981 }
982
983 async fn announce_room_now(&self, info: &StoredRoom, member_count: u32) {
984 let owner_fingerprints =
985 repo::list_room_owners(&self.db, &info.id).unwrap_or_default();
986 let verified_only = repo::get_room_verified_only(&self.db, &info.id).unwrap_or(false);
987 let host_addrs = self.dialable_addrs();
988 let ann = RoomAnnouncement {
989 room_id: info.id.clone(),
990 name: info.name.clone(),
991 encrypted: info.encrypted,
992 passphrase_salt: info.passphrase_salt.clone(),
993 member_count,
994 creator_fingerprint: info.creator_fingerprint.clone(),
995 announced_at: now_unix(),
996 owner_fingerprints,
997 verified_only,
998 host_addrs,
999 };
1000 self.network.announce_room(ann).await;
1001 }
1002
1003 async fn broadcast_member_announce(&self, room_id: &str) -> Result<()> {
1004 let our_fp = self.identity.fingerprint().to_string();
1005 let wrapped = {
1006 let mut rooms = self.active_rooms.lock().unwrap();
1007 let room = rooms
1008 .get_mut(room_id)
1009 .ok_or_else(|| HuddleError::Other("not in room".into()))?;
1010 if room.info.encrypted {
1011 let crypto = room.crypto.as_mut().unwrap();
1012 let session_key = crypto.our_session_key_b64();
1013 let passphrase_key = room
1014 .passphrase_key
1015 .as_ref()
1016 .ok_or_else(|| HuddleError::Session("missing passphrase key".into()))?;
1017 Some(passphrase::wrap(session_key.as_bytes(), passphrase_key)?)
1018 } else {
1019 None
1020 }
1021 };
1022 let display_name = repo::get_display_name(&self.db).unwrap_or(None);
1023 let msg = RoomMessage::MemberAnnounce {
1024 sender_fingerprint: our_fp,
1025 wrapped_session_key: wrapped,
1026 display_name,
1027 sender_ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
1028 };
1029 let bytes = encode_wire(&msg)?;
1030 self.network
1031 .publish_room_message(room_id.to_string(), bytes)
1032 .await;
1033 Ok(())
1034 }
1035
1036 fn spawn_event_processor(&self, mut net_rx: tokio::sync::mpsc::Receiver<NetworkEvent>) {
1037 let handle = self.clone();
1038 tokio::spawn(async move {
1039 while let Some(event) = net_rx.recv().await {
1040 handle.process_network_event(event).await;
1041 }
1042 info!("event processor stopped");
1043 });
1044 }
1045
1046 fn spawn_announcement_ticker(&self) {
1047 let handle = self.clone();
1048 tokio::spawn(async move {
1049 let mut interval =
1050 tokio::time::interval(Duration::from_secs(ANNOUNCE_INTERVAL_SECS));
1051 interval.tick().await; loop {
1053 interval.tick().await;
1054 let snapshot: Vec<(StoredRoom, u32)> = {
1055 let active = handle.active_rooms.lock().unwrap();
1056 active
1057 .values()
1058 .map(|r| (r.info.clone(), r.members.len() as u32))
1059 .collect()
1060 };
1061 for (info, member_count) in snapshot {
1062 handle.announce_room_now(&info, member_count).await;
1063 }
1064 }
1065 });
1066 }
1067
1068 fn spawn_discovered_room_pruner(&self) {
1069 let handle = self.clone();
1070 tokio::spawn(async move {
1071 let mut interval = tokio::time::interval(Duration::from_secs(10));
1072 interval.tick().await;
1073 loop {
1074 interval.tick().await;
1075 let now = now_unix();
1076 let mut to_drop = Vec::new();
1077 {
1078 let mut map = handle.discovered_rooms.lock().unwrap();
1079 map.retain(|id, r| {
1080 if now - r.last_seen > DISCOVERED_TTL_SECS {
1081 to_drop.push(id.clone());
1082 false
1083 } else {
1084 true
1085 }
1086 });
1087 }
1088 for id in to_drop {
1089 let _ = handle.app_event_tx.send(AppEvent::RoomLost { room_id: id });
1090 }
1091 }
1092 });
1093 }
1094
1095 async fn process_network_event(&self, event: NetworkEvent) {
1096 match event {
1097 NetworkEvent::PeerDiscovered { peer_id } => {
1098 let _ = self.app_event_tx.send(AppEvent::PeerDiscovered { peer_id });
1099 }
1100 NetworkEvent::PeerExpired { peer_id } => {
1101 self.connected_dial_addrs
1107 .lock()
1108 .unwrap()
1109 .retain(|_addr, pid| *pid != peer_id);
1110 let _ = self.app_event_tx.send(AppEvent::PeerExpired { peer_id });
1111 }
1112 NetworkEvent::ListeningOn { address } => {
1113 let _ = self.app_event_tx.send(AppEvent::ListeningOn {
1114 address: address.to_string(),
1115 });
1116 }
1117 NetworkEvent::RoomAnnouncementReceived(ann) => {
1118 if let Some(salt) = &ann.passphrase_salt {
1120 ROOM_SALT_CACHE
1121 .lock()
1122 .unwrap()
1123 .insert(ann.room_id.clone(), salt.clone());
1124 }
1125 let our_fp_for_dial = self.identity.fingerprint().to_string();
1130 if ann.creator_fingerprint != our_fp_for_dial && !ann.host_addrs.is_empty() {
1131 let now = now_unix();
1132 let should_dial = {
1133 let mut attempts = self.host_addr_dial_attempts.lock().unwrap();
1134 match attempts.get(&ann.creator_fingerprint).copied() {
1135 Some(last) if now - last < HOST_ADDR_DIAL_BACKOFF_SECS => false,
1136 _ => {
1137 attempts.insert(ann.creator_fingerprint.clone(), now);
1138 true
1139 }
1140 }
1141 };
1142 if should_dial {
1143 if let Some(first) = ann.host_addrs.first() {
1144 info!(
1145 announcer = %ann.creator_fingerprint,
1146 addr = %first,
1147 "opportunistic dial via room announcement host_addrs"
1148 );
1149 let _ = self.dial(first).await;
1152 }
1153 }
1154 }
1155 let discovered = DiscoveredRoom {
1156 room_id: ann.room_id.clone(),
1157 name: ann.name.clone(),
1158 encrypted: ann.encrypted,
1159 member_count: ann.member_count,
1160 creator_fingerprint: ann.creator_fingerprint.clone(),
1161 last_seen: now_unix(),
1162 restorable: false,
1163 };
1164 if self.active_rooms.lock().unwrap().contains_key(&ann.room_id) {
1169 self.discovered_rooms
1170 .lock()
1171 .unwrap()
1172 .insert(ann.room_id.clone(), discovered);
1173 return;
1174 }
1175 self.discovered_rooms
1176 .lock()
1177 .unwrap()
1178 .insert(ann.room_id.clone(), discovered.clone());
1179 let _ = self.app_event_tx.send(AppEvent::RoomDiscovered(discovered));
1180 }
1181 NetworkEvent::RoomMessageReceived {
1182 room_id,
1183 payload,
1184 from_peer: _,
1185 } => {
1186 let wire: WireMessage = match serde_json::from_slice(&payload) {
1193 Ok(w) => w,
1194 Err(e) => {
1195 warn!(%e, "bad wire envelope");
1196 return;
1197 }
1198 };
1199 let (msg, verified_signer) = match wire {
1200 WireMessage::Plain(m) => (m, None),
1201 WireMessage::Signed(env) => {
1202 let claimed_pubkey = env.ed25519_pubkey_b64.clone();
1203 match crate::crypto::verify_signed(&env) {
1204 Ok((m, fp)) => {
1205 match repo::get_member_ed25519_pubkey(
1212 &self.db, &room_id, &fp,
1213 ) {
1214 Ok(Some(known)) if known != claimed_pubkey => {
1215 warn!(
1216 %fp, %room_id,
1217 "pubkey mismatch vs stored; dropping signed message"
1218 );
1219 return;
1220 }
1221 _ => {}
1222 }
1223 (m, Some(fp))
1224 }
1225 Err(e) => {
1226 warn!(%e, fp = %env.fingerprint, "signed envelope verify failed");
1227 return;
1228 }
1229 }
1230 }
1231 };
1232 self.handle_room_message(&room_id, msg, verified_signer).await;
1233 }
1234 NetworkEvent::DialSucceeded { peer_id, address } => {
1235 let addr_s = address.to_string();
1236 self.connected_dial_addrs
1237 .lock()
1238 .unwrap()
1239 .insert(addr_s.clone(), peer_id);
1240 let _ = repo::upsert_known_peer(
1244 &self.db,
1245 &KnownPeer {
1246 address: addr_s.clone(),
1247 label: None,
1248 last_connected_at: Some(now_unix()),
1249 last_attempt_at: Some(now_unix()),
1250 created_at: now_unix(),
1251 fingerprint: None,
1252 trusted: false,
1253 },
1254 );
1255 let _ = self.app_event_tx.send(AppEvent::DialSucceeded {
1256 address: addr_s,
1257 peer_id,
1258 });
1259 }
1260 NetworkEvent::DialFailed { address, error } => {
1261 let addr_s = address.to_string();
1262 let _ = self.app_event_tx.send(AppEvent::DialFailed {
1263 address: addr_s,
1264 error,
1265 });
1266 }
1267 NetworkEvent::PeerIdentified { peer_id, fingerprint } => {
1268 let matched_addrs: Vec<String> = {
1274 let map = self.connected_dial_addrs.lock().unwrap();
1275 map.iter()
1276 .filter_map(|(addr, pid)| {
1277 if *pid == peer_id {
1278 Some(addr.clone())
1279 } else {
1280 None
1281 }
1282 })
1283 .collect()
1284 };
1285 let mismatch = {
1295 let mut map = self.pending_invite_dials.lock().unwrap();
1296 let mut found: Option<(String, String)> = None;
1297 for addr in &matched_addrs {
1298 if let Some(claimed) = map.remove(addr) {
1299 if claimed != fingerprint {
1300 found = Some((addr.clone(), claimed));
1301 break;
1302 }
1303 }
1304 }
1305 found
1306 };
1307 if let Some((addr, claimed)) = mismatch {
1308 warn!(
1309 %addr, %claimed, actual=%fingerprint,
1310 "invite fingerprint mismatch — disconnecting"
1311 );
1312 self.network.disconnect_peer(peer_id).await;
1313 let _ = self.app_event_tx.send(AppEvent::InviteFingerprintMismatch {
1314 address: addr,
1315 claimed,
1316 actual: fingerprint.clone(),
1317 });
1318 return;
1319 }
1320 for addr in matched_addrs {
1321 let _ = repo::upsert_known_peer(
1322 &self.db,
1323 &KnownPeer {
1324 address: addr,
1325 label: None,
1326 last_connected_at: Some(now_unix()),
1327 last_attempt_at: Some(now_unix()),
1328 created_at: now_unix(),
1329 fingerprint: Some(fingerprint.clone()),
1330 trusted: true,
1331 },
1332 );
1333 }
1334 }
1335 NetworkEvent::RelayReservationEstablished { address } => {
1336 info!(addr = %address, "relay reservation established");
1341 self.relay_circuit_addrs
1342 .lock()
1343 .unwrap()
1344 .insert(address.to_string());
1345 let _ = self.app_event_tx.send(AppEvent::ListeningOn {
1346 address: address.to_string(),
1347 });
1348 }
1349 NetworkEvent::NatProbeResult {
1350 tested_addr,
1351 reachable,
1352 } => {
1353 let addr_s = tested_addr.to_string();
1354 let (transitioned, becomes_reachable) = {
1355 let mut set = self.nat_reachable_addrs.lock().unwrap();
1356 let was_empty = set.is_empty();
1357 if reachable {
1358 set.insert(addr_s.clone());
1359 } else {
1360 set.remove(&addr_s);
1361 }
1362 let is_empty = set.is_empty();
1363 (was_empty != is_empty, !is_empty)
1364 };
1365 if transitioned {
1366 let label = if becomes_reachable {
1367 "reachable".to_string()
1368 } else {
1369 "private".to_string()
1370 };
1371 info!(reachable = %becomes_reachable, "NAT reachability changed");
1372 let _ = self.app_event_tx.send(AppEvent::NatStatusChanged {
1373 label,
1374 reachable: becomes_reachable,
1375 });
1376 }
1377 }
1378 NetworkEvent::DcutrUpgrade {
1379 remote_peer,
1380 success,
1381 } => {
1382 if success {
1383 let s = remote_peer.to_base58();
1387 let tail: String = s.chars().rev().take(8).collect::<String>()
1388 .chars()
1389 .rev()
1390 .collect();
1391 let _ = self.app_event_tx.send(AppEvent::DcutrSucceeded {
1392 peer_label: tail,
1393 });
1394 }
1395 }
1396 NetworkEvent::InboundDial {
1397 peer_id,
1398 fingerprint,
1399 address,
1400 } => {
1401 if repo::is_peer_blocked(&self.db, &fingerprint).unwrap_or(false) {
1403 info!(%fingerprint, "inbound dial auto-rejected: peer is blocked");
1404 self.network.reject_inbound(peer_id).await;
1405 return;
1406 }
1407 let global_verified_only =
1412 repo::get_setting(&self.db, "verified_only_inbound")
1413 .ok()
1414 .flatten()
1415 .map(|v| v == "1")
1416 .unwrap_or(false);
1417 if global_verified_only {
1418 let is_verified =
1419 repo::is_globally_verified(&self.db, &fingerprint).unwrap_or(false)
1420 || repo::is_fingerprint_trusted(&self.db, &fingerprint)
1421 .unwrap_or(false);
1422 if !is_verified {
1423 info!(
1424 %fingerprint,
1425 "inbound dial auto-rejected: verified-only mode"
1426 );
1427 self.network.reject_inbound(peer_id).await;
1428 return;
1429 }
1430 }
1431 if repo::is_fingerprint_trusted(&self.db, &fingerprint).unwrap_or(false) {
1432 info!(%fingerprint, "inbound dial auto-accepted: peer is trusted");
1433 self.connected_dial_addrs
1436 .lock()
1437 .unwrap()
1438 .insert(address.to_string(), peer_id);
1439 let _ = repo::upsert_known_peer(
1440 &self.db,
1441 &KnownPeer {
1442 address: address.to_string(),
1443 label: None,
1444 last_connected_at: Some(now_unix()),
1445 last_attempt_at: Some(now_unix()),
1446 created_at: now_unix(),
1447 fingerprint: Some(fingerprint),
1448 trusted: true,
1449 },
1450 );
1451 self.network.accept_inbound(peer_id).await;
1452 return;
1453 }
1454 let _ = self.app_event_tx.send(AppEvent::InboundDial {
1456 peer_id,
1457 fingerprint,
1458 address: address.to_string(),
1459 });
1460 }
1461 }
1462 }
1463
1464 async fn handle_room_message(
1470 &self,
1471 room_id: &str,
1472 msg: RoomMessage,
1473 verified_signer: Option<String>,
1474 ) {
1475 let our_fp = self.identity.fingerprint().to_string();
1476 match msg {
1477 RoomMessage::MemberAnnounce {
1478 sender_fingerprint,
1479 wrapped_session_key,
1480 display_name,
1481 sender_ed25519_pubkey,
1482 } => {
1483 if sender_fingerprint == our_fp {
1484 return;
1485 }
1486 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
1489 .unwrap_or(false)
1490 {
1491 info!(%sender_fingerprint, %room_id, "dropping MemberAnnounce from banned peer");
1492 return;
1493 }
1494 if repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
1501 && !repo::is_globally_verified(&self.db, &sender_fingerprint).unwrap_or(false)
1502 {
1503 info!(
1504 %sender_fingerprint, %room_id,
1505 "dropping MemberAnnounce: room is verified-only and joiner isn't verified"
1506 );
1507 let owners = repo::list_room_owners(&self.db, room_id).unwrap_or_default();
1508 let lowest_owner = owners.iter().min().cloned();
1509 if lowest_owner.as_deref() == Some(&our_fp) {
1510 let msg = RoomMessage::JoinRefused {
1511 room_id: room_id.to_string(),
1512 target_fingerprint: sender_fingerprint.clone(),
1513 reason: "room requires SAS verification — ask an existing member to verify you".into(),
1514 };
1515 if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
1516 if let Ok(bytes) =
1517 crate::network::protocol::encode_wire_signed(&env)
1518 {
1519 self.network
1520 .publish_room_message(room_id.to_string(), bytes)
1521 .await;
1522 }
1523 }
1524 }
1525 return;
1526 }
1527 let need_inbound = {
1528 let mut rooms = self.active_rooms.lock().unwrap();
1529 let room = match rooms.get_mut(room_id) {
1530 Some(r) => r,
1531 None => return,
1532 };
1533 let newly_added = room.members.insert(sender_fingerprint.clone());
1534 if newly_added {
1535 let _ = self.app_event_tx.send(AppEvent::MemberJoined {
1536 room_id: room_id.to_string(),
1537 fingerprint: sender_fingerprint.clone(),
1538 });
1539 }
1540 let _ = repo::upsert_room_member(
1545 &self.db,
1546 &StoredRoomMember {
1547 room_id: room_id.to_string(),
1548 peer_id: String::new(), fingerprint: sender_fingerprint.clone(),
1550 last_seen: Some(now_unix()),
1551 verified: false,
1552 ed25519_pubkey: sender_ed25519_pubkey.clone(),
1553 role: "member".into(),
1559 },
1560 );
1561 if let Some(name) = display_name.as_deref() {
1562 let _ = repo::set_member_display_name(
1563 &self.db,
1564 room_id,
1565 &sender_fingerprint,
1566 Some(name),
1567 );
1568 }
1569 room.info.encrypted && wrapped_session_key.is_some()
1570 };
1571
1572 if need_inbound {
1573 let wrapped = wrapped_session_key.unwrap();
1574 let result = {
1575 let mut rooms = self.active_rooms.lock().unwrap();
1576 let room = rooms.get_mut(room_id).unwrap();
1577 let passphrase_key = match &room.passphrase_key {
1578 Some(k) => k,
1579 None => {
1580 warn!("no passphrase key when receiving session key");
1581 return;
1582 }
1583 };
1584 match passphrase::unwrap(&wrapped, passphrase_key) {
1585 Ok(plain) => match String::from_utf8(plain) {
1586 Ok(key_b64) => {
1587 let crypto = room.crypto.as_mut().unwrap();
1588 crypto.add_inbound_session(&sender_fingerprint, &key_b64)
1589 }
1590 Err(e) => Err(HuddleError::Session(format!("utf8: {e}"))),
1591 },
1592 Err(e) => Err(e),
1593 }
1594 };
1595 if let Err(e) = result {
1596 error!(%e, "add inbound session failed");
1597 }
1598 }
1599 }
1600 RoomMessage::SessionKeyRequest {
1601 requester_fingerprint,
1602 } => {
1603 if requester_fingerprint == our_fp {
1604 return;
1605 }
1606 if let Err(e) = self.broadcast_member_announce(room_id).await {
1608 warn!(%e, "broadcast member announce on request");
1609 }
1610 }
1611 RoomMessage::Encrypted {
1612 sender_fingerprint,
1613 session_id,
1614 ciphertext_b64,
1615 } => {
1616 if sender_fingerprint == our_fp {
1617 return;
1618 }
1619 let ct_bytes = match base64::Engine::decode(
1620 &base64::engine::general_purpose::STANDARD,
1621 &ciphertext_b64,
1622 ) {
1623 Ok(b) => b,
1624 Err(e) => {
1625 warn!(%e, "bad base64 ciphertext");
1626 return;
1627 }
1628 };
1629 let plaintext = {
1630 let mut rooms = self.active_rooms.lock().unwrap();
1631 let room = match rooms.get_mut(room_id) {
1632 Some(r) => r,
1633 None => return,
1634 };
1635 let crypto = match room.crypto.as_mut() {
1636 Some(c) => c,
1637 None => return,
1638 };
1639 crypto.decrypt(&sender_fingerprint, &session_id, &ct_bytes)
1640 };
1641 match plaintext {
1642 Ok(pt) => {
1643 let body = String::from_utf8_lossy(&pt).to_string();
1644 let sent_at = now_unix();
1645 let _ = repo::insert_room_message(
1646 &self.db,
1647 room_id,
1648 &sender_fingerprint,
1649 "in",
1650 &body,
1651 sent_at,
1652 );
1653 let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
1654 self.maybe_emit_mention(room_id, &body);
1655 let _ = self.app_event_tx.send(AppEvent::MessageReceived {
1656 room_id: room_id.to_string(),
1657 sender_fingerprint,
1658 body,
1659 sent_at,
1660 });
1661 }
1662 Err(e) => {
1663 debug!(%e, "decrypt failed (probably missing session key)");
1664 }
1665 }
1666 }
1667 RoomMessage::Plain {
1668 sender_fingerprint,
1669 body,
1670 } => {
1671 if sender_fingerprint == our_fp {
1672 return;
1673 }
1674 let sent_at = now_unix();
1675 let _ = repo::insert_room_message(
1676 &self.db,
1677 room_id,
1678 &sender_fingerprint,
1679 "in",
1680 &body,
1681 sent_at,
1682 );
1683 let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
1684 self.maybe_emit_mention(room_id, &body);
1685 let _ = self.app_event_tx.send(AppEvent::MessageReceived {
1686 room_id: room_id.to_string(),
1687 sender_fingerprint,
1688 body,
1689 sent_at,
1690 });
1691 }
1692 RoomMessage::Typing { sender_fingerprint } => {
1693 if sender_fingerprint == our_fp {
1694 return;
1695 }
1696 let expiry = now_unix() + TYPING_TTL_SECS;
1697 let mut rooms = self.active_rooms.lock().unwrap();
1698 if let Some(room) = rooms.get_mut(room_id) {
1699 room.typers.insert(sender_fingerprint, expiry);
1700 }
1701 drop(rooms);
1702 let _ = self.app_event_tx.send(AppEvent::TypingChanged {
1703 room_id: room_id.to_string(),
1704 });
1705 }
1706 RoomMessage::RotateRoomKey {
1707 rotator_fingerprint,
1708 new_salt,
1709 } => {
1710 if rotator_fingerprint == our_fp {
1711 return;
1712 }
1713 let signer = match verified_signer {
1718 Some(fp) => fp,
1719 None => {
1720 warn!(%room_id, "RotateRoomKey arrived unsigned; dropping");
1721 return;
1722 }
1723 };
1724 if signer != rotator_fingerprint {
1725 warn!(
1726 %signer, %rotator_fingerprint, %room_id,
1727 "RotateRoomKey signer mismatch with claimed rotator; dropping"
1728 );
1729 return;
1730 }
1731 let _ = self.app_event_tx.send(AppEvent::RotationRequested {
1732 room_id: room_id.to_string(),
1733 rotator_fingerprint,
1734 new_salt,
1735 });
1736 }
1737 RoomMessage::MemberLeave { sender_fingerprint } => {
1738 if sender_fingerprint == our_fp {
1739 return;
1740 }
1741 let removed = {
1742 let mut rooms = self.active_rooms.lock().unwrap();
1743 if let Some(room) = rooms.get_mut(room_id) {
1744 room.members.remove(&sender_fingerprint)
1745 } else {
1746 false
1747 }
1748 };
1749 if removed {
1750 let _ = self.app_event_tx.send(AppEvent::MemberLeft {
1751 room_id: room_id.to_string(),
1752 fingerprint: sender_fingerprint,
1753 });
1754 }
1755 }
1756 RoomMessage::FileOffer {
1757 sender_fingerprint,
1758 file_id,
1759 name,
1760 size_bytes,
1761 mime,
1762 chunk_count,
1763 encrypted_meta,
1764 } => {
1765 if sender_fingerprint == our_fp {
1766 return; }
1768 self.handle_file_offer(
1769 room_id,
1770 sender_fingerprint,
1771 file_id,
1772 name,
1773 size_bytes,
1774 mime,
1775 chunk_count,
1776 encrypted_meta,
1777 );
1778 }
1779 RoomMessage::FileChunk {
1780 sender_fingerprint,
1781 file_id,
1782 chunk_index,
1783 total_chunks,
1784 data_b64,
1785 } => {
1786 if sender_fingerprint == our_fp {
1787 return;
1788 }
1789 self.handle_file_chunk(
1790 room_id,
1791 sender_fingerprint,
1792 file_id,
1793 chunk_index,
1794 total_chunks,
1795 data_b64,
1796 );
1797 }
1798 RoomMessage::OwnerGrant {
1799 room_id: announced_room_id,
1800 target_fingerprint,
1801 } => {
1802 if announced_room_id != room_id {
1807 warn!(payload_room = %announced_room_id, topic_room = %room_id, "OwnerGrant room mismatch");
1808 return;
1809 }
1810 let signer = match verified_signer {
1811 Some(fp) => fp,
1812 None => {
1813 warn!(%room_id, "OwnerGrant arrived unsigned; dropping");
1814 return;
1815 }
1816 };
1817 if !self.is_owner(room_id, &signer) {
1818 warn!(%signer, %room_id, "OwnerGrant signer isn't an owner; dropping");
1819 return;
1820 }
1821 info!(%signer, %target_fingerprint, %room_id, "OwnerGrant applied");
1822 if let Err(e) =
1823 repo::set_member_role(&self.db, room_id, &target_fingerprint, "owner")
1824 {
1825 warn!(%e, "OwnerGrant: set_member_role failed");
1826 }
1827 }
1828 RoomMessage::BanMember {
1829 room_id: announced_room_id,
1830 target_fingerprint,
1831 } => {
1832 if announced_room_id != room_id {
1833 warn!(payload_room = %announced_room_id, topic_room = %room_id, "BanMember room mismatch");
1834 return;
1835 }
1836 let signer = match verified_signer {
1837 Some(fp) => fp,
1838 None => {
1839 warn!(%room_id, "BanMember arrived unsigned; dropping");
1840 return;
1841 }
1842 };
1843 if !self.is_owner(room_id, &signer) {
1844 warn!(%signer, %room_id, "BanMember signer isn't an owner; dropping");
1845 return;
1846 }
1847 if target_fingerprint == our_fp {
1848 info!(%room_id, %signer, "we were kicked from this room");
1854 self.active_rooms.lock().unwrap().remove(room_id);
1855 let _ = self.app_event_tx.send(AppEvent::RoomLeft {
1856 room_id: room_id.to_string(),
1857 });
1858 return;
1859 }
1860 info!(%signer, %target_fingerprint, %room_id, "BanMember applied");
1861 if let Err(e) = repo::add_room_ban(
1862 &self.db,
1863 room_id,
1864 &target_fingerprint,
1865 &signer,
1866 "", now_unix(),
1868 ) {
1869 warn!(%e, "BanMember: add_room_ban failed");
1870 }
1871 self.evict_banned_member(room_id, &target_fingerprint);
1872 }
1873 RoomMessage::SasInit {
1874 tx_id,
1875 ephemeral_x25519_pubkey_b64,
1876 target_fingerprint,
1877 } => {
1878 if target_fingerprint != our_fp {
1879 return;
1884 }
1885 let signer = match verified_signer {
1886 Some(fp) => fp,
1887 None => {
1888 warn!("SasInit arrived unsigned; dropping");
1889 return;
1890 }
1891 };
1892 let their_pub =
1893 match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
1894 Ok(pk) => pk,
1895 Err(e) => {
1896 warn!(%e, "SasInit: bad x25519 pubkey");
1897 return;
1898 }
1899 };
1900 let tx_id_bytes = match B64.decode(&tx_id) {
1901 Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
1902 let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
1903 arr.copy_from_slice(&b);
1904 arr
1905 }
1906 _ => {
1907 warn!(%tx_id, "SasInit: bad tx_id length");
1908 return;
1909 }
1910 };
1911 let (_, our_secret, our_pub) = crate::crypto::sas::new_session();
1912 let sas_code =
1913 crate::crypto::sas::derive_sas_code(&our_secret, &their_pub, &tx_id_bytes);
1914 self.sas_flows.lock().unwrap().insert(
1915 tx_id.clone(),
1916 SasFlow {
1917 room_id: room_id.to_string(),
1918 partner_fingerprint: signer.clone(),
1919 our_secret,
1920 sas_code: Some(sas_code.clone()),
1921 our_confirmed: false,
1922 their_confirmed: false,
1923 },
1924 );
1925 let response = RoomMessage::SasResponse {
1928 tx_id: tx_id.clone(),
1929 ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
1930 };
1931 if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
1932 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
1933 self.network
1934 .publish_room_message(room_id.to_string(), bytes)
1935 .await;
1936 }
1937 }
1938 let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
1939 room_id: room_id.to_string(),
1940 partner_fingerprint: signer,
1941 tx_id,
1942 emoji_string: sas_code.emoji_string(),
1943 emoji_labels: sas_code.emoji_labels(),
1944 decimal: sas_code.decimal,
1945 });
1946 }
1947 RoomMessage::SasResponse {
1948 tx_id,
1949 ephemeral_x25519_pubkey_b64,
1950 } => {
1951 let signer = match verified_signer {
1952 Some(fp) => fp,
1953 None => {
1954 warn!("SasResponse arrived unsigned; dropping");
1955 return;
1956 }
1957 };
1958 let their_pub =
1959 match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
1960 Ok(pk) => pk,
1961 Err(e) => {
1962 warn!(%e, "SasResponse: bad x25519 pubkey");
1963 return;
1964 }
1965 };
1966 let tx_id_bytes = match B64.decode(&tx_id) {
1967 Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
1968 let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
1969 arr.copy_from_slice(&b);
1970 arr
1971 }
1972 _ => return,
1973 };
1974 let emit = {
1975 let mut flows = self.sas_flows.lock().unwrap();
1976 let flow = match flows.get_mut(&tx_id) {
1977 Some(f) => f,
1978 None => {
1979 warn!(%tx_id, "SasResponse for unknown tx_id");
1980 return;
1981 }
1982 };
1983 if flow.partner_fingerprint != signer {
1984 warn!(
1985 expected = %flow.partner_fingerprint, got = %signer,
1986 "SasResponse signer doesn't match flow's partner; dropping"
1987 );
1988 return;
1989 }
1990 let code = crate::crypto::sas::derive_sas_code(
1991 &flow.our_secret,
1992 &their_pub,
1993 &tx_id_bytes,
1994 );
1995 flow.sas_code = Some(code.clone());
1996 code
1997 };
1998 let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
1999 room_id: room_id.to_string(),
2000 partner_fingerprint: signer,
2001 tx_id,
2002 emoji_string: emit.emoji_string(),
2003 emoji_labels: emit.emoji_labels(),
2004 decimal: emit.decimal,
2005 });
2006 }
2007 RoomMessage::CodeJoinRequest {
2008 room_id: announced_room_id,
2009 joiner_x25519_pubkey_b64,
2010 code,
2011 } => {
2012 if announced_room_id != room_id {
2013 return;
2014 }
2015 let joiner_fp = match verified_signer {
2016 Some(fp) => fp,
2017 None => {
2018 warn!("CodeJoinRequest unsigned; dropping");
2019 return;
2020 }
2021 };
2022 let our_fp = self.identity.fingerprint().to_string();
2026 if !self.is_owner(room_id, &our_fp) {
2027 return;
2028 }
2029 let now = now_unix();
2031 let (code_ok, our_session_id, wrap_input) = {
2032 let mut rooms = self.active_rooms.lock().unwrap();
2033 let room = match rooms.get_mut(room_id) {
2034 Some(r) => r,
2035 None => return,
2036 };
2037 if room.passphrase_key.is_none() {
2038 warn!("CodeJoinRequest: no passphrase key locally; can't respond");
2039 return;
2040 }
2041 let original_len = room.issued_codes.len();
2042 room.issued_codes.retain(|(c, exp)| !(c == &code && *exp > now));
2043 let matched = room.issued_codes.len() < original_len;
2044 if !matched {
2045 info!(%joiner_fp, "CodeJoinRequest: code invalid or expired; ignoring");
2046 return;
2047 }
2048 let crypto = room.crypto.as_ref().unwrap();
2049 (
2050 true,
2051 crypto.our_session_id(),
2052 crypto.our_session_key_b64(),
2053 )
2054 };
2055 let _ = code_ok;
2056 let their_pub = match crate::crypto::sas::parse_pubkey(&joiner_x25519_pubkey_b64) {
2058 Ok(pk) => pk,
2059 Err(e) => {
2060 warn!(%e, "CodeJoinRequest: bad pubkey");
2061 return;
2062 }
2063 };
2064 use x25519_dalek::{PublicKey, StaticSecret};
2065 let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
2066 let our_pub = PublicKey::from(&our_secret);
2067 let shared = our_secret.diffie_hellman(&their_pub);
2068 let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
2070 let mut wrap_key = [0u8; passphrase::KEY_LEN];
2071 hk.expand(b"huddle-code-join-v1", &mut wrap_key)
2072 .expect("32 bytes is within HKDF limits");
2073 let wrapped = match passphrase::wrap(wrap_input.as_bytes(), &wrap_key) {
2076 Ok(w) => w,
2077 Err(e) => {
2078 warn!(%e, "CodeJoinRequest: wrap failed");
2079 return;
2080 }
2081 };
2082 let response = RoomMessage::CodeJoinResponse {
2083 room_id: room_id.to_string(),
2084 target_fingerprint: joiner_fp.clone(),
2085 owner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2086 owner_session_id: our_session_id,
2087 wrapped_session_key_b64: wrapped,
2088 nonce_b64: String::new(), };
2090 if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
2091 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
2092 self.network
2093 .publish_room_message(room_id.to_string(), bytes)
2094 .await;
2095 }
2096 }
2097 info!(%joiner_fp, %room_id, "issued CodeJoinResponse");
2098 }
2099 RoomMessage::CodeJoinResponse {
2100 room_id: announced_room_id,
2101 target_fingerprint,
2102 owner_x25519_pubkey_b64,
2103 owner_session_id,
2104 wrapped_session_key_b64,
2105 nonce_b64: _,
2106 } => {
2107 if announced_room_id != room_id || target_fingerprint != our_fp {
2108 return;
2109 }
2110 let owner_fp = match verified_signer {
2111 Some(fp) => fp,
2112 None => {
2113 warn!("CodeJoinResponse unsigned; dropping");
2114 return;
2115 }
2116 };
2117 let our_secret = match self
2118 .pending_code_secrets
2119 .lock()
2120 .unwrap()
2121 .remove(&(room_id.to_string(), our_fp.clone()))
2122 {
2123 Some(s) => s,
2124 None => {
2125 warn!(%room_id, "CodeJoinResponse with no pending code-join state");
2126 return;
2127 }
2128 };
2129 let owner_pub = match crate::crypto::sas::parse_pubkey(&owner_x25519_pubkey_b64) {
2130 Ok(pk) => pk,
2131 Err(e) => {
2132 warn!(%e, "CodeJoinResponse: bad owner pubkey");
2133 return;
2134 }
2135 };
2136 let shared = our_secret.diffie_hellman(&owner_pub);
2137 let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
2138 let mut wrap_key = [0u8; passphrase::KEY_LEN];
2139 hk.expand(b"huddle-code-join-v1", &mut wrap_key)
2140 .expect("32 bytes within HKDF limits");
2141 let session_key_bytes =
2142 match passphrase::unwrap(&wrapped_session_key_b64, &wrap_key) {
2143 Ok(b) => b,
2144 Err(e) => {
2145 warn!(%e, "CodeJoinResponse: unwrap failed");
2146 return;
2147 }
2148 };
2149 let session_key_str = match String::from_utf8(session_key_bytes) {
2150 Ok(s) => s,
2151 Err(e) => {
2152 warn!(%e, "CodeJoinResponse: session key wasn't valid utf8");
2153 return;
2154 }
2155 };
2156 let mut rooms = self.active_rooms.lock().unwrap();
2158 if let Some(room) = rooms.get_mut(room_id) {
2159 if let Some(crypto) = room.crypto.as_mut() {
2160 if let Err(e) =
2161 crypto.add_inbound_session(&owner_fp, &session_key_str)
2162 {
2163 warn!(%e, "CodeJoinResponse: add_inbound_session failed");
2164 } else {
2165 info!(%room_id, %owner_fp, %owner_session_id, "code-join completed; can decrypt owner's messages");
2166 room.members.insert(owner_fp.clone());
2167 let _ = self.app_event_tx.send(AppEvent::MemberJoined {
2168 room_id: room_id.to_string(),
2169 fingerprint: owner_fp,
2170 });
2171 }
2172 }
2173 }
2174 }
2175 RoomMessage::JoinRefused {
2176 room_id: announced_room_id,
2177 target_fingerprint,
2178 reason,
2179 } => {
2180 if announced_room_id != room_id || target_fingerprint != our_fp {
2181 return;
2182 }
2183 let _ = self.app_event_tx.send(AppEvent::Error {
2187 description: format!("join refused: {reason}"),
2188 });
2189 }
2190 RoomMessage::SasConfirm { tx_id, matched } => {
2191 let signer = match verified_signer {
2192 Some(fp) => fp,
2193 None => return,
2194 };
2195 let (room_id_done, partner_fp_done, both_done) = {
2196 let mut flows = self.sas_flows.lock().unwrap();
2197 let flow = match flows.get_mut(&tx_id) {
2198 Some(f) => f,
2199 None => return,
2200 };
2201 if flow.partner_fingerprint != signer {
2202 return;
2203 }
2204 if !matched {
2205 let _ = flow;
2207 flows.remove(&tx_id);
2208 return;
2209 }
2210 flow.their_confirmed = true;
2211 if flow.our_confirmed && flow.their_confirmed {
2212 (
2213 Some(flow.room_id.clone()),
2214 Some(flow.partner_fingerprint.clone()),
2215 true,
2216 )
2217 } else {
2218 (None, None, false)
2219 }
2220 };
2221 if both_done {
2222 if let (Some(rid), Some(pfp)) = (room_id_done, partner_fp_done) {
2223 if let Err(e) = self.finish_sas(&tx_id, &rid, &pfp).await {
2224 warn!(%e, "finish_sas failed");
2225 }
2226 }
2227 }
2228 }
2229 }
2230 }
2231
2232 pub async fn send_file(&self, room_id: &str, path: &Path) -> Result<String> {
2240 let bytes = std::fs::read(path)?;
2241 let name = path
2242 .file_name()
2243 .map(|n| n.to_string_lossy().to_string())
2244 .unwrap_or_else(|| "untitled".into());
2245 let mime = crate::files::guess_mime(&name);
2246 let original_path = path.to_path_buf();
2247
2248 let (room_encrypted, mut maybe_session_id, encrypted_meta_opt, wire_bytes) = {
2249 let mut rooms = self.active_rooms.lock().unwrap();
2250 let room = rooms
2251 .get_mut(room_id)
2252 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
2253 if room.info.encrypted {
2254 let crypto = room
2255 .crypto
2256 .as_mut()
2257 .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
2258 let (ciphertext, meta) = file_encryption::encrypt_file(&bytes, crypto)?;
2259 (true, Some(meta.megolm_session_id.clone()), Some(meta), ciphertext)
2260 } else {
2261 (false, None, None, bytes)
2262 }
2263 };
2264 let _ = &mut maybe_session_id; let plan =
2267 self.file_manager
2268 .prepare_outgoing_from_bytes(&name, mime.clone(), wire_bytes)?;
2269 let file_id = plan.file_id.clone();
2270 let total = plan.chunks.len() as u32;
2271 let our_fp = self.identity.fingerprint().to_string();
2272
2273 let attachment = StoredAttachment {
2274 id: 0,
2275 room_id: room_id.to_string(),
2276 message_id: None,
2277 sender_fingerprint: our_fp.clone(),
2278 file_id: file_id.clone(),
2279 name: name.clone(),
2280 mime: mime.clone(),
2281 size_bytes: plan.size_bytes as i64,
2282 status: AttachmentStatus::Ready,
2283 cache_path: Some(self.file_manager.cache_path(&file_id).to_string_lossy().into()),
2284 saved_path: Some(original_path.to_string_lossy().into()),
2285 error: None,
2286 encrypted: room_encrypted,
2287 wrapped_key: encrypted_meta_opt.as_ref().map(|m| m.wrapped_key_b64.clone()),
2288 nonce: encrypted_meta_opt.as_ref().map(|m| m.nonce_b64.clone()),
2289 megolm_session_id: encrypted_meta_opt
2290 .as_ref()
2291 .map(|m| m.megolm_session_id.clone()),
2292 content_hash: encrypted_meta_opt.as_ref().map(|m| m.content_hash.clone()),
2293 created_at: now_unix(),
2294 };
2295 repo::upsert_attachment(&self.db, &attachment)?;
2296 let _ = self.app_event_tx.send(AppEvent::FileOffered {
2297 room_id: room_id.to_string(),
2298 file_id: file_id.clone(),
2299 name: name.clone(),
2300 size_bytes: plan.size_bytes,
2301 sender_fingerprint: our_fp.clone(),
2302 });
2303
2304 let offer = RoomMessage::FileOffer {
2306 sender_fingerprint: our_fp.clone(),
2307 file_id: file_id.clone(),
2308 name,
2309 size_bytes: plan.size_bytes,
2310 mime,
2311 chunk_count: total,
2312 encrypted_meta: encrypted_meta_opt,
2313 };
2314 if let Ok(bytes) = encode_wire(&offer) {
2315 self.network
2316 .publish_room_message(room_id.to_string(), bytes)
2317 .await;
2318 }
2319
2320 let net = self.network.clone();
2323 let room = room_id.to_string();
2324 let our = our_fp.clone();
2325 let fid = file_id.clone();
2326 let chunks = plan.chunks.clone();
2327 tokio::spawn(async move {
2328 for (i, data) in chunks.iter().enumerate() {
2329 let msg = RoomMessage::FileChunk {
2330 sender_fingerprint: our.clone(),
2331 file_id: fid.clone(),
2332 chunk_index: i as u32,
2333 total_chunks: total,
2334 data_b64: B64.encode(data),
2335 };
2336 if let Ok(bytes) = encode_wire(&msg) {
2337 net.publish_room_message(room.clone(), bytes).await;
2338 }
2339 tokio::time::sleep(Duration::from_millis(40)).await;
2340 }
2341 });
2342
2343 Ok(file_id)
2344 }
2345
2346 pub async fn save_to_downloads(&self, room_id: &str, file_id: &str) -> Result<PathBuf> {
2349 let attachment = repo::get_attachment(&self.db, room_id, file_id)?
2350 .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
2351 if !matches!(
2352 attachment.status,
2353 AttachmentStatus::Ready | AttachmentStatus::Saved
2354 ) {
2355 return Err(HuddleError::Other(format!(
2356 "attachment is not ready (status={})",
2357 attachment.status.as_str()
2358 )));
2359 }
2360 let plaintext = if attachment.encrypted
2365 && attachment.sender_fingerprint == self.identity.fingerprint()
2366 {
2367 match attachment
2368 .saved_path
2369 .as_deref()
2370 .filter(|p| Path::new(p).exists())
2371 {
2372 Some(src) => std::fs::read(src)?,
2373 None => {
2374 return Err(HuddleError::Other(
2375 "your original file has moved or been deleted — it can't be \
2376 recovered from the encrypted cache"
2377 .into(),
2378 ));
2379 }
2380 }
2381 } else {
2382 let cached = self.file_manager.read_cache(file_id)?;
2383 if attachment.encrypted {
2384 let meta = EncryptedFileMeta {
2385 megolm_session_id: attachment
2386 .megolm_session_id
2387 .clone()
2388 .ok_or_else(|| HuddleError::Other("missing megolm_session_id".into()))?,
2389 wrapped_key_b64: attachment
2390 .wrapped_key
2391 .clone()
2392 .ok_or_else(|| HuddleError::Other("missing wrapped_key".into()))?,
2393 nonce_b64: attachment
2394 .nonce
2395 .clone()
2396 .ok_or_else(|| HuddleError::Other("missing nonce".into()))?,
2397 content_hash: attachment
2398 .content_hash
2399 .clone()
2400 .ok_or_else(|| HuddleError::Other("missing content_hash".into()))?,
2401 };
2402 self.decrypt_attachment(
2403 room_id,
2404 &attachment.sender_fingerprint,
2405 &cached,
2406 &meta,
2407 )?
2408 } else {
2409 cached
2410 }
2411 };
2412 let saved = self.file_manager.write_to_downloads(&attachment.name, &plaintext)?;
2413 repo::update_attachment_paths(
2414 &self.db,
2415 room_id,
2416 file_id,
2417 None,
2418 Some(&saved.to_string_lossy()),
2419 )?;
2420 repo::update_attachment_status(&self.db, room_id, file_id, AttachmentStatus::Saved, None)?;
2421 let _ = self.app_event_tx.send(AppEvent::FileSaved {
2422 file_id: file_id.into(),
2423 path: saved.to_string_lossy().into(),
2424 });
2425 Ok(saved)
2426 }
2427
2428 pub async fn cancel_transfer(&self, room_id: &str, file_id: &str) -> Result<()> {
2430 self.file_manager.cancel_incoming(file_id);
2431 repo::update_attachment_status(
2432 &self.db,
2433 room_id,
2434 file_id,
2435 AttachmentStatus::Cancelled,
2436 None,
2437 )?;
2438 Ok(())
2439 }
2440
2441 pub fn open_saved(&self, room_id: &str, file_id: &str) -> Result<()> {
2443 let attachment = repo::get_attachment(&self.db, room_id, file_id)?
2444 .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
2445 let path = attachment
2446 .saved_path
2447 .ok_or_else(|| HuddleError::Other("not saved yet — press Enter to save first".into()))?;
2448 open_with_system(&path)
2449 }
2450
2451 pub fn list_room_attachments(&self, room_id: &str) -> Result<Vec<StoredAttachment>> {
2452 repo::list_room_attachments(&self.db, room_id)
2453 }
2454
2455 pub fn set_member_verified(
2459 &self,
2460 room_id: &str,
2461 fingerprint: &str,
2462 verified: bool,
2463 ) -> Result<()> {
2464 let members = repo::list_room_members(&self.db, room_id).unwrap_or_default();
2469 if !members.iter().any(|m| m.fingerprint == fingerprint) {
2470 repo::upsert_room_member(
2471 &self.db,
2472 &StoredRoomMember {
2473 room_id: room_id.to_string(),
2474 peer_id: String::new(),
2475 fingerprint: fingerprint.to_string(),
2476 last_seen: Some(now_unix()),
2477 verified,
2478 ed25519_pubkey: None,
2479 role: "member".into(),
2480 },
2481 )?;
2482 }
2483 repo::set_member_verified(&self.db, room_id, fingerprint, verified)
2484 }
2485
2486 pub fn verified_fingerprints(&self, room_id: &str) -> Vec<String> {
2487 repo::list_verified_fingerprints(&self.db, room_id).unwrap_or_default()
2488 }
2489
2490 pub fn is_owner(&self, room_id: &str, fingerprint: &str) -> bool {
2493 repo::list_room_owners(&self.db, room_id)
2494 .unwrap_or_default()
2495 .iter()
2496 .any(|fp| fp == fingerprint)
2497 }
2498
2499 pub fn we_are_owner(&self, room_id: &str) -> bool {
2500 self.is_owner(room_id, &self.identity.fingerprint().to_string())
2501 }
2502
2503 pub fn room_owners(&self, room_id: &str) -> Vec<String> {
2506 repo::list_room_owners(&self.db, room_id).unwrap_or_default()
2507 }
2508
2509 pub fn verified_only_inbound(&self) -> bool {
2512 repo::get_setting(&self.db, "verified_only_inbound")
2513 .unwrap_or(None)
2514 .map(|v| v == "1")
2515 .unwrap_or(false)
2516 }
2517
2518 pub fn set_verified_only_inbound(&self, on: bool) -> Result<()> {
2519 repo::set_setting(&self.db, "verified_only_inbound", if on { "1" } else { "0" })
2520 }
2521
2522 pub fn room_verified_only(&self, room_id: &str) -> bool {
2527 repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
2528 }
2529
2530 pub fn set_room_verified_only(&self, room_id: &str, on: bool) -> Result<()> {
2531 repo::set_room_verified_only(&self.db, room_id, on)
2532 }
2533
2534 pub fn onboarding_seen(&self) -> bool {
2536 repo::is_onboarding_seen(&self.db).unwrap_or(true)
2537 }
2538
2539 pub fn mark_onboarding_seen(&self) -> Result<()> {
2540 repo::mark_onboarding_seen(&self.db)
2541 }
2542
2543 pub async fn grant_owner(&self, room_id: &str, target_fingerprint: &str) -> Result<()> {
2547 let our_fp = self.identity.fingerprint().to_string();
2548 if !self.is_owner(room_id, &our_fp) {
2549 return Err(HuddleError::Other(
2550 "only an owner can grant owner".into(),
2551 ));
2552 }
2553 let msg = RoomMessage::OwnerGrant {
2554 room_id: room_id.to_string(),
2555 target_fingerprint: target_fingerprint.to_string(),
2556 };
2557 let env = crate::crypto::sign_message(&self.identity, &msg)?;
2558 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
2559 self.network
2560 .publish_room_message(room_id.to_string(), bytes)
2561 .await;
2562 repo::set_member_role(&self.db, room_id, target_fingerprint, "owner")?;
2564 Ok(())
2565 }
2566
2567 pub async fn kick_member(
2578 &self,
2579 room_id: &str,
2580 target_fingerprint: &str,
2581 ) -> Result<String> {
2582 let our_fp = self.identity.fingerprint().to_string();
2583 if !self.is_owner(room_id, &our_fp) {
2584 return Err(HuddleError::Other("only an owner can kick".into()));
2585 }
2586 if target_fingerprint == our_fp {
2587 return Err(HuddleError::Other("can't kick yourself".into()));
2588 }
2589 let info = self
2590 .active_rooms
2591 .lock()
2592 .unwrap()
2593 .get(room_id)
2594 .map(|r| r.info.clone())
2595 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
2596 if !info.encrypted {
2597 let msg = RoomMessage::BanMember {
2601 room_id: room_id.to_string(),
2602 target_fingerprint: target_fingerprint.to_string(),
2603 };
2604 let env = crate::crypto::sign_message(&self.identity, &msg)?;
2605 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
2606 self.network
2607 .publish_room_message(room_id.to_string(), bytes)
2608 .await;
2609 repo::add_room_ban(
2610 &self.db,
2611 room_id,
2612 target_fingerprint,
2613 &our_fp,
2614 &env.signature_b64,
2615 now_unix(),
2616 )?;
2617 self.evict_banned_member(room_id, target_fingerprint);
2618 return Ok(String::new());
2619 }
2620 let new_passphrase = generate_join_passphrase();
2622 let msg = RoomMessage::BanMember {
2623 room_id: room_id.to_string(),
2624 target_fingerprint: target_fingerprint.to_string(),
2625 };
2626 let env = crate::crypto::sign_message(&self.identity, &msg)?;
2627 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
2628 self.network
2629 .publish_room_message(room_id.to_string(), bytes)
2630 .await;
2631 repo::add_room_ban(
2632 &self.db,
2633 room_id,
2634 target_fingerprint,
2635 &our_fp,
2636 &env.signature_b64,
2637 now_unix(),
2638 )?;
2639 self.evict_banned_member(room_id, target_fingerprint);
2640 self.rotate_room(room_id, &new_passphrase).await?;
2643 Ok(new_passphrase)
2644 }
2645
2646 pub fn generate_join_code(&self, room_id: &str) -> Result<String> {
2653 let our_fp = self.identity.fingerprint().to_string();
2654 if !self.is_owner(room_id, &our_fp) {
2655 return Err(HuddleError::Other(
2656 "only an owner can issue join codes".into(),
2657 ));
2658 }
2659 let code = generate_alphanumeric_code(8);
2660 let expires_at = now_unix() + 10 * 60;
2661 let mut rooms = self.active_rooms.lock().unwrap();
2662 let room = rooms
2663 .get_mut(room_id)
2664 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
2665 let now = now_unix();
2667 room.issued_codes.retain(|(_, exp)| *exp > now);
2668 room.issued_codes.push((code.clone(), expires_at));
2669 Ok(code)
2670 }
2671
2672 pub async fn join_room_with_code(
2679 &self,
2680 room_id: &str,
2681 code: &str,
2682 ) -> Result<()> {
2683 let info = {
2685 let d = self.discovered_rooms.lock().unwrap().get(room_id).cloned();
2686 match d {
2687 Some(d) => StoredRoom {
2688 id: room_id.to_string(),
2689 name: d.name,
2690 creator_fingerprint: d.creator_fingerprint,
2691 encrypted: d.encrypted,
2692 passphrase_salt: None, created_at: now_unix(),
2694 last_active: Some(now_unix()),
2695 },
2696 None => {
2697 return Err(HuddleError::Other(format!(
2698 "room {room_id} not visible — wait for an announcement"
2699 )))
2700 }
2701 }
2702 };
2703 if !info.encrypted {
2704 return Err(HuddleError::Other(
2705 "code-join only applies to encrypted rooms".into(),
2706 ));
2707 }
2708 let our_fp = self.identity.fingerprint().to_string();
2709 use x25519_dalek::{PublicKey, StaticSecret};
2712 let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
2713 let our_pub = PublicKey::from(&our_secret);
2714 let key = (room_id.to_string(), our_fp.clone());
2719 self.pending_code_secrets
2720 .lock()
2721 .unwrap()
2722 .insert(key.clone(), our_secret);
2723 let map = self.pending_code_secrets.clone();
2728 let tx = self.app_event_tx.clone();
2729 let timeout_room = room_id.to_string();
2730 tokio::spawn(async move {
2731 tokio::time::sleep(std::time::Duration::from_secs(30)).await;
2732 let still_pending = map.lock().unwrap().remove(&key).is_some();
2733 if still_pending {
2734 let _ = tx.send(AppEvent::CodeJoinTimedOut {
2735 room_id: timeout_room,
2736 reason: "no response from owner — code may be wrong or expired".into(),
2737 });
2738 }
2739 });
2740 repo::insert_room(&self.db, &info)?;
2747 self.active_rooms.lock().unwrap().insert(
2750 room_id.to_string(),
2751 ActiveRoom {
2752 info: info.clone(),
2753 crypto: Some(RoomCrypto::new_for_room(
2754 self.db.clone(),
2755 room_id.to_string(),
2756 our_fp.clone(),
2757 self.session_persist_key,
2758 )?),
2759 passphrase_key: None,
2760 members: {
2761 let mut s = HashSet::new();
2762 s.insert(our_fp.clone());
2763 s
2764 },
2765 typers: HashMap::new(),
2766 read_only: true,
2767 issued_codes: Vec::new(),
2768 },
2769 );
2770 self.network.subscribe_room(room_id.to_string()).await;
2771 let req = RoomMessage::CodeJoinRequest {
2773 room_id: room_id.to_string(),
2774 joiner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2775 code: code.to_string(),
2776 };
2777 let env = crate::crypto::sign_message(&self.identity, &req)?;
2778 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
2779 self.network
2780 .publish_room_message(room_id.to_string(), bytes)
2781 .await;
2782 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
2785 room_id: room_id.to_string(),
2786 });
2787 Ok(())
2788 }
2789
2790 pub async fn sas_start(&self, room_id: &str, target_fingerprint: &str) -> Result<String> {
2796 let (tx_id_bytes, our_secret, our_pub) = crate::crypto::sas::new_session();
2797 let tx_id = B64.encode(tx_id_bytes);
2798 let msg = RoomMessage::SasInit {
2799 tx_id: tx_id.clone(),
2800 ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2801 target_fingerprint: target_fingerprint.to_string(),
2802 };
2803 let env = crate::crypto::sign_message(&self.identity, &msg)?;
2804 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
2805 self.sas_flows.lock().unwrap().insert(
2806 tx_id.clone(),
2807 SasFlow {
2808 room_id: room_id.to_string(),
2809 partner_fingerprint: target_fingerprint.to_string(),
2810 our_secret,
2811 sas_code: None,
2812 our_confirmed: false,
2813 their_confirmed: false,
2814 },
2815 );
2816 self.network
2817 .publish_room_message(room_id.to_string(), bytes)
2818 .await;
2819 Ok(tx_id)
2820 }
2821
2822 pub async fn sas_match(&self, tx_id: &str) -> Result<()> {
2826 let (room_id, partner_fp, both_done) = {
2827 let mut flows = self.sas_flows.lock().unwrap();
2828 let flow = flows
2829 .get_mut(tx_id)
2830 .ok_or_else(|| HuddleError::Other("unknown SAS tx_id".into()))?;
2831 flow.our_confirmed = true;
2832 (
2833 flow.room_id.clone(),
2834 flow.partner_fingerprint.clone(),
2835 flow.our_confirmed && flow.their_confirmed,
2836 )
2837 };
2838 let msg = RoomMessage::SasConfirm {
2839 tx_id: tx_id.to_string(),
2840 matched: true,
2841 };
2842 let env = crate::crypto::sign_message(&self.identity, &msg)?;
2843 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
2844 self.network
2845 .publish_room_message(room_id.clone(), bytes)
2846 .await;
2847 if both_done {
2848 self.finish_sas(tx_id, &room_id, &partner_fp).await?;
2849 }
2850 Ok(())
2851 }
2852
2853 pub fn sas_cancel(&self, tx_id: &str) {
2857 self.sas_flows.lock().unwrap().remove(tx_id);
2858 }
2859
2860 async fn finish_sas(
2863 &self,
2864 tx_id: &str,
2865 room_id: &str,
2866 partner_fingerprint: &str,
2867 ) -> Result<()> {
2868 repo::set_member_verified(&self.db, room_id, partner_fingerprint, true)?;
2869 repo::add_verified_peer(&self.db, partner_fingerprint, now_unix())?;
2870 self.sas_flows.lock().unwrap().remove(tx_id);
2871 let _ = self.app_event_tx.send(AppEvent::SasVerified {
2872 room_id: room_id.to_string(),
2873 partner_fingerprint: partner_fingerprint.to_string(),
2874 });
2875 Ok(())
2876 }
2877
2878 fn evict_banned_member(&self, room_id: &str, fingerprint: &str) {
2883 if let Some(room) = self.active_rooms.lock().unwrap().get_mut(room_id) {
2884 room.members.remove(fingerprint);
2885 }
2886 let _ = self.app_event_tx.send(AppEvent::MemberLeft {
2887 room_id: room_id.to_string(),
2888 fingerprint: fingerprint.to_string(),
2889 });
2890 }
2891
2892 pub fn display_name(&self) -> Option<String> {
2893 repo::get_display_name(&self.db).unwrap_or(None)
2894 }
2895
2896 pub fn set_display_name(&self, name: Option<&str>) -> Result<()> {
2897 repo::set_display_name(&self.db, name)
2898 }
2899
2900 pub fn lookup_member_display_name(&self, fingerprint: &str) -> Option<String> {
2902 repo::lookup_display_name(&self.db, fingerprint).unwrap_or(None)
2903 }
2904
2905 pub fn is_room_muted(&self, room_id: &str) -> bool {
2906 repo::is_room_muted(&self.db, room_id).unwrap_or(false)
2907 }
2908
2909 pub fn list_room_bans(&self, room_id: &str) -> Vec<String> {
2914 repo::list_room_bans(&self.db, room_id).unwrap_or_default()
2915 }
2916
2917 pub fn list_blocked_peers(&self) -> Vec<String> {
2921 repo::list_blocked_peers(&self.db).unwrap_or_default()
2922 }
2923
2924 pub fn unblock_peer(&self, fingerprint: &str) -> Result<()> {
2928 repo::unblock_peer(&self.db, fingerprint)
2929 }
2930
2931 pub fn is_room_read_only(&self, room_id: &str) -> bool {
2937 self.active_rooms
2938 .lock()
2939 .unwrap()
2940 .get(room_id)
2941 .map(|r| r.read_only)
2942 .unwrap_or(false)
2943 }
2944
2945 pub fn set_room_muted(&self, room_id: &str, muted: bool) -> Result<()> {
2946 repo::set_room_muted(&self.db, room_id, muted)
2947 }
2948
2949 pub async fn broadcast_typing(&self, room_id: &str) {
2952 if !self.active_rooms.lock().unwrap().contains_key(room_id) {
2953 return;
2954 }
2955 let msg = RoomMessage::Typing {
2956 sender_fingerprint: self.identity.fingerprint().to_string(),
2957 };
2958 if let Ok(bytes) = encode_wire(&msg) {
2959 self.network
2960 .publish_room_message(room_id.to_string(), bytes)
2961 .await;
2962 }
2963 }
2964
2965 pub fn typers_in_room(&self, room_id: &str) -> Vec<String> {
2968 let now = now_unix();
2969 let mut rooms = self.active_rooms.lock().unwrap();
2970 let room = match rooms.get_mut(room_id) {
2971 Some(r) => r,
2972 None => return Vec::new(),
2973 };
2974 room.typers.retain(|_, exp| *exp > now);
2975 let mut v: Vec<String> = room.typers.keys().cloned().collect();
2976 v.sort();
2977 v
2978 }
2979
2980 pub async fn rotate_room(&self, room_id: &str, new_passphrase: &str) -> Result<()> {
2990 if new_passphrase.is_empty() {
2991 return Err(HuddleError::Other("new passphrase is empty".into()));
2992 }
2993 let new_salt = passphrase::random_salt();
2994 let new_key = passphrase::derive_key(new_passphrase, &new_salt)?;
2995
2996 let info = {
2997 let mut rooms = self.active_rooms.lock().unwrap();
2998 let room = rooms
2999 .get_mut(room_id)
3000 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3001 if !room.info.encrypted {
3002 return Err(HuddleError::Other(
3003 "rotation only applies to encrypted rooms".into(),
3004 ));
3005 }
3006 let new_crypto = RoomCrypto::new_for_room(
3008 self.db.clone(),
3009 room_id.to_string(),
3010 self.identity.fingerprint().to_string(),
3011 self.session_persist_key,
3012 )?;
3013 room.crypto = Some(new_crypto);
3014 room.passphrase_key = Some(new_key);
3015 room.info.passphrase_salt = Some(new_salt.to_vec());
3016 room.info.clone()
3017 };
3018
3019 let rot = RoomMessage::RotateRoomKey {
3025 rotator_fingerprint: self.identity.fingerprint().to_string(),
3026 new_salt: new_salt.to_vec(),
3027 };
3028 if let Ok(env) = crate::crypto::sign_message(&self.identity, &rot) {
3032 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
3033 self.network
3034 .publish_room_message(room_id.to_string(), bytes)
3035 .await;
3036 }
3037 }
3038 if let Err(e) = self.broadcast_member_announce(room_id).await {
3040 warn!(%e, "rotate: broadcast announce failed");
3041 }
3042
3043 repo::insert_room(&self.db, &info)?;
3045 Ok(())
3046 }
3047
3048 pub async fn accept_rotation(
3052 &self,
3053 room_id: &str,
3054 new_salt: &[u8],
3055 new_passphrase: &str,
3056 ) -> Result<()> {
3057 let new_key = passphrase::derive_key(new_passphrase, new_salt)?;
3058 let info = {
3059 let mut rooms = self.active_rooms.lock().unwrap();
3060 let room = rooms
3061 .get_mut(room_id)
3062 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3063 room.passphrase_key = Some(new_key);
3064 room.info.passphrase_salt = Some(new_salt.to_vec());
3065 room.info.clone()
3066 };
3067 let req = RoomMessage::SessionKeyRequest {
3071 requester_fingerprint: self.identity.fingerprint().to_string(),
3072 };
3073 if let Ok(bytes) = encode_wire(&req) {
3074 self.network
3075 .publish_room_message(room_id.to_string(), bytes)
3076 .await;
3077 }
3078 repo::insert_room(&self.db, &info)?;
3079 Ok(())
3080 }
3081
3082 #[allow(clippy::too_many_arguments)]
3087 fn handle_file_offer(
3088 &self,
3089 room_id: &str,
3090 sender_fingerprint: String,
3091 file_id: String,
3092 name: String,
3093 size_bytes: u64,
3094 mime: Option<String>,
3095 _chunk_count: u32,
3096 encrypted_meta: Option<EncryptedFileMeta>,
3097 ) {
3098 let encrypted = encrypted_meta.is_some();
3099 let attachment = StoredAttachment {
3100 id: 0,
3101 room_id: room_id.to_string(),
3102 message_id: None,
3103 sender_fingerprint: sender_fingerprint.clone(),
3104 file_id: file_id.clone(),
3105 name: name.clone(),
3106 mime,
3107 size_bytes: size_bytes as i64,
3108 status: AttachmentStatus::Offered,
3109 cache_path: None,
3110 saved_path: None,
3111 error: None,
3112 encrypted,
3113 wrapped_key: encrypted_meta.as_ref().map(|m| m.wrapped_key_b64.clone()),
3114 nonce: encrypted_meta.as_ref().map(|m| m.nonce_b64.clone()),
3115 megolm_session_id: encrypted_meta.as_ref().map(|m| m.megolm_session_id.clone()),
3116 content_hash: encrypted_meta.as_ref().map(|m| m.content_hash.clone()),
3117 created_at: now_unix(),
3118 };
3119 if let Err(e) = repo::upsert_attachment(&self.db, &attachment) {
3120 warn!(%e, "upsert attachment");
3121 return;
3122 }
3123 self.file_manager.set_expected_size(&file_id, size_bytes);
3126 let _ = self.app_event_tx.send(AppEvent::FileOffered {
3127 room_id: room_id.to_string(),
3128 file_id,
3129 name,
3130 size_bytes,
3131 sender_fingerprint,
3132 });
3133 }
3134
3135 fn handle_file_chunk(
3136 &self,
3137 room_id: &str,
3138 _sender_fingerprint: String,
3139 file_id: String,
3140 chunk_index: u32,
3141 total_chunks: u32,
3142 data_b64: String,
3143 ) {
3144 let data = match B64.decode(&data_b64) {
3145 Ok(d) => d,
3146 Err(e) => {
3147 warn!(%e, "bad chunk base64");
3148 return;
3149 }
3150 };
3151 let expected_size = match repo::get_attachment(&self.db, room_id, &file_id) {
3155 Ok(Some(a)) => {
3156 if matches!(
3157 a.status,
3158 AttachmentStatus::Cancelled | AttachmentStatus::Failed
3159 ) {
3160 return;
3161 }
3162 a.size_bytes as u64
3163 }
3164 Ok(None) => crate::files::MAX_FILE_SIZE,
3165 Err(e) => {
3166 warn!(%e, "get attachment for chunk");
3167 crate::files::MAX_FILE_SIZE
3168 }
3169 };
3170
3171 let result = self.file_manager.accept_chunk(
3172 &file_id,
3173 chunk_index,
3174 total_chunks,
3175 data,
3176 expected_size,
3177 );
3178 match result {
3179 Ok(None) => {
3180 let _ = repo::update_attachment_status(
3182 &self.db,
3183 room_id,
3184 &file_id,
3185 AttachmentStatus::Downloading,
3186 None,
3187 );
3188 let bytes_so_far = self
3191 .file_manager
3192 .progress(&file_id)
3193 .map(|(b, _)| b)
3194 .unwrap_or(0);
3195 let _ = self.app_event_tx.send(AppEvent::FileProgress {
3196 file_id: file_id.clone(),
3197 bytes_received: bytes_so_far,
3198 total_bytes: expected_size,
3199 });
3200 }
3201 Ok(Some(completed)) => {
3202 let _ = repo::update_attachment_paths(
3203 &self.db,
3204 room_id,
3205 &file_id,
3206 Some(&completed.cache_path.to_string_lossy()),
3207 None,
3208 );
3209 let _ = repo::update_attachment_status(
3210 &self.db,
3211 room_id,
3212 &file_id,
3213 AttachmentStatus::Ready,
3214 None,
3215 );
3216 let _ = self.app_event_tx.send(AppEvent::FileReady {
3217 file_id: file_id.clone(),
3218 });
3219 }
3220 Err(e) => {
3221 let msg = e.to_string();
3222 warn!(%msg, "chunk processing failed");
3223 let _ = repo::update_attachment_status(
3224 &self.db,
3225 room_id,
3226 &file_id,
3227 AttachmentStatus::Failed,
3228 Some(&msg),
3229 );
3230 let _ = self.app_event_tx.send(AppEvent::FileFailed {
3231 file_id: file_id.clone(),
3232 reason: msg,
3233 });
3234 }
3235 }
3236 }
3237
3238 fn maybe_emit_mention(&self, room_id: &str, body: &str) {
3241 let full = self.identity.fingerprint().to_lowercase();
3242 let short: &str = full.split('-').next().unwrap_or(&full);
3244 let lower = body.to_lowercase();
3245 let hit = lower.contains(full.as_str())
3249 || lower
3250 .split(|c: char| !c.is_ascii_hexdigit())
3251 .any(|tok| tok == short);
3252 if hit {
3253 let _ = self.app_event_tx.send(AppEvent::MentionReceived {
3254 room_id: room_id.to_string(),
3255 body: body.to_string(),
3256 });
3257 }
3258 }
3259
3260 fn decrypt_attachment(
3261 &self,
3262 room_id: &str,
3263 sender_fingerprint: &str,
3264 ciphertext: &[u8],
3265 meta: &EncryptedFileMeta,
3266 ) -> Result<Vec<u8>> {
3267 let mut rooms = self.active_rooms.lock().unwrap();
3268 let room = rooms
3269 .get_mut(room_id)
3270 .ok_or_else(|| HuddleError::Other("not in room".into()))?;
3271 let crypto = room
3272 .crypto
3273 .as_mut()
3274 .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
3275 file_encryption::decrypt_file(ciphertext, meta, crypto, sender_fingerprint)
3276 }
3277}
3278
3279fn open_with_system(path: &str) -> Result<()> {
3281 #[cfg(target_os = "macos")]
3282 let cmd = "open";
3283 #[cfg(target_os = "linux")]
3284 let cmd = "xdg-open";
3285 #[cfg(target_os = "windows")]
3286 let cmd = "cmd";
3287 #[cfg(target_os = "windows")]
3288 let args = vec!["/C", "start", "", path];
3289 #[cfg(not(target_os = "windows"))]
3290 let args = vec![path];
3291
3292 std::process::Command::new(cmd)
3293 .args(args)
3294 .spawn()
3295 .map_err(|e| HuddleError::Other(format!("spawn opener: {e}")))?;
3296 Ok(())
3297}
3298
3299static ROOM_SALT_CACHE: std::sync::LazyLock<Mutex<HashMap<String, Vec<u8>>>> =
3302 std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
3303
3304pub fn salt_len() -> usize {
3309 SALT_LEN
3310}
3311
3312fn now_unix() -> i64 {
3313 SystemTime::now()
3314 .duration_since(UNIX_EPOCH)
3315 .unwrap()
3316 .as_secs() as i64
3317}
3318
3319fn generate_join_passphrase() -> String {
3325 use rand::RngCore;
3326 let mut bytes = [0u8; 16];
3327 rand::thread_rng().fill_bytes(&mut bytes);
3328 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
3331}
3332
3333fn generate_alphanumeric_code(len: usize) -> String {
3338 use rand::Rng;
3339 const ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ23456789";
3340 let mut rng = rand::thread_rng();
3341 let mut out = String::with_capacity(len + 1);
3342 for i in 0..len {
3343 if i == 4 && len == 8 {
3344 out.push('-'); }
3346 let idx = rng.gen_range(0..ALPHABET.len());
3347 out.push(ALPHABET[idx] as char);
3348 }
3349 out
3350}
3351
3352#[cfg(test)]
3353mod parser_tests {
3354 use super::parse_dial_address;
3355
3356 #[test]
3357 fn parses_ipv4_port() {
3358 let m = parse_dial_address("10.3.72.53:9027").unwrap();
3359 assert_eq!(m.to_string(), "/ip4/10.3.72.53/tcp/9027");
3360 }
3361
3362 #[test]
3363 fn parses_bracketed_ipv6() {
3364 let m = parse_dial_address("[::1]:9027").unwrap();
3365 assert_eq!(m.to_string(), "/ip6/::1/tcp/9027");
3366 }
3367
3368 #[test]
3369 fn rejects_unbracketed_ipv6() {
3370 let err = parse_dial_address("fe80::1:9027").unwrap_err();
3371 assert!(err.to_string().contains("brackets"));
3372 }
3373
3374 #[test]
3375 fn passes_through_raw_multiaddr() {
3376 let m = parse_dial_address("/ip4/1.2.3.4/tcp/9000").unwrap();
3377 assert_eq!(m.to_string(), "/ip4/1.2.3.4/tcp/9000");
3378 }
3379
3380 #[test]
3381 fn empty_address_is_error() {
3382 assert!(parse_dial_address(" ").is_err());
3383 }
3384
3385 #[test]
3386 fn rejects_bad_port() {
3387 assert!(parse_dial_address("1.2.3.4:notaport").is_err());
3388 }
3389}