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 last_profile_broadcast_at_ms: Arc<Mutex<HashMap<String, i64>>>,
195 app_event_tx: broadcast::Sender<AppEvent>,
196}
197
198const HOST_ADDR_DIAL_BACKOFF_SECS: i64 = 300;
201
202const PROFILE_REBROADCAST_FLOOR_MS: i64 = 60_000;
206
207impl AppHandle {
208 pub async fn start() -> Result<Self> {
209 Self::start_with_options(NetworkMode::Mdns, 0, None, Vec::new()).await
210 }
211
212 pub async fn start_with_options(
213 mode: NetworkMode,
214 port: u16,
215 master_key: Option<&[u8; 32]>,
216 relays: Vec<Multiaddr>,
217 ) -> Result<Self> {
218 config::ensure_data_dir()?;
219 let session_persist_key = match master_key {
224 Some(mk) => storage::keychain::derive_subkey(mk, b"megolm-persist"),
225 None => [0u8; 32],
226 };
227 let db = storage::open_db(&config::db_path(), master_key)?;
228 Self::start_with_db_and_options(db, mode, port, session_persist_key, relays).await
229 }
230
231 pub async fn start_with_db(db: Db) -> Result<Self> {
232 Self::start_with_db_and_options(db, NetworkMode::Mdns, 0, [0u8; 32], Vec::new()).await
233 }
234
235 pub async fn start_with_db_and_options(
236 db: Db,
237 mode: NetworkMode,
238 port: u16,
239 session_persist_key: [u8; 32],
240 relays: Vec<Multiaddr>,
241 ) -> Result<Self> {
242 let identity = Self::load_or_create_identity(&db)?;
243 let identity = Arc::new(identity);
244 info!(fingerprint = %identity.fingerprint(), peer_id = %identity.peer_id(), mode = %mode.as_str(), port, relay_count = relays.len(), "identity loaded");
245
246 let (net_event_tx, net_event_rx) = tokio::sync::mpsc::channel::<NetworkEvent>(256);
247 let (app_event_tx, _) = broadcast::channel::<AppEvent>(256);
248 let network =
249 network::start_network_with(&identity, net_event_tx, mode, port, relays)?;
250
251 let active_rooms = Arc::new(Mutex::new(HashMap::new()));
252 let discovered_rooms = Arc::new(Mutex::new(HashMap::new()));
253 let restorable_rooms = Arc::new(Mutex::new(HashMap::new()));
254 let connected_dial_addrs = Arc::new(Mutex::new(HashMap::new()));
255 let file_manager = Arc::new(FileManager::new(&config::data_dir())?);
256
257 let handle = Self {
258 identity,
259 network,
260 mode,
261 active_rooms,
262 discovered_rooms,
263 restorable_rooms,
264 connected_dial_addrs,
265 file_manager,
266 db,
267 session_persist_key,
268 sas_flows: Arc::new(Mutex::new(HashMap::new())),
269 pending_code_secrets: Arc::new(Mutex::new(HashMap::new())),
270 pending_invite_dials: Arc::new(Mutex::new(HashMap::new())),
271 nat_reachable_addrs: Arc::new(Mutex::new(HashSet::new())),
272 relay_circuit_addrs: Arc::new(Mutex::new(HashSet::new())),
273 host_addr_dial_attempts: Arc::new(Mutex::new(HashMap::new())),
274 last_profile_broadcast_at_ms: Arc::new(Mutex::new(HashMap::new())),
275 app_event_tx,
276 };
277
278 handle.spawn_event_processor(net_event_rx);
279 handle.spawn_announcement_ticker();
280 handle.spawn_discovered_room_pruner();
281 handle.spawn_known_peer_reconnector();
282 handle.restore_rooms_from_db().await;
283
284 Ok(handle)
285 }
286
287 pub fn mode(&self) -> NetworkMode {
288 self.mode
289 }
290
291 pub fn subscribe(&self) -> broadcast::Receiver<AppEvent> {
292 self.app_event_tx.subscribe()
293 }
294
295 pub fn fingerprint(&self) -> &str {
296 self.identity.fingerprint()
297 }
298
299 pub fn peer_id(&self) -> PeerId {
300 self.identity.peer_id()
301 }
302
303 pub fn discovered_rooms(&self) -> Vec<DiscoveredRoom> {
304 let now = now_unix();
305 let mut by_id: HashMap<String, DiscoveredRoom> = self
306 .discovered_rooms
307 .lock()
308 .unwrap()
309 .clone();
310
311 for room in self.active_rooms.lock().unwrap().values() {
315 let entry = DiscoveredRoom {
316 room_id: room.info.id.clone(),
317 name: room.info.name.clone(),
318 encrypted: room.info.encrypted,
319 member_count: room.members.len() as u32,
320 creator_fingerprint: room.info.creator_fingerprint.clone(),
321 last_seen: now,
322 restorable: false,
323 host_addrs: Vec::new(),
324 };
325 by_id
326 .entry(room.info.id.clone())
327 .and_modify(|d| {
328 d.last_seen = now;
329 if entry.member_count > d.member_count {
330 d.member_count = entry.member_count;
331 }
332 d.restorable = false;
333 })
334 .or_insert(entry);
335 }
336
337 for (id, stored) in self.restorable_rooms.lock().unwrap().iter() {
341 if by_id.contains_key(id) {
342 continue;
343 }
344 by_id.insert(
345 id.clone(),
346 DiscoveredRoom {
347 room_id: id.clone(),
348 name: stored.name.clone(),
349 encrypted: stored.encrypted,
350 member_count: 0,
351 creator_fingerprint: stored.creator_fingerprint.clone(),
352 last_seen: stored.last_active.unwrap_or(stored.created_at),
353 restorable: true,
354 host_addrs: Vec::new(),
355 },
356 );
357 }
358
359 let mut v: Vec<DiscoveredRoom> = by_id.into_values().collect();
360 v.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
361 v
362 }
363
364 pub fn active_room_ids(&self) -> Vec<String> {
365 self.active_rooms.lock().unwrap().keys().cloned().collect()
366 }
367
368 pub fn active_room_info(&self, room_id: &str) -> Option<StoredRoom> {
369 self.active_rooms
370 .lock()
371 .unwrap()
372 .get(room_id)
373 .map(|r| r.info.clone())
374 }
375
376 pub fn room_members(&self, room_id: &str) -> Vec<String> {
377 self.active_rooms
378 .lock()
379 .unwrap()
380 .get(room_id)
381 .map(|r| {
382 let mut m: Vec<String> = r.members.iter().cloned().collect();
383 m.sort();
384 m
385 })
386 .unwrap_or_default()
387 }
388
389 pub fn room_messages(&self, room_id: &str, limit: i64) -> Result<Vec<repo::StoredRoomMessage>> {
390 repo::get_room_messages(&self.db, room_id, limit)
391 }
392
393 pub fn search_room_messages(
394 &self,
395 room_id: &str,
396 query: &str,
397 limit: i64,
398 ) -> Result<Vec<repo::StoredRoomMessage>> {
399 repo::search_room_messages(&self.db, room_id, query, limit)
400 }
401
402 pub async fn start_room(
404 &self,
405 name: &str,
406 encrypted: bool,
407 passphrase: Option<&str>,
408 ) -> Result<String> {
409 if encrypted && passphrase.is_none() {
410 return Err(HuddleError::Other(
411 "encrypted room requires a passphrase".into(),
412 ));
413 }
414
415 let created_at = now_unix();
416 let creator_fp = self.identity.fingerprint().to_string();
417 let room_id = derive_room_id(&creator_fp, name, created_at);
418
419 let (passphrase_salt, passphrase_key) = if encrypted {
420 let salt = passphrase::random_salt();
421 let key = passphrase::derive_key(passphrase.unwrap(), &salt)?;
422 (Some(salt.to_vec()), Some(key))
423 } else {
424 (None, None)
425 };
426
427 let info = StoredRoom {
428 id: room_id.clone(),
429 name: name.to_string(),
430 creator_fingerprint: creator_fp.clone(),
431 encrypted,
432 passphrase_salt: passphrase_salt.clone(),
433 created_at,
434 last_active: Some(created_at),
435 };
436 repo::insert_room(&self.db, &info)?;
437
438 let crypto = if encrypted {
439 Some(RoomCrypto::new_for_room(
440 self.db.clone(),
441 room_id.clone(),
442 creator_fp.clone(),
443 self.session_persist_key,
444 )?)
445 } else {
446 None
447 };
448
449 let mut members = HashSet::new();
450 members.insert(creator_fp.clone());
451
452 repo::upsert_room_member(
456 &self.db,
457 &StoredRoomMember {
458 room_id: room_id.clone(),
459 peer_id: String::new(),
460 fingerprint: creator_fp.clone(),
461 last_seen: Some(created_at),
462 verified: true, ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
464 role: "owner".into(),
465 },
466 )?;
467
468 self.active_rooms.lock().unwrap().insert(
469 room_id.clone(),
470 ActiveRoom {
471 info: info.clone(),
472 crypto,
473 passphrase_key,
474 members,
475 typers: HashMap::new(),
476 read_only: false,
477 issued_codes: Vec::new(),
478 },
479 );
480
481 self.network.subscribe_room(room_id.clone()).await;
482 self.announce_room_now(&info, 1).await;
483
484 let app = self.clone();
487 let rid = room_id.clone();
488 tokio::spawn(async move {
489 tokio::time::sleep(Duration::from_millis(500)).await;
490 if let Err(e) = app.broadcast_member_announce(&rid).await {
491 warn!(%e, "broadcast member announce");
492 }
493 });
494
495 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
496 room_id: room_id.clone(),
497 });
498
499 Ok(room_id)
500 }
501
502 pub async fn join_room(&self, room_id: &str, passphrase: Option<&str>) -> Result<()> {
506 let (name, creator_fingerprint, encrypted, salt_opt) = {
508 if let Some(d) = self.discovered_rooms.lock().unwrap().get(room_id).cloned() {
509 let salt = self.get_room_salt(room_id);
510 (d.name, d.creator_fingerprint, d.encrypted, salt)
511 } else if let Some(stored) = self.restorable_rooms.lock().unwrap().get(room_id).cloned()
512 {
513 (
514 stored.name,
515 stored.creator_fingerprint,
516 stored.encrypted,
517 stored.passphrase_salt,
518 )
519 } else if let Some(stored) = repo::get_room(&self.db, room_id)? {
520 (
521 stored.name,
522 stored.creator_fingerprint,
523 stored.encrypted,
524 stored.passphrase_salt,
525 )
526 } else {
527 return Err(HuddleError::Other(format!("room {room_id} not found")));
528 }
529 };
530
531 if encrypted && passphrase.is_none() {
532 return Err(HuddleError::Other(
533 "encrypted room requires a passphrase".into(),
534 ));
535 }
536
537 let passphrase_key = if encrypted {
538 let salt = salt_opt
539 .clone()
540 .ok_or_else(|| HuddleError::Other("missing salt for encrypted room".into()))?;
541 Some(passphrase::derive_key(passphrase.unwrap(), &salt)?)
542 } else {
543 None
544 };
545
546 let info = StoredRoom {
547 id: room_id.to_string(),
548 name,
549 creator_fingerprint,
550 encrypted,
551 passphrase_salt: salt_opt.clone(),
552 created_at: now_unix(),
553 last_active: Some(now_unix()),
554 };
555 repo::insert_room(&self.db, &info)?;
556
557 let crypto = if encrypted {
558 let our_fp = self.identity.fingerprint().to_string();
561 let existing = RoomCrypto::load(
562 self.db.clone(),
563 room_id.to_string(),
564 our_fp.clone(),
565 self.session_persist_key,
566 )?;
567 Some(match existing {
568 Some(c) => c,
569 None => RoomCrypto::new_for_room(
570 self.db.clone(),
571 room_id.to_string(),
572 our_fp,
573 self.session_persist_key,
574 )?,
575 })
576 } else {
577 None
578 };
579
580 let mut members = HashSet::new();
581 members.insert(self.identity.fingerprint().to_string());
582
583 self.active_rooms.lock().unwrap().insert(
584 room_id.to_string(),
585 ActiveRoom {
586 info: info.clone(),
587 crypto,
588 passphrase_key,
589 members,
590 typers: HashMap::new(),
591 read_only: false,
592 issued_codes: Vec::new(),
593 },
594 );
595 self.restorable_rooms.lock().unwrap().remove(room_id);
597
598 self.network.subscribe_room(room_id.to_string()).await;
599
600 let app = self.clone();
601 let rid = room_id.to_string();
602 tokio::spawn(async move {
603 tokio::time::sleep(Duration::from_millis(500)).await;
604 if let Err(e) = app.broadcast_member_announce(&rid).await {
605 warn!(%e, "broadcast member announce");
606 }
607 let req = RoomMessage::SessionKeyRequest {
609 requester_fingerprint: app.identity.fingerprint().to_string(),
610 };
611 if let Ok(bytes) = encode_wire(&req) {
612 app.network.publish_room_message(rid.clone(), bytes).await;
613 }
614 });
615
616 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
617 room_id: room_id.to_string(),
618 });
619
620 Ok(())
621 }
622
623 async fn restore_rooms_from_db(&self) {
628 let rooms = match repo::list_rooms(&self.db) {
629 Ok(v) => v,
630 Err(e) => {
631 warn!(%e, "list rooms on restore");
632 return;
633 }
634 };
635 let our_fp = self.identity.fingerprint().to_string();
636 let count = rooms.len();
637 for info in rooms {
638 if info.encrypted {
639 self.restorable_rooms
640 .lock()
641 .unwrap()
642 .insert(info.id.clone(), info);
643 continue;
644 }
645 let mut members = HashSet::new();
646 members.insert(our_fp.clone());
647 if let Ok(stored_members) = repo::list_room_members(&self.db, &info.id) {
648 for m in stored_members {
649 members.insert(m.fingerprint);
650 }
651 }
652 let member_count = members.len() as u32;
653 self.active_rooms.lock().unwrap().insert(
654 info.id.clone(),
655 ActiveRoom {
656 info: info.clone(),
657 crypto: None,
658 passphrase_key: None,
659 members,
660 typers: HashMap::new(),
661 read_only: false,
662 issued_codes: Vec::new(),
663 },
664 );
665 self.network.subscribe_room(info.id.clone()).await;
666 self.announce_room_now(&info, member_count).await;
667 info!(room_id = %info.id, name = %info.name, "restored room");
668 }
669 if count > 0 {
670 debug!(count, "restored rooms from db");
671 }
672 }
673
674 pub async fn leave_room(&self, room_id: &str) -> Result<bool> {
679 let leave_msg = RoomMessage::MemberLeave {
681 sender_fingerprint: self.identity.fingerprint().to_string(),
682 };
683 let dispatched = match encode_wire(&leave_msg) {
684 Ok(bytes) => {
685 self.network
686 .publish_room_message(room_id.to_string(), bytes)
687 .await;
688 true
689 }
690 Err(e) => {
691 warn!(%e, %room_id, "failed to encode MemberLeave notice");
692 false
693 }
694 };
695
696 self.active_rooms.lock().unwrap().remove(room_id);
697 self.network.unsubscribe_room(room_id.to_string()).await;
698
699 let _ = self.app_event_tx.send(AppEvent::RoomLeft {
700 room_id: room_id.to_string(),
701 });
702 Ok(dispatched)
703 }
704
705 pub async fn send_room_message(&self, room_id: &str, body: &str) -> Result<()> {
706 let our_fp = self.identity.fingerprint().to_string();
707 let msg = {
708 let mut rooms = self.active_rooms.lock().unwrap();
709 let room = rooms
710 .get_mut(room_id)
711 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
712
713 if room.read_only {
714 return Err(HuddleError::Other(
715 "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(),
716 ));
717 }
718
719 if room.info.encrypted {
720 let crypto = room
721 .crypto
722 .as_mut()
723 .ok_or_else(|| HuddleError::Session("encrypted room missing crypto".into()))?;
724 let (session_id, ct_bytes) = crypto.encrypt(body.as_bytes())?;
725 RoomMessage::Encrypted {
726 sender_fingerprint: our_fp.clone(),
727 session_id,
728 ciphertext_b64: base64::Engine::encode(
729 &base64::engine::general_purpose::STANDARD,
730 &ct_bytes,
731 ),
732 }
733 } else {
734 RoomMessage::Plain {
735 sender_fingerprint: our_fp.clone(),
736 body: body.to_string(),
737 }
738 }
739 };
740
741 let bytes = encode_wire(&msg)?;
742 self.network
743 .publish_room_message(room_id.to_string(), bytes)
744 .await;
745
746 let now = now_unix();
747 let msg_id =
748 repo::insert_room_message(&self.db, room_id, &our_fp, "out", body, now)?;
749 repo::update_room_last_active(&self.db, room_id, now)?;
750
751 let _ = self.app_event_tx.send(AppEvent::MessageSent {
752 room_id: room_id.to_string(),
753 body: body.to_string(),
754 message_id: msg_id,
755 });
756
757 Ok(())
758 }
759
760 pub async fn shutdown(&self) {
761 self.network.shutdown().await;
762 }
763
764 pub async fn dial_by_id_or_username(&self, input: &str) -> Result<()> {
791 let trimmed = input.trim();
792 if trimmed.is_empty() {
793 return Err(HuddleError::Other("input is empty".into()));
794 }
795 let target_fp = if let Some(fp) = normalize_to_fingerprint(trimmed) {
796 fp
797 } else {
798 let matches = repo::find_peers_by_username(&self.db, trimmed)?;
799 if matches.is_empty() {
800 return Err(HuddleError::Other(format!(
801 "no peer named `{}` known yet — paste their invite link instead",
802 trimmed
803 )));
804 }
805 if matches.len() > 1 {
806 return Err(HuddleError::Other(format!(
807 "username `{}` is ambiguous ({} peers share it) — use their HD- ID instead",
808 trimmed,
809 matches.len()
810 )));
811 }
812 matches.into_iter().next().unwrap()
813 };
814 if target_fp == self.identity.fingerprint() {
815 return Err(HuddleError::Other("that's your own ID".into()));
816 }
817 let addr = self.resolve_dial_addr(&target_fp).ok_or_else(|| {
818 HuddleError::Other(format!(
819 "haven't seen `{}` on the network yet — ask them for an invite link",
820 short_fp_for_msg(&target_fp)
821 ))
822 })?;
823 self.dial(&addr).await
824 }
825
826 fn resolve_dial_addr(&self, fingerprint: &str) -> Option<String> {
831 for room in self.discovered_rooms.lock().unwrap().values() {
832 if room.creator_fingerprint == fingerprint {
833 if let Some(addr) = room.host_addrs.first() {
834 return Some(addr.clone());
835 }
836 }
837 }
838 let known = repo::list_known_peers(&self.db).ok()?;
839 for peer in known {
840 if peer.fingerprint.as_deref() == Some(fingerprint) {
841 return Some(peer.address);
842 }
843 }
844 None
845 }
846
847 pub async fn dial(&self, input: &str) -> Result<()> {
848 let multiaddr = parse_dial_address(input)?;
849 let canonical = multiaddr.to_string();
850 info!(%canonical, "dialing");
851
852 repo::upsert_known_peer(
853 &self.db,
854 &KnownPeer {
855 address: canonical.clone(),
856 label: None,
857 last_connected_at: None,
858 last_attempt_at: Some(now_unix()),
859 created_at: now_unix(),
860 fingerprint: None,
864 trusted: false,
865 },
866 )?;
867
868 let _ = self.app_event_tx.send(AppEvent::Dialing {
869 address: canonical.clone(),
870 });
871 self.network.dial(multiaddr).await;
872 Ok(())
873 }
874
875 pub fn nat_reachable_addrs(&self) -> Vec<String> {
880 self.nat_reachable_addrs
881 .lock()
882 .unwrap()
883 .iter()
884 .cloned()
885 .collect()
886 }
887
888 pub fn dialable_addrs(&self) -> Vec<String> {
896 let mut out: Vec<String> = self
897 .relay_circuit_addrs
898 .lock()
899 .unwrap()
900 .iter()
901 .cloned()
902 .collect();
903 for a in self.nat_reachable_addrs.lock().unwrap().iter() {
904 if !out.contains(a) {
905 out.push(a.clone());
906 }
907 }
908 out.truncate(4);
909 out
910 }
911
912 pub async fn dial_invite(&self, address: &str, claimed_fp: &str) -> Result<()> {
925 let multiaddr = parse_dial_address(address)?;
926 let canonical = multiaddr.to_string();
927 self.pending_invite_dials
928 .lock()
929 .unwrap()
930 .insert(canonical.clone(), claimed_fp.to_string());
931 self.dial(address).await
934 }
935
936 pub fn known_peers(&self) -> Vec<KnownPeerStatus> {
937 let connected = self.connected_dial_addrs.lock().unwrap().clone();
938 let stored = repo::list_known_peers(&self.db).unwrap_or_default();
939 stored
940 .into_iter()
941 .map(|p| {
942 let connected_peer = connected.get(&p.address).copied();
943 KnownPeerStatus {
944 address: p.address,
945 label: p.label,
946 last_connected_at: p.last_connected_at,
947 connected_peer_id: connected_peer,
948 }
949 })
950 .collect()
951 }
952
953 pub async fn forget_peer(&self, address: &str) -> Result<()> {
954 repo::forget_known_peer(&self.db, address)?;
955 self.connected_dial_addrs.lock().unwrap().remove(address);
956 Ok(())
957 }
958
959 pub async fn redial(&self, address: &str) -> Result<()> {
961 self.dial(address).await
962 }
963
964 pub async fn accept_inbound(&self, peer_id: PeerId, address: &str) {
969 self.network.accept_inbound(peer_id).await;
970 self.connected_dial_addrs
971 .lock()
972 .unwrap()
973 .insert(address.to_string(), peer_id);
974 }
975
976 pub async fn reject_inbound(&self, peer_id: PeerId, fingerprint: &str) -> Result<()> {
981 self.network.reject_inbound(peer_id).await;
982 repo::block_peer(&self.db, fingerprint, now_unix())?;
983 Ok(())
984 }
985
986 pub async fn trust_inbound(
989 &self,
990 peer_id: PeerId,
991 fingerprint: &str,
992 address: &str,
993 ) -> Result<()> {
994 self.network.accept_inbound(peer_id).await;
995 self.connected_dial_addrs
996 .lock()
997 .unwrap()
998 .insert(address.to_string(), peer_id);
999 repo::upsert_known_peer(
1003 &self.db,
1004 &KnownPeer {
1005 address: address.to_string(),
1006 label: None,
1007 last_connected_at: Some(now_unix()),
1008 last_attempt_at: Some(now_unix()),
1009 created_at: now_unix(),
1010 fingerprint: Some(fingerprint.to_string()),
1011 trusted: true,
1012 },
1013 )?;
1014 Ok(())
1015 }
1016
1017 fn spawn_known_peer_reconnector(&self) {
1018 let handle = self.clone();
1019 tokio::spawn(async move {
1020 tokio::time::sleep(Duration::from_millis(500)).await;
1022 let known = repo::list_known_peers(&handle.db).unwrap_or_default();
1023 for (i, peer) in known.into_iter().enumerate() {
1027 let handle = handle.clone();
1028 tokio::spawn(async move {
1029 let jitter = (peer.address.len() as u64 * 37) % 200;
1032 tokio::time::sleep(Duration::from_millis(150 * i as u64 + jitter)).await;
1033 if let Err(e) = handle.dial(&peer.address).await {
1034 debug!(%e, addr = %peer.address, "auto-reconnect failed");
1035 }
1036 });
1037 }
1038 });
1039 }
1040
1041 fn load_or_create_identity(db: &Db) -> Result<Identity> {
1046 if let Some(stored) = repo::load_identity(db)? {
1047 let mut bytes = [0u8; 32];
1048 bytes.copy_from_slice(&stored.ed25519_secret);
1049 Identity::from_secret_bytes(bytes)
1050 } else {
1051 let id = Identity::generate()?;
1052 repo::save_identity(db, &id.secret_bytes(), now_unix())?;
1053 Ok(id)
1054 }
1055 }
1056
1057 fn get_room_salt(&self, room_id: &str) -> Option<Vec<u8>> {
1058 self.active_rooms
1059 .lock()
1060 .unwrap()
1061 .get(room_id)
1062 .and_then(|r| r.info.passphrase_salt.clone())
1063 .or_else(|| {
1064 ROOM_SALT_CACHE
1066 .lock()
1067 .unwrap()
1068 .get(room_id)
1069 .cloned()
1070 })
1071 }
1072
1073 async fn announce_room_now(&self, info: &StoredRoom, member_count: u32) {
1074 let owner_fingerprints =
1075 repo::list_room_owners(&self.db, &info.id).unwrap_or_default();
1076 let verified_only = repo::get_room_verified_only(&self.db, &info.id).unwrap_or(false);
1077 let host_addrs = self.dialable_addrs();
1078 let ann = RoomAnnouncement {
1079 room_id: info.id.clone(),
1080 name: info.name.clone(),
1081 encrypted: info.encrypted,
1082 passphrase_salt: info.passphrase_salt.clone(),
1083 member_count,
1084 creator_fingerprint: info.creator_fingerprint.clone(),
1085 announced_at: now_unix(),
1086 owner_fingerprints,
1087 verified_only,
1088 host_addrs,
1089 };
1090 self.network.announce_room(ann).await;
1091 }
1092
1093 async fn broadcast_member_announce(&self, room_id: &str) -> Result<()> {
1094 let our_fp = self.identity.fingerprint().to_string();
1095 let wrapped = {
1096 let mut rooms = self.active_rooms.lock().unwrap();
1097 let room = rooms
1098 .get_mut(room_id)
1099 .ok_or_else(|| HuddleError::Other("not in room".into()))?;
1100 if room.info.encrypted {
1101 let crypto = room.crypto.as_mut().unwrap();
1102 let session_key = crypto.our_session_key_b64();
1103 let passphrase_key = room
1104 .passphrase_key
1105 .as_ref()
1106 .ok_or_else(|| HuddleError::Session("missing passphrase key".into()))?;
1107 Some(passphrase::wrap(session_key.as_bytes(), passphrase_key)?)
1108 } else {
1109 None
1110 }
1111 };
1112 let display_name = repo::get_display_name(&self.db).unwrap_or(None);
1113 let msg = RoomMessage::MemberAnnounce {
1114 sender_fingerprint: our_fp,
1115 wrapped_session_key: wrapped,
1116 display_name,
1117 sender_ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
1118 };
1119 let bytes = encode_wire(&msg)?;
1120 self.network
1121 .publish_room_message(room_id.to_string(), bytes)
1122 .await;
1123 Ok(())
1124 }
1125
1126 fn spawn_event_processor(&self, mut net_rx: tokio::sync::mpsc::Receiver<NetworkEvent>) {
1127 let handle = self.clone();
1128 tokio::spawn(async move {
1129 while let Some(event) = net_rx.recv().await {
1130 handle.process_network_event(event).await;
1131 }
1132 info!("event processor stopped");
1133 });
1134 }
1135
1136 fn spawn_announcement_ticker(&self) {
1137 let handle = self.clone();
1138 tokio::spawn(async move {
1139 let mut interval =
1140 tokio::time::interval(Duration::from_secs(ANNOUNCE_INTERVAL_SECS));
1141 interval.tick().await; loop {
1143 interval.tick().await;
1144 let snapshot: Vec<(StoredRoom, u32)> = {
1145 let active = handle.active_rooms.lock().unwrap();
1146 active
1147 .values()
1148 .map(|r| (r.info.clone(), r.members.len() as u32))
1149 .collect()
1150 };
1151 for (info, member_count) in snapshot {
1152 handle.announce_room_now(&info, member_count).await;
1153 }
1154 }
1155 });
1156 }
1157
1158 fn spawn_discovered_room_pruner(&self) {
1159 let handle = self.clone();
1160 tokio::spawn(async move {
1161 let mut interval = tokio::time::interval(Duration::from_secs(10));
1162 interval.tick().await;
1163 loop {
1164 interval.tick().await;
1165 let now = now_unix();
1166 let mut to_drop = Vec::new();
1167 {
1168 let mut map = handle.discovered_rooms.lock().unwrap();
1169 map.retain(|id, r| {
1170 if now - r.last_seen > DISCOVERED_TTL_SECS {
1171 to_drop.push(id.clone());
1172 false
1173 } else {
1174 true
1175 }
1176 });
1177 }
1178 for id in to_drop {
1179 let _ = handle.app_event_tx.send(AppEvent::RoomLost { room_id: id });
1180 }
1181 }
1182 });
1183 }
1184
1185 async fn process_network_event(&self, event: NetworkEvent) {
1186 match event {
1187 NetworkEvent::PeerDiscovered { peer_id } => {
1188 let _ = self.app_event_tx.send(AppEvent::PeerDiscovered { peer_id });
1189 }
1190 NetworkEvent::PeerExpired { peer_id } => {
1191 self.connected_dial_addrs
1197 .lock()
1198 .unwrap()
1199 .retain(|_addr, pid| *pid != peer_id);
1200 let _ = self.app_event_tx.send(AppEvent::PeerExpired { peer_id });
1201 }
1202 NetworkEvent::ListeningOn { address } => {
1203 let _ = self.app_event_tx.send(AppEvent::ListeningOn {
1204 address: address.to_string(),
1205 });
1206 }
1207 NetworkEvent::RoomAnnouncementReceived(ann) => {
1208 if let Some(salt) = &ann.passphrase_salt {
1210 ROOM_SALT_CACHE
1211 .lock()
1212 .unwrap()
1213 .insert(ann.room_id.clone(), salt.clone());
1214 }
1215 let our_fp_for_dial = self.identity.fingerprint().to_string();
1220 if ann.creator_fingerprint != our_fp_for_dial && !ann.host_addrs.is_empty() {
1221 let now = now_unix();
1222 let should_dial = {
1223 let mut attempts = self.host_addr_dial_attempts.lock().unwrap();
1224 match attempts.get(&ann.creator_fingerprint).copied() {
1225 Some(last) if now - last < HOST_ADDR_DIAL_BACKOFF_SECS => false,
1226 _ => {
1227 attempts.insert(ann.creator_fingerprint.clone(), now);
1228 true
1229 }
1230 }
1231 };
1232 if should_dial {
1233 if let Some(first) = ann.host_addrs.first() {
1234 info!(
1235 announcer = %ann.creator_fingerprint,
1236 addr = %first,
1237 "opportunistic dial via room announcement host_addrs"
1238 );
1239 let _ = self.dial(first).await;
1242 }
1243 }
1244 }
1245 let discovered = DiscoveredRoom {
1246 room_id: ann.room_id.clone(),
1247 name: ann.name.clone(),
1248 encrypted: ann.encrypted,
1249 member_count: ann.member_count,
1250 creator_fingerprint: ann.creator_fingerprint.clone(),
1251 last_seen: now_unix(),
1252 restorable: false,
1253 host_addrs: ann.host_addrs.clone(),
1254 };
1255 if self.active_rooms.lock().unwrap().contains_key(&ann.room_id) {
1260 self.discovered_rooms
1261 .lock()
1262 .unwrap()
1263 .insert(ann.room_id.clone(), discovered);
1264 return;
1265 }
1266 self.discovered_rooms
1267 .lock()
1268 .unwrap()
1269 .insert(ann.room_id.clone(), discovered.clone());
1270 let _ = self.app_event_tx.send(AppEvent::RoomDiscovered(discovered));
1271 }
1272 NetworkEvent::RoomMessageReceived {
1273 room_id,
1274 payload,
1275 from_peer: _,
1276 } => {
1277 let wire: WireMessage = match serde_json::from_slice(&payload) {
1284 Ok(w) => w,
1285 Err(e) => {
1286 warn!(%e, "bad wire envelope");
1287 return;
1288 }
1289 };
1290 let (msg, verified_signer) = match wire {
1291 WireMessage::Plain(m) => (m, None),
1292 WireMessage::Signed(env) => {
1293 let claimed_pubkey = env.ed25519_pubkey_b64.clone();
1294 match crate::crypto::verify_signed(&env) {
1295 Ok((m, fp)) => {
1296 match repo::get_member_ed25519_pubkey(
1303 &self.db, &room_id, &fp,
1304 ) {
1305 Ok(Some(known)) if known != claimed_pubkey => {
1306 warn!(
1307 %fp, %room_id,
1308 "pubkey mismatch vs stored; dropping signed message"
1309 );
1310 return;
1311 }
1312 _ => {}
1313 }
1314 (m, Some(fp))
1315 }
1316 Err(e) => {
1317 warn!(%e, fp = %env.fingerprint, "signed envelope verify failed");
1318 return;
1319 }
1320 }
1321 }
1322 };
1323 self.handle_room_message(&room_id, msg, verified_signer).await;
1324 }
1325 NetworkEvent::DialSucceeded { peer_id, address } => {
1326 let addr_s = address.to_string();
1327 self.connected_dial_addrs
1328 .lock()
1329 .unwrap()
1330 .insert(addr_s.clone(), peer_id);
1331 let _ = repo::upsert_known_peer(
1335 &self.db,
1336 &KnownPeer {
1337 address: addr_s.clone(),
1338 label: None,
1339 last_connected_at: Some(now_unix()),
1340 last_attempt_at: Some(now_unix()),
1341 created_at: now_unix(),
1342 fingerprint: None,
1343 trusted: false,
1344 },
1345 );
1346 let _ = self.app_event_tx.send(AppEvent::DialSucceeded {
1347 address: addr_s,
1348 peer_id,
1349 });
1350 }
1351 NetworkEvent::DialFailed { address, error } => {
1352 let addr_s = address.to_string();
1353 let _ = self.app_event_tx.send(AppEvent::DialFailed {
1354 address: addr_s,
1355 error,
1356 });
1357 }
1358 NetworkEvent::PeerIdentified { peer_id, fingerprint } => {
1359 let matched_addrs: Vec<String> = {
1365 let map = self.connected_dial_addrs.lock().unwrap();
1366 map.iter()
1367 .filter_map(|(addr, pid)| {
1368 if *pid == peer_id {
1369 Some(addr.clone())
1370 } else {
1371 None
1372 }
1373 })
1374 .collect()
1375 };
1376 let mismatch = {
1386 let mut map = self.pending_invite_dials.lock().unwrap();
1387 let mut found: Option<(String, String)> = None;
1388 for addr in &matched_addrs {
1389 if let Some(claimed) = map.remove(addr) {
1390 if claimed != fingerprint {
1391 found = Some((addr.clone(), claimed));
1392 break;
1393 }
1394 }
1395 }
1396 found
1397 };
1398 if let Some((addr, claimed)) = mismatch {
1399 warn!(
1400 %addr, %claimed, actual=%fingerprint,
1401 "invite fingerprint mismatch — disconnecting"
1402 );
1403 self.network.disconnect_peer(peer_id).await;
1404 let _ = self.app_event_tx.send(AppEvent::InviteFingerprintMismatch {
1405 address: addr,
1406 claimed,
1407 actual: fingerprint.clone(),
1408 });
1409 return;
1410 }
1411 for addr in matched_addrs {
1412 let _ = repo::upsert_known_peer(
1413 &self.db,
1414 &KnownPeer {
1415 address: addr,
1416 label: None,
1417 last_connected_at: Some(now_unix()),
1418 last_attempt_at: Some(now_unix()),
1419 created_at: now_unix(),
1420 fingerprint: Some(fingerprint.clone()),
1421 trusted: true,
1422 },
1423 );
1424 }
1425 let our_username = repo::get_display_name(&self.db).unwrap_or(None);
1433 if our_username.is_some() {
1434 let now_ms = now_unix_ms();
1435 let should_send = {
1436 let mut last = self.last_profile_broadcast_at_ms.lock().unwrap();
1437 match last.get(&fingerprint) {
1438 Some(prev) if now_ms - prev < PROFILE_REBROADCAST_FLOOR_MS => false,
1439 _ => {
1440 last.insert(fingerprint.clone(), now_ms);
1441 true
1442 }
1443 }
1444 };
1445 if should_send {
1446 let msg = RoomMessage::ProfileUpdate {
1447 sender_fingerprint: self.identity.fingerprint().to_string(),
1448 username: our_username,
1449 updated_at: now_ms,
1450 };
1451 if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
1452 if let Ok(bytes) =
1453 crate::network::protocol::encode_wire_signed(&env)
1454 {
1455 let rooms: Vec<String> = self
1456 .active_rooms
1457 .lock()
1458 .unwrap()
1459 .keys()
1460 .cloned()
1461 .collect();
1462 for room_id in rooms {
1463 self.network
1464 .publish_room_message(room_id, bytes.clone())
1465 .await;
1466 }
1467 }
1468 }
1469 }
1470 }
1471 }
1472 NetworkEvent::RelayReservationEstablished { address } => {
1473 info!(addr = %address, "relay reservation established");
1478 self.relay_circuit_addrs
1479 .lock()
1480 .unwrap()
1481 .insert(address.to_string());
1482 let _ = self.app_event_tx.send(AppEvent::ListeningOn {
1483 address: address.to_string(),
1484 });
1485 }
1486 NetworkEvent::NatProbeResult {
1487 tested_addr,
1488 reachable,
1489 } => {
1490 let addr_s = tested_addr.to_string();
1491 let (transitioned, becomes_reachable) = {
1492 let mut set = self.nat_reachable_addrs.lock().unwrap();
1493 let was_empty = set.is_empty();
1494 if reachable {
1495 set.insert(addr_s.clone());
1496 } else {
1497 set.remove(&addr_s);
1498 }
1499 let is_empty = set.is_empty();
1500 (was_empty != is_empty, !is_empty)
1501 };
1502 if transitioned {
1503 let label = if becomes_reachable {
1504 "reachable".to_string()
1505 } else {
1506 "private".to_string()
1507 };
1508 info!(reachable = %becomes_reachable, "NAT reachability changed");
1509 let _ = self.app_event_tx.send(AppEvent::NatStatusChanged {
1510 label,
1511 reachable: becomes_reachable,
1512 });
1513 }
1514 }
1515 NetworkEvent::DcutrUpgrade {
1516 remote_peer,
1517 success,
1518 } => {
1519 if success {
1520 let s = remote_peer.to_base58();
1524 let tail: String = s.chars().rev().take(8).collect::<String>()
1525 .chars()
1526 .rev()
1527 .collect();
1528 let _ = self.app_event_tx.send(AppEvent::DcutrSucceeded {
1529 peer_label: tail,
1530 });
1531 }
1532 }
1533 NetworkEvent::InboundDial {
1534 peer_id,
1535 fingerprint,
1536 address,
1537 } => {
1538 if repo::is_peer_blocked(&self.db, &fingerprint).unwrap_or(false) {
1540 info!(%fingerprint, "inbound dial auto-rejected: peer is blocked");
1541 self.network.reject_inbound(peer_id).await;
1542 return;
1543 }
1544 let global_verified_only =
1549 repo::get_setting(&self.db, "verified_only_inbound")
1550 .ok()
1551 .flatten()
1552 .map(|v| v == "1")
1553 .unwrap_or(false);
1554 if global_verified_only {
1555 let is_verified =
1556 repo::is_globally_verified(&self.db, &fingerprint).unwrap_or(false)
1557 || repo::is_fingerprint_trusted(&self.db, &fingerprint)
1558 .unwrap_or(false);
1559 if !is_verified {
1560 info!(
1561 %fingerprint,
1562 "inbound dial auto-rejected: verified-only mode"
1563 );
1564 self.network.reject_inbound(peer_id).await;
1565 return;
1566 }
1567 }
1568 if repo::is_fingerprint_trusted(&self.db, &fingerprint).unwrap_or(false) {
1569 info!(%fingerprint, "inbound dial auto-accepted: peer is trusted");
1570 self.connected_dial_addrs
1573 .lock()
1574 .unwrap()
1575 .insert(address.to_string(), peer_id);
1576 let _ = repo::upsert_known_peer(
1577 &self.db,
1578 &KnownPeer {
1579 address: address.to_string(),
1580 label: None,
1581 last_connected_at: Some(now_unix()),
1582 last_attempt_at: Some(now_unix()),
1583 created_at: now_unix(),
1584 fingerprint: Some(fingerprint),
1585 trusted: true,
1586 },
1587 );
1588 self.network.accept_inbound(peer_id).await;
1589 return;
1590 }
1591 let _ = self.app_event_tx.send(AppEvent::InboundDial {
1593 peer_id,
1594 fingerprint,
1595 address: address.to_string(),
1596 });
1597 }
1598 }
1599 }
1600
1601 async fn handle_room_message(
1607 &self,
1608 room_id: &str,
1609 msg: RoomMessage,
1610 verified_signer: Option<String>,
1611 ) {
1612 let our_fp = self.identity.fingerprint().to_string();
1613 match msg {
1614 RoomMessage::MemberAnnounce {
1615 sender_fingerprint,
1616 wrapped_session_key,
1617 display_name,
1618 sender_ed25519_pubkey,
1619 } => {
1620 if sender_fingerprint == our_fp {
1621 return;
1622 }
1623 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
1626 .unwrap_or(false)
1627 {
1628 info!(%sender_fingerprint, %room_id, "dropping MemberAnnounce from banned peer");
1629 return;
1630 }
1631 if repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
1638 && !repo::is_globally_verified(&self.db, &sender_fingerprint).unwrap_or(false)
1639 {
1640 info!(
1641 %sender_fingerprint, %room_id,
1642 "dropping MemberAnnounce: room is verified-only and joiner isn't verified"
1643 );
1644 let owners = repo::list_room_owners(&self.db, room_id).unwrap_or_default();
1645 let lowest_owner = owners.iter().min().cloned();
1646 if lowest_owner.as_deref() == Some(&our_fp) {
1647 let msg = RoomMessage::JoinRefused {
1648 room_id: room_id.to_string(),
1649 target_fingerprint: sender_fingerprint.clone(),
1650 reason: "room requires SAS verification — ask an existing member to verify you".into(),
1651 };
1652 if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
1653 if let Ok(bytes) =
1654 crate::network::protocol::encode_wire_signed(&env)
1655 {
1656 self.network
1657 .publish_room_message(room_id.to_string(), bytes)
1658 .await;
1659 }
1660 }
1661 }
1662 return;
1663 }
1664 let need_inbound = {
1665 let mut rooms = self.active_rooms.lock().unwrap();
1666 let room = match rooms.get_mut(room_id) {
1667 Some(r) => r,
1668 None => return,
1669 };
1670 let newly_added = room.members.insert(sender_fingerprint.clone());
1671 if newly_added {
1672 let _ = self.app_event_tx.send(AppEvent::MemberJoined {
1673 room_id: room_id.to_string(),
1674 fingerprint: sender_fingerprint.clone(),
1675 });
1676 }
1677 let _ = repo::upsert_room_member(
1682 &self.db,
1683 &StoredRoomMember {
1684 room_id: room_id.to_string(),
1685 peer_id: String::new(), fingerprint: sender_fingerprint.clone(),
1687 last_seen: Some(now_unix()),
1688 verified: false,
1689 ed25519_pubkey: sender_ed25519_pubkey.clone(),
1690 role: "member".into(),
1696 },
1697 );
1698 if let Some(name) = display_name.as_deref() {
1699 let _ = repo::set_member_display_name(
1700 &self.db,
1701 room_id,
1702 &sender_fingerprint,
1703 Some(name),
1704 );
1705 }
1706 room.info.encrypted && wrapped_session_key.is_some()
1707 };
1708
1709 if need_inbound {
1710 let wrapped = wrapped_session_key.unwrap();
1711 let result = {
1712 let mut rooms = self.active_rooms.lock().unwrap();
1713 let room = rooms.get_mut(room_id).unwrap();
1714 let passphrase_key = match &room.passphrase_key {
1715 Some(k) => k,
1716 None => {
1717 warn!("no passphrase key when receiving session key");
1718 return;
1719 }
1720 };
1721 match passphrase::unwrap(&wrapped, passphrase_key) {
1722 Ok(plain) => match String::from_utf8(plain) {
1723 Ok(key_b64) => {
1724 let crypto = room.crypto.as_mut().unwrap();
1725 crypto.add_inbound_session(&sender_fingerprint, &key_b64)
1726 }
1727 Err(e) => Err(HuddleError::Session(format!("utf8: {e}"))),
1728 },
1729 Err(e) => Err(e),
1730 }
1731 };
1732 if let Err(e) = result {
1733 error!(%e, "add inbound session failed");
1734 }
1735 }
1736 }
1737 RoomMessage::SessionKeyRequest {
1738 requester_fingerprint,
1739 } => {
1740 if requester_fingerprint == our_fp {
1741 return;
1742 }
1743 if let Err(e) = self.broadcast_member_announce(room_id).await {
1745 warn!(%e, "broadcast member announce on request");
1746 }
1747 }
1748 RoomMessage::Encrypted {
1749 sender_fingerprint,
1750 session_id,
1751 ciphertext_b64,
1752 } => {
1753 if sender_fingerprint == our_fp {
1754 return;
1755 }
1756 let ct_bytes = match base64::Engine::decode(
1757 &base64::engine::general_purpose::STANDARD,
1758 &ciphertext_b64,
1759 ) {
1760 Ok(b) => b,
1761 Err(e) => {
1762 warn!(%e, "bad base64 ciphertext");
1763 return;
1764 }
1765 };
1766 let plaintext = {
1767 let mut rooms = self.active_rooms.lock().unwrap();
1768 let room = match rooms.get_mut(room_id) {
1769 Some(r) => r,
1770 None => return,
1771 };
1772 let crypto = match room.crypto.as_mut() {
1773 Some(c) => c,
1774 None => return,
1775 };
1776 crypto.decrypt(&sender_fingerprint, &session_id, &ct_bytes)
1777 };
1778 match plaintext {
1779 Ok(pt) => {
1780 let body = String::from_utf8_lossy(&pt).to_string();
1781 let sent_at = now_unix();
1782 let _ = repo::insert_room_message(
1783 &self.db,
1784 room_id,
1785 &sender_fingerprint,
1786 "in",
1787 &body,
1788 sent_at,
1789 );
1790 let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
1791 self.maybe_emit_mention(room_id, &body);
1792 let _ = self.app_event_tx.send(AppEvent::MessageReceived {
1793 room_id: room_id.to_string(),
1794 sender_fingerprint,
1795 body,
1796 sent_at,
1797 });
1798 }
1799 Err(e) => {
1800 debug!(%e, "decrypt failed (probably missing session key)");
1801 }
1802 }
1803 }
1804 RoomMessage::Plain {
1805 sender_fingerprint,
1806 body,
1807 } => {
1808 if sender_fingerprint == our_fp {
1809 return;
1810 }
1811 let sent_at = now_unix();
1812 let _ = repo::insert_room_message(
1813 &self.db,
1814 room_id,
1815 &sender_fingerprint,
1816 "in",
1817 &body,
1818 sent_at,
1819 );
1820 let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
1821 self.maybe_emit_mention(room_id, &body);
1822 let _ = self.app_event_tx.send(AppEvent::MessageReceived {
1823 room_id: room_id.to_string(),
1824 sender_fingerprint,
1825 body,
1826 sent_at,
1827 });
1828 }
1829 RoomMessage::Typing { sender_fingerprint } => {
1830 if sender_fingerprint == our_fp {
1831 return;
1832 }
1833 let expiry = now_unix() + TYPING_TTL_SECS;
1834 let mut rooms = self.active_rooms.lock().unwrap();
1835 if let Some(room) = rooms.get_mut(room_id) {
1836 room.typers.insert(sender_fingerprint, expiry);
1837 }
1838 drop(rooms);
1839 let _ = self.app_event_tx.send(AppEvent::TypingChanged {
1840 room_id: room_id.to_string(),
1841 });
1842 }
1843 RoomMessage::RotateRoomKey {
1844 rotator_fingerprint,
1845 new_salt,
1846 } => {
1847 if rotator_fingerprint == our_fp {
1848 return;
1849 }
1850 let signer = match verified_signer {
1855 Some(fp) => fp,
1856 None => {
1857 warn!(%room_id, "RotateRoomKey arrived unsigned; dropping");
1858 return;
1859 }
1860 };
1861 if signer != rotator_fingerprint {
1862 warn!(
1863 %signer, %rotator_fingerprint, %room_id,
1864 "RotateRoomKey signer mismatch with claimed rotator; dropping"
1865 );
1866 return;
1867 }
1868 let _ = self.app_event_tx.send(AppEvent::RotationRequested {
1869 room_id: room_id.to_string(),
1870 rotator_fingerprint,
1871 new_salt,
1872 });
1873 }
1874 RoomMessage::MemberLeave { sender_fingerprint } => {
1875 if sender_fingerprint == our_fp {
1876 return;
1877 }
1878 let removed = {
1879 let mut rooms = self.active_rooms.lock().unwrap();
1880 if let Some(room) = rooms.get_mut(room_id) {
1881 room.members.remove(&sender_fingerprint)
1882 } else {
1883 false
1884 }
1885 };
1886 if removed {
1887 let _ = self.app_event_tx.send(AppEvent::MemberLeft {
1888 room_id: room_id.to_string(),
1889 fingerprint: sender_fingerprint,
1890 });
1891 }
1892 }
1893 RoomMessage::FileOffer {
1894 sender_fingerprint,
1895 file_id,
1896 name,
1897 size_bytes,
1898 mime,
1899 chunk_count,
1900 encrypted_meta,
1901 } => {
1902 if sender_fingerprint == our_fp {
1903 return; }
1905 self.handle_file_offer(
1906 room_id,
1907 sender_fingerprint,
1908 file_id,
1909 name,
1910 size_bytes,
1911 mime,
1912 chunk_count,
1913 encrypted_meta,
1914 );
1915 }
1916 RoomMessage::FileChunk {
1917 sender_fingerprint,
1918 file_id,
1919 chunk_index,
1920 total_chunks,
1921 data_b64,
1922 } => {
1923 if sender_fingerprint == our_fp {
1924 return;
1925 }
1926 self.handle_file_chunk(
1927 room_id,
1928 sender_fingerprint,
1929 file_id,
1930 chunk_index,
1931 total_chunks,
1932 data_b64,
1933 );
1934 }
1935 RoomMessage::OwnerGrant {
1936 room_id: announced_room_id,
1937 target_fingerprint,
1938 } => {
1939 if announced_room_id != room_id {
1944 warn!(payload_room = %announced_room_id, topic_room = %room_id, "OwnerGrant room mismatch");
1945 return;
1946 }
1947 let signer = match verified_signer {
1948 Some(fp) => fp,
1949 None => {
1950 warn!(%room_id, "OwnerGrant arrived unsigned; dropping");
1951 return;
1952 }
1953 };
1954 if !self.is_owner(room_id, &signer) {
1955 warn!(%signer, %room_id, "OwnerGrant signer isn't an owner; dropping");
1956 return;
1957 }
1958 info!(%signer, %target_fingerprint, %room_id, "OwnerGrant applied");
1959 if let Err(e) =
1960 repo::set_member_role(&self.db, room_id, &target_fingerprint, "owner")
1961 {
1962 warn!(%e, "OwnerGrant: set_member_role failed");
1963 }
1964 }
1965 RoomMessage::BanMember {
1966 room_id: announced_room_id,
1967 target_fingerprint,
1968 } => {
1969 if announced_room_id != room_id {
1970 warn!(payload_room = %announced_room_id, topic_room = %room_id, "BanMember room mismatch");
1971 return;
1972 }
1973 let signer = match verified_signer {
1974 Some(fp) => fp,
1975 None => {
1976 warn!(%room_id, "BanMember arrived unsigned; dropping");
1977 return;
1978 }
1979 };
1980 if !self.is_owner(room_id, &signer) {
1981 warn!(%signer, %room_id, "BanMember signer isn't an owner; dropping");
1982 return;
1983 }
1984 if target_fingerprint == our_fp {
1985 info!(%room_id, %signer, "we were kicked from this room");
1991 self.active_rooms.lock().unwrap().remove(room_id);
1992 let _ = self.app_event_tx.send(AppEvent::RoomLeft {
1993 room_id: room_id.to_string(),
1994 });
1995 return;
1996 }
1997 info!(%signer, %target_fingerprint, %room_id, "BanMember applied");
1998 if let Err(e) = repo::add_room_ban(
1999 &self.db,
2000 room_id,
2001 &target_fingerprint,
2002 &signer,
2003 "", now_unix(),
2005 ) {
2006 warn!(%e, "BanMember: add_room_ban failed");
2007 }
2008 self.evict_banned_member(room_id, &target_fingerprint);
2009 }
2010 RoomMessage::SasInit {
2011 tx_id,
2012 ephemeral_x25519_pubkey_b64,
2013 target_fingerprint,
2014 } => {
2015 if target_fingerprint != our_fp {
2016 return;
2021 }
2022 let signer = match verified_signer {
2023 Some(fp) => fp,
2024 None => {
2025 warn!("SasInit arrived unsigned; dropping");
2026 return;
2027 }
2028 };
2029 let their_pub =
2030 match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
2031 Ok(pk) => pk,
2032 Err(e) => {
2033 warn!(%e, "SasInit: bad x25519 pubkey");
2034 return;
2035 }
2036 };
2037 let tx_id_bytes = match B64.decode(&tx_id) {
2038 Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
2039 let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
2040 arr.copy_from_slice(&b);
2041 arr
2042 }
2043 _ => {
2044 warn!(%tx_id, "SasInit: bad tx_id length");
2045 return;
2046 }
2047 };
2048 let (_, our_secret, our_pub) = crate::crypto::sas::new_session();
2049 let sas_code =
2050 crate::crypto::sas::derive_sas_code(&our_secret, &their_pub, &tx_id_bytes);
2051 self.sas_flows.lock().unwrap().insert(
2052 tx_id.clone(),
2053 SasFlow {
2054 room_id: room_id.to_string(),
2055 partner_fingerprint: signer.clone(),
2056 our_secret,
2057 sas_code: Some(sas_code.clone()),
2058 our_confirmed: false,
2059 their_confirmed: false,
2060 },
2061 );
2062 let response = RoomMessage::SasResponse {
2065 tx_id: tx_id.clone(),
2066 ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2067 };
2068 if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
2069 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
2070 self.network
2071 .publish_room_message(room_id.to_string(), bytes)
2072 .await;
2073 }
2074 }
2075 let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
2076 room_id: room_id.to_string(),
2077 partner_fingerprint: signer,
2078 tx_id,
2079 emoji_string: sas_code.emoji_string(),
2080 emoji_labels: sas_code.emoji_labels(),
2081 decimal: sas_code.decimal,
2082 });
2083 }
2084 RoomMessage::SasResponse {
2085 tx_id,
2086 ephemeral_x25519_pubkey_b64,
2087 } => {
2088 let signer = match verified_signer {
2089 Some(fp) => fp,
2090 None => {
2091 warn!("SasResponse arrived unsigned; dropping");
2092 return;
2093 }
2094 };
2095 let their_pub =
2096 match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
2097 Ok(pk) => pk,
2098 Err(e) => {
2099 warn!(%e, "SasResponse: bad x25519 pubkey");
2100 return;
2101 }
2102 };
2103 let tx_id_bytes = match B64.decode(&tx_id) {
2104 Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
2105 let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
2106 arr.copy_from_slice(&b);
2107 arr
2108 }
2109 _ => return,
2110 };
2111 let emit = {
2112 let mut flows = self.sas_flows.lock().unwrap();
2113 let flow = match flows.get_mut(&tx_id) {
2114 Some(f) => f,
2115 None => {
2116 warn!(%tx_id, "SasResponse for unknown tx_id");
2117 return;
2118 }
2119 };
2120 if flow.partner_fingerprint != signer {
2121 warn!(
2122 expected = %flow.partner_fingerprint, got = %signer,
2123 "SasResponse signer doesn't match flow's partner; dropping"
2124 );
2125 return;
2126 }
2127 let code = crate::crypto::sas::derive_sas_code(
2128 &flow.our_secret,
2129 &their_pub,
2130 &tx_id_bytes,
2131 );
2132 flow.sas_code = Some(code.clone());
2133 code
2134 };
2135 let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
2136 room_id: room_id.to_string(),
2137 partner_fingerprint: signer,
2138 tx_id,
2139 emoji_string: emit.emoji_string(),
2140 emoji_labels: emit.emoji_labels(),
2141 decimal: emit.decimal,
2142 });
2143 }
2144 RoomMessage::CodeJoinRequest {
2145 room_id: announced_room_id,
2146 joiner_x25519_pubkey_b64,
2147 code,
2148 } => {
2149 if announced_room_id != room_id {
2150 return;
2151 }
2152 let joiner_fp = match verified_signer {
2153 Some(fp) => fp,
2154 None => {
2155 warn!("CodeJoinRequest unsigned; dropping");
2156 return;
2157 }
2158 };
2159 let our_fp = self.identity.fingerprint().to_string();
2163 if !self.is_owner(room_id, &our_fp) {
2164 return;
2165 }
2166 let now = now_unix();
2168 let (code_ok, our_session_id, wrap_input) = {
2169 let mut rooms = self.active_rooms.lock().unwrap();
2170 let room = match rooms.get_mut(room_id) {
2171 Some(r) => r,
2172 None => return,
2173 };
2174 if room.passphrase_key.is_none() {
2175 warn!("CodeJoinRequest: no passphrase key locally; can't respond");
2176 return;
2177 }
2178 let original_len = room.issued_codes.len();
2179 room.issued_codes.retain(|(c, exp)| !(c == &code && *exp > now));
2180 let matched = room.issued_codes.len() < original_len;
2181 if !matched {
2182 info!(%joiner_fp, "CodeJoinRequest: code invalid or expired; ignoring");
2183 return;
2184 }
2185 let crypto = room.crypto.as_ref().unwrap();
2186 (
2187 true,
2188 crypto.our_session_id(),
2189 crypto.our_session_key_b64(),
2190 )
2191 };
2192 let _ = code_ok;
2193 let their_pub = match crate::crypto::sas::parse_pubkey(&joiner_x25519_pubkey_b64) {
2195 Ok(pk) => pk,
2196 Err(e) => {
2197 warn!(%e, "CodeJoinRequest: bad pubkey");
2198 return;
2199 }
2200 };
2201 use x25519_dalek::{PublicKey, StaticSecret};
2202 let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
2203 let our_pub = PublicKey::from(&our_secret);
2204 let shared = our_secret.diffie_hellman(&their_pub);
2205 let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
2207 let mut wrap_key = [0u8; passphrase::KEY_LEN];
2208 hk.expand(b"huddle-code-join-v1", &mut wrap_key)
2209 .expect("32 bytes is within HKDF limits");
2210 let wrapped = match passphrase::wrap(wrap_input.as_bytes(), &wrap_key) {
2213 Ok(w) => w,
2214 Err(e) => {
2215 warn!(%e, "CodeJoinRequest: wrap failed");
2216 return;
2217 }
2218 };
2219 let response = RoomMessage::CodeJoinResponse {
2220 room_id: room_id.to_string(),
2221 target_fingerprint: joiner_fp.clone(),
2222 owner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2223 owner_session_id: our_session_id,
2224 wrapped_session_key_b64: wrapped,
2225 nonce_b64: String::new(), };
2227 if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
2228 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
2229 self.network
2230 .publish_room_message(room_id.to_string(), bytes)
2231 .await;
2232 }
2233 }
2234 info!(%joiner_fp, %room_id, "issued CodeJoinResponse");
2235 }
2236 RoomMessage::CodeJoinResponse {
2237 room_id: announced_room_id,
2238 target_fingerprint,
2239 owner_x25519_pubkey_b64,
2240 owner_session_id,
2241 wrapped_session_key_b64,
2242 nonce_b64: _,
2243 } => {
2244 if announced_room_id != room_id || target_fingerprint != our_fp {
2245 return;
2246 }
2247 let owner_fp = match verified_signer {
2248 Some(fp) => fp,
2249 None => {
2250 warn!("CodeJoinResponse unsigned; dropping");
2251 return;
2252 }
2253 };
2254 let our_secret = match self
2255 .pending_code_secrets
2256 .lock()
2257 .unwrap()
2258 .remove(&(room_id.to_string(), our_fp.clone()))
2259 {
2260 Some(s) => s,
2261 None => {
2262 warn!(%room_id, "CodeJoinResponse with no pending code-join state");
2263 return;
2264 }
2265 };
2266 let owner_pub = match crate::crypto::sas::parse_pubkey(&owner_x25519_pubkey_b64) {
2267 Ok(pk) => pk,
2268 Err(e) => {
2269 warn!(%e, "CodeJoinResponse: bad owner pubkey");
2270 return;
2271 }
2272 };
2273 let shared = our_secret.diffie_hellman(&owner_pub);
2274 let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
2275 let mut wrap_key = [0u8; passphrase::KEY_LEN];
2276 hk.expand(b"huddle-code-join-v1", &mut wrap_key)
2277 .expect("32 bytes within HKDF limits");
2278 let session_key_bytes =
2279 match passphrase::unwrap(&wrapped_session_key_b64, &wrap_key) {
2280 Ok(b) => b,
2281 Err(e) => {
2282 warn!(%e, "CodeJoinResponse: unwrap failed");
2283 return;
2284 }
2285 };
2286 let session_key_str = match String::from_utf8(session_key_bytes) {
2287 Ok(s) => s,
2288 Err(e) => {
2289 warn!(%e, "CodeJoinResponse: session key wasn't valid utf8");
2290 return;
2291 }
2292 };
2293 let mut rooms = self.active_rooms.lock().unwrap();
2295 if let Some(room) = rooms.get_mut(room_id) {
2296 if let Some(crypto) = room.crypto.as_mut() {
2297 if let Err(e) =
2298 crypto.add_inbound_session(&owner_fp, &session_key_str)
2299 {
2300 warn!(%e, "CodeJoinResponse: add_inbound_session failed");
2301 } else {
2302 info!(%room_id, %owner_fp, %owner_session_id, "code-join completed; can decrypt owner's messages");
2303 room.members.insert(owner_fp.clone());
2304 let _ = self.app_event_tx.send(AppEvent::MemberJoined {
2305 room_id: room_id.to_string(),
2306 fingerprint: owner_fp,
2307 });
2308 }
2309 }
2310 }
2311 }
2312 RoomMessage::JoinRefused {
2313 room_id: announced_room_id,
2314 target_fingerprint,
2315 reason,
2316 } => {
2317 if announced_room_id != room_id || target_fingerprint != our_fp {
2318 return;
2319 }
2320 let _ = self.app_event_tx.send(AppEvent::Error {
2324 description: format!("join refused: {reason}"),
2325 });
2326 }
2327 RoomMessage::SasConfirm { tx_id, matched } => {
2328 let signer = match verified_signer {
2329 Some(fp) => fp,
2330 None => return,
2331 };
2332 let (room_id_done, partner_fp_done, both_done) = {
2333 let mut flows = self.sas_flows.lock().unwrap();
2334 let flow = match flows.get_mut(&tx_id) {
2335 Some(f) => f,
2336 None => return,
2337 };
2338 if flow.partner_fingerprint != signer {
2339 return;
2340 }
2341 if !matched {
2342 let _ = flow;
2344 flows.remove(&tx_id);
2345 return;
2346 }
2347 flow.their_confirmed = true;
2348 if flow.our_confirmed && flow.their_confirmed {
2349 (
2350 Some(flow.room_id.clone()),
2351 Some(flow.partner_fingerprint.clone()),
2352 true,
2353 )
2354 } else {
2355 (None, None, false)
2356 }
2357 };
2358 if both_done {
2359 if let (Some(rid), Some(pfp)) = (room_id_done, partner_fp_done) {
2360 if let Err(e) = self.finish_sas(&tx_id, &rid, &pfp).await {
2361 warn!(%e, "finish_sas failed");
2362 }
2363 }
2364 }
2365 }
2366 RoomMessage::ProfileUpdate {
2367 sender_fingerprint,
2368 username,
2369 updated_at,
2370 } => {
2371 let signer = match verified_signer {
2377 Some(fp) => fp,
2378 None => {
2379 warn!(
2380 sender = %sender_fingerprint,
2381 "dropping unsigned ProfileUpdate"
2382 );
2383 return;
2384 }
2385 };
2386 if signer != sender_fingerprint {
2387 warn!(
2388 signer = %signer,
2389 claimed = %sender_fingerprint,
2390 "dropping ProfileUpdate with signer != sender"
2391 );
2392 return;
2393 }
2394 if let Err(e) = repo::upsert_peer_profile(
2395 &self.db,
2396 &sender_fingerprint,
2397 username.as_deref(),
2398 updated_at,
2399 ) {
2400 warn!(%e, "upsert_peer_profile failed");
2401 return;
2402 }
2403 let _ = self.app_event_tx.send(AppEvent::PeerProfileUpdated {
2404 fingerprint: sender_fingerprint,
2405 username,
2406 });
2407 }
2408 }
2409 }
2410
2411 pub async fn send_file(&self, room_id: &str, path: &Path) -> Result<String> {
2419 let bytes = std::fs::read(path)?;
2420 let name = path
2421 .file_name()
2422 .map(|n| n.to_string_lossy().to_string())
2423 .unwrap_or_else(|| "untitled".into());
2424 let mime = crate::files::guess_mime(&name);
2425 let original_path = path.to_path_buf();
2426
2427 let (room_encrypted, mut maybe_session_id, encrypted_meta_opt, wire_bytes) = {
2428 let mut rooms = self.active_rooms.lock().unwrap();
2429 let room = rooms
2430 .get_mut(room_id)
2431 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
2432 if room.info.encrypted {
2433 let crypto = room
2434 .crypto
2435 .as_mut()
2436 .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
2437 let (ciphertext, meta) = file_encryption::encrypt_file(&bytes, crypto)?;
2438 (true, Some(meta.megolm_session_id.clone()), Some(meta), ciphertext)
2439 } else {
2440 (false, None, None, bytes)
2441 }
2442 };
2443 let _ = &mut maybe_session_id; let plan =
2446 self.file_manager
2447 .prepare_outgoing_from_bytes(&name, mime.clone(), wire_bytes)?;
2448 let file_id = plan.file_id.clone();
2449 let total = plan.chunks.len() as u32;
2450 let our_fp = self.identity.fingerprint().to_string();
2451
2452 let attachment = StoredAttachment {
2453 id: 0,
2454 room_id: room_id.to_string(),
2455 message_id: None,
2456 sender_fingerprint: our_fp.clone(),
2457 file_id: file_id.clone(),
2458 name: name.clone(),
2459 mime: mime.clone(),
2460 size_bytes: plan.size_bytes as i64,
2461 status: AttachmentStatus::Ready,
2462 cache_path: Some(self.file_manager.cache_path(&file_id).to_string_lossy().into()),
2463 saved_path: Some(original_path.to_string_lossy().into()),
2464 error: None,
2465 encrypted: room_encrypted,
2466 wrapped_key: encrypted_meta_opt.as_ref().map(|m| m.wrapped_key_b64.clone()),
2467 nonce: encrypted_meta_opt.as_ref().map(|m| m.nonce_b64.clone()),
2468 megolm_session_id: encrypted_meta_opt
2469 .as_ref()
2470 .map(|m| m.megolm_session_id.clone()),
2471 content_hash: encrypted_meta_opt.as_ref().map(|m| m.content_hash.clone()),
2472 created_at: now_unix(),
2473 };
2474 repo::upsert_attachment(&self.db, &attachment)?;
2475 let _ = self.app_event_tx.send(AppEvent::FileOffered {
2476 room_id: room_id.to_string(),
2477 file_id: file_id.clone(),
2478 name: name.clone(),
2479 size_bytes: plan.size_bytes,
2480 sender_fingerprint: our_fp.clone(),
2481 });
2482
2483 let offer = RoomMessage::FileOffer {
2485 sender_fingerprint: our_fp.clone(),
2486 file_id: file_id.clone(),
2487 name,
2488 size_bytes: plan.size_bytes,
2489 mime,
2490 chunk_count: total,
2491 encrypted_meta: encrypted_meta_opt,
2492 };
2493 if let Ok(bytes) = encode_wire(&offer) {
2494 self.network
2495 .publish_room_message(room_id.to_string(), bytes)
2496 .await;
2497 }
2498
2499 let net = self.network.clone();
2502 let room = room_id.to_string();
2503 let our = our_fp.clone();
2504 let fid = file_id.clone();
2505 let chunks = plan.chunks.clone();
2506 tokio::spawn(async move {
2507 for (i, data) in chunks.iter().enumerate() {
2508 let msg = RoomMessage::FileChunk {
2509 sender_fingerprint: our.clone(),
2510 file_id: fid.clone(),
2511 chunk_index: i as u32,
2512 total_chunks: total,
2513 data_b64: B64.encode(data),
2514 };
2515 if let Ok(bytes) = encode_wire(&msg) {
2516 net.publish_room_message(room.clone(), bytes).await;
2517 }
2518 tokio::time::sleep(Duration::from_millis(40)).await;
2519 }
2520 });
2521
2522 Ok(file_id)
2523 }
2524
2525 pub async fn save_to_downloads(&self, room_id: &str, file_id: &str) -> Result<PathBuf> {
2528 let attachment = repo::get_attachment(&self.db, room_id, file_id)?
2529 .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
2530 if !matches!(
2531 attachment.status,
2532 AttachmentStatus::Ready | AttachmentStatus::Saved
2533 ) {
2534 return Err(HuddleError::Other(format!(
2535 "attachment is not ready (status={})",
2536 attachment.status.as_str()
2537 )));
2538 }
2539 let plaintext = if attachment.encrypted
2544 && attachment.sender_fingerprint == self.identity.fingerprint()
2545 {
2546 match attachment
2547 .saved_path
2548 .as_deref()
2549 .filter(|p| Path::new(p).exists())
2550 {
2551 Some(src) => std::fs::read(src)?,
2552 None => {
2553 return Err(HuddleError::Other(
2554 "your original file has moved or been deleted — it can't be \
2555 recovered from the encrypted cache"
2556 .into(),
2557 ));
2558 }
2559 }
2560 } else {
2561 let cached = self.file_manager.read_cache(file_id)?;
2562 if attachment.encrypted {
2563 let meta = EncryptedFileMeta {
2564 megolm_session_id: attachment
2565 .megolm_session_id
2566 .clone()
2567 .ok_or_else(|| HuddleError::Other("missing megolm_session_id".into()))?,
2568 wrapped_key_b64: attachment
2569 .wrapped_key
2570 .clone()
2571 .ok_or_else(|| HuddleError::Other("missing wrapped_key".into()))?,
2572 nonce_b64: attachment
2573 .nonce
2574 .clone()
2575 .ok_or_else(|| HuddleError::Other("missing nonce".into()))?,
2576 content_hash: attachment
2577 .content_hash
2578 .clone()
2579 .ok_or_else(|| HuddleError::Other("missing content_hash".into()))?,
2580 };
2581 self.decrypt_attachment(
2582 room_id,
2583 &attachment.sender_fingerprint,
2584 &cached,
2585 &meta,
2586 )?
2587 } else {
2588 cached
2589 }
2590 };
2591 let saved = self.file_manager.write_to_downloads(&attachment.name, &plaintext)?;
2592 repo::update_attachment_paths(
2593 &self.db,
2594 room_id,
2595 file_id,
2596 None,
2597 Some(&saved.to_string_lossy()),
2598 )?;
2599 repo::update_attachment_status(&self.db, room_id, file_id, AttachmentStatus::Saved, None)?;
2600 let _ = self.app_event_tx.send(AppEvent::FileSaved {
2601 file_id: file_id.into(),
2602 path: saved.to_string_lossy().into(),
2603 });
2604 Ok(saved)
2605 }
2606
2607 pub async fn cancel_transfer(&self, room_id: &str, file_id: &str) -> Result<()> {
2609 self.file_manager.cancel_incoming(file_id);
2610 repo::update_attachment_status(
2611 &self.db,
2612 room_id,
2613 file_id,
2614 AttachmentStatus::Cancelled,
2615 None,
2616 )?;
2617 Ok(())
2618 }
2619
2620 pub fn open_saved(&self, room_id: &str, file_id: &str) -> Result<()> {
2622 let attachment = repo::get_attachment(&self.db, room_id, file_id)?
2623 .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
2624 let path = attachment
2625 .saved_path
2626 .ok_or_else(|| HuddleError::Other("not saved yet — press Enter to save first".into()))?;
2627 open_with_system(&path)
2628 }
2629
2630 pub fn list_room_attachments(&self, room_id: &str) -> Result<Vec<StoredAttachment>> {
2631 repo::list_room_attachments(&self.db, room_id)
2632 }
2633
2634 pub fn set_member_verified(
2638 &self,
2639 room_id: &str,
2640 fingerprint: &str,
2641 verified: bool,
2642 ) -> Result<()> {
2643 let members = repo::list_room_members(&self.db, room_id).unwrap_or_default();
2648 if !members.iter().any(|m| m.fingerprint == fingerprint) {
2649 repo::upsert_room_member(
2650 &self.db,
2651 &StoredRoomMember {
2652 room_id: room_id.to_string(),
2653 peer_id: String::new(),
2654 fingerprint: fingerprint.to_string(),
2655 last_seen: Some(now_unix()),
2656 verified,
2657 ed25519_pubkey: None,
2658 role: "member".into(),
2659 },
2660 )?;
2661 }
2662 repo::set_member_verified(&self.db, room_id, fingerprint, verified)
2663 }
2664
2665 pub fn verified_fingerprints(&self, room_id: &str) -> Vec<String> {
2666 repo::list_verified_fingerprints(&self.db, room_id).unwrap_or_default()
2667 }
2668
2669 pub fn is_owner(&self, room_id: &str, fingerprint: &str) -> bool {
2672 repo::list_room_owners(&self.db, room_id)
2673 .unwrap_or_default()
2674 .iter()
2675 .any(|fp| fp == fingerprint)
2676 }
2677
2678 pub fn we_are_owner(&self, room_id: &str) -> bool {
2679 self.is_owner(room_id, &self.identity.fingerprint().to_string())
2680 }
2681
2682 pub fn room_owners(&self, room_id: &str) -> Vec<String> {
2685 repo::list_room_owners(&self.db, room_id).unwrap_or_default()
2686 }
2687
2688 pub fn verified_only_inbound(&self) -> bool {
2691 repo::get_setting(&self.db, "verified_only_inbound")
2692 .unwrap_or(None)
2693 .map(|v| v == "1")
2694 .unwrap_or(false)
2695 }
2696
2697 pub fn set_verified_only_inbound(&self, on: bool) -> Result<()> {
2698 repo::set_setting(&self.db, "verified_only_inbound", if on { "1" } else { "0" })
2699 }
2700
2701 pub fn room_verified_only(&self, room_id: &str) -> bool {
2706 repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
2707 }
2708
2709 pub fn set_room_verified_only(&self, room_id: &str, on: bool) -> Result<()> {
2710 repo::set_room_verified_only(&self.db, room_id, on)
2711 }
2712
2713 pub fn onboarding_seen(&self) -> bool {
2715 repo::is_onboarding_seen(&self.db).unwrap_or(true)
2716 }
2717
2718 pub fn mark_onboarding_seen(&self) -> Result<()> {
2719 repo::mark_onboarding_seen(&self.db)
2720 }
2721
2722 pub async fn grant_owner(&self, room_id: &str, target_fingerprint: &str) -> Result<()> {
2726 let our_fp = self.identity.fingerprint().to_string();
2727 if !self.is_owner(room_id, &our_fp) {
2728 return Err(HuddleError::Other(
2729 "only an owner can grant owner".into(),
2730 ));
2731 }
2732 let msg = RoomMessage::OwnerGrant {
2733 room_id: room_id.to_string(),
2734 target_fingerprint: target_fingerprint.to_string(),
2735 };
2736 let env = crate::crypto::sign_message(&self.identity, &msg)?;
2737 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
2738 self.network
2739 .publish_room_message(room_id.to_string(), bytes)
2740 .await;
2741 repo::set_member_role(&self.db, room_id, target_fingerprint, "owner")?;
2743 Ok(())
2744 }
2745
2746 pub async fn kick_member(
2757 &self,
2758 room_id: &str,
2759 target_fingerprint: &str,
2760 ) -> Result<String> {
2761 let our_fp = self.identity.fingerprint().to_string();
2762 if !self.is_owner(room_id, &our_fp) {
2763 return Err(HuddleError::Other("only an owner can kick".into()));
2764 }
2765 if target_fingerprint == our_fp {
2766 return Err(HuddleError::Other("can't kick yourself".into()));
2767 }
2768 let info = self
2769 .active_rooms
2770 .lock()
2771 .unwrap()
2772 .get(room_id)
2773 .map(|r| r.info.clone())
2774 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
2775 if !info.encrypted {
2776 let msg = RoomMessage::BanMember {
2780 room_id: room_id.to_string(),
2781 target_fingerprint: target_fingerprint.to_string(),
2782 };
2783 let env = crate::crypto::sign_message(&self.identity, &msg)?;
2784 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
2785 self.network
2786 .publish_room_message(room_id.to_string(), bytes)
2787 .await;
2788 repo::add_room_ban(
2789 &self.db,
2790 room_id,
2791 target_fingerprint,
2792 &our_fp,
2793 &env.signature_b64,
2794 now_unix(),
2795 )?;
2796 self.evict_banned_member(room_id, target_fingerprint);
2797 return Ok(String::new());
2798 }
2799 let new_passphrase = generate_join_passphrase();
2801 let msg = RoomMessage::BanMember {
2802 room_id: room_id.to_string(),
2803 target_fingerprint: target_fingerprint.to_string(),
2804 };
2805 let env = crate::crypto::sign_message(&self.identity, &msg)?;
2806 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
2807 self.network
2808 .publish_room_message(room_id.to_string(), bytes)
2809 .await;
2810 repo::add_room_ban(
2811 &self.db,
2812 room_id,
2813 target_fingerprint,
2814 &our_fp,
2815 &env.signature_b64,
2816 now_unix(),
2817 )?;
2818 self.evict_banned_member(room_id, target_fingerprint);
2819 self.rotate_room(room_id, &new_passphrase).await?;
2822 Ok(new_passphrase)
2823 }
2824
2825 pub fn generate_join_code(&self, room_id: &str) -> Result<String> {
2832 let our_fp = self.identity.fingerprint().to_string();
2833 if !self.is_owner(room_id, &our_fp) {
2834 return Err(HuddleError::Other(
2835 "only an owner can issue join codes".into(),
2836 ));
2837 }
2838 let code = generate_alphanumeric_code(8);
2839 let expires_at = now_unix() + 10 * 60;
2840 let mut rooms = self.active_rooms.lock().unwrap();
2841 let room = rooms
2842 .get_mut(room_id)
2843 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
2844 let now = now_unix();
2846 room.issued_codes.retain(|(_, exp)| *exp > now);
2847 room.issued_codes.push((code.clone(), expires_at));
2848 Ok(code)
2849 }
2850
2851 pub async fn join_room_with_code(
2858 &self,
2859 room_id: &str,
2860 code: &str,
2861 ) -> Result<()> {
2862 let info = {
2864 let d = self.discovered_rooms.lock().unwrap().get(room_id).cloned();
2865 match d {
2866 Some(d) => StoredRoom {
2867 id: room_id.to_string(),
2868 name: d.name,
2869 creator_fingerprint: d.creator_fingerprint,
2870 encrypted: d.encrypted,
2871 passphrase_salt: None, created_at: now_unix(),
2873 last_active: Some(now_unix()),
2874 },
2875 None => {
2876 return Err(HuddleError::Other(format!(
2877 "room {room_id} not visible — wait for an announcement"
2878 )))
2879 }
2880 }
2881 };
2882 if !info.encrypted {
2883 return Err(HuddleError::Other(
2884 "code-join only applies to encrypted rooms".into(),
2885 ));
2886 }
2887 let our_fp = self.identity.fingerprint().to_string();
2888 use x25519_dalek::{PublicKey, StaticSecret};
2891 let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
2892 let our_pub = PublicKey::from(&our_secret);
2893 let key = (room_id.to_string(), our_fp.clone());
2898 self.pending_code_secrets
2899 .lock()
2900 .unwrap()
2901 .insert(key.clone(), our_secret);
2902 let map = self.pending_code_secrets.clone();
2907 let tx = self.app_event_tx.clone();
2908 let timeout_room = room_id.to_string();
2909 tokio::spawn(async move {
2910 tokio::time::sleep(std::time::Duration::from_secs(30)).await;
2911 let still_pending = map.lock().unwrap().remove(&key).is_some();
2912 if still_pending {
2913 let _ = tx.send(AppEvent::CodeJoinTimedOut {
2914 room_id: timeout_room,
2915 reason: "no response from owner — code may be wrong or expired".into(),
2916 });
2917 }
2918 });
2919 repo::insert_room(&self.db, &info)?;
2926 self.active_rooms.lock().unwrap().insert(
2929 room_id.to_string(),
2930 ActiveRoom {
2931 info: info.clone(),
2932 crypto: Some(RoomCrypto::new_for_room(
2933 self.db.clone(),
2934 room_id.to_string(),
2935 our_fp.clone(),
2936 self.session_persist_key,
2937 )?),
2938 passphrase_key: None,
2939 members: {
2940 let mut s = HashSet::new();
2941 s.insert(our_fp.clone());
2942 s
2943 },
2944 typers: HashMap::new(),
2945 read_only: true,
2946 issued_codes: Vec::new(),
2947 },
2948 );
2949 self.network.subscribe_room(room_id.to_string()).await;
2950 let req = RoomMessage::CodeJoinRequest {
2952 room_id: room_id.to_string(),
2953 joiner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2954 code: code.to_string(),
2955 };
2956 let env = crate::crypto::sign_message(&self.identity, &req)?;
2957 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
2958 self.network
2959 .publish_room_message(room_id.to_string(), bytes)
2960 .await;
2961 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
2964 room_id: room_id.to_string(),
2965 });
2966 Ok(())
2967 }
2968
2969 pub async fn sas_start(&self, room_id: &str, target_fingerprint: &str) -> Result<String> {
2975 let (tx_id_bytes, our_secret, our_pub) = crate::crypto::sas::new_session();
2976 let tx_id = B64.encode(tx_id_bytes);
2977 let msg = RoomMessage::SasInit {
2978 tx_id: tx_id.clone(),
2979 ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2980 target_fingerprint: target_fingerprint.to_string(),
2981 };
2982 let env = crate::crypto::sign_message(&self.identity, &msg)?;
2983 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
2984 self.sas_flows.lock().unwrap().insert(
2985 tx_id.clone(),
2986 SasFlow {
2987 room_id: room_id.to_string(),
2988 partner_fingerprint: target_fingerprint.to_string(),
2989 our_secret,
2990 sas_code: None,
2991 our_confirmed: false,
2992 their_confirmed: false,
2993 },
2994 );
2995 self.network
2996 .publish_room_message(room_id.to_string(), bytes)
2997 .await;
2998 Ok(tx_id)
2999 }
3000
3001 pub async fn sas_match(&self, tx_id: &str) -> Result<()> {
3005 let (room_id, partner_fp, both_done) = {
3006 let mut flows = self.sas_flows.lock().unwrap();
3007 let flow = flows
3008 .get_mut(tx_id)
3009 .ok_or_else(|| HuddleError::Other("unknown SAS tx_id".into()))?;
3010 flow.our_confirmed = true;
3011 (
3012 flow.room_id.clone(),
3013 flow.partner_fingerprint.clone(),
3014 flow.our_confirmed && flow.their_confirmed,
3015 )
3016 };
3017 let msg = RoomMessage::SasConfirm {
3018 tx_id: tx_id.to_string(),
3019 matched: true,
3020 };
3021 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3022 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3023 self.network
3024 .publish_room_message(room_id.clone(), bytes)
3025 .await;
3026 if both_done {
3027 self.finish_sas(tx_id, &room_id, &partner_fp).await?;
3028 }
3029 Ok(())
3030 }
3031
3032 pub fn sas_cancel(&self, tx_id: &str) {
3036 self.sas_flows.lock().unwrap().remove(tx_id);
3037 }
3038
3039 async fn finish_sas(
3042 &self,
3043 tx_id: &str,
3044 room_id: &str,
3045 partner_fingerprint: &str,
3046 ) -> Result<()> {
3047 repo::set_member_verified(&self.db, room_id, partner_fingerprint, true)?;
3048 repo::add_verified_peer(&self.db, partner_fingerprint, now_unix())?;
3049 self.sas_flows.lock().unwrap().remove(tx_id);
3050 let _ = self.app_event_tx.send(AppEvent::SasVerified {
3051 room_id: room_id.to_string(),
3052 partner_fingerprint: partner_fingerprint.to_string(),
3053 });
3054 Ok(())
3055 }
3056
3057 fn evict_banned_member(&self, room_id: &str, fingerprint: &str) {
3062 if let Some(room) = self.active_rooms.lock().unwrap().get_mut(room_id) {
3063 room.members.remove(fingerprint);
3064 }
3065 let _ = self.app_event_tx.send(AppEvent::MemberLeft {
3066 room_id: room_id.to_string(),
3067 fingerprint: fingerprint.to_string(),
3068 });
3069 }
3070
3071 pub fn display_name(&self) -> Option<String> {
3072 repo::get_display_name(&self.db).unwrap_or(None)
3073 }
3074
3075 pub fn set_display_name(&self, name: Option<&str>) -> Result<()> {
3076 repo::set_display_name(&self.db, name)
3077 }
3078
3079 pub async fn set_username(&self, name: Option<&str>) -> Result<()> {
3085 repo::set_display_name(&self.db, name)?;
3086 let msg = RoomMessage::ProfileUpdate {
3087 sender_fingerprint: self.identity.fingerprint().to_string(),
3088 username: name.map(|s| s.to_string()),
3089 updated_at: now_unix_ms(),
3090 };
3091 let env = crate::crypto::sign_message(&self.identity, &msg)?;
3092 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3093 let rooms: Vec<String> = self.active_rooms.lock().unwrap().keys().cloned().collect();
3094 for room_id in rooms {
3095 self.network
3096 .publish_room_message(room_id, bytes.clone())
3097 .await;
3098 }
3099 Ok(())
3100 }
3101
3102 pub fn lookup_username(&self, fingerprint: &str) -> Option<String> {
3107 repo::get_peer_username(&self.db, fingerprint).unwrap_or(None)
3108 }
3109
3110 pub fn lookup_member_display_name(&self, fingerprint: &str) -> Option<String> {
3114 self.lookup_username(fingerprint)
3115 }
3116
3117 pub fn is_room_muted(&self, room_id: &str) -> bool {
3118 repo::is_room_muted(&self.db, room_id).unwrap_or(false)
3119 }
3120
3121 pub fn list_room_bans(&self, room_id: &str) -> Vec<String> {
3126 repo::list_room_bans(&self.db, room_id).unwrap_or_default()
3127 }
3128
3129 pub fn list_blocked_peers(&self) -> Vec<String> {
3133 repo::list_blocked_peers(&self.db).unwrap_or_default()
3134 }
3135
3136 pub fn unblock_peer(&self, fingerprint: &str) -> Result<()> {
3140 repo::unblock_peer(&self.db, fingerprint)
3141 }
3142
3143 pub fn is_room_read_only(&self, room_id: &str) -> bool {
3149 self.active_rooms
3150 .lock()
3151 .unwrap()
3152 .get(room_id)
3153 .map(|r| r.read_only)
3154 .unwrap_or(false)
3155 }
3156
3157 pub fn set_room_muted(&self, room_id: &str, muted: bool) -> Result<()> {
3158 repo::set_room_muted(&self.db, room_id, muted)
3159 }
3160
3161 pub async fn broadcast_typing(&self, room_id: &str) {
3164 if !self.active_rooms.lock().unwrap().contains_key(room_id) {
3165 return;
3166 }
3167 let msg = RoomMessage::Typing {
3168 sender_fingerprint: self.identity.fingerprint().to_string(),
3169 };
3170 if let Ok(bytes) = encode_wire(&msg) {
3171 self.network
3172 .publish_room_message(room_id.to_string(), bytes)
3173 .await;
3174 }
3175 }
3176
3177 pub fn typers_in_room(&self, room_id: &str) -> Vec<String> {
3180 let now = now_unix();
3181 let mut rooms = self.active_rooms.lock().unwrap();
3182 let room = match rooms.get_mut(room_id) {
3183 Some(r) => r,
3184 None => return Vec::new(),
3185 };
3186 room.typers.retain(|_, exp| *exp > now);
3187 let mut v: Vec<String> = room.typers.keys().cloned().collect();
3188 v.sort();
3189 v
3190 }
3191
3192 pub async fn rotate_room(&self, room_id: &str, new_passphrase: &str) -> Result<()> {
3202 if new_passphrase.is_empty() {
3203 return Err(HuddleError::Other("new passphrase is empty".into()));
3204 }
3205 let new_salt = passphrase::random_salt();
3206 let new_key = passphrase::derive_key(new_passphrase, &new_salt)?;
3207
3208 let info = {
3209 let mut rooms = self.active_rooms.lock().unwrap();
3210 let room = rooms
3211 .get_mut(room_id)
3212 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3213 if !room.info.encrypted {
3214 return Err(HuddleError::Other(
3215 "rotation only applies to encrypted rooms".into(),
3216 ));
3217 }
3218 let new_crypto = RoomCrypto::new_for_room(
3220 self.db.clone(),
3221 room_id.to_string(),
3222 self.identity.fingerprint().to_string(),
3223 self.session_persist_key,
3224 )?;
3225 room.crypto = Some(new_crypto);
3226 room.passphrase_key = Some(new_key);
3227 room.info.passphrase_salt = Some(new_salt.to_vec());
3228 room.info.clone()
3229 };
3230
3231 let rot = RoomMessage::RotateRoomKey {
3237 rotator_fingerprint: self.identity.fingerprint().to_string(),
3238 new_salt: new_salt.to_vec(),
3239 };
3240 if let Ok(env) = crate::crypto::sign_message(&self.identity, &rot) {
3244 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
3245 self.network
3246 .publish_room_message(room_id.to_string(), bytes)
3247 .await;
3248 }
3249 }
3250 if let Err(e) = self.broadcast_member_announce(room_id).await {
3252 warn!(%e, "rotate: broadcast announce failed");
3253 }
3254
3255 repo::insert_room(&self.db, &info)?;
3257 Ok(())
3258 }
3259
3260 pub async fn accept_rotation(
3264 &self,
3265 room_id: &str,
3266 new_salt: &[u8],
3267 new_passphrase: &str,
3268 ) -> Result<()> {
3269 let new_key = passphrase::derive_key(new_passphrase, new_salt)?;
3270 let info = {
3271 let mut rooms = self.active_rooms.lock().unwrap();
3272 let room = rooms
3273 .get_mut(room_id)
3274 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3275 room.passphrase_key = Some(new_key);
3276 room.info.passphrase_salt = Some(new_salt.to_vec());
3277 room.info.clone()
3278 };
3279 let req = RoomMessage::SessionKeyRequest {
3283 requester_fingerprint: self.identity.fingerprint().to_string(),
3284 };
3285 if let Ok(bytes) = encode_wire(&req) {
3286 self.network
3287 .publish_room_message(room_id.to_string(), bytes)
3288 .await;
3289 }
3290 repo::insert_room(&self.db, &info)?;
3291 Ok(())
3292 }
3293
3294 #[allow(clippy::too_many_arguments)]
3299 fn handle_file_offer(
3300 &self,
3301 room_id: &str,
3302 sender_fingerprint: String,
3303 file_id: String,
3304 name: String,
3305 size_bytes: u64,
3306 mime: Option<String>,
3307 _chunk_count: u32,
3308 encrypted_meta: Option<EncryptedFileMeta>,
3309 ) {
3310 let encrypted = encrypted_meta.is_some();
3311 let attachment = StoredAttachment {
3312 id: 0,
3313 room_id: room_id.to_string(),
3314 message_id: None,
3315 sender_fingerprint: sender_fingerprint.clone(),
3316 file_id: file_id.clone(),
3317 name: name.clone(),
3318 mime,
3319 size_bytes: size_bytes as i64,
3320 status: AttachmentStatus::Offered,
3321 cache_path: None,
3322 saved_path: None,
3323 error: None,
3324 encrypted,
3325 wrapped_key: encrypted_meta.as_ref().map(|m| m.wrapped_key_b64.clone()),
3326 nonce: encrypted_meta.as_ref().map(|m| m.nonce_b64.clone()),
3327 megolm_session_id: encrypted_meta.as_ref().map(|m| m.megolm_session_id.clone()),
3328 content_hash: encrypted_meta.as_ref().map(|m| m.content_hash.clone()),
3329 created_at: now_unix(),
3330 };
3331 if let Err(e) = repo::upsert_attachment(&self.db, &attachment) {
3332 warn!(%e, "upsert attachment");
3333 return;
3334 }
3335 self.file_manager.set_expected_size(&file_id, size_bytes);
3338 let _ = self.app_event_tx.send(AppEvent::FileOffered {
3339 room_id: room_id.to_string(),
3340 file_id,
3341 name,
3342 size_bytes,
3343 sender_fingerprint,
3344 });
3345 }
3346
3347 fn handle_file_chunk(
3348 &self,
3349 room_id: &str,
3350 _sender_fingerprint: String,
3351 file_id: String,
3352 chunk_index: u32,
3353 total_chunks: u32,
3354 data_b64: String,
3355 ) {
3356 let data = match B64.decode(&data_b64) {
3357 Ok(d) => d,
3358 Err(e) => {
3359 warn!(%e, "bad chunk base64");
3360 return;
3361 }
3362 };
3363 let expected_size = match repo::get_attachment(&self.db, room_id, &file_id) {
3367 Ok(Some(a)) => {
3368 if matches!(
3369 a.status,
3370 AttachmentStatus::Cancelled | AttachmentStatus::Failed
3371 ) {
3372 return;
3373 }
3374 a.size_bytes as u64
3375 }
3376 Ok(None) => crate::files::MAX_FILE_SIZE,
3377 Err(e) => {
3378 warn!(%e, "get attachment for chunk");
3379 crate::files::MAX_FILE_SIZE
3380 }
3381 };
3382
3383 let result = self.file_manager.accept_chunk(
3384 &file_id,
3385 chunk_index,
3386 total_chunks,
3387 data,
3388 expected_size,
3389 );
3390 match result {
3391 Ok(None) => {
3392 let _ = repo::update_attachment_status(
3394 &self.db,
3395 room_id,
3396 &file_id,
3397 AttachmentStatus::Downloading,
3398 None,
3399 );
3400 let bytes_so_far = self
3403 .file_manager
3404 .progress(&file_id)
3405 .map(|(b, _)| b)
3406 .unwrap_or(0);
3407 let _ = self.app_event_tx.send(AppEvent::FileProgress {
3408 file_id: file_id.clone(),
3409 bytes_received: bytes_so_far,
3410 total_bytes: expected_size,
3411 });
3412 }
3413 Ok(Some(completed)) => {
3414 let _ = repo::update_attachment_paths(
3415 &self.db,
3416 room_id,
3417 &file_id,
3418 Some(&completed.cache_path.to_string_lossy()),
3419 None,
3420 );
3421 let _ = repo::update_attachment_status(
3422 &self.db,
3423 room_id,
3424 &file_id,
3425 AttachmentStatus::Ready,
3426 None,
3427 );
3428 let _ = self.app_event_tx.send(AppEvent::FileReady {
3429 file_id: file_id.clone(),
3430 });
3431 }
3432 Err(e) => {
3433 let msg = e.to_string();
3434 warn!(%msg, "chunk processing failed");
3435 let _ = repo::update_attachment_status(
3436 &self.db,
3437 room_id,
3438 &file_id,
3439 AttachmentStatus::Failed,
3440 Some(&msg),
3441 );
3442 let _ = self.app_event_tx.send(AppEvent::FileFailed {
3443 file_id: file_id.clone(),
3444 reason: msg,
3445 });
3446 }
3447 }
3448 }
3449
3450 fn maybe_emit_mention(&self, room_id: &str, body: &str) {
3453 let full = self.identity.fingerprint().to_lowercase();
3454 let short: &str = full.split('-').next().unwrap_or(&full);
3456 let lower = body.to_lowercase();
3457 let hit = lower.contains(full.as_str())
3461 || lower
3462 .split(|c: char| !c.is_ascii_hexdigit())
3463 .any(|tok| tok == short);
3464 if hit {
3465 let _ = self.app_event_tx.send(AppEvent::MentionReceived {
3466 room_id: room_id.to_string(),
3467 body: body.to_string(),
3468 });
3469 }
3470 }
3471
3472 fn decrypt_attachment(
3473 &self,
3474 room_id: &str,
3475 sender_fingerprint: &str,
3476 ciphertext: &[u8],
3477 meta: &EncryptedFileMeta,
3478 ) -> Result<Vec<u8>> {
3479 let mut rooms = self.active_rooms.lock().unwrap();
3480 let room = rooms
3481 .get_mut(room_id)
3482 .ok_or_else(|| HuddleError::Other("not in room".into()))?;
3483 let crypto = room
3484 .crypto
3485 .as_mut()
3486 .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
3487 file_encryption::decrypt_file(ciphertext, meta, crypto, sender_fingerprint)
3488 }
3489
3490 pub async fn go_dark(&self, master_passphrase: &str) -> Result<()> {
3502 let no_master = self.session_persist_key == [0u8; 32];
3503 if !no_master {
3504 let salt = storage::keychain::load_or_create_salt()?;
3505 let candidate_master =
3506 storage::keychain::derive_master_key(master_passphrase, &salt)?;
3507 let candidate_subkey =
3508 storage::keychain::derive_subkey(&candidate_master, b"megolm-persist");
3509 if !ct_eq_32(&candidate_subkey, &self.session_persist_key) {
3510 return Err(HuddleError::Other(
3511 "incorrect master passphrase".into(),
3512 ));
3513 }
3514 }
3515
3516 let room_ids: Vec<String> = self
3517 .active_rooms
3518 .lock()
3519 .unwrap()
3520 .keys()
3521 .cloned()
3522 .collect();
3523 let _ = tokio::time::timeout(Duration::from_secs(2), async {
3524 for room_id in &room_ids {
3525 if let Err(e) = self.leave_room(room_id).await {
3526 warn!(%room_id, %e, "go_dark: leave_room failed");
3527 }
3528 }
3529 })
3530 .await;
3531
3532 self.network.shutdown().await;
3533 tokio::time::sleep(Duration::from_millis(300)).await;
3534
3535 let data_dir = config::data_dir();
3536 let candidates = [
3537 "huddle.db",
3538 "huddle.db-shm",
3539 "huddle.db-wal",
3540 "keychain.salt",
3541 "huddle.log",
3542 "config.toml",
3543 ];
3544 for name in &candidates {
3545 let path = data_dir.join(name);
3546 wipe_file(&path);
3547 }
3548 if let Ok(read) = std::fs::read_dir(&data_dir) {
3549 for entry in read.flatten() {
3550 if let Some(name) = entry.file_name().to_str() {
3551 if name.starts_with("huddle.log.") {
3552 wipe_file(&entry.path());
3553 }
3554 }
3555 }
3556 }
3557 let files_dir = data_dir.join("files");
3561 if let Ok(read) = std::fs::read_dir(&files_dir) {
3562 for entry in read.flatten() {
3563 let path = entry.path();
3564 if path.is_file() {
3565 wipe_file(&path);
3566 } else if path.is_dir() {
3567 if let Ok(inner) = std::fs::read_dir(&path) {
3570 for inner_entry in inner.flatten() {
3571 if inner_entry.path().is_file() {
3572 wipe_file(&inner_entry.path());
3573 }
3574 }
3575 }
3576 let _ = std::fs::remove_dir(&path);
3577 }
3578 }
3579 }
3580 let _ = std::fs::remove_dir(&files_dir);
3581 let _ = std::fs::remove_dir(&data_dir);
3582
3583 let _ = self.app_event_tx.send(AppEvent::WentDark);
3584 Ok(())
3585 }
3586}
3587
3588fn normalize_to_fingerprint(input: &str) -> Option<String> {
3595 let s = input
3596 .trim()
3597 .trim_start_matches("HD-")
3598 .trim_start_matches("hd-")
3599 .to_string();
3600 let hex_only: String = s.chars().filter(|c| *c != '-').collect();
3601 if hex_only.len() != 24 || !hex_only.chars().all(|c| c.is_ascii_hexdigit()) {
3602 return None;
3603 }
3604 let lower = hex_only.to_ascii_lowercase();
3605 let chunks: Vec<String> = lower
3606 .as_bytes()
3607 .chunks(4)
3608 .map(|c| std::str::from_utf8(c).unwrap().to_string())
3609 .collect();
3610 Some(chunks.join("-"))
3611}
3612
3613fn short_fp_for_msg(fingerprint: &str) -> String {
3617 let head: String = fingerprint
3618 .chars()
3619 .filter(|c| *c != '-')
3620 .take(4)
3621 .collect::<String>()
3622 .to_ascii_uppercase();
3623 format!("HD-{}…", head)
3624}
3625
3626fn ct_eq_32(a: &[u8; 32], b: &[u8; 32]) -> bool {
3630 let mut diff = 0u8;
3631 for i in 0..32 {
3632 diff |= a[i] ^ b[i];
3633 }
3634 diff == 0
3635}
3636
3637fn wipe_file(path: &Path) {
3641 use std::io::Write;
3642 if let Ok(meta) = std::fs::metadata(path) {
3643 if let Ok(mut f) = std::fs::OpenOptions::new().write(true).open(path) {
3644 let zeros = vec![0u8; meta.len() as usize];
3645 let _ = f.write_all(&zeros);
3646 let _ = f.sync_all();
3647 }
3648 }
3649 if let Err(e) = std::fs::remove_file(path) {
3650 if e.kind() != std::io::ErrorKind::NotFound {
3651 warn!(?path, %e, "wipe_file: remove failed");
3652 }
3653 }
3654}
3655
3656fn open_with_system(path: &str) -> Result<()> {
3658 #[cfg(target_os = "macos")]
3659 let cmd = "open";
3660 #[cfg(target_os = "linux")]
3661 let cmd = "xdg-open";
3662 #[cfg(target_os = "windows")]
3663 let cmd = "cmd";
3664 #[cfg(target_os = "windows")]
3665 let args = vec!["/C", "start", "", path];
3666 #[cfg(not(target_os = "windows"))]
3667 let args = vec![path];
3668
3669 std::process::Command::new(cmd)
3670 .args(args)
3671 .spawn()
3672 .map_err(|e| HuddleError::Other(format!("spawn opener: {e}")))?;
3673 Ok(())
3674}
3675
3676static ROOM_SALT_CACHE: std::sync::LazyLock<Mutex<HashMap<String, Vec<u8>>>> =
3679 std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
3680
3681pub fn salt_len() -> usize {
3686 SALT_LEN
3687}
3688
3689fn now_unix() -> i64 {
3690 SystemTime::now()
3691 .duration_since(UNIX_EPOCH)
3692 .unwrap()
3693 .as_secs() as i64
3694}
3695
3696fn now_unix_ms() -> i64 {
3697 SystemTime::now()
3698 .duration_since(UNIX_EPOCH)
3699 .unwrap()
3700 .as_millis() as i64
3701}
3702
3703fn generate_join_passphrase() -> String {
3709 use rand::RngCore;
3710 let mut bytes = [0u8; 16];
3711 rand::thread_rng().fill_bytes(&mut bytes);
3712 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
3715}
3716
3717fn generate_alphanumeric_code(len: usize) -> String {
3722 use rand::Rng;
3723 const ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ23456789";
3724 let mut rng = rand::thread_rng();
3725 let mut out = String::with_capacity(len + 1);
3726 for i in 0..len {
3727 if i == 4 && len == 8 {
3728 out.push('-'); }
3730 let idx = rng.gen_range(0..ALPHABET.len());
3731 out.push(ALPHABET[idx] as char);
3732 }
3733 out
3734}
3735
3736#[cfg(test)]
3737mod parser_tests {
3738 use super::parse_dial_address;
3739
3740 #[test]
3741 fn parses_ipv4_port() {
3742 let m = parse_dial_address("10.3.72.53:9027").unwrap();
3743 assert_eq!(m.to_string(), "/ip4/10.3.72.53/tcp/9027");
3744 }
3745
3746 #[test]
3747 fn parses_bracketed_ipv6() {
3748 let m = parse_dial_address("[::1]:9027").unwrap();
3749 assert_eq!(m.to_string(), "/ip6/::1/tcp/9027");
3750 }
3751
3752 #[test]
3753 fn rejects_unbracketed_ipv6() {
3754 let err = parse_dial_address("fe80::1:9027").unwrap_err();
3755 assert!(err.to_string().contains("brackets"));
3756 }
3757
3758 #[test]
3759 fn passes_through_raw_multiaddr() {
3760 let m = parse_dial_address("/ip4/1.2.3.4/tcp/9000").unwrap();
3761 assert_eq!(m.to_string(), "/ip4/1.2.3.4/tcp/9000");
3762 }
3763
3764 #[test]
3765 fn empty_address_is_error() {
3766 assert!(parse_dial_address(" ").is_err());
3767 }
3768
3769 #[test]
3770 fn rejects_bad_port() {
3771 assert!(parse_dial_address("1.2.3.4:notaport").is_err());
3772 }
3773}